diff --git a/packages/dynamic-codecs/README.md b/packages/dynamic-codecs/README.md index e0ac33cfa..8cf7e4414 100644 --- a/packages/dynamic-codecs/README.md +++ b/packages/dynamic-codecs/README.md @@ -31,6 +31,45 @@ const counter = codec.decode(bytes); // ^ { authority: '9BbWp6tcX9MEGSUEpNXfspYxYsWCxE9FgRkAc3RpftkT', count: 42n } ``` +## Decoded nodes + +`getNodeCodec` returns a codec that decodes into a `DecodedNode` rather than a plain value. A decoded node keeps the `path` of the node that decoded it, from the root, its `value` in the format of `getNodeValueCodec`, the absolute cursor positions before and after reading it (`preOffset` and `postOffset`, transforms included, in the vocabulary of Kit's offset codecs) and the decoded nodes of its children, named after the attributes of their node, e.g. the `fields` of a struct. It encodes from `{ value }`. + +```ts +import { assertIsDecodedNode, getNodeCodec } from '@codama/dynamic-codecs'; + +// counter = struct { authority: publicKey, count: u64 } +const decoded = getNodeCodec([root, program, counterAccount]).decode(bytes); +decoded.value; // { authority: '9BbW…ftkT', count: 42n } +assertIsDecodedNode(decoded.data, 'structTypeNode'); +decoded.data.fields[1].type; // { path: [root, program, counterAccount, struct, countField, u64], value: 42n, preOffset: 32, postOffset: 40 } +``` + +| Node | Children | +| --------------------------------------------------------------------- | ---------------------------------- | +| `AccountNode`, `EventNode` | `data` | +| `InstructionNode` | `data` (absent without data) | +| `DefinedTypeNode`, `StructFieldTypeNode` | `type` | +| `StructTypeNode` | `fields` | +| `EnumTypeNode` | `variant`, the decoded variant | +| `EnumVariantTypeNode` | `data` (absent without data) | +| `OptionTypeNode`, `RemainderOptionTypeNode`, `ZeroableOptionTypeNode` | `item` (absent when `None`) | +| `ArrayTypeNode`, `SetTypeNode`, `TupleTypeNode` | `items` | +| `MapTypeNode` | `entries`, as `[key, value]` pairs | + +Other type nodes, such as integers, fixed points or strings, have no children: their node, available via `getLastNodeFromPath(decoded.path)`, tells how to read their value. + +Links are transparent: they decode as the node they link to, so the path of a linked type goes through its definition, e.g. `[root, program, definedType, enumTypeNode]`. The transforms of a link, e.g. a size prefix, are included in the cursor positions of the type it decodes as, although that type's own node does not carry them. Recursive types decode at any depth. + +Each node kind has its own decoded node type, e.g. `DecodedStructTypeNode` or `DecodedIntegerTypeNode`, and `DecodedNode` gives the one of a node type. Use `isDecodedNode` or `assertIsDecodedNode` to narrow a decoded node to a node kind, e.g. to access its children. + +```ts +const amount = decoded.data; // DecodedTypeNode +if (isDecodedNode(amount, 'structTypeNode')) { + amount.fields; // DecodedStructFieldTypeNode[] +} +``` + ## Node paths The full path is needed to resolve link nodes, which may point to other programs, and injected values, which are provided by the enclosing instructions. @@ -200,9 +239,17 @@ A link back to its own type always has a variable size, so it cannot be the item ## Visitors +### `getNodeCodecVisitor(linkables, options?)` + +The visitor used by `getNodeCodec` under the hood. It returns a codec of decoded nodes for the visited node. On top of the `bytesEncoding` option, it accepts the `stack` of the visited node's ancestors, used to resolve links, and the `scope` of the enclosing instructions' provided values, used to resolve injected values. + +```ts +const codec = visit(someTypeNode, getNodeCodecVisitor(linkables, { scope, stack })); +``` + ### `getNodeValueCodecVisitor(linkables, options?)` -The visitor used by `getNodeValueCodec` under the hood. It returns a `Codec` for the visited node. On top of the `bytesEncoding` option, it accepts the `stack` of the visited node's ancestors, used to resolve links, and the `scope` of the enclosing instructions' provided values, used to resolve injected values. +The visitor used by `getNodeValueCodec` under the hood. It accepts the same options and returns a `Codec` for the visited node, whose values are the values of the decoded nodes of `getNodeCodecVisitor`. ```ts const codec = visit(someTypeNode, getNodeValueCodecVisitor(linkables, { scope, stack })); diff --git a/packages/dynamic-codecs/src/codecs.ts b/packages/dynamic-codecs/src/codecs.ts index 8a475c7b9..8844fd3dc 100644 --- a/packages/dynamic-codecs/src/codecs.ts +++ b/packages/dynamic-codecs/src/codecs.ts @@ -31,6 +31,7 @@ import { hasDefinedTypeFiniteValue, interceptVisitor, LinkableDictionary, + mapVisitor, NodePath, NodeStack, pipe, @@ -77,14 +78,44 @@ import { getUnionCodec, getUnitCodec, getUtf8Codec, + isFixedSize, + isOption, + isSome, + none, NumberCodec, + Option, + OptionOrNullable, offsetCodec, padLeftCodec, padRightCodec, ReadonlyUint8Array, + some, transformCodec, } from '@solana/codecs'; +import type { + DecodedAccountNode, + DecodedNodeCodec, + DecodedArrayTypeNode, + DecodedDefinedTypeNode, + DecodedEnumTypeNode, + DecodedEnumVariantTypeNode, + DecodedEventNode, + DecodedInstructionNode, + DecodedMapTypeNode, + DecodedNode, + DecodedNodeInput, + DecodedOptionTypeNode, + DecodedRemainderOptionTypeNode, + DecodedSetTypeNode, + DecodedStructFieldTypeNode, + DecodedStructTypeNode, + DecodedTupleTypeNode, + DecodedTypeNode, + DecodedZeroableOptionTypeNode, + EnumVariantValue, + GetDecodedNode, +} from './decoded'; import { getLazyCodec } from './lazy'; import { assertUniqueItems, @@ -106,14 +137,16 @@ export type EncodableNodes = | InstructionNode | RegisteredTypeNode; -/** Options shared by {@link getNodeValueCodec} and {@link getNodeValueCodecVisitor}. */ +/** Options shared by {@link getNodeCodec}, {@link getNodeValueCodec} and their visitors. */ export type CodecVisitorOptions = { /** The encoding used to decode plain bytes, e.g. `["base64", "SGVsbG8="]`. Defaults to `base64`. */ bytesEncoding?: BytesEncoding; }; -/** The decoded value of an enum variant, e.g. `{ __kind: 'move', __discriminator: 2, data: { x: 1n } }`. */ -type EnumVariantValue = { __discriminator?: number; __kind: string; data?: unknown }; +/** A decoded node before its cursor positions are known, which the outermost layer of its codec adds. */ +type Unranged = TDecoded extends unknown + ? Omit + : never; /** Bytes as `[encoding, data]` tuples, e.g. `["base16", "0102"]`, or as raw bytes when encoding. */ type BytesValue = ReadonlyUint8Array | Uint8Array | [BytesEncoding, string]; @@ -142,26 +175,28 @@ function isInteger(value: unknown): value is bigint | number { } /** - * Get a codec for the node at the end of the given path. Values are raw JavaScript - * values, e.g. integers are `bigint`s and enums are `{ __kind, __discriminator, data }`. + * Get a codec decoding the node at the end of the given path into a {@link DecodedNode}: + * the decoded value, together with the path of the node that decoded it, the cursor + * positions around it and the decoded nodes of its children, e.g. the fields of a struct. + * It encodes from + * `{ value }`, where `value` is in the format of {@link getNodeValueCodec}. * * The full path, from the root node, is needed to resolve link nodes and injected * values, e.g. `[root, program, definedType]`. * - * Encoding rejects values of the wrong type, e.g. a string for an integer, with a - * `DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE` error whose `nodePath` is the path of the - * node that rejected the value. Missing values are only accepted by options, which - * encode them as `None`, structs, whose fields then use their default values, and - * struct fields with default values. - * * @example * ```ts - * const codec = getNodeValueCodec([root, program, definedType]); - * const bytes = codec.encode({ amount: 42n }); - * const value = codec.decode(bytes); + * const codec = getNodeCodec([root, program, definedType]); + * const bytes = codec.encode({ value: { amount: 42n } }); + * const decoded = codec.decode(bytes); + * decoded.value; // { amount: 42n } + * decoded.type; // the decoded struct, with its `fields` * ``` */ -export function getNodeValueCodec(path: NodePath, options: CodecVisitorOptions = {}): Codec { +export function getNodeCodec( + path: NodePath, + options: CodecVisitorOptions = {}, +): Codec> { const linkables = new LinkableDictionary(); visit(path[0], getRecordLinkablesVisitor(linkables)); @@ -169,14 +204,40 @@ export function getNodeValueCodec(path: NodePath, options: Codec const ancestors = path.slice(0, -1); const frames = ancestors.flatMap(node => (isNode(node, 'instructionNode') ? [node.provides ?? []] : [])); - return visit( - getLastNodeFromPath(path), - getNodeValueCodecVisitor(linkables, { + const codec = visit( + getLastNodeFromPath(path) as EncodableNodes, + getNodeCodecVisitor(linkables, { ...options, scope: new ProvidedScope(...frames), stack: new NodeStack(ancestors), }), ); + return codec as Codec>; +} + +/** + * Get a codec for the node at the end of the given path. Values are raw JavaScript + * values, e.g. integers are `bigint`s and enums are `{ __kind, __discriminator, data }`. + * It is the value of the decoded nodes of {@link getNodeCodec}. + * + * The full path, from the root node, is needed to resolve link nodes and injected + * values, e.g. `[root, program, definedType]`. + * + * Encoding rejects values of the wrong type, e.g. a string for an integer, with a + * `DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE` error whose `nodePath` is the path of the + * node that rejected the value. Missing values are only accepted by options, which + * encode them as `None`, structs, whose fields then use their default values, and + * struct fields with default values. + * + * @example + * ```ts + * const codec = getNodeValueCodec([root, program, definedType]); + * const bytes = codec.encode({ amount: 42n }); + * const value = codec.decode(bytes); + * ``` + */ +export function getNodeValueCodec(path: NodePath, options: CodecVisitorOptions = {}): Codec { + return getValueCodec(getNodeCodec(path, options)); } /** @@ -189,15 +250,48 @@ export function getNodeValueCodecVisitor( linkables: LinkableDictionary, options: CodecVisitorOptions & { scope?: ProvidedScope; stack?: NodeStack } = {}, ): Visitor, EncodableNodes['kind']> { + return mapVisitor(getNodeCodecVisitor(linkables, options), getValueCodec); +} + +/** The codec of the values of the decoded nodes of the given codec. */ +function getValueCodec(codec: DecodedNodeCodec): Codec { + return transformCodec( + codec, + (value: unknown): DecodedNodeInput => ({ value }), + decoded => decoded.value, + ); +} + +/** + * A visitor that returns a codec of decoded nodes for the visited node, as described + * by {@link getNodeCodec}. + * + * The `stack` must hold the ancestors of the visited node to resolve link nodes, and the + * `scope` must hold the `provides` of its enclosing instructions to resolve injected values. + */ +export function getNodeCodecVisitor( + linkables: LinkableDictionary, + options: CodecVisitorOptions & { scope?: ProvidedScope; stack?: NodeStack } = {}, +): Visitor { const stack = options.stack ?? new NodeStack(); const scope = options.scope ?? new ProvidedScope(); const bytesEncoding = options.bytesEncoding ?? 'base64'; - const valueVisitor = getValueNodeVisitor(linkables, { codecVisitorFactory: () => visitor, scope, stack }); - const getConstantBytes = (node: ConstantValueNode) => getConstantValueBytes(node, visitor, valueVisitor); + + // Constants, sizes and prefixes are encoded with the value codecs of their types. + let valueCodecVisitor: Visitor, EncodableNodes['kind']> | undefined; + const getValueCodecVisitor = () => (valueCodecVisitor ??= mapVisitor(visitor, getValueCodec)); + const visitValueCodec = (node: EncodableNodes): Codec => visit(node, getValueCodecVisitor()); + const valueNodeVisitor = getValueNodeVisitor(linkables, { + codecVisitorFactory: getValueCodecVisitor, + scope, + stack, + }); + const getConstantBytes = (node: ConstantValueNode) => + getConstantValueBytes(node, getValueCodecVisitor(), valueNodeVisitor); // Default values are only evaluated when needed, e.g. an injected default only throws if // the input does not provide the field. Since they are evaluated when encoding, by which - // point the live `stack` and `scope` have unwound, they cannot reuse `valueVisitor`: they + // point the live `stack` and `scope` have unwound, they cannot reuse `valueNodeVisitor`: they // get their own value visitor, and thus codec visitor for constants, bound to clones of // the `stack` and `scope` taken when the codec is created, so links and injections resolve // from where the default value is defined. @@ -213,10 +307,10 @@ export function getNodeValueCodecVisitor( // The codecs of the defined types being created, so a link back to one of them gets a lazy // codec deferring to it rather than creating it again forever, e.g. for linked lists. - const definedTypesInProgress = new Map }>(); + const definedTypesInProgress = new Map(); // The recursive types known to have a finite value, so each is only checked once. const finiteDefinedTypes = new Set(); - const getRecursiveCodec = (path: NodePath, inProgress: { codec?: Codec }) => { + const getRecursiveCodec = (path: NodePath, inProgress: { codec?: DecodedNodeCodec }) => { // A type without a finite value would make its lazy codec recurse forever. const definedType = getLastNodeFromPath(path); if (!finiteDefinedTypes.has(definedType)) { @@ -238,6 +332,52 @@ export function getNodeValueCodecVisitor( }); }; + // The absolute offsets of the first bytes being read, one per nested read of bytes that a + // transform sliced, e.g. a size prefix reads its data from a copy of the bytes it prefixes. + const baseOffsets: number[] = [0]; + const getBaseOffset = () => baseOffsets[baseOffsets.length - 1]; + + // The outermost layer of every codec records the absolute cursor positions around its node, + // completing the decoded node its inner layers created without them. + const withRange = (codec: DecodedNodeCodec): DecodedNodeCodec => + mapCodecRead(codec, read => (bytes, offset) => { + const [decoded, newOffset] = read(bytes, offset); + const baseOffset = getBaseOffset(); + return [{ ...decoded, postOffset: baseOffset + newOffset, preOffset: baseOffset + offset }, newOffset]; + }); + + // Codecs decoding nodes without their cursor positions, which `withRange` adds on top of them. + const toDecodedNodeCodec = (codec: Codec>): DecodedNodeCodec => + codec as DecodedNodeCodec; + + // Transforms that read their inner codec from a copy of some of the bytes they read keep + // track of where that copy starts, so the cursor positions of inner nodes remain absolute. + const withSlicedBytes = ( + codec: DecodedNodeCodec, + transform: (codec: DecodedNodeCodec) => DecodedNodeCodec, + getSliceStart: (bytes: ReadonlyUint8Array | Uint8Array, offset: number) => number, + ): DecodedNodeCodec => { + const reads: { baseOffset: number; bytes: ReadonlyUint8Array | Uint8Array; offset: number }[] = []; + const inner = mapCodecRead(codec, read => (bytes, offset) => { + const outer = reads[reads.length - 1]; + const sliced = bytes !== outer.bytes; + baseOffsets.push(sliced ? outer.baseOffset + getSliceStart(outer.bytes, outer.offset) : outer.baseOffset); + try { + return read(bytes, offset); + } finally { + baseOffsets.pop(); + } + }); + return mapCodecRead(transform(inner), read => (bytes, offset) => { + reads.push({ baseOffset: getBaseOffset(), bytes, offset }); + try { + return read(bytes, offset); + } finally { + reads.pop(); + } + }); + }; + const visitLinkedNode = ( node: TLinkNode, ) => { @@ -245,22 +385,84 @@ export function getNodeValueCodecVisitor( return stack.visitPath(path, visitor); }; - // Arrays and sets are encoded from arrays. - const getArrayLikeCodec = (item: Codec, count: CountNode): Codec => - assertValueType(getCollectionCodec(item, count) as Codec, stack.getPath(), 'array', value => - Array.isArray(value), + // Leaves decode as their value only, whose type their own codec ensures. A missing input + // is a missing value, e.g. the items missing from a tuple, which the tuple codec reads by index. + const getLeafCodec = (codec: Codec): DecodedNodeCodec => { + const path = stack.getPath(); + return toDecodedNodeCodec( + transformCodec( + codec, + (input: DecodedNodeInput | undefined) => input?.value, + value => ({ path, value }) as Unranged, + ), + ); + }; + + // Composite nodes decode as their value and the decoded nodes of their children. Each + // caller builds its decoded node, checked against its type with `satisfies`. + const getCompositeCodec = ( + codec: Codec, + toChildren: (value: unknown) => TFrom, + fromChildren: (children: TTo) => Unranged, + ): DecodedNodeCodec => + toDecodedNodeCodec( + transformCodec(codec, (input: DecodedNodeInput | undefined) => toChildren(input?.value), fromChildren), ); - // Options encode missing values as `None`, like `null`. - const encodeMissingAsNone = (codec: Codec): Codec => - transformCodec(codec, value => (value === undefined ? null : value)); + // Arrays and sets are encoded from arrays, and build their decoded node from their items. + const getArrayLikeCodec = ( + item: DecodedNodeCodec, + count: CountNode, + fromItems: (items: DecodedTypeNode[], value: unknown[]) => Unranged, + assertItems?: (items: readonly unknown[]) => void, + ): DecodedNodeCodec => { + const nodePath = stack.getPath(); + return getCompositeCodec( + getCollectionCodec(item, count), + value => { + if (!Array.isArray(value)) throw getUnexpectedValueTypeError(nodePath, 'array', value); + assertItems?.(value); + return value.map((itemValue: unknown) => ({ value: itemValue })); + }, + decodedItems => { + const items = decodedItems as DecodedTypeNode[]; + return fromItems( + items, + items.map(decoded => decoded.value), + ); + }, + ); + }; + + // Options encode missing values as `None`, like `null`, and build their decoded node from + // their item, if any. + const getOptionLikeCodec = ( + optionCodec: Codec, Option>, + fromItem: (item: DecodedTypeNode | undefined, value: Option) => Unranged, + ): DecodedNodeCodec => + getCompositeCodec( + optionCodec, + value => { + if (value === undefined || value === null) return none(); + if (isOption(value)) return isSome(value) ? some({ value: value.value }) : none(); + return some({ value }); + }, + option => { + if (!isSome(option)) return fromItem(undefined, none()); + const item = option.value as DecodedTypeNode; + return fromItem(item, some(item.value)); + }, + ); - const getCollectionCodec = (item: Codec, count: CountNode): Codec => { + const getCollectionCodec = ( + item: Codec, + count: CountNode, + ): Codec => { switch (count.kind) { case 'fixedCountNode': return getArrayCodec(item, { size: count.value }); case 'prefixedCountNode': - return getArrayCodec(item, { size: visit(count.prefix, visitor) as NumberCodec }); + return getArrayCodec(item, { size: visitValueCodec(count.prefix) as NumberCodec }); case 'remainderCountNode': return getArrayCodec(item, { size: 'remainder' }); case 'sentinelCountNode': { @@ -270,14 +472,30 @@ export function getNodeValueCodecVisitor( } }; - const applyTransform = (codec: Codec, transform: TransformNode): Codec => { + const applyTransform = (codec: DecodedNodeCodec, transform: TransformNode): DecodedNodeCodec => { switch (transform.kind) { case 'fixedSizeTransformNode': - return fixCodecSize(codec, transform.size); - case 'sizePrefixTransformNode': - return addCodecSizePrefix(codec, visit(transform.prefix, visitor) as NumberCodec); - case 'sentinelTransformNode': - return addCodecSentinel(codec, getConstantBytes(transform.sentinel)); + return withSlicedBytes( + codec, + inner => fixCodecSize(inner, transform.size), + (_, offset) => offset, + ); + case 'sizePrefixTransformNode': { + const prefix = visitValueCodec(transform.prefix) as NumberCodec; + return withSlicedBytes( + codec, + inner => addCodecSizePrefix(inner, prefix), + (bytes, offset) => prefix.read(bytes, offset)[1], + ); + } + case 'sentinelTransformNode': { + const sentinel = getConstantBytes(transform.sentinel); + return withSlicedBytes( + codec, + inner => addCodecSentinel(inner, sentinel), + (_, offset) => offset, + ); + } case 'hiddenPrefixTransformNode': return getHiddenPrefixCodec( codec, @@ -322,20 +540,35 @@ export function getNodeValueCodecVisitor( return codec; }; - const baseVisitor: Visitor, EncodableNodes['kind']> = { + const baseVisitor: Visitor = { visitAccount(node) { - return visit(node.data, this); + const path = stack.getPath('accountNode'); + return getCompositeCodec( + visit(node.data, this), + value => ({ value }), + decoded => { + const data = decoded as DecodedTypeNode; + return { data, path, value: data.value } satisfies Unranged; + }, + ); }, visitAccountLink(node) { return visitLinkedNode(node); }, visitArrayType(node) { - return getArrayLikeCodec(visit(node.item, this), node.count); + const path = stack.getPath('arrayTypeNode'); + return getArrayLikeCodec( + visit(node.item, this), + node.count, + (items, value) => ({ items, path, value }) satisfies Unranged, + ); }, visitBooleanType(node) { - const size = visit(node.size, this) as FixedSizeNumberCodec; + const size = visitValueCodec(node.size) as FixedSizeNumberCodec; const codec = getBooleanCodec({ size }) as Codec; - return assertValueType(codec, stack.getPath(), 'boolean', value => typeof value === 'boolean'); + return getLeafCodec( + assertValueType(codec, stack.getPath(), 'boolean', value => typeof value === 'boolean'), + ); }, visitBytesType() { // Bytes decode as `[encoding, data]` tuples, e.g. `["base64", "SGVsbG8="]`, rather @@ -359,17 +592,28 @@ export function getNodeValueCodecVisitor( return getCodecFromBytesEncoding(encoding).write(data, bytes, offset); }, }) as Codec; - return assertValueType(codec, stack.getPath(), 'Uint8Array | [BytesEncoding, string]', isBytesValue); + return getLeafCodec( + assertValueType(codec, stack.getPath(), 'Uint8Array | [BytesEncoding, string]', isBytesValue), + ); }, visitDateTimeType(node) { - return visit(node.number, this); + return getLeafCodec(visitValueCodec(node.number)); }, visitDefinedType(node) { - const inProgress: { codec?: Codec } = {}; + const path = stack.getPath('definedTypeNode'); + const inProgress: { codec?: DecodedNodeCodec } = {}; definedTypesInProgress.set(node, inProgress); try { - const codec = visit(node.type, this); - inProgress.codec = codec; + const codec = getCompositeCodec( + visit(node.type, this), + value => ({ value }), + decoded => { + const type = decoded as DecodedTypeNode; + return { path, type, value: type.value } satisfies Unranged; + }, + ); + // Links back to this type read it through `inProgress`, so it must have its cursor positions. + inProgress.codec = withRange(codec); return codec; } finally { definedTypesInProgress.delete(node); @@ -378,25 +622,31 @@ export function getNodeValueCodecVisitor( visitDefinedTypeLink(node) { const path = linkables.getPathOrThrow(stack.getPath(node.kind)); const inProgress = definedTypesInProgress.get(getLastNodeFromPath(path)); - if (inProgress) return getRecursiveCodec(path, inProgress); - return stack.visitPath(path, visitor); + const definedTypeCodec = inProgress ? getRecursiveCodec(path, inProgress) : stack.visitPath(path, visitor); + // Links are transparent: they decode as the type of their defined type. + return transformCodec( + definedTypeCodec, + (input: DecodedNodeInput) => input, + decoded => (decoded as DecodedDefinedTypeNode).type, + ); }, visitDurationType(node) { - return visit(node.number, this); + return getLeafCodec(visitValueCodec(node.number)); }, visitEnumType(node) { - const size = visit(node.size, this) as NumberCodec; + const size = visitValueCodec(node.size) as NumberCodec; const variants = node.variants ?? []; // Each variant codec encodes its own discriminator prefix, see `visitEnumVariantType`. const discriminators = variants.map((variant, index) => variant.discriminator ?? index); - const nodePath = stack.getPath(); + const nodePath = stack.getPath('enumTypeNode'); const union = getUnionCodec( - variants.map(variant => visit(variant, this) as Codec), - (value: EnumVariantValue) => { - const index = variants.findIndex(variant => variant.identifier === value.__kind); + variants.map(variant => visit(variant, this)), + (input: DecodedNodeInput) => { + const { __kind } = input.value as EnumVariantValue; + const index = variants.findIndex(variant => variant.identifier === __kind); if (index < 0) { throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, { - actualType: `variant '${String(value.__kind)}'`, + actualType: `variant '${String(__kind)}'`, expectedType: `one of [${variants.map(variant => variant.identifier).join(', ')}]`, nodeKind: 'enumTypeNode', nodePath, @@ -406,53 +656,69 @@ export function getNodeValueCodecVisitor( }, (bytes, offset) => discriminators.indexOf(Number(size.read(bytes, offset)[0])), ); - // Variants without data may also be encoded from their identifier, e.g. `'frozen'`. - return transformCodec(union, (value: unknown): EnumVariantValue => { - if (typeof value === 'string') return { __kind: value }; - if (isObjectRecord(value) && typeof value.__kind === 'string') return value as EnumVariantValue; - throw getUnexpectedValueTypeError(nodePath, 'string | { __kind: string }', value); - }) as Codec; + return getCompositeCodec( + union, + // Variants without data may also be encoded from their identifier, e.g. `'frozen'`. + value => { + if (typeof value === 'string') return { value: { __kind: value } }; + if (isObjectRecord(value) && typeof value.__kind === 'string') return { value }; + throw getUnexpectedValueTypeError(nodePath, 'string | { __kind: string }', value); + }, + decoded => { + const variant = decoded as DecodedEnumVariantTypeNode; + return { path: nodePath, value: variant.value, variant } satisfies Unranged; + }, + ); }, visitEnumVariantType(node) { const __kind = node.identifier; - const nodePath = stack.getPath(); - const payload = node.data ? visit(node.data, this) : (getUnitCodec() as Codec); - const codec = transformCodec( - payload, - (value: EnumVariantValue) => { - // Within an enum, the enum already ensures the value is an object of this variant. - if (!isObjectRecord(value) || value.__kind !== __kind) { - const expectedType = - node.data === undefined ? `{ __kind: '${__kind}' }` : `{ __kind: '${__kind}', data }`; - const actualType = isObjectRecord(value) - ? `variant '${String(value.__kind)}'` - : formatValueType(value); - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, { - actualType, - expectedType, - nodeKind: 'enumVariantTypeNode', - nodePath, - }); - } - if (node.data === undefined) return undefined; - if (value.data === undefined) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, { - actualType: `variant '${__kind}' without data`, - expectedType: `{ __kind: '${__kind}', data }`, - nodeKind: 'enumVariantTypeNode', - nodePath, - }); - } - return value.data; - }, - (data): EnumVariantValue => (node.data === undefined ? { __kind } : { __kind, data }), - ); + const nodePath = stack.getPath('enumVariantTypeNode'); + const getData = (value: unknown): unknown => { + // Within an enum, the enum already ensures the value is an object of this variant. + if (!isObjectRecord(value) || value.__kind !== __kind) { + const expectedType = + node.data === undefined ? `{ __kind: '${__kind}' }` : `{ __kind: '${__kind}', data }`; + const actualType = isObjectRecord(value) + ? `variant '${String(value.__kind)}'` + : formatValueType(value); + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, { + actualType, + expectedType, + nodeKind: 'enumVariantTypeNode', + nodePath, + }); + } + if (node.data === undefined) return undefined; + if (value.data === undefined) { + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, { + actualType: `variant '${__kind}' without data`, + expectedType: `{ __kind: '${__kind}', data }`, + nodeKind: 'enumVariantTypeNode', + nodePath, + }); + } + return value.data; + }; + const codec: DecodedNodeCodec = node.data + ? getCompositeCodec( + visit(node.data, this), + value => ({ value: getData(value) }), + decoded => { + const data = decoded as DecodedTypeNode; + const value = { __kind, data: data.value }; + return { data, path: nodePath, value } satisfies Unranged; + }, + ) + : getCompositeCodec( + getUnitCodec(), + getData, + () => ({ path: nodePath, value: { __kind } }) satisfies Unranged, + ); // Within an enum, i.e. when the parent enum is on the stack, the variant also // encodes its discriminator as a prefix and decodes it as `__discriminator`. - const path = stack.getPath(); - const parent = path[path.length - 2]; - if (!isNode(parent, 'enumTypeNode')) return codec as Codec; + const parent = nodePath[nodePath.length - 2]; + if (!isNode(parent, 'enumTypeNode')) return codec; const index = (parent.variants ?? []).findIndex(variant => variant.identifier === node.identifier); if (index < 0 && node.discriminator === undefined) { throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, { @@ -461,121 +727,200 @@ export function getNodeValueCodecVisitor( } // An omitted discriminator is the variant's position, not the previous discriminator plus one. const __discriminator = node.discriminator ?? index; - const prefix = getConstantCodec((visit(parent.size, this) as NumberCodec).encode(__discriminator)); + const prefix = getConstantCodec((visitValueCodec(parent.size) as NumberCodec).encode(__discriminator)); return transformCodec( getHiddenPrefixCodec(codec, [prefix]), - (value: EnumVariantValue) => value, - (value: EnumVariantValue): EnumVariantValue => ({ __discriminator, ...value }), - ) as Codec; + (input: DecodedNodeInput) => input, + (decoded): DecodedEnumVariantTypeNode => { + const variant = decoded as DecodedEnumVariantTypeNode; + return { ...variant, value: { __discriminator, ...variant.value } }; + }, + ); }, visitEvent(node) { - return visit(node.data, this); + const path = stack.getPath('eventNode'); + return getCompositeCodec( + visit(node.data, this), + value => ({ value }), + decoded => { + const data = decoded as DecodedTypeNode; + return { data, path, value: data.value } satisfies Unranged; + }, + ); }, visitFixedPointType(node) { - return visit(node.number, this); + return getLeafCodec(visitValueCodec(node.number)); }, visitFloatType(node) { const codec = getFloatCodec(node) as Codec; - return assertValueType(codec, stack.getPath(), 'number | bigint', value => - ['bigint', 'number'].includes(typeof value), + return getLeafCodec( + assertValueType(codec, stack.getPath(), 'number | bigint', value => + ['bigint', 'number'].includes(typeof value), + ), ); }, visitInstruction(node) { - return node.data ? visit(node.data, this) : (getUnitCodec() as Codec); + if (!node.data) return getLeafCodec(getUnitCodec() as Codec); + const path = stack.getPath('instructionNode'); + return getCompositeCodec( + visit(node.data, this), + value => ({ value }), + decoded => { + const data = decoded as DecodedTypeNode; + return { data, path, value: data.value } satisfies Unranged; + }, + ); }, visitInstructionLink(node) { return visitLinkedNode(node); }, visitIntegerType(node) { const codec = getIntegerCodec(node) as Codec; - return assertValueType(codec, stack.getPath(), 'integer (number | bigint)', isInteger); + return getLeafCodec(assertValueType(codec, stack.getPath(), 'integer (number | bigint)', isInteger)); }, visitMapType(node) { + const nodePath = stack.getPath('mapTypeNode'); const entry = getTupleCodec([visit(node.key, this), visit(node.value, this)]); // Maps are represented as objects in order to be compatible with JSON. - const codec = transformCodec( - getCollectionCodec(entry as Codec, node.count), - (value: object) => Object.entries(value), - entries => Object.fromEntries(entries as [PropertyKey, unknown][]), - ) as Codec; - return assertValueType(codec, stack.getPath(), 'object', isObjectRecord); + return getCompositeCodec( + getCollectionCodec(entry, node.count), + value => { + if (!isObjectRecord(value)) throw getUnexpectedValueTypeError(nodePath, 'object', value); + return Object.entries(value).map( + ([key, entryValue]) => [{ value: key }, { value: entryValue }] as const, + ); + }, + decodedEntries => { + const entries = decodedEntries as (readonly [DecodedTypeNode, DecodedTypeNode])[]; + const value = Object.fromEntries( + entries.map(([key, entryValue]) => [key.value as PropertyKey, entryValue.value]), + ); + return { entries, path: nodePath, value } satisfies Unranged; + }, + ); }, visitOptionType(node) { + const path = stack.getPath('optionTypeNode'); + const fromItem = (item: DecodedTypeNode | undefined, value: Option) => + ({ ...(item ? { item } : {}), path, value }) satisfies Unranged; const item = visit(node.item, this); - const prefix = visit(node.prefix, this) as NumberCodec; + const prefix = visitValueCodec(node.prefix) as NumberCodec; if (node.fixed) { assertIsFixedSize(item); - return encodeMissingAsNone(getOptionCodec(item, { noneValue: 'zeroes', prefix }) as Codec); + return getOptionLikeCodec(getOptionCodec(item, { noneValue: 'zeroes', prefix }), fromItem); } - return encodeMissingAsNone(getOptionCodec(item, { prefix }) as Codec); + return getOptionLikeCodec(getOptionCodec(item, { prefix }), fromItem); }, visitPublicKeyType() { const codec = fixCodecSize(getBase58Codec(), 32) as Codec; - return assertValueType( - codec, - stack.getPath(), - 'Address', - value => typeof value === 'string' && isAddress(value), + return getLeafCodec( + assertValueType( + codec, + stack.getPath(), + 'Address', + value => typeof value === 'string' && isAddress(value), + ), ); }, visitRemainderOptionType(node) { - return encodeMissingAsNone(getOptionCodec(visit(node.item, this), { prefix: null }) as Codec); + const path = stack.getPath('remainderOptionTypeNode'); + return getOptionLikeCodec( + getOptionCodec(visit(node.item, this), { prefix: null }), + (item, value) => + ({ ...(item ? { item } : {}), path, value }) satisfies Unranged, + ); }, visitSetType(node) { // Sets are represented as arrays in order to be compatible with JSON. const item = visit(node.item, this); - return assertUniqueItems(getArrayLikeCodec(item, node.count), item, stack.getPath()); + const path = stack.getPath('setTypeNode'); + return getArrayLikeCodec( + item, + node.count, + (items, value) => ({ items, path, value }) satisfies Unranged, + items => assertUniqueItems(items, getValueCodec(item), path), + ); }, visitStringType(node) { const codec = getCodecFromBytesEncoding(node.encoding) as Codec; - return assertValueType(codec, stack.getPath(), 'string', value => typeof value === 'string'); + return getLeafCodec(assertValueType(codec, stack.getPath(), 'string', value => typeof value === 'string')); }, visitStructFieldType(node) { - const codec = visit(node.type, this); - if (node.defaultValue === undefined) return codec; - const getDefaultValue = getDefaultValueGetter(node.defaultValue); + const path = stack.getPath('structFieldTypeNode'); + const getDefaultValue = node.defaultValue ? getDefaultValueGetter(node.defaultValue) : undefined; const omitted = node.defaultValueStrategy === 'omitted'; - // Omitted fields always encode their default value, and other fields encode it - // when missing, i.e. when their value is `undefined`. - return transformCodec(codec, (value: unknown) => - omitted || value === undefined ? getDefaultValue() : value, + return getCompositeCodec( + visit(node.type, this), + // Omitted fields always encode their default value, and other fields encode it + // when missing, i.e. when their value is `undefined`. + value => ({ value: getDefaultValue && (omitted || value === undefined) ? getDefaultValue() : value }), + decoded => { + const type = decoded as DecodedTypeNode; + return { path, type, value: type.value } satisfies Unranged; + }, ); }, visitStructType(node) { + const nodePath = stack.getPath('structTypeNode'); const fields = (node.fields ?? []).map(field => [field.identifier, visit(field, this)] as const); - const codec = assertValueType( - getStructCodec(fields) as Codec, - stack.getPath(), - 'object', - isObjectRecord, + return getCompositeCodec( + getStructCodec(fields), + value => { + // A missing struct encodes all its fields as missing, so their default values apply. + const struct = value === undefined ? {} : value; + if (!isObjectRecord(struct)) throw getUnexpectedValueTypeError(nodePath, 'object', struct); + return Object.fromEntries( + fields.map(([identifier]) => [identifier, { value: struct[identifier] }]), + ); + }, + decodedFields => { + const decoded = fields.map( + ([identifier]) => decodedFields[identifier] as DecodedStructFieldTypeNode, + ); + const value = Object.fromEntries(decoded.map((field, index) => [fields[index][0], field.value])); + return { fields: decoded, path: nodePath, value } satisfies Unranged; + }, ); - // A missing struct encodes all its fields as missing, so their default values apply. - return transformCodec(codec, value => (value === undefined ? {} : value)); }, visitTupleType(node) { - const codec = getTupleCodec((node.items ?? []).map(item => visit(item, this))) as Codec; - return assertValueType(codec, stack.getPath(), 'array', value => Array.isArray(value)); + const nodePath = stack.getPath('tupleTypeNode'); + return getCompositeCodec( + getTupleCodec((node.items ?? []).map(item => visit(item, this))), + value => { + if (!Array.isArray(value)) throw getUnexpectedValueTypeError(nodePath, 'array', value); + return value.map((itemValue: unknown) => ({ value: itemValue })); + }, + decodedItems => { + const items = decodedItems as DecodedTypeNode[]; + const value = items.map(decoded => decoded.value); + return { items, path: nodePath, value } satisfies Unranged; + }, + ); }, visitZeroableOptionType(node) { + const path = stack.getPath('zeroableOptionTypeNode'); + const fromItem = (item: DecodedTypeNode | undefined, value: Option) => + ({ ...(item ? { item } : {}), path, value }) satisfies Unranged; const item = visit(node.item, this); assertIsFixedSize(item); if (node.zeroValue) { const noneValue = getConstantBytes(node.zeroValue); - return encodeMissingAsNone(getOptionCodec(item, { noneValue, prefix: null }) as Codec); + return getOptionLikeCodec(getOptionCodec(item, { noneValue, prefix: null }), fromItem); } - return encodeMissingAsNone(getOptionCodec(item, { noneValue: 'zeroes', prefix: null }) as Codec); + return getOptionLikeCodec(getOptionCodec(item, { noneValue: 'zeroes', prefix: null }), fromItem); }, }; - const visitor: Visitor, EncodableNodes['kind']> = pipe( + const visitor: Visitor = pipe( baseVisitor, // Layer each type node's transforms, innermost first, on top of its own codec. - // For link nodes, they apply on top of the linked type's own transforms. + // For link nodes, they apply on top of the linked type's own transforms. Then + // record the cursor positions around the node, transforms included. v => interceptVisitor(v, (node, next) => { const codec = next(node); - if (!isNode(node, TYPE_NODE_KINDS)) return codec; - return (node.transforms ?? []).reduce(applyTransform, codec); + if (!isNode(node, TYPE_NODE_KINDS)) return withRange(codec); + return withRange((node.transforms ?? []).reduce(applyTransform, codec)); }), v => recordProvidedScopeVisitor(v, scope), v => recordNodeStackVisitor(v, stack), @@ -661,3 +1006,18 @@ function getFloatCodec(node: FloatTypeNode): Codec { }); } } + +/** Replace the `read` function of a codec, keeping its size and `write` function. */ +function mapCodecRead( + codec: Codec, + mapRead: (read: Codec['read']) => Codec['read'], +): Codec { + const read = mapRead(codec.read); + if (isFixedSize(codec)) return createCodec({ fixedSize: codec.fixedSize, read, write: codec.write }); + return createCodec({ + getSizeFromValue: codec.getSizeFromValue, + ...(codec.maxSize === undefined ? {} : { maxSize: codec.maxSize }), + read, + write: codec.write, + }); +} diff --git a/packages/dynamic-codecs/src/decoded.ts b/packages/dynamic-codecs/src/decoded.ts new file mode 100644 index 000000000..d9e0c3329 --- /dev/null +++ b/packages/dynamic-codecs/src/decoded.ts @@ -0,0 +1,291 @@ +import type { + AccountLinkNode, + AccountNode, + ArrayTypeNode, + BooleanTypeNode, + BytesEncoding, + BytesTypeNode, + DateTimeTypeNode, + DefinedTypeLinkNode, + DefinedTypeNode, + DurationTypeNode, + EnumTypeNode, + EnumVariantTypeNode, + EventNode, + FixedPointTypeNode, + FloatTypeNode, + InstructionLinkNode, + InstructionNode, + IntegerTypeNode, + MapTypeNode, + Node, + OptionTypeNode, + PublicKeyTypeNode, + RegisteredTypeNode, + RemainderOptionTypeNode, + SetTypeNode, + StandaloneTypeNode, + StringTypeNode, + StructFieldTypeNode, + StructTypeNode, + TupleTypeNode, + ZeroableOptionTypeNode, +} from '@codama/nodes'; +import { assertIsNodePath, isNodePath, type NodePath } from '@codama/visitors-core'; +import type { Codec, Option } from '@solana/codecs'; + +/** + * The node kinds a decoded node can be about. Links are transparent: they + * decode as the node they link to, e.g. a defined type link as the type of + * its defined type. + */ +export type DecodableNode = AccountNode | DefinedTypeNode | EventNode | InstructionNode | RegisteredTypeNode; + +/** The decoded value of an enum variant, e.g. `{ __kind: 'move', __discriminator: 2, data: { x: 1n } }`. */ +export type EnumVariantValue = { __discriminator?: number; __kind: string; data?: unknown }; + +/** What codecs of decoded nodes encode: the value to encode, e.g. `{ value: { amount: 42n } }`. */ +export type DecodedNodeInput = { readonly value: unknown }; + +/** The attributes shared by every decoded node. */ +type DecodedNodeBase = { + /** + * The position of the cursor, from the start of the bytes, after reading + * the node, transforms included. Offset transforms may move it anywhere, + * e.g. back to `preOffset` when they rewind. + */ + readonly postOffset: number; + /** + * The position of the cursor, from the start of the bytes, before reading + * the node, transforms included, e.g. before its size prefix. + */ + readonly preOffset: number; + /** + * The path of the node that decoded the bytes, from the root. Links are + * transparent, so the path of a linked type goes through its definition, + * e.g. `[root, program, definedType, enumTypeNode]`. + */ + readonly path: NodePath; + /** The decoded value, in the format of `getNodeValueCodec`. */ + readonly value: TValue; +}; + +/** A decoded account, e.g. `{ value: { count: 42n }, data: … }`. */ +export type DecodedAccountNode = DecodedNodeBase & { + /** The decoded data of the account. */ + readonly data: DecodedTypeNode; +}; + +/** A decoded array, e.g. `{ value: [1n, 2n], items: […] }`. */ +export type DecodedArrayTypeNode = DecodedNodeBase & { + /** The decoded items of the array. */ + readonly items: readonly DecodedTypeNode[]; +}; + +/** A decoded boolean, e.g. `{ value: true }`. */ +export type DecodedBooleanTypeNode = DecodedNodeBase; + +/** Decoded bytes, e.g. `{ value: ['base64', 'AQI='] }`. */ +export type DecodedBytesTypeNode = DecodedNodeBase; + +/** A decoded date-time, as its raw number of ticks, e.g. `{ value: 1700000000n }`. */ +export type DecodedDateTimeTypeNode = DecodedNodeBase; + +/** A decoded defined type, e.g. `{ value: 42n, type: … }`. */ +export type DecodedDefinedTypeNode = DecodedNodeBase & { + /** The decoded type of the defined type. */ + readonly type: DecodedTypeNode; +}; + +/** A decoded duration, as its raw number of ticks, e.g. `{ value: 3600n }`. */ +export type DecodedDurationTypeNode = DecodedNodeBase; + +/** A decoded enum, e.g. `{ value: { __kind: 'quit', __discriminator: 0 }, variant: … }`. */ +export type DecodedEnumTypeNode = DecodedNodeBase & { + /** The decoded variant of the enum, covering its discriminator. */ + readonly variant: DecodedEnumVariantTypeNode; +}; + +/** A decoded enum variant, e.g. `{ value: { __kind: 'move', __discriminator: 1, data: { x: 5n } }, data: … }`. */ +export type DecodedEnumVariantTypeNode = DecodedNodeBase & { + /** The decoded data of the variant, absent for variants without data. */ + readonly data?: DecodedTypeNode; +}; + +/** A decoded event, e.g. `{ value: { amount: 42n }, data: … }`. */ +export type DecodedEventNode = DecodedNodeBase & { + /** The decoded data of the event. */ + readonly data: DecodedTypeNode; +}; + +/** A decoded fixed point, as its raw number, e.g. `{ value: 12345n }` for `123.45` with a scale of 2. */ +export type DecodedFixedPointTypeNode = DecodedNodeBase; + +/** A decoded float, e.g. `{ value: 1.5 }`. */ +export type DecodedFloatTypeNode = DecodedNodeBase; + +/** A decoded instruction, e.g. `{ value: { amount: 42n }, data: … }`. */ +export type DecodedInstructionNode = DecodedNodeBase & { + /** The decoded data of the instruction, absent for instructions without data. */ + readonly data?: DecodedTypeNode; +}; + +/** A decoded integer, e.g. `{ value: 42n }`. */ +export type DecodedIntegerTypeNode = DecodedNodeBase; + +/** A decoded map, e.g. `{ value: { 1: 'one' }, entries: [[key, value]] }`. */ +export type DecodedMapTypeNode = DecodedNodeBase> & { + /** The decoded entries of the map, keeping the decoded nodes of their keys. */ + readonly entries: readonly (readonly [key: DecodedTypeNode, value: DecodedTypeNode])[]; +}; + +/** A decoded option, e.g. `{ value: { __option: 'Some', value: 42n }, item: … }`. */ +export type DecodedOptionTypeNode = DecodedNodeBase> & { + /** The decoded item of the option, absent when `None`. */ + readonly item?: DecodedTypeNode; +}; + +/** A decoded public key, e.g. `{ value: '11111111111111111111111111111111' }`. */ +export type DecodedPublicKeyTypeNode = DecodedNodeBase; + +/** A decoded remainder option, e.g. `{ value: { __option: 'Some', value: 42n }, item: … }`. */ +export type DecodedRemainderOptionTypeNode = DecodedNodeBase> & { + /** The decoded item of the option, absent when `None`. */ + readonly item?: DecodedTypeNode; +}; + +/** A decoded set, e.g. `{ value: [1n, 2n], items: […] }`. */ +export type DecodedSetTypeNode = DecodedNodeBase & { + /** The decoded items of the set. */ + readonly items: readonly DecodedTypeNode[]; +}; + +/** A decoded string, e.g. `{ value: 'hello' }`. */ +export type DecodedStringTypeNode = DecodedNodeBase; + +/** A decoded struct field, e.g. `{ value: 42n, type: … }`. */ +export type DecodedStructFieldTypeNode = DecodedNodeBase & { + /** The decoded type of the field. */ + readonly type: DecodedTypeNode; +}; + +/** A decoded struct, e.g. `{ value: { amount: 42n }, fields: […] }`. */ +export type DecodedStructTypeNode = DecodedNodeBase> & { + /** The decoded fields of the struct, in order. */ + readonly fields: readonly DecodedStructFieldTypeNode[]; +}; + +/** A decoded tuple, e.g. `{ value: [1n, 'a'], items: […] }`. */ +export type DecodedTupleTypeNode = DecodedNodeBase & { + /** The decoded items of the tuple. */ + readonly items: readonly DecodedTypeNode[]; +}; + +/** A decoded zeroable option, e.g. `{ value: { __option: 'Some', value: 42n }, item: … }`. */ +export type DecodedZeroableOptionTypeNode = DecodedNodeBase> & { + /** The decoded item of the option, absent when `None`. */ + readonly item?: DecodedTypeNode; +}; + +/** + * The decoded node of each decodable node kind. Indexing it with every + * `DecodableNode['kind']` in {@link DecodedNode} fails to type-check if a + * kind is missing. + */ +type DecodedNodeMap = { + accountNode: DecodedAccountNode; + arrayTypeNode: DecodedArrayTypeNode; + booleanTypeNode: DecodedBooleanTypeNode; + bytesTypeNode: DecodedBytesTypeNode; + dateTimeTypeNode: DecodedDateTimeTypeNode; + definedTypeNode: DecodedDefinedTypeNode; + durationTypeNode: DecodedDurationTypeNode; + enumTypeNode: DecodedEnumTypeNode; + enumVariantTypeNode: DecodedEnumVariantTypeNode; + eventNode: DecodedEventNode; + fixedPointTypeNode: DecodedFixedPointTypeNode; + floatTypeNode: DecodedFloatTypeNode; + instructionNode: DecodedInstructionNode; + integerTypeNode: DecodedIntegerTypeNode; + mapTypeNode: DecodedMapTypeNode; + optionTypeNode: DecodedOptionTypeNode; + publicKeyTypeNode: DecodedPublicKeyTypeNode; + remainderOptionTypeNode: DecodedRemainderOptionTypeNode; + setTypeNode: DecodedSetTypeNode; + stringTypeNode: DecodedStringTypeNode; + structFieldTypeNode: DecodedStructFieldTypeNode; + structTypeNode: DecodedStructTypeNode; + tupleTypeNode: DecodedTupleTypeNode; + zeroableOptionTypeNode: DecodedZeroableOptionTypeNode; +}; + +/** + * A node decoded from bytes: the path of the node that decoded it, its value, + * the cursor positions around it and the decoded nodes of its children, named after the + * attributes of that node, e.g. the `fields` of a struct. + * + * Without a node type, any decoded node. With one, the decoded node of that + * type, e.g. `DecodedNode` is a {@link DecodedStructTypeNode}. + * + * @example + * ```ts + * // amount = struct { value: u64 } + * const decoded = getNodeCodec([root, program, amount]).decode(bytes); + * decoded.value; // { value: 42n } + * assertIsDecodedNode(decoded.type, 'structTypeNode'); + * decoded.type.fields[0].type; // { path: [root, program, amount, struct, field, u64], value: 42n, preOffset: 0, postOffset: 8 } + * ``` + */ +export type DecodedNode = DecodedNodeMap[TNode['kind']]; + +/** The decoded nodes of the given node kind(s), e.g. {@link DecodedStructTypeNode} for `'structTypeNode'`. */ +export type GetDecodedNodeFromKind = DecodedNodeMap[TKind]; + +/** A codec of decoded nodes, encoding from `{ value }` and decoding into a {@link DecodedNode}. */ +export type DecodedNodeCodec = Codec; + +/** A decoded type, e.g. the type of a struct field, links being resolved. */ +export type DecodedTypeNode = DecodedNode; + +/** The decoded node of a node a codec can be created for, links being transparent. */ +export type GetDecodedNode = + TNode extends AccountLinkNode + ? DecodedAccountNode + : TNode extends InstructionLinkNode + ? DecodedInstructionNode + : TNode extends DefinedTypeLinkNode + ? DecodedTypeNode + : TNode extends DecodableNode + ? DecodedNode + : never; + +/** + * Whether the decoded node was decoded by a node of the given kind(s), which + * narrows it to the decoded node of that kind, e.g. to access its children. + * + * @example + * ```ts + * if (isDecodedNode(field.type, 'structTypeNode')) { + * field.type.fields; // DecodedStructFieldTypeNode[] + * } + * ``` + */ +export function isDecodedNode( + decoded: DecodedNode | null | undefined, + kind: TKind | TKind[], +): decoded is GetDecodedNodeFromKind { + return !!decoded && isNodePath(decoded.path, kind); +} + +/** + * Assert that the decoded node was decoded by a node of the given kind(s), + * which narrows it to the decoded node of that kind. + * + * @throws `CODAMA_ERROR__UNEXPECTED_NODE_KIND` when it was not. + */ +export function assertIsDecodedNode( + decoded: DecodedNode | null | undefined, + kind: TKind | TKind[], +): asserts decoded is GetDecodedNodeFromKind { + assertIsNodePath(decoded?.path, kind); +} diff --git a/packages/dynamic-codecs/src/index.ts b/packages/dynamic-codecs/src/index.ts index 3e22c7181..6963842e9 100644 --- a/packages/dynamic-codecs/src/index.ts +++ b/packages/dynamic-codecs/src/index.ts @@ -5,6 +5,7 @@ import { CodecVisitorOptions, getNodeValueCodecVisitor } from './codecs'; import { getValueNodeVisitor } from './values'; export * from './codecs'; +export * from './decoded'; export * from './values'; export type { ReadonlyUint8Array }; diff --git a/packages/dynamic-codecs/src/lazy.ts b/packages/dynamic-codecs/src/lazy.ts index 2a9627ba0..e313f1201 100644 --- a/packages/dynamic-codecs/src/lazy.ts +++ b/packages/dynamic-codecs/src/lazy.ts @@ -6,10 +6,10 @@ import { Codec, createCodec, getEncodedSize } from '@solana/codecs'; * before that codec is fully created. Always variable-size, since the size of * the deferred codec is not known yet. */ -export function getLazyCodec(getCodec: () => Codec): Codec { - let codec: Codec | undefined; +export function getLazyCodec(getCodec: () => Codec): Codec { + let codec: Codec | undefined; const resolve = () => (codec ??= getCodec()); - return createCodec({ + return createCodec({ getSizeFromValue: value => getEncodedSize(value, resolve()), read: (bytes, offset) => resolve().read(bytes, offset), write: (value, bytes, offset) => resolve().write(value, bytes, offset), diff --git a/packages/dynamic-codecs/src/validation.ts b/packages/dynamic-codecs/src/validation.ts index 038ed7154..f7c0a81fe 100644 --- a/packages/dynamic-codecs/src/validation.ts +++ b/packages/dynamic-codecs/src/validation.ts @@ -57,35 +57,22 @@ export function assertValueType( } /** - * Reject arrays whose items encode to the same bytes before encoding them as a - * set, since sets hold unique values. Comparing encoded bytes works for any item - * type, e.g. structs or tuples, and accounts for encoded default values. Values - * that are not arrays go through so the wrapped codec can reject them. + * Reject the items of a set that encode to the same bytes, since sets hold + * unique values. Comparing encoded bytes works for any item type, e.g. structs + * or tuples, and accounts for encoded default values. * * Each item is encoded once more for the check, and variable-size codecs run it * twice (when sizing and when writing), a cost accepted for its simplicity. */ -export function assertUniqueItems( - codec: Codec, - item: Encoder, - nodePath: readonly Node[], -): Codec { +export function assertUniqueItems(items: readonly unknown[], item: Encoder, nodePath: readonly Node[]): void { const base16 = getBase16Decoder(); - return transformCodec(codec, (value: unknown) => { - if (!Array.isArray(value)) return value; - const indices = new Map(); - value.forEach((itemValue: unknown, index) => { - const key = base16.decode(item.encode(itemValue)); - const firstIndex = indices.get(key); - if (firstIndex !== undefined) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM, { - firstIndex, - index, - nodePath, - }); - } - indices.set(key, index); - }); - return value; + const indices = new Map(); + items.forEach((itemValue: unknown, index) => { + const key = base16.decode(item.encode(itemValue)); + const firstIndex = indices.get(key); + if (firstIndex !== undefined) { + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM, { firstIndex, index, nodePath }); + } + indices.set(key, index); }); } diff --git a/packages/dynamic-codecs/test/codecs/TupleTypeNode.test.ts b/packages/dynamic-codecs/test/codecs/TupleTypeNode.test.ts index 23deb44a7..718180b4a 100644 --- a/packages/dynamic-codecs/test/codecs/TupleTypeNode.test.ts +++ b/packages/dynamic-codecs/test/codecs/TupleTypeNode.test.ts @@ -1,3 +1,4 @@ +import { CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, CodamaError } from '@codama/errors'; import { fixedSizeTransformNode, integerTypeNode, stringTypeNode, tupleTypeNode } from '@codama/nodes'; import { expect, test } from 'vitest'; @@ -11,3 +12,20 @@ test('it encodes tuples', () => { expect(codec.encode(['foo', 42])).toStrictEqual(hex('666f6f2a00')); expect(codec.decode(hex('666f6f2a00'))).toStrictEqual(['foo', 42n]); }); + +test('it rejects tuples missing an item as a missing value of that item', () => { + // Given a tuple of an integer and a string. + const string = stringTypeNode('utf8'); + const tuple = tupleTypeNode([integerTypeNode('u8'), string]); + const codec = getNodeValueCodec([tuple]); + + // When we encode a tuple without its string, then the string rejects its missing value. + expect(() => codec.encode([42])).toThrow( + new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, { + actualType: 'undefined', + expectedType: 'string', + nodeKind: 'stringTypeNode', + nodePath: [tuple, string], + }), + ); +}); diff --git a/packages/dynamic-codecs/test/decoded.test.ts b/packages/dynamic-codecs/test/decoded.test.ts new file mode 100644 index 000000000..e448648bd --- /dev/null +++ b/packages/dynamic-codecs/test/decoded.test.ts @@ -0,0 +1,100 @@ +import { CODAMA_ERROR__UNEXPECTED_NODE_KIND, CodamaError } from '@codama/errors'; +import { + type AccountLinkNode, + type DefinedTypeLinkNode, + integerTypeNode, + type IntegerTypeNode, + structFieldTypeNode, + structTypeNode, + type StructTypeNode, +} from '@codama/nodes'; +import { describe, expect, expectTypeOf, test } from 'vitest'; + +import { + assertIsDecodedNode, + type DecodedAccountNode, + type DecodedIntegerTypeNode, + type DecodedNode, + type DecodedStructFieldTypeNode, + type DecodedStructTypeNode, + type DecodedTypeNode, + type GetDecodedNode, + type GetDecodedNodeFromKind, + getNodeCodec, + isDecodedNode, +} from '../src'; +import { hex } from './_setup'; + +describe('isDecodedNode', () => { + test('it tells whether a decoded node was decoded by a node of the given kind', () => { + // Given a decoded struct field whose type is an integer. + const struct = structTypeNode([structFieldTypeNode({ identifier: 'a', type: integerTypeNode('u8') })]); + const type = getNodeCodec([struct]).decode(hex('2a')).fields[0].type; + + // When we check its kind, then only the integer kind matches. + expect(isDecodedNode(type, 'integerTypeNode')).toBe(true); + expect(isDecodedNode(type, ['booleanTypeNode', 'integerTypeNode'])).toBe(true); + expect(isDecodedNode(type, 'structTypeNode')).toBe(false); + }); + + test('it rejects missing decoded nodes', () => { + expect(isDecodedNode(undefined, 'integerTypeNode')).toBe(false); + expect(isDecodedNode(null, 'integerTypeNode')).toBe(false); + }); + + test('it narrows decoded nodes to the given kind', () => { + const type = {} as DecodedTypeNode; + if (isDecodedNode(type, 'integerTypeNode')) { + expectTypeOf(type).toEqualTypeOf(); + expectTypeOf(type.value).toEqualTypeOf(); + } + if (isDecodedNode(type, 'structTypeNode')) { + expectTypeOf(type.fields).toEqualTypeOf(); + } + }); +}); + +describe('assertIsDecodedNode', () => { + test('it accepts decoded nodes of the given kind', () => { + const node = integerTypeNode('u8'); + const decoded = getNodeCodec([node]).decode(hex('2a')); + expect(() => assertIsDecodedNode(decoded, 'integerTypeNode')).not.toThrow(); + }); + + test('it throws for decoded nodes of another kind', () => { + const node = integerTypeNode('u8'); + const decoded = getNodeCodec([node]).decode(hex('2a')); + expect(() => assertIsDecodedNode(decoded, 'structTypeNode')).toThrow( + new CodamaError(CODAMA_ERROR__UNEXPECTED_NODE_KIND, { + expectedKinds: ['structTypeNode'], + kind: 'integerTypeNode', + node, + }), + ); + }); +}); + +describe('decoded node types', () => { + test('it types the decoded node of a codec after the last node of its path', () => { + const codec = getNodeCodec([integerTypeNode('u8')]); + expectTypeOf(codec.decode(hex('2a')).value).toEqualTypeOf(); + }); + + test('it types links as the decoded node they link to', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('it types decoded nodes of a node type as the decoded node of that type', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + DecodedIntegerTypeNode | DecodedStructTypeNode + >(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('it types the children of decoded nodes after the attributes of their node', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/dynamic-codecs/test/getNodeCodec.test.ts b/packages/dynamic-codecs/test/getNodeCodec.test.ts new file mode 100644 index 000000000..05dc39a2e --- /dev/null +++ b/packages/dynamic-codecs/test/getNodeCodec.test.ts @@ -0,0 +1,830 @@ +import { + accountNode, + arrayTypeNode, + booleanTypeNode, + bytesTypeNode, + constantValueNodeFromBytes, + dateTimeTypeNode, + definedTypeLinkNode, + definedTypeNode, + durationTypeNode, + enumTypeNode, + enumVariantTypeNode, + eventNode, + fixedPointTypeNode, + fixedSizeTransformNode, + floatTypeNode, + hiddenPrefixTransformNode, + hiddenSuffixTransformNode, + instructionNode, + integerTypeNode, + mapTypeNode, + optionTypeNode, + postOffsetTransformNode, + preOffsetTransformNode, + prefixedCountNode, + programLinkNode, + programNode, + publicKeyTypeNode, + remainderCountNode, + remainderOptionTypeNode, + rootNode, + sentinelCountNode, + sentinelTransformNode, + setTypeNode, + sizePrefixTransformNode, + stringTypeNode, + structFieldTypeNode, + structTypeNode, + tupleTypeNode, + zeroableOptionTypeNode, +} from '@codama/nodes'; +import { describe, expect, test } from 'vitest'; + +import { getNodeCodec, getNodeValueCodec } from '../src'; +import { hex } from './_setup'; + +describe('leaves', () => { + test('it decodes integers as their value, path and cursor positions', () => { + const node = integerTypeNode('u16'); + expect(getNodeCodec([node]).decode(hex('2a00'))).toStrictEqual({ + path: [node], + postOffset: 2, + preOffset: 0, + value: 42n, + }); + }); + + test('it decodes cursor positions from the offset it starts reading at', () => { + const node = integerTypeNode('u16'); + expect(getNodeCodec([node]).decode(hex('ff2a00'), 1)).toStrictEqual({ + path: [node], + postOffset: 3, + preOffset: 1, + value: 42n, + }); + }); + + test('it decodes floats', () => { + const node = floatTypeNode('f32'); + expect(getNodeCodec([node]).decode(hex('0000c03f'))).toStrictEqual({ + path: [node], + postOffset: 4, + preOffset: 0, + value: 1.5, + }); + }); + + test('it decodes booleans', () => { + const node = booleanTypeNode(); + expect(getNodeCodec([node]).decode(hex('01'))).toStrictEqual({ + path: [node], + postOffset: 1, + preOffset: 0, + value: true, + }); + }); + + test('it decodes bytes', () => { + const node = bytesTypeNode(); + expect(getNodeCodec([node]).decode(hex('0102'))).toStrictEqual({ + path: [node], + postOffset: 2, + preOffset: 0, + value: ['base64', 'AQI='], + }); + }); + + test('it decodes strings', () => { + const node = stringTypeNode('utf8'); + expect(getNodeCodec([node]).decode(hex('6869'))).toStrictEqual({ + path: [node], + postOffset: 2, + preOffset: 0, + value: 'hi', + }); + }); + + test('it decodes public keys', () => { + const node = publicKeyTypeNode(); + expect(getNodeCodec([node]).decode(new Uint8Array(32))).toStrictEqual({ + path: [node], + postOffset: 32, + preOffset: 0, + value: '11111111111111111111111111111111', + }); + }); + + test('it decodes fixed points as their raw number, at their own path', () => { + const node = fixedPointTypeNode(integerTypeNode('u32'), 2); + expect(getNodeCodec([node]).decode(hex('39300000'))).toStrictEqual({ + path: [node], + postOffset: 4, + preOffset: 0, + value: 12345n, + }); + }); + + test('it decodes date-times as their raw number, at their own path', () => { + const node = dateTimeTypeNode(integerTypeNode('i64')); + expect(getNodeCodec([node]).decode(hex('0100000000000000'))).toStrictEqual({ + path: [node], + postOffset: 8, + preOffset: 0, + value: 1n, + }); + }); + + test('it decodes durations as their raw number, at their own path', () => { + const node = durationTypeNode(integerTypeNode('u32')); + expect(getNodeCodec([node]).decode(hex('100e0000'))).toStrictEqual({ + path: [node], + postOffset: 4, + preOffset: 0, + value: 3600n, + }); + }); + + test('it encodes leaves from their value', () => { + expect(getNodeCodec([integerTypeNode('u16')]).encode({ value: 42 })).toStrictEqual(hex('2a00')); + }); +}); + +describe('composites', () => { + test('it decodes the fields of structs', () => { + // Given a struct of two fields. + const u8 = integerTypeNode('u8'); + const u16 = integerTypeNode('u16'); + const fieldA = structFieldTypeNode({ identifier: 'a', type: u8 }); + const fieldB = structFieldTypeNode({ identifier: 'b', type: u16 }); + const struct = structTypeNode([fieldA, fieldB]); + + // When we decode it. + const decoded = getNodeCodec([struct]).decode(hex('010200')); + + // Then each field has its own decoded node and cursor positions. + expect(decoded).toStrictEqual({ + fields: [ + { + path: [struct, fieldA], + postOffset: 1, + preOffset: 0, + type: { path: [struct, fieldA, u8], postOffset: 1, preOffset: 0, value: 1n }, + value: 1n, + }, + { + path: [struct, fieldB], + postOffset: 3, + preOffset: 1, + type: { path: [struct, fieldB, u16], postOffset: 3, preOffset: 1, value: 2n }, + value: 2n, + }, + ], + path: [struct], + postOffset: 3, + preOffset: 0, + value: { a: 1n, b: 2n }, + }); + }); + + test('it decodes the variant of enums', () => { + // Given an enum whose second variant holds a struct. + const u8 = integerTypeNode('u8'); + const field = structFieldTypeNode({ identifier: 'x', type: u8 }); + const struct = structTypeNode([field]); + const quit = enumVariantTypeNode('quit'); + const move = enumVariantTypeNode('move', { data: struct }); + const enumType = enumTypeNode([quit, move]); + + // When we decode its second variant. + const decoded = getNodeCodec([enumType]).decode(hex('0105')); + + // Then the variant covers its discriminator and holds its data. + const value = { __discriminator: 1, __kind: 'move', data: { x: 5n } }; + expect(decoded).toStrictEqual({ + path: [enumType], + postOffset: 2, + preOffset: 0, + value, + variant: { + data: { + fields: [ + { + path: [enumType, move, struct, field], + postOffset: 2, + preOffset: 1, + type: { path: [enumType, move, struct, field, u8], postOffset: 2, preOffset: 1, value: 5n }, + value: 5n, + }, + ], + path: [enumType, move, struct], + postOffset: 2, + preOffset: 1, + value: { x: 5n }, + }, + path: [enumType, move], + postOffset: 2, + preOffset: 0, + value, + }, + }); + }); + + test('it decodes enum variants without data', () => { + const quit = enumVariantTypeNode('quit'); + const enumType = enumTypeNode([quit]); + const value = { __discriminator: 0, __kind: 'quit' }; + expect(getNodeCodec([enumType]).decode(hex('00'))).toStrictEqual({ + path: [enumType], + postOffset: 1, + preOffset: 0, + value, + variant: { path: [enumType, quit], postOffset: 1, preOffset: 0, value }, + }); + }); + + test('it decodes enum variants outside of an enum, without discriminator', () => { + const u8 = integerTypeNode('u8'); + const variant = enumVariantTypeNode('move', { data: u8 }); + expect(getNodeCodec([variant]).decode(hex('05'))).toStrictEqual({ + data: { path: [variant, u8], postOffset: 1, preOffset: 0, value: 5n }, + path: [variant], + postOffset: 1, + preOffset: 0, + value: { __kind: 'move', data: 5n }, + }); + }); + + test('it decodes the item of options that are present', () => { + const u8 = integerTypeNode('u8'); + const option = optionTypeNode(u8); + expect(getNodeCodec([option]).decode(hex('012a'))).toStrictEqual({ + item: { path: [option, u8], postOffset: 2, preOffset: 1, value: 42n }, + path: [option], + postOffset: 2, + preOffset: 0, + value: { __option: 'Some', value: 42n }, + }); + }); + + test('it decodes options that are absent without an item', () => { + const option = optionTypeNode(integerTypeNode('u8')); + expect(getNodeCodec([option]).decode(hex('00'))).toStrictEqual({ + path: [option], + postOffset: 1, + preOffset: 0, + value: { __option: 'None' }, + }); + }); + + test('it decodes the item of remainder options', () => { + const u8 = integerTypeNode('u8'); + const option = remainderOptionTypeNode(u8); + expect(getNodeCodec([option]).decode(hex('2a'))).toStrictEqual({ + item: { path: [option, u8], postOffset: 1, preOffset: 0, value: 42n }, + path: [option], + postOffset: 1, + preOffset: 0, + value: { __option: 'Some', value: 42n }, + }); + }); + + test('it decodes zeroable options that are absent without an item', () => { + const option = zeroableOptionTypeNode(integerTypeNode('u16')); + expect(getNodeCodec([option]).decode(hex('0000'))).toStrictEqual({ + path: [option], + postOffset: 2, + preOffset: 0, + value: { __option: 'None' }, + }); + }); + + test('it decodes the items of arrays', () => { + const u16 = integerTypeNode('u16'); + const array = arrayTypeNode(u16, prefixedCountNode(integerTypeNode('u8'))); + expect(getNodeCodec([array]).decode(hex('0201000200'))).toStrictEqual({ + items: [ + { path: [array, u16], postOffset: 3, preOffset: 1, value: 1n }, + { path: [array, u16], postOffset: 5, preOffset: 3, value: 2n }, + ], + path: [array], + postOffset: 5, + preOffset: 0, + value: [1n, 2n], + }); + }); + + test('it decodes the items of arrays ending with a sentinel', () => { + const u8 = integerTypeNode('u8'); + const array = arrayTypeNode(u8, sentinelCountNode(constantValueNodeFromBytes('base16', 'ff'))); + expect(getNodeCodec([array]).decode(hex('0102ff'))).toStrictEqual({ + items: [ + { path: [array, u8], postOffset: 1, preOffset: 0, value: 1n }, + { path: [array, u8], postOffset: 2, preOffset: 1, value: 2n }, + ], + path: [array], + postOffset: 3, + preOffset: 0, + value: [1n, 2n], + }); + }); + + test('it decodes the items of sets', () => { + const u8 = integerTypeNode('u8'); + const set = setTypeNode(u8, remainderCountNode()); + expect(getNodeCodec([set]).decode(hex('0102'))).toStrictEqual({ + items: [ + { path: [set, u8], postOffset: 1, preOffset: 0, value: 1n }, + { path: [set, u8], postOffset: 2, preOffset: 1, value: 2n }, + ], + path: [set], + postOffset: 2, + preOffset: 0, + value: [1n, 2n], + }); + }); + + test('it decodes the items of tuples', () => { + const u8 = integerTypeNode('u8'); + const u16 = integerTypeNode('u16'); + const tuple = tupleTypeNode([u8, u16]); + expect(getNodeCodec([tuple]).decode(hex('010200'))).toStrictEqual({ + items: [ + { path: [tuple, u8], postOffset: 1, preOffset: 0, value: 1n }, + { path: [tuple, u16], postOffset: 3, preOffset: 1, value: 2n }, + ], + path: [tuple], + postOffset: 3, + preOffset: 0, + value: [1n, 2n], + }); + }); + + test('it decodes the entries of maps, keeping the decoded values of their keys', () => { + // Given a map from integers to integers. + const key = integerTypeNode('u16'); + const u8 = integerTypeNode('u8'); + const map = mapTypeNode(key, u8, prefixedCountNode(integerTypeNode('u8'))); + + // When we decode it. + const decoded = getNodeCodec([map]).decode(hex('01' + '0700' + '2a')); + + // Then its keys keep their decoded integer, which its object value turns into a string. + expect(decoded).toStrictEqual({ + entries: [ + [ + { path: [map, key], postOffset: 3, preOffset: 1, value: 7n }, + { path: [map, u8], postOffset: 4, preOffset: 3, value: 42n }, + ], + ], + path: [map], + postOffset: 4, + preOffset: 0, + value: { 7: 42n }, + }); + }); + + test('it encodes composites from their value', () => { + // Given a struct holding an array and an option. + const struct = structTypeNode([ + structFieldTypeNode({ + identifier: 'items', + type: arrayTypeNode(integerTypeNode('u8'), prefixedCountNode(integerTypeNode('u8'))), + }), + structFieldTypeNode({ identifier: 'maybe', type: optionTypeNode(integerTypeNode('u8')) }), + ]); + + // When we encode its value, then we get the same bytes as its value codec. + const value = { items: [1, 2], maybe: 3 }; + expect(getNodeCodec([struct]).encode({ value })).toStrictEqual(getNodeValueCodec([struct]).encode(value)); + }); +}); + +describe('top-level nodes', () => { + test('it decodes the data of accounts', () => { + const u8 = integerTypeNode('u8'); + const account = accountNode({ data: u8, identifier: 'counter' }); + const root = rootNode(programNode({ accounts: [account], identifier: 'myProgram', publicKey: '1111' })); + expect(getNodeCodec([root, root.program, account]).decode(hex('2a'))).toStrictEqual({ + data: { path: [root, root.program, account, u8], postOffset: 1, preOffset: 0, value: 42n }, + path: [root, root.program, account], + postOffset: 1, + preOffset: 0, + value: 42n, + }); + }); + + test('it decodes the data of events', () => { + const u8 = integerTypeNode('u8'); + const event = eventNode({ data: u8, identifier: 'tick' }); + const root = rootNode(programNode({ events: [event], identifier: 'myProgram', publicKey: '1111' })); + expect(getNodeCodec([root, root.program, event]).decode(hex('2a'))).toStrictEqual({ + data: { path: [root, root.program, event, u8], postOffset: 1, preOffset: 0, value: 42n }, + path: [root, root.program, event], + postOffset: 1, + preOffset: 0, + value: 42n, + }); + }); + + test('it decodes the data of instructions', () => { + const u8 = integerTypeNode('u8'); + const instruction = instructionNode({ data: u8, identifier: 'ping' }); + expect(getNodeCodec([instruction]).decode(hex('2a'))).toStrictEqual({ + data: { path: [instruction, u8], postOffset: 1, preOffset: 0, value: 42n }, + path: [instruction], + postOffset: 1, + preOffset: 0, + value: 42n, + }); + }); + + test('it decodes instructions without data as empty', () => { + const instruction = instructionNode({ identifier: 'ping' }); + expect(getNodeCodec([instruction]).decode(hex(''))).toStrictEqual({ + path: [instruction], + postOffset: 0, + preOffset: 0, + value: undefined, + }); + }); + + test('it decodes the type of defined types', () => { + const u8 = integerTypeNode('u8'); + const definedType = definedTypeNode({ identifier: 'amount', type: u8 }); + const root = rootNode(programNode({ definedTypes: [definedType], identifier: 'myProgram', publicKey: '1111' })); + expect(getNodeCodec([root, root.program, definedType]).decode(hex('2a'))).toStrictEqual({ + path: [root, root.program, definedType], + postOffset: 1, + preOffset: 0, + type: { path: [root, root.program, definedType, u8], postOffset: 1, preOffset: 0, value: 42n }, + value: 42n, + }); + }); +}); + +describe('links', () => { + test('it decodes links as the type of their defined type', () => { + // Given a struct field linking to a defined type. + const u8 = integerTypeNode('u8'); + const definedType = definedTypeNode({ identifier: 'amount', type: u8 }); + const field = structFieldTypeNode({ identifier: 'amount', type: definedTypeLinkNode('amount') }); + const struct = structTypeNode([field]); + const root = rootNode(programNode({ definedTypes: [definedType], identifier: 'myProgram', publicKey: '1111' })); + + // When we decode the struct. + const decoded = getNodeCodec([root, root.program, struct]).decode(hex('2a')); + + // Then the type of the field is the type of the defined type, at its own path. + expect(decoded.fields[0].type).toStrictEqual({ + path: [root, root.program, definedType, u8], + postOffset: 1, + preOffset: 0, + value: 42n, + }); + }); + + test('it decodes links to types of other programs', () => { + // Given a type of program A linking to a type of program B. + const u8 = integerTypeNode('u8'); + const typeB = definedTypeNode({ identifier: 'amount', type: u8 }); + const typeA = definedTypeNode({ + identifier: 'balance', + type: definedTypeLinkNode('amount', { program: programLinkNode('programB') }), + }); + const programA = programNode({ definedTypes: [typeA], identifier: 'programA', publicKey: '1111' }); + const programB = programNode({ definedTypes: [typeB], identifier: 'programB', publicKey: '2222' }); + const root = rootNode(programA, { additionalPrograms: [programB] }); + + // When we decode the type of program A, then its type is the one of program B. + expect(getNodeCodec([root, programA, typeA]).decode(hex('2a')).type).toStrictEqual({ + path: [root, programB, typeB, u8], + postOffset: 1, + preOffset: 0, + value: 42n, + }); + }); + + test('it includes the transforms of links in the cursor positions of their type', () => { + // Given a struct field linking to a string, with a size prefix on the link. + const string = stringTypeNode('utf8'); + const definedType = definedTypeNode({ identifier: 'label', type: string }); + const link = definedTypeLinkNode('label', { transforms: [sizePrefixTransformNode(integerTypeNode('u8'))] }); + const struct = structTypeNode([structFieldTypeNode({ identifier: 'label', type: link })]); + const root = rootNode(programNode({ definedTypes: [definedType], identifier: 'myProgram', publicKey: '1111' })); + + // When we decode it, then the type covers the size prefix of the link. + const decoded = getNodeCodec([root, root.program, struct]).decode(hex('026869')); + expect(decoded.fields[0].type).toStrictEqual({ + path: [root, root.program, definedType, string], + postOffset: 3, + preOffset: 0, + value: 'hi', + }); + }); + + test('it decodes recursive types at every depth', () => { + // Given a linked list. + const u8 = integerTypeNode('u8'); + const next = optionTypeNode(definedTypeLinkNode('list')); + const valueField = structFieldTypeNode({ identifier: 'value', type: u8 }); + const nextField = structFieldTypeNode({ identifier: 'next', type: next }); + const struct = structTypeNode([valueField, nextField]); + const list = definedTypeNode({ identifier: 'list', type: struct }); + const root = rootNode(programNode({ definedTypes: [list], identifier: 'myProgram', publicKey: '1111' })); + + // When we decode a list of two items. + const decoded = getNodeCodec([root, root.program, list]).decode(hex('01010200')); + + // Then the nested list has the same paths, with its own cursor positions. + const path = [root, root.program, list, struct]; + const none = { __option: 'None' }; + const nested = { + fields: [ + { + path: [...path, valueField], + postOffset: 3, + preOffset: 2, + type: { path: [...path, valueField, u8], postOffset: 3, preOffset: 2, value: 2n }, + value: 2n, + }, + { + path: [...path, nextField], + postOffset: 4, + preOffset: 3, + type: { path: [...path, nextField, next], postOffset: 4, preOffset: 3, value: none }, + value: none, + }, + ], + path, + postOffset: 4, + preOffset: 2, + value: { next: none, value: 2n }, + }; + expect(decoded).toStrictEqual({ + path: [root, root.program, list], + postOffset: 4, + preOffset: 0, + type: { + fields: [ + { + path: [...path, valueField], + postOffset: 1, + preOffset: 0, + type: { path: [...path, valueField, u8], postOffset: 1, preOffset: 0, value: 1n }, + value: 1n, + }, + { + path: [...path, nextField], + postOffset: 4, + preOffset: 1, + type: { + item: nested, + path: [...path, nextField, next], + postOffset: 4, + preOffset: 1, + value: { __option: 'Some', value: nested.value }, + }, + value: { __option: 'Some', value: nested.value }, + }, + ], + path, + postOffset: 4, + preOffset: 0, + value: { next: { __option: 'Some', value: nested.value }, value: 1n }, + }, + value: { next: { __option: 'Some', value: nested.value }, value: 1n }, + }); + }); +}); + +describe('transforms', () => { + // Each test decodes `struct { x: u8, inner: struct { a: u8 } }` with transforms on the inner + // struct, after a first byte for `x`, and checks the cursor positions around the inner struct and its field. + const u8 = integerTypeNode('u8'); + const getInnerStruct = (transforms: Parameters[1]) => { + const a = structFieldTypeNode({ identifier: 'a', type: u8 }); + const inner = structTypeNode([a], transforms); + const innerField = structFieldTypeNode({ identifier: 'inner', type: inner }); + const struct = structTypeNode([structFieldTypeNode({ identifier: 'x', type: u8 }), innerField]); + return { a, inner, innerField, struct }; + }; + + test('it keeps cursor positions absolute within size prefixes', () => { + const { a, inner, innerField, struct } = getInnerStruct({ + transforms: [sizePrefixTransformNode(integerTypeNode('u8'))], + }); + expect(getNodeCodec([struct]).decode(hex('07' + '01' + '2a')).fields[1].type).toStrictEqual({ + fields: [ + { + path: [struct, innerField, inner, a], + postOffset: 3, + preOffset: 2, + type: { path: [struct, innerField, inner, a, u8], postOffset: 3, preOffset: 2, value: 42n }, + value: 42n, + }, + ], + path: [struct, innerField, inner], + postOffset: 3, + preOffset: 1, + value: { a: 42n }, + }); + }); + + test('it keeps cursor positions absolute within nested size prefixes', () => { + // Given a size-prefixed string within a size-prefixed struct. + const string = stringTypeNode('utf8', { transforms: [sizePrefixTransformNode(integerTypeNode('u8'))] }); + const label = structFieldTypeNode({ identifier: 'label', type: string }); + const inner = structTypeNode([label], { transforms: [sizePrefixTransformNode(integerTypeNode('u8'))] }); + const innerField = structFieldTypeNode({ identifier: 'inner', type: inner }); + const struct = structTypeNode([structFieldTypeNode({ identifier: 'x', type: u8 }), innerField]); + + // When we decode it, then the string reports where it is in the whole bytes. + const decoded = getNodeCodec([struct]).decode(hex('07' + '03' + '02' + '6869')); + expect(decoded.fields[1].type).toStrictEqual({ + fields: [ + { + path: [struct, innerField, inner, label], + postOffset: 5, + preOffset: 2, + type: { + path: [struct, innerField, inner, label, string], + postOffset: 5, + preOffset: 2, + value: 'hi', + }, + value: 'hi', + }, + ], + path: [struct, innerField, inner], + postOffset: 5, + preOffset: 1, + value: { label: 'hi' }, + }); + }); + + test('it keeps cursor positions absolute within fixed sizes', () => { + const { a, inner, innerField, struct } = getInnerStruct({ transforms: [fixedSizeTransformNode(2)] }); + expect(getNodeCodec([struct]).decode(hex('07' + '2a00')).fields[1].type).toStrictEqual({ + fields: [ + { + path: [struct, innerField, inner, a], + postOffset: 2, + preOffset: 1, + type: { path: [struct, innerField, inner, a, u8], postOffset: 2, preOffset: 1, value: 42n }, + value: 42n, + }, + ], + path: [struct, innerField, inner], + postOffset: 3, + preOffset: 1, + value: { a: 42n }, + }); + }); + + test('it keeps cursor positions absolute within sentinels', () => { + const { a, inner, innerField, struct } = getInnerStruct({ + transforms: [sentinelTransformNode(constantValueNodeFromBytes('base16', 'ff'))], + }); + expect(getNodeCodec([struct]).decode(hex('07' + '2aff')).fields[1].type).toStrictEqual({ + fields: [ + { + path: [struct, innerField, inner, a], + postOffset: 2, + preOffset: 1, + type: { path: [struct, innerField, inner, a, u8], postOffset: 2, preOffset: 1, value: 42n }, + value: 42n, + }, + ], + path: [struct, innerField, inner], + postOffset: 3, + preOffset: 1, + value: { a: 42n }, + }); + }); + + test('it includes hidden prefixes and suffixes in cursor positions', () => { + const { a, inner, innerField, struct } = getInnerStruct({ + transforms: [ + hiddenPrefixTransformNode([constantValueNodeFromBytes('base16', 'aa')]), + hiddenSuffixTransformNode([constantValueNodeFromBytes('base16', 'bb')]), + ], + }); + expect(getNodeCodec([struct]).decode(hex('07' + 'aa2abb')).fields[1].type).toStrictEqual({ + fields: [ + { + path: [struct, innerField, inner, a], + postOffset: 3, + preOffset: 2, + type: { path: [struct, innerField, inner, a, u8], postOffset: 3, preOffset: 2, value: 42n }, + value: 42n, + }, + ], + path: [struct, innerField, inner], + postOffset: 4, + preOffset: 1, + value: { a: 42n }, + }); + }); + + test('it includes padded offsets in cursor positions', () => { + const { a, inner, innerField, struct } = getInnerStruct({ + transforms: [ + preOffsetTransformNode(1, { strategy: 'padded' }), + postOffsetTransformNode(1, { strategy: 'padded' }), + ], + }); + expect(getNodeCodec([struct]).decode(hex('07' + '002a00')).fields[1].type).toStrictEqual({ + fields: [ + { + path: [struct, innerField, inner, a], + postOffset: 3, + preOffset: 2, + type: { path: [struct, innerField, inner, a, u8], postOffset: 3, preOffset: 2, value: 42n }, + value: 42n, + }, + ], + path: [struct, innerField, inner], + postOffset: 4, + preOffset: 1, + value: { a: 42n }, + }); + }); + + test('it reports the cursor positions around nodes moving the cursor back with a relative pre-offset', () => { + // Given a struct whose second field reads the byte before the cursor. + const a = structFieldTypeNode({ identifier: 'a', type: u8 }); + const back = integerTypeNode('u8', { transforms: [preOffsetTransformNode(-1, { strategy: 'relative' })] }); + const b = structFieldTypeNode({ identifier: 'b', type: back }); + const struct = structTypeNode([a, b]); + + // When we decode it, then the second field starts and ends at the cursor, having read the first byte again. + expect(getNodeCodec([struct]).decode(hex('2a')).fields[1]).toStrictEqual({ + path: [struct, b], + postOffset: 1, + preOffset: 1, + type: { path: [struct, b, back], postOffset: 1, preOffset: 1, value: 42n }, + value: 42n, + }); + }); + + test('it reports the cursor positions around nodes rewinding the cursor with an absolute post-offset', () => { + // Given a struct whose second field moves the cursor back to the start after being read. + const a = structFieldTypeNode({ identifier: 'a', type: u8 }); + const rewind = integerTypeNode('u8', { transforms: [postOffsetTransformNode(0, { strategy: 'absolute' })] }); + const b = structFieldTypeNode({ identifier: 'b', type: rewind }); + const struct = structTypeNode([a, b]); + + // When we decode it, then the second field ends where the cursor was moved to. + expect(getNodeCodec([struct]).decode(hex('0102')).fields[1]).toStrictEqual({ + path: [struct, b], + postOffset: 0, + preOffset: 1, + type: { path: [struct, b, rewind], postOffset: 0, preOffset: 1, value: 2n }, + value: 2n, + }); + }); + + test('it reports where the children of a node moved by an absolute pre-offset are read', () => { + // Given a struct whose inner struct is read from the third byte. + const { a, inner, innerField, struct } = getInnerStruct({ + transforms: [preOffsetTransformNode(2, { strategy: 'absolute' })], + }); + + // When we decode it, then the inner struct spans from the cursor before it, and its field is at the third byte. + expect(getNodeCodec([struct]).decode(hex('07' + 'ff' + '2a')).fields[1].type).toStrictEqual({ + fields: [ + { + path: [struct, innerField, inner, a], + postOffset: 3, + preOffset: 2, + type: { path: [struct, innerField, inner, a, u8], postOffset: 3, preOffset: 2, value: 42n }, + value: 42n, + }, + ], + path: [struct, innerField, inner], + postOffset: 3, + preOffset: 1, + value: { a: 42n }, + }); + }); + + test('it keeps cursor positions absolute within arrays ending with a sentinel', () => { + // Given a struct holding an array ending with a sentinel after a first field. + const array = arrayTypeNode(u8, sentinelCountNode(constantValueNodeFromBytes('base16', 'ff'))); + const items = structFieldTypeNode({ identifier: 'items', type: array }); + const struct = structTypeNode([structFieldTypeNode({ identifier: 'x', type: u8 }), items]); + + // When we decode it, then the items report where they are in the whole bytes. + expect(getNodeCodec([struct]).decode(hex('07' + '0102ff')).fields[1].type).toStrictEqual({ + items: [ + { path: [struct, items, array, u8], postOffset: 2, preOffset: 1, value: 1n }, + { path: [struct, items, array, u8], postOffset: 3, preOffset: 2, value: 2n }, + ], + path: [struct, items, array], + postOffset: 4, + preOffset: 1, + value: [1n, 2n], + }); + }); +});