diff --git a/packages/ssz/src/type/container.ts b/packages/ssz/src/type/container.ts index 4d052cc8..978fe648 100644 --- a/packages/ssz/src/type/container.ts +++ b/packages/ssz/src/type/container.ts @@ -14,7 +14,14 @@ import {namedClass} from "../util/named.ts"; import {Case} from "../util/strings.ts"; import {Require} from "../util/types.ts"; import {getContainerTreeViewClass} from "../view/container.ts"; -import {ContainerTreeViewType, ContainerTreeViewTypeConstructor, FieldEntry, ValueOfFields} from "../view/container.ts"; +import { + ContainerTreeViewType, + ContainerTreeViewTypeConstructor, + EphemeralFieldEntry, + EphemeralValueOfFields, + FieldEntry, + ValueOfFields, +} from "../view/container.ts"; import { ContainerTreeViewDUType, ContainerTreeViewDUTypeConstructor, @@ -25,11 +32,21 @@ import {ByteViews, CompositeType, CompositeTypeAny} from "./composite.ts"; type BytesRange = {start: number; end: number}; -export type ContainerOptions> = { +export type ContainerOptions< + Fields extends Record, + EphemeralFields extends Record> = Record, +> = { typeName?: string; jsonCase?: KeyCase; casingMap?: CasingMap; cachePermanentRootStruct?: boolean; + /** + * Optional declaration of additional fields that are NOT part of consensus serialization, hashing, + * JSON encoding, or equality. Ephemeral fields are accessible/settable on the value, View, and ViewDU + * (typed via their `Type`) but never affect any on-the-wire or merkle behavior. Useful for caching + * derived data on a container without forking the type. + */ + ephemeralFields?: EphemeralFields; getContainerTreeViewClass?: typeof getContainerTreeViewClass; getContainerTreeViewDUClass?: typeof getContainerTreeViewDUClass; }; @@ -49,10 +66,13 @@ type CasingMap> = Partial<{[K in keyof Fi * Container: ordered heterogeneous collection of values * - Notation: Custom name per instance */ -export class ContainerType>> extends CompositeType< - ValueOfFields, - ContainerTreeViewType, - ContainerTreeViewDUType +export class ContainerType< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> extends CompositeType< + ValueOfFields & EphemeralValueOfFields, + ContainerTreeViewType, + ContainerTreeViewDUType > { readonly typeName: string; readonly depth: number; @@ -65,6 +85,8 @@ export class ContainerType>> extends // Precomputed data for faster serdes readonly fieldsEntries: FieldEntry[]; + readonly ephemeralFields: EphemeralFields; + readonly ephemeralFieldsEntries: EphemeralFieldEntry[]; /** End of fixed section of serialized Container */ readonly fixedEnd: number; protected readonly fieldsGindex: Record; @@ -75,12 +97,12 @@ export class ContainerType>> extends protected readonly variableOffsetsPosition: number[]; /** Cached TreeView constuctor with custom prototype for this Type's properties */ - protected readonly TreeView: ContainerTreeViewTypeConstructor; - protected readonly TreeViewDU: ContainerTreeViewDUTypeConstructor; + protected readonly TreeView: ContainerTreeViewTypeConstructor; + protected readonly TreeViewDU: ContainerTreeViewDUTypeConstructor; constructor( readonly fields: Fields, - readonly opts?: ContainerOptions + readonly opts?: ContainerOptions ) { super(opts?.cachePermanentRootStruct); @@ -105,6 +127,26 @@ export class ContainerType>> extends throw Error("Container must have > 0 fields"); } + // Build ephemeralFieldsEntries. Ephemeral fields are NOT part of any consensus computation: + // they are excluded from maxChunkCount, gindex, serdes data, and chunk hashing. They exist + // only as application-level slots accessible on the value/View/ViewDU. + // + // NOTE: Avoid using ephemeral names that collide with View/ViewDU base-class members + // (`type`, `tree`, `node`, `cache`, `nodes`, etc.) — those properties are pre-existing on + // the view classes and the ephemeral accessor would conflict (often as a TS readonly error). + this.ephemeralFields = (opts?.ephemeralFields ?? ({} as EphemeralFields)) as EphemeralFields; + this.ephemeralFieldsEntries = []; + for (const fieldName of Object.keys(this.ephemeralFields) as (keyof EphemeralFields)[]) { + if (Object.prototype.hasOwnProperty.call(fields, fieldName as string)) { + throw Error(`Ephemeral field name '${String(fieldName as symbol)}' collides with a consensus field`); + } + this.ephemeralFieldsEntries.push({ + fieldName, + fieldType: this.ephemeralFields[fieldName], + jsonKey: fieldName as string, + }); + } + // Precalculate for Proofs API this.fieldsGindex = {} as Record; for (let i = 0; i < this.fieldsEntries.length; i++) { @@ -143,32 +185,33 @@ export class ContainerType>> extends return new (namedClass(ContainerType, opts.typeName))(fields, opts); } - defaultValue(): ValueOfFields { + defaultValue(): ValueOfFields & EphemeralValueOfFields { const value = {} as ValueOfFields; for (const {fieldName, fieldType} of this.fieldsEntries) { value[fieldName] = fieldType.defaultValue() as ValueOf; } - return value; + // Ephemeral fields are intentionally omitted: they are optional and absent by default. + return value as ValueOfFields & EphemeralValueOfFields; } - getView(tree: Tree): ContainerTreeViewType { + getView(tree: Tree): ContainerTreeViewType { return new this.TreeView(this, tree); } - getViewDU(node: Node, cache?: unknown): ContainerTreeViewDUType { + getViewDU(node: Node, cache?: unknown): ContainerTreeViewDUType { return new this.TreeViewDU(this, node, cache); } - cacheOfViewDU(view: ContainerTreeViewDUType): unknown { + cacheOfViewDU(view: ContainerTreeViewDUType): unknown { return view.cache; } - commitView(view: ContainerTreeViewType): Node { + commitView(view: ContainerTreeViewType): Node { return view.node; } commitViewDU( - view: ContainerTreeViewDUType, + view: ContainerTreeViewDUType, hcOffset = 0, hcByLevel: HashComputationLevel[] | null = null ): Node { @@ -176,6 +219,36 @@ export class ContainerType>> extends return view.node; } + /** + * Override of {@link CompositeType.toView}: after building the tree-backed View, copy any present + * ephemeral fields from the source value onto the View's ephemeral storage. Ephemeral fields are + * not represented in the tree, so they would otherwise be lost. + */ + toView( + value: ValueOfFields & EphemeralValueOfFields + ): ContainerTreeViewType { + const view = super.toView(value); + for (const {fieldName} of this.ephemeralFieldsEntries) { + const v = (value as Record)[fieldName as string]; + if (v !== undefined) (view as unknown as Record)[fieldName as string] = v; + } + return view; + } + + /** + * Override of {@link CompositeType.toViewDU}: see {@link ContainerType.toView}. + */ + toViewDU( + value: ValueOfFields & EphemeralValueOfFields + ): ContainerTreeViewDUType { + const view = super.toViewDU(value); + for (const {fieldName} of this.ephemeralFieldsEntries) { + const v = (value as Record)[fieldName as string]; + if (v !== undefined) (view as unknown as Record)[fieldName as string] = v; + } + return view; + } + // Serialization + deserialization // ------------------------------- // Containers can mix fixed length and variable length data. @@ -184,7 +257,7 @@ export class ContainerType>> extends // [field1 offset][field2 data ][field1 data ] // [0x000000c] [0xaabbaabbaabbaabb][0xffffffffffffffffffffffff] - value_serializedSize(value: ValueOfFields): number { + value_serializedSize(value: ValueOfFields & EphemeralValueOfFields): number { let totalSize = 0; for (let i = 0; i < this.fieldsEntries.length; i++) { const {fieldName, fieldType} = this.fieldsEntries[i]; @@ -195,7 +268,11 @@ export class ContainerType>> extends return totalSize; } - value_serializeToBytes(output: ByteViews, offset: number, value: ValueOfFields): number { + value_serializeToBytes( + output: ByteViews, + offset: number, + value: ValueOfFields & EphemeralValueOfFields + ): number { let fixedIndex = offset; let variableIndex = offset + this.fixedEnd; @@ -214,7 +291,12 @@ export class ContainerType>> extends return variableIndex; } - value_deserializeFromBytes(data: ByteViews, start: number, end: number, reuseBytes?: boolean): ValueOfFields { + value_deserializeFromBytes( + data: ByteViews, + start: number, + end: number, + reuseBytes?: boolean + ): ValueOfFields & EphemeralValueOfFields { const fieldRanges = this.getFieldRanges(data.dataView, start, end); const value = {} as {[K in keyof Fields]: unknown}; @@ -229,7 +311,7 @@ export class ContainerType>> extends ); } - return value as ValueOfFields; + return value as ValueOfFields & EphemeralValueOfFields; } tree_serializedSize(node: Node): number { @@ -281,7 +363,7 @@ export class ContainerType>> extends // Merkleization - protected getBlocksBytes(struct: ValueOfFields): Uint8Array { + protected getBlocksBytes(struct: ValueOfFields & EphemeralValueOfFields): Uint8Array { for (let i = 0; i < this.fieldsEntries.length; i++) { const {fieldName, fieldType} = this.fieldsEntries[i]; fieldType.hashTreeRootInto(struct[fieldName], this.blocksBuffer, i * 32); @@ -342,7 +424,7 @@ export class ContainerType>> extends // JSON - fromJson(json: unknown): ValueOfFields { + fromJson(json: unknown): ValueOfFields & EphemeralValueOfFields { if (typeof json !== "object") { throw Error("JSON must be of type object"); } @@ -360,22 +442,24 @@ export class ContainerType>> extends } value[fieldName] = fieldType.fromJson(jsonValue) as ValueOf; } - - return value; + // Ephemerals are intentionally not part of JSON encoding. + return value as ValueOfFields & EphemeralValueOfFields; } - toJson(value: ValueOfFields): Record { + toJson(value: ValueOfFields & EphemeralValueOfFields): Record { const json: Record = {}; for (let i = 0; i < this.fieldsEntries.length; i++) { const {fieldName, fieldType, jsonKey} = this.fieldsEntries[i]; json[jsonKey] = fieldType.toJson(value[fieldName]); } - + // Ephemerals are intentionally not part of JSON encoding. return json; } - clone(value: ValueOfFields): ValueOfFields { + clone( + value: ValueOfFields & EphemeralValueOfFields + ): ValueOfFields & EphemeralValueOfFields { const newValue = {} as ValueOfFields; for (let i = 0; i < this.fieldsEntries.length; i++) { @@ -383,17 +467,28 @@ export class ContainerType>> extends newValue[fieldName] = fieldType.clone(value[fieldName]) as ValueOf; } - return newValue; + // Carry over present ephemeral fields, cloning each via its own type. + for (const {fieldName, fieldType} of this.ephemeralFieldsEntries) { + const v = (value as Record)[fieldName as string]; + if (v !== undefined) { + (newValue as Record)[fieldName as string] = fieldType.clone(v); + } + } + + return newValue as ValueOfFields & EphemeralValueOfFields; } - equals(a: ValueOfFields, b: ValueOfFields): boolean { + equals( + a: ValueOfFields & EphemeralValueOfFields, + b: ValueOfFields & EphemeralValueOfFields + ): 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; } } - + // Ephemeral fields are NOT considered for equality — they are not part of consensus. return true; } diff --git a/packages/ssz/src/type/containerNodeStruct.ts b/packages/ssz/src/type/containerNodeStruct.ts index 635a1425..49ce85ea 100644 --- a/packages/ssz/src/type/containerNodeStruct.ts +++ b/packages/ssz/src/type/containerNodeStruct.ts @@ -2,7 +2,7 @@ import {Node} from "@chainsafe/persistent-merkle-tree"; import {BranchNodeStruct} from "../branchNodeStruct.ts"; import {namedClass} from "../util/named.ts"; import {Require} from "../util/types.ts"; -import {ValueOfFields} from "../view/container.ts"; +import {EphemeralValueOfFields, ValueOfFields} from "../view/container.ts"; import {getContainerTreeViewClass} from "../view/containerNodeStruct.ts"; import {getContainerTreeViewDUClass} from "../viewDU/containerNodeStruct.ts"; import {ByteViews, Type} from "./abstract.ts"; @@ -23,10 +23,13 @@ import {ContainerOptions, ContainerType, renderContainerTypeName} from "./contai * * This tradeoff is good for data that is read often, written rarely, and consumes a lot of memory (i.e. Validator) */ -export class ContainerNodeStructType>> extends ContainerType { +export class ContainerNodeStructType< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> extends ContainerType { constructor( readonly fields: Fields, - opts?: ContainerOptions + opts?: ContainerOptions ) { super(fields, { // Overwrite default "Container" typeName @@ -65,17 +68,24 @@ export class ContainerNodeStructType } tree_serializedSize(node: Node): number { - return this.value_serializedSize((node as BranchNodeStruct>).value); + return this.value_serializedSize( + (node as BranchNodeStruct>).value as ValueOfFields & + EphemeralValueOfFields + ); } tree_serializeToBytes(output: ByteViews, offset: number, node: Node): number { const {value} = node as BranchNodeStruct>; - return this.value_serializeToBytes(output, offset, value); + return this.value_serializeToBytes( + output, + offset, + value as ValueOfFields & EphemeralValueOfFields + ); } tree_deserializeFromBytes(data: ByteViews, start: number, end: number): Node { const value = this.value_deserializeFromBytes(data, start, end); - return new BranchNodeStruct(this.valueToTree.bind(this), value); + return new BranchNodeStruct(this.valueToTree.bind(this), value as ValueOfFields); } // Proofs @@ -103,18 +113,20 @@ export class ContainerNodeStructType // Overwrites for fast conversion node <-> value - tree_toValue(node: Node): ValueOfFields { - return (node as BranchNodeStruct>).value; + tree_toValue(node: Node): ValueOfFields & EphemeralValueOfFields { + return (node as BranchNodeStruct>).value as ValueOfFields & + EphemeralValueOfFields; } - value_toTree(value: ValueOfFields): Node { - return new BranchNodeStruct(this.valueToTree.bind(this), value); + value_toTree(value: ValueOfFields & EphemeralValueOfFields): Node { + return new BranchNodeStruct(this.valueToTree.bind(this), value as ValueOfFields); } private valueToTree(value: ValueOfFields): Node { - const uint8Array = new Uint8Array(this.value_serializedSize(value)); + const widened = value as ValueOfFields & EphemeralValueOfFields; + const uint8Array = new Uint8Array(this.value_serializedSize(widened)); const dataView = new DataView(uint8Array.buffer, uint8Array.byteOffset, uint8Array.byteLength); - this.value_serializeToBytes({uint8Array, dataView}, 0, value); + this.value_serializeToBytes({uint8Array, dataView}, 0, widened); return super.tree_deserializeFromBytes({uint8Array, dataView}, 0, uint8Array.length); } } diff --git a/packages/ssz/src/view/container.ts b/packages/ssz/src/view/container.ts index 225413e3..55459fd0 100644 --- a/packages/ssz/src/view/container.ts +++ b/packages/ssz/src/view/container.ts @@ -12,22 +12,57 @@ export type FieldEntry>> = { gindex: Gindex; }; +/** + * Entry for an ephemeral (non-consensus) field. No `gindex` because ephemeral fields are not part of the merkle tree. + */ +export type EphemeralFieldEntry>> = { + fieldName: keyof EphemeralFields; + fieldType: EphemeralFields[keyof EphemeralFields]; + jsonKey: string; +}; + /** Expected API of this View's type. This interface allows to break a recursive dependency between types and views */ -export type BasicContainerTypeGeneric>> = CompositeType< - ValueOfFields, - ContainerTreeViewType, +export type BasicContainerTypeGeneric< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> = CompositeType< + ValueOfFields & EphemeralValueOfFields, + ContainerTreeViewType, unknown > & { readonly fields: Fields; readonly fieldsEntries: (FieldEntry | FieldEntry>)[]; + // Optional so that other consumers (ProfileType, StableContainerType) need not provide them. + // ContainerType always populates both — readers should default to `[]` for the loop. + readonly ephemeralFields?: EphemeralFields; + readonly ephemeralFieldsEntries?: EphemeralFieldEntry[]; }; -export type ContainerTypeGeneric>> = BasicContainerTypeGeneric & { +export type ContainerTypeGeneric< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> = BasicContainerTypeGeneric & { readonly fixedEnd: number; }; export type ValueOfFields>> = {[K in keyof Fields]: ValueOf}; +/** + * `Partial>`, but resolves to `unknown` when EphemeralFields is the unspecified + * default `Record` (its `keyof` is `string`). Without this guard, intersecting with + * `Partial<{[K in string]: never}>` would force every string key of the consensus value to be `undefined`. + */ +export type EphemeralValueOfFields>> = + string extends keyof EphemeralFields ? unknown : Partial>; + +/** + * Helper for callers that have populated all ephemeral fields and want them required (non-optional) at the type level. + */ +export type PopulatedValueOfFields< + Fields extends Record>, + EphemeralFields extends Record>, +> = ValueOfFields & ValueOfFields; + export type FieldsView>> = { [K in keyof Fields]: Fields[K] extends CompositeType ? // If composite, return view. MAY propagate changes updwards @@ -38,10 +73,17 @@ export type FieldsView>> = { : never; }; -export type ContainerTreeViewType>> = FieldsView & - TreeView>; -export type ContainerTreeViewTypeConstructor>> = { - new (type: ContainerTypeGeneric, tree: Tree): ContainerTreeViewType; +export type ContainerTreeViewType< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> = FieldsView & + EphemeralValueOfFields & + TreeView>; +export type ContainerTreeViewTypeConstructor< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> = { + new (type: ContainerTypeGeneric, tree: Tree): ContainerTreeViewType; }; /** @@ -59,9 +101,15 @@ export type ContainerTreeViewTypeConstructor>> extends TreeView> { +class ContainerTreeView< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> extends TreeView> { + /** Storage for ephemeral (non-consensus) field values. Kept per-instance, not in the tree. */ + protected ephemeralValues: Record = {}; + constructor( - readonly type: ContainerTypeGeneric, + readonly type: ContainerTypeGeneric, readonly tree: Tree ) { super(); @@ -72,10 +120,11 @@ class ContainerTreeView>> extends Tr } } -export function getContainerTreeViewClass>>( - type: ContainerTypeGeneric -): ContainerTreeViewTypeConstructor { - class CustomContainerTreeView extends ContainerTreeView {} +export function getContainerTreeViewClass< + Fields extends Record>, + EphemeralFields extends Record> = Record, +>(type: ContainerTypeGeneric): ContainerTreeViewTypeConstructor { + class CustomContainerTreeView extends ContainerTreeView {} // Dynamically define prototype methods for (let index = 0; index < type.fieldsEntries.length; index++) { @@ -133,8 +182,24 @@ export function getContainerTreeViewClass}).ephemeralValues[key]; + }, + set: function (this: CustomContainerTreeView, value: unknown) { + (this as unknown as {ephemeralValues: Record}).ephemeralValues[key] = value; + }, + }); + } + // Change class name Object.defineProperty(CustomContainerTreeView, "name", {value: type.typeName, writable: false}); - return CustomContainerTreeView as unknown as ContainerTreeViewTypeConstructor; + return CustomContainerTreeView as unknown as ContainerTreeViewTypeConstructor; } diff --git a/packages/ssz/src/view/containerNodeStruct.ts b/packages/ssz/src/view/containerNodeStruct.ts index b0ea1c08..218776dd 100644 --- a/packages/ssz/src/view/containerNodeStruct.ts +++ b/packages/ssz/src/view/containerNodeStruct.ts @@ -3,7 +3,12 @@ import {BranchNodeStruct} from "../branchNodeStruct.ts"; import {Type, ValueOf} from "../type/abstract.ts"; import {isCompositeType} from "../type/composite.ts"; import {TreeView} from "./abstract.ts"; -import {ContainerTreeViewTypeConstructor, ContainerTypeGeneric, ValueOfFields} from "./container.ts"; +import { + ContainerTreeViewTypeConstructor, + ContainerTypeGeneric, + EphemeralValueOfFields, + ValueOfFields, +} from "./container.ts"; /** * Intented usage: @@ -20,9 +25,15 @@ import {ContainerTreeViewTypeConstructor, ContainerTypeGeneric, ValueOfFields} f * iterate the entire data structure and views * */ -class ContainerTreeView>> extends TreeView> { +class ContainerTreeView< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> extends TreeView> { + /** Storage for ephemeral (non-consensus) field values. Kept per-instance, not in the tree. */ + protected ephemeralValues: Record = {}; + constructor( - readonly type: ContainerTypeGeneric, + readonly type: ContainerTypeGeneric, readonly tree: Tree ) { super(); @@ -33,10 +44,11 @@ class ContainerTreeView>> extends Tr } } -export function getContainerTreeViewClass>>( - type: ContainerTypeGeneric -): ContainerTreeViewTypeConstructor { - class CustomContainerTreeView extends ContainerTreeView {} +export function getContainerTreeViewClass< + Fields extends Record>, + EphemeralFields extends Record> = Record, +>(type: ContainerTypeGeneric): ContainerTreeViewTypeConstructor { + class CustomContainerTreeView extends ContainerTreeView {} // Dynamically define prototype methods for (let index = 0; index < type.fieldsEntries.length; index++) { @@ -57,7 +69,9 @@ export function getContainerTreeViewClass>; - const newNodeValue = this.type.clone(node.value); + const newNodeValue = this.type.clone( + node.value as ValueOfFields & EphemeralValueOfFields + ) as ValueOfFields; // TODO: Should this check for valid field name? Benchmark the cost newNodeValue[fieldName] = value as ValueOf; @@ -84,7 +98,9 @@ export function getContainerTreeViewClass>; - const newNodeValue = this.type.clone(node.value); + const newNodeValue = this.type.clone( + node.value as ValueOfFields & EphemeralValueOfFields + ) as ValueOfFields; // TODO: Should this check for valid field name? Benchmark the cost newNodeValue[fieldName] = fieldType.toValueFromView(view) as ValueOf; @@ -101,8 +117,23 @@ export function getContainerTreeViewClass}).ephemeralValues[key]; + }, + set: function (this: CustomContainerTreeView, value: unknown) { + (this as unknown as {ephemeralValues: Record}).ephemeralValues[key] = value; + }, + }); + } + // Change class name Object.defineProperty(CustomContainerTreeView, "name", {value: type.typeName, writable: false}); - return CustomContainerTreeView as unknown as ContainerTreeViewTypeConstructor; + return CustomContainerTreeView as unknown as ContainerTreeViewTypeConstructor; } diff --git a/packages/ssz/src/viewDU/container.ts b/packages/ssz/src/viewDU/container.ts index 3e8ab350..cec2072a 100644 --- a/packages/ssz/src/viewDU/container.ts +++ b/packages/ssz/src/viewDU/container.ts @@ -9,7 +9,7 @@ import { import {ByteViews, Type} from "../type/abstract.ts"; import {BasicType, isBasicType} from "../type/basic.ts"; import {CompositeType, CompositeTypeAny, isCompositeType} from "../type/composite.ts"; -import {BasicContainerTypeGeneric, ContainerTypeGeneric} from "../view/container.ts"; +import {BasicContainerTypeGeneric, ContainerTypeGeneric, EphemeralValueOfFields} from "../view/container.ts"; import {TreeViewDU} from "./abstract.ts"; export type FieldsViewDU>> = { @@ -22,10 +22,21 @@ export type FieldsViewDU>> = { : never; }; -export type ContainerTreeViewDUType>> = FieldsViewDU & - TreeViewDU>; -export type ContainerTreeViewDUTypeConstructor>> = { - new (type: ContainerTypeGeneric, node: Node, cache?: unknown): ContainerTreeViewDUType; +export type ContainerTreeViewDUType< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> = FieldsViewDU & + EphemeralValueOfFields & + TreeViewDU>; +export type ContainerTreeViewDUTypeConstructor< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> = { + new ( + type: ContainerTypeGeneric, + node: Node, + cache?: unknown + ): ContainerTreeViewDUType; }; export type ChangedNode = {index: number; node: Node}; @@ -36,17 +47,20 @@ type ContainerTreeViewDUCache = { nodesPopulated: boolean; }; -export class BasicContainerTreeViewDU>> extends TreeViewDU< - BasicContainerTypeGeneric -> { +export class BasicContainerTreeViewDU< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> extends TreeViewDU> { protected nodes: Node[] = []; protected caches: unknown[]; protected readonly nodesChanged = new Set(); protected readonly viewsChanged = new Map(); + /** Storage for ephemeral (non-consensus) field values. Kept per-instance, not in the tree. */ + protected ephemeralValues: Record = {}; private nodesPopulated: boolean; constructor( - readonly type: BasicContainerTypeGeneric, + readonly type: BasicContainerTypeGeneric, protected _rootNode: Node, cache?: ContainerTreeViewDUCache ) { @@ -157,9 +171,12 @@ export class BasicContainerTreeViewDU>> extends BasicContainerTreeViewDU { +class ContainerTreeViewDU< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> extends BasicContainerTreeViewDU { constructor( - readonly type: ContainerTypeGeneric, + readonly type: ContainerTypeGeneric, protected _rootNode: Node, cache?: ContainerTreeViewDUCache ) { @@ -205,10 +222,11 @@ class ContainerTreeViewDU>> extends } } -export function getContainerTreeViewDUClass>>( - type: ContainerTypeGeneric -): ContainerTreeViewDUTypeConstructor { - class CustomContainerTreeViewDU extends ContainerTreeViewDU {} +export function getContainerTreeViewDUClass< + Fields extends Record>, + EphemeralFields extends Record> = Record, +>(type: ContainerTypeGeneric): ContainerTreeViewDUTypeConstructor { + class CustomContainerTreeViewDU extends ContainerTreeViewDU {} // Dynamically define prototype methods for (let index = 0; index < type.fieldsEntries.length; index++) { @@ -305,8 +323,24 @@ export function getContainerTreeViewDUClass}).ephemeralValues[key]; + }, + set: function (this: CustomContainerTreeViewDU, value: unknown) { + (this as unknown as {ephemeralValues: Record}).ephemeralValues[key] = value; + }, + }); + } + // Change class name Object.defineProperty(CustomContainerTreeViewDU, "name", {value: type.typeName, writable: false}); - return CustomContainerTreeViewDU as unknown as ContainerTreeViewDUTypeConstructor; + return CustomContainerTreeViewDU as unknown as ContainerTreeViewDUTypeConstructor; } diff --git a/packages/ssz/src/viewDU/containerNodeStruct.ts b/packages/ssz/src/viewDU/containerNodeStruct.ts index b86a2ca0..63e003d2 100644 --- a/packages/ssz/src/viewDU/containerNodeStruct.ts +++ b/packages/ssz/src/viewDU/containerNodeStruct.ts @@ -2,18 +2,21 @@ import {HashComputationLevel, Node} from "@chainsafe/persistent-merkle-tree"; import {BranchNodeStruct} from "../branchNodeStruct.ts"; import {Type, ValueOf} from "../type/abstract.ts"; import {isCompositeType} from "../type/composite.ts"; -import {ContainerTypeGeneric, ValueOfFields} from "../view/container.ts"; +import {ContainerTypeGeneric, EphemeralValueOfFields, ValueOfFields} from "../view/container.ts"; import {TreeViewDU} from "./abstract.ts"; import {ContainerTreeViewDUTypeConstructor} from "./container.ts"; -export class ContainerNodeStructTreeViewDU>> extends TreeViewDU< - ContainerTypeGeneric -> { +export class ContainerNodeStructTreeViewDU< + Fields extends Record>, + EphemeralFields extends Record> = Record, +> extends TreeViewDU> { protected valueChanged: ValueOfFields | null = null; protected _rootNode: BranchNodeStruct>; + /** Storage for ephemeral (non-consensus) field values. Kept per-instance, not in the tree. */ + protected ephemeralValues: Record = {}; constructor( - readonly type: ContainerTypeGeneric, + readonly type: ContainerTypeGeneric, node: Node ) { super(); @@ -43,7 +46,9 @@ export class ContainerNodeStructTreeViewDU>; + this._rootNode = this.type.value_toTree( + value as ValueOfFields & EphemeralValueOfFields + ) as BranchNodeStruct>; } if (this._rootNode.h0 === null && hcByLevel !== null) { @@ -57,10 +62,11 @@ export class ContainerNodeStructTreeViewDU>>( - type: ContainerTypeGeneric -): ContainerTreeViewDUTypeConstructor { - class CustomContainerTreeViewDU extends ContainerNodeStructTreeViewDU {} +export function getContainerTreeViewDUClass< + Fields extends Record>, + EphemeralFields extends Record> = Record, +>(type: ContainerTypeGeneric): ContainerTreeViewDUTypeConstructor { + class CustomContainerTreeViewDU extends ContainerNodeStructTreeViewDU {} // Dynamically define prototype methods for (let index = 0; index < type.fieldsEntries.length; index++) { @@ -81,7 +87,9 @@ export function getContainerTreeViewDUClass) { if (this.valueChanged === null) { - this.valueChanged = this.type.clone(this._rootNode.value); + this.valueChanged = this.type.clone( + this._rootNode.value as ValueOfFields & EphemeralValueOfFields + ) as ValueOfFields; } this.valueChanged[fieldName] = value; @@ -106,7 +114,9 @@ export function getContainerTreeViewDUClass & EphemeralValueOfFields + ) as ValueOfFields; } const value = fieldType.toValueFromViewDU(view); @@ -122,8 +132,23 @@ export function getContainerTreeViewDUClass}).ephemeralValues[key]; + }, + set: function (this: CustomContainerTreeViewDU, value: unknown) { + (this as unknown as {ephemeralValues: Record}).ephemeralValues[key] = value; + }, + }); + } + // Change class name Object.defineProperty(CustomContainerTreeViewDU, "name", {value: type.typeName, writable: false}); - return CustomContainerTreeViewDU as unknown as ContainerTreeViewDUTypeConstructor; + return CustomContainerTreeViewDU as unknown as ContainerTreeViewDUTypeConstructor; } diff --git a/packages/ssz/test/unit/byType/container/ephemeral.test.ts b/packages/ssz/test/unit/byType/container/ephemeral.test.ts new file mode 100644 index 00000000..ea026790 --- /dev/null +++ b/packages/ssz/test/unit/byType/container/ephemeral.test.ts @@ -0,0 +1,204 @@ +import {Tree} from "@chainsafe/persistent-merkle-tree"; +import {describe, expect, expectTypeOf, it} from "vitest"; +import {ContainerNodeStructType, ContainerType, ListBasicType, UintNumberType} from "../../../../src/index.ts"; +import {byteType, uint64NumInfType} from "../../../utils/primitiveTypes.ts"; + +const uint16 = new UintNumberType(2); + +// Run the same suite for both ContainerType and ContainerNodeStructType to confirm both honor +// the "ephemerals are excluded from consensus, included on value/View/ViewDU" contract. +for (const Variant of [ContainerType, ContainerNodeStructType] as const) { + const variantName = Variant === ContainerType ? "ContainerType" : "ContainerNodeStructType"; + + describe(`${variantName} ephemeralFields`, () => { + const consensusFields = {a: uint64NumInfType, b: uint64NumInfType}; + const ephemeralFields = {total: uint64NumInfType, tag: byteType}; + + const typeWith = new Variant(consensusFields, { + typeName: `${variantName}WithEphemerals`, + ephemeralFields, + }); + const typeWithout = new Variant(consensusFields, {typeName: `${variantName}NoEphemerals`}); + + it("does not advertise ephemeral fields as consensus fields", () => { + expect(typeWith.fieldsEntries).toHaveLength(2); + expect(typeWith.maxChunkCount).toBe(2); + expect(typeWith.depth).toBe(typeWithout.depth); + expect(typeWith.fixedSize).toBe(typeWithout.fixedSize); + + expect(typeWith.ephemeralFieldsEntries).toHaveLength(2); + expect(typeWith.ephemeralFieldsEntries.map((e) => e.fieldName)).toEqual(["total", "tag"]); + }); + + it("rejects ephemeral field names that collide with consensus fields", () => { + expect( + () => + new Variant(consensusFields, { + ephemeralFields: {a: uint64NumInfType}, + }) + ).toThrow(/collides/); + }); + + it("serialize is identical to a sibling type without ephemeralFields", () => { + const consensusValue = {a: 1, b: 2}; + const valueWithEphemerals = {...consensusValue, total: 999, tag: 7}; + + const bytesWith = typeWith.serialize(valueWithEphemerals); + const bytesWithout = typeWithout.serialize(consensusValue); + expect(bytesWith).toEqual(bytesWithout); + }); + + it("hashTreeRoot ignores ephemerals", () => { + const v1 = {a: 1, b: 2, total: 100, tag: 1}; + const v2 = {a: 1, b: 2, total: 999, tag: 9}; + const v3 = {a: 1, b: 2}; + expect(typeWith.hashTreeRoot(v1)).toEqual(typeWith.hashTreeRoot(v2)); + expect(typeWith.hashTreeRoot(v1)).toEqual(typeWith.hashTreeRoot(v3)); + // and same as the consensus-only sibling + expect(typeWith.hashTreeRoot(v1)).toEqual(typeWithout.hashTreeRoot({a: 1, b: 2})); + }); + + it("equals ignores ephemerals (consensus-only)", () => { + expect(typeWith.equals({a: 1, b: 2, total: 1}, {a: 1, b: 2, total: 99})).toBe(true); + expect(typeWith.equals({a: 1, b: 2}, {a: 1, b: 3})).toBe(false); + }); + + it("clone copies present ephemerals through (using each ephemeral type's clone)", () => { + const src = {a: 1, b: 2, total: 42, tag: 9}; + const cloned = typeWith.clone(src); + expect(cloned).toEqual(src); + // Distinct object reference + expect(cloned).not.toBe(src); + + const srcNoEphemeral = {a: 1, b: 2}; + const clonedNoEphemeral = typeWith.clone(srcNoEphemeral); + expect(Object.prototype.hasOwnProperty.call(clonedNoEphemeral, "total")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(clonedNoEphemeral, "tag")).toBe(false); + }); + + it("defaultValue does not include ephemeral keys", () => { + const dv = typeWith.defaultValue(); + expect(Object.prototype.hasOwnProperty.call(dv, "total")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(dv, "tag")).toBe(false); + }); + + it("fromJson(toJson(value)) drops ephemerals", () => { + const value = {a: 1, b: 2, total: 999, tag: 5}; + const json = typeWith.toJson(value); + expect(Object.prototype.hasOwnProperty.call(json, "total")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(json, "tag")).toBe(false); + + const back = typeWith.fromJson(json); + expect(back).toEqual({a: 1, b: 2}); + }); + + it("View accepts and returns ephemerals via property accessors", () => { + const view = typeWith.toView({a: 1, b: 2, total: 77, tag: 3}); + expect(view.a).toBe(1); + expect(view.b).toBe(2); + expect(view.total).toBe(77); + expect(view.tag).toBe(3); + + view.total = 88; + expect(view.total).toBe(88); + // Setting an ephemeral does not affect the consensus root. + expect(view.hashTreeRoot()).toEqual(typeWithout.hashTreeRoot({a: 1, b: 2})); + }); + + it("ViewDU accepts and returns ephemerals via property accessors", () => { + const viewDU = typeWith.toViewDU({a: 1, b: 2, total: 77, tag: 3}); + expect(viewDU.a).toBe(1); + expect(viewDU.b).toBe(2); + expect(viewDU.total).toBe(77); + expect(viewDU.tag).toBe(3); + + viewDU.total = 88; + expect(viewDU.total).toBe(88); + expect(viewDU.hashTreeRoot()).toEqual(typeWithout.hashTreeRoot({a: 1, b: 2})); + }); + + it("getView(tree) starts with empty ephemerals (no value source)", () => { + const node = typeWith.value_toTree({a: 1, b: 2}); + const view = typeWith.getView(new Tree(node)); + expect(view.total).toBeUndefined(); + expect(view.tag).toBeUndefined(); + }); + + it("re-deserializing serialized bytes produces no ephemeral keys", () => { + const value = {a: 1, b: 2, total: 77, tag: 3}; + const bytes = typeWith.serialize(value); + const back = typeWith.deserialize(bytes); + expect(back).toEqual({a: 1, b: 2}); + expect(Object.prototype.hasOwnProperty.call(back, "total")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(back, "tag")).toBe(false); + }); + }); +} + +// Composite ephemeral field — exercise that types containing children work too. +describe("ContainerType ephemeralFields with composite ephemeral type", () => { + const totalList = new ListBasicType(uint64NumInfType, 4); + const consensusFields = {a: uint64NumInfType}; + + const containerType = new ContainerType(consensusFields, { + typeName: "ContainerWithListEphemeral", + ephemeralFields: {totalList}, + }); + + it("clone deep-copies the ephemeral list value", () => { + const original = [10, 20]; + const src = {a: 1, totalList: [...original]}; + const cloned = containerType.clone(src); + expect(cloned.totalList).toEqual(original); + expect(cloned.totalList).not.toBe(src.totalList); + }); + + it("toView/toViewDU carry over composite ephemerals as raw values", () => { + const src = {a: 1, totalList: [10, 20]}; + const view = containerType.toView(src); + expect(view.totalList).toEqual([10, 20]); + + const viewDU = containerType.toViewDU(src); + expect(viewDU.totalList).toEqual([10, 20]); + }); +}); + +// Compile-time assertions on the public type surface. +describe("ContainerType ephemeralFields type-level surface", () => { + const consensusFields = {a: uint64NumInfType, b: uint16}; + const ephemeralFields = {total: uint64NumInfType}; + + const typeWith = new ContainerType(consensusFields, {ephemeralFields}); + + it("consensus fields are required, ephemerals are optional on the value type", () => { + const v = typeWith.defaultValue(); + expectTypeOf(v.a).toEqualTypeOf(); + expectTypeOf(v.b).toEqualTypeOf(); + expectTypeOf(v.total).toEqualTypeOf(); + }); + + it("View getter returns T | undefined for ephemerals; setter accepts T", () => { + const view = typeWith.toView({a: 1, b: 2, total: 7}); + expectTypeOf(view.a).toEqualTypeOf(); + expectTypeOf(view.total).toEqualTypeOf(); + // Setter accepts T (not undefined) + view.total = 99; + }); + + it("ViewDU getter/setter shape mirrors View", () => { + const viewDU = typeWith.toViewDU({a: 1, b: 2, total: 7}); + expectTypeOf(viewDU.a).toEqualTypeOf(); + expectTypeOf(viewDU.total).toEqualTypeOf(); + viewDU.total = 88; + }); + + it("a ContainerType without ephemeralFields keeps its existing value type unchanged", () => { + const plain = new ContainerType(consensusFields); + const v = plain.defaultValue(); + expectTypeOf(v.a).toEqualTypeOf(); + expectTypeOf(v.b).toEqualTypeOf(); + // No `total` key on the type + // @ts-expect-error - total is not declared + v.total; + }); +});