Money and precision
Amounts are integers in minor units from one end of the system to the other, and they cross the wire as decimal strings. This is not fastidiousness — it is the difference between a balance and an estimate.
Why an amount is never a JSON number
A JSON number is an IEEE-754 double. Doubles hold integers exactly up to 2⁵³ and then start rounding — silently, with no error, no warning and no flag anywhere.
> 9007199254740993 9007199254740992 // one satoshi, gone > JSON.parse('{"amountMinor":9007199254740993}').amountMinor 9007199254740992 // gone before your code ran > 0.1 + 0.2 0.30000000000000004 // and this is why nothing is ever a float
The second line is the important one. By the time your handler sees the value, the parser has already changed it. No validation you write afterwards can detect that, because there is nothing left to compare against — which is why the engine refuses the shape rather than trying to check the value.
On the wire
| Accepted | Refused | |
|---|---|---|
| Payout amount | {"amountMinor": "250000000"} | {"amountMinor": 250000000} → 400 |
| Block height | "blockHeight": "19482013" | a JSON number |
| Derivation index | "derivationIndex": "4471" | a JSON number |
| Row counts in a report | "count": "184" | a JSON number |
Inside the engine
| Layer | Representation | Enforced by |
|---|---|---|
| Domain and application code | bigint | A lint rule fails the build on a numeric literal or a float operation in a money-named identifier. |
| Database | NUMERIC(78,0) / BIGINT | Column types. No float or double precision anywhere in the schema. |
| Postgres driver | Text, parsed to bigint | Type OIDs for numeric, int8 and timestamps are forced to text so the driver never produces a double. |
| HTTP | Decimal string | Serialisation refuses a bigint-to-number conversion rather than performing one. |
node-postgres parses NUMERIC into a JavaScript number — so a schema that is perfectly correct still hands your code a rounded balance. That is configured away at the pool, once, for the whole engine.On your side
// Reading — widen to BigInt, never to Number. const positions = await payments.treasury.positions('BTC'); const total = BigInt(positions.totalMinor); // exact const wrong = Number(positions.totalMinor); // silently rounds above 2^53 // Arithmetic stays in BigInt. const remaining = total - BigInt(payout.amountMinor); // Writing — back to a decimal string. await payments.payouts.submit({ amountMinor: remaining.toString(), // "9007199254740993" /* … */ }, key);
float or double column, everything above is undone at your own boundary. Use NUMERIC or a 64-bit integer of minor units.Displaying an amount
Formatting is the last place a rounding bug hides, because it looks like presentation. Group the digits on the string; do not route it through a number to do it.
// Wrong — correct for small values, quietly wrong for large ones. Number(amountMinor).toLocaleString(); // Right — the digits are never a number at any point. function group(minor) { return minor.replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } // Right — converting minor units to a display value, exactly. function toDecimal(minor, decimals) { const padded = minor.padStart(decimals + 1, '0'); const whole = padded.slice(0, -decimals); const fraction = padded.slice(-decimals).replace(/0+$/, ''); return fraction ? `${whole}.${fraction}` : whole; } toDecimal('250000000', 6); // '250' USDT toDecimal('9007199254740993', 8); // '90071992.54740993' BTC
Every figure on the operator console is formatted this way, which is why a balance above 2⁵³ renders there exactly as the database holds it.