From 5776c5835747992e9d49eb9cf508ba8a39a70bdf Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 19 Feb 2026 07:05:15 -0500 Subject: [PATCH] fix credits package bug and improve stale payment logic --- src/game/gameModeManager.js | 29 ++++++++++++++++---- src/index.js | 23 ++++++++++++++++ src/migrations/013_payment_address_index.sql | 5 ++++ src/network/paymentHandlers.js | 21 ++++++++++++-- src/network/socketHandlers.js | 26 ++++++++++++++---- test/payment_payout_comprehensive.test.js | 22 ++++++++------- 6 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 src/migrations/013_payment_address_index.sql diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 79478b9..b0f3c90 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -810,7 +810,14 @@ class GameModeManager { */ async createPaymentRequest(socketId, paymentType, options = {}) { try { - const user = await this.getOrCreateUser(socketId); + // Prefer session-resolved userId (stable across reconnects) over socket_id lookup + let user; + if (options.userId) { + const result = await this.db.query('SELECT * FROM users WHERE id = $1', [options.userId]); + user = result.rows[0] || await this.getOrCreateUser(socketId); + } else { + user = await this.getOrCreateUser(socketId); + } const reuseExisting = options.reuseExisting !== false; const requestedPackageId = options.packageId; @@ -864,6 +871,15 @@ class GameModeManager { } } + // Expire any stale pending payments for this user before creating a new one. + // A user should only have one active pending payment at a time. + const expiredResult = await this.db.query(` + UPDATE payments SET status = 'expired' + WHERE user_id = $1 AND status = 'pending' + RETURNING subaddress + `, [user.id]); + const expiredAddresses = expiredResult.rows.map(r => r.subaddress); + // Create payment request using wallet RPC with correct parameters const paymentResult = await this.walletService.createPaymentRequest( amount, @@ -874,12 +890,12 @@ class GameModeManager { const expiresAt = paymentResult.expiresAt || new Date(Date.now() + 30 * 60 * 1000); - // Store payment info in database + // Store payment info in database (address_index enables monitoring restoration after restart) const insertResult = await this.db.query(` - INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, description, created_at, expires_at) - VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7) + INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, description, created_at, expires_at, address_index) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7, $8) RETURNING id, expires_at - `, [user.id, socketId, paymentResult.address, amount, paymentType, description, expiresAt]); + `, [user.id, socketId, paymentResult.address, amount, paymentType, description, expiresAt, paymentResult.addressIndex ?? null]); const insertedRow = insertResult.rows[0]; @@ -893,7 +909,8 @@ class GameModeManager { package: this._serializePackageInfo(packageInfo), paymentType, description, - reused: false + reused: false, + expiredAddresses }; } catch (error) { diff --git a/src/index.js b/src/index.js index fa36ff4..27bd75c 100644 --- a/src/index.js +++ b/src/index.js @@ -52,6 +52,7 @@ const activeGames = new Map(); // Maps socketId to Game objects let alertService = null; // Initialized later when payment system starts let payoutRetryService = null; // Initialized later when payment system starts let batchPayoutInterval = null; // Initialized later when payment system starts +let paymentExpiryInterval = null; // Initialized later when payment system starts const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, debugManager, gameModeManager, walletRPCService); // Configure static file serving const htmlPath = path.join(__dirname, '../html'); @@ -1835,6 +1836,27 @@ async function startServer() { // Start periodic alert checks (every 5 minutes) alertService.startPeriodicChecks(300000); } + + // Periodic job: expire old pending payments (every 5 minutes) + paymentExpiryInterval = setInterval(async () => { + try { + const result = await databaseManager.query(` + UPDATE payments SET status = 'expired' + WHERE status = 'pending' AND expires_at < NOW() + RETURNING id, subaddress + `); + if (result.rows.length > 0) { + for (const row of result.rows) { + walletRPCService.stopPaymentMonitoring(row.subaddress); + walletRPCService.addressToUser.delete(row.subaddress); + walletRPCService.addressToSocket.delete(row.subaddress); + } + console.log(`[PaymentExpiry] Expired ${result.rows.length} stale payment(s)`); + } + } catch (e) { + console.error('[PaymentExpiry] Error:', e.message); + } + }, 5 * 60 * 1000); } catch (error) { console.error('❌ Failed to start server:', error); @@ -1862,6 +1884,7 @@ async function gracefulShutdown(signal) { // Stop scheduled timers if (batchPayoutInterval) clearInterval(batchPayoutInterval); + if (paymentExpiryInterval) clearInterval(paymentExpiryInterval); if (gameModeManager._batchPayoutTimer) clearTimeout(gameModeManager._batchPayoutTimer); if (payoutRetryService) payoutRetryService.stop(); if (alertService) alertService.stopPeriodicChecks?.(); diff --git a/src/migrations/013_payment_address_index.sql b/src/migrations/013_payment_address_index.sql new file mode 100644 index 0000000..327ffa2 --- /dev/null +++ b/src/migrations/013_payment_address_index.sql @@ -0,0 +1,5 @@ +-- Add address_index column to payments table +-- Stores the wallet subaddress index so payment monitoring can be fully +-- restored from DB after server restart (needed for get_transfers RPC call) + +ALTER TABLE payments ADD COLUMN IF NOT EXISTS address_index INTEGER; diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index 7b861d6..43fd4c4 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -190,11 +190,28 @@ class PaymentHandlers { return; } - const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, { + // Resolve authoritative userId from session manager (stable across socket reconnects) + let sessionUserId = null; + if (this.sessionManager?.sessions?.has(socket.id)) { + sessionUserId = this.sessionManager.sessions.get(socket.id).id; + } + + const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, { reuseExisting: true, - packageId: options.packageId + packageId: options.packageId, + userId: sessionUserId }); const reused = !!paymentRequest.reused; + + // Stop monitoring any expired addresses from previous payment requests + if (paymentRequest.expiredAddresses?.length > 0) { + for (const addr of paymentRequest.expiredAddresses) { + this.walletService.stopPaymentMonitoring(addr); + this.walletService.addressToUser.delete(addr); + this.walletService.addressToSocket.delete(addr); + } + } + const formattedAmount = paymentRequest.amountFormatted ?? this.gameModeManager.formatAtomicHuman(amount, 3); let qrDataUrl = null; try { diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index 212ebd6..e8eaa0f 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -753,10 +753,9 @@ class SocketHandlers { this.paymentHandlers.stopMonitoringForSocket(socketId); } - // Clean up wallet Maps to prevent memory leaks (addressToUser, addressToSocket) - if (this.walletService && typeof this.walletService.cleanupUserPayments === 'function') { - this.walletService.cleanupUserPayments(socketId); - } + // NOTE: Do NOT call walletService.cleanupUserPayments() here. + // addressToUser entries must persist so checkPaymentStatus() can query the wallet + // RPC after session resumption. They get cleaned up when payments are confirmed or expired. // Clean up movement timestamps this.playerMoveTimestamps.delete(socketId); @@ -815,7 +814,7 @@ class SocketHandlers { try { // Query for pending (unpaid) payment requests for this user const pendingResult = await this.gameModeManager.db.query(` - SELECT id, subaddress, expected_amount, payment_type, description, expires_at + SELECT id, subaddress, expected_amount, payment_type, description, expires_at, address_index FROM payments WHERE user_id = $1 AND status = 'pending' @@ -880,10 +879,25 @@ class SocketHandlers { package: null }; + // Re-register address in wallet service so checkPaymentStatus() can query the wallet RPC. + // This is essential after server restart or if the entry was lost during disconnect. + if (this.walletService && payment.subaddress) { + this.walletService.addressToUser.set(payment.subaddress, { + userId: String(dbUserId), + socketId: socket.id, + amount: Number(payment.expected_amount), + addressIndex: payment.address_index ?? 0, + accountIndex: 0, + detected: false, + confirmed: false, + status: 'pending' + }); + } + // Restart payment monitoring // First stop any existing monitoring (unlikely but safe) this.paymentHandlers.stopMonitoringForSocket(socket.id); - + // Restart monitoring via paymentHandlers internal method if (typeof this.paymentHandlers._monitorAddress === 'function') { this.paymentHandlers._monitorAddress( diff --git a/test/payment_payout_comprehensive.test.js b/test/payment_payout_comprehensive.test.js index 75c42c1..50e8952 100644 --- a/test/payment_payout_comprehensive.test.js +++ b/test/payment_payout_comprehensive.test.js @@ -432,18 +432,19 @@ describe('Payment & Payout Comprehensive Tests', () => { test('creates single_game payment request correctly', async () => { const user = { id: 1, credits: 0 }; gmm.singleGamePrice = 100000000000; - + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment - + mockDb.query.mockResolvedValueOnce({ rows: [] }); // expire stale payments + mockWalletService.createPaymentRequest.mockResolvedValueOnce({ address: 'wow1paymentaddr', expiresAt: new Date() }); - - mockDb.query.mockResolvedValueOnce({ - rows: [{ id: 999, expires_at: new Date() }] + + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 999, expires_at: new Date() }] }); // INSERT payment const result = await gmm.createPaymentRequest('socket1', 'single_game'); @@ -462,18 +463,19 @@ describe('Payment & Payout Comprehensive Tests', () => { test('creates credits_package payment request correctly', async () => { const user = { id: 1, credits: 0 }; gmm.creditsPackagePrice = 500000000000; - + mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment - + mockDb.query.mockResolvedValueOnce({ rows: [] }); // expire stale payments + mockWalletService.createPaymentRequest.mockResolvedValueOnce({ address: 'wow1creditspayment', expiresAt: new Date() }); - - mockDb.query.mockResolvedValueOnce({ - rows: [{ id: 1000, expires_at: new Date() }] + + mockDb.query.mockResolvedValueOnce({ + rows: [{ id: 1000, expires_at: new Date() }] }); // INSERT payment const result = await gmm.createPaymentRequest('socket1', 'credits_package');