11import {
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' ;
4142import {
4243 addCodecSentinel ,
4344 addCodecSizePrefix ,
@@ -82,6 +83,7 @@ import {
8283 transformCodec ,
8384} from '@solana/codecs' ;
8485
86+ import { assertValueType , formatValueType , getUnexpectedValueTypeError , isObjectRecord } from './validation' ;
8587import { 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. */
108110type 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+
110120function 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
0 commit comments