Skip to content

Commit 2e559ef

Browse files
committed
Reject values of the wrong type when encoding dynamic codecs
1 parent f4b10ab commit 2e559ef

14 files changed

Lines changed: 511 additions & 79 deletions

File tree

‎packages/dynamic-address-resolution/test/resolvers/resolve-pda-address.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
CODAMA_ERROR__DYNAMIC_CLIENT__NODE_REFERENCE_NOT_FOUND,
55
CODAMA_ERROR__DYNAMIC_CLIENT__PDA_SEED_MISSING,
66
CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE,
7+
CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE,
78
CodamaError,
89
} from '@codama/errors';
910
import { type Address, getAddressEncoder, getProgramDerivedAddress } from '@solana/addresses';
@@ -261,6 +262,24 @@ describe('variable seeds', () => {
261262
);
262263
});
263264

265+
test('it throws when a seed value has the wrong type rather than deriving another PDA', async () => {
266+
const data = structTypeNode([structFieldTypeNode({ identifier: 'owner', type: publicKeyTypeNode() })]);
267+
const defaultValue = pdaValueNode(pdaLinkNode('vault'), {
268+
seeds: [pdaSeedValueNode('owner', dataValueNode('owner'))],
269+
});
270+
const { path } = getAccountPath(account('vault', { defaultValue }), { data }, { pdas: [ownerPda] });
271+
await expect(resolveInstructionAccountAddress({ dataInput: { owner: 42 }, path })).rejects.toThrow(
272+
expect.objectContaining({
273+
context: expect.objectContaining({
274+
__code: CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE,
275+
actualType: 'number (42)',
276+
expectedType: 'Address',
277+
nodeKind: 'publicKeyTypeNode',
278+
}),
279+
}),
280+
);
281+
});
282+
264283
test('it throws when a seed value is not provided by the PDA value', async () => {
265284
const { path } = getAccountPath(
266285
account('vault', { defaultValue: pdaValueNode(pdaLinkNode('vault')) }),

‎packages/dynamic-client/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export {
2121
CODAMA_ERROR__DYNAMIC_CLIENT__NODE_REFERENCE_NOT_FOUND,
2222
CODAMA_ERROR__DYNAMIC_CLIENT__PDA_NOT_FOUND,
2323
CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE,
24-
CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE,
24+
CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE,
2525
CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_NODE,
2626
CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_OPTIONAL_ACCOUNT_STRATEGY,
2727
CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT,

‎packages/dynamic-codecs/README.md‎

Lines changed: 43 additions & 29 deletions
Large diffs are not rendered by default.

‎packages/dynamic-codecs/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@codama/fragments": "workspace:*",
5757
"@codama/nodes": "workspace:*",
5858
"@codama/visitors-core": "workspace:*",
59+
"@solana/addresses": "^8.4.0",
5960
"@solana/codecs": "^8.4.0"
6061
},
6162
"devDependencies": {

‎packages/dynamic-codecs/src/codecs.ts‎

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION,
3-
CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE,
3+
CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE,
44
CODAMA_ERROR__UNRECOGNIZED_BYTES_ENCODING,
55
CODAMA_ERROR__UNRECOGNIZED_NUMBER_FORMAT,
66
CodamaError,
@@ -38,6 +38,7 @@ import {
3838
visit,
3939
Visitor,
4040
} from '@codama/visitors-core';
41+
import { isAddress } from '@solana/addresses';
4142
import {
4243
addCodecSentinel,
4344
addCodecSizePrefix,
@@ -82,6 +83,7 @@ import {
8283
transformCodec,
8384
} from '@solana/codecs';
8485

86+
import { assertValueType, formatValueType, getUnexpectedValueTypeError, isObjectRecord } from './validation';
8587
import { getValueNodeVisitor } from './values';
8688

8789
/** The node kinds a codec can be created for. */
@@ -107,17 +109,42 @@ type EnumVariantValue = { __discriminator?: number; __kind: string; data?: unkno
107109
/** Bytes as `[encoding, data]` tuples, e.g. `["base16", "0102"]`, or as raw bytes when encoding. */
108110
type BytesValue = ReadonlyUint8Array | Uint8Array | [BytesEncoding, string];
109111

112+
// Derived from a record so that adding a bytes encoding without listing it here is a type error.
113+
const BYTES_ENCODINGS: readonly unknown[] = Object.keys({
114+
base16: true,
115+
base58: true,
116+
base64: true,
117+
utf8: true,
118+
} satisfies Record<BytesEncoding, true>);
119+
110120
function isUint8Array(value: unknown): value is ReadonlyUint8Array | Uint8Array {
111121
return value instanceof Uint8Array;
112122
}
113123

124+
function isBytesValue(value: unknown): value is BytesValue {
125+
if (isUint8Array(value)) return true;
126+
return (
127+
Array.isArray(value) && value.length === 2 && BYTES_ENCODINGS.includes(value[0]) && typeof value[1] === 'string'
128+
);
129+
}
130+
131+
function isInteger(value: unknown): value is bigint | number {
132+
return typeof value === 'bigint' || (typeof value === 'number' && Number.isInteger(value));
133+
}
134+
114135
/**
115136
* Get a codec for the node at the end of the given path. Values are raw JavaScript
116137
* values, e.g. integers are `bigint`s and enums are `{ __kind, __discriminator, data }`.
117138
*
118139
* The full path, from the root node, is needed to resolve link nodes and injected
119140
* values, e.g. `[root, program, definedType]`.
120141
*
142+
* Encoding rejects values of the wrong type, e.g. a string for an integer, with a
143+
* `DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE` error whose `nodePath` is the path of the
144+
* node that rejected the value. Missing values are only accepted by options, which
145+
* encode them as `None`, structs, whose fields then use their default values, and
146+
* struct fields with default values.
147+
*
121148
* @example
122149
* ```ts
123150
* const codec = getNodeValueCodec([root, program, definedType]);
@@ -182,6 +209,16 @@ export function getNodeValueCodecVisitor(
182209
return stack.visitPath(path, visitor);
183210
};
184211

212+
// Arrays and sets are encoded from arrays.
213+
const getArrayLikeCodec = (item: Codec<unknown>, count: CountNode): Codec<unknown> =>
214+
assertValueType(getCollectionCodec(item, count) as Codec<unknown>, stack.getPath(), 'array', value =>
215+
Array.isArray(value),
216+
);
217+
218+
// Options encode missing values as `None`, like `null`.
219+
const encodeMissingAsNone = (codec: Codec<unknown>): Codec<unknown> =>
220+
transformCodec<unknown, unknown, unknown>(codec, value => (value === undefined ? null : value));
221+
185222
const getCollectionCodec = (item: Codec<unknown>, count: CountNode): Codec<unknown[]> => {
186223
switch (count.kind) {
187224
case 'fixedCountNode':
@@ -257,16 +294,17 @@ export function getNodeValueCodecVisitor(
257294
return visitLinkedNode(node);
258295
},
259296
visitArrayType(node) {
260-
return getCollectionCodec(visit(node.item, this), node.count) as Codec<unknown>;
297+
return getArrayLikeCodec(visit(node.item, this), node.count);
261298
},
262299
visitBooleanType(node) {
263300
const size = visit(node.size, this) as FixedSizeNumberCodec;
264-
return getBooleanCodec({ size }) as Codec<unknown>;
301+
const codec = getBooleanCodec({ size }) as Codec<unknown>;
302+
return assertValueType(codec, stack.getPath(), 'boolean', value => typeof value === 'boolean');
265303
},
266304
visitBytesType() {
267305
// Bytes decode as `[encoding, data]` tuples, e.g. `["base64", "SGVsbG8="]`, rather
268306
// than `Uint8Arrays` in order to be compatible with JSON. Both encode.
269-
return createCodec<BytesValue, [BytesEncoding, string]>({
307+
const codec = createCodec<BytesValue, [BytesEncoding, string]>({
270308
getSizeFromValue: value => {
271309
if (isUint8Array(value)) return value.length;
272310
const [encoding, data] = value;
@@ -285,6 +323,7 @@ export function getNodeValueCodecVisitor(
285323
return getCodecFromBytesEncoding(encoding).write(data, bytes, offset);
286324
},
287325
}) as Codec<unknown>;
326+
return assertValueType(codec, stack.getPath(), 'Uint8Array | [BytesEncoding, string]', isBytesValue);
288327
},
289328
visitDateTimeType(node) {
290329
return visit(node.number, this);
@@ -303,38 +342,58 @@ export function getNodeValueCodecVisitor(
303342
const variants = node.variants ?? [];
304343
// Each variant codec encodes its own discriminator prefix, see `visitEnumVariantType`.
305344
const discriminators = variants.map((variant, index) => variant.discriminator ?? index);
345+
const nodePath = stack.getPath();
306346
const union = getUnionCodec(
307347
variants.map(variant => visit(variant, this) as Codec<EnumVariantValue>),
308348
(value: EnumVariantValue) => {
309349
const index = variants.findIndex(variant => variant.identifier === value.__kind);
310350
if (index < 0) {
311-
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, {
351+
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, {
312352
actualType: `variant '${String(value.__kind)}'`,
313353
expectedType: `one of [${variants.map(variant => variant.identifier).join(', ')}]`,
314354
nodeKind: 'enumTypeNode',
355+
nodePath,
315356
});
316357
}
317358
return index;
318359
},
319360
(bytes, offset) => discriminators.indexOf(Number(size.read(bytes, offset)[0])),
320361
);
321362
// Variants without data may also be encoded from their identifier, e.g. `'frozen'`.
322-
return transformCodec(union, (value: EnumVariantValue | string) =>
323-
typeof value === 'string' ? { __kind: value } : value,
324-
) as Codec<unknown>;
363+
return transformCodec(union, (value: unknown): EnumVariantValue => {
364+
if (typeof value === 'string') return { __kind: value };
365+
if (isObjectRecord(value) && typeof value.__kind === 'string') return value as EnumVariantValue;
366+
throw getUnexpectedValueTypeError(nodePath, 'string | { __kind: string }', value);
367+
}) as Codec<unknown>;
325368
},
326369
visitEnumVariantType(node) {
327370
const __kind = node.identifier;
371+
const nodePath = stack.getPath();
328372
const payload = node.data ? visit(node.data, this) : (getUnitCodec() as Codec<unknown>);
329373
const codec = transformCodec(
330374
payload,
331375
(value: EnumVariantValue) => {
376+
// Within an enum, the enum already ensures the value is an object of this variant.
377+
if (!isObjectRecord(value) || value.__kind !== __kind) {
378+
const expectedType =
379+
node.data === undefined ? `{ __kind: '${__kind}' }` : `{ __kind: '${__kind}', data }`;
380+
const actualType = isObjectRecord(value)
381+
? `variant '${String(value.__kind)}'`
382+
: formatValueType(value);
383+
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, {
384+
actualType,
385+
expectedType,
386+
nodeKind: 'enumVariantTypeNode',
387+
nodePath,
388+
});
389+
}
332390
if (node.data === undefined) return undefined;
333391
if (value.data === undefined) {
334-
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPE, {
392+
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, {
335393
actualType: `variant '${__kind}' without data`,
336394
expectedType: `{ __kind: '${__kind}', data }`,
337395
nodeKind: 'enumVariantTypeNode',
396+
nodePath,
338397
});
339398
}
340399
return value.data;
@@ -369,7 +428,10 @@ export function getNodeValueCodecVisitor(
369428
return visit(node.number, this);
370429
},
371430
visitFloatType(node) {
372-
return getFloatCodec(node) as Codec<unknown>;
431+
const codec = getFloatCodec(node) as Codec<unknown>;
432+
return assertValueType(codec, stack.getPath(), 'number | bigint', value =>
433+
['bigint', 'number'].includes(typeof value),
434+
);
373435
},
374436
visitInstruction(node) {
375437
return node.data ? visit(node.data, this) : (getUnitCodec() as Codec<unknown>);
@@ -378,38 +440,47 @@ export function getNodeValueCodecVisitor(
378440
return visitLinkedNode(node);
379441
},
380442
visitIntegerType(node) {
381-
return getIntegerCodec(node) as Codec<unknown>;
443+
const codec = getIntegerCodec(node) as Codec<unknown>;
444+
return assertValueType(codec, stack.getPath(), 'integer (number | bigint)', isInteger);
382445
},
383446
visitMapType(node) {
384447
const entry = getTupleCodec([visit(node.key, this), visit(node.value, this)]);
385448
// Maps are represented as objects in order to be compatible with JSON.
386-
return transformCodec(
449+
const codec = transformCodec(
387450
getCollectionCodec(entry as Codec<unknown>, node.count),
388451
(value: object) => Object.entries(value),
389452
entries => Object.fromEntries(entries as [PropertyKey, unknown][]),
390453
) as Codec<unknown>;
454+
return assertValueType(codec, stack.getPath(), 'object', isObjectRecord);
391455
},
392456
visitOptionType(node) {
393457
const item = visit(node.item, this);
394458
const prefix = visit(node.prefix, this) as NumberCodec;
395459
if (node.fixed) {
396460
assertIsFixedSize(item);
397-
return getOptionCodec(item, { noneValue: 'zeroes', prefix }) as Codec<unknown>;
461+
return encodeMissingAsNone(getOptionCodec(item, { noneValue: 'zeroes', prefix }) as Codec<unknown>);
398462
}
399-
return getOptionCodec(item, { prefix }) as Codec<unknown>;
463+
return encodeMissingAsNone(getOptionCodec(item, { prefix }) as Codec<unknown>);
400464
},
401465
visitPublicKeyType() {
402-
return fixCodecSize(getBase58Codec(), 32) as Codec<unknown>;
466+
const codec = fixCodecSize(getBase58Codec(), 32) as Codec<unknown>;
467+
return assertValueType(
468+
codec,
469+
stack.getPath(),
470+
'Address',
471+
value => typeof value === 'string' && isAddress(value),
472+
);
403473
},
404474
visitRemainderOptionType(node) {
405-
return getOptionCodec(visit(node.item, this), { prefix: null }) as Codec<unknown>;
475+
return encodeMissingAsNone(getOptionCodec(visit(node.item, this), { prefix: null }) as Codec<unknown>);
406476
},
407477
visitSetType(node) {
408478
// Sets are represented as arrays in order to be compatible with JSON.
409-
return getCollectionCodec(visit(node.item, this), node.count) as Codec<unknown>;
479+
return getArrayLikeCodec(visit(node.item, this), node.count);
410480
},
411481
visitStringType(node) {
412-
return getCodecFromBytesEncoding(node.encoding) as Codec<unknown>;
482+
const codec = getCodecFromBytesEncoding(node.encoding) as Codec<unknown>;
483+
return assertValueType(codec, stack.getPath(), 'string', value => typeof value === 'string');
413484
},
414485
visitStructFieldType(node) {
415486
const codec = visit(node.type, this);
@@ -424,19 +495,27 @@ export function getNodeValueCodecVisitor(
424495
},
425496
visitStructType(node) {
426497
const fields = (node.fields ?? []).map(field => [field.identifier, visit(field, this)] as const);
427-
return getStructCodec(fields) as Codec<unknown>;
498+
const codec = assertValueType(
499+
getStructCodec(fields) as Codec<unknown>,
500+
stack.getPath(),
501+
'object',
502+
isObjectRecord,
503+
);
504+
// A missing struct encodes all its fields as missing, so their default values apply.
505+
return transformCodec<unknown, unknown, unknown>(codec, value => (value === undefined ? {} : value));
428506
},
429507
visitTupleType(node) {
430-
return getTupleCodec((node.items ?? []).map(item => visit(item, this))) as Codec<unknown>;
508+
const codec = getTupleCodec((node.items ?? []).map(item => visit(item, this))) as Codec<unknown>;
509+
return assertValueType(codec, stack.getPath(), 'array', value => Array.isArray(value));
431510
},
432511
visitZeroableOptionType(node) {
433512
const item = visit(node.item, this);
434513
assertIsFixedSize(item);
435514
if (node.zeroValue) {
436515
const noneValue = getConstantBytes(node.zeroValue);
437-
return getOptionCodec(item, { noneValue, prefix: null }) as Codec<unknown>;
516+
return encodeMissingAsNone(getOptionCodec(item, { noneValue, prefix: null }) as Codec<unknown>);
438517
}
439-
return getOptionCodec(item, { noneValue: 'zeroes', prefix: null }) as Codec<unknown>;
518+
return encodeMissingAsNone(getOptionCodec(item, { noneValue: 'zeroes', prefix: null }) as Codec<unknown>);
440519
},
441520
};
442521

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, CodamaError } from '@codama/errors';
2+
import { Node } from '@codama/nodes';
3+
import { Codec, transformCodec } from '@solana/codecs';
4+
5+
/** Describe the type of a value in error messages, e.g. `number (1.5)` or `array (length 2)`. */
6+
export function formatValueType(value: unknown): string {
7+
if (value === null) return 'null';
8+
if (typeof value === 'number') return `number (${value})`;
9+
if (value instanceof Uint8Array) return `Uint8Array (length ${value.length})`;
10+
if (Array.isArray(value)) return `array (length ${value.length})`;
11+
return typeof value;
12+
}
13+
14+
/**
15+
* Whether the value is a plain object, e.g. the value of a struct or a map. Other
16+
* objects, such as arrays, bytes, `Map`s or `Date`s, are rejected since their
17+
* entries are not their own enumerable properties.
18+
*/
19+
export function isObjectRecord(value: unknown): value is Record<string, unknown> {
20+
if (typeof value !== 'object' || value === null) return false;
21+
const prototype = Object.getPrototypeOf(value) as unknown;
22+
return prototype === Object.prototype || prototype === null;
23+
}
24+
25+
/** The error thrown when the node at the end of `nodePath` cannot encode the given value. */
26+
export function getUnexpectedValueTypeError(
27+
nodePath: readonly Node[],
28+
expectedType: string,
29+
value: unknown,
30+
): CodamaError {
31+
return new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, {
32+
actualType: formatValueType(value),
33+
expectedType,
34+
nodeKind: nodePath[nodePath.length - 1].kind,
35+
nodePath,
36+
});
37+
}
38+
39+
/**
40+
* Reject the values the check does not accept before encoding them, since
41+
* encoders may otherwise silently encode them, e.g. `'abc'` as a `u16` zero.
42+
*/
43+
export function assertValueType(
44+
codec: Codec<unknown>,
45+
nodePath: readonly Node[],
46+
expectedType: string,
47+
isValid: (value: unknown) => boolean,
48+
): Codec<unknown> {
49+
return transformCodec(codec, (value: unknown) => {
50+
if (!isValid(value)) throw getUnexpectedValueTypeError(nodePath, expectedType, value);
51+
return value;
52+
});
53+
}

0 commit comments

Comments
 (0)