Fix critical double pay bugs

This commit is contained in:
jwinterm
2026-02-01 16:38:51 -05:00
parent 4999ce2ac5
commit 9a53df4a35
7 changed files with 389 additions and 149 deletions
+100 -16
View File
@@ -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 };
}
}
+74 -21
View File
@@ -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(`
@@ -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);
+112 -89
View File
@@ -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)
+8 -1
View File
@@ -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: [] };
}
}
};
+42 -16
View File
@@ -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);
+14 -6
View File
@@ -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({