@chainsafe/ssz
Version:
Simple Serialize
512 lines • 22.7 kB
JavaScript
import { BranchNode, concatGindices, getNode, getNodesAtDepth, merkleizeBlocksBytes, subtreeFillToContents, toGindex, zeroHash, zeroNode, } from "@chainsafe/persistent-merkle-tree";
import { cacheRoot, maxChunksToDepth, symbolCachedPermanentRoot, } from "../util/merkleize.js";
import { namedClass } from "../util/named.js";
import { Case } from "../util/strings.js";
import { BitArray } from "../value/bitArray.js";
import { computeSerdesData, getProfileTreeViewClass, } from "../view/profile.js";
import { getProfileTreeViewDUClass, } from "../viewDU/profile.js";
import { CompositeType } from "./composite.js";
import { isOptionalType, toNonOptionalType } from "./optional.js";
import { mixInActiveFields, setActiveFields } from "./stableContainer.js";
/**
* Profile: ordered heterogeneous collection of values that inherits merkleization from a base stable container
* - EIP: https://eips.ethereum.org/EIPS/eip-7495
* - No reordering of fields for merkleization
*/
export class ProfileType extends CompositeType {
fields;
opts;
typeName;
depth;
maxChunkCount;
fixedSize;
minSize;
maxSize;
isList = false;
isViewMutable = true;
activeFields;
// Precomputed data for faster serdes
fieldsEntries;
/** End of fixed section of serialized Container */
fieldsGindex;
jsonKeyToFieldName;
/** Cached TreeView constuctor with custom prototype for this Type's properties */
TreeView;
TreeViewDU;
optionalFieldsCount;
// temporary root to avoid memory allocation
tempRoot = new Uint8Array(32);
constructor(fields, activeFields, opts) {
super();
this.fields = fields;
this.opts = opts;
// Render detailed typeName. Consumers should overwrite since it can get long
this.typeName = opts?.typeName ?? renderContainerTypeName(fields);
if (activeFields.getTrueBitIndexes().length !== Object.keys(fields).length) {
throw new Error("activeFields must have the same number of true bits as fields");
}
this.activeFields = activeFields;
this.maxChunkCount = this.activeFields.bitLen;
this.depth = maxChunksToDepth(this.maxChunkCount) + 1;
// Precalculated data for faster serdes
this.fieldsEntries = [];
const fieldNames = Object.keys(fields);
this.optionalFieldsCount = 0;
for (let i = 0, fieldIx = 0; i < this.activeFields.bitLen; i++) {
if (!this.activeFields.get(i)) {
continue;
}
const fieldName = fieldNames[fieldIx++];
const fieldType = fields[fieldName];
const optional = isOptionalType(fieldType);
this.fieldsEntries.push({
fieldName,
fieldType: toNonOptionalType(fieldType),
jsonKey: precomputeJsonKey(fieldName, opts?.casingMap, opts?.jsonCase),
gindex: toGindex(this.depth, BigInt(i)),
chunkIndex: i,
optional,
});
if (optional) {
this.optionalFieldsCount++;
}
}
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++) {
const { fieldName, chunkIndex } = this.fieldsEntries[i];
this.fieldsGindex[fieldName] = toGindex(this.depth, BigInt(chunkIndex));
}
// 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;
// TODO: This options are necessary for ContainerNodeStruct to override this.
// Refactor this constructor to allow customization without pollutin the options
this.TreeView = opts?.getProfileTreeViewClass?.(this) ?? getProfileTreeViewClass(this);
this.TreeViewDU = opts?.getProfileTreeViewDUClass?.(this) ?? getProfileTreeViewDUClass(this);
const fieldBytes = this.activeFields.bitLen * 32;
this.blocksBuffer = new Uint8Array(Math.ceil(fieldBytes / 64) * 64);
}
static named(fields, activeFields, opts) {
return new (namedClass(ProfileType, opts.typeName))(fields, activeFields, opts);
}
defaultValue() {
const value = {};
for (const { fieldName, fieldType, optional } of this.fieldsEntries) {
value[fieldName] = (optional ? null : 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) {
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) {
let totalSize = Math.ceil(this.optionalFieldsCount / 8);
for (let i = 0; i < this.fieldsEntries.length; i++) {
const { fieldName, fieldType, optional } = this.fieldsEntries[i];
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, offset, value) {
const optionalFields = BitArray.fromBitLen(this.optionalFieldsCount);
let optionalIndex = 0;
for (let i = 0; i < this.fieldsEntries.length; i++) {
const { fieldName, optional } = this.fieldsEntries[i];
if (optional) {
optionalFields.set(optionalIndex++, value[fieldName] !== null);
}
}
output.uint8Array.set(optionalFields.uint8Array, offset);
const { fixedEnd } = computeSerdesData(optionalFields, this.fieldsEntries);
const optionalFieldsLen = optionalFields.uint8Array.length;
let fixedIndex = offset + optionalFieldsLen;
let variableIndex = offset + fixedEnd + optionalFieldsLen;
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 relative to the start of serialized active fields, after the Bitvector[N]
output.dataView.setUint32(fixedIndex, variableIndex - offset - optionalFieldsLen, 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 { optionalFields, fieldRanges } = this.getFieldRanges(data, start, end);
const value = {};
const optionalFieldsLen = optionalFields.uint8Array.length;
start += optionalFieldsLen;
let optionalIndex = 0;
for (let i = 0; i < this.fieldsEntries.length; i++) {
const { fieldName, fieldType, optional } = this.fieldsEntries[i];
if (optional && !optionalFields.get(optionalIndex++)) {
value[fieldName] = null;
continue;
}
const fieldRange = fieldRanges[i];
value[fieldName] = fieldType.value_deserializeFromBytes(data, start + fieldRange.start, start + fieldRange.end);
}
return value;
}
tree_serializedSize(node) {
let totalSize = Math.ceil(this.optionalFieldsCount / 8);
const nodes = getNodesAtDepth(node, this.depth, 0, this.activeFields.bitLen);
for (let i = 0; i < this.fieldsEntries.length; i++) {
const { fieldType, chunkIndex, optional } = this.fieldsEntries[i];
const node = nodes[chunkIndex];
// zeroNode() means optional field is null, it's different from a node with all zeros
if (optional && node === zeroNode(0)) {
continue;
}
// Offset (4 bytes) + size
totalSize += fieldType.fixedSize === null ? 4 + fieldType.tree_serializedSize(node) : fieldType.fixedSize;
}
return totalSize;
}
tree_serializeToBytes(output, offset, node) {
const optionalFields = BitArray.fromBitLen(this.optionalFieldsCount);
const optionalFieldsLen = optionalFields.uint8Array.length;
const nodes = getNodesAtDepth(node, this.depth, 0, this.activeFields.bitLen);
let optionalIndex = -1;
if (this.optionalFieldsCount > 0) {
// 1st loop to compute optional fields
for (let i = 0; i < this.fieldsEntries.length; i++) {
const { chunkIndex, optional } = this.fieldsEntries[i];
const node = nodes[chunkIndex];
if (optional) {
optionalIndex++;
if (node !== zeroNode(0)) {
optionalFields.set(optionalIndex, true);
}
}
}
}
output.uint8Array.set(optionalFields.uint8Array, offset);
const { fixedEnd } = computeSerdesData(optionalFields, this.fieldsEntries);
let fixedIndex = offset + optionalFieldsLen;
let variableIndex = offset + fixedEnd + optionalFieldsLen;
// 2nd loop to serialize fields
for (let i = 0; i < this.fieldsEntries.length; i++) {
const { fieldType, chunkIndex, optional } = this.fieldsEntries[i];
const node = nodes[chunkIndex];
if (optional && node === zeroNode(0)) {
continue;
}
if (fieldType.fixedSize === null) {
// write offset relative to the start of serialized active fields, after the Bitvector[N]
output.dataView.setUint32(fixedIndex, variableIndex - offset - optionalFieldsLen, 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 { optionalFields, fieldRanges } = this.getFieldRanges(data, start, end);
const nodes = new Array(this.activeFields.bitLen).fill(zeroNode(0));
const optionalFieldsLen = optionalFields.uint8Array.length;
start += optionalFieldsLen;
let optionalIndex = -1;
for (let i = 0; i < this.fieldsEntries.length; i++) {
const { fieldType, chunkIndex, optional } = this.fieldsEntries[i];
if (optional) {
optionalIndex++;
if (!optionalFields.get(optionalIndex)) {
continue;
}
}
const fieldRange = fieldRanges[i];
nodes[chunkIndex] = fieldType.tree_deserializeFromBytes(data, start + fieldRange.start, start + fieldRange.end);
}
const root = new BranchNode(subtreeFillToContents(nodes, this.depth - 1), zeroNode(0));
return setActiveFields(root, this.activeFields);
}
// Merkleization
// hashTreeRoot is the same to parent as it call hashTreeRootInto()
hashTreeRootInto(value, output, offset, safeCache = false) {
// Return cached mutable root if any
if (this.cachePermanentRootStruct) {
const cachedRoot = value[symbolCachedPermanentRoot];
if (cachedRoot) {
output.set(cachedRoot, offset);
return;
}
}
const blocksBytes = this.getBlocksBytes(value);
merkleizeBlocksBytes(blocksBytes, this.maxChunkCount, this.tempRoot, 0);
mixInActiveFields(this.tempRoot, this.activeFields, output, offset);
if (this.cachePermanentRootStruct) {
cacheRoot(value, output, offset, safeCache);
}
}
getBlocksBytes(struct) {
this.blocksBuffer.fill(0);
for (let i = 0; i < this.fieldsEntries.length; i++) {
const { fieldName, fieldType, chunkIndex, optional } = this.fieldsEntries[i];
if (optional && struct[fieldName] == null) {
this.blocksBuffer.set(zeroHash(0), chunkIndex * 32);
}
else {
fieldType.hashTreeRootInto(struct[fieldName], this.blocksBuffer, chunkIndex * 32);
}
}
// remaining bytes are zeroed as we never write them
return this.blocksBuffer;
}
// Proofs
/** INTERNAL METHOD: For view's API, create proof from a tree */
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
* - offsets are relative to the start of serialized active fields, after the Bitvector[N]
*/
getFieldRanges(data, start, end) {
const optionalFieldsByteLen = Math.ceil(this.optionalFieldsCount / 8);
const optionalFields = new BitArray(data.uint8Array.subarray(start, start + optionalFieldsByteLen), this.optionalFieldsCount);
const { variableOffsetsPosition, fixedEnd, fieldRangesFixedLen, isFixedLen } = computeSerdesData(optionalFields, this.fieldsEntries);
if (variableOffsetsPosition.length === 0) {
// Validate fixed length container
const size = end - start;
if (size !== fixedEnd + optionalFieldsByteLen) {
throw Error(`${this.typeName} size ${size} not equal fixed end plus optionalFieldsByteLen ${fixedEnd + optionalFieldsByteLen}`);
}
return { optionalFields, fieldRanges: fieldRangesFixedLen };
}
// Read offsets in one pass
const offsets = readVariableOffsets(data.dataView, start, end, optionalFieldsByteLen, fixedEnd, variableOffsetsPosition);
offsets.push(end - start - optionalFieldsByteLen); // The offsets are relative to the start of serialized optional fields
// 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 { optionalFields, fieldRanges };
}
}
/**
* Returns the byte ranges of all variable size fields.
* Offsets are relative to the start of serialized active fields, after the Bitvector[N]
*/
function readVariableOffsets(data, start, end, optionalFieldsEnd, 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;
const optionalFieldsByteLen = optionalFieldsEnd - 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] + optionalFieldsByteLen, 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) {
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 = "Profile") {
const fieldNames = Object.keys(fields);
const fieldTypeNames = fieldNames
.map((fieldName) => `${String(fieldName)}: ${fields[fieldName].typeName}`)
.join(", ");
return `${prefix}({${fieldTypeNames}})`;
}
//# sourceMappingURL=profile.js.map