[2.x] Reject values of the wrong type when encoding dynamic codecs - #1193
lorisleiva merged 1 commit into
Conversation
|
trevor-cortex
left a comment
There was a problem hiding this comment.
Summary
Adds encode-side type validation to @codama/dynamic-codecs so that values Kit would silently coerce ('abc' → u16 zero, 1.5 → 1, short base58 strings padded into a 32-byte pubkey, etc.) now throw DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE instead. The mechanism is a single assertValueType helper that wraps each leaf/container codec in an encode-only transformCodec, capturing stack.getPath() at codec-creation time so the error carries the exact nodePath even after the stack unwinds. Options gain undefined → None, structs gain undefined → {} so field defaults apply, and the error constant is renamed (same code).
The design is sound. A few things I checked and that hold up:
- Layering order is right. Validation is applied inside
next(node), so theinterceptVisitortransform layer (fixedSize,sizePrefix, offsets…) sits outside the check and the check still sees the raw user value. For structs theundefined → {}transform is correctly outsideassertValueType, soencode(undefined)doesn't trip theisObjectRecordcheck. stack.getPath()returns a copy ([...this.currentPath]), so the capturednodePathclosures are stable. The node-path tests confirm this through links, enums, options and maps.transformCodecpreservesfixedSize, so wrapping option/struct items doesn't break theassertIsFixedSizecalls invisitOptionType/visitZeroableOptionType.nodePath: readonly Node[]in the error context has precedent (CANNOT_RESOLVE_PATH,LINKED_NODE_NOT_FOUND), andencodeContextObjectstringifies objects as[object Object], so production error messages won't balloon with the full IDL.- Bigint range checks remain Kit's responsibility (
NUMBER_OUT_OF_RANGE), which is the right split —isIntegeronly guards the shape.
Things to watch
Mapinstances slip throughvisitMapType— see inline.isObjectRecord(new Map(...))istrue, andObject.entries(map)is[], so aMapsilently encodes as an empty map. That's the same class of footgun this PR is eliminating; sets already rejectSetinstances viaArray.isArray, so maps should be symmetric.- No changeset in the diff.
CONTRIBUTING.mdsays any user-facing change needs one, and this touches three published packages (@codama/errorsin the fixed group,@codama/dynamic-codecs,@codama/dynamic-client) with a behaviour change and a renamed export. If the2.xseeded major changeset is meant to cover this, fine — just confirming it's deliberate. - Renamed export is a hard break.
CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPEdisappears from@codama/errorsand@codama/dynamic-client. The repo keptFAILED_TO_EXECUTE_RESOLVERas a@deprecatedconstant rather than removing it — see inline for an optional alias in the same spirit. - Standalone
enumVariantTypeNodewithout data still accepts anything. InvisitEnumVariantType,if (node.data === undefined) return undefined;runs before any shape check, sogetNodeValueCodec([enumVariantTypeNode('quit')]).encode(42)encodes to empty bytes. Inside an enum the union's__kindlookup already guards this, so it only affects the standalone case — low priority, but it's the one remaining node kind that doesn't validate.
For subsequent reviewers
- The
@solana/addressesruntime dependency is new todynamic-codecs(forisAddress). Same^8.4.0range as@solana/codecs, and the sibling dynamic packages already depend on it, so no version skew — but worth knowing it's there. nodeKindin the error context is now redundant withnodePath[nodePath.length - 1].kind; it's kept because$nodeKindis interpolated into the message. Reasonable.formatValueTypedeliberately includes the value for numbers only (number (1.5)), not for strings — presumably to avoid leaking long/sensitive strings into error messages. Consistent with the tests.
72e9efe to
e881843
Compare
trevor-cortex
left a comment
There was a problem hiding this comment.
Re-review
Re-fetched the diff. Everything raised in the first pass has been addressed, and the fixes are cleaner than what I suggested:
Map/ non-plain objects —isObjectRecordnow checks the prototype (Object.prototypeornull) rather than special-casingMap, soMap,Set,Dateand class instances are all rejected for structs and maps. TheObject.create(null)acceptance test covers the JSON-parse-with-null-proto case too.BYTES_ENCODINGSexhaustiveness — now derived from asatisfies Record<BytesEncoding, true>record, so a new encoding in the spec is a compile error here.- Standalone
enumVariantTypeNode— now rejects anything that isn't a plain object with the matching__kind, with theexpectedTypestring adapting to whether the variant carries data. Nice touch thatactualTypereportsvariant 'move'when the shape is right but the kind is wrong, and falls back toformatValueTypeotherwise. The comment noting the check is redundant inside an enum is accurate: the enum's own transform already guaranteesisObjectRecord(value) && typeof __kind === 'string', and the union index lookup guarantees the kind matches. visitEnumTypenon-string/non-object values — now throwstring | { __kind: string }instead of reaching the union with a garbage__kind. Test added.- The
@deprecatedalias was optional; a clean break on 2.x is a reasonable call.
One small note, not blocking: the prototype check in isObjectRecord is realm-sensitive — an object created in another realm (Node vm context, iframe) has a different Object.prototype and will be rejected. That's an unusual scenario for instruction inputs, and the alternative (Object.prototype.toString.call(value) === '[object Object]') has its own holes with Symbol.toStringTag, so I'd leave it as is — just flagging in case a bug report ever shows up with that shape.
The changeset point from the first review still stands if it wasn't deliberate, but I assume the 2.x stack handles it collectively.
LGTM.
803123b to
f4b10ab
Compare
e881843 to
2e559ef
Compare
f4b10ab to
ef341d9
Compare
45c081b to
e3bfabb
Compare
b79b1c0 to
50f84a6
Compare
e3bfabb to
97d1f4c
Compare
50f84a6 to
0b55a54
Compare
97d1f4c to
9812253
Compare
0b55a54 to
a72493e
Compare
9812253 to
fcd8c62
Compare
a5ba85a to
6845cc7
Compare
fcd8c62 to
375cb5e
Compare
6845cc7 to
b57efa1
Compare
36c98ff to
af66e5f
Compare
8a311bc to
a773e2e
Compare
af66e5f to
608b4bd
Compare
a773e2e to
add2ee0
Compare
608b4bd to
1ef2655
Compare
add2ee0 to
cac2333
Compare
9c01248 to
4d1c3d7
Compare
cac2333 to
eadafbd
Compare
eadafbd to
7f7c898
Compare

This PR makes the
@codama/dynamic-codecsencoders reject values of the wrong type. Kit encoders silently encode many of them, e.g.'abc'orundefinedas au16zero,1.5as1or'no'astrue, which could produce unexpected instruction data or PDAs.DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPEotherwise: integers accept integernumbers orbigints, public keys base58 addresses, bytes aUint8Arrayor an[encoding, data]tuple, structs and maps plain objects (so aMapor aDateis rejected), arrays, sets and tuples arrays, enums an identifier or a{ __kind }object, and standalone enum variants a{ __kind }object of that variant.None(previouslySomeof a zero value) and missing structs as structs with missing fields, so their default values apply. Other missing values throw.nodePathof the node that rejected the value, e.g.[root, program, instruction, data, amountField, amountType].DYNAMIC_CLIENT__UNEXPECTED_ARGUMENT_TYPEtoDYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, keeping its code.