mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-24 17:48:34 +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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user