Skip to main content
ADR 0001 · Accepted · Phase 0
Status: Accepted · Phase: 0 · Supersedes: none · Superseded by: none

Context

Arc’s central invariant is that every journal balances exactly: debits equal credits, with no tolerance. That requirement alone constrains the numeric representation, because a tolerance is not a policy anyone can defend: “how far off is acceptable?” has no answer that survives contact with a growing transaction volume. The system also handles assets with wildly different scales: An IEEE-754 double is exact only to 2^53 ≈ 9.007e15. That covers cents comfortably and covers an 18-decimal token not at all: a single ordinary balance exceeds it. So the question was not “are floats accurate enough?” but “what representation makes the exactness requirement structural rather than aspirational?”

Decision

Every monetary value is an integer count of the currency’s minor units, held as a bigint, paired with a currency code. No number appears in any monetary API. Rounding happens only through divRound, which requires an explicit mode, and the residual is always returned so it can be posted to a rounding account. This is enforced by ESLint rather than by convention. In source, the following are errors:
A convention that depends on every future developer remembering it is not a convention: it is a countdown. Making it a build failure is what turns the decision into a property of the system.
Two files carry a scoped exception with a written justification: the seeded PRNG in packages/chain/src/random.ts (32-bit bit-mixing, not money) and the Jaro–Winkler implementation in services/risk/src/sanctions.ts (similarity thresholds, not amounts). Each disable is file-scoped with the reasoning above it, rather than a repository-wide relaxation.

Consequences

Good

  • Arithmetic is exact and associative. (a + b) + c === a + (b + c) holds, which it does not for floats. Every reconciliation and batching path depends on this.
  • The balance invariant needs no epsilon. The ledger check is debits === credits, full stop.
  • 18-decimal assets work without special-casing. No separate code path for chain assets versus fiat.
  • Rounding is a stated policy at each boundary rather than an emergent property of whichever operation happened to run last.
  • Residuals stay auditable. divResidual returns the exact leftover so it can be posted to a rounding account and queried later.

Costs

  • Every amount must be converted at the system edge. JSON carries minor units as strings, never numbers, because most clients parse a JSON number as a float, which would silently reintroduce the exact problem this decision exists to remove. Every API, event envelope and database boundary needs an explicit codec.
  • Callers must choose a rounding mode explicitly. More verbose. That verbosity is the point: a rounding mode chosen by default is a rounding mode nobody reviewed.
  • Ergonomic cost. Money.multiply(rate) is more typing than amount * rate, and every contributor has to learn the type before they can write a line of monetary code.
  • Third-party interop. Anything handing back a float must be converted at the edge, carefully, once.

Alternatives

Binary fractions cannot represent most decimal money values. 0.1 + 0.2 === 0.30000000000000004, and each error is around 1e-17.Individually negligible; structurally fatal, because the errors accumulate exactly in the FX, fee, and batching paths: the paths that run most often and feed reconciliation. The result is a system where the trial balance is close to zero and nobody can say why it is not zero.The 2^53 ceiling settles it independently: an 18-decimal token balance cannot be held in a double at all.
This is the mainstream answer and it is a good one. Arbitrary-precision decimals handle the accuracy problem correctly and are widely used in financial systems.Rejected for three reasons:
  1. Integers are faster, and the ledger’s hot path is summing entries.
  2. Integers map directly onto both ISO-4217 minor units and on-chain base units. A decimal type requires a conversion at every chain boundary, which is a conversion that can be wrong.
  3. It removes any question of where precision is configured. A decimal library has a precision setting somewhere, and “somewhere” eventually means “in two places that disagree”.
The honest counterpoint: a decimal type is more ergonomic and less surprising to new contributors. That cost was accepted.
Sidesteps the fractional-representation problem while keeping familiar arithmetic, and works fine for fiat.Rejected on the 2^53 ceiling alone. For a chain-agnostic system settling in 6- and 18-decimal assets, this is not a close call.

Why money is never a float

The same decision explained rather than recorded, with the rounding-residual mechanism worked through.

The cent that vanished

What this decision prevents, told as the failure it prevents.