From 7f7c8981514bd09723909375dd6254808b0e6456 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Tue, 29 Sep 2026 17:08:33 +0100 Subject: [PATCH] Reject values of the wrong type when encoding dynamic codecs --- .../resolvers/resolve-pda-address.test.ts | 19 ++ packages/dynamic-client/src/index.ts | 2 +- packages/dynamic-codecs/README.md | 72 +++--- packages/dynamic-codecs/package.json | 1 + packages/dynamic-codecs/src/codecs.ts | 123 +++++++-- packages/dynamic-codecs/src/validation.ts | 53 ++++ .../test/codecs/EnumTypeNode.test.ts | 19 +- .../test/codecs/EnumVariantTypeNode.test.ts | 27 +- .../test/codecs/StructTypeNode.test.ts | 23 ++ .../dynamic-codecs/test/validation.test.ts | 233 ++++++++++++++++++ packages/errors/src/codes.ts | 4 +- packages/errors/src/context.ts | 7 +- packages/errors/src/messages.ts | 4 +- pnpm-lock.yaml | 3 + 14 files changed, 511 insertions(+), 79 deletions(-) create mode 100644 packages/dynamic-codecs/src/validation.ts create mode 100644 packages/dynamic-codecs/test/validation.test.ts diff --git a/packages/dynamic-address-resolution/test/resolvers/resolve-pda-address.test.ts b/packages/dynamic-address-resolution/test/resolvers/resolve-pda-address.test.ts index ce61ab2ea..1c645c8e5 100644 --- a/packages/dynamic-address-resolution/test/resolvers/resolve-pda-address.test.ts +++ b/packages/dynamic-address-resolution/test/resolvers/resolve-pda-address.test.ts @@ -4,6 +4,7 @@ import { CODAMA_ERROR__DYNAMIC_CLIENT__NODE_REFERENCE_NOT_FOUND, CODAMA_ERROR__DYNAMIC_CLIENT__PDA_SEED_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, + CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, CodamaError, } from '@codama/errors'; import { type Address, getAddressEncoder, getProgramDerivedAddress } from '@solana/addresses'; @@ -261,6 +262,24 @@ describe('variable seeds', () => { ); }); + test('it throws when a seed value has the wrong type rather than deriving another PDA', async () => { + const data = structTypeNode([structFieldTypeNode({ identifier: 'owner', type: publicKeyTypeNode() })]); + const defaultValue = pdaValueNode(pdaLinkNode('vault'), { + seeds: [pdaSeedValueNode('owner', dataValueNode('owner'))], + }); + const { path } = getAccountPath(account('vault', { defaultValue }), { data }, { pdas: [ownerPda] }); + await expect(resolveInstructionAccountAddress({ dataInput: { owner: 42 }, path })).rejects.toThrow( + expect.objectContaining({ + context: expect.objectContaining({ + __code: CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, + actualType: 'number (42)', + expectedType: 'Address', + nodeKind: 'publicKeyTypeNode', + }), + }), + ); + }); + test('it throws when a seed value is not provided by the PDA value', async () => { const { path } = getAccountPath( account('vault', { defaultValue: pdaValueNode(pdaLinkNode('vault')) }), diff --git a/packages/dynamic-client/src/index.ts b/packages/dynamic-client/src/index.ts index b18d098ef..312e168b7 100644 --- a/packages/dynamic-client/src/index.ts +++ b/packages/dynamic-client/src/index.ts @@ -21,7 +21,7 @@ export { CODAMA_ERROR__DYNAMIC_CLIENT__NODE_REFERENCE_NOT_FOUND, CODAMA_ERROR__DYNAMIC_CLIENT__PDA_NOT_FOUND, CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, - CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, + CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_NODE, CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_OPTIONAL_ACCOUNT_STRATEGY, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT, diff --git a/packages/dynamic-codecs/README.md b/packages/dynamic-codecs/README.md index e4286b523..1be27c792 100644 --- a/packages/dynamic-codecs/README.md +++ b/packages/dynamic-codecs/README.md @@ -69,35 +69,35 @@ const codec = getNodeValueCodec([root, program, definedType], { bytesEncoding: ' Values are raw JavaScript values that stay close to the bytes. For instance, a fixed point decodes to its raw integer, not to a decimal number. Types such as `Uint8Array`, `Set` or `Map` are avoided in decoded values to keep them JSON compatible, with the exception of `bigint`. When encoding, a few more inputs are accepted for convenience, as described in the notes below. -| Node | Example | Notes | -| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------- | -| [`IntegerTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/IntegerTypeNode.md) | `42n` | Always decodes to a `bigint`, whatever its size. Encodes from a `number` or a `bigint`. | -| [`FloatTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/FloatTypeNode.md) | `1.5` | | -| [`FixedPointTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/FixedPointTypeNode.md) | `150n` | The raw integer, e.g. `1.5` with a scale of `2`. | -| [`DateTimeTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/DateTimeTypeNode.md) | `1700000000n` | The raw integer, in ticks. | -| [`DurationTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/DurationTypeNode.md) | `1500n` | The raw integer, in ticks. | -| [`BooleanTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/BooleanTypeNode.md) | `true` | | -| [`StringTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/StringTypeNode.md) | `"Hello World"` | Uses the encoding of the node. | -| [`BytesTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/BytesTypeNode.md) | `["base64", "SGVsbG8="]` | Also encodes from a `Uint8Array`. Decodes using the `bytesEncoding` option. | -| [`PublicKeyTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/PublicKeyTypeNode.md) | `"9BbWp6tcX9MEGSUEpNXfspYxYsWCxE9FgRkAc3RpftkT"` | A base58 string. | -| [`StructTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/StructTypeNode.md) | `{ first_name: "John", age: 42n }` | Keys are the raw field identifiers. See [Default values](#default-values). | -| [`TupleTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/TupleTypeNode.md) | `["John", 42n]` | | -| [`EnumTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/EnumTypeNode.md) | `{ __kind: "move", __discriminator: 2, data: { x: 1n } }` | See [Enums](#enums). Variants without data also encode from their identifier. | -| [`ArrayTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/ArrayTypeNode.md) | `[1n, 2n, 3n]` | | -| [`SetTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/SetTypeNode.md) | `[1n, 2n, 3n]` | Same as arrays. | -| [`MapTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/MapTypeNode.md) | `{ key1: "value1", key2: "value2" }` | An object. | -| [`OptionTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/OptionTypeNode.md) | `{ __option: "Some", value: 42n }` or `{ __option: "None" }` | Option objects, rather than `T \| null`, keep nested options distinct. | -| [`RemainderOptionTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/RemainderOptionTypeNode.md) | `{ __option: "Some", value: 42n }` or `{ __option: "None" }` | Same as options. | -| [`ZeroableOptionTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/ZeroableOptionTypeNode.md) | `{ __option: "Some", value: 42n }` or `{ __option: "None" }` | Same as options. | -| [`StructFieldTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/StructFieldTypeNode.md) | - | Same as `node.type`. | -| [`EnumVariantTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/EnumVariantTypeNode.md) | - | `{ __kind, data }`, prefixed by the enum discriminator when the path includes the enum. | -| [`DefinedTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/DefinedTypeNode.md) | - | Same as `node.type`. | -| [`AccountNode`](https://github.com/codama-idl/spec/blob/main/docs/AccountNode.md) | - | Same as `node.data`. | -| [`EventNode`](https://github.com/codama-idl/spec/blob/main/docs/EventNode.md) | - | Same as `node.data`. | -| [`InstructionNode`](https://github.com/codama-idl/spec/blob/main/docs/InstructionNode.md) | - | Same as `node.data`. Instructions without data encode to empty bytes. | -| [`DefinedTypeLinkNode`](https://github.com/codama-idl/spec/blob/main/docs/linkNodes/DefinedTypeLinkNode.md) | - | Same as the linked type, with the transforms of the link on top. | -| [`AccountLinkNode`](https://github.com/codama-idl/spec/blob/main/docs/linkNodes/AccountLinkNode.md) | - | Same as the linked account. | -| [`InstructionLinkNode`](https://github.com/codama-idl/spec/blob/main/docs/linkNodes/InstructionLinkNode.md) | - | Same as the linked instruction. | +| Node | Example | Notes | +| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| [`IntegerTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/IntegerTypeNode.md) | `42n` | Always decodes to a `bigint`, whatever its size. Encodes from an integer `number` or a `bigint`. | +| [`FloatTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/FloatTypeNode.md) | `1.5` | | +| [`FixedPointTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/FixedPointTypeNode.md) | `150n` | The raw integer, e.g. `1.5` with a scale of `2`. | +| [`DateTimeTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/DateTimeTypeNode.md) | `1700000000n` | The raw integer, in ticks. | +| [`DurationTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/DurationTypeNode.md) | `1500n` | The raw integer, in ticks. | +| [`BooleanTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/BooleanTypeNode.md) | `true` | | +| [`StringTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/StringTypeNode.md) | `"Hello World"` | Uses the encoding of the node. | +| [`BytesTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/BytesTypeNode.md) | `["base64", "SGVsbG8="]` | Also encodes from a `Uint8Array`. Decodes using the `bytesEncoding` option. | +| [`PublicKeyTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/PublicKeyTypeNode.md) | `"9BbWp6tcX9MEGSUEpNXfspYxYsWCxE9FgRkAc3RpftkT"` | A base58 string. | +| [`StructTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/StructTypeNode.md) | `{ first_name: "John", age: 42n }` | Keys are the raw field identifiers. See [Default values](#default-values). | +| [`TupleTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/TupleTypeNode.md) | `["John", 42n]` | | +| [`EnumTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/EnumTypeNode.md) | `{ __kind: "move", __discriminator: 2, data: { x: 1n } }` | See [Enums](#enums). Variants without data also encode from their identifier. | +| [`ArrayTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/ArrayTypeNode.md) | `[1n, 2n, 3n]` | | +| [`SetTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/SetTypeNode.md) | `[1n, 2n, 3n]` | Same as arrays. | +| [`MapTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/MapTypeNode.md) | `{ key1: "value1", key2: "value2" }` | An object. | +| [`OptionTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/OptionTypeNode.md) | `{ __option: "Some", value: 42n }` or `{ __option: "None" }` | Option objects, rather than `T \| null`, keep nested options distinct. Also encodes from a value, `null` or `undefined`. | +| [`RemainderOptionTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/RemainderOptionTypeNode.md) | `{ __option: "Some", value: 42n }` or `{ __option: "None" }` | Same as options. | +| [`ZeroableOptionTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/ZeroableOptionTypeNode.md) | `{ __option: "Some", value: 42n }` or `{ __option: "None" }` | Same as options. | +| [`StructFieldTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/StructFieldTypeNode.md) | - | Same as `node.type`. | +| [`EnumVariantTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/EnumVariantTypeNode.md) | - | `{ __kind, data }`, prefixed by the enum discriminator when the path includes the enum. | +| [`DefinedTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/DefinedTypeNode.md) | - | Same as `node.type`. | +| [`AccountNode`](https://github.com/codama-idl/spec/blob/main/docs/AccountNode.md) | - | Same as `node.data`. | +| [`EventNode`](https://github.com/codama-idl/spec/blob/main/docs/EventNode.md) | - | Same as `node.data`. | +| [`InstructionNode`](https://github.com/codama-idl/spec/blob/main/docs/InstructionNode.md) | - | Same as `node.data`. Instructions without data encode to empty bytes. | +| [`DefinedTypeLinkNode`](https://github.com/codama-idl/spec/blob/main/docs/linkNodes/DefinedTypeLinkNode.md) | - | Same as the linked type, with the transforms of the link on top. | +| [`AccountLinkNode`](https://github.com/codama-idl/spec/blob/main/docs/linkNodes/AccountLinkNode.md) | - | Same as the linked account. | +| [`InstructionLinkNode`](https://github.com/codama-idl/spec/blob/main/docs/linkNodes/InstructionLinkNode.md) | - | Same as the linked instruction. | ### Enums @@ -145,6 +145,20 @@ codec.encode({ amount: 42, fee: 9 }); // 0x032a0009 Default values are only evaluated when needed. For instance, an `injectedValueNode` default only throws when it is not provided and the field is missing from the input. +A missing struct encodes as a struct whose fields are all missing, so their default values apply, and a missing option encodes as `None`. + +### Invalid values + +Encoding throws a `CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE` error when a value does not match its type, e.g. a string for an integer or a missing field without a default value, rather than encoding unexpected bytes. Structs and maps must be plain objects, so `Map`s or class instances are rejected. Its `nodePath` context is the path of the node that rejected the value, from the root. + +```ts +const codec = getNodeValueCodec([root, program, instruction]); +codec.encode({ amount: 'x' }); +// throws with { nodePath: [root, program, instruction, data, amountField, amountType], nodeKind: 'integerTypeNode', … } +``` + +Linked types report the path of their definition, e.g. `[root, program, definedType, …]`, rather than the path of the link. + ### Transforms The `transforms` of a type node are applied in order, from the innermost to the outermost. Here, the size prefix wraps the fixed-size string. diff --git a/packages/dynamic-codecs/package.json b/packages/dynamic-codecs/package.json index 2f2e05e95..e4dc9ed07 100644 --- a/packages/dynamic-codecs/package.json +++ b/packages/dynamic-codecs/package.json @@ -56,6 +56,7 @@ "@codama/fragments": "workspace:*", "@codama/nodes": "workspace:*", "@codama/visitors-core": "workspace:*", + "@solana/addresses": "^8.4.0", "@solana/codecs": "^8.4.0" }, "devDependencies": { diff --git a/packages/dynamic-codecs/src/codecs.ts b/packages/dynamic-codecs/src/codecs.ts index a83c19eee..1d1f513c3 100644 --- a/packages/dynamic-codecs/src/codecs.ts +++ b/packages/dynamic-codecs/src/codecs.ts @@ -1,6 +1,6 @@ import { CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, - CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, + CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, CODAMA_ERROR__UNRECOGNIZED_BYTES_ENCODING, CODAMA_ERROR__UNRECOGNIZED_NUMBER_FORMAT, CodamaError, @@ -38,6 +38,7 @@ import { visit, Visitor, } from '@codama/visitors-core'; +import { isAddress } from '@solana/addresses'; import { addCodecSentinel, addCodecSizePrefix, @@ -82,6 +83,7 @@ import { transformCodec, } from '@solana/codecs'; +import { assertValueType, formatValueType, getUnexpectedValueTypeError, isObjectRecord } from './validation'; import { getValueNodeVisitor } from './values'; /** The node kinds a codec can be created for. */ @@ -107,10 +109,29 @@ type EnumVariantValue = { __discriminator?: number; __kind: string; data?: unkno /** Bytes as `[encoding, data]` tuples, e.g. `["base16", "0102"]`, or as raw bytes when encoding. */ type BytesValue = ReadonlyUint8Array | Uint8Array | [BytesEncoding, string]; +// Derived from a record so that adding a bytes encoding without listing it here is a type error. +const BYTES_ENCODINGS: readonly unknown[] = Object.keys({ + base16: true, + base58: true, + base64: true, + utf8: true, +} satisfies Record); + function isUint8Array(value: unknown): value is ReadonlyUint8Array | Uint8Array { return value instanceof Uint8Array; } +function isBytesValue(value: unknown): value is BytesValue { + if (isUint8Array(value)) return true; + return ( + Array.isArray(value) && value.length === 2 && BYTES_ENCODINGS.includes(value[0]) && typeof value[1] === 'string' + ); +} + +function isInteger(value: unknown): value is bigint | number { + return typeof value === 'bigint' || (typeof value === 'number' && Number.isInteger(value)); +} + /** * 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 }`. @@ -118,6 +139,12 @@ function isUint8Array(value: unknown): value is ReadonlyUint8Array | Uint8Array * 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]); @@ -182,6 +209,16 @@ 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), + ); + + // Options encode missing values as `None`, like `null`. + const encodeMissingAsNone = (codec: Codec): Codec => + transformCodec(codec, value => (value === undefined ? null : value)); + const getCollectionCodec = (item: Codec, count: CountNode): Codec => { switch (count.kind) { case 'fixedCountNode': @@ -257,16 +294,17 @@ export function getNodeValueCodecVisitor( return visitLinkedNode(node); }, visitArrayType(node) { - return getCollectionCodec(visit(node.item, this), node.count) as Codec; + return getArrayLikeCodec(visit(node.item, this), node.count); }, visitBooleanType(node) { const size = visit(node.size, this) as FixedSizeNumberCodec; - return getBooleanCodec({ size }) as Codec; + const codec = getBooleanCodec({ size }) as Codec; + return assertValueType(codec, stack.getPath(), 'boolean', value => typeof value === 'boolean'); }, visitBytesType() { // Bytes decode as `[encoding, data]` tuples, e.g. `["base64", "SGVsbG8="]`, rather // than `Uint8Arrays` in order to be compatible with JSON. Both encode. - return createCodec({ + const codec = createCodec({ getSizeFromValue: value => { if (isUint8Array(value)) return value.length; const [encoding, data] = value; @@ -285,6 +323,7 @@ export function getNodeValueCodecVisitor( return getCodecFromBytesEncoding(encoding).write(data, bytes, offset); }, }) as Codec; + return assertValueType(codec, stack.getPath(), 'Uint8Array | [BytesEncoding, string]', isBytesValue); }, visitDateTimeType(node) { return visit(node.number, this); @@ -303,15 +342,17 @@ export function getNodeValueCodecVisitor( 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 union = getUnionCodec( variants.map(variant => visit(variant, this) as Codec), (value: EnumVariantValue) => { const index = variants.findIndex(variant => variant.identifier === value.__kind); if (index < 0) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, { + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, { actualType: `variant '${String(value.__kind)}'`, expectedType: `one of [${variants.map(variant => variant.identifier).join(', ')}]`, nodeKind: 'enumTypeNode', + nodePath, }); } return index; @@ -319,22 +360,40 @@ 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: EnumVariantValue | string) => - typeof value === 'string' ? { __kind: value } : value, - ) as Codec; + 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; }, 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_ARGUMENT_TYPE, { + 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; @@ -369,7 +428,10 @@ export function getNodeValueCodecVisitor( return visit(node.number, this); }, visitFloatType(node) { - return getFloatCodec(node) as Codec; + const codec = getFloatCodec(node) as Codec; + return assertValueType(codec, stack.getPath(), 'number | bigint', value => + ['bigint', 'number'].includes(typeof value), + ); }, visitInstruction(node) { return node.data ? visit(node.data, this) : (getUnitCodec() as Codec); @@ -378,38 +440,47 @@ export function getNodeValueCodecVisitor( return visitLinkedNode(node); }, visitIntegerType(node) { - return getIntegerCodec(node) as Codec; + const codec = getIntegerCodec(node) as Codec; + return assertValueType(codec, stack.getPath(), 'integer (number | bigint)', isInteger); }, visitMapType(node) { const entry = getTupleCodec([visit(node.key, this), visit(node.value, this)]); // Maps are represented as objects in order to be compatible with JSON. - return transformCodec( + 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); }, visitOptionType(node) { const item = visit(node.item, this); const prefix = visit(node.prefix, this) as NumberCodec; if (node.fixed) { assertIsFixedSize(item); - return getOptionCodec(item, { noneValue: 'zeroes', prefix }) as Codec; + return encodeMissingAsNone(getOptionCodec(item, { noneValue: 'zeroes', prefix }) as Codec); } - return getOptionCodec(item, { prefix }) as Codec; + return encodeMissingAsNone(getOptionCodec(item, { prefix }) as Codec); }, visitPublicKeyType() { - return fixCodecSize(getBase58Codec(), 32) as Codec; + const codec = fixCodecSize(getBase58Codec(), 32) as Codec; + return assertValueType( + codec, + stack.getPath(), + 'Address', + value => typeof value === 'string' && isAddress(value), + ); }, visitRemainderOptionType(node) { - return getOptionCodec(visit(node.item, this), { prefix: null }) as Codec; + return encodeMissingAsNone(getOptionCodec(visit(node.item, this), { prefix: null }) as Codec); }, visitSetType(node) { // Sets are represented as arrays in order to be compatible with JSON. - return getCollectionCodec(visit(node.item, this), node.count) as Codec; + return getArrayLikeCodec(visit(node.item, this), node.count); }, visitStringType(node) { - return getCodecFromBytesEncoding(node.encoding) as Codec; + const codec = getCodecFromBytesEncoding(node.encoding) as Codec; + return assertValueType(codec, stack.getPath(), 'string', value => typeof value === 'string'); }, visitStructFieldType(node) { const codec = visit(node.type, this); @@ -424,19 +495,27 @@ export function getNodeValueCodecVisitor( }, visitStructType(node) { const fields = (node.fields ?? []).map(field => [field.identifier, visit(field, this)] as const); - return getStructCodec(fields) as Codec; + const codec = assertValueType( + getStructCodec(fields) as Codec, + stack.getPath(), + 'object', + isObjectRecord, + ); + // A missing struct encodes all its fields as missing, so their default values apply. + return transformCodec(codec, value => (value === undefined ? {} : value)); }, visitTupleType(node) { - return getTupleCodec((node.items ?? []).map(item => visit(item, this))) as Codec; + const codec = getTupleCodec((node.items ?? []).map(item => visit(item, this))) as Codec; + return assertValueType(codec, stack.getPath(), 'array', value => Array.isArray(value)); }, visitZeroableOptionType(node) { const item = visit(node.item, this); assertIsFixedSize(item); if (node.zeroValue) { const noneValue = getConstantBytes(node.zeroValue); - return getOptionCodec(item, { noneValue, prefix: null }) as Codec; + return encodeMissingAsNone(getOptionCodec(item, { noneValue, prefix: null }) as Codec); } - return getOptionCodec(item, { noneValue: 'zeroes', prefix: null }) as Codec; + return encodeMissingAsNone(getOptionCodec(item, { noneValue: 'zeroes', prefix: null }) as Codec); }, }; diff --git a/packages/dynamic-codecs/src/validation.ts b/packages/dynamic-codecs/src/validation.ts new file mode 100644 index 000000000..cb4930f73 --- /dev/null +++ b/packages/dynamic-codecs/src/validation.ts @@ -0,0 +1,53 @@ +import { CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, CodamaError } from '@codama/errors'; +import { Node } from '@codama/nodes'; +import { Codec, transformCodec } from '@solana/codecs'; + +/** Describe the type of a value in error messages, e.g. `number (1.5)` or `array (length 2)`. */ +export function formatValueType(value: unknown): string { + if (value === null) return 'null'; + if (typeof value === 'number') return `number (${value})`; + if (value instanceof Uint8Array) return `Uint8Array (length ${value.length})`; + if (Array.isArray(value)) return `array (length ${value.length})`; + return typeof value; +} + +/** + * Whether the value is a plain object, e.g. the value of a struct or a map. Other + * objects, such as arrays, bytes, `Map`s or `Date`s, are rejected since their + * entries are not their own enumerable properties. + */ +export function isObjectRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null) return false; + const prototype = Object.getPrototypeOf(value) as unknown; + return prototype === Object.prototype || prototype === null; +} + +/** The error thrown when the node at the end of `nodePath` cannot encode the given value. */ +export function getUnexpectedValueTypeError( + nodePath: readonly Node[], + expectedType: string, + value: unknown, +): CodamaError { + return new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, { + actualType: formatValueType(value), + expectedType, + nodeKind: nodePath[nodePath.length - 1].kind, + nodePath, + }); +} + +/** + * Reject the values the check does not accept before encoding them, since + * encoders may otherwise silently encode them, e.g. `'abc'` as a `u16` zero. + */ +export function assertValueType( + codec: Codec, + nodePath: readonly Node[], + expectedType: string, + isValid: (value: unknown) => boolean, +): Codec { + return transformCodec(codec, (value: unknown) => { + if (!isValid(value)) throw getUnexpectedValueTypeError(nodePath, expectedType, value); + return value; + }); +} diff --git a/packages/dynamic-codecs/test/codecs/EnumTypeNode.test.ts b/packages/dynamic-codecs/test/codecs/EnumTypeNode.test.ts index d5de9dcd1..a831cb1c2 100644 --- a/packages/dynamic-codecs/test/codecs/EnumTypeNode.test.ts +++ b/packages/dynamic-codecs/test/codecs/EnumTypeNode.test.ts @@ -1,4 +1,4 @@ -import { CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, CodamaError } from '@codama/errors'; +import { CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, CodamaError } from '@codama/errors'; import { enumTypeNode, enumVariantTypeNode, @@ -142,27 +142,28 @@ test('it encodes variants without data from their identifier', () => { }); test('it throws when encoding an unknown variant', () => { - const codec = getNodeValueCodec([ - enumTypeNode([enumVariantTypeNode('frozen'), enumVariantTypeNode('initialized')]), - ]); + const enumType = enumTypeNode([enumVariantTypeNode('frozen'), enumVariantTypeNode('initialized')]); + const codec = getNodeValueCodec([enumType]); expect(() => codec.encode('Frozen')).toThrow( - new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, { + new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, { actualType: "variant 'Frozen'", expectedType: 'one of [frozen, initialized]', nodeKind: 'enumTypeNode', + nodePath: [enumType], }), ); expect(() => codec.encode({ __kind: 'thawed' })).toThrow(/variant 'thawed'/); }); test('it throws when encoding a variant with data without its data', () => { - const codec = getNodeValueCodec([ - enumTypeNode([enumVariantTypeNode('quit'), enumVariantTypeNode('move', { data: integerTypeNode('u8') })]), - ]); - const error = new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, { + const move = enumVariantTypeNode('move', { data: integerTypeNode('u8') }); + const enumType = enumTypeNode([enumVariantTypeNode('quit'), move]); + const codec = getNodeValueCodec([enumType]); + const error = new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, { actualType: "variant 'move' without data", expectedType: "{ __kind: 'move', data }", nodeKind: 'enumVariantTypeNode', + nodePath: [enumType, move], }); expect(() => codec.encode('move')).toThrow(error); expect(() => codec.encode({ __kind: 'move' })).toThrow(error); diff --git a/packages/dynamic-codecs/test/codecs/EnumVariantTypeNode.test.ts b/packages/dynamic-codecs/test/codecs/EnumVariantTypeNode.test.ts index 8d682cef8..2818522b9 100644 --- a/packages/dynamic-codecs/test/codecs/EnumVariantTypeNode.test.ts +++ b/packages/dynamic-codecs/test/codecs/EnumVariantTypeNode.test.ts @@ -1,6 +1,6 @@ import { CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, - CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, + CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, CodamaError, } from '@codama/errors'; import { @@ -72,15 +72,18 @@ test('it throws when the variant is not part of its parent enum', () => { }); test('it throws when a variant with data is encoded without data', () => { - const codec = getNodeValueCodec([enumVariantTypeNode('move', { data: integerTypeNode('u8') })]); - const expected = expect.objectContaining({ - context: { - __code: CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, - actualType: "variant 'move' without data", - expectedType: "{ __kind: 'move', data }", - nodeKind: 'enumVariantTypeNode', - }, - }); - expect(() => codec.encode({ __kind: 'move', data: undefined })).toThrow(expected); - expect(() => codec.encode('move')).toThrow(expected); + const move = enumVariantTypeNode('move', { data: integerTypeNode('u8') }); + const codec = getNodeValueCodec([move]); + const context = { + __code: CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, + actualType: "variant 'move' without data", + expectedType: "{ __kind: 'move', data }", + nodeKind: 'enumVariantTypeNode', + nodePath: [move], + }; + expect(() => codec.encode({ __kind: 'move', data: undefined })).toThrow(expect.objectContaining({ context })); + // Unlike enums, standalone variants are not encoded from their identifier. + expect(() => codec.encode('move')).toThrow( + expect.objectContaining({ context: { ...context, actualType: 'string' } }), + ); }); diff --git a/packages/dynamic-codecs/test/codecs/StructTypeNode.test.ts b/packages/dynamic-codecs/test/codecs/StructTypeNode.test.ts index ccc68a532..361fd3260 100644 --- a/packages/dynamic-codecs/test/codecs/StructTypeNode.test.ts +++ b/packages/dynamic-codecs/test/codecs/StructTypeNode.test.ts @@ -9,7 +9,9 @@ import { stringTypeNode, stringValueNode, structFieldTypeNode, + structFieldValueNode, structTypeNode, + structValueNode, } from '@codama/nodes'; import { expect, test } from 'vitest'; @@ -69,6 +71,27 @@ test('it encodes the default value of optional fields when missing', () => { } }); +test('it encodes missing structs as structs with missing fields', () => { + const codec = getNodeValueCodec([ + structTypeNode([ + structFieldTypeNode({ + defaultValue: integerValueNode('3'), + defaultValueStrategy: 'omitted', + identifier: 'discriminator', + type: integerTypeNode('u8'), + }), + structFieldTypeNode({ + defaultValue: structValueNode([structFieldValueNode('fee', integerValueNode('5'))]), + identifier: 'config', + type: structTypeNode([structFieldTypeNode({ identifier: 'fee', type: integerTypeNode('u16') })]), + }), + ]), + ]); + expect(codec.encode(undefined)).toStrictEqual(hex('030500')); + expect(codec.encode({ config: undefined })).toStrictEqual(hex('030500')); + expect(() => codec.encode({ config: {} })).toThrow(); +}); + test('it encodes default values of any kind', () => { const codec = getNodeValueCodec([ structTypeNode([ diff --git a/packages/dynamic-codecs/test/validation.test.ts b/packages/dynamic-codecs/test/validation.test.ts new file mode 100644 index 000000000..870472bba --- /dev/null +++ b/packages/dynamic-codecs/test/validation.test.ts @@ -0,0 +1,233 @@ +import { CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE } from '@codama/errors'; +import { + arrayTypeNode, + booleanTypeNode, + bytesTypeNode, + dateTimeTypeNode, + definedTypeLinkNode, + definedTypeNode, + enumTypeNode, + enumVariantTypeNode, + floatTypeNode, + instructionNode, + integerTypeNode, + mapTypeNode, + Node, + optionTypeNode, + prefixedCountNode, + programNode, + publicKeyTypeNode, + RegisteredTypeNode, + remainderOptionTypeNode, + rootNode, + setTypeNode, + stringTypeNode, + structFieldTypeNode, + structTypeNode, + tupleTypeNode, + zeroableOptionTypeNode, +} from '@codama/nodes'; +import { NodePath } from '@codama/visitors-core'; +import { describe, expect, test } from 'vitest'; + +import { getNodeValueCodec } from '../src'; +import { hex } from './_setup'; + +const u8 = integerTypeNode('u8'); +const u16 = integerTypeNode('u16'); +const u8Array = arrayTypeNode(u8, prefixedCountNode(integerTypeNode('u32'))); + +/** Match an `UNEXPECTED_VALUE_TYPE` error with exactly the given context. */ +function valueTypeError(context: { + actualType: string; + expectedType: string; + nodeKind: Node['kind']; + nodePath: readonly Node[]; +}) { + return expect.objectContaining({ + context: { __code: CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, ...context }, + }); +} + +/** A short description of a value for test titles, e.g. `Map` or `'abc'`. */ +function actualTypeOf(value: unknown): string { + if (typeof value === 'object' && value !== null) return value.constructor?.name ?? 'object'; + return typeof value === 'string' ? `'${value}'` : String(value); +} + +describe('value types', () => { + const cases: [RegisteredTypeNode, unknown, string, string][] = [ + [u16, 'abc', 'integer (number | bigint)', 'string'], + [u16, 1.5, 'integer (number | bigint)', 'number (1.5)'], + [u16, null, 'integer (number | bigint)', 'null'], + [u16, undefined, 'integer (number | bigint)', 'undefined'], + [integerTypeNode('u64'), '12', 'integer (number | bigint)', 'string'], + [floatTypeNode('f32'), 'abc', 'number | bigint', 'string'], + [booleanTypeNode(), 'no', 'boolean', 'string'], + [stringTypeNode('utf8'), 42, 'string', 'number (42)'], + [publicKeyTypeNode(), 'abc', 'Address', 'string'], + [bytesTypeNode(), 'abc', 'Uint8Array | [BytesEncoding, string]', 'string'], + [bytesTypeNode(), ['hex', '00'], 'Uint8Array | [BytesEncoding, string]', 'array (length 2)'], + [u8Array, 'abc', 'array', 'string'], + [setTypeNode(u8, prefixedCountNode(u16)), {}, 'array', 'object'], + [tupleTypeNode([u8]), 42, 'array', 'number (42)'], + [mapTypeNode(stringTypeNode('utf8'), u8, prefixedCountNode(u16)), [], 'object', 'array (length 0)'], + [mapTypeNode(stringTypeNode('utf8'), u8, prefixedCountNode(u16)), new Map([['a', 1]]), 'object', 'object'], + [structTypeNode([]), null, 'object', 'null'], + [structTypeNode([]), new Date(0), 'object', 'object'], + [structTypeNode([]), new Set(), 'object', 'object'], + [enumVariantTypeNode('quit'), 42, "{ __kind: 'quit' }", 'number (42)'], + [enumVariantTypeNode('quit'), { __kind: 'move' }, "{ __kind: 'quit' }", "variant 'move'"], + [enumVariantTypeNode('move', { data: u8 }), 42, "{ __kind: 'move', data }", 'number (42)'], + [ + enumVariantTypeNode('move', { data: u8 }), + { __kind: 'quit', data: 1 }, + "{ __kind: 'move', data }", + "variant 'quit'", + ], + [enumTypeNode([enumVariantTypeNode('quit')]), 42, 'string | { __kind: string }', 'number (42)'], + ]; + + test.each(cases.map(([node, value, ...rest]) => [node.kind, actualTypeOf(value), node, value, ...rest] as const))( + '%s rejects %s', + (_kind, _title, node, value, expectedType, actualType) => { + const codec = getNodeValueCodec([node] as NodePath); + expect(() => codec.encode(value)).toThrow( + valueTypeError({ actualType, expectedType, nodeKind: node.kind, nodePath: [node] }), + ); + }, + ); + + test('it accepts plain objects without prototypes', () => { + const struct = structTypeNode([structFieldTypeNode({ identifier: 'amount', type: u8 })]); + const value = Object.assign(Object.create(null) as object, { amount: 42 }); + expect(getNodeValueCodec([struct]).encode(value)).toStrictEqual(hex('2a')); + }); + + test('it accepts integers as numbers or bigints', () => { + const codec = getNodeValueCodec([u16]); + expect(codec.encode(42)).toStrictEqual(hex('2a00')); + expect(codec.encode(42n)).toStrictEqual(hex('2a00')); + }); + + test('it encodes missing options as None', () => { + expect(getNodeValueCodec([optionTypeNode(u8)]).encode(undefined)).toStrictEqual(hex('00')); + expect(getNodeValueCodec([zeroableOptionTypeNode(u16)]).encode(undefined)).toStrictEqual(hex('0000')); + expect(getNodeValueCodec([remainderOptionTypeNode(u8)]).encode(undefined)).toStrictEqual(hex('')); + const struct = structTypeNode([structFieldTypeNode({ identifier: 'fee', type: optionTypeNode(u8) })]); + expect(getNodeValueCodec([struct]).encode({})).toStrictEqual(hex('00')); + }); + + test('it rejects missing values of other types', () => { + const amount = structFieldTypeNode({ identifier: 'amount', type: u8 }); + const struct = structTypeNode([amount]); + expect(() => getNodeValueCodec([struct]).encode({})).toThrow( + valueTypeError({ + actualType: 'undefined', + expectedType: 'integer (number | bigint)', + nodeKind: 'integerTypeNode', + nodePath: [struct, amount, u8], + }), + ); + }); +}); + +describe('node paths', () => { + const integerError = (nodePath: readonly Node[]) => + valueTypeError({ + actualType: 'string', + expectedType: 'integer (number | bigint)', + nodeKind: 'integerTypeNode', + nodePath, + }); + + test('it reports the path of the rejecting node through structs, arrays and tuples', () => { + // Given nested structs, arrays and tuples. + const feeItem = integerTypeNode('u8'); + const fees = arrayTypeNode(feeItem, prefixedCountNode(integerTypeNode('u32'))); + const feesField = structFieldTypeNode({ identifier: 'fees', type: fees }); + const pairSecond = integerTypeNode('u8'); + const pair = tupleTypeNode([integerTypeNode('u8'), pairSecond]); + const pairField = structFieldTypeNode({ identifier: 'pair', type: pair }); + const config = structTypeNode([feesField, pairField]); + const configField = structFieldTypeNode({ identifier: 'config', type: config }); + const struct = structTypeNode([configField]); + const codec = getNodeValueCodec([struct]); + + // Then errors report the path of the node that rejected the value. + expect(() => codec.encode({ config: { fees: [1, 'x'], pair: [1, 2] } })).toThrow( + integerError([struct, configField, config, feesField, fees, feeItem]), + ); + expect(() => codec.encode({ config: { fees: [], pair: [1, 'x'] } })).toThrow( + integerError([struct, configField, config, pairField, pair, pairSecond]), + ); + }); + + test('it reports paths from the root for instruction data', () => { + const amountType = integerTypeNode('u16'); + const amount = structFieldTypeNode({ identifier: 'amount', type: amountType }); + const data = structTypeNode([amount]); + const instruction = instructionNode({ data, identifier: 'transfer' }); + const program = programNode({ identifier: 'myProgram', instructions: [instruction], publicKey: '1111' }); + const root = rootNode(program); + + expect(() => getNodeValueCodec([root, program, instruction]).encode({ amount: 'x' })).toThrow( + integerError([root, program, instruction, data, amount, amountType]), + ); + }); + + test('it reports paths through the definitions of linked types', () => { + // Given a struct using a linked struct type. + const feeType = integerTypeNode('u16'); + const fee = structFieldTypeNode({ identifier: 'fee', type: feeType }); + const configStruct = structTypeNode([fee]); + const config = definedTypeNode({ identifier: 'config', type: configStruct }); + const struct = structTypeNode([ + structFieldTypeNode({ identifier: 'config', type: definedTypeLinkNode('config') }), + ]); + const account = definedTypeNode({ identifier: 'account', type: struct }); + const program = programNode({ definedTypes: [config, account], identifier: 'myProgram', publicKey: '1111' }); + const root = rootNode(program); + + // Then the path goes through the definition of the linked type. + expect(() => getNodeValueCodec([root, program, account]).encode({ config: { fee: 'x' } })).toThrow( + integerError([root, program, config, configStruct, fee, feeType]), + ); + }); + + test('it reports paths through options, enums, maps and date-times', () => { + // Given fields of various types wrapping integers. + const optionItem = integerTypeNode('u8'); + const option = optionTypeNode(optionItem); + const optionField = structFieldTypeNode({ identifier: 'fee', type: option }); + const amountType = integerTypeNode('u8'); + const amount = structFieldTypeNode({ identifier: 'amount', type: amountType }); + const payload = structTypeNode([amount]); + const move = enumVariantTypeNode('move', { data: payload }); + const enumType = enumTypeNode([move]); + const enumField = structFieldTypeNode({ identifier: 'action', type: enumType }); + const mapValue = integerTypeNode('u8'); + const map = mapTypeNode(stringTypeNode('utf8'), mapValue, prefixedCountNode(u16)); + const mapField = structFieldTypeNode({ identifier: 'balances', type: map }); + const timestamp = integerTypeNode('i64'); + const dateTime = dateTimeTypeNode(timestamp); + const dateTimeField = structFieldTypeNode({ identifier: 'createdAt', type: dateTime }); + const struct = structTypeNode([optionField, enumField, mapField, dateTimeField]); + const codec = getNodeValueCodec([struct]); + const valid = { action: { __kind: 'move', data: { amount: 1 } }, balances: {}, createdAt: 0, fee: null }; + + // Then errors report the path of the node that rejected the value. + expect(() => codec.encode({ ...valid, fee: 'x' })).toThrow( + integerError([struct, optionField, option, optionItem]), + ); + expect(() => codec.encode({ ...valid, action: { __kind: 'move', data: { amount: 'x' } } })).toThrow( + integerError([struct, enumField, enumType, move, payload, amount, amountType]), + ); + expect(() => codec.encode({ ...valid, balances: { alice: 'x' } })).toThrow( + integerError([struct, mapField, map, mapValue]), + ); + expect(() => codec.encode({ ...valid, createdAt: 'x' })).toThrow( + integerError([struct, dateTimeField, dateTime, timestamp]), + ); + }); +}); diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts index e1d4638ac..c24f0df77 100644 --- a/packages/errors/src/codes.ts +++ b/packages/errors/src/codes.ts @@ -93,7 +93,7 @@ export const CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA = 2500010; export const CODAMA_ERROR__DYNAMIC_CLIENT__DATA_MISSING = 2500011; export const CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING = 2500012; export const CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ARGUMENT_INPUT = 2500013; -export const CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE = 2500014; +export const CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE = 2500014; export const CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_ARGUMENT = 2500015; /** @deprecated No longer thrown as of Codama v2, which removed `resolverValueNode`. */ export const CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER = 2500016; @@ -152,7 +152,7 @@ export type CodamaErrorCode = | typeof CODAMA_ERROR__DYNAMIC_CLIENT__PDA_NOT_FOUND | typeof CODAMA_ERROR__DYNAMIC_CLIENT__PDA_SEED_MISSING | typeof CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE - | typeof CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE + | typeof CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE | typeof CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_NODE | typeof CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_OPTIONAL_ACCOUNT_STRATEGY | typeof CODAMA_ERROR__ENUM_VARIANT_NOT_FOUND diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index 6493a18df..f55b9bbba 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -54,7 +54,7 @@ import { CODAMA_ERROR__DYNAMIC_CLIENT__PDA_NOT_FOUND, CODAMA_ERROR__DYNAMIC_CLIENT__PDA_SEED_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, - CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, + CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_NODE, CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_OPTIONAL_ACCOUNT_STRATEGY, CODAMA_ERROR__ENUM_VARIANT_NOT_FOUND, @@ -213,10 +213,13 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{ actualType: string; expectedType: string; }; - [CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE]: { + [CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE]: { actualType: string; expectedType: string; + /** The kind of the node that rejected the value. */ nodeKind: NodeKind; + /** The path of the node that rejected the value, from the root. */ + nodePath: readonly Node[]; }; [CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_NODE]: { nodeKind: NodeKind; diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index b3abdb7fb..2be354292 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -33,7 +33,7 @@ import { CODAMA_ERROR__DYNAMIC_CLIENT__PDA_NOT_FOUND, CODAMA_ERROR__DYNAMIC_CLIENT__PDA_SEED_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, - CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, + CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_NODE, CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_OPTIONAL_ACCOUNT_STRATEGY, CODAMA_ERROR__ENUM_VARIANT_NOT_FOUND, @@ -121,7 +121,7 @@ export const CodamaErrorMessages: Readonly<{ [CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE]: 'Expected [$expectedType] for account [$accountName], but received [$actualType].', - [CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE]: + [CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE]: 'Expected [$expectedType] for [$nodeKind], but received [$actualType].', [CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_NODE]: 'Unsupported node kind [$nodeKind].', [CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_OPTIONAL_ACCOUNT_STRATEGY]: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 352420d51..bf5ba7aeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: '@codama/visitors-core': specifier: workspace:* version: link:../visitors-core + '@solana/addresses': + specifier: ^8.4.0 + version: 8.4.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2) '@solana/codecs': specifier: ^8.4.0 version: 8.4.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2)