mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
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:
@@ -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`).
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user