diff --git a/packages/dynamic-codecs/README.md b/packages/dynamic-codecs/README.md index 1be27c792..49476d610 100644 --- a/packages/dynamic-codecs/README.md +++ b/packages/dynamic-codecs/README.md @@ -84,7 +84,7 @@ Values are raw JavaScript values that stay close to the bytes. For instance, a f | [`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. | +| [`SetTypeNode`](https://github.com/codama-idl/spec/blob/main/docs/typeNodes/SetTypeNode.md) | `[1n, 2n, 3n]` | Same as arrays. Encoding rejects duplicate items, see [Invalid values](#invalid-values). | | [`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. | @@ -151,6 +151,8 @@ A missing struct encodes as a struct whose fields are all missing, so their defa 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. +Sets also throw a `CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM` error when two items encode to the same bytes, e.g. `[42, 42n]`. Its context gives the `index` of the duplicate, the `firstIndex` of the item it equals and the `nodePath` of the set. Decoding keeps duplicates, so existing data reads as it is. + ```ts const codec = getNodeValueCodec([root, program, instruction]); codec.encode({ amount: 'x' }); diff --git a/packages/dynamic-codecs/src/codecs.ts b/packages/dynamic-codecs/src/codecs.ts index 1d1f513c3..eab540cf6 100644 --- a/packages/dynamic-codecs/src/codecs.ts +++ b/packages/dynamic-codecs/src/codecs.ts @@ -83,7 +83,13 @@ import { transformCodec, } from '@solana/codecs'; -import { assertValueType, formatValueType, getUnexpectedValueTypeError, isObjectRecord } from './validation'; +import { + assertUniqueItems, + assertValueType, + formatValueType, + getUnexpectedValueTypeError, + isObjectRecord, +} from './validation'; import { getValueNodeVisitor } from './values'; /** The node kinds a codec can be created for. */ @@ -476,7 +482,8 @@ export function getNodeValueCodecVisitor( }, visitSetType(node) { // Sets are represented as arrays in order to be compatible with JSON. - return getArrayLikeCodec(visit(node.item, this), node.count); + const item = visit(node.item, this); + return assertUniqueItems(getArrayLikeCodec(item, node.count), item, stack.getPath()); }, visitStringType(node) { const codec = getCodecFromBytesEncoding(node.encoding) as Codec; diff --git a/packages/dynamic-codecs/src/validation.ts b/packages/dynamic-codecs/src/validation.ts index cb4930f73..038ed7154 100644 --- a/packages/dynamic-codecs/src/validation.ts +++ b/packages/dynamic-codecs/src/validation.ts @@ -1,6 +1,10 @@ -import { CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, CodamaError } from '@codama/errors'; +import { + CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM, + CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, + CodamaError, +} from '@codama/errors'; import { Node } from '@codama/nodes'; -import { Codec, transformCodec } from '@solana/codecs'; +import { Codec, Encoder, getBase16Decoder, 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 { @@ -51,3 +55,37 @@ export function assertValueType( return value; }); } + +/** + * 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. + * + * 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 { + 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; + }); +} diff --git a/packages/dynamic-codecs/test/codecs/SetTypeNode.test.ts b/packages/dynamic-codecs/test/codecs/SetTypeNode.test.ts index ced2dff36..95fc4bbda 100644 --- a/packages/dynamic-codecs/test/codecs/SetTypeNode.test.ts +++ b/packages/dynamic-codecs/test/codecs/SetTypeNode.test.ts @@ -1,4 +1,20 @@ -import { fixedCountNode, integerTypeNode, prefixedCountNode, remainderCountNode, setTypeNode } from '@codama/nodes'; +import { + CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM, + CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, +} from '@codama/errors'; +import { + fixedCountNode, + integerTypeNode, + integerValueNode, + Node, + prefixedCountNode, + remainderCountNode, + setTypeNode, + stringTypeNode, + structFieldTypeNode, + structTypeNode, + tupleTypeNode, +} from '@codama/nodes'; import { expect, test } from 'vitest'; import { getNodeValueCodec } from '../../src'; @@ -21,3 +37,104 @@ test('it decodes remainder sets', () => { expect(codec.encode([42, 99, 650])).toStrictEqual(hex('2a0063008a02')); expect(codec.decode(hex('2a0063008a02'))).toStrictEqual([42n, 99n, 650n]); }); + +/** Match a `DUPLICATE_SET_ITEM` error with exactly the given context. */ +function duplicateSetItemError(context: { firstIndex: number; index: number; nodePath: readonly Node[] }) { + return expect.objectContaining({ + context: { __code: CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM, ...context }, + }); +} + +test('it rejects duplicate items when encoding', () => { + const set = setTypeNode(integerTypeNode('u16'), prefixedCountNode(integerTypeNode('u32'))); + const codec = getNodeValueCodec([set]); + expect(() => codec.encode([42, 99, 42])).toThrow( + duplicateSetItemError({ firstIndex: 0, index: 2, nodePath: [set] }), + ); +}); + +test('it compares items by their encoded bytes', () => { + const set = setTypeNode(integerTypeNode('u16'), remainderCountNode()); + const codec = getNodeValueCodec([set]); + expect(() => codec.encode([42, 99, 42n])).toThrow( + duplicateSetItemError({ firstIndex: 0, index: 2, nodePath: [set] }), + ); +}); + +test('it rejects duplicate tuple items', () => { + const set = setTypeNode(tupleTypeNode([integerTypeNode('u8'), stringTypeNode('utf8')]), fixedCountNode(3)); + const codec = getNodeValueCodec([set]); + expect(() => + codec.encode([ + [1, 'a'], + [2, 'a'], + [2, 'a'], + ]), + ).toThrow(duplicateSetItemError({ firstIndex: 1, index: 2, nodePath: [set] })); +}); + +test('it rejects struct items that only differ by a field set to its default value', () => { + const set = setTypeNode( + structTypeNode([ + structFieldTypeNode({ identifier: 'id', type: integerTypeNode('u8') }), + structFieldTypeNode({ + defaultValue: integerValueNode('0'), + identifier: 'flags', + type: integerTypeNode('u8'), + }), + ]), + remainderCountNode(), + ); + const codec = getNodeValueCodec([set]); + expect(() => codec.encode([{ id: 1 }, { flags: 0, id: 1 }])).toThrow( + duplicateSetItemError({ firstIndex: 0, index: 1, nodePath: [set] }), + ); +}); + +test('it reports the path of nested sets', () => { + const set = setTypeNode(integerTypeNode('u8'), remainderCountNode()); + const field = structFieldTypeNode({ identifier: 'tags', type: set }); + const struct = structTypeNode([field]); + const codec = getNodeValueCodec([struct]); + expect(() => codec.encode({ tags: [1, 1] })).toThrow( + duplicateSetItemError({ firstIndex: 0, index: 1, nodePath: [struct, field, set] }), + ); +}); + +test('it keeps duplicate items when decoding', () => { + const codec = getNodeValueCodec([setTypeNode(integerTypeNode('u16'), remainderCountNode())]); + expect(codec.decode(hex('2a002a00'))).toStrictEqual([42n, 42n]); +}); + +test('it rejects values that are not arrays as such', () => { + const set = setTypeNode(integerTypeNode('u8'), remainderCountNode()); + const codec = getNodeValueCodec([set]); + expect(() => codec.encode('abc')).toThrow( + expect.objectContaining({ + context: { + __code: CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, + actualType: 'string', + expectedType: 'array', + nodeKind: 'setTypeNode', + nodePath: [set], + }, + }), + ); +}); + +test('it rejects items of the wrong type before checking for duplicates', () => { + const item = integerTypeNode('u8'); + const set = setTypeNode(item, remainderCountNode()); + const codec = getNodeValueCodec([set]); + expect(() => codec.encode([1, 'x', 'x'])).toThrow( + expect.objectContaining({ + context: { + __code: CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, + actualType: 'string', + expectedType: 'integer (number | bigint)', + nodeKind: 'integerTypeNode', + nodePath: [set, item], + }, + }), + ); +}); diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts index 11fcedf29..9537576e9 100644 --- a/packages/errors/src/codes.ts +++ b/packages/errors/src/codes.ts @@ -102,6 +102,7 @@ export const CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT = 2500017; export const CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_NODE = 2500018; export const CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION = 2500019; export const CODAMA_ERROR__DYNAMIC_CLIENT__PDA_SEED_MISSING = 2500020; +export const CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM = 2500021; // Renderers-related errors. // Reserve error codes in the range [2800000-2800999]. @@ -141,6 +142,7 @@ export type CodamaErrorCode = | typeof CODAMA_ERROR__DYNAMIC_CLIENT__CIRCULAR_ACCOUNT_DEPENDENCY | typeof CODAMA_ERROR__DYNAMIC_CLIENT__DATA_MISSING | typeof CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING + | typeof CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM | typeof CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA | typeof CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA | typeof CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index a9b4ffdbc..e2205ac69 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -42,6 +42,7 @@ import { CODAMA_ERROR__DYNAMIC_CLIENT__CIRCULAR_ACCOUNT_DEPENDENCY, CODAMA_ERROR__DYNAMIC_CLIENT__DATA_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING, + CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER, @@ -164,6 +165,14 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{ argumentName: IdentifierString; instructionName: IdentifierString; }; + [CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM]: { + /** The index of the first item the duplicate is equal to. */ + firstIndex: number; + /** The index of the duplicate item. */ + index: number; + /** The path of the set type node that rejected the value, from the root. */ + nodePath: readonly Node[]; + }; [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA]: { accountName: IdentifierString; }; diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index b31096af7..117072697 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -21,6 +21,7 @@ import { CODAMA_ERROR__DYNAMIC_CLIENT__CIRCULAR_ACCOUNT_DEPENDENCY, CODAMA_ERROR__DYNAMIC_CLIENT__DATA_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING, + CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER, @@ -102,6 +103,8 @@ export const CodamaErrorMessages: Readonly<{ [CODAMA_ERROR__DYNAMIC_CLIENT__DATA_MISSING]: 'Missing data [$path] in [$instructionName].', [CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING]: 'Default value is missing for argument [$argumentName] in [$instructionName].', + [CODAMA_ERROR__DYNAMIC_CLIENT__DUPLICATE_SET_ITEM]: + 'Expected the items of a set to be unique, but the item at index [$index] equals the item at index [$firstIndex].', [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA]: 'Failed to derive PDA for account [$accountName].', [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA]: 'Failed to encode the data of [$instructionName].', [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER]: