Skip to content

NumberValueNode cannot represent exact 64-bit integers or decimal quantities #97

Description

@lorisleiva

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions