refactor: exact BigInt money math via src/money/atomic.js

Money is integer atomic units, but payout and sum math ran on float64
(`Math.round(base * multiplier)`, `sum + Number(x)`), which silently loses
precision above 2^53 — a real risk for large/credit/XMR amounts.

- Add src/money/atomic.js: the single source of truth for atomic-unit math
  (toBig, toSafe, sum, add, mulByDecimal, format). mulByDecimal multiplies an
  atomic amount by a decimal multiplier using exact integer arithmetic with
  half-up rounding; toSafe narrows to a number only when exactly representable
  and otherwise returns a decimal string.
- calculatePayout now uses money.mulByDecimal/toSafe instead of float math.
- walletRPCService batch total + fee sum use money.sum instead of float reduce.

Adds moneyAtomic.test.js with property tests on amounts above 2^53 proving
exactness where the old float path drifted. Full suite: 14 suites / 129 tests.
This commit is contained in:
jwinterm
2026-05-31 09:45:58 -04:00
parent a664a8bbce
commit 24aed355c6
5 changed files with 191 additions and 5 deletions
+6 -2
View File
@@ -13,6 +13,7 @@ const {
} = require('./helpers/gameModeUtils');
const { ValidationError, normalizeError } = require('../utils/errors');
const paymentConfig = require('../config/paymentConfig');
const money = require('../money/atomic');
const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW depending on currency decimals
const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000;
@@ -243,8 +244,11 @@ class GameModeManager {
? (multipliers.escapeWithTreasure ?? multipliers.escape ?? 0)
: (multipliers.escape ?? 0);
const amount = Math.round(base * multiplier);
// Exact integer math: base (atomic units) * decimal multiplier via BigInt, then
// narrow back to a number only when exactly representable. Avoids float precision
// loss on large atomic amounts (the old `Math.round(base * multiplier)`).
const amount = money.toSafe(money.mulByDecimal(base, multiplier));
console.log(`🧮 calculatePayout: mode=${normalizedMode}, usingCredits=${usingCredits}, treasureFound=${treasureFound}`);
console.log(` base=${base}, multipliers=${JSON.stringify(multipliers)}, chosen multiplier=${multiplier}`);
console.log(` final amount=${amount} (base * multiplier = ${base} * ${multiplier})`);
+100
View File
@@ -0,0 +1,100 @@
/**
* Money — atomic-unit arithmetic on BigInt.
*
* The single source of truth for money math. All monetary values in this app are integer
* atomic units (1 WOW = 10^11, 1 XMR = 10^12). JavaScript `number` is float64 and loses
* precision above 2^53, so sums/products of large atomic amounts must NOT be done with `+`
* or `*` on numbers. Everything here operates on BigInt and only narrows back to `number`
* when the value is provably exact (<= MAX_SAFE_INTEGER).
*/
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
/**
* Coerce a string | number | bigint atomic amount to BigInt.
* Rejects non-integer numbers and non-numeric strings (money is never fractional atomic).
* @param {string|number|bigint} v
* @returns {bigint}
*/
function toBig(v) {
if (typeof v === 'bigint') return v;
if (typeof v === 'number') {
if (!Number.isFinite(v) || !Number.isInteger(v)) {
throw new Error(`Invalid atomic amount (not an integer number): ${v}`);
}
return BigInt(v);
}
if (typeof v === 'string') {
const s = v.trim();
if (!/^-?\d+$/.test(s)) {
throw new Error(`Invalid atomic amount (not an integer string): ${v}`);
}
return BigInt(s);
}
throw new Error(`Invalid atomic amount type: ${typeof v}`);
}
/**
* Narrow a BigInt atomic amount to a JS number when it is exactly representable,
* otherwise return its decimal string. This keeps small amounts ergonomic (and
* backward compatible with existing number-typed call sites and tests) while never
* silently losing precision on large amounts.
* @param {bigint} big
* @returns {number|string}
*/
function toSafe(big) {
const b = toBig(big);
return (b <= MAX_SAFE && b >= -MAX_SAFE) ? Number(b) : b.toString();
}
/** Sum a list of atomic amounts exactly. @returns {bigint} */
function sum(values) {
return (values || []).reduce((acc, v) => acc + toBig(v), 0n);
}
/** Add two atomic amounts exactly. @returns {bigint} */
function add(a, b) {
return toBig(a) + toBig(b);
}
/**
* Multiply an atomic amount by a non-negative decimal multiplier (e.g. 2, 3, 1.5, 2.5)
* using exact integer math with half-up rounding — no float multiplication.
* @param {string|number|bigint} atomic
* @param {number|string} multiplier
* @returns {bigint}
*/
function mulByDecimal(atomic, multiplier) {
const a = toBig(atomic);
const s = String(multiplier);
if (!/^\d+(\.\d+)?$/.test(s)) {
throw new Error(`Invalid multiplier (expected non-negative decimal): ${multiplier}`);
}
const [intPart, fracPart = ''] = s.split('.');
const places = fracPart.length;
const scale = 10n ** BigInt(places);
const numerator = BigInt(intPart + fracPart); // "2.5" -> 25n, scale 10n
// Half-up rounding: add half the denominator before truncating division.
return (a * numerator + scale / 2n) / scale;
}
/**
* Format an atomic amount as a human-readable decimal string for the given currency
* decimals (e.g. format(150000000000n, 11) -> "1.5"). Trims trailing fractional zeros.
* @param {string|number|bigint} atomic
* @param {number} decimals
* @returns {string}
*/
function format(atomic, decimals) {
const a = toBig(atomic);
const neg = a < 0n;
const abs = neg ? -a : a;
const d = BigInt(decimals);
const divisor = 10n ** d;
const whole = abs / divisor;
const frac = abs % divisor;
let fracStr = frac.toString().padStart(Number(decimals), '0').replace(/0+$/, '');
return (neg ? '-' : '') + whole.toString() + (fracStr ? '.' + fracStr : '');
}
module.exports = { toBig, toSafe, sum, add, mulByDecimal, format, MAX_SAFE };
+3 -2
View File
@@ -1,6 +1,7 @@
const axios = require('axios');
const { v4: uuidv4 } = require('uuid');
const { ExternalServiceError, AppError } = require('../utils/errors');
const money = require('../money/atomic');
class WalletRPCService {
constructor(debugManager) {
@@ -311,7 +312,7 @@ class WalletRPCService {
if (this.debugManager?.CONSOLE_LOGGING) {
console.log(`🚀 Initiating batch payout (${destinations.length} destinations)`, {
totalAmount: destinations.reduce((sum, d) => sum + Number(d.amount), 0),
totalAmount: money.sum(destinations.map(d => d.amount)).toString(),
destinations: destinations.map(d => d.address.substring(0, 10) + '...')
});
}
@@ -322,7 +323,7 @@ class WalletRPCService {
tx_hash_list: response.result.tx_hash_list || [],
tx_key_list: response.result.tx_key_list || [],
fee_list: response.result.fee_list || [],
totalFee: (response.result.fee_list || []).reduce((a, b) => a + b, 0)
totalFee: money.toSafe(money.sum(response.result.fee_list || []))
};
if (this.debugManager?.CONSOLE_LOGGING) {