diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 3c17797..44f3003 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -340,6 +340,28 @@ WantedBy=multi-user.target --- +## Wallet Output Management + +Wownero (and Monero) lock change outputs for several blocks after spending. If the wallet has only one large output and multiple payouts fire in quick succession, the second payout will fail because the change output from the first is still locked. + +**Solution**: Pre-split wallet outputs into many smaller ones. The game server includes a CLI tool for this: + +```bash +# Preview what would happen (no transaction sent) +node scripts/splitOutputs.js --amount 10 --count 30 --dry-run + +# Split into 30 outputs of 10 WOW each (requires ~300 WOW + fees) +node scripts/splitOutputs.js --amount 10 --count 30 +``` + +This creates 30 independently-spendable outputs, allowing up to 30 concurrent payouts before any output locking becomes an issue. + +**When to re-split**: As payouts go out, outputs get consumed and consolidated via change. Periodically check the wallet and re-split when the number of spendable outputs drops low. The server also batches payouts that happen within 5 seconds of each other into a single transaction to conserve outputs. + +**Recommended setup**: After initial wallet funding, run the split script. For a game with typical traffic, 20-30 outputs of 10 WOW each provides good concurrency headroom. + +--- + ## Monitoring ### Check Service Status diff --git a/docs/TODO.md b/docs/TODO.md index c6bee6e..8a1df53 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -87,6 +87,16 @@ - [x] **Admin UI**: Full dashboard at `/admin.html` with charts and tables - [x] **Payout Retry**: Manual retry button in admin dashboard +## Payout Reliability (Feb 2026) +- [x] **Fire-and-Forget INSERT Fix**: `_insertGameRecord` now awaited, prevents race conditions ✅ +- [x] **Double Game-Over Prevention**: `activeGames.delete()` moved before any `await` in `handleGameOver` ✅ +- [x] **DB-Level Duplicate Payout Prevention**: Unique partial index on `payouts(game_id)` (migration 012) ✅ +- [x] **Payout Address Locking**: Address captured at game start, stored in `games.payout_address` ✅ +- [x] **Dead Code Removal**: Deleted unused `processGameCompletion()` method ✅ +- [x] **Same-Block Payout Batching**: Payouts within 5-second window batched into one `transfer_split` call ✅ +- [x] **Output Splitting Script**: `scripts/splitOutputs.js` for wallet output management ✅ +- [x] **transfer_split**: Switched from `transfer` to `transfer_split` for output resilience ✅ + ## Pre-Production Testing Checklist ### Security Verification @@ -120,6 +130,8 @@ - [x] **Payout Retry Queue**: Automatic retry mechanism via `payoutRetryService.js` ✅ - [x] **Address Validation**: Wallet RPC `validate_address` method ✅ - [ ] **Redis Rate Limiting**: Replace in-memory rate limiter with Redis for persistence across restarts +- [ ] **Full Payout Batching**: Implement configurable batch intervals beyond same-block 5-second window +- [ ] **Wallet Health Monitoring**: Track spendable output count, alert when outputs are low ### Frontend - [ ] **Socket Reconnection**: Add `reconnect` and `disconnect` handlers to restore state after connection loss diff --git a/scripts/splitOutputs.js b/scripts/splitOutputs.js new file mode 100755 index 0000000..f531b7f --- /dev/null +++ b/scripts/splitOutputs.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node +/** + * splitOutputs.js — Split wallet outputs for concurrent payout capacity. + * + * Wownero/Monero lock change outputs for several blocks after spending. + * Pre-splitting one large output into many smaller ones allows the wallet + * to handle multiple concurrent payouts without "not enough unlocked balance". + * + * Usage: + * node scripts/splitOutputs.js [options] + * + * Options: + * --amount Target output size in WOW (default: 10) + * --count Number of outputs to create (default: 20) + * --dry-run Show what would happen without sending + * --endpoint Wallet RPC endpoint (overrides PRIMARY_WALLET_ENDPOINT) + */ + +const path = require('path'); +const axios = require('axios'); + +// Load .env from the src/ directory (same as server) +require('dotenv').config({ path: path.resolve(__dirname, '..', 'src', '.env') }); + +const ATOMIC_DIVISOR = 1e11; // Wownero: 11 decimal places + +function parseArgs() { + const args = process.argv.slice(2); + const opts = { + amount: 10, + count: 20, + dryRun: false, + endpoint: process.env.PRIMARY_WALLET_ENDPOINT || 'http://127.0.0.1:34570' + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--amount': + opts.amount = parseFloat(args[++i]); + if (!opts.amount || opts.amount <= 0) { + console.error('Error: --amount must be a positive number'); + process.exit(1); + } + break; + case '--count': + opts.count = parseInt(args[++i], 10); + if (!opts.count || opts.count <= 0) { + console.error('Error: --count must be a positive integer'); + process.exit(1); + } + break; + case '--dry-run': + opts.dryRun = true; + break; + case '--endpoint': + opts.endpoint = args[++i]; + break; + case '--help': + case '-h': + console.log(` +splitOutputs.js — Split wallet outputs for concurrent payout capacity. + +Usage: node scripts/splitOutputs.js [options] + +Options: + --amount Target output size in WOW (default: 10) + --count Number of outputs to create (default: 20) + --dry-run Show what would happen without sending + --endpoint Wallet RPC endpoint (default: PRIMARY_WALLET_ENDPOINT env) + --help Show this help message + +Example: + node scripts/splitOutputs.js --amount 10 --count 30 + Creates 30 outputs of 10 WOW each (requires 300+ WOW unlocked balance). +`); + process.exit(0); + break; + default: + console.error(`Unknown option: ${args[i]}`); + process.exit(1); + } + } + + return opts; +} + +async function rpcCall(endpoint, method, params = {}) { + const response = await axios.post(`${endpoint}/json_rpc`, { + jsonrpc: '2.0', + id: '0', + method, + params + }); + if (response.data.error) { + throw new Error(`RPC error (${method}): ${response.data.error.message}`); + } + return response.data; +} + +async function main() { + const opts = parseArgs(); + const amountAtomic = Math.round(opts.amount * ATOMIC_DIVISOR); + const totalNeeded = amountAtomic * opts.count; + + console.log('=== Wownero Output Splitter ==='); + console.log(`Endpoint: ${opts.endpoint}`); + console.log(`Output size: ${opts.amount} WOW (${amountAtomic} atomic)`); + console.log(`Count: ${opts.count} outputs`); + console.log(`Total: ${(totalNeeded / ATOMIC_DIVISOR).toFixed(4)} WOW (+ fees)`); + if (opts.dryRun) console.log('Mode: DRY RUN'); + console.log(''); + + // Step 1: Get wallet address + let ownAddress; + try { + const addrResp = await rpcCall(opts.endpoint, 'get_address', { + account_index: 0, + address_index: [0] + }); + ownAddress = addrResp.result.address; + console.log(`Wallet: ${ownAddress.substring(0, 12)}...${ownAddress.substring(ownAddress.length - 6)}`); + } catch (err) { + console.error(`Failed to get wallet address: ${err.message}`); + console.error('Make sure wownero-wallet-rpc is running on', opts.endpoint); + process.exit(1); + } + + // Step 2: Check balance + let balance; + try { + const balResp = await rpcCall(opts.endpoint, 'get_balance', { account_index: 0 }); + balance = balResp.result; + console.log(`Balance: ${(balance.balance / ATOMIC_DIVISOR).toFixed(4)} WOW`); + console.log(`Unlocked: ${(balance.unlocked_balance / ATOMIC_DIVISOR).toFixed(4)} WOW`); + console.log(''); + } catch (err) { + console.error(`Failed to get balance: ${err.message}`); + process.exit(1); + } + + // Rough fee estimate: ~0.01 WOW per output + const estimatedFees = Math.round(0.01 * ATOMIC_DIVISOR * opts.count); + const totalWithFees = totalNeeded + estimatedFees; + + if (balance.unlocked_balance < totalWithFees) { + console.error(`Insufficient unlocked balance!`); + console.error(` Need: ~${(totalWithFees / ATOMIC_DIVISOR).toFixed(4)} WOW (including estimated fees)`); + console.error(` Have: ${(balance.unlocked_balance / ATOMIC_DIVISOR).toFixed(4)} WOW unlocked`); + process.exit(1); + } + + // Step 3: Build destinations (all to own address) + const destinations = []; + for (let i = 0; i < opts.count; i++) { + destinations.push({ + amount: amountAtomic, + address: ownAddress + }); + } + + console.log(`Splitting into ${opts.count} outputs of ${opts.amount} WOW each...`); + + if (opts.dryRun) { + console.log(''); + console.log('DRY RUN — no transaction sent.'); + console.log(`Would create ${opts.count} outputs of ${amountAtomic} atomic units each.`); + console.log(`Estimated total (with fees): ~${(totalWithFees / ATOMIC_DIVISOR).toFixed(4)} WOW`); + console.log('Remaining balance would be a change output.'); + process.exit(0); + } + + // Step 4: Send transfer_split to self + try { + const txResp = await rpcCall(opts.endpoint, 'transfer_split', { + destinations, + account_index: 0, + priority: 1, + get_tx_key: true + }); + + const result = txResp.result; + const totalFee = (result.fee_list || []).reduce((a, b) => a + b, 0); + + console.log(''); + console.log('=== Split Complete ==='); + console.log(`Transactions: ${result.tx_hash_list.length}`); + for (const txHash of result.tx_hash_list) { + console.log(` tx: ${txHash}`); + } + console.log(`Total fee: ${(totalFee / ATOMIC_DIVISOR).toFixed(6)} WOW`); + console.log(`Outputs created: ${opts.count}`); + console.log(''); + console.log('Outputs will be spendable after 4 block confirmations (~8 minutes).'); + } catch (err) { + console.error(`Failed to split outputs: ${err.message}`); + process.exit(1); + } +} + +main().catch(err => { + console.error('Unexpected error:', err.message); + process.exit(1); +}); diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index f86d8c0..7f44827 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -1136,89 +1136,42 @@ class GameModeManager { const userRow = userResult.rows[0]; const payoutAddress = lockedPayoutAddress || (userRow && userRow.payout_address); if (userRow && payoutAddress) { - // SECURITY: Use safe payout pattern - create pending record FIRST, then RPC, then store tx_hash - let payoutId = null; + // 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 { - // Step 1: Create pending payout record FIRST (ensures we have a record even if RPC succeeds but server crashes) 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, // Use DB id if available + gameDbId, payoutAddress, payoutAmount, multiplier, treasureFound ? 'escape_with_treasure' : 'escape' ]); - payoutId = insertResult.rows[0].id; + const payoutId = insertResult.rows[0].id; - // Step 2: Attempt wallet RPC transfer (using locked address) - const payoutResult = await this.walletService.processPayout({ - userId: userRow.id, - gameId, - address: payoutAddress, - amount: payoutAmount, - multiplier, - description: `Game ${gameId} payout` - }); - - // Step 3: IMMEDIATELY store tx_hash if RPC succeeded (BEFORE any transaction) - // This ensures we never lose the tx_hash even if subsequent DB operations fail - if (payoutResult.success && payoutResult.txHash) { - await this.db.query(` - UPDATE payouts SET tx_hash = $1, fee = $2 WHERE id = $3 - `, [payoutResult.txHash, payoutResult.fee || null, payoutId]); - } - - // Step 4: Update status and user stats atomically - await this.db.withTransaction(async (client) => { - await client.query(` - UPDATE payouts - SET status = $1, processed_at = NOW() - WHERE id = $2 - `, [payoutResult.success ? 'completed' : 'failed', payoutId]); - - if (payoutResult.success) { - 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 - `, [payoutAmount, userRow.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, - txHash: payoutResult.txHash + treasure: treasureFound }, score: metrics.score ?? null }; } catch (payoutErr) { - const normalizedPayout = normalizeError(payoutErr, 'Failed to send payout'); - console.error('❌ Error creating payout:', normalizedPayout.message); - // Update payout record with failure if it exists - if (payoutId) { - try { - await this.db.query(` - UPDATE payouts - SET status = 'failed', - error_message = $1, - last_error = $1, - last_retry_at = NOW() - WHERE id = $2 - `, [payoutErr.message, payoutId]); - } catch (dbErr) { - console.error('Failed to update payout error:', dbErr.message); - } - } + 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 }; } } @@ -1233,6 +1186,140 @@ class GameModeManager { 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 all pending payouts with no tx_hash (not yet attempted) + const pending = await this.db.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 + `); + + if (pending.rows.length === 0) return; + + 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 + }); + + if (result.success && result.txHash) { + await this.db.query( + `UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3 WHERE id = $4`, + [result.txHash, result.fee || null, batchId, p.id] + ); + } + + await this.db.withTransaction(async (client) => { + await client.query( + `UPDATE payouts SET status = 'completed', processed_at = NOW() WHERE id = $1`, + [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) { + console.error(`❌ Single payout ${p.id} failed:`, err.message); + await this.db.query( + `UPDATE payouts SET status = 'failed', last_error = $1, last_retry_at = NOW() WHERE id = $2`, + [err.message, p.id] + ).catch(() => {}); + } + } else { + // Multiple payouts — batch via transfer_split with multiple destinations + try { + const result = await this.walletService.processBatchPayout(destinations); + + // All destinations in one transfer_split typically produce 1 tx + const txHash = result.tx_hash_list[0]; + const feePerPayout = result.totalFee + ? Math.ceil(result.totalFee / pending.rows.length) + : null; + + for (const p of pending.rows) { + // Store tx_hash immediately for each payout + await this.db.query( + `UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3 WHERE id = $4`, + [txHash, feePerPayout, batchId, p.id] + ); + + // Update status and user stats atomically + await this.db.withTransaction(async (client) => { + await client.query( + `UPDATE payouts SET status = 'completed', processed_at = NOW() WHERE id = $1`, + [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(`💸 Batch payout completed: ${pending.rows.length} payouts in tx ${txHash}`); + } catch (err) { + console.error(`❌ Batch payout failed (${pending.rows.length} payouts):`, err.message); + // Mark all as failed — retry service will pick them up individually + for (const p of pending.rows) { + await this.db.query( + `UPDATE payouts SET status = 'failed', last_error = $1, last_retry_at = NOW() WHERE id = $2`, + [err.message, p.id] + ).catch(() => {}); + } + } + } + } catch (err) { + console.error('❌ _processPendingPayouts error:', err.message); + } finally { + this._isBatchProcessing = false; + } + } } module.exports = GameModeManager; diff --git a/src/index.js b/src/index.js index 19d4fce..310d068 100644 --- a/src/index.js +++ b/src/index.js @@ -1686,6 +1686,10 @@ debugManager.onNewBlockCallback((blockHeight) => { socketHandlers.startGamesForWaiting(blockHeight); // Check active games for timeout socketHandlers.checkGamesTimeout(blockHeight); + // Flush any pending payouts (safety net for batch timer) + gameModeManager._processPendingPayouts().catch(err => { + console.error('❌ Block-triggered payout processing error:', err.message); + }); }); // Initialize payment system diff --git a/src/payments/walletRPCService.js b/src/payments/walletRPCService.js index c3e5845..091dc93 100644 --- a/src/payments/walletRPCService.js +++ b/src/payments/walletRPCService.js @@ -272,14 +272,68 @@ class WalletRPCService { } /** - * Placeholder for future batch payout aggregation. - * Currently returns early to avoid runtime errors where it's scheduled. + * Send a batch payout to multiple destinations in a single transfer_split call. + * More efficient than individual transfers — uses fewer outputs and pays less in fees. + * @param {Array<{amount: number|bigint, address: string}>} destinations + * @returns {Object} - { tx_hash_list, tx_key_list, fee_list, totalFee } */ - async processBatchPayouts() { - if (this.debugManager?.CONSOLE_LOGGING) { - console.log('⚙️ processBatchPayouts stub invoked (no payouts processed).'); + async processBatchPayout(destinations) { + if (!destinations || destinations.length === 0) { + throw new AppError('No destinations provided for batch payout', { + safeMessage: 'Batch payout requires at least one destination.' + }); } - return { processed: 0 }; + + // Validate all addresses before attempting transfer + for (const dest of destinations) { + if (!dest.address || typeof dest.address !== 'string') { + throw new AppError('Invalid address in batch payout', { + safeMessage: 'One or more payout addresses are invalid.' + }); + } + const validation = await this.validateAddress(dest.address); + if (!validation.valid) { + throw new AppError(`Invalid payout address in batch: ${dest.address.substring(0, 10)}...`, { + safeMessage: 'One or more payout addresses failed validation.' + }); + } + } + + const transferParams = { + destinations: destinations.map(d => ({ + amount: this.normalizeAtomicAmount(d.amount), + address: d.address + })), + account_index: this.accountIndex, + priority: 1, + get_tx_key: true + }; + + if (this.debugManager?.CONSOLE_LOGGING) { + console.log(`🚀 Initiating batch payout (${destinations.length} destinations)`, { + totalAmount: destinations.reduce((sum, d) => sum + Number(d.amount), 0), + destinations: destinations.map(d => d.address.substring(0, 10) + '...') + }); + } + + const response = await this.rpcCall('transfer_split', transferParams); + + const result = { + 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) + }; + + if (this.debugManager?.CONSOLE_LOGGING) { + console.log(`💸 Batch payout sent (${result.tx_hash_list.length} tx)`, { + txHashes: result.tx_hash_list, + totalFee: result.totalFee, + destinations: destinations.length + }); + } + + return result; } normalizeAtomicAmount(value) { @@ -342,6 +396,22 @@ class WalletRPCService { } } + /** + * Get the wallet's own primary address (account_index 0, address_index 0). + * Used by output-splitting script and internal operations. + * @returns {Object} - { address: string, address_index: number } + */ + async getOwnAddress() { + const response = await this.rpcCall('get_address', { + account_index: this.accountIndex, + address_index: [0] + }); + return { + address: response.result.address, + address_index: 0 + }; + } + /** * Validate a payout address using wallet RPC * @param {string} address - The address to validate @@ -420,22 +490,30 @@ class WalletRPCService { }); } - const response = await this.rpcCall('transfer', transferParams); + // Use transfer_split for better output handling — automatically splits + // across multiple outputs if needed, more resilient than plain transfer + const response = await this.rpcCall('transfer_split', transferParams); + + // transfer_split returns arrays: tx_hash_list, tx_key_list, fee_list + const txHash = response.result.tx_hash_list?.[0]; + const txKey = response.result.tx_key_list?.[0]; + const fee = response.result.fee_list?.reduce((a, b) => a + b, 0) || 0; if (this.debugManager?.CONSOLE_LOGGING) { console.log('💸 Payout sent successfully', { to: address.substring(0, 10) + '...', amount: normalizedAmount, - txHash: response.result.tx_hash, - fee: response.result.fee + txHash, + fee, + txCount: response.result.tx_hash_list?.length || 1 }); } return { success: true, - txHash: response.result.tx_hash, - txKey: response.result.tx_key, - fee: response.result.fee, + txHash, + txKey, + fee, userId, gameId, amount: normalizedAmount