From d8d10728bae2096d450ab0fe09dfae3fdbf4dcd1 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 31 May 2026 15:25:46 -0400 Subject: [PATCH] fix: gate direct (entry-fee) payouts behind DIRECT_PAYOUTS_ENABLED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit completeGame only gated PAID_CREDITS payouts (on creditsPayoutEnabled); a PAID_SINGLE win paid out unconditionally, so DIRECT_PAYOUTS_ENABLED=false did NOT actually stop direct payouts — a no-payout instance (mainnet legitimacy) would still send crypto on an entry-fee win. Caught during deploy verification. - Add this.directPayoutEnabled (from DIRECT_PAYOUTS_ENABLED env / direct rule enabled in the config snapshot), mirroring creditsPayoutEnabled. - completeGame now gates the PAID_SINGLE payout on directPayoutEnabled. - The derived directPayoutsEnabled flags (getGameModeInfo / effective mode) and /api/game-modes PAID_SINGLE.payoutMultiplier now respect it (show 0 when off) instead of inferring from multipliers > 0. Adds a regression test: a PAID_SINGLE win with directPayoutEnabled=false queues no payout. Full suite: 22 suites / 159 tests. --- src/game/gameModeManager.js | 19 +++++++++++++++---- src/index.js | 4 +++- test/payoutSnapshot.test.js | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index cb9d38c..452690b 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -77,6 +77,10 @@ class GameModeManager { this.creditsPackagePrice = parseAtomicEnvValue(process.env.CREDITS_PACKAGE_PRICE, this.creditsPackagePrice); this.creditsPerGameCost = parseAtomicEnvValue(process.env.CREDITS_PER_GAME, 1) || 1; this.creditsPayoutEnabled = /^true$/i.test(process.env.CREDITS_PAYOUTS_ENABLED || process.env.CREDITS_PAYOUT_ENABLED || 'false'); + // Direct (per-game / entry-fee) payouts must be independently gateable, like credits. + // Default true for backward compatibility; set DIRECT_PAYOUTS_ENABLED=false for a + // no-payout instance (sell entry/credits for prestige only, e.g. mainnet legitimacy). + this.directPayoutEnabled = !/^false$/i.test(process.env.DIRECT_PAYOUTS_ENABLED || 'true'); this.creditsPayoutBaseValue = parseAtomicEnvValue(process.env.CREDITS_PAYOUT_BASE, this.singleGamePrice); process.env.CREDITS_PER_GAME = String(this.creditsPerGameCost); @@ -159,6 +163,10 @@ class GameModeManager { const directRule = config.payouts.rules.direct || {}; const creditsRule = config.payouts.rules.credits || {}; + if (directRule.enabled !== undefined) { + this.directPayoutEnabled = !!directRule.enabled; + } + if (directRule.multipliers) { if (directRule.multipliers.escape !== undefined) { this.directPayoutMultipliers.escape = Number(directRule.multipliers.escape); @@ -469,7 +477,7 @@ class GameModeManager { bothModesEnabled, preferCreditsFirst: this.preferCreditsFirst, creditsPayoutsEnabled: this.creditsPayoutEnabled, - directPayoutsEnabled: this.directPayoutMultipliers.escape > 0 + directPayoutsEnabled: this.directPayoutEnabled && this.directPayoutMultipliers.escape > 0 }; } @@ -1152,7 +1160,7 @@ class GameModeManager { freePlayEnabled: this.freePlayEnabled, directModeEnabled: this.directModeEnabled, creditsModeEnabled: this.creditsModeEnabled, - directPayoutsEnabled: this.directPayoutMultipliers.escape > 0, + directPayoutsEnabled: this.directPayoutEnabled && this.directPayoutMultipliers.escape > 0, creditsPayoutsEnabled: this.creditsPayoutEnabled, payoutMultipliers: { direct: this.directPayoutMultipliers, @@ -1244,8 +1252,11 @@ 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); + // Handle payouts based on the actual recorded payment mode, not the current global + // setting. Each mode's payout is gated by its own enable flag, so a no-payout + // instance (DIRECT_PAYOUTS_ENABLED=false / CREDITS_PAYOUTS_ENABLED=false) never sends. + const payoutEligibleStartMode = (recordedPaymentMode === 'PAID_SINGLE' && this.directPayoutEnabled) + || (recordedPaymentMode === 'PAID_CREDITS' && this.creditsPayoutEnabled); if (payoutEligibleStartMode && won) { // 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 diff --git a/src/index.js b/src/index.js index a882966..bacfecd 100644 --- a/src/index.js +++ b/src/index.js @@ -595,7 +595,9 @@ app.get('/api/game-modes', (req, res) => { name: 'Paid Single Game', cost: toDisplay(directMode.price), enabled: !!directMode.enabled, - payoutMultiplier: config.payouts.rules.direct.multipliers + payoutMultiplier: (directMode.enabled && config.payouts.rules.direct.enabled) + ? config.payouts.rules.direct.multipliers + : 0 }, PAID_CREDITS: { name: 'Credits Package', diff --git a/test/payoutSnapshot.test.js b/test/payoutSnapshot.test.js index bfc96f3..b18a0e1 100644 --- a/test/payoutSnapshot.test.js +++ b/test/payoutSnapshot.test.js @@ -89,4 +89,21 @@ describe('Payout uses the snapshot recorded at game start', () => { expect(result.payout.amount).toBe(200000000000); // live 2x }); + + test('no payout for a PAID_SINGLE win when direct payouts are disabled (no-payout instance)', async () => { + const { gmm, db } = buildGmm(); + gmm.directPayoutEnabled = false; // e.g. mainnet legitimacy: sell entry, never pay out + + db.query + .mockResolvedValueOnce({ rows: [{ id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] }) // game record + .mockResolvedValue({ rows: [] }); // completion UPDATE + anything else + + const result = await gmm.completeGame('socket1', 'game123', true, true, { moves: 10 }); + + expect(result.success).toBe(true); + expect(result.payout == null || result.payout === undefined).toBe(true); // no payout queued + // No payout INSERT happened. + const insertedPayout = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0])); + expect(insertedPayout).toBeUndefined(); + }); });