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
+1 -1
View File
@@ -25,7 +25,7 @@ Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker
- [x] **0.6 🟠 S — Client + admin XSS and CSP.** ✅ Single shared `escapeHtml` helper (`html/js/core/escapeHtml.js`, escapes all five chars incl. both quotes); leaderboard delegates to it. Chat sinks (`onChatBroadcast`/`onChatHistory`) now escape username + message. Admin panel escapes user-controlled fields (payout address, socket id, player name, status) and its local `escapeHtml` now escapes quotes (safe in `title="…"`). CSP + `X-Content-Type-Options`/`X-Frame-Options`/`Referrer-Policy` headers added via middleware in `index.js` (`connect-src 'self'` keeps Socket.IO working; nonce-based tightening tracked as Phase 4.4).
## Phase 1 — Money correctness hardening (🟠)
- [ ] **1.1 M — `src/money/atomic.js` BigInt module** as the only money type; remove `Number()` casts in `gameModeManager.js` and float `reduce` in `walletRPCService.js:314`.
- [x] **1.1 M — `src/money/atomic.js` BigInt module.** ✅ Exact integer money math (`toBig`/`toSafe`/`sum`/`add`/`mulByDecimal`/`format`). `calculatePayout` now computes `base * multiplier` via BigInt (half-up rounding, narrows to number only when exact). `walletRPCService` batch total + fee sum use `money.sum` instead of float `reduce`. Property tests (`moneyAtomic.test.js`) prove exactness on amounts above 2^53 where the old float path drifted.
- [ ] **1.2 S — Snapshot payout config at game start** (`gameModeManager.js:237`); pay from recorded multiplier/base.
- [ ] **1.3 S — Close payout uniqueness holes.** Cover `processing` in the unique index; guard INSERT (`gameModeManager.js:1161`); add status/outcome/mode enum CHECKs.
- [ ] **1.4 S — Fix retry stat guard + non-atomic tx_hash write** (`src/payments/payoutRetryService.js:121,174`).
+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) {
+81
View File
@@ -0,0 +1,81 @@
/**
* Money / atomic-unit arithmetic tests (Phase 1.1).
*
* These specifically exercise the large-amount cases where the old float path
* (`base * multiplier`, `sum + Number(x)`) silently loses precision above 2^53.
*/
const money = require('../src/money/atomic');
describe('money.toBig / toSafe', () => {
test('parses string, number and bigint', () => {
expect(money.toBig('100000000000')).toBe(100000000000n);
expect(money.toBig(5)).toBe(5n);
expect(money.toBig(7n)).toBe(7n);
});
test('rejects non-integer numbers and junk strings', () => {
expect(() => money.toBig(1.5)).toThrow();
expect(() => money.toBig('1.5')).toThrow();
expect(() => money.toBig('abc')).toThrow();
expect(() => money.toBig(Infinity)).toThrow();
});
test('toSafe narrows to number when exact, keeps string when too large', () => {
expect(money.toSafe(200000000000n)).toBe(200000000000);
// 10^18 atomic > MAX_SAFE_INTEGER (~9.007e15) -> must stay a string, not a lossy number
expect(money.toSafe(1000000000000000000n)).toBe('1000000000000000000');
});
});
describe('money.mulByDecimal (exact, no float)', () => {
test('whole multipliers', () => {
expect(money.mulByDecimal(100000000000n, 2)).toBe(200000000000n);
expect(money.mulByDecimal(100000000000n, 3)).toBe(300000000000n);
});
test('fractional multipliers with half-up rounding', () => {
expect(money.mulByDecimal(100000000000n, 1.5)).toBe(150000000000n);
expect(money.mulByDecimal(50000000000n, 1.5)).toBe(75000000000n);
expect(money.mulByDecimal(3n, 1.5)).toBe(5n); // 4.5 -> 5 (half up)
expect(money.mulByDecimal(1n, 2.5)).toBe(3n); // 2.5 -> 3 (half up)
expect(money.mulByDecimal(0n, 3)).toBe(0n);
});
test('large amounts stay exact where float would drift', () => {
// 9,007,199,254,740,993 is 2^53 + 1 — not representable as a float64.
const big = 9007199254740993n;
expect(money.mulByDecimal(big, 3)).toBe(27021597764222979n);
// Sanity: the naive float computation is wrong, proving why BigInt matters.
expect(Number(big) * 3).not.toBe(27021597764222979);
});
test('rejects invalid multipliers', () => {
expect(() => money.mulByDecimal(1n, -1)).toThrow();
expect(() => money.mulByDecimal(1n, 'x')).toThrow();
});
});
describe('money.sum / add (exact)', () => {
test('sums large atomic amounts without precision loss', () => {
const vals = ['9007199254740991', '9007199254740991', '9007199254740991'];
expect(money.sum(vals)).toBe(27021597764222973n); // exact
// The naive float reduce drifts away from the exact integer total.
const floatSum = vals.reduce((a, b) => a + Number(b), 0);
expect(BigInt(floatSum)).not.toBe(money.sum(vals));
});
test('add coerces mixed types', () => {
expect(money.add('100', 50)).toBe(150n);
expect(money.add(7n, '3')).toBe(10n);
});
});
describe('money.format', () => {
test('formats with currency decimals and trims trailing zeros', () => {
expect(money.format(150000000000n, 11)).toBe('1.5');
expect(money.format(100000000000n, 11)).toBe('1');
expect(money.format(1n, 11)).toBe('0.00000000001');
expect(money.format(0n, 11)).toBe('0');
});
});