Summary
NumberValueNode.number is specified as an f64 (64-bit float). Any integer beyond 2^53 − 1 (9007199254740991) therefore cannot be represented exactly. The canonical case where this bites is a u64/u128 constant used as an instruction or account discriminator, whose full range is a routine part of real Solana programs. The same model also makes exact decimal quantities (e.g. 0.1) unrepresentable, which matters anywhere a value node describes a price or token amount.
Reported downstream in codama-idl/renderers-js#93.
The corruption chain
Using 12048014319693667524 as a u64 discriminator:
| Stage |
Value |
| Source (IDL) |
12048014319693667524 |
After JSON.parse → nearest f64 |
12048014319693668352 |
| Shortest round-trip print |
12048014319693668000 |
The value is corrupted before any implementation sees the AST: standard JSON number parsing rounds to the nearest double at load time. So this is an interchange-format limitation, not a bug in any one renderer. A Rust consumer could hold the value in a native u64, but the JSON wire format cannot deliver it there intact.
Today's workaround
Encode large discriminators as bytes rather than numbers — e.g. a constantValueNode wrapping a bytesTypeNode + bytesValueNode. This is what @codama/nodes-from-anchor already does for Anchor 0.30+ discriminators (8-byte arrays), which sidestep the problem entirely.
Proposal for the next major
Introduce a fixed-point value node modelled on @solana/fixed-points, where a value is raw / base^scale. raw must be a string on the wire so it survives JSON parsing losslessly; in memory it maps to a native big integer (bigint in JS, i128/u128 or wider in Rust):
{ "kind": "fixedPointValueNode", "raw": "12048014319693667524", "scale": 0, "base": 10 }
Note that the quotes around raw are load-bearing:
{ "raw": 12048014319693667524 }
✗ corrupted at JSON.parse.
{ "raw": "12048014319693667524" }
✓ lossless.
A single node with a parameterisable base (10 or 2) covers both flavours — decimal fixed-points for prices/amounts and binary fixed-points for Q-format fractions — and the scale: 0 case losslessly represents any integer, solving the discriminator problem. It also gives JS renderers a direct lowering target, since Kit ships runtime types and codecs with exactly these semantics.
By the same logic, every numeric value — floats included — should arguably be stored as a string on the wire: JSON float round-tripping is serialiser-dependent, and string storage makes the interchange format deterministic across languages. Whether the fixed-point node replaces NumberValueNode or complements it (keeping a — string-encoded — float node for genuinely floating-point values) is open for discussion; either way changing the value-node union is breaking, hence parking this for v2.
Alternative worth discussing: byte-array value storage
A more radical direction: store all values as pure byte arrays (e.g. hex strings). Since a value node is always paired with a type node, the semantic value is always recoverable, and this unifies every value kind under one lossless representation — effectively generalising bytesValueNode into the only storage. Trade-offs to weigh:
- Producers must encode values at IDL-generation time (e.g. Codama Macros would need byte-encoding logic in Rust).
- The canonical form of a value becomes coupled to its type's encoding: changing a field's type (or endianness) invalidates stored defaults, and the same conceptual value has different canonical bytes per type.
- Human readability and reviewability of IDLs drops; display-only consumers (docs generators, explorers) need a full codec stack to show a default value.
Summary
NumberValueNode.numberis specified as anf64(64-bit float). Any integer beyond 2^53 − 1 (9007199254740991) therefore cannot be represented exactly. The canonical case where this bites is au64/u128constant used as an instruction or account discriminator, whose full range is a routine part of real Solana programs. The same model also makes exact decimal quantities (e.g.0.1) unrepresentable, which matters anywhere a value node describes a price or token amount.Reported downstream in codama-idl/renderers-js#93.
The corruption chain
Using
12048014319693667524as au64discriminator:12048014319693667524JSON.parse→ nearestf641204801431969366835212048014319693668000The value is corrupted before any implementation sees the AST: standard JSON number parsing rounds to the nearest double at load time. So this is an interchange-format limitation, not a bug in any one renderer. A Rust consumer could hold the value in a native
u64, but the JSON wire format cannot deliver it there intact.Today's workaround
Encode large discriminators as bytes rather than numbers — e.g. a
constantValueNodewrapping abytesTypeNode+bytesValueNode. This is what@codama/nodes-from-anchoralready does for Anchor 0.30+ discriminators (8-byte arrays), which sidestep the problem entirely.Proposal for the next major
Introduce a fixed-point value node modelled on
@solana/fixed-points, where a value israw / base^scale.rawmust be a string on the wire so it survives JSON parsing losslessly; in memory it maps to a native big integer (bigintin JS,i128/u128or wider in Rust):{ "kind": "fixedPointValueNode", "raw": "12048014319693667524", "scale": 0, "base": 10 }Note that the quotes around
raware load-bearing:{ "raw": 12048014319693667524 }✗ corrupted at
JSON.parse.{ "raw": "12048014319693667524" }✓ lossless.
A single node with a parameterisable base (10 or 2) covers both flavours — decimal fixed-points for prices/amounts and binary fixed-points for Q-format fractions — and the
scale: 0case losslessly represents any integer, solving the discriminator problem. It also gives JS renderers a direct lowering target, since Kit ships runtime types and codecs with exactly these semantics.By the same logic, every numeric value — floats included — should arguably be stored as a string on the wire: JSON float round-tripping is serialiser-dependent, and string storage makes the interchange format deterministic across languages. Whether the fixed-point node replaces
NumberValueNodeor complements it (keeping a — string-encoded — float node for genuinely floating-point values) is open for discussion; either way changing the value-node union is breaking, hence parking this for v2.Alternative worth discussing: byte-array value storage
A more radical direction: store all values as pure byte arrays (e.g. hex strings). Since a value node is always paired with a type node, the semantic value is always recoverable, and this unifies every value kind under one lossless representation — effectively generalising
bytesValueNodeinto the only storage. Trade-offs to weigh: