fix: gate direct (entry-fee) payouts behind DIRECT_PAYOUTS_ENABLED

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.
This commit is contained in:
jwinterm
2026-05-31 15:25:46 -04:00
parent 703dda265b
commit d8d10728ba
3 changed files with 35 additions and 5 deletions
+15 -4
View File
@@ -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
+3 -1
View File
@@ -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',
+17
View File
@@ -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();
});
});