bigint. €100.00 is 10000n, not 100.0.
This is the decision the rest of the system rests on, and it is worth being precise about why, because “floats are inaccurate” is the correct conclusion from a slightly wrong argument.
The two reasons, and the second is the decisive one
Reason one: binary fractions cannot represent decimal fractions
IEEE-754 doubles are binary fractions.0.1 has no exact binary representation, in the same way 1/3 has no exact decimal one. So:
A ledger with an epsilon is not a ledger. Once you accept a tolerance, “how far off is acceptable?” has no defensible answer, the threshold is chosen by whoever is debugging that day, and the drift grows with volume. You no longer have a ledger; you have a ledger-shaped approximation.
Reason two: 2^53 is not big enough
This is the one that settles it for a chain-agnostic system, and it gets discussed less. Doubles are exact only up to 2^53 ≈ 9.007e15. In cents that is about $90 trillion, which is fine. But:
A single 18-decimal token balance routinely exceeds 1e18 in base units. Arc settles on chains where that is the normal representation, so floats are not merely risky: they cannot hold the number at all.
What the Money type actually is
10000n alone is meaningless: €100.00 and ¥10,000 are the same integer and very different amounts, so the currency travels with the number rather than living in a variable name or a column comment.
Arithmetic is exact by construction. Addition and subtraction are bigint operations. Multiplication by a rate is where policy becomes necessary, and Arc makes it explicit rather than implicit.
Rounding, and where the remainder goes
Multiplying money by a rate produces a value that is usually not a whole number of minor units. A 1.5% fee on €33.33 is €0.49995. Something has to happen to the0.00995.
Most systems drop it. Arc posts it.
1
divRound() produces the rounded value
Under an explicitly stated rounding policy, not
Math.round, which is banned, and which rounds a float you should not have had in the first place.2
divResidual() returns the exact leftover
The precise remainder, as a
bigint. It exists specifically so the remainder can be given somewhere to go.3
The residual becomes its own ledger entry
Posted against a rounding account, so the journal still balances and the fraction stays auditable.
The residual becomes a number someone can look at, rather than drift nobody can explain. Over millions of transfers, “where did the rounding go?” is a question with an exact answer and a queryable account balance.
The full narrative is The cent that vanished.
Value is conserved under allocation
The property that stops a cent appearing or vanishing when a transfer is split into fees:Splitting any amount across any weights always sums back to exactly the original.
This is asserted by a property-based test across randomly generated amounts and weight vectors, not by a handful of examples. Naive allocation, round each share independently, fails it routinely: three ways of splitting €10.00 gives €3.33 each and loses a cent. The allocator distributes remainders deterministically so the total is preserved, and the test proves it holds for inputs nobody thought to write down.
Rates are exact too
Rate carries a numerator and denominator rather than a decimal, so inversion is lossless. A rate provider typically publishes one direction of a pair; getting the other by Rate.invert() costs nothing in precision, which matters because the inverted rate then feeds an FX calculation whose output is a customer-facing number.
Enforced, not merely intended
A convention that depends on every future developer remembering it is not a convention: it is a countdown. So the rule is mechanical. ESLint rejects, in source:0.015 in a source file. You cannot call parseFloat. You cannot format with toFixed. The build fails.
The two exceptions, both justified in writing
packages/chain/src/random.ts: the seeded PRNG
packages/chain/src/random.ts: the seeded PRNG
mulberry32 with an FNV-1a seed hash. This is bit-mixing on a 32-bit state, not money, and distorting it to satisfy the lint rule would break the determinism the entire chain simulator depends on.The exception is scoped to the file with a comment explaining why, and nothing it produces becomes an amount without conversion to
bigint minor units first.services/risk/src/sanctions.ts: Jaro-Winkler similarity
services/risk/src/sanctions.ts: Jaro-Winkler similarity
Name-similarity scores are 0–1 fractions. The prefix weight of 0.1 and the match threshold of 0.9 are algorithm constants, not amounts. Nothing in the file produces or consumes a monetary value.Same treatment: a file-scoped disable with the reasoning written above it, rather than a repository-wide loosening.
What this costs
Honesty about the trade:- Ergonomics.
Money.multiply(rate)is more typing thanamount * rate, and every developer joining the project has to learn the type. - Serialisation.
bigintdoes not surviveJSON.stringifywithout help, so every boundary, API, event envelope, database, needs an explicit codec. - Third-party interop. Anything that hands you a float has to be converted at the edge, carefully, once.
ADR 0001: the decision record
The formal record, with alternatives considered.
Next: the ledger
What exact arithmetic buys you: an invariant with no tolerance.