diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 98c083e..1e520e7 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -254,15 +254,16 @@ class GameModeManager { /** * 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 + * @returns {object} Result with success, creditsAdded, newBalance, alreadyProcessed */ async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null) { try { const user = await this.getOrCreateUser(socketId); - + // Determine credits to add from package or fallback let creditsToAdd = 10; // Default fallback if (packageInfo && packageInfo.credits) { @@ -270,9 +271,18 @@ class GameModeManager { } else { // Try to get from payment record description const paymentResult = await this.db.query(` - SELECT description FROM payments WHERE id = $1 + SELECT description, status FROM payments WHERE id = $1 `, [paymentId]); if (paymentResult.rows.length > 0) { + // SECURITY: Check if already confirmed (prevents double-crediting) + if (paymentResult.rows[0].status === 'confirmed') { + console.log(`⚠️ Payment ${paymentId} already confirmed, skipping duplicate processing`); + return { + success: false, + alreadyProcessed: true, + reason: 'Payment already processed' + }; + } const desc = paymentResult.rows[0].description || ''; const match = desc.match(/(\d+)\s*credits?/i); if (match) { @@ -281,9 +291,31 @@ class GameModeManager { } } - // Update user credits + // SECURITY: Atomically update payment status from 'pending' to 'confirmed' + // Only proceeds if the payment was actually pending (prevents double-crediting) + const paymentUpdateResult = await this.db.query(` + UPDATE payments + SET status = 'confirmed', + credits_purchased = $1, + confirmed_at = NOW() + WHERE id = $2 AND status = 'pending' + RETURNING id + `, [creditsToAdd, paymentId]); + + // If no rows were updated, payment was already processed + if (paymentUpdateResult.rows.length === 0) { + console.log(`⚠️ Payment ${paymentId} already confirmed (atomic check), skipping duplicate`); + return { + success: false, + alreadyProcessed: true, + reason: 'Payment already processed' + }; + } + + // Payment successfully marked as confirmed - now add credits + // Use atomic increment to prevent race conditions const updateResult = await this.db.query(` - UPDATE users + UPDATE users SET credits = credits + $1, total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1, updated_at = NOW() @@ -293,15 +325,6 @@ class GameModeManager { const newBalance = updateResult.rows[0]?.credits ?? (user.credits + creditsToAdd); - // Mark payment as processed - await this.db.query(` - UPDATE payments - SET status = 'confirmed', - credits_purchased = $1, - confirmed_at = NOW() - WHERE id = $2 - `, [creditsToAdd, paymentId]); - // Record credit transaction await this.db.query(` INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) @@ -1253,8 +1276,26 @@ class GameModeManager { const userResult = await this.db.query(`SELECT * FROM users WHERE socket_id = $1 LIMIT 1`, [socketId]); const userRow = userResult.rows[0]; if (userRow && userRow.payout_address) { + // SECURITY: Use safe payout pattern - create pending record FIRST, then RPC, then store tx_hash + let payoutId = null; try { - await this.walletService.processPayout({ + // 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 + userRow.payout_address, + payoutAmount, + multiplier, + treasureFound ? 'escape_with_treasure' : 'escape' + ]); + payoutId = insertResult.rows[0].id; + + // Step 2: Attempt wallet RPC transfer + const payoutResult = await this.walletService.processPayout({ userId: userRow.id, gameId, address: userRow.payout_address, @@ -1262,19 +1303,62 @@ class GameModeManager { 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]); + } + }); + return { success: true, mode: recordedPaymentMode, payout: { amount: payoutAmount, multiplier, - treasure: treasureFound + treasure: treasureFound, + txHash: payoutResult.txHash }, 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); + } + } return { success: true, mode: recordedPaymentMode, payout: null, payoutError: normalizedPayout.safeMessage, score: metrics.score ?? null }; } } diff --git a/src/index.js b/src/index.js index 65b9f01..50a60c7 100644 --- a/src/index.js +++ b/src/index.js @@ -688,24 +688,45 @@ app.post('/api/admin/refund/payment', adminAuth, asyncHandler(async (req, res) = // If credits were purchased, deduct them let creditsDeducted = 0; if (payment.credits_purchased > 0 && payment.user_id) { + // SECURITY: Use FOR UPDATE to lock the row and prevent race conditions const userResult = await client.query(` - SELECT credits FROM users WHERE id = $1 + SELECT credits FROM users WHERE id = $1 FOR UPDATE `, [payment.user_id]); - + const currentCredits = userResult.rows[0]?.credits || 0; creditsDeducted = Math.min(payment.credits_purchased, currentCredits); - + if (creditsDeducted > 0) { - const newBalance = currentCredits - creditsDeducted; - await client.query(` - UPDATE users SET credits = $1 WHERE id = $2 - `, [newBalance, payment.user_id]); - + // SECURITY: Use atomic decrement with guard to prevent negative balance + const updateResult = await client.query(` + UPDATE users SET credits = credits - $1 WHERE id = $2 AND credits >= $1 + RETURNING credits + `, [creditsDeducted, payment.user_id]); + + let newBalance = updateResult.rows[0]?.credits; + + // If update failed (race condition changed credits), recalculate + if (updateResult.rows.length === 0) { + const recheckResult = await client.query(`SELECT credits FROM users WHERE id = $1 FOR UPDATE`, [payment.user_id]); + const actualCredits = recheckResult.rows[0]?.credits || 0; + creditsDeducted = Math.min(payment.credits_purchased, actualCredits); + if (creditsDeducted > 0) { + const retryResult = await client.query(` + UPDATE users SET credits = credits - $1 WHERE id = $2 AND credits >= $1 RETURNING credits + `, [creditsDeducted, payment.user_id]); + newBalance = retryResult.rows[0]?.credits ?? (actualCredits - creditsDeducted); + } else { + newBalance = actualCredits; + } + } + // Record credit transaction - await client.query(` - INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) - VALUES ($1, $2, $3, $4, 'refund') - `, [payment.user_id, -creditsDeducted, `Refund for payment ${paymentId}`, newBalance]); + if (creditsDeducted > 0) { + await client.query(` + INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) + VALUES ($1, $2, $3, $4, 'refund') + `, [payment.user_id, -creditsDeducted, `Refund for payment ${paymentId}`, newBalance]); + } } } @@ -782,22 +803,54 @@ app.post('/api/admin/credits/adjust', adminAuth, asyncHandler(async (req, res) = } const currentCredits = user.credits || 0; - const newBalance = Math.max(0, currentCredits + amount); // Prevent negative balance - const actualAdjustment = newBalance - currentCredits; - + + // SECURITY: Use atomic UPDATE to prevent race conditions + // For negative adjustments, ensure user has enough credits + let updateResult; + let actualAdjustment; + let newBalance; + + if (amount < 0) { + // Deduction: atomic check that credits >= amount to deduct + const deductAmount = Math.abs(amount); + updateResult = await db.query(` + UPDATE users + SET credits = credits - $1, updated_at = NOW() + WHERE id = $2 AND credits >= $1 + RETURNING credits + `, [deductAmount, user.id]); + + if (updateResult.rows.length === 0) { + return res.json({ + success: false, + message: `Cannot deduct ${deductAmount} credits - user only has ${currentCredits} credits.`, + user: { credits: currentCredits } + }); + } + + newBalance = updateResult.rows[0].credits; + actualAdjustment = newBalance - currentCredits; + } else { + // Addition: always succeeds + updateResult = await db.query(` + UPDATE users + SET credits = credits + $1, updated_at = NOW() + WHERE id = $2 + RETURNING credits + `, [amount, user.id]); + + newBalance = updateResult.rows[0]?.credits ?? (currentCredits + amount); + actualAdjustment = amount; + } + if (actualAdjustment === 0) { return res.json({ success: false, - message: 'No adjustment made (would result in same balance).', + message: 'No adjustment made.', user: { credits: currentCredits } }); } - // Update credits - await db.query(` - UPDATE users SET credits = $1, updated_at = NOW() WHERE id = $2 - `, [newBalance, user.id]); - // Record transaction const transactionType = actualAdjustment > 0 ? 'admin_credit' : 'admin_debit'; await db.query(` diff --git a/src/migrations/010_security_constraints.sql b/src/migrations/010_security_constraints.sql new file mode 100644 index 0000000..d620fc2 --- /dev/null +++ b/src/migrations/010_security_constraints.sql @@ -0,0 +1,39 @@ +-- Migration 010: Security constraints to prevent invalid data states +-- These constraints enforce business rules at the database level as a safety net + +-- Ensure credits can never go negative +-- This catches any code bugs that might bypass application-level checks +ALTER TABLE users ADD CONSTRAINT users_credits_non_negative + CHECK (credits >= 0); + +-- Ensure total_amount_won can never go negative +ALTER TABLE users ADD CONSTRAINT users_total_won_non_negative + CHECK (total_amount_won >= 0); + +-- Ensure total_games_played can never go negative +ALTER TABLE users ADD CONSTRAINT users_games_played_non_negative + CHECK (total_games_played >= 0); + +-- Ensure total_games_won can never go negative +ALTER TABLE users ADD CONSTRAINT users_games_won_non_negative + CHECK (total_games_won >= 0); + +-- Ensure payout amounts are always positive +ALTER TABLE payouts ADD CONSTRAINT payouts_amount_positive + CHECK (amount > 0); + +-- Ensure payment amounts are always positive +ALTER TABLE payments ADD CONSTRAINT payments_amount_positive + CHECK (expected_amount > 0); + +-- Ensure multiplier is non-negative (0 means no payout) +ALTER TABLE payouts ADD CONSTRAINT payouts_multiplier_non_negative + CHECK (multiplier >= 0); + +-- Add index for faster payout retry queries +CREATE INDEX IF NOT EXISTS idx_payouts_retry_candidates ON payouts (status, retry_count, last_retry_at) +WHERE status IN ('pending', 'failed') AND (retry_count IS NULL OR retry_count < 3); + +-- Add index for finding unprocessed payments during recovery +CREATE INDEX IF NOT EXISTS idx_payments_recovery_candidates ON payments (user_id, status, payment_type, credits_purchased) +WHERE status = 'confirmed' AND payment_type = 'credits_package' AND (credits_purchased IS NULL OR credits_purchased = 0); diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index 8bf0247..27dff65 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -257,98 +257,121 @@ class PaymentHandlers { } socket.emit('queue_joined', { position: (existingIdx === -1 ? this.queueManager.getQueueLength() : existingIdx + 1), message: 'Payment received! Waiting for next block to start game...', currentBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null, nextBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() + 1 : null }); } else if (status.confirmed) { - if (!this.confirmedPayments.has(paymentRequest.id)) { - this.confirmedPayments.add(paymentRequest.id); - this._confirmedTimestamps.set(paymentRequest.id, Date.now()); - - // Handle credits_package: add credits to user - const mapping = this.socketPaymentMap.get(socket.id); - if (mapping && mapping.paymentType === 'credits_package' && this.gameModeManager) { - try { - const creditsResult = await this.gameModeManager.processCreditsPackageConfirmation( - socket.id, - paymentRequest.id, - mapping.package - ); - if (creditsResult.success) { - socket.emit('credits_update', { balance: creditsResult.newBalance }); - socket.emit('payment_confirmed', { - paymentId: paymentRequest.id, - message: `Payment confirmed! Added ${creditsResult.creditsAdded} credits. New balance: ${creditsResult.newBalance}`, - creditsAdded: creditsResult.creditsAdded, - newBalance: creditsResult.newBalance, - confirmations: status.confirmations - }); - this.broadcastManager.sendStatusUpdate(socket.id, 'success', - `✅ CREDITS PURCHASED!\n\n+${creditsResult.creditsAdded} credits added.\nNew balance: ${creditsResult.newBalance} credits.\n\nType 'enter' to start a game!`); - } else { - console.error('Failed to process credits package:', creditsResult.reason); - socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed but failed to add credits. Contact support.', confirmations: status.confirmations }); + // SECURITY: Use in-memory Set as fast-path, but DB is source of truth + // This prevents double-processing on server restart + if (this.confirmedPayments.has(paymentRequest.id)) { + // Already processed in this session, skip + this.stopMonitoringForSocket(socket.id); + return; + } + + // Mark in memory to prevent duplicate processing within this session + this.confirmedPayments.add(paymentRequest.id); + this._confirmedTimestamps.set(paymentRequest.id, Date.now()); + + // Handle credits_package: add credits to user + const mapping = this.socketPaymentMap.get(socket.id); + if (mapping && mapping.paymentType === 'credits_package' && this.gameModeManager) { + try { + // processCreditsPackageConfirmation now atomically checks status='pending' + const creditsResult = await this.gameModeManager.processCreditsPackageConfirmation( + socket.id, + paymentRequest.id, + mapping.package + ); + if (creditsResult.success) { + socket.emit('credits_update', { balance: creditsResult.newBalance }); + socket.emit('payment_confirmed', { + paymentId: paymentRequest.id, + message: `Payment confirmed! Added ${creditsResult.creditsAdded} credits. New balance: ${creditsResult.newBalance}`, + creditsAdded: creditsResult.creditsAdded, + newBalance: creditsResult.newBalance, + confirmations: status.confirmations + }); + this.broadcastManager.sendStatusUpdate(socket.id, 'success', + `✅ CREDITS PURCHASED!\n\n+${creditsResult.creditsAdded} credits added.\nNew balance: ${creditsResult.newBalance} credits.\n\nType 'enter' to start a game!`); + } else if (creditsResult.alreadyProcessed) { + // Payment was already confirmed (e.g., after server restart) - just notify user + console.log(`[PaymentHandlers] Payment ${paymentRequest.id} already processed, skipping duplicate`); + socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment already processed.', confirmations: status.confirmations }); + } else { + console.error('Failed to process credits package:', creditsResult.reason); + socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed but failed to add credits. Contact support.', confirmations: status.confirmations }); + } + } catch (e) { + console.error('Error processing credits package confirmation:', e.message); + socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations }); + } + } else { + // single_game payment - standard flow + // CRITICAL: Atomically update payment status and check if we actually updated it + // This prevents double-processing on server restart + let wasUpdated = false; + try { + if (this.gameModeManager && this.gameModeManager.db) { + const updateResult = await this.gameModeManager.db.query(` + UPDATE payments + SET status = 'confirmed', + confirmed_at = NOW() + WHERE id = $1 AND status = 'pending' + RETURNING id + `, [paymentRequest.id]); + wasUpdated = updateResult.rows.length > 0; + if (this.debugManager.CONSOLE_LOGGING) { + console.log(`[PaymentHandlers] Payment ${paymentRequest.id} status update: ${wasUpdated ? 'success' : 'already confirmed'}`); } - } catch (e) { - console.error('Error processing credits package confirmation:', e.message); - socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations }); + } + } catch (dbErr) { + console.error('[PaymentHandlers] Failed to update payment status in DB:', dbErr.message); + } + + // Only proceed with game logic if we actually confirmed this payment + if (!wasUpdated) { + console.log(`[PaymentHandlers] Payment ${paymentRequest.id} already confirmed, skipping duplicate processing`); + socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment already processed.', confirmations: status.confirmations }); + this.stopMonitoringForSocket(socket.id); + return; + } + + socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations }); + + // IMPORTANT: If payment confirmed before mempool detection (fast blocks), + // the player may not be in the queue yet. Add them now with confirmed=true. + const existingIdx = this.queueManager.getPlayerIndex(socket.id); + if (existingIdx === -1) { + console.log(`[PaymentHandlers] Payment confirmed but player not in queue - adding now (socket: ${socket.id})`); + // Try to get DB userId from session + let userId = null; + if (this.sessionManager?.sessions?.has(socket.id)) { + userId = this.sessionManager.sessions.get(socket.id).id; + } + this.queueManager.addPlayer({ + serverId: socket.id, + clientId: currentUser ? currentUser.clientId : null, + userId: userId, + paymentId: paymentRequest.id, + requiresConfirmation: false, // Already confirmed + confirmed: true + }); + } else { + // Player was in queue from mempool detection, just mark confirmed + this.queueManager.markConfirmed(socket.id); + } + + // Attempt immediate game start so user doesn't wait another full block + const currentBlock = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null; + if (currentBlock !== null) { + const started = await this.queueManager.startGameImmediately(socket.id, currentBlock); + if (!started) { + console.log(`[PaymentHandlers] Immediate start failed for ${socket.id} - player will start on next block`); + if (this.debugManager.CONSOLE_LOGGING) { + this.queueManager.debugDumpQueue(); + } + } else { + console.log(`[PaymentHandlers] ✅ Immediate game start successful for ${socket.id}`); } } else { - // single_game payment - standard flow - // CRITICAL: Update payment status in DB BEFORE trying to start the game - // This ensures processGameStart can find the confirmed payment - try { - if (this.gameModeManager && this.gameModeManager.db) { - await this.gameModeManager.db.query(` - UPDATE payments - SET status = 'confirmed', - confirmed_at = NOW() - WHERE id = $1 AND status = 'pending' - `, [paymentRequest.id]); - if (this.debugManager.CONSOLE_LOGGING) { - console.log(`[PaymentHandlers] Updated payment ${paymentRequest.id} status to confirmed in DB`); - } - } - } catch (dbErr) { - console.error('[PaymentHandlers] Failed to update payment status in DB:', dbErr.message); - } - - socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations }); - - // IMPORTANT: If payment confirmed before mempool detection (fast blocks), - // the player may not be in the queue yet. Add them now with confirmed=true. - const existingIdx = this.queueManager.getPlayerIndex(socket.id); - if (existingIdx === -1) { - console.log(`[PaymentHandlers] Payment confirmed but player not in queue - adding now (socket: ${socket.id})`); - // Try to get DB userId from session - let userId = null; - if (this.sessionManager?.sessions?.has(socket.id)) { - userId = this.sessionManager.sessions.get(socket.id).id; - } - this.queueManager.addPlayer({ - serverId: socket.id, - clientId: currentUser ? currentUser.clientId : null, - userId: userId, - paymentId: paymentRequest.id, - requiresConfirmation: false, // Already confirmed - confirmed: true - }); - } else { - // Player was in queue from mempool detection, just mark confirmed - this.queueManager.markConfirmed(socket.id); - } - - // Attempt immediate game start so user doesn't wait another full block - const currentBlock = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null; - if (currentBlock !== null) { - const started = await this.queueManager.startGameImmediately(socket.id, currentBlock); - if (!started) { - console.log(`[PaymentHandlers] Immediate start failed for ${socket.id} - player will start on next block`); - if (this.debugManager.CONSOLE_LOGGING) { - this.queueManager.debugDumpQueue(); - } - } else { - console.log(`[PaymentHandlers] ✅ Immediate game start successful for ${socket.id}`); - } - } else { - console.log(`[PaymentHandlers] No block height available - player will start on next block`); - } + console.log(`[PaymentHandlers] No block height available - player will start on next block`); } } // Clean up monitoring for this socket (even if duplicate) diff --git a/test/payment_flow.integration.test.js b/test/payment_flow.integration.test.js index e2a8805..840850c 100644 --- a/test/payment_flow.integration.test.js +++ b/test/payment_flow.integration.test.js @@ -67,8 +67,15 @@ describe('Payment flow integration: confirm-after-block immediate start', () => // Mock processGameStart for QueueManager.startGameImmediately processGameStart: async () => ({ success: true, effectiveMode: 'PAID_SINGLE' }), // Mock db for PaymentHandlers DB update on confirmation + // The security fix requires UPDATE to return rows to confirm the payment was updated db: { - query: async () => ({ rows: [] }) + query: async (sql) => { + // If this is the UPDATE payments query, return a row to indicate success + if (sql && sql.includes('UPDATE payments') && sql.includes('RETURNING')) { + return { rows: [{ id: 'pay-1' }] }; + } + return { rows: [] }; + } } }; diff --git a/test/payment_payout_comprehensive.test.js b/test/payment_payout_comprehensive.test.js index af122a1..5da3394 100644 --- a/test/payment_payout_comprehensive.test.js +++ b/test/payment_payout_comprehensive.test.js @@ -7,7 +7,14 @@ const GameModeManager = require('../src/game/gameModeManager'); // Mock dependencies const createMockDb = () => ({ - query: jest.fn() + query: jest.fn(), + // Mock withTransaction to execute the callback with a mock client + withTransaction: jest.fn().mockImplementation(async (callback) => { + const mockClient = { + query: jest.fn().mockResolvedValue({ rows: [] }) + }; + return callback(mockClient); + }) }); const createMockWalletService = () => ({ @@ -287,20 +294,26 @@ describe('Payment & Payout Comprehensive Tests', () => { }); // Game record lookup - mockDb.query.mockResolvedValueOnce({ - rows: [{ id: 1, payment_mode: 'direct' }] + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payment_mode: 'direct' }] }); // Game update mockDb.query.mockResolvedValueOnce({ rows: [] }); // Check for existing payout mockDb.query.mockResolvedValueOnce({ rows: [] }); // User lookup - mockDb.query.mockResolvedValueOnce({ - rows: [{ id: 1, payout_address: 'wow1testaddress' }] + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payout_address: 'wow1testaddress' }] }); + // INSERT payout (pending record) - returns new payout id + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 999 }] + }); + // UPDATE payouts (store tx_hash) + mockDb.query.mockResolvedValueOnce({ rows: [] }); const result = await gmm.completeGame('socket1', 'game123', true, false, { moves: 50 }); - + expect(result.success).toBe(true); expect(result.payout).toBeDefined(); expect(result.payout.amount).toBe(200000000000); @@ -320,17 +333,23 @@ describe('Payment & Payout Comprehensive Tests', () => { fee: 1000000 }); - mockDb.query.mockResolvedValueOnce({ - rows: [{ id: 1, payment_mode: 'direct' }] + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payment_mode: 'direct' }] }); mockDb.query.mockResolvedValueOnce({ rows: [] }); mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout - mockDb.query.mockResolvedValueOnce({ - rows: [{ id: 1, payout_address: 'wow1testaddress' }] + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payout_address: 'wow1testaddress' }] }); + // INSERT payout (pending record) + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 999 }] + }); + // UPDATE payouts (store tx_hash) + mockDb.query.mockResolvedValueOnce({ rows: [] }); const result = await gmm.completeGame('socket1', 'game123', true, true, { moves: 30 }); - + expect(result.payout.amount).toBe(300000000000); // 3x for treasure expect(result.payout.multiplier).toBe(3); expect(result.payout.treasure).toBe(true); @@ -395,20 +414,27 @@ describe('Payment & Payout Comprehensive Tests', () => { mockWalletService.processPayout.mockResolvedValueOnce({ success: true, - txHash: 'xyz789' + txHash: 'xyz789', + fee: 1000000 }); - mockDb.query.mockResolvedValueOnce({ + mockDb.query.mockResolvedValueOnce({ rows: [{ id: 1, payment_mode: 'credits' }] // Game was started with credits }); mockDb.query.mockResolvedValueOnce({ rows: [] }); // game update mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payout - mockDb.query.mockResolvedValueOnce({ - rows: [{ id: 1, payout_address: 'wow1testaddress' }] + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1, payout_address: 'wow1testaddress' }] }); + // INSERT payout (pending record) + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 999 }] + }); + // UPDATE payouts (store tx_hash) + mockDb.query.mockResolvedValueOnce({ rows: [] }); const result = await gmm.completeGame('socket1', 'game123', true, false, {}); - + // Should use credits multipliers, not direct expect(result.payout.amount).toBe(75000000000); // 50000000000 * 1.5 expect(result.payout.multiplier).toBe(1.5); diff --git a/test/walletRPCService.test.js b/test/walletRPCService.test.js index 418ff2e..84c984c 100644 --- a/test/walletRPCService.test.js +++ b/test/walletRPCService.test.js @@ -136,18 +136,23 @@ describe('WalletRPCService.processPayout', () => { }); test('validates amount is positive', async () => { - await expect(svc.processPayout({ - address: 'wow1addr', - amount: 0 + // Mock validateAddress to pass so we can test amount validation + svc.validateAddress = jest.fn().mockResolvedValue({ valid: true }); + + await expect(svc.processPayout({ + address: 'wow1addr', + amount: 0 })).rejects.toThrow(/amount/i); - await expect(svc.processPayout({ - address: 'wow1addr', - amount: -100 + await expect(svc.processPayout({ + address: 'wow1addr', + amount: -100 })).rejects.toThrow(/amount/i); }); test('calls transfer RPC with correct parameters', async () => { + // Mock validateAddress to return valid + svc.validateAddress = jest.fn().mockResolvedValue({ valid: true }); svc.rpcCall = jest.fn().mockResolvedValueOnce({ result: { tx_hash: 'abc123def456', @@ -183,6 +188,7 @@ describe('WalletRPCService.processPayout', () => { }); test('includes subaddr_indices when subaddressIndex specified', async () => { + svc.validateAddress = jest.fn().mockResolvedValue({ valid: true }); svc.rpcCall = jest.fn().mockResolvedValueOnce({ result: { tx_hash: 'xyz', tx_key: 'key', fee: 100 } }); @@ -201,6 +207,7 @@ describe('WalletRPCService.processPayout', () => { }); test('does not include subaddr_indices for invalid index values', async () => { + svc.validateAddress = jest.fn().mockResolvedValue({ valid: true }); svc.rpcCall = jest.fn().mockResolvedValueOnce({ result: { tx_hash: 'xyz', tx_key: 'key', fee: 100 } }); @@ -219,6 +226,7 @@ describe('WalletRPCService.processPayout', () => { }); test('handles RPC failure and wraps error', async () => { + svc.validateAddress = jest.fn().mockResolvedValue({ valid: true }); svc.rpcCall = jest.fn().mockRejectedValueOnce(new Error('Insufficient balance')); await expect(svc.processPayout({