From 5ac9b8376b0f5e468edd779711165995e359fe36 Mon Sep 17 00:00:00 2001 From: harkamal Date: Sun, 27 Aug 2023 14:16:56 +0530 Subject: [PATCH] feat: add optional ssz type --- packages/ssz/src/index.ts | 1 + packages/ssz/src/type/optional.ts | 224 ++++++++++++++++++ .../test/unit/byType/optional/invalid.test.ts | 16 ++ .../test/unit/byType/optional/tree.test.ts | 35 +++ .../test/unit/byType/optional/valid.test.ts | 56 +++++ .../ssz/test/unit/byType/runTypeProofTest.ts | 12 +- 6 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 packages/ssz/src/type/optional.ts create mode 100644 packages/ssz/test/unit/byType/optional/invalid.test.ts create mode 100644 packages/ssz/test/unit/byType/optional/tree.test.ts create mode 100644 packages/ssz/test/unit/byType/optional/valid.test.ts diff --git a/packages/ssz/src/index.ts b/packages/ssz/src/index.ts index 4e014e45..4be1d2d5 100644 --- a/packages/ssz/src/index.ts +++ b/packages/ssz/src/index.ts @@ -11,6 +11,7 @@ export {ListCompositeType} from "./type/listComposite"; export {NoneType} from "./type/none"; export {UintBigintType, UintNumberType} from "./type/uint"; export {UnionType} from "./type/union"; +export {OptionalType} from "./type/optional"; export {VectorBasicType} from "./type/vectorBasic"; export {VectorCompositeType} from "./type/vectorComposite"; diff --git a/packages/ssz/src/type/optional.ts b/packages/ssz/src/type/optional.ts new file mode 100644 index 00000000..29b299f3 --- /dev/null +++ b/packages/ssz/src/type/optional.ts @@ -0,0 +1,224 @@ +import {concatGindices, getNode, Gindex, Node, Tree, zeroNode} from "@chainsafe/persistent-merkle-tree"; +import {mixInLength} from "../util/merkleize"; +import {Require} from "../util/types"; +import {namedClass} from "../util/named"; +import {Type, ByteViews, JsonPath} from "./abstract"; +import {CompositeType, isCompositeType} from "./composite"; +import {addLengthNode, getLengthFromRootNode} from "./arrayBasic"; +/* eslint-disable @typescript-eslint/member-ordering */ + +export type OptionalOpts = { + typeName?: string; +}; +type ValueOfType> = ElementType extends Type ? T | null : never; +const VALUE_GINDEX = BigInt(2); +const SELECTOR_GINDEX = BigInt(3); + +/** + * Optional: optional type containing either None or a type + * - Notation: Optional[type], e.g. optional[uint64] + * - merklizes as list of length 0 or 1, essentially acts like + * - like Union[none,type] or + * - list [], [type] + */ +export class OptionalType> extends CompositeType< + ValueOfType, + ValueOfType, + ValueOfType +> { + readonly typeName: string; + readonly depth = 1; + readonly maxChunkCount = 1; + readonly fixedSize = null; + readonly minSize: number; + readonly maxSize: number; + readonly isList = true; + readonly isViewMutable = true; + + constructor(readonly elementType: ElementType, opts?: OptionalOpts) { + super(); + + this.typeName = opts?.typeName ?? `Optional[${elementType.typeName}]`; + + this.minSize = 0; + this.maxSize = elementType.maxSize; + } + + static named>( + elementType: ElementType, + opts: Require + ): OptionalType { + return new (namedClass(OptionalType, opts.typeName))(elementType, opts); + } + + defaultValue(): ValueOfType { + return null as ValueOfType; + } + + getView(tree: Tree): ValueOfType { + return this.tree_toValue(tree.rootNode); + } + + getViewDU(node: Node): ValueOfType { + return this.tree_toValue(node); + } + + cacheOfViewDU(): unknown { + return; + } + + commitView(view: ValueOfType): Node { + return this.value_toTree(view); + } + + commitViewDU(view: ValueOfType): Node { + return this.value_toTree(view); + } + + value_serializedSize(value: ValueOfType): number { + return value !== null ? 1 + this.elementType.value_serializedSize(value) : 0; + } + + value_serializeToBytes(output: ByteViews, offset: number, value: ValueOfType): number { + if (value !== null) { + output.uint8Array[offset] = 1; + return this.elementType.value_serializeToBytes(output, offset + 1, value); + } else { + return offset; + } + } + + value_deserializeFromBytes(data: ByteViews, start: number, end: number): ValueOfType { + if (start === end) { + return null as ValueOfType; + } else { + const selector = data.uint8Array[start]; + if (selector !== 1) { + throw Error(`Invalid selector=${selector} for Optional type`); + } + return this.elementType.value_deserializeFromBytes(data, start + 1, end) as ValueOfType; + } + } + + tree_serializedSize(node: Node): number { + const length = getLengthFromRootNode(node); + return length === 1 ? 1 + this.elementType.value_serializedSize(node.left) : 0; + } + + tree_serializeToBytes(output: ByteViews, offset: number, node: Node): number { + const selector = getLengthFromRootNode(node); + + const valueNode = node.left; + if (selector === 0) { + return offset; + } else { + output.uint8Array[offset] = 1; + } + return this.elementType.tree_serializeToBytes(output, offset + 1, valueNode); + } + + tree_deserializeFromBytes(data: ByteViews, start: number, end: number): Node { + let valueNode; + let selector; + if (start === end) { + selector = 0; + valueNode = zeroNode(0); + } else { + selector = data.uint8Array[start]; + if (selector !== 1) { + throw Error(`Invalid selector=${selector} for Optional type`); + } + valueNode = this.elementType.tree_deserializeFromBytes(data, start + 1, end); + } + + return addLengthNode(valueNode, selector); + } + + // Merkleization + + hashTreeRoot(value: ValueOfType): Uint8Array { + const selector = value === null ? 0 : 1; + return mixInLength(super.hashTreeRoot(value), selector); + } + + protected getRoots(value: ValueOfType): Uint8Array[] { + const valueRoot = value ? this.elementType.hashTreeRoot(value) : new Uint8Array(32); + return [valueRoot]; + } + + // Proofs + + getPropertyGindex(prop: string): bigint { + if (isCompositeType(this.elementType)) { + const propIndex = this.elementType.getPropertyGindex(prop); + if (propIndex === null) { + throw Error(`index not found for property=${prop}`); + } + return concatGindices([VALUE_GINDEX, propIndex]); + } else { + throw new Error("not applicable for Optional basic type"); + } + } + + getPropertyType(): Type { + return this.elementType; + } + + getIndexProperty(index: number): string | number | null { + if (isCompositeType(this.elementType)) { + return this.elementType.getIndexProperty(index); + } else { + throw Error("not applicable for Optional basic type"); + } + } + + tree_createProofGindexes(node: Node, jsonPaths: JsonPath[]): Gindex[] { + if (isCompositeType(this.elementType)) { + const valueNode = node.left; + const gindices = this.elementType.tree_createProofGindexes(valueNode, jsonPaths); + return gindices.map((gindex) => concatGindices([VALUE_GINDEX, gindex])); + } else { + throw Error("not applicable for Optional basic type"); + } + } + + tree_getLeafGindices(rootGindex: bigint, rootNode?: Node): bigint[] { + if (!rootNode) { + throw Error("rootNode required"); + } + + const gindices: Gindex[] = [concatGindices([rootGindex, SELECTOR_GINDEX])]; + const selector = getLengthFromRootNode(rootNode); + const extendedFieldGindex = concatGindices([rootGindex, VALUE_GINDEX]); + if (selector !== 0 && isCompositeType(this.elementType)) { + gindices.push(...this.elementType.tree_getLeafGindices(extendedFieldGindex, getNode(rootNode, VALUE_GINDEX))); + } else { + gindices.push(extendedFieldGindex); + } + return gindices; + } + + // JSON + + fromJson(json: unknown): ValueOfType { + return (json === null ? null : this.elementType.fromJson(json)) as ValueOfType; + } + + toJson(value: ValueOfType): unknown | Record { + return value === null ? null : this.elementType.toJson(value); + } + + clone(value: ValueOfType): ValueOfType { + return (value === null ? null : this.elementType.clone(value)) as ValueOfType; + } + + equals(a: ValueOfType, b: ValueOfType): boolean { + if (a === null && b === null) { + return true; + } else if (a === null || b === null) { + return false; + } + + return this.elementType.equals(a, b); + } +} diff --git a/packages/ssz/test/unit/byType/optional/invalid.test.ts b/packages/ssz/test/unit/byType/optional/invalid.test.ts new file mode 100644 index 00000000..ca5c68b3 --- /dev/null +++ b/packages/ssz/test/unit/byType/optional/invalid.test.ts @@ -0,0 +1,16 @@ +import {expect} from "chai"; +import {UintNumberType, OptionalType} from "../../../../src"; +import {runTypeTestInvalid} from "../runTypeTestInvalid"; + +const byteType = new UintNumberType(1); + +runTypeTestInvalid({ + type: new OptionalType(byteType), + values: [ + {id: "Bad selector", serialized: "0x02ff"}, + + {id: "Array", json: []}, + {id: "incorrect value", json: {}}, + {id: "Object stringified", json: JSON.stringify({})}, + ], +}); diff --git a/packages/ssz/test/unit/byType/optional/tree.test.ts b/packages/ssz/test/unit/byType/optional/tree.test.ts new file mode 100644 index 00000000..ff4b91e0 --- /dev/null +++ b/packages/ssz/test/unit/byType/optional/tree.test.ts @@ -0,0 +1,35 @@ +import {expect} from "chai"; +import {OptionalType, ContainerType, UintNumberType, NoneType, ValueOf, toHexString} from "../../../../src"; + +const byteType = new UintNumberType(1); +const SimpleObject = new ContainerType({ + b: byteType, + a: byteType, +}); + +describe("Optional view tests", () => { + // Not using runViewTestMutation because the View of optional simple is a value + it("optional simple type", () => { + const type = new OptionalType(byteType); + const value: ValueOf = 9; + const root = type.hashTreeRoot(value); + + const view = type.toView(value); + const viewDU = type.toViewDU(value); + + expect(toHexString(type.commitView(view).root)).equals(toHexString(root)); + expect(toHexString(type.commitViewDU(viewDU).root)).equals(toHexString(root)); + }); + + it("optional composite type", () => { + const type = new OptionalType(SimpleObject); + const value: ValueOf = {a:9,b:11}; + const root = type.hashTreeRoot(value); + + const view = type.toView(value); + const viewDU = type.toViewDU(value); + + expect(toHexString(type.commitView(view).root)).equals(toHexString(root)); + expect(toHexString(type.commitViewDU(viewDU).root)).equals(toHexString(root)); + }); +}); diff --git a/packages/ssz/test/unit/byType/optional/valid.test.ts b/packages/ssz/test/unit/byType/optional/valid.test.ts new file mode 100644 index 00000000..698a6e84 --- /dev/null +++ b/packages/ssz/test/unit/byType/optional/valid.test.ts @@ -0,0 +1,56 @@ +import {OptionalType, UintNumberType, ListBasicType, ContainerType, ListCompositeType} from "../../../../src"; +import {runTypeTestValid} from "../runTypeTestValid"; + +const number8Type = new UintNumberType(1); +const SimpleObject = new ContainerType({ + b: number8Type, + a: number8Type, +}); + +// test for a basic type +runTypeTestValid({ + type: new OptionalType(number8Type), + defaultValue: null, + values: [ + {serialized: "0x", json: null, root: "0xf5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"}, + {serialized: "0x0109", json: 9, root: "0xc17ba48dfddbdec0cbfbf24c1aef5ebac372f63b9dad08e99224d0c9a9f22f72"}, + ], +}); + +// null should merklize same as empty list or list with 1 value but serializes without optional prefix 0x01 +runTypeTestValid({ + type: new ListBasicType(number8Type, 1), + defaultValue: [], + values: [ + {serialized: "0x", json: [], root: "0xf5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"}, + {serialized: "0x09", json: [9], root: "0xc17ba48dfddbdec0cbfbf24c1aef5ebac372f63b9dad08e99224d0c9a9f22f72"}, + ], +}); + +// test for a composite type +runTypeTestValid({ + type: new OptionalType(SimpleObject), + defaultValue: null, + values: [ + {serialized: "0x", json: null, root: "0xf5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"}, + { + serialized: "0x010b09", + json: {a: 9, b: 11}, + root: "0xb4fc36ed412e6f56e3002b2f56559c55420e843e182168ed087669bd3e5338a7", + }, + ], +}); + +// null should merklize same as empty list or list with 1 value but serializes without optional prefix 0x01 +runTypeTestValid({ + type: new ListCompositeType(SimpleObject, 1), + defaultValue: [], + values: [ + {serialized: "0x", json: [], root: "0xf5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"}, + { + serialized: "0x0b09", + json: [{a: 9, b: 11}], + root: "0xb4fc36ed412e6f56e3002b2f56559c55420e843e182168ed087669bd3e5338a7", + }, + ], +}); diff --git a/packages/ssz/test/unit/byType/runTypeProofTest.ts b/packages/ssz/test/unit/byType/runTypeProofTest.ts index 658c662e..e9b2b6c8 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, Type} from "../../../src"; +import {BitArray, ContainerType, fromHexString, JsonPath, Type, OptionalType} from "../../../src"; import {CompositeTypeAny, isCompositeType} from "../../../src/type/composite"; import {ArrayBasicTreeView} from "../../../src/view/arrayBasic"; import {RootHex} from "../../lodestarTypes"; @@ -88,6 +88,9 @@ function getJsonPathsFromValue(value: unknown, parentPath: JsonPath = [], jsonPa * Returns the end type of a JSON path */ function getJsonPathType(type: CompositeTypeAny, jsonPath: JsonPath): Type { + if (type instanceof OptionalType) { + type = type.getPropertyType() as CompositeTypeAny; + } for (const jsonProp of jsonPath) { type = type.getPropertyType(jsonProp) as CompositeTypeAny; } @@ -104,6 +107,9 @@ 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 OptionalType) { + type = type.getPropertyType(); + } if (type instanceof ContainerType) { // Coerce jsonProp to a fieldName. JSON paths may be in JSON notation or fieldName notation const fieldName = type["jsonKeyToFieldName"][jsonProp] ?? jsonProp; @@ -131,6 +137,10 @@ 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 OptionalType) { + type = type.getPropertyType(); + } + if (type instanceof ContainerType) { if (type["jsonKeyToFieldName"][jsonProp] === undefined) { throw Error(`Unknown jsonProp ${jsonProp} for type ${type.typeName}`);