mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-26 17:48:32 +01:00
Fix critical double pay bugs
This commit is contained in:
+100
-16
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user