Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/dynamic-codecs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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' });
Expand Down
11 changes: 9 additions & 2 deletions packages/dynamic-codecs/src/codecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<unknown>;
Expand Down
42 changes: 40 additions & 2 deletions packages/dynamic-codecs/src/validation.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<unknown>,
item: Encoder<unknown>,
nodePath: readonly Node[],
): Codec<unknown> {
const base16 = getBase16Decoder();
return transformCodec(codec, (value: unknown) => {
if (!Array.isArray(value)) return value;
const indices = new Map<string, number>();
value.forEach((itemValue: unknown, index) => {
const key = base16.decode(item.encode(itemValue));
Comment thread
lorisleiva marked this conversation as resolved.
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;
});
}
119 changes: 118 additions & 1 deletion packages/dynamic-codecs/test/codecs/SetTypeNode.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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]);
});
Comment thread
lorisleiva marked this conversation as resolved.

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],
},
}),
);
});
2 changes: 2 additions & 0 deletions packages/errors/src/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/errors/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
};
Expand Down
3 changes: 3 additions & 0 deletions packages/errors/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]:
Expand Down
Loading