UNPKG

@chainsafe/ssz

Version:

Simple Serialize

441 lines 18.1 kB
import { concatGindices, getNode, getNodesAtDepth, subtreeFillToContents, toGindex, } from "@chainsafe/persistent-merkle-tree"; import { maxChunksToDepth } from "../util/merkleize.js"; import { namedClass } from "../util/named.js"; import { Case } from "../util/strings.js"; import { getContainerTreeViewClass } from "../view/container.js"; import { getContainerTreeViewDUClass, } from "../viewDU/container.js"; import { CompositeType } from "./composite.js"; /** * Container: ordered heterogeneous collection of values * - Notation: Custom name per instance */ export class ContainerType extends CompositeType { fields; opts; typeName; depth; maxChunkCount; fixedSize; minSize; maxSize; isList = false; isViewMutable = true; // Precomputed data for faster serdes fieldsEntries; /** End of fixed section of serialized Container */ fixedEnd; fieldsGindex; jsonKeyToFieldName; isFixedLen; fieldRangesFixedLen; /** Offsets position relative to start of serialized Container. Length may not equal field count. */ variableOffsetsPosition; /** Cached TreeView constuctor with custom prototype for this Type's properties */ TreeView; TreeViewDU; constructor(fields, opts) { super(opts?.cachePermanentRootStruct); this.fields = fields; this.opts = opts; // Render detailed typeName. Consumers should overwrite since it can get long this.typeName = opts?.typeName ?? renderContainerTypeName(fields); this.maxChunkCount = Object.keys(fields).length; this.depth = maxChunksToDepth(this.maxChunkCount); // Precalculated data for faster serdes this.fieldsEntries = []; for (const fieldName of Object.keys(fields)) { this.fieldsEntries.push({ fieldName, fieldType: this.fields[fieldName], jsonKey: precomputeJsonKey(fieldName, opts?.casingMap, opts?.jsonCase), gindex: toGindex(this.depth, BigInt(this.fieldsEntries.length)), }); } if (this.fieldsEntries.length === 0) { throw Error("Container must have > 0 fields"); } // Precalculate for Proofs API this.fieldsGindex = {}; 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(fields); this.minSize = minLen; this.maxSize = maxLen; this.fixedSize = fixedSize; const { isFixedLen, fieldRangesFixedLen, variableOffsetsPosition, fixedEnd } = precomputeSerdesData(fields); this.isFixedLen = isFixedLen; this.fieldRangesFixedLen = fieldRangesFixedLen; this.variableOffsetsPosition = variableOffsetsPosition; this.fixedEnd = fixedEnd; // 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); const fieldBytes = this.fieldsEntries.length * 32; this.blocksBuffer = new Uint8Array(Math.ceil(fieldBytes / 64) * 64); } static named(fields, opts) { return new (namedClass(ContainerType, opts.typeName))(fields, opts); } defaultValue() { const value = {}; for (const { fieldName, fieldType } of this.fieldsEntries) { value[fieldName] = fieldType.defaultValue(); } return value; } getView(tree) { return new this.TreeView(this, tree); } getViewDU(node, cache) { return new this.TreeViewDU(this, node, cache); } cacheOfViewDU(view) { return view.cache; } commitView(view) { return view.node; } commitViewDU(view, hcOffset = 0, hcByLevel = null) { view.commit(hcOffset, hcByLevel); 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) { let totalSize = 0; for (let i = 0; i < this.fieldsEntries.length; i++) { const { fieldName, fieldType } = this.fieldsEntries[i]; // Offset (4 bytes) + size totalSize += fieldType.fixedSize === null ? 4 + fieldType.value_serializedSize(value[fieldName]) : fieldType.fixedSize; } return totalSize; } value_serializeToBytes(output, offset, value) { let fixedIndex = offset; let variableIndex = offset + this.fixedEnd; for (let i = 0; i < this.fieldsEntries.length; i++) { const { fieldName, fieldType } = this.fieldsEntries[i]; 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, start, end) { const fieldRanges = this.getFieldRanges(data.dataView, start, end); const value = {}; for (let i = 0; i < this.fieldsEntries.length; i++) { const { fieldName, fieldType } = this.fieldsEntries[i]; const fieldRange = fieldRanges[i]; value[fieldName] = fieldType.value_deserializeFromBytes(data, start + fieldRange.start, start + fieldRange.end); } return value; } tree_serializedSize(node) { let totalSize = 0; const nodes = getNodesAtDepth(node, this.depth, 0, this.fieldsEntries.length); for (let i = 0; i < this.fieldsEntries.length; i++) { const { fieldType } = this.fieldsEntries[i]; const node = nodes[i]; // Offset (4 bytes) + size totalSize += fieldType.fixedSize === null ? 4 + fieldType.tree_serializedSize(node) : fieldType.fixedSize; } return totalSize; } tree_serializeToBytes(output, offset, node) { let fixedIndex = offset; let variableIndex = offset + this.fixedEnd; const nodes = getNodesAtDepth(node, this.depth, 0, this.fieldsEntries.length); for (let i = 0; i < this.fieldsEntries.length; i++) { const { fieldType } = this.fieldsEntries[i]; 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, start, end) { const fieldRanges = this.getFieldRanges(data.dataView, start, end); const nodes = new Array(this.fieldsEntries.length); for (let i = 0; i < this.fieldsEntries.length; i++) { const { fieldType } = this.fieldsEntries[i]; const fieldRange = fieldRanges[i]; nodes[i] = fieldType.tree_deserializeFromBytes(data, start + fieldRange.start, start + fieldRange.end); } return subtreeFillToContents(nodes, this.depth); } // Merkleization getBlocksBytes(struct) { for (let i = 0; i < this.fieldsEntries.length; i++) { const { fieldName, fieldType } = this.fieldsEntries[i]; fieldType.hashTreeRootInto(struct[fieldName], this.blocksBuffer, i * 32); } // remaining bytes are zeroed as we never write them return this.blocksBuffer; } // Proofs // getPropertyGindex // getPropertyType // tree_getLeafGindices getPropertyGindex(prop) { const gindex = this.fieldsGindex[prop] ?? this.fieldsGindex[this.jsonKeyToFieldName[prop]]; if (gindex === undefined) throw Error(`Unknown container property ${prop}`); return gindex; } getPropertyType(prop) { const type = this.fields[prop] ?? this.fields[this.jsonKeyToFieldName[prop]]; if (type === undefined) throw Error(`Unknown container property ${prop}`); return type; } getIndexProperty(index) { if (index >= this.fieldsEntries.length) { return null; } return this.fieldsEntries[index].fieldName; } tree_getLeafGindices(rootGindex, rootNode) { const gindices = []; for (let i = 0; i < this.fieldsEntries.length; i++) { const { fieldName, fieldType } = this.fieldsEntries[i]; const fieldGindex = this.fieldsGindex[fieldName]; const fieldGindexFromRoot = concatGindices([rootGindex, fieldGindex]); if (fieldType.isBasic) { gindices.push(fieldGindexFromRoot); } else { const compositeType = fieldType; 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) { if (typeof json !== "object") { throw Error("JSON must be of type object"); } if (json === null) { throw Error("JSON must not be null"); } const value = {}; for (let i = 0; i < this.fieldsEntries.length; i++) { const { fieldName, fieldType, jsonKey } = this.fieldsEntries[i]; const jsonValue = json[jsonKey]; if (jsonValue === undefined) { throw Error(`JSON expected key ${jsonKey} is undefined`); } value[fieldName] = fieldType.fromJson(jsonValue); } return value; } toJson(value) { const json = {}; for (let i = 0; i < this.fieldsEntries.length; i++) { const { fieldName, fieldType, jsonKey } = this.fieldsEntries[i]; json[jsonKey] = fieldType.toJson(value[fieldName]); } return json; } clone(value) { const newValue = {}; for (let i = 0; i < this.fieldsEntries.length; i++) { const { fieldName, fieldType } = this.fieldsEntries[i]; newValue[fieldName] = fieldType.clone(value[fieldName]); } return newValue; } equals(a, b) { 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 fields, both variable and fixed size. * 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, start, end) { if (this.variableOffsetsPosition.length === 0) { // Validate fixed length container const size = end - start; if (size !== this.fixedEnd) { throw Error(`${this.typeName} size ${size} not equal fixed size ${this.fixedEnd}`); } return this.fieldRangesFixedLen; } // Read offsets in one pass const offsets = readVariableOffsets(data, start, end, this.fixedEnd, this.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(this.isFixedLen.length); for (let i = 0; i < this.isFixedLen.length; i++) { if (this.isFixedLen[i]) { // push from fixLen ranges ++ fieldRanges[i] = this.fieldRangesFixedLen[fixedIdx++]; } else { // push from varLen ranges ++ fieldRanges[i] = { start: offsets[variableIdx], end: offsets[variableIdx + 1] }; variableIdx++; } } return fieldRanges; } } /** * Returns the byte ranges of all variable size fields. */ function readVariableOffsets(data, start, end, fixedEnd, variableOffsetsPosition) { // 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 fixed and variable offsets position for faster deserialization. * @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 * - */ function precomputeSerdesData(fields) { const isFixedLen = []; const fieldRangesFixedLen = []; const variableOffsetsPosition = []; let pointerFixed = 0; for (const fieldType of Object.values(fields)) { 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, }; } /** * Precompute sizes of the Container doing one pass over fields */ function precomputeSizes(fields) { let minLen = 0; let maxLen = 0; let fixedSize = 0; for (const fieldType of Object.values(fields)) { minLen += fieldType.minSize; maxLen += fieldType.maxSize; if (fieldType.fixedSize === null) { // +4 for the offset minLen += 4; maxLen += 4; fixedSize = null; } else if (fixedSize !== null) { fixedSize += fieldType.fixedSize; } } 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, casingMap, jsonCase) { if (casingMap) { const keyFromCaseMap = casingMap[fieldName]; if (keyFromCaseMap === undefined) { throw Error(`casingMap[${String(fieldName)}] not defined`); } return keyFromCaseMap; } if (jsonCase) return Case[jsonCase](fieldName); return fieldName; } /** * Render field typeNames for a detailed typeName of this Container */ export function renderContainerTypeName(fields, prefix = "Container") { const fieldNames = Object.keys(fields); const fieldTypeNames = fieldNames .map((fieldName) => `${String(fieldName)}: ${fields[fieldName].typeName}`) .join(", "); return `${prefix}({${fieldTypeNames}})`; } //# sourceMappingURL=container.js.map