/** * Game Mode Manager * Handles different game modes (FREE, PAID_SINGLE, PAID_CREDITS) */ const { DEFAULT_DECIMALS, parseAtomicEnvValue, inferCurrencyDecimals: inferCurrencyDecimalsForSymbol, formatAtomic, formatAtomicHuman, getDecimalDivisor, gameNameFor, currencyLabelFor } = require('./helpers/gameModeUtils'); const { ValidationError, normalizeError } = require('../utils/errors'); const paymentConfig = require('../config/paymentConfig'); const money = require('../money/atomic'); // A wallet "not enough (unlocked) money" error is raised BEFORE the tx is broadcast, so it // is SAFE to retry (no double-pay risk) — unlike an ambiguous post-broadcast failure. Monero // locks spent outputs (incl. change) for ~10 blocks, so this is the expected error when all // outputs are temporarily locked. function isInsufficientFundsError(message) { return /not enough (unlocked )?(money|balance|outputs)|insufficient|no unlocked|unlocked balance/i.test(String(message || '')); } const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW depending on currency decimals const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000; class GameModeManager { constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null) { this.db = databaseManager; this.walletService = walletRPCService; this.debugManager = debugManager; this.paymentConfigManager = paymentConfigManager || null; this.cryptoType = process.env.CRYPTO_TYPE || 'XMR'; this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType); // Network configuration (mainnet/stagenet/testnet) - only applies to Monero // Wownero only has mainnet, so this is ignored for WOW this.network = (process.env.MONERO_NETWORK || 'mainnet').toLowerCase(); this.isTestNetwork = this.network === 'stagenet' || this.network === 'testnet'; this.singleGamePrice = DEFAULT_SINGLE_GAME_PRICE; this.creditsPackagePrice = DEFAULT_CREDITS_PACKAGE_PRICE; this.creditsPayoutEnabled = false; this.creditsPayoutBaseValue = DEFAULT_SINGLE_GAME_PRICE; this.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; this.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; this.creditsPerGameCost = 1; this.preferCreditsFirst = true; this.paymentsEnabled = false; this.directModeEnabled = false; this.creditsModeEnabled = false; this.configSnapshot = null; this.applyLegacyEnvConfig(); if (this.paymentConfigManager) { const config = this.paymentConfigManager.getConfig(); this.applyConfigSnapshot(config, { emitLog: false }); this.setLegacyGameMode(this.paymentConfigManager.getLegacyGameMode()); if (this.paymentConfigManager.eventBus && typeof this.paymentConfigManager.eventBus.on === 'function') { this.paymentConfigManager.eventBus.on('paymentConfig:update', (updatedConfig) => { this.applyConfigSnapshot(updatedConfig, { emitLog: true, context: 'updated' }); this.setLegacyGameMode(this.paymentConfigManager.getLegacyGameMode()); }); } } this.logConfiguration('initialized'); } inferCurrencyDecimals(symbol) { return inferCurrencyDecimalsForSymbol(symbol); } // Product name (Monerogue/Wownerogue) and network-aware currency label (sXMR on // stagenet, XMR on mainnet, WOW for Wownero) — derived so they always track config. get gameName() { return gameNameFor(this.cryptoType); } get currencyLabel() { return currencyLabelFor(this.cryptoType, this.network); } applyLegacyEnvConfig() { this.setLegacyGameMode(process.env.GAME_MODE || 'FREE'); this.cryptoType = process.env.CRYPTO_TYPE || this.cryptoType; this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType); // Support both DIRECT_GAME_PRICE and legacy SINGLE_GAME_PRICE this.singleGamePrice = parseAtomicEnvValue(process.env.DIRECT_GAME_PRICE || process.env.SINGLE_GAME_PRICE, this.singleGamePrice); 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); const directEscape = Number(process.env.DIRECT_PAYOUT_ESCAPE); if (Number.isFinite(directEscape) && directEscape > 0) { this.directPayoutMultipliers.escape = directEscape; } const directTreasure = Number(process.env.DIRECT_PAYOUT_TREASURE); if (Number.isFinite(directTreasure) && directTreasure > 0) { this.directPayoutMultipliers.escapeWithTreasure = directTreasure; } const creditsEscape = Number(process.env.CREDITS_PAYOUT_ESCAPE); if (Number.isFinite(creditsEscape) && creditsEscape > 0) { this.creditPayoutMultipliers.escape = creditsEscape; } const creditsTreasure = Number(process.env.CREDITS_PAYOUT_TREASURE); if (Number.isFinite(creditsTreasure) && creditsTreasure > 0) { this.creditPayoutMultipliers.escapeWithTreasure = creditsTreasure; } if (process.env.PREFER_CREDITS_FIRST) { this.preferCreditsFirst = /^true$/i.test(process.env.PREFER_CREDITS_FIRST); } this.paymentsEnabled = this.gameMode !== 'FREE'; this.directModeEnabled = this.gameMode === 'PAID_SINGLE'; this.creditsModeEnabled = this.gameMode === 'PAID_CREDITS'; // FREE_PLAY_ENABLED lets players CHOOSE free play even on an instance that also // sells credits/entry (their score goes to the Pleb board; paid games go to the // Hall of Champions). Always true when payments are off (free is the only option). this.freePlayEnabled = !this.paymentsEnabled || /^true$/i.test(process.env.FREE_PLAY_ENABLED || 'false'); } applyConfigSnapshot(config, options = {}) { if (!config || typeof config !== 'object') { return; } this.configSnapshot = config; this.paymentsEnabled = !!config.paymentsEnabled; if (config.currency) { if (config.currency.symbol) { this.cryptoType = config.currency.symbol; } if (config.currency.decimals !== undefined) { this.currencyDecimals = Number(config.currency.decimals); } else { this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType); } } if (config.modes && config.modes.direct) { const { price, enabled } = config.modes.direct; if (price !== undefined && price !== null) { this.singleGamePrice = Number(price); } this.directModeEnabled = !!enabled; } if (config.modes && config.modes.credits) { const creditsMode = config.modes.credits; if (creditsMode.packages && creditsMode.packages.length > 0) { const primaryPackage = creditsMode.packages[0]; if (primaryPackage.price !== undefined && primaryPackage.price !== null) { this.creditsPackagePrice = Number(primaryPackage.price); } if (primaryPackage.credits) { process.env.CREDITS_PER_PACKAGE = String(primaryPackage.credits); } } if (creditsMode.creditsPerGame !== undefined) { this.creditsPerGameCost = Number(creditsMode.creditsPerGame) || 1; } this.creditsModeEnabled = !!creditsMode.enabled; } if (config.payouts && config.payouts.rules) { 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); } if (directRule.multipliers.escapeWithTreasure !== undefined) { this.directPayoutMultipliers.escapeWithTreasure = Number(directRule.multipliers.escapeWithTreasure); } } if (creditsRule.multipliers) { if (creditsRule.multipliers.escape !== undefined) { this.creditPayoutMultipliers.escape = Number(creditsRule.multipliers.escape); } if (creditsRule.multipliers.escapeWithTreasure !== undefined) { this.creditPayoutMultipliers.escapeWithTreasure = Number(creditsRule.multipliers.escapeWithTreasure); } } if (creditsRule.baseValue !== undefined) { this.creditsPayoutBaseValue = Number(creditsRule.baseValue); } if (creditsRule.enabled !== undefined) { this.creditsPayoutEnabled = !!creditsRule.enabled; } } if (config.preferences) { if (config.preferences.preferCreditsFirst !== undefined) { this.preferCreditsFirst = !!config.preferences.preferCreditsFirst; } } process.env.SINGLE_GAME_PRICE = String(this.singleGamePrice); process.env.CREDITS_PACKAGE_PRICE = String(this.creditsPackagePrice); process.env.CREDITS_PER_GAME = String(this.creditsPerGameCost); process.env.CREDITS_PAYOUT_ENABLED = this.creditsPayoutEnabled ? 'true' : 'false'; process.env.CREDITS_PAYOUTS_ENABLED = process.env.CREDITS_PAYOUT_ENABLED; if (options.emitLog) { this.logConfiguration(options.context || 'updated'); } } setLegacyGameMode(mode) { this.gameMode = (mode || 'FREE').toUpperCase(); process.env.GAME_MODE = this.gameMode; } formatAtomic(value) { return formatAtomic({ value, decimals: Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS }); } getDecimalDivisor() { return getDecimalDivisor(Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS); } formatAtomicHuman(value, digits = 3) { return formatAtomicHuman({ value, decimals: Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS, digits }); } getPrimaryCreditPackage() { const packages = this.configSnapshot?.modes?.credits?.packages; if (Array.isArray(packages) && packages.length > 0) { return packages[0]; } return { id: 'default', credits: 10, price: this.creditsPackagePrice, bonus: 0 }; } calculatePayout(mode, { treasureFound = false } = {}) { const normalizedMode = (mode || this.gameMode || 'FREE').toUpperCase(); const usingCredits = normalizedMode === 'PAID_CREDITS'; const base = usingCredits ? (this.creditsPayoutBaseValue || this.singleGamePrice) : this.singleGamePrice; const multipliers = usingCredits ? this.creditPayoutMultipliers : this.directPayoutMultipliers; const multiplier = treasureFound ? (multipliers.escapeWithTreasure ?? multipliers.escape ?? 0) : (multipliers.escape ?? 0); // 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})`); return { amount, multiplier, base }; } /** * Process a confirmed credits package payment - add credits to user * SECURITY: Uses atomic check-and-update to prevent double-crediting on server restart * @param {string} socketId - Socket ID of the user * @param {number} paymentId - Payment record ID * @param {object} packageInfo - Package info (credits, bonus, etc.) * @returns {object} Result with success, creditsAdded, newBalance, alreadyProcessed */ async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null, receivedAmount = null) { try { // Look up user from payment record's user_id (stable across socket reconnects) // This avoids the bug where socket ID changes if user refreshes during confirmation const paymentLookup = await this.db.query(` SELECT user_id, description, status FROM payments WHERE id = $1 `, [paymentId]); if (paymentLookup.rows.length === 0) { console.error(`Payment ${paymentId} not found`); return { success: false, reason: 'Payment not found' }; } // Fast-fail if already confirmed (avoids unnecessary transaction) if (paymentLookup.rows[0].status === 'confirmed') { console.log(`Payment ${paymentId} already confirmed, skipping duplicate processing`); return { success: false, alreadyProcessed: true, reason: 'Payment already processed' }; } const userId = paymentLookup.rows[0].user_id; // Determine credits to add from package info or payment description let creditsToAdd = 10; // Default fallback if (packageInfo && packageInfo.credits) { creditsToAdd = Number(packageInfo.credits) + (Number(packageInfo.bonus) || 0); } else { const desc = paymentLookup.rows[0].description || ''; const match = desc.match(/(\d+)\s*credits?/i); if (match) { creditsToAdd = parseInt(match[1], 10) || 10; } } // CRITICAL: Wrap all three operations in a transaction. // If any step fails, the payment stays 'pending' and can be recovered. const result = await this.db.withTransaction(async (client) => { // Step 1: Atomically mark payment as confirmed (prevents double-crediting) const paymentUpdateResult = await client.query(` UPDATE payments SET status = 'confirmed', credits_purchased = $1, confirmed_at = NOW(), received_amount = COALESCE($3, received_amount) WHERE id = $2 AND status = 'pending' RETURNING id `, [creditsToAdd, paymentId, receivedAmount]); if (paymentUpdateResult.rows.length === 0) { // Already processed by another instance - not an error return { alreadyProcessed: true }; } // Step 2: Add credits to user (using payment's user_id, not socket lookup) const updateResult = await client.query(` UPDATE users SET credits = credits + $1, total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1, updated_at = NOW() WHERE id = $2 RETURNING credits `, [creditsToAdd, userId]); const newBalance = updateResult.rows[0]?.credits ?? creditsToAdd; // Step 3: Record credit transaction for audit trail await client.query(` INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) VALUES ($1, $2, 'package_purchase', $3, 'purchase') `, [userId, creditsToAdd, newBalance]); return { newBalance }; }); if (result.alreadyProcessed) { console.log(`Payment ${paymentId} already confirmed (atomic check), skipping duplicate`); return { success: false, alreadyProcessed: true, reason: 'Payment already processed' }; } console.log(`Credits package confirmed: +${creditsToAdd} credits for user ${userId}, new balance: ${result.newBalance}`); return { success: true, creditsAdded: creditsToAdd, newBalance: result.newBalance }; } catch (error) { const normalized = normalizeError(error, 'Failed to process credits package confirmation'); console.error('Error processing credits package:', normalized.message); return { success: false, reason: normalized.safeMessage }; } } async _findReusablePayment(userId, paymentType) { if (!userId) return null; const result = await this.db.query(` SELECT id, subaddress, expected_amount, payment_type, status, created_at, expires_at, description FROM payments WHERE user_id = $1 AND payment_type = $2 AND status = 'pending' AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at DESC LIMIT 1 `, [userId, paymentType]); return result.rows[0] || null; } /** * Convert package info to JSON-safe format (BigInt price -> Number) */ _serializePackageInfo(packageInfo) { if (!packageInfo) return null; return { ...packageInfo, price: typeof packageInfo.price === 'bigint' ? Number(packageInfo.price) : packageInfo.price }; } _mapPaymentRowToRequest(row, paymentType, packageInfo) { if (!row) return null; const amount = Number(row.expected_amount); return { id: row.id, address: row.subaddress, amount, amountFormatted: this.formatAtomicHuman(amount, 4), currency: this.cryptoType, expiresAt: row.expires_at, paymentType, description: row.description, package: this._serializePackageInfo(packageInfo), reused: true }; } logConfiguration(context = 'initialized') { console.log(`🎮 Game Mode Manager ${context}: ${this.gameMode} mode`); console.log(`💰 Currency: ${this.cryptoType} (decimals: ${this.currencyDecimals})`); console.log(`💵 Single game price: ${this.singleGamePrice} atomic units (~${this.formatAtomic(this.singleGamePrice)} ${this.cryptoType})`); console.log(`💵 Credits payout base: ${this.creditsPayoutBaseValue} atomic units (~${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`); console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units (~${this.formatAtomic(this.creditsPackagePrice)} ${this.cryptoType})`); console.log(`🎯 Credits per game cost: ${this.creditsPerGameCost}`); console.log(`🧮 Payout multipliers - direct: ${JSON.stringify(this.directPayoutMultipliers)}, credits: ${JSON.stringify(this.creditPayoutMultipliers)}`); console.log(`🔁 Mode availability - direct: ${this.directModeEnabled}, credits: ${this.creditsModeEnabled}, preferCreditsFirst: ${this.preferCreditsFirst}`); if (this.creditsPayoutEnabled) { console.log(`🎁 Credits payout mode ENABLED - base value: ${this.creditsPayoutBaseValue} atomic (~${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`); } console.log(`⚙️ Payments enabled: ${this.paymentsEnabled}`); } /** * Get effective game mode for a specific user, considering: * - Whether both modes are enabled (mixed mode) * - User's credit balance * - preferCreditsFirst setting * @param {object} user - User object with credits field * @returns {object} { mode, canUseCredits, canUseDirect, hasCredits, creditsBalance } */ getEffectiveModeForUser(user) { const hasCredits = (user?.credits || 0) >= this.creditsPerGameCost; const bothModesEnabled = this.directModeEnabled && this.creditsModeEnabled; // Determine available options const canUseCredits = this.creditsModeEnabled && hasCredits; const canUseDirect = this.directModeEnabled; // Determine effective mode let effectiveMode; if (!this.paymentsEnabled) { effectiveMode = 'FREE'; } else if (bothModesEnabled) { // Mixed mode: prefer based on config and availability if (this.preferCreditsFirst && hasCredits) { effectiveMode = 'PAID_CREDITS'; } else if (hasCredits) { effectiveMode = 'PAID_CREDITS'; // Has credits, can use them } else { effectiveMode = 'PAID_SINGLE'; // No credits, must pay } } else if (this.creditsModeEnabled) { effectiveMode = 'PAID_CREDITS'; } else if (this.directModeEnabled) { effectiveMode = 'PAID_SINGLE'; } else { effectiveMode = 'FREE'; } return { mode: effectiveMode, canUseCredits, canUseDirect, hasCredits, creditsBalance: user?.credits || 0, creditsRequired: this.creditsPerGameCost, bothModesEnabled, preferCreditsFirst: this.preferCreditsFirst, creditsPayoutsEnabled: this.creditsPayoutEnabled, directPayoutsEnabled: this.directPayoutEnabled && this.directPayoutMultipliers.escape > 0 }; } /** * Get available payment options for a user * @param {string} socketId - Socket ID * @returns {object} Available options for the user */ async getPaymentOptionsForUser(socketId) { try { const user = await this.getOrCreateUser(socketId); const effective = this.getEffectiveModeForUser(user); const options = []; // Free play (Pleb board, no payout) — offered alongside paid options when enabled. if (this.freePlayEnabled && this.paymentsEnabled) { options.push({ type: 'play_free', label: 'Play Free (Pleb leaderboard)', mode: 'FREE', cost: 0, costDisplay: 'Free', payoutEligible: false, recommended: false }); } if (effective.canUseCredits) { options.push({ type: 'use_credit', label: `Use 1 Credit (${effective.creditsBalance} available)`, mode: 'PAID_CREDITS', cost: 0, costDisplay: '1 credit', payoutEligible: effective.creditsPayoutsEnabled, recommended: effective.preferCreditsFirst }); } if (effective.canUseDirect) { options.push({ type: 'pay_direct', label: `Pay ${this.formatAtomicHuman(this.singleGamePrice, 2)} ${this.cryptoType}`, mode: 'PAID_SINGLE', cost: this.singleGamePrice, costDisplay: `${this.formatAtomicHuman(this.singleGamePrice, 2)} ${this.cryptoType}`, payoutEligible: effective.directPayoutsEnabled, recommended: !effective.preferCreditsFirst || !effective.hasCredits }); } if (this.creditsModeEnabled) { const pkg = this.getPrimaryCreditPackage(); options.push({ type: 'buy_credits', label: `Buy ${pkg.credits} Credits`, mode: 'PURCHASE', cost: Number(pkg.price), costDisplay: `${this.formatAtomicHuman(pkg.price, 2)} ${this.cryptoType}`, credits: pkg.credits + (pkg.bonus || 0), payoutEligible: false, recommended: false }); } return { user: { credits: effective.creditsBalance, hasPayoutAddress: !!user.payout_address }, effective, options }; } catch (error) { const normalized = normalizeError(error, 'Failed to get payment options'); console.error('❌ Error getting payment options:', normalized.message); return { user: { credits: 0, hasPayoutAddress: false }, effective: { mode: 'FREE' }, options: [] }; } } /** * Check if user can start a game */ async canUserStartGame(socketId) { try { const user = await this.getOrCreateUser(socketId); const effective = this.getEffectiveModeForUser(user); // FREE mode or payments disabled if (effective.mode === 'FREE') { return { allowed: true, reason: 'Free mode' }; } // Mixed mode: check if user can play with credits OR has confirmed payment if (effective.bothModesEnabled) { // Option 1: User has credits if (effective.hasCredits) { return { allowed: true, reason: `${effective.creditsBalance} credits available`, useCredits: true, creditsRequired: this.creditsPerGameCost, balance: effective.creditsBalance, effectiveMode: 'PAID_CREDITS' }; } // Option 2: User has confirmed single_game payment const pendingPayment = await this.db.query(` SELECT * FROM payments WHERE socket_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) ORDER BY confirmed_at DESC LIMIT 1 `, [socketId]); if (pendingPayment.rows.length > 0) { return { allowed: true, reason: 'Payment confirmed', paymentId: pendingPayment.rows[0].id, effectiveMode: 'PAID_SINGLE' }; } // Neither credits nor payment available return { allowed: false, reason: 'Payment or credits required', action: 'choose_payment', options: await this.getPaymentOptionsForUser(socketId) }; } // Single mode logic (backwards compatible) switch (effective.mode) { case 'PAID_CREDITS': if (effective.hasCredits) { return { allowed: true, reason: `${effective.creditsBalance} credits remaining`, creditsRequired: this.creditsPerGameCost, effectiveMode: 'PAID_CREDITS' }; } return { allowed: false, reason: 'Insufficient credits', action: 'purchase_credits', creditsRequired: this.creditsPerGameCost, balance: effective.creditsBalance }; case 'PAID_SINGLE': const payment = await this.db.query(` SELECT * FROM payments WHERE socket_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) ORDER BY confirmed_at DESC LIMIT 1 `, [socketId]); if (payment.rows.length > 0) { return { allowed: true, reason: 'Payment confirmed', paymentId: payment.rows[0].id, effectiveMode: 'PAID_SINGLE' }; } return { allowed: false, reason: 'Payment required', action: 'make_payment' }; default: return { allowed: false, reason: 'Invalid game mode' }; } } catch (error) { const normalized = normalizeError(error, 'Unable to verify game eligibility'); console.error('❌ Error checking user game eligibility:', normalized.message); return { allowed: false, reason: normalized.safeMessage }; } } /** * Process game start (deduct credits or link payment) */ async processGameStart(socketId, gameId, options = {}) { try { const user = await this.getOrCreateUser(socketId); // The player explicitly chose FREE play (even on an instance that also sells // credits/entry). Record this game as FREE so it lands on the Pleb leaderboard, // with no payment and no payout. if (options.forceFree) { return await this._processGameStartFree(user, gameId); } const effective = this.getEffectiveModeForUser(user); // FREE mode (instance is free-only) if (effective.mode === 'FREE') { return { success: true, effectiveMode: 'FREE' }; } // Mixed mode: determine which method to use if (effective.bothModesEnabled) { // Prefer credits if available and preferCreditsFirst is true if (effective.hasCredits && this.preferCreditsFirst) { return await this._processGameStartWithCredits(user, socketId, gameId); } // Check for confirmed direct payment const payment = await this.db.query(` SELECT * FROM payments WHERE socket_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) ORDER BY confirmed_at DESC LIMIT 1 `, [socketId]); if (payment.rows.length > 0) { return await this._processGameStartWithPayment(user, payment.rows[0], gameId); } // Fall back to credits if available if (effective.hasCredits) { return await this._processGameStartWithCredits(user, socketId, gameId); } return { success: false, reason: 'No valid payment or credits found' }; } // Single mode logic switch (effective.mode) { case 'PAID_CREDITS': return await this._processGameStartWithCredits(user, socketId, gameId); case 'PAID_SINGLE': const payment = await this.db.query(` SELECT * FROM payments WHERE socket_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' AND NOT EXISTS ( SELECT 1 FROM games WHERE games.payment_id = payments.id ) ORDER BY confirmed_at DESC LIMIT 1 `, [socketId]); if (payment.rows.length === 0) { return { success: false, reason: 'No valid payment found' }; } return await this._processGameStartWithPayment(user, payment.rows[0], gameId); default: return { success: false, reason: 'Invalid game mode' }; } } catch (error) { const normalized = normalizeError(error, 'Failed to process game start'); console.error('❌ Error processing game start:', normalized.message); return { success: false, reason: normalized.safeMessage }; } } /** * 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 }; } /** * Start a game the player explicitly chose to play for FREE. Records game_mode='FREE' * on the game (overriding the instance default, so it goes to the Pleb leaderboard) and * counts the game. No payment, no payout. */ async _processGameStartFree(user, gameId) { await this.db.withTransaction(async (client) => { await client.query(`UPDATE games SET game_mode = 'FREE' WHERE dungeon_seed = $1`, [gameId]); await client.query(` UPDATE users SET total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $1 `, [user.id]); }); return { success: true, effectiveMode: 'FREE' }; } 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 const updateRes = await client.query(` UPDATE users SET credits = credits - $1, total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $2 AND credits >= $1 RETURNING credits `, [creditsToSpend, user.id]); if (updateRes.rows.length === 0) { return { insufficient: true }; } const remainingCredits = updateRes.rows[0].credits; await client.query(` 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, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]); await client.query(` INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) VALUES ($1, $2, 'game_entry', $3, 'spend') `, [user.id, -creditsToSpend, remainingCredits]); return { remainingCredits }; }); if (result.insufficient) { return { success: false, reason: 'Insufficient credits', creditsRequired: creditsToSpend, creditsAvailable: user.credits }; } console.log(`Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${result.remainingCredits} remaining`); return { success: true, creditsRemaining: result.remainingCredits, creditsSpent: creditsToSpend, effectiveMode: 'PAID_CREDITS' }; } 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, 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, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]); await client.query(` UPDATE users SET total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $1 `, [user.id]); }); console.log(`Linked game ${gameId} to payment ${payment.id}`); return { success: true, paymentId: payment.id, effectiveMode: 'PAID_SINGLE' }; } // processGameCompletion() removed — dead code, never called. // Only completeGame() (below) is used for game completion/payouts. /** * Create payment request */ async createPaymentRequest(socketId, paymentType, options = {}) { try { // Prefer session-resolved userId (stable across reconnects) over socket_id lookup let user; if (options.userId) { const result = await this.db.query('SELECT * FROM users WHERE id = $1', [options.userId]); user = result.rows[0] || await this.getOrCreateUser(socketId); } else { user = await this.getOrCreateUser(socketId); } const reuseExisting = options.reuseExisting !== false; const requestedPackageId = options.packageId; let amount; let description; let packageInfo = null; switch (paymentType) { case 'single_game': { amount = this.singleGamePrice; description = `${this.gameName} single game entry (${this.currencyLabel})`; break; } case 'credits_package': { // Find the requested package, or fall back to primary const packages = this.configSnapshot?.modes?.credits?.packages || []; let selectedPackage = null; if (requestedPackageId && packages.length > 0) { selectedPackage = packages.find(p => p.id === requestedPackageId); } // Fall back to first package if not found if (!selectedPackage) { selectedPackage = this.getPrimaryCreditPackage(); } const packagePrice = selectedPackage?.price ?? this.creditsPackagePrice; amount = typeof packagePrice === 'bigint' ? Number(packagePrice) : Number(packagePrice); packageInfo = selectedPackage; const creditCount = selectedPackage?.credits ?? 10; const bonusCredits = selectedPackage?.bonus ?? 0; const bonusText = bonusCredits > 0 ? ` (+${bonusCredits} bonus)` : ''; description = `${this.gameName} ${creditCount}${bonusText} credits package (${this.currencyLabel})`; break; } default: throw new ValidationError(`Invalid payment type requested: ${paymentType}`, { safeMessage: 'Unsupported payment type requested.' }); } if (reuseExisting) { const existingRow = await this._findReusablePayment(user.id, paymentType); if (existingRow) { const existing = this._mapPaymentRowToRequest(existingRow, paymentType, packageInfo); if (existing && !existing.description) { existing.description = description; } return existing; } } // Expire any stale pending payments for this user before creating a new one. // A user should only have one active pending payment at a time. const expiredResult = await this.db.query(` UPDATE payments SET status = 'expired' WHERE user_id = $1 AND status = 'pending' RETURNING subaddress `, [user.id]); const expiredAddresses = expiredResult.rows.map(r => r.subaddress); // Create payment request using wallet RPC with correct parameters const paymentResult = await this.walletService.createPaymentRequest( amount, description, user.id, socketId ); const expiresAt = paymentResult.expiresAt || new Date(Date.now() + 30 * 60 * 1000); // Store payment info in database (address_index enables monitoring restoration after restart) const insertResult = await this.db.query(` INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, description, created_at, expires_at, address_index) VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7, $8) RETURNING id, expires_at `, [user.id, socketId, paymentResult.address, amount, paymentType, description, expiresAt, paymentResult.addressIndex ?? null]); const insertedRow = insertResult.rows[0]; return { id: insertedRow?.id, address: paymentResult.address, amount: amount, amountFormatted: this.formatAtomicHuman(amount, 4), currency: this.cryptoType, expiresAt: insertedRow?.expires_at || expiresAt, package: this._serializePackageInfo(packageInfo), paymentType, description, reused: false, expiredAddresses }; } catch (error) { const normalized = normalizeError(error, 'Failed to create payment request'); console.error('❌ Error creating payment request:', normalized.message); throw normalized; } } /** * Get or create user record */ /** * Resolve the database user for a socket. * * IDENTITY (Phase 2.1): The stable identity is `users.id`, established from the * client's `anon_token` by SessionManager at connection time. This method resolves * through that session identity FIRST, so every money/credit/payout path operates on * the same stable row. It only falls back to the legacy `socket_id` lookup-or-create * when no session exists (e.g. SessionManager not wired, or a REST call for a socket * with no live session) — `socket_id` is mutable and non-unique and must never be the * primary money key. */ async getOrCreateUser(socketId, ipAddress = null) { try { // 1. Prefer the stable identity established by SessionManager (anon_token -> id). if (this.sessionManager && typeof this.sessionManager.getBySocket === 'function') { try { const sessionUser = await this.sessionManager.getBySocket(socketId); if (sessionUser && sessionUser.id != null) { // Return a FRESH row by stable id (the cached session row may be stale). const fresh = await this.db.query(`SELECT * FROM users WHERE id = $1`, [sessionUser.id]); if (fresh.rows.length > 0) { await this.db.query(`UPDATE users SET last_active = NOW() WHERE id = $1`, [fresh.rows[0].id]); return fresh.rows[0]; } } } catch (sessErr) { // Fall through to legacy resolution on any session-lookup error. } } // 2. Legacy fallback: look up by socket_id. let userResult = await this.db.query(` SELECT * FROM users WHERE socket_id = $1 `, [socketId]); if (userResult.rows.length > 0) { await this.db.query(` UPDATE users SET last_active = NOW() WHERE id = $1 `, [userResult.rows[0].id]); return userResult.rows[0]; } // 3. Last resort: create a new user. This only fires when no session was // established for the socket (abnormal in normal flow, since every connection // creates a session) — log it so orphan-row creation is visible. userResult = await this.db.query(` INSERT INTO users (socket_id, ip_address) VALUES ($1, $2) RETURNING * `, [socketId, ipAddress]); console.warn(`👤 Created new user without a session (socket ${socketId}) — no anon_token identity resolved.`); return userResult.rows[0]; } catch (error) { const normalized = normalizeError(error, 'Failed to load user'); console.error('❌ Error getting/creating user:', normalized.message); throw normalized; } } /** * Set user payout address */ async setUserPayoutAddress(socketId, payoutAddress) { try { const user = await this.getOrCreateUser(socketId); await this.db.query(` UPDATE users SET payout_address = $1, updated_at = NOW() WHERE id = $2 `, [payoutAddress, user.id]); console.log(`💰 Set payout address for user ${user.id}: ${payoutAddress}`); return true; } catch (error) { const normalized = normalizeError(error, 'Failed to update payout address'); console.error('❌ Error setting payout address:', normalized.message); return false; } } /** * Get user statistics */ async getUserStats(socketId) { try { const result = await this.db.query(` SELECT * FROM get_user_stats($1) `, [socketId]); if (result.rows.length > 0) { return result.rows[0]; } return { total_games: 0, games_won: 0, win_rate: 0, total_paid: 0, total_won: 0, net_profit: 0, credits_remaining: 0 }; } catch (error) { const normalized = normalizeError(error, 'Failed to load user stats'); console.error('❌ Error getting user stats:', normalized.message); return null; } } /** * Get game mode info for frontend */ getGameModeInfo() { // Debug: Log credits payout base value being sent console.log(`📤 getGameModeInfo: creditsPayoutBaseValue = ${this.creditsPayoutBaseValue} (${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`); // Determine if we should show a testnet warning // Only for XMR on stagenet/testnet - WOW only has mainnet const showTestnetWarning = this.cryptoType === 'XMR' && this.isTestNetwork; const testnetWarning = showTestnetWarning ? `⚠️ STAGENET MODE: This server is using ${this.network} XMR. Do NOT send real mainnet XMR! Only ${this.network} XMR will be accepted.` : null; // Serialize all credit packages (convert BigInt prices to Number) const packages = this.configSnapshot?.modes?.credits?.packages || []; const creditPackages = packages.map(pkg => ({ id: pkg.id, credits: pkg.credits, price: typeof pkg.price === 'bigint' ? Number(pkg.price) : pkg.price, bonus: pkg.bonus || 0, priceFormatted: this.formatAtomicHuman( typeof pkg.price === 'bigint' ? Number(pkg.price) : pkg.price, 2 ) })); return { mode: this.gameMode, cryptoType: this.cryptoType, currencyLabel: this.currencyLabel, // sXMR on stagenet, XMR on mainnet, WOW for Wownero gameName: this.gameName, // Monerogue / Wownerogue network: this.network, isTestNetwork: this.isTestNetwork, testnetWarning: testnetWarning, singleGamePrice: this.singleGamePrice, singleGamePriceFormatted: this.formatAtomicHuman(this.singleGamePrice, 2), creditsPackagePrice: this.creditsPackagePrice, creditsPerGame: this.creditsPerGameCost, creditPackages: creditPackages, creditsPayoutBaseValue: this.creditsPayoutBaseValue, paymentsEnabled: this.paymentsEnabled, freePlayEnabled: this.freePlayEnabled, directModeEnabled: this.directModeEnabled, creditsModeEnabled: this.creditsModeEnabled, directPayoutsEnabled: this.directPayoutEnabled && this.directPayoutMultipliers.escape > 0, creditsPayoutsEnabled: this.creditsPayoutEnabled, payoutMultipliers: { direct: this.directPayoutMultipliers, credits: this.creditPayoutMultipliers }, features: { paymentRequired: this.paymentsEnabled, creditsSystem: this.creditsModeEnabled, payouts: this.directPayoutMultipliers.escape > 0 || this.creditPayoutMultipliers.escape > 0 }, earlyEntry: { enabled: this.configSnapshot?.earlyEntry?.enabled ?? false, allowInFreeMode: this.configSnapshot?.earlyEntry?.allowInFreeMode ?? false, allowInCreditsMode: this.configSnapshot?.earlyEntry?.allowInCreditsMode ?? false }, // Smirk wallet integration. Smirk only works on mainnet, so it is forced off on // any test network (stagenet/testnet) regardless of SMIRK_ENABLED. smirkEnabled: process.env.SMIRK_ENABLED !== 'false' && !this.isTestNetwork, explorerTxUrl: process.env.EXPLORER_TX_URL || null }; } /** * Complete a game (called from socket handlers when game ends) * @param {string} socketId - Player's socket ID * @param {string} gameId - Game UUID * @param {boolean} won - Whether the player won (escaped) * @param {boolean} treasureFound - Whether treasure was found * @returns {object} payout / completion info */ async completeGame(socketId, gameId, won, treasureFound, metrics = {}) { // For FREE mode we just return basic info if (this.gameMode === 'FREE') { return { success: true, mode: 'FREE', payout: null, score: metrics.score ?? null }; } try { // Retrieve the game record to get the actual payment_mode used at game start 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, 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]); if (gameRecord.rows.length > 0) { 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 if (process.env.NODE_ENV === 'development') { console.warn('Game record lookup warning:', e.message); } } // Update/record game completion in DB try { await this.db.query(` UPDATE games SET status = $1, treasure_found = $2, moves_made = COALESCE($3, moves_made), duration_seconds = COALESCE($4, duration_seconds), completed_at = NOW() WHERE dungeon_seed = $5 AND socket_id = $6 `, [ won ? 'won' : 'lost', treasureFound, metrics.moves ?? null, metrics.durationSeconds ?? null, gameId, socketId ]); } catch (e) { // Non-fatal if games table differs during early dev. if (process.env.NODE_ENV === 'development') { console.warn('Game completion update warning:', e.message); } } // 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 // 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) { const existingPayout = await this.db.query(` SELECT id FROM payouts WHERE game_id = $1 AND status IN ('pending', 'processing', 'completed') `, [gameDbId]); if (existingPayout.rows.length > 0) { console.log(`⚠️ Payout already exists for game ${gameId}, skipping duplicate`); return { success: true, mode: recordedPaymentMode, payout: null, reason: 'Payout already processed', score: metrics.score ?? null }; } } // Use payout address locked at game start, fall back to current user address const userResult = await this.db.query(`SELECT * FROM users WHERE socket_id = $1 LIMIT 1`, [socketId]); const userRow = userResult.rows[0]; const payoutAddress = lockedPayoutAddress || (userRow && userRow.payout_address); if (userRow && payoutAddress) { // Create pending payout record, then schedule batch processing. // Multiple wins within 5 seconds are batched into one transfer_split call, // saving outputs and preventing locked-output failures. try { const insertResult = await this.db.query(` INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, created_at) VALUES ($1, $2, $3, $4, $5, $6, 'pending', NOW()) RETURNING id `, [ userRow.id, gameDbId, payoutAddress, payoutAmount, multiplier, treasureFound ? 'escape_with_treasure' : 'escape' ]); const payoutId = insertResult.rows[0].id; // Schedule batch processing (5-second debounce collects concurrent wins) this._scheduleBatchPayout(); return { success: true, mode: recordedPaymentMode, payout: { status: 'queued', payoutId, amount: payoutAmount, multiplier, treasure: treasureFound }, score: metrics.score ?? null }; } catch (payoutErr) { const normalizedPayout = normalizeError(payoutErr, 'Failed to create payout record'); console.error('❌ Error creating payout record:', normalizedPayout.message); return { success: true, mode: recordedPaymentMode, payout: null, payoutError: normalizedPayout.safeMessage, score: metrics.score ?? null }; } } return { success: true, mode: recordedPaymentMode, payout: null, reason: 'No payout address', score: metrics.score ?? null }; } // Credits mode: decrement nothing here (already handled start). Optionally could award stats. return { success: true, mode: recordedPaymentMode, payout: null, score: metrics.score ?? null }; } catch (err) { const normalized = normalizeError(err, 'Failed to complete game'); console.error('❌ completeGame error:', normalized.message); return { success: false, error: normalized.safeMessage }; } } // ---- Batch Payout Processing ---- /** * Schedule batch payout processing with a short debounce. * Multiple wins within the debounce window (5s) are batched into one transfer. * Also called on new block events as a safety net. */ _scheduleBatchPayout() { if (this._batchPayoutTimer) clearTimeout(this._batchPayoutTimer); this._batchPayoutTimer = setTimeout(() => { this._processPendingPayouts().catch(err => { console.error('❌ Batch payout processing error:', err.message); }); }, 5000); } /** * Process all pending payouts that haven't been sent yet. * Batches them into a single transfer_split call for efficiency. * Called by debounce timer and on new block events. */ async _processPendingPayouts() { // Prevent concurrent batch processing if (this._isBatchProcessing) return; this._isBatchProcessing = true; try { // Gather pending payouts inside a transaction with row-level locks. // FOR UPDATE SKIP LOCKED prevents the retry service from grabbing the same rows. const pending = await this.db.withTransaction(async (client) => { const result = await client.query(` SELECT id, user_id, game_id, payout_address, amount, multiplier, reason FROM payouts WHERE status = 'pending' AND tx_hash IS NULL ORDER BY created_at ASC FOR UPDATE SKIP LOCKED `); // Mark as 'processing' so retry service won't touch them even after this tx commits if (result.rows.length > 0) { const ids = result.rows.map(r => r.id); await client.query( `UPDATE payouts SET status = 'processing' WHERE id = ANY($1)`, [ids] ); } return result; }); if (pending.rows.length === 0) return; // MONERO OUTPUT LOCKING: a spent output (incl. the change output) is locked for // ~10 blocks. If we don't have enough UNLOCKED balance to cover this batch right // now, DEFER it (revert to pending) instead of attempting a transfer that would // fail — the next batch interval and every new-block tick re-check, so it sends as // soon as outputs unlock. Batching already minimises how many change outputs we // create; this handles the "all outputs currently locked" window. try { const totalNeeded = money.sum(pending.rows.map(p => p.amount)); // BigInt atomic const bal = await this.walletService.getBalance(); // unlocked_balance arrives as a JS number from the RPC; coerce to integer BigInt. const unlocked = (bal && !bal.error) ? BigInt(Math.round(Number(bal.unlocked_balance) || 0)) : null; if (unlocked !== null && unlocked < totalNeeded) { const ids = pending.rows.map(p => p.id); await this.db.query( `UPDATE payouts SET status = 'pending', last_error = $2, last_retry_at = NOW() WHERE id = ANY($1)`, [ids, 'Deferred: insufficient unlocked balance (Monero outputs locked ~10 blocks)'] ).catch(() => {}); console.warn(`⏳ Deferring ${pending.rows.length} payout(s): unlocked ${unlocked} < needed ${totalNeeded}. Outputs likely locked; will retry next block/interval.`); return; } } catch (balErr) { // If the balance check itself fails, fall through and let the transfer try. console.error('Payout balance pre-check failed, proceeding:', balErr.message); } console.log(`📦 Processing batch of ${pending.rows.length} pending payout(s)`); // Build destinations for transfer_split const destinations = pending.rows.map(p => ({ amount: p.amount, address: p.payout_address })); // Generate batch_id to link payouts processed together const batchId = require('uuid').v4(); if (pending.rows.length === 1) { // Single payout — use processPayout for simpler flow const p = pending.rows[0]; try { const result = await this.walletService.processPayout({ userId: p.user_id, gameId: p.game_id, address: p.payout_address, amount: p.amount, multiplier: p.multiplier }); // Store tx_hash + status + user stats atomically in one transaction await this.db.withTransaction(async (client) => { await client.query( `UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3, status = 'completed', processed_at = NOW() WHERE id = $4`, [result.txHash || null, result.fee || null, batchId, p.id] ); await client.query( `UPDATE users SET total_amount_won = total_amount_won + $1, total_payouts_received = COALESCE(total_payouts_received, 0) + 1 WHERE id = $2`, [p.amount, p.user_id] ); }); console.log(`💸 Single payout ${p.id} completed: ${result.txHash}`); } catch (err) { // Insufficient-unlocked-funds is a pre-broadcast error -> retry on the next // batch run (revert to pending). Other failures -> 'failed' (retry service, // which re-checks the chain before re-sending). const status = isInsufficientFundsError(err.message) ? 'pending' : 'failed'; console.error(`❌ Single payout ${p.id} failed -> ${status}:`, err.message); await this.db.query( `UPDATE payouts SET status = $1, last_error = $2, last_retry_at = NOW() WHERE id = $3`, [status, err.message, p.id] ).catch(() => {}); } } else { // Multiple payouts — batch via transfer_split with multiple destinations const ids = pending.rows.map(p => p.id); try { const result = await this.walletService.processBatchPayout(destinations); // transfer_split sends ONE on-chain tx to all destinations; every payout // row in this batch legitimately shares that tx_hash (see migration 015). const txHash = (result.tx_hash_list && result.tx_hash_list[0]) || null; const feePerPayout = result.totalFee ? Math.ceil(result.totalFee / pending.rows.length) : null; // Mark the ENTIRE batch completed in a SINGLE transaction so we never // leave some rows completed and others stranded mid-batch (which the old // per-row loop did when the shared tx_hash hit the unique index). await this.db.withTransaction(async (client) => { await client.query( `UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3, status = 'completed', processed_at = NOW() WHERE id = ANY($4)`, [txHash, feePerPayout, batchId, ids] ); // Per-row stat increments inside the same transaction. Amounts are passed // as DB values so Postgres does the BIGINT arithmetic (no JS float). for (const p of pending.rows) { await client.query( `UPDATE users SET total_amount_won = total_amount_won + $1::bigint, total_payouts_received = COALESCE(total_payouts_received, 0) + 1 WHERE id = $2`, [p.amount, p.user_id] ); } }); console.log(`💸 Batch payout completed: ${pending.rows.length} payouts in tx ${txHash}`); } catch (err) { // An insufficient-unlocked-funds error is raised BEFORE broadcast (no tx // went out), so it's safe to retry: revert to 'pending' and let the next // run send it once outputs unlock — no operator alert needed (transient). // // ANY OTHER error is ambiguous: transfer_split may have broadcast on-chain // even though the RPC response errored. Those rows have no tx_hash, so the // retry service's blockchain guard can't protect them and an auto-retry // could DOUBLE-PAY. Mark 'needs_review' (retry service skips it) + alert. const fundsIssue = isInsufficientFundsError(err.message); const status = fundsIssue ? 'pending' : 'needs_review'; console.error(`❌ Batch payout failed (${pending.rows.length} payouts) -> ${status}:`, err.message); await this.db.query( `UPDATE payouts SET status = $1, last_error = $2, last_retry_at = NOW() WHERE id = ANY($3)`, [status, String(err.message).slice(0, 500), ids] ).catch(() => {}); if (!fundsIssue && this.alertService && typeof this.alertService.sendAlert === 'function') { this.alertService.sendAlert('batch_payout_failed', { subject: '⚠️ Batch payout failed — manual review required', html: `
A batch of ${pending.rows.length} payout(s) failed and was marked needs_review to avoid a possible double-payout.
` + `Payout IDs: ${ids.join(', ')}
Error: ${err.message}
` }).catch(() => {}); } } } } catch (err) { console.error('❌ _processPendingPayouts error:', err.message); } finally { this._isBatchProcessing = false; } } } module.exports = GameModeManager;