Files
wownerogue/test/moneyAtomic.test.js
T
jwinterm 24aed355c6 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.
2026-05-31 09:45:58 -04:00

82 lines
3.1 KiB
JavaScript

/**
* 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');
});
});