From 473a4091d41cacd9f0ff77db558320eed2162b22 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 31 May 2026 09:55:45 -0400 Subject: [PATCH] fix: snapshot payout terms at game start (what-you-saw-is-what-you-get) Payouts were computed at completion from LIVE config, so an admin changing multipliers mid-game would change the payout of an already-in-progress game. - Migration 016 adds payout_escape_amount/payout_treasure_amount (BIGINT) and the corresponding multipliers to the games table. - Both game-start paths stamp the resolved payout amounts (computed from the config in effect at entry) onto the game row. - completeGame pays from the snapshot, falling back to live calculation only for legacy games that predate the snapshot columns. Adds payoutSnapshot.test.js proving a mid-game config change cannot alter an in-flight payout. Full suite: 15 suites / 131 tests. --- docs/REMEDIATION_PLAN.md | 2 +- src/game/gameModeManager.js | 59 +++++++++++++++-- src/migrations/016_payout_snapshot.sql | 13 ++++ test/payoutSnapshot.test.js | 92 ++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 src/migrations/016_payout_snapshot.sql create mode 100644 test/payoutSnapshot.test.js diff --git a/docs/REMEDIATION_PLAN.md b/docs/REMEDIATION_PLAN.md index f6536d0..a020730 100644 --- a/docs/REMEDIATION_PLAN.md +++ b/docs/REMEDIATION_PLAN.md @@ -26,7 +26,7 @@ Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker ## Phase 1 — Money correctness hardening (🟠) - [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. +- [x] **1.2 S — Snapshot payout config at game start.** ✅ Migration 016 adds `payout_escape_amount`/`payout_treasure_amount` (BIGINT) + multipliers to `games`. Both start paths stamp the resolved payout amounts (via `_computePayoutSnapshot`); `completeGame` pays from the snapshot (falls back to live calc only for pre-snapshot games). New `payoutSnapshot.test.js` proves a mid-game config change can't alter an in-flight payout. - [ ] **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`). - [ ] **1.5 S — Remove dead refund send-path** (`src/index.js:795`). diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 2e46989..dbd41e9 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -731,8 +731,25 @@ class GameModeManager { } } + /** + * Compute the payout terms to snapshot on a game at start, so an admin changing + * config mid-game can't alter an in-flight game's payout. Returns atomic amounts as + * strings (for the BIGINT columns) plus the multipliers for the audit trail. + */ + _computePayoutSnapshot(mode) { + const escape = this.calculatePayout(mode, { treasureFound: false }); + const treasure = this.calculatePayout(mode, { treasureFound: true }); + return { + escapeAmount: String(escape.amount), + treasureAmount: String(treasure.amount), + escapeMult: escape.multiplier, + treasureMult: treasure.multiplier + }; + } + async _processGameStartWithCredits(user, socketId, gameId) { const creditsToSpend = this.creditsPerGameCost; + const snap = this._computePayoutSnapshot('PAID_CREDITS'); const result = await this.db.withTransaction(async (client) => { // Conditional update prevents credits going negative @@ -752,9 +769,11 @@ class GameModeManager { const remainingCredits = updateRes.rows[0].credits; await client.query(` - UPDATE games SET game_mode = 'PAID_CREDITS', payout_address = $2 + UPDATE games SET game_mode = 'PAID_CREDITS', payout_address = $2, + payout_escape_amount = $3, payout_treasure_amount = $4, + payout_escape_mult = $5, payout_treasure_mult = $6 WHERE dungeon_seed = $1 - `, [gameId, user.payout_address || null]); + `, [gameId, user.payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]); await client.query(` INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) @@ -784,12 +803,15 @@ class GameModeManager { } async _processGameStartWithPayment(user, payment, gameId) { + const snap = this._computePayoutSnapshot('PAID_SINGLE'); await this.db.withTransaction(async (client) => { await client.query(` UPDATE games - SET payment_id = $1, game_mode = 'PAID_SINGLE', payout_address = $3 + SET payment_id = $1, game_mode = 'PAID_SINGLE', payout_address = $3, + payout_escape_amount = $4, payout_treasure_amount = $5, + payout_escape_mult = $6, payout_treasure_mult = $7 WHERE dungeon_seed = $2 - `, [payment.id, gameId, user.payout_address || null]); + `, [payment.id, gameId, user.payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]); await client.query(` UPDATE users @@ -1100,9 +1122,13 @@ class GameModeManager { let recordedPaymentMode = this.gameMode; // fallback to global let gameDbId = null; let lockedPayoutAddress = null; // address locked at game start + let payoutSnapshot = null; // payout terms snapshotted at game start try { const gameRecord = await this.db.query(` - SELECT id, game_mode, payout_address FROM games + SELECT id, game_mode, payout_address, + payout_escape_amount, payout_treasure_amount, + payout_escape_mult, payout_treasure_mult + FROM games WHERE dungeon_seed = $1 AND socket_id = $2 LIMIT 1 `, [gameId, socketId]); @@ -1110,6 +1136,15 @@ class GameModeManager { gameDbId = gameRecord.rows[0].id; recordedPaymentMode = (gameRecord.rows[0].game_mode || this.gameMode).toUpperCase(); lockedPayoutAddress = gameRecord.rows[0].payout_address || null; + const r = gameRecord.rows[0]; + if (r.payout_escape_amount != null || r.payout_treasure_amount != null) { + payoutSnapshot = { + escapeAmount: r.payout_escape_amount, + treasureAmount: r.payout_treasure_amount, + escapeMult: r.payout_escape_mult, + treasureMult: r.payout_treasure_mult + }; + } } } catch (e) { // Non-fatal, use fallback @@ -1141,7 +1176,19 @@ class GameModeManager { // Handle payouts based on the actual recorded payment mode, not the current global setting const payoutEligibleStartMode = (recordedPaymentMode === 'PAID_SINGLE') || (recordedPaymentMode === 'PAID_CREDITS' && this.creditsPayoutEnabled); if (payoutEligibleStartMode && won) { - const { amount: payoutAmount, multiplier } = this.calculatePayout(recordedPaymentMode, { treasureFound }); + // Use the payout terms snapshotted at game start (so a mid-game config change + // can't alter an in-flight payout). Fall back to live calculation only for + // older games that predate the snapshot columns. + let payoutAmount; + let multiplier; + if (payoutSnapshot) { + payoutAmount = treasureFound ? payoutSnapshot.treasureAmount : payoutSnapshot.escapeAmount; + multiplier = treasureFound ? payoutSnapshot.treasureMult : payoutSnapshot.escapeMult; + } else { + const live = this.calculatePayout(recordedPaymentMode, { treasureFound }); + payoutAmount = live.amount; + multiplier = live.multiplier; + } // Check if payout was already processed for this game (prevent duplicates) if (gameDbId) { diff --git a/src/migrations/016_payout_snapshot.sql b/src/migrations/016_payout_snapshot.sql new file mode 100644 index 0000000..a0afb6d --- /dev/null +++ b/src/migrations/016_payout_snapshot.sql @@ -0,0 +1,13 @@ +-- Migration 016: Snapshot payout terms on the game at start +-- +-- Payout amounts were computed at completion time from LIVE config, so an admin changing +-- multipliers mid-game would change the payout of an already-in-progress game ("what you +-- saw at entry" was not honoured). Snapshot the resolved payout amounts (and the +-- multipliers, for audit) onto the games row at start; completion reads the snapshot. +-- +-- Amounts are atomic units (BIGINT, exact). Multipliers are stored for the audit trail. +ALTER TABLE games + ADD COLUMN IF NOT EXISTS payout_escape_amount BIGINT, + ADD COLUMN IF NOT EXISTS payout_treasure_amount BIGINT, + ADD COLUMN IF NOT EXISTS payout_escape_mult NUMERIC(8,3), + ADD COLUMN IF NOT EXISTS payout_treasure_mult NUMERIC(8,3); diff --git a/test/payoutSnapshot.test.js b/test/payoutSnapshot.test.js new file mode 100644 index 0000000..bfc96f3 --- /dev/null +++ b/test/payoutSnapshot.test.js @@ -0,0 +1,92 @@ +/** + * Payout snapshot tests (Phase 1.2). + * + * The payout terms are snapshotted onto the game at start. completeGame must pay from + * that snapshot, so a mid-game config change can't alter an in-flight game's payout. + */ + +const GameModeManager = require('../src/game/gameModeManager'); + +const createMockDb = () => { + const mockClient = { query: jest.fn().mockResolvedValue({ rows: [] }) }; + return { + query: jest.fn().mockResolvedValue({ rows: [] }), + withTransaction: jest.fn().mockImplementation(async (cb) => cb(mockClient)), + _mockClient: mockClient + }; +}; + +const createMockPaymentConfig = () => ({ + getConfig: () => ({ + paymentsEnabled: true, + currency: { symbol: 'WOW', decimals: 11 }, + modes: { + direct: { enabled: true, price: 100000000000n }, + credits: { enabled: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] } + }, + payouts: { + rules: { + direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } }, + credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n } + } + }, + preferences: { preferCreditsFirst: true }, + earlyEntry: { enabled: false } + }), + getLegacyGameMode: () => 'PAID_SINGLE', + eventBus: { on: () => {} } +}); + +function buildGmm() { + const db = createMockDb(); + const gmm = new GameModeManager( + db, + { processPayout: jest.fn(), processBatchPayout: jest.fn() }, + { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, + createMockPaymentConfig() + ); + gmm._scheduleBatchPayout = jest.fn(); // don't actually schedule + return { gmm, db }; +} + +describe('Payout uses the snapshot recorded at game start', () => { + test('pays the snapshot amount, not the live-config amount', async () => { + const { gmm, db } = buildGmm(); + + // Snapshot recorded at start: escape pays 123456 atomic (deliberately unlike the + // live 2x = 200000000000), treasure pays 999999. + db.query + .mockResolvedValueOnce({ rows: [{ + id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr', + payout_escape_amount: '123456', payout_treasure_amount: '999999', + payout_escape_mult: '2.000', payout_treasure_mult: '3.000' + }] }) // game record lookup + .mockResolvedValueOnce({ rows: [] }) // game completion UPDATE + .mockResolvedValueOnce({ rows: [] }) // existing-payout check + .mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'wow1addr' }] }) // user lookup + .mockResolvedValueOnce({ rows: [{ id: 999 }] }); // INSERT payout + + // Even if live config is mutated after start, the snapshot must win. + gmm.directPayoutMultipliers = { escape: 9, escapeWithTreasure: 9 }; + + const result = await gmm.completeGame('socket1', 'game123', true, false, { moves: 10 }); + + expect(result.payout).toBeDefined(); + expect(result.payout.amount).toBe('123456'); + }); + + test('falls back to live calculation for legacy games without a snapshot', async () => { + const { gmm, db } = buildGmm(); + + db.query + .mockResolvedValueOnce({ rows: [{ id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] }) // no snapshot cols + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'wow1addr' }] }) + .mockResolvedValueOnce({ rows: [{ id: 999 }] }); + + const result = await gmm.completeGame('socket1', 'game123', true, false, { moves: 10 }); + + expect(result.payout.amount).toBe(200000000000); // live 2x + }); +});