Payments Engine

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.

any JavaScript console
> 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

AcceptedRefused
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
Anything that could conceivably exceed 2⁵³ is a string, including counts and heights. A uniform rule is worth more than a per-field judgement call, because the judgement is what gets forgotten when a new field is added.

Inside the engine

LayerRepresentationEnforced by
Domain and application codebigintA lint rule fails the build on a numeric literal or a float operation in a money-named identifier.
DatabaseNUMERIC(78,0) / BIGINTColumn types. No float or double precision anywhere in the schema.
Postgres driverText, parsed to bigintType OIDs for numeric, int8 and timestamps are forced to text so the driver never produces a double.
HTTPDecimal stringSerialisation refuses a bigint-to-number conversion rather than performing one.
The driver line is the one people miss. Left to itself, 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

node
// 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);
Your database matters too. If your application stores account balances in a 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.

node
// 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.