From f43c6a6bd3b5efec360a7bbc3632024fd228bc4b Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 14 May 2024 04:56:38 -0400 Subject: [PATCH] feat: add StableContainer --- packages/ssz/src/index.ts | 1 + packages/ssz/src/type/stableContainer.ts | 779 ++++++++++++++++++ packages/ssz/src/view/stableContainer.ts | 250 ++++++ packages/ssz/src/viewDU/stableContainer.ts | 317 +++++++ .../ssz/test/unit/byType/runTypeProofTest.ts | 20 +- .../unit/byType/stableContainer/valid.test.ts | 46 ++ 6 files changed, 1405 insertions(+), 8 deletions(-) create mode 100644 packages/ssz/src/type/stableContainer.ts create mode 100644 packages/ssz/src/view/stableContainer.ts create mode 100644 packages/ssz/src/viewDU/stableContainer.ts create mode 100644 packages/ssz/test/unit/byType/stableContainer/valid.test.ts diff --git a/packages/ssz/src/index.ts b/packages/ssz/src/index.ts index b0c6d077..cba6b83d 100644 --- a/packages/ssz/src/index.ts +++ b/packages/ssz/src/index.ts @@ -15,6 +15,7 @@ export {OptionalType} from "./type/optional"; export {VectorBasicType} from "./type/vectorBasic"; export {VectorCompositeType} from "./type/vectorComposite"; export {ListUintNum64Type} from "./type/listUintNum64"; +export {StableContainerType} from "./type/stableContainer"; // Base types export {ArrayType} from "./type/array"; diff --git a/packages/ssz/src/type/stableContainer.ts b/packages/ssz/src/type/stableContainer.ts new file mode 100644 index 00000000..c5810e8e --- /dev/null +++ b/packages/ssz/src/type/stableContainer.ts @@ -0,0 +1,779 @@ +import { + Node, + BranchNode, + LeafNode, + getNodesAtDepth, + subtreeFillToContents, + Tree, + Gindex, + toGindex, + concatGindices, + getNode, + zeroNode, + countToDepth, + setNodeH, + getNodeH, + setNode, + setNodeWithFn, +} from "@chainsafe/persistent-merkle-tree"; +import {ValueWithCachedPermanentRoot, hash64, maxChunksToDepth, merkleize, splitIntoRootChunks, symbolCachedPermanentRoot} from "../util/merkleize"; +import {Require} from "../util/types"; +import {namedClass} from "../util/named"; +import {Type, ValueOf} from "./abstract"; +import {CompositeType, ByteViews, CompositeTypeAny} from "./composite"; +import { + getContainerTreeViewClass, + ValueOfFields, + FieldEntry, + ContainerTreeViewType, + ContainerTreeViewTypeConstructor, + NonOptionalType, + NonOptionalFields, + computeSerdesData, +} from "../view/stableContainer"; +import { + getContainerTreeViewDUClass, + ContainerTreeViewDUType, + ContainerTreeViewDUTypeConstructor, +} from "../viewDU/stableContainer"; +import {Case} from "../util/strings"; +import {OptionalType} from "./optional"; +import {BitArray} from "../value/bitArray"; +/* eslint-disable @typescript-eslint/member-ordering */ + +type BytesRange = {start: number; end: number}; + +export type StableContainerOptions> = { + typeName?: string; + jsonCase?: KeyCase; + casingMap?: CasingMap; + cachePermanentRootStruct?: boolean; + getContainerTreeViewClass?: typeof getContainerTreeViewClass; + getContainerTreeViewDUClass?: typeof getContainerTreeViewDUClass; +}; + +export type KeyCase = + | "eth2" + | "snake" + | "constant" + | "camel" + | "header" + //Same as squish + | "pascal"; + +type CasingMap> = Partial<{[K in keyof Fields]: string}>; + +function isOptionalType(type: Type): type is OptionalType> { + return type instanceof OptionalType; +} + +function toNonOptionalType>(type: T): NonOptionalType { + return (isOptionalType(type) ? type.elementType : type) as NonOptionalType; +} + +/** + * StableContainer: ordered heterogeneous collection of values + * - Notation: Custom name per instance + */ +export class StableContainerType>> extends CompositeType< + ValueOfFields, + ContainerTreeViewType, + ContainerTreeViewDUType +> { + readonly typeName: string; + readonly depth: number; + readonly maxChunkCount: number; + readonly fixedSize: number | null; + readonly minSize: number; + readonly maxSize: number; + readonly isList = false; + readonly isViewMutable = true; + + readonly fields: Fields; + // Precomputed data for faster serdes + readonly fieldsEntries: FieldEntry>[]; + /** End of fixed section of serialized Container */ + // readonly fixedEnd: number; + protected readonly fieldsGindex: Record; + protected readonly jsonKeyToFieldName: Record; + // protected readonly isFixedLen: boolean[]; + // protected readonly fieldRangesFixedLen: BytesRange[]; + // /** Offsets position relative to start of serialized Container. Length may not equal field count. */ + // protected readonly variableOffsetsPosition: number[]; + + /** Cached TreeView constuctor with custom prototype for this Type's properties */ + protected readonly TreeView: ContainerTreeViewTypeConstructor; + protected readonly TreeViewDU: ContainerTreeViewDUTypeConstructor; + + constructor(fields: Fields, readonly maxFields: number, readonly opts?: StableContainerOptions) { + super(opts?.cachePermanentRootStruct); + + this.fields = fields; + + // Render detailed typeName. Consumers should overwrite since it can get long + this.typeName = opts?.typeName ?? renderContainerTypeName(fields); + + this.maxChunkCount = maxFields; + // Add 1 for the mixed-in bitvector + this.depth = maxChunksToDepth(this.maxChunkCount) + 1; + + // Precalculated data for faster serdes + this.fieldsEntries = []; + for (const fieldName of Object.keys(fields) as (keyof Fields)[]) { + const fieldType = fields[fieldName]; + + this.fieldsEntries.push({ + fieldName, + fieldType: toNonOptionalType(fieldType), + jsonKey: precomputeJsonKey(fieldName, opts?.casingMap, opts?.jsonCase), + gindex: toGindex(this.depth, BigInt(this.fieldsEntries.length)), + optional: isOptionalType(fieldType), + }); + } + + if (this.fieldsEntries.length === 0) { + throw Error("StableContainer must have > 0 fields"); + } + + // Precalculate for Proofs API + this.fieldsGindex = {} as Record; + for (let i = 0; i < this.fieldsEntries.length; i++) { + this.fieldsGindex[this.fieldsEntries[i].fieldName] = toGindex(this.depth, BigInt(i)); + } + + // To resolve JSON paths in fieldName notation and jsonKey notation + this.jsonKeyToFieldName = {}; + for (const {fieldName, jsonKey} of this.fieldsEntries) { + this.jsonKeyToFieldName[jsonKey] = fieldName; + } + + const {minLen, maxLen, fixedSize} = precomputeSizes(this.fieldsEntries); + this.minSize = minLen; + this.maxSize = maxLen; + this.fixedSize = fixedSize; + + // TODO: This options are necessary for ContainerNodeStruct to override this. + // Refactor this constructor to allow customization without pollutin the options + this.TreeView = opts?.getContainerTreeViewClass?.(this) ?? getContainerTreeViewClass(this); + this.TreeViewDU = opts?.getContainerTreeViewDUClass?.(this) ?? getContainerTreeViewDUClass(this); + } + + static named>>( + fields: Fields, + maxFields: number, + opts: Require, "typeName"> + ): StableContainerType { + return new (namedClass(StableContainerType, opts.typeName))(fields, maxFields, opts); + } + + defaultValue(): ValueOfFields { + const value = {} as ValueOfFields; + for (const {fieldName, fieldType, optional} of this.fieldsEntries) { + value[fieldName] = (optional ? null : fieldType.defaultValue()) as ValueOf; + } + return value; + } + + getView(tree: Tree): ContainerTreeViewType { + return new this.TreeView(this, tree); + } + + getViewDU(node: Node, cache?: unknown): ContainerTreeViewDUType { + return new this.TreeViewDU(this, node, cache); + } + + cacheOfViewDU(view: ContainerTreeViewDUType): unknown { + return view.cache; + } + + commitView(view: ContainerTreeViewType): Node { + return view.node; + } + + commitViewDU(view: ContainerTreeViewDUType): Node { + view.commit(); + return view.node; + } + + // Serialization + deserialization + // ------------------------------- + // Containers can mix fixed length and variable length data. + // + // Fixed part Variable part + // [field1 offset][field2 data ][field1 data ] + // [0x000000c] [0xaabbaabbaabbaabb][0xffffffffffffffffffffffff] + + value_serializedSize(value: ValueOfFields): number { + let totalSize = Math.ceil(this.fieldsEntries.length / 8); + for (let i = 0; i < this.fieldsEntries.length; i++) { + const {fieldName, fieldType, optional} = this.fieldsEntries[i]; + // skip optional fields with nullish values + if (optional && value[fieldName] == null) { + continue; + } + + // Offset (4 bytes) + size + totalSize += + fieldType.fixedSize === null ? 4 + fieldType.value_serializedSize(value[fieldName]) : fieldType.fixedSize; + } + + return totalSize; + } + + value_serializeToBytes(output: ByteViews, offset: number, value: ValueOfFields): number { + // compute active field bitvector + const activeFields = BitArray.fromBoolArray(this.fieldsEntries.map(({fieldName}) => value[fieldName] != null)); + // write active field bitvector + output.uint8Array.set(activeFields.uint8Array, offset); + offset += activeFields.uint8Array.length; + + const {fixedEnd} = computeSerdesData(activeFields, this.fieldsEntries); + + let fixedIndex = offset; + let variableIndex = offset + fixedEnd; + + for (let i = 0; i < this.fieldsEntries.length; i++) { + const {fieldName, fieldType, optional} = this.fieldsEntries[i]; + // skip optional fields with nullish values + if (optional && value[fieldName] == null) { + continue; + } + + if (fieldType.fixedSize === null) { + // write offset + output.dataView.setUint32(fixedIndex, variableIndex - offset, true); + fixedIndex += 4; + // write serialized element to variable section + variableIndex = fieldType.value_serializeToBytes(output, variableIndex, value[fieldName]); + } else { + fixedIndex = fieldType.value_serializeToBytes(output, fixedIndex, value[fieldName]); + } + } + return variableIndex; + } + + value_deserializeFromBytes(data: ByteViews, start: number, end: number): ValueOfFields { + const {activeFields, fieldRanges} = this.getFieldRanges(data, start, end); + const value = {} as {[K in keyof Fields]: unknown}; + + for (let i = 0, rangesIx = 0; i < this.fieldsEntries.length; i++) { + const {fieldName, fieldType, optional} = this.fieldsEntries[i]; + if (optional && !activeFields.get(i)) { + value[fieldName] = null; + continue; + } + + const fieldRange = fieldRanges[rangesIx++]; + value[fieldName] = fieldType.value_deserializeFromBytes(data, start + fieldRange.start, start + fieldRange.end); + } + + return value as ValueOfFields; + } + + tree_serializedSize(node: Node): number { + let totalSize = Math.ceil(this.fieldsEntries.length / 8); + const activeFields = this.tree_getActiveFields(node); + const nodes = getNodesAtDepth(node, this.depth, 0, this.fieldsEntries.length) as Node[]; + for (let i = 0; i < this.fieldsEntries.length; i++) { + const {fieldType, optional} = this.fieldsEntries[i]; + const node = nodes[i]; + if (optional && !activeFields.get(i)) { + continue; + } + + // Offset (4 bytes) + size + totalSize += fieldType.fixedSize === null ? 4 + fieldType.tree_serializedSize(node) : fieldType.fixedSize; + } + return totalSize; + } + + tree_serializeToBytes(output: ByteViews, offset: number, node: Node): number { + // compute active field bitvector + const activeFields = this.tree_getActiveFields(node); + // write active field bitvector + output.uint8Array.set(activeFields.uint8Array, offset); + offset += activeFields.uint8Array.length; + + const {fixedEnd} = computeSerdesData(activeFields, this.fieldsEntries); + + let fixedIndex = offset; + let variableIndex = offset + fixedEnd; + + const nodes = getNodesAtDepth(node, this.depth, 0, this.fieldsEntries.length); + + for (let i = 0; i < this.fieldsEntries.length; i++) { + const {fieldType, optional} = this.fieldsEntries[i]; + if (optional && !activeFields.get(i)) { + continue; + } + + const node = nodes[i]; + if (fieldType.fixedSize === null) { + // write offset + output.dataView.setUint32(fixedIndex, variableIndex - offset, true); + fixedIndex += 4; + // write serialized element to variable section + variableIndex = fieldType.tree_serializeToBytes(output, variableIndex, node); + } else { + fixedIndex = fieldType.tree_serializeToBytes(output, fixedIndex, node); + } + } + return variableIndex; + } + + tree_deserializeFromBytes(data: ByteViews, start: number, end: number): Node { + const {activeFields, fieldRanges} = this.getFieldRanges(data, start, end); + const nodes = new Array(this.fieldsEntries.length); + + for (let i = 0, rangesIx = 0; i < this.fieldsEntries.length; i++) { + const {fieldType, optional} = this.fieldsEntries[i]; + if (optional && !activeFields.get(i)) { + nodes[i] = zeroNode(0); + continue; + } + + const fieldRange = fieldRanges[rangesIx++]; + nodes[i] = fieldType.tree_deserializeFromBytes(data, start + fieldRange.start, start + fieldRange.end); + } + + const rootNode = new BranchNode(subtreeFillToContents(nodes, this.depth - 1), zeroNode(0)); + return this.tree_setActiveFields(rootNode, activeFields); + } + + // Merkleization + hashTreeRoot(value: ValueOfFields): Uint8Array { + // Return cached mutable root if any + if (this.cachePermanentRootStruct) { + const cachedRoot = (value as ValueWithCachedPermanentRoot)[symbolCachedPermanentRoot]; + if (cachedRoot) { + return cachedRoot; + } + } + + // compute active field bitvector + const activeFields = BitArray.fromBoolArray(this.fieldsEntries.map(({fieldName}) => value[fieldName] != null)); + const root = mixInActiveFields(super.hashTreeRoot(value), activeFields); + + if (this.cachePermanentRootStruct) { + (value as ValueWithCachedPermanentRoot)[symbolCachedPermanentRoot] = root; + } + + return root; + } + + protected getRoots(struct: ValueOfFields): Uint8Array[] { + const roots = new Array(this.fieldsEntries.length); + + for (let i = 0; i < this.fieldsEntries.length; i++) { + const {fieldName, fieldType, optional} = this.fieldsEntries[i]; + if (optional && struct[fieldName] == null) { + roots[i] = new Uint8Array(32); + continue; + } + + roots[i] = fieldType.hashTreeRoot(struct[fieldName]); + } + + return roots; + } + + // Proofs + + // getPropertyGindex + // getPropertyType + // tree_getLeafGindices + + getPropertyGindex(prop: string): Gindex | null { + const gindex = this.fieldsGindex[prop] ?? this.fieldsGindex[this.jsonKeyToFieldName[prop]]; + if (gindex === undefined) throw Error(`Unknown container property ${prop}`); + return gindex; + } + + getPropertyType(prop: string): Type { + const fieldName = this.fields[prop] ? prop : this.jsonKeyToFieldName[prop]; + const entry = this.fieldsEntries.find((entry) => entry.fieldName === fieldName); + if (entry === undefined) throw Error(`Unknown container property ${prop}`); + return entry.fieldType; + } + + getIndexProperty(index: number): string | null { + if (index >= this.fieldsEntries.length) { + return null; + } + return this.fieldsEntries[index].fieldName as string; + } + + tree_getLeafGindices(rootGindex: Gindex, rootNode?: Node): Gindex[] { + const gindices: Gindex[] = []; + if (!rootNode) { + throw new Error("StableContainer.tree_getLeafGindices requires tree argument to get leaves"); + } + const activeFields = this.tree_getActiveFields(rootNode); + + for (let i = 0; i < this.fieldsEntries.length; i++) { + const {fieldName, fieldType, optional} = this.fieldsEntries[i]; + if (optional && !activeFields.get(i)) { + // field is inactive and doesn't count as a leaf + continue; + } + + const fieldGindex = this.fieldsGindex[fieldName]; + const fieldGindexFromRoot = concatGindices([rootGindex, fieldGindex]); + + if (fieldType.isBasic) { + gindices.push(fieldGindexFromRoot); + } else { + const compositeType = fieldType as unknown as CompositeTypeAny; + if (fieldType.fixedSize === null) { + if (!rootNode) { + throw new Error("variable type requires tree argument to get leaves"); + } + gindices.push(...compositeType.tree_getLeafGindices(fieldGindexFromRoot, getNode(rootNode, fieldGindex))); + } else { + gindices.push(...compositeType.tree_getLeafGindices(fieldGindexFromRoot)); + } + } + } + + return gindices; + } + + // JSON + + fromJson(json: unknown): ValueOfFields { + if (typeof json !== "object") { + throw Error("JSON must be of type object"); + } + if (json === null) { + throw Error("JSON must not be null"); + } + + const value = {} as ValueOfFields; + + for (let i = 0; i < this.fieldsEntries.length; i++) { + const {fieldName, fieldType, jsonKey, optional} = this.fieldsEntries[i]; + const jsonValue = (json as Record)[jsonKey]; + if (optional && jsonValue == null) { + value[fieldName] = null as ValueOf; + continue; + } + + if (jsonValue === undefined) { + throw Error(`JSON expected key ${jsonKey} is undefined`); + } + value[fieldName] = fieldType.fromJson(jsonValue) as ValueOf; + } + + return value; + } + + toJson(value: ValueOfFields): Record { + const json: Record = {}; + + for (let i = 0; i < this.fieldsEntries.length; i++) { + const {fieldName, fieldType, jsonKey, optional} = this.fieldsEntries[i]; + if (optional && value[fieldName] == null) { + json[jsonKey] = null; + continue; + } + + json[jsonKey] = fieldType.toJson(value[fieldName]); + } + + return json; + } + + clone(value: ValueOfFields): ValueOfFields { + const newValue = {} as ValueOfFields; + + for (let i = 0; i < this.fieldsEntries.length; i++) { + const {fieldName, fieldType, optional} = this.fieldsEntries[i]; + if (optional && value[fieldName] == null) { + newValue[fieldName] = null as ValueOf; + continue; + } + + newValue[fieldName] = fieldType.clone(value[fieldName]) as ValueOf; + } + + return newValue; + } + + equals(a: ValueOfFields, b: ValueOfFields): boolean { + for (let i = 0; i < this.fieldsEntries.length; i++) { + const {fieldName, fieldType} = this.fieldsEntries[i]; + if (!fieldType.equals(a[fieldName], b[fieldName])) { + return false; + } + } + + return true; + } + + /** + * Deserializer helper: Returns the bytes ranges of all active fields, both variable and fixed size. + * Also returns the active field bitvector. + * + * Fields may not be contiguous in the serialized bytes, so the returned ranges are [start, end]. + * - For fixed size fields re-uses the pre-computed values this.fieldRangesFixedLen + * - For variable size fields does a first pass over the fixed section to read offsets + */ + getFieldRanges(data: ByteViews, start: number, end: number): {activeFields: BitArray; fieldRanges: BytesRange[]} { + const activeFieldsByteLen = Math.ceil(this.fieldsEntries.length / 8); + // active fields bitvector, do not mutate + const activeFields = new BitArray( + data.uint8Array.subarray(start, start + activeFieldsByteLen), + this.fieldsEntries.length + ); + + const {variableOffsetsPosition, fixedEnd, fieldRangesFixedLen, isFixedLen} = computeSerdesData( + activeFields, + this.fieldsEntries + ); + + if (variableOffsetsPosition.length === 0) { + // Validate fixed length container + const size = end - start; + if (size !== fixedEnd) { + throw Error(`${this.typeName} size ${size} not equal fixed size ${fixedEnd}`); + } + + return {activeFields, fieldRanges: fieldRangesFixedLen}; + } + + // Read offsets in one pass + const offsets = readVariableOffsets(data.dataView, start, end, fixedEnd, variableOffsetsPosition); + offsets.push(end - start); // The offsets are relative to the start + + // Merge fieldRangesFixedLen + offsets in one array + let variableIdx = 0; + let fixedIdx = 0; + const fieldRanges = new Array(isFixedLen.length); + + for (let i = 0; i < isFixedLen.length; i++) { + if (isFixedLen[i]) { + // push from fixLen ranges ++ + fieldRanges[i] = fieldRangesFixedLen[fixedIdx++]; + } else { + // push from varLen ranges ++ + fieldRanges[i] = {start: offsets[variableIdx], end: offsets[variableIdx + 1]}; + variableIdx++; + } + } + return {activeFields, fieldRanges}; + } + + // helpers for the active fields + tree_getActiveFields(rootNode: Node): BitArray { + return getActiveFields(rootNode, this.fieldsEntries.length); + } + + tree_setActiveFields(rootNode: Node, activeFields: BitArray): Node { + return setActiveFields(rootNode, activeFields); + } + + tree_getActiveField(rootNode: Node, fieldIndex: number): boolean { + return getActiveField(rootNode, this.fieldsEntries.length, fieldIndex); + } + + tree_setActiveField(rootNode: Node, fieldIndex: number, value: boolean): Node { + return setActiveField(rootNode, this.fieldsEntries.length, fieldIndex, value); + } +} + +/** + * Returns the byte ranges of all variable size fields. + */ +function readVariableOffsets( + data: DataView, + start: number, + end: number, + fixedEnd: number, + variableOffsetsPosition: number[] +): number[] { + // Since variable-sized values can be interspersed with fixed-sized values, we precalculate + // the offset indices so we can more easily deserialize the fields in once pass first we get the fixed sizes + // Note: `fixedSizes[i] = null` if that field has variable length + + const size = end - start; + + // with the fixed sizes, we can read the offsets, and store for our single pass + const offsets = new Array(variableOffsetsPosition.length); + for (let i = 0; i < variableOffsetsPosition.length; i++) { + const offset = data.getUint32(start + variableOffsetsPosition[i], true); + + // Validate offsets. If the list is empty the offset points to the end of the buffer, offset == size + if (offset > size) { + throw new Error(`Offset out of bounds ${offset} > ${size}`); + } + if (i === 0) { + if (offset !== fixedEnd) { + throw new Error(`First offset must equal to fixedEnd ${offset} != ${fixedEnd}`); + } + } else { + if (offset < offsets[i - 1]) { + throw new Error(`Offsets must be increasing ${offset} < ${offsets[i - 1]}`); + } + } + + offsets[i] = offset; + } + + return offsets; +} + +/** + * Precompute sizes of the Container doing one pass over fields + */ +function precomputeSizes>>( + fields: FieldEntry[] +): { + minLen: number; + maxLen: number; + fixedSize: number | null; +} { + // at a minimum, the active fields bitvector is prepended + const activeFieldsLen = Math.ceil(fields.length / 8); + + let minLen = activeFieldsLen; + let maxLen = activeFieldsLen; + const fixedSize = null; + + for (const {fieldType, optional} of fields) { + minLen += optional ? 0 : fieldType.minSize; + maxLen += fieldType.maxSize; + + if (fieldType.fixedSize === null) { + // +4 for the offset + minLen += optional ? 0 : 4; + maxLen += 4; + } + } + return {minLen, maxLen, fixedSize}; +} + +/** + * Compute the JSON key for each fieldName. There will exist a single JSON representation for each type. + * To transform JSON payloads to a casing that is different from the type's defined use external tooling. + */ +export function precomputeJsonKey>>( + fieldName: keyof Fields, + casingMap?: CasingMap, + jsonCase?: KeyCase +): string { + if (casingMap) { + const keyFromCaseMap = casingMap[fieldName]; + if (keyFromCaseMap === undefined) { + throw Error(`casingMap[${fieldName}] not defined`); + } + return keyFromCaseMap as string; + } else if (jsonCase) { + return Case[jsonCase](fieldName as string); + } else { + return fieldName as string; + } +} + +/** + * Render field typeNames for a detailed typeName of this Container + */ +export function renderContainerTypeName>>( + fields: Fields, + prefix = "StableContainer" +): string { + const fieldNames = Object.keys(fields) as (keyof Fields)[]; + const fieldTypeNames = fieldNames.map((fieldName) => `${fieldName}: ${fields[fieldName].typeName}`).join(", "); + return `${prefix}({${fieldTypeNames}})`; +} + +/** + * Get the active field bitvector, given the root of the tree and # of fields + */ +export function getActiveFields(rootNode: Node, bitLen: number): BitArray { + // fast path for depth 1, the bitvector fits in one chunk + if (bitLen <= 256) { + return new BitArray(rootNode.right.root.subarray(0, Math.ceil(bitLen / 8)), bitLen); + } + + const activeFieldsBuf = new Uint8Array(Math.ceil(bitLen / 8)); + const depth = countToDepth(BigInt(activeFieldsBuf.length)); + const nodes = getNodesAtDepth(rootNode.right, depth, 0, Math.ceil(bitLen / 256)); + for (let i = 0; i < nodes.length; i++) { + activeFieldsBuf.set(nodes[i].root, i * 32); + } + + return new BitArray(activeFieldsBuf, bitLen); +} + +export function setActiveFields(rootNode: Node, activeFields: BitArray): Node { + // fast path for depth 1, the bitvector fits in one chunk + if (activeFields.bitLen <= 256) { + const activeFieldsBuf = new Uint8Array(32); + activeFieldsBuf.set(activeFields.uint8Array); + return new BranchNode(rootNode.left, LeafNode.fromRoot(activeFieldsBuf)); + } + + const activeFieldsChunkCount = Math.ceil(activeFields.bitLen / 256); + const nodes: Node[] = []; + for (let i = 0; i < activeFieldsChunkCount; i++) { + const activeFieldsBuf = new Uint8Array(32); + activeFieldsBuf.set(activeFields.uint8Array.subarray(i * 32, (i + 1) * 32)); + nodes.push(LeafNode.fromRoot(activeFieldsBuf)); + } + + return new BranchNode(rootNode.left, subtreeFillToContents(nodes, Math.ceil(Math.log2(activeFieldsChunkCount)))); +} + +export function getActiveField(rootNode: Node, bitLen: number, fieldIndex: number): boolean { + const hIndex = Math.floor(fieldIndex / 32); + const hBitIndex = fieldIndex % 32; + + // fast path for depth 1, the bitvector fits in one chunk + if (bitLen <= 256) { + const h = getNodeH(rootNode.right, hIndex); + return Boolean(h & (1 << hBitIndex)); + } + + const chunkCount = Math.ceil(bitLen / 256); + const chunkIx = bitLen % 256; + const depth = Math.ceil(Math.log2(chunkCount)); + + const chunk = getNode(rootNode, toGindex(depth, BigInt(chunkIx))); + const h = getNodeH(chunk, hIndex); + return Boolean(h & (1 << hBitIndex)); +} + +export function setActiveField(rootNode: Node, bitLen: number, fieldIndex: number, value: boolean): Node { + const bitIx = Math.min(fieldIndex / 8); + const byteIx = fieldIndex % 8; + + // fast path for depth 1, the bitvector fits in one chunk + if (bitLen <= 256) { + const activeFieldsBuf = rootNode.right.root; + activeFieldsBuf[byteIx] |= (value ? 1 : 0) << bitIx; + + return setNode(rootNode, "11", LeafNode.fromRoot(activeFieldsBuf)); + } + + const chunkCount = Math.ceil(bitLen / 256); + const chunkIx = bitLen % 256; + const depth = Math.ceil(Math.log2(chunkCount)); + return setNodeWithFn(rootNode, BigInt(2 * depth + chunkIx), (node) => { + const chunkBuf = node.root; + chunkBuf[byteIx] |= (value ? 1 : 0) << bitIx; + return LeafNode.fromRoot(chunkBuf); + }); +} + +export function mixInActiveFields(root: Uint8Array, activeFields: BitArray): Uint8Array { + // fast path for depth 1, the bitvector fits in one chunk + if (activeFields.bitLen <= 256) { + const activeFieldsChunk = new Uint8Array(32); + activeFieldsChunk.set(activeFields.uint8Array); + return hash64(root, activeFieldsChunk); + } + + const activeFieldsChunks = splitIntoRootChunks(activeFields.uint8Array); + const activeFieldsRoot = merkleize(activeFieldsChunks, activeFieldsChunks.length); + return hash64(root, activeFieldsRoot); +} diff --git a/packages/ssz/src/view/stableContainer.ts b/packages/ssz/src/view/stableContainer.ts new file mode 100644 index 00000000..8e7f45a8 --- /dev/null +++ b/packages/ssz/src/view/stableContainer.ts @@ -0,0 +1,250 @@ +import { + getNodeAtDepth, + Gindex, + LeafNode, + Node, + toGindexBitstring, + Tree, + zeroNode, +} from "@chainsafe/persistent-merkle-tree"; +import {Type, ValueOf} from "../type/abstract"; +import {isBasicType, BasicType} from "../type/basic"; +import {isCompositeType, CompositeType} from "../type/composite"; +import {TreeView} from "./abstract"; +import {OptionalType} from "../type/optional"; +import {BitArray} from "../value/bitArray"; + +// some code is here to break the circular dependency between type, view, and viewDU + +export type FieldEntry>> = { + fieldName: keyof Fields; + fieldType: Fields[keyof Fields]; + jsonKey: string; + gindex: Gindex; + optional: boolean; +}; + +export type NonOptionalType> = T extends OptionalType ? U : T; +export type NonOptionalFields>> = { + [K in keyof Fields]: NonOptionalType; +}; + +/** Expected API of this View's type. This interface allows to break a recursive dependency between types and views */ +export type ContainerTypeGeneric>> = CompositeType< + ValueOfFields, + ContainerTreeViewType, + unknown +> & { + readonly fields: Fields; + readonly fieldsEntries: FieldEntry>[]; + + tree_getActiveFields: (node: Node) => BitArray; + tree_setActiveFields: (node: Node, activeFields: BitArray) => Node; + tree_getActiveField: (node: Node, fieldIndex: number) => boolean; + tree_setActiveField: (node: Node, fieldIndex: number, value: boolean) => Node; +}; + +export type ValueOfFields>> = {[K in keyof Fields]: ValueOf}; + +export type ViewType> = T extends CompositeType + ? // If composite, return view. MAY propagate changes updwards + TV + : // If basic, return struct value. Will NOT propagate changes upwards + T extends BasicType + ? V + : never; + +export type OptionalViewType> = T extends CompositeType + ? // If composite, return view. MAY propagate changes updwards if not nullish + TV | null | undefined + : // If basic, return struct value or nullish. Will NOT propagate changes upwards + T extends BasicType + ? V | null | undefined + : never; + +export type FieldsView>> = { + [K in keyof Fields]: Fields[K] extends OptionalType ? OptionalViewType : ViewType; +}; + +export type ContainerTreeViewType>> = FieldsView & + TreeView>; +export type ContainerTreeViewTypeConstructor>> = { + new (type: ContainerTypeGeneric, tree: Tree): ContainerTreeViewType; +}; + +/** + * Intented usage: + * + * - Get initial BeaconState from disk. + * - Before applying next block, switch to mutable + * - Get some field, create a view in mutable mode + * - Do modifications of the state in the state transition function + * - When done, commit and apply new root node once to og BeaconState + * - However, keep all the caches and transfer them to the new BeaconState + * + * Questions: + * - Can the child views created in mutable mode switch to not mutable? If so, it seems that it needs to recursively + * iterate the entire data structure and views + * + */ +class ContainerTreeView>> extends TreeView> { + constructor(readonly type: ContainerTypeGeneric, readonly tree: Tree) { + super(); + } + + get node(): Node { + return this.tree.rootNode; + } +} + +export function getContainerTreeViewClass>>( + type: ContainerTypeGeneric +): ContainerTreeViewTypeConstructor { + class CustomContainerTreeView extends ContainerTreeView {} + + // Dynamically define prototype methods + for (let index = 0; index < type.fieldsEntries.length; index++) { + const {fieldName, fieldType, optional} = type.fieldsEntries[index]; + + // If the field type is basic, the value to get and set will be the actual 'struct' value (i.e. a JS number). + // The view must use the tree_getFromNode() and tree_setToNode() methods to persist the struct data to the node, + // and use the cached views array to store the new node. + if (isBasicType(fieldType)) { + Object.defineProperty(CustomContainerTreeView.prototype, fieldName, { + configurable: false, + enumerable: true, + + // TODO: Review the memory cost of this closures + get: function (this: CustomContainerTreeView) { + const leafNode = getNodeAtDepth(this.node, this.type.depth, index) as LeafNode; + if (optional && this.type.tree_getActiveField(this.tree.rootNode, index) === false) { + return null; + } + return fieldType.tree_getFromNode(leafNode); + }, + + set: function (this: CustomContainerTreeView, value) { + if (optional && value == null) { + this.tree.setNodeAtDepth(this.type.depth, index, zeroNode(0)); + // only update the active field if necessary + if (this.type.tree_getActiveField(this.tree.rootNode, index)) { + this.tree.rootNode = this.type.tree_setActiveField(this.tree.rootNode, index, false); + } + return; + } + const leafNodePrev = getNodeAtDepth(this.node, this.type.depth, index) as LeafNode; + const leafNode = leafNodePrev.clone(); + fieldType.tree_setToNode(leafNode, value); + this.tree.setNodeAtDepth(this.type.depth, index, leafNode); + // only update the active field if necessary + if (!this.type.tree_getActiveField(this.tree.rootNode, index)) { + this.tree.rootNode = this.type.tree_setActiveField(this.tree.rootNode, index, false); + } + }, + }); + } + + // If the field type is composite, the value to get and set will be another TreeView (if not nullish). The parent TreeView must + // cache the view itself to retain the caches of the child view. To set a value the view must return a node to + // set it to the parent tree in the field gindex. + else if (isCompositeType(fieldType)) { + Object.defineProperty(CustomContainerTreeView.prototype, fieldName, { + configurable: false, + enumerable: true, + + // Returns TreeView of fieldName + get: function (this: CustomContainerTreeView) { + const gindex = toGindexBitstring(this.type.depth, index); + const subtree = this.tree.getSubtree(gindex); + if (optional && this.type.tree_getActiveField(this.tree.rootNode, index) === false) { + return null; + } + return fieldType.getView(subtree); + }, + + // Expects TreeView of fieldName + set: function (this: CustomContainerTreeView, value: unknown) { + if (optional && value == null) { + this.tree.setNodeAtDepth(this.type.depth, index, zeroNode(0)); + // only update the active field if necessary + if (this.type.tree_getActiveField(this.tree.rootNode, index)) { + this.tree.rootNode = this.type.tree_setActiveField(this.tree.rootNode, index, false); + } + return; + } + const node = fieldType.commitView(value); + this.tree.setNodeAtDepth(this.type.depth, index, node); + // only update the active field if necessary + if (!this.type.tree_getActiveField(this.tree.rootNode, index)) { + this.tree.rootNode = this.type.tree_setActiveField(this.tree.rootNode, index, false); + } + }, + }); + } + + // Should never happen + else { + /* istanbul ignore next - unreachable code */ + throw Error(`Unknown fieldType ${fieldType.typeName} for fieldName ${fieldName}`); + } + } + + // Change class name + Object.defineProperty(CustomContainerTreeView, "name", {value: type.typeName, writable: false}); + + return CustomContainerTreeView as unknown as ContainerTreeViewTypeConstructor; +} + +type BytesRange = {start: number; end: number}; + +/** + * Precompute fixed and variable offsets position for faster deserialization. + * @throws when activeFields does not align with non-optional field types + * @returns Does a single pass over all fields and returns: + * - isFixedLen: If field index [i] is fixed length + * - fieldRangesFixedLen: For fields with fixed length, their range of bytes + * - variableOffsetsPosition: Position of the 4 bytes offset for variable size fields + * - fixedEnd: End of the fixed size range + * - + */ +export function computeSerdesData>>( + activeFields: BitArray, + fields: FieldEntry[] +): { + isFixedLen: boolean[]; + fieldRangesFixedLen: BytesRange[]; + variableOffsetsPosition: number[]; + fixedEnd: number; +} { + const isFixedLen: boolean[] = []; + const fieldRangesFixedLen: BytesRange[] = []; + const variableOffsetsPosition: number[] = []; + let pointerFixed = Math.ceil(fields.length / 8); + + for (const [i, {fieldName, fieldType, optional}] of fields.entries()) { + // if the field is inactive + if (!activeFields.get(i)) { + if (!optional) { + throw new Error(`Field "${String(fieldName)}" must be active since it is not optional`); + } + continue; + } + + isFixedLen.push(fieldType.fixedSize !== null); + if (fieldType.fixedSize === null) { + // Variable length + variableOffsetsPosition.push(pointerFixed); + pointerFixed += 4; + } else { + fieldRangesFixedLen.push({start: pointerFixed, end: pointerFixed + fieldType.fixedSize}); + pointerFixed += fieldType.fixedSize; + } + } + + return { + isFixedLen, + fieldRangesFixedLen, + variableOffsetsPosition, + fixedEnd: pointerFixed, + }; +} diff --git a/packages/ssz/src/viewDU/stableContainer.ts b/packages/ssz/src/viewDU/stableContainer.ts new file mode 100644 index 00000000..7821029e --- /dev/null +++ b/packages/ssz/src/viewDU/stableContainer.ts @@ -0,0 +1,317 @@ +import {getNodeAtDepth, LeafNode, Node, setNodesAtDepth, zeroNode} from "@chainsafe/persistent-merkle-tree"; +import {ByteViews, Type} from "../type/abstract"; +import {BasicType, isBasicType} from "../type/basic"; +import {CompositeType, isCompositeType, CompositeTypeAny} from "../type/composite"; +import {computeSerdesData, ContainerTypeGeneric} from "../view/stableContainer"; +import {TreeViewDU} from "./abstract"; +import {OptionalType} from "../type/optional"; +import { BitArray } from "../value/bitArray" + +/* eslint-disable @typescript-eslint/member-ordering */ + +export type ViewDUValue> = T extends CompositeType + ? // If composite, return view. MAY propagate changes updwards + TVDU + : // If basic, return struct value. Will NOT propagate changes upwards + T extends BasicType + ? V + : never; + +export type OptionalViewDUValue> = T extends CompositeType + ? // If composite, return view. MAY propagate changes updwards + TVDU | null | undefined + : // If basic, return struct value. Will NOT propagate changes upwards + T extends BasicType + ? V | null | undefined + : never; + +export type FieldsViewDU>> = { + [K in keyof Fields]: Fields[K] extends OptionalType ? OptionalViewDUValue : ViewDUValue; +}; + +export type ContainerTreeViewDUType>> = FieldsViewDU & + TreeViewDU>; +export type ContainerTreeViewDUTypeConstructor>> = { + new (type: ContainerTypeGeneric, node: Node, cache?: unknown): ContainerTreeViewDUType; +}; + +type ContainerTreeViewDUCache = { + activeFields: BitArray; + nodes: Node[]; + caches: unknown[]; + nodesPopulated: boolean; +}; + +class ContainerTreeViewDU>> extends TreeViewDU< + ContainerTypeGeneric +> { + /** pending active fields bitvector */ + protected activeFields: BitArray; + protected nodes: Node[] = []; + protected caches: unknown[]; + protected readonly nodesChanged = new Set(); + protected readonly viewsChanged = new Map(); + private nodesPopulated: boolean; + + constructor( + readonly type: ContainerTypeGeneric, + protected _rootNode: Node, + cache?: ContainerTreeViewDUCache + ) { + super(); + + if (cache) { + this.activeFields = cache.activeFields; + this.nodes = cache.nodes; + this.caches = cache.caches; + this.nodesPopulated = cache.nodesPopulated; + } else { + this.activeFields = type.tree_getActiveFields(_rootNode); + this.nodes = []; + this.caches = []; + this.nodesPopulated = false; + } + } + + get node(): Node { + return this._rootNode; + } + + get cache(): ContainerTreeViewDUCache { + return { + activeFields: this.activeFields, + nodes: this.nodes, + caches: this.caches, + nodesPopulated: this.nodesPopulated, + }; + } + + commit(): void { + if (this.nodesChanged.size === 0 && this.viewsChanged.size === 0) { + return; + } + + const nodesChanged: {index: number; node: Node}[] = []; + + for (const [index, view] of this.viewsChanged) { + const fieldType = this.type.fieldsEntries[index].fieldType as unknown as CompositeTypeAny; + const node = fieldType.commitViewDU(view); + // Set new node in nodes array to ensure data represented in the tree and fast nodes access is equal + this.nodes[index] = node; + nodesChanged.push({index, node}); + + // Cache the view's caches to preserve it's data after 'this.viewsChanged.clear()' + const cache = fieldType.cacheOfViewDU(view); + if (cache) this.caches[index] = cache; + } + + for (const index of this.nodesChanged) { + nodesChanged.push({index, node: this.nodes[index]}); + } + + // TODO: Optimize to loop only once, Numerical sort ascending + const nodesChangedSorted = nodesChanged.sort((a, b) => a.index - b.index); + const indexes = nodesChangedSorted.map((entry) => entry.index); + const nodes = nodesChangedSorted.map((entry) => entry.node); + + this._rootNode = setNodesAtDepth(this._rootNode, this.type.depth, indexes, nodes); + this._rootNode = this.type.tree_setActiveFields(this._rootNode, this.activeFields); + + this.nodesChanged.clear(); + this.viewsChanged.clear(); + } + + protected clearCache(): void { + this.nodes = []; + this.caches = []; + this.nodesPopulated = false; + + // Must clear nodesChanged, otherwise a subsequent commit call will break, because it assumes a node is there + this.nodesChanged.clear(); + + // It's not necessary to clear this.viewsChanged since they have no effect on the cache. + // However preserving _SOME_ caches results in a very unpredictable experience. + this.viewsChanged.clear(); + } + + /** + * Same method to `type/container.ts` that call ViewDU.serializeToBytes() of internal fields. + */ + serializeToBytes(output: ByteViews, offset: number): number { + this.commit(); + + const activeFields = this.type.tree_getActiveFields(this.node); + // write active fields bitvector + output.uint8Array.set(activeFields.uint8Array, offset); + offset += activeFields.uint8Array.length; + + const {fixedEnd} = computeSerdesData(activeFields, this.type.fieldsEntries); + + let fixedIndex = offset; + let variableIndex = offset + fixedEnd; + for (let index = 0; index < this.type.fieldsEntries.length; index++) { + const {fieldType, optional} = this.type.fieldsEntries[index]; + if (optional && !activeFields.get(index)) { + continue; + } + + let node = this.nodes[index]; + if (node === undefined) { + node = getNodeAtDepth(this._rootNode, this.type.depth, index); + this.nodes[index] = node; + } + if (fieldType.fixedSize === null) { + // write offset + output.dataView.setUint32(fixedIndex, variableIndex - offset, true); + fixedIndex += 4; + // write serialized element to variable section + // basic types always have fixedSize + if (isCompositeType(fieldType)) { + const view = fieldType.getViewDU(node, this.caches[index]) as TreeViewDU; + if (view.serializeToBytes !== undefined) { + variableIndex = view.serializeToBytes(output, variableIndex); + } else { + // some types don't define ViewDU as TreeViewDU, like the UnionType, in that case view.serializeToBytes = undefined + variableIndex = fieldType.tree_serializeToBytes(output, variableIndex, node); + } + } + } else { + fixedIndex = fieldType.tree_serializeToBytes(output, fixedIndex, node); + } + } + + return variableIndex; + } +} + +export function getContainerTreeViewDUClass>>( + type: ContainerTypeGeneric +): ContainerTreeViewDUTypeConstructor { + class CustomContainerTreeViewDU extends ContainerTreeViewDU {} + + // Dynamically define prototype methods + for (let index = 0; index < type.fieldsEntries.length; index++) { + const {fieldName, fieldType, optional} = type.fieldsEntries[index]; + + // If the field type is basic, the value to get and set will be the actual 'struct' value (i.e. a JS number). + // The view must use the tree_getFromNode() and tree_setToNode() methods to persist the struct data to the node, + // and use the cached views array to store the new node. + if (isBasicType(fieldType)) { + Object.defineProperty(CustomContainerTreeViewDU.prototype, fieldName, { + configurable: false, + enumerable: true, + + // TODO: Review the memory cost of this closures + get: function (this: CustomContainerTreeViewDU) { + if (optional && this.activeFields.get(index) === false) { + return null; + } + + // First walk through the tree to get the root node for that index + let node = this.nodes[index]; + if (node === undefined) { + node = getNodeAtDepth(this._rootNode, this.type.depth, index); + this.nodes[index] = node; + } + + return fieldType.tree_getFromNode(node as LeafNode) as unknown; + }, + + set: function (this: CustomContainerTreeViewDU, value) { + if (optional && value == null) { + this.nodes[index] = zeroNode(0); + this.nodesChanged.add(index); + this.activeFields.set(index, false); + return; + } + + // Create new node if current leafNode is not dirty + let nodeChanged: LeafNode; + if (this.nodesChanged.has(index)) { + // TODO: This assumes that node has already been populated + nodeChanged = this.nodes[index] as LeafNode; + } else { + const nodePrev = (this.nodes[index] ?? getNodeAtDepth(this._rootNode, this.type.depth, index)) as LeafNode; + + nodeChanged = nodePrev.clone(); + // Store the changed node in the nodes cache + this.nodes[index] = nodeChanged; + this.nodesChanged.add(index); + } + + fieldType.tree_setToNode(nodeChanged, value); + this.activeFields.set(index, true); + }, + }); + } + + // If the field type is composite, the value to get and set will be another TreeView. The parent TreeView must + // cache the view itself to retain the caches of the child view. To set a value the view must return a node to + // set it to the parent tree in the field gindex. + else if (isCompositeType(fieldType)) { + Object.defineProperty(CustomContainerTreeViewDU.prototype, fieldName, { + configurable: false, + enumerable: true, + + // Returns TreeViewDU of fieldName + get: function (this: CustomContainerTreeViewDU) { + if (optional && this.activeFields.get(index) === false) { + return null; + } + + const viewChanged = this.viewsChanged.get(index); + if (viewChanged) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return viewChanged; + } + + let node = this.nodes[index]; + if (node === undefined) { + node = getNodeAtDepth(this._rootNode, this.type.depth, index); + this.nodes[index] = node; + } + + // Keep a reference to the new view to call .commit on it latter, only if mutable + const view = fieldType.getViewDU(node, this.caches[index]); + if (fieldType.isViewMutable) { + this.viewsChanged.set(index, view); + } + + // No need to persist the child's view cache since a second get returns this view instance. + // The cache is only persisted on commit where the viewsChanged map is dropped. + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return view; + }, + + // Expects TreeViewDU of fieldName + set: function (this: CustomContainerTreeViewDU, view: unknown) { + if (optional && view == null) { + this.nodes[index] = zeroNode(0); + this.nodesChanged.add(index); + this.activeFields.set(index, false); + return; + } + + // When setting a view: + // - Not necessary to commit node + // - Not necessary to persist cache + // Just keeping a reference to the view in this.viewsChanged ensures consistency + this.viewsChanged.set(index, view); + this.activeFields.set(index, true); + }, + }); + } + + // Should never happen + else { + /* istanbul ignore next - unreachable code */ + throw Error(`Unknown fieldType ${fieldType.typeName} for fieldName ${fieldName}`); + } + } + + // Change class name + Object.defineProperty(CustomContainerTreeViewDU, "name", {value: type.typeName, writable: false}); + + return CustomContainerTreeViewDU as unknown as ContainerTreeViewDUTypeConstructor; +} diff --git a/packages/ssz/test/unit/byType/runTypeProofTest.ts b/packages/ssz/test/unit/byType/runTypeProofTest.ts index aa7a7441..1496b7ca 100644 --- a/packages/ssz/test/unit/byType/runTypeProofTest.ts +++ b/packages/ssz/test/unit/byType/runTypeProofTest.ts @@ -1,6 +1,6 @@ import {Node} from "@chainsafe/persistent-merkle-tree"; import {expect} from "chai"; -import {BitArray, ContainerType, fromHexString, JsonPath, OptionalType, Type} from "../../../src"; +import {BitArray, ContainerType, fromHexString, JsonPath, OptionalType, StableContainerType, Type} from "../../../src"; import {CompositeTypeAny, isCompositeType} from "../../../src/type/composite"; import {ArrayBasicTreeView} from "../../../src/view/arrayBasic"; import {RootHex} from "../../lodestarTypes"; @@ -37,9 +37,12 @@ export function runProofTestOnAllJsonPaths({ const viewLeafFromProof = getJsonPathView(type, viewFromProof, jsonPath); const jsonLeaf = getJsonPathValue(type, json, jsonPath); - const jsonLeafFromProof = typeLeaf.toJson( - isCompositeType(typeLeaf) ? typeLeaf.toValueFromView(viewLeafFromProof) : viewLeafFromProof - ); + const jsonLeafFromProof = + viewLeafFromProof == null + ? viewLeafFromProof + : typeLeaf.toJson( + isCompositeType(typeLeaf) ? typeLeaf.toValueFromView(viewLeafFromProof) : viewLeafFromProof + ); expect(jsonLeafFromProof).to.deep.equal(jsonLeaf, "Wrong value fromProof"); @@ -108,9 +111,10 @@ function getJsonPathView(type: Type, view: unknown, jsonPath: JsonPath) if (typeof jsonProp === "number") { view = (view as ArrayBasicTreeView).get(jsonProp); } else if (typeof jsonProp === "string") { - if (type instanceof ContainerType) { + if (type instanceof ContainerType || type instanceof StableContainerType) { // Coerce jsonProp to a fieldName. JSON paths may be in JSON notation or fieldName notation - const fieldName = type["jsonKeyToFieldName"][jsonProp] ?? jsonProp; + const fieldName = + (type as ContainerType>>)["jsonKeyToFieldName"][jsonProp] ?? jsonProp; view = (view as Record)[fieldName as string]; } else { throw Error(`type ${type.typeName} is not a ContainerType - jsonProp '${jsonProp}'`); @@ -139,8 +143,8 @@ function getJsonPathValue(type: Type, json: unknown, jsonPath: JsonPath if (typeof jsonProp === "number") { json = (json as unknown[])[jsonProp]; } else if (typeof jsonProp === "string") { - if (type instanceof ContainerType) { - if (type["jsonKeyToFieldName"][jsonProp] === undefined) { + if (type instanceof ContainerType || type instanceof StableContainerType) { + if ((type as ContainerType>>)["jsonKeyToFieldName"][jsonProp] === undefined) { throw Error(`Unknown jsonProp ${jsonProp} for type ${type.typeName}`); } diff --git a/packages/ssz/test/unit/byType/stableContainer/valid.test.ts b/packages/ssz/test/unit/byType/stableContainer/valid.test.ts new file mode 100644 index 00000000..863d8a65 --- /dev/null +++ b/packages/ssz/test/unit/byType/stableContainer/valid.test.ts @@ -0,0 +1,46 @@ +import {OptionalType, StableContainerType, UintNumberType} from "../../../../src"; +import {runTypeTestValid} from "../runTypeTestValid"; + +// taken from eip spec tests + +const optionalUint16 = new OptionalType(new UintNumberType(2)); +const byteType = new UintNumberType(1); +const Shape = new StableContainerType( + { + side: optionalUint16, + color: byteType, + radius: optionalUint16, + }, + 4 +); + +runTypeTestValid({ + type: Shape, + defaultValue: {side: null, color: 0, radius: null}, + values: [ + { + id: "shape-0", + serialized: "0x074200014200", + json: {side: 0x42, color: 1, radius: 0x42}, + root: "0x37b28eab19bc3e246e55d2e2b2027479454c27ee006d92d4847c84893a162e6d", + }, + { + id: "shape-1", + serialized: "0x03420001", + json: {side: 0x42, color: 1, radius: null}, + root: "0xbfdb6fda9d02805e640c0f5767b8d1bb9ff4211498a5e2d7c0f36e1b88ce57ff", + }, + { + id: "shape-2", + serialized: "0x0201", + json: {side: null, color: 1, radius: null}, + root: "0x522edd7309c0041b8eb6a218d756af558e9cf4c816441ec7e6eef42dfa47bb98", + }, + { + id: "shape-3", + serialized: "0x06014200", + json: {side: null, color: 1, radius: 0x42}, + root: "0xf66d2c38c8d2afbd409e86c529dff728e9a4208215ca20ee44e49c3d11e145d8", + }, + ], +});