diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index 31f3811..bf48926 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -32,6 +32,8 @@ const { } = require('../game/soloRestartSnapshot'); const { normalizeError } = require('../utils/errors'); const money = require('../money/atomic'); +const ProductGrants = require('../payments/productGrants'); +const { isWowPaymentsPaused } = require('../config/operatedProductProfiles'); const { buildCommerceDisclosure, validatePaidAcknowledgement @@ -775,10 +777,6 @@ class SocketHandlers { 'Welcome back! Your game has been restored.'); } - // Restore payment monitoring if it was active - if (suspendedState.paymentMonitoringActive && this.paymentHandlers) { - this._restorePaymentMonitoring(socket, dbUserId); - } } } } @@ -798,8 +796,6 @@ class SocketHandlers { nextBlock: (this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : 0) + 1 }); - // Check for pending payment and restore monitoring - this._restorePaymentMonitoring(socket, dbUserId); } } } @@ -808,6 +804,11 @@ class SocketHandlers { if (this.gameModeManager) { socket.emit('game_mode_info', this.gameModeManager.getGameModeInfo()); } + // A pending invoice belongs to the authenticated session, whether or not its owner + // also has a suspended game or queue entry. Deliver capabilities before the invoice. + if (sessionInfo?.resumed && dbUserId) { + this._restorePaymentMonitoring(socket, dbUserId); + } this._emitIdentity(socket).catch(err => { if (this.debugManager?.CONSOLE_LOGGING) console.warn('Failed to send identity snapshot:', err.message); }); @@ -1747,14 +1748,20 @@ class SocketHandlers { * @param {number} dbUserId - The DB user ID */ async _restorePaymentMonitoring(socket, dbUserId) { - if (!dbUserId || !this.paymentHandlers || !this.gameModeManager) { + const manager = this.gameModeManager; + if (!dbUserId || !this.paymentHandlers || !manager?.db + || manager.paymentsEnabled !== true || isWowPaymentsPaused(process.env) + || !this._isCurrentPaymentRestoreOwner(socket, dbUserId)) { return; } + const monitorBeforeRestore = this.paymentHandlers.socketPaymentMap?.get(socket.id) || null; 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, address_index, + const pendingResult = await manager.db.query(` + SELECT id, user_id, subaddress, expected_amount, payment_type, description, + expires_at, address_index, product_id, product_grants, + provider_id, provider_invoice_id, fairness_proof_version, fairness_offer_id, fairness_offer_issued_at, fairness_commitment, fairness_server_seed, fairness_client_seed, fairness_bound_at, fairness_consumed_at @@ -1766,6 +1773,8 @@ class SocketHandlers { LIMIT 1 `, [dbUserId]); + if (!this._isCurrentPaymentRestoreOwner(socket, dbUserId)) return; + if (pendingResult.rows.length === 0) { // No pending payment to restore if (this.debugManager.CONSOLE_LOGGING) { @@ -1775,6 +1784,21 @@ class SocketHandlers { } const payment = pendingResult.rows[0]; + const paymentType = payment.payment_type; + if (String(payment.user_id) !== String(dbUserId) + || !['single_game', 'credits_package', 'cosmetic_pack'].includes(paymentType) + || typeof manager.isPaymentIntakeEnabled !== 'function' + || !manager.isPaymentIntakeEnabled(paymentType)) return; + + const cryptoType = manager.cryptoType; + const provider = manager.paymentProviders?.getProvider?.(cryptoType) || null; + const providerId = provider?.id || 'native-monero'; + if ((payment.provider_id && payment.provider_id !== providerId) + || (providerId !== 'native-monero' && !payment.provider_invoice_id)) return; + + // This socket may already be watching the same invoice after another restore path. + if (String(this.paymentHandlers.socketPaymentMap?.get(socket.id)?.paymentId) + === String(payment.id)) return; const fairnessProof = payment.payment_type === 'single_game' && typeof this.gameModeManager._paymentFairnessProofFromRow === 'function' ? this.gameModeManager._paymentFairnessProofFromRow(payment) @@ -1788,9 +1812,8 @@ class SocketHandlers { 'This paid entry has no durable fairness binding and requires support review.'); return; } - const cryptoType = this.gameModeManager.cryptoType; - const formattedAmount = this.gameModeManager.formatAtomicHuman - ? this.gameModeManager.formatAtomicHuman(payment.expected_amount, 3) + const formattedAmount = manager.formatAtomicHuman + ? manager.formatAtomicHuman(payment.expected_amount, 3) : payment.expected_amount; // Generate QR code @@ -1802,24 +1825,68 @@ class SocketHandlers { payment.expected_amount, cryptoType, payment.description || 'Restored payment', - this.gameModeManager.currencyDecimals + manager.currencyDecimals ); } catch (qrErr) { console.warn('QR generation failed during restore:', qrErr.message); } + if (!this._isCurrentPaymentRestoreOwner(socket, dbUserId) + || manager.paymentsEnabled !== true || isWowPaymentsPaused(process.env) + || !manager.isPaymentIntakeEnabled(paymentType)) return; + // Another request or concurrent restore may have installed a watcher while the DB + // and QR work awaited. Never replace it with the older snapshot read above. + const currentMonitor = this.paymentHandlers.socketPaymentMap?.get(socket.id) || null; + if (currentMonitor !== monitorBeforeRestore + || (currentMonitor && String(currentMonitor.paymentId) === String(payment.id))) return; + + // Stop an older watcher before binding this invoice. A stop can remove the old + // address watcher, so binding first would risk stopping the newly restored one. + this.paymentHandlers.stopMonitoringForSocket(socket.id); + + const currentUser = this.connectionHandler.getUserBySocket(socket.id) || { serverId: socket.id }; + const paymentRequest = { + id: payment.id, + address: payment.subaddress, + invoiceId: payment.provider_invoice_id || payment.subaddress, + amount: payment.expected_amount, + amountFormatted: formattedAmount, + package: null, + fairnessProof + }; + + if (this.walletService && payment.subaddress && providerId === 'native-monero') { + this.walletService.addressToUser.set(payment.subaddress, { + userId: String(dbUserId), + socketId: socket.id, + amount: money.toSafe(money.toBig(payment.expected_amount)), + addressIndex: payment.address_index ?? 0, + accountIndex: 0, + detected: false, + confirmed: false, + status: 'pending' + }); + } + + if (typeof this.paymentHandlers._monitorAddress !== 'function') return; + this.paymentHandlers._monitorAddress(socket, paymentRequest, payment.expected_amount, + cryptoType, currentUser, paymentType, fairnessProof); + // Re-emit payment_created to the client socket.emit('payment_created', { paymentId: payment.id, ownerUserId: dbUserId, asset: String(cryptoType).toLowerCase(), - network: this.gameModeManager.network, + network: manager.network, memo: payment.description, address: payment.subaddress, amount: payment.expected_amount, amountFormatted: formattedAmount, - humanAmount: money.format(payment.expected_amount, this.gameModeManager.currencyDecimals), + humanAmount: money.format(payment.expected_amount, manager.currencyDecimals), paymentType: payment.payment_type, + productId: payment.product_id, + grants: payment.product_grants + ? ProductGrants.publicGrantSummary(payment.product_grants) : null, cryptoType: cryptoType, description: payment.description, expiresAt: payment.expires_at, @@ -1834,49 +1901,6 @@ class SocketHandlers { } : null }); - // Get current user object for queue operations - const currentUser = this.connectionHandler.getUserBySocket(socket.id) || { serverId: socket.id }; - - // Create a mock paymentRequest object for _monitorAddress - const paymentRequest = { - id: payment.id, - address: payment.subaddress, - amount: payment.expected_amount, - amountFormatted: formattedAmount, - package: null, - fairnessProof - }; - - // 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: money.toSafe(money.toBig(payment.expected_amount)), - addressIndex: payment.address_index ?? 0, - accountIndex: 0, - detected: false, - confirmed: false, - status: 'pending' - }); - } - - // Clear any monitoring already attached to this socket before restarting it. - this.paymentHandlers.stopMonitoringForSocket(socket.id); - - if (typeof this.paymentHandlers._monitorAddress === 'function') { - this.paymentHandlers._monitorAddress( - socket, - paymentRequest, - payment.expected_amount, - cryptoType, - currentUser, - payment.payment_type, - fairnessProof - ); - } - // Send status update this.broadcastManager.sendStatusUpdate( socket.id, @@ -1892,6 +1916,11 @@ class SocketHandlers { } } + _isCurrentPaymentRestoreOwner(socket, dbUserId) { + return this._isShuttingDown !== true && socket?.connected === true + && String(this.sessionManager?.sessions?.get(socket.id)?.id) === String(dbUserId); + } + // Helper methods /** diff --git a/test/smirkInvoiceContract.test.js b/test/smirkInvoiceContract.test.js index ac3df5a..79892da 100644 --- a/test/smirkInvoiceContract.test.js +++ b/test/smirkInvoiceContract.test.js @@ -21,6 +21,7 @@ test.each(['request', 'show', 'restore'])('the %s invoice path supplies an exact const manager = Object.create(GameModeManager.prototype); Object.assign(manager, { cryptoType: 'WOW', network: 'mainnet', currencyDecimals: 11, + paymentsEnabled: true, isPaymentIntakeEnabled: () => true, formatAtomicHuman: () => '1.2346', gameMode: 'PAID_CREDITS', creditsPackagePrice: row.expected_amount, @@ -40,12 +41,15 @@ test.each(['request', 'show', 'restore'])('the %s invoice path supplies an exact handlers._allowInvoiceFlowForReserve = async () => true; handlers._ensureRequiredPayoutAddress = async () => ({ ok: true }); handlers._monitorAddress = jest.fn(); - const socket = { id: 'fixture-socket', emit: (event, data) => emitted.push([event, data]) }; + const socket = { id: 'fixture-socket', connected: true, + emit: (event, data) => emitted.push([event, data]) }; try { if (path === 'request') await handlers.handlePaymentRequest(socket, { type: 'credits_package' }); else if (path === 'show') await handlers.createAndShowPaymentRequest(socket, { paymentType: 'credits_package' }); else await SocketHandlers.prototype._restorePaymentMonitoring.call({ gameModeManager: manager, paymentHandlers: handlers, debugManager: { CONSOLE_LOGGING: false }, + sessionManager: { sessions: new Map([[socket.id, { id: 7 }]]) }, + _isCurrentPaymentRestoreOwner: SocketHandlers.prototype._isCurrentPaymentRestoreOwner, connectionHandler: { getUserBySocket: () => ({ serverId: socket.id }) }, broadcastManager: { sendStatusUpdate: jest.fn() } }, socket, 7); diff --git a/test/socketPaymentRestore.test.js b/test/socketPaymentRestore.test.js new file mode 100644 index 0000000..9c8b881 --- /dev/null +++ b/test/socketPaymentRestore.test.js @@ -0,0 +1,209 @@ +'use strict'; + +jest.mock('../src/payments/qrService', () => ({ + generatePaymentQR: jest.fn(async () => 'data:image/png;base64,fixture') +})); + +const SocketHandlers = require('../src/network/socketHandlers'); +const GameModeManager = require('../src/game/gameModeManager'); +const { generatePaymentQR } = require('../src/payments/qrService'); + +const pending = { + id: 31, user_id: 7, subaddress: 'WW3UyJFX6GPKdM21APyVYjGhfX3ebyHvPPgtFC3wHxkYTCGg8k2UM9DLTCzcgoaLHSKnvJKRtCPgJ1sxvgYiFCoK19vLk83Jn', + expected_amount: '123456789012', payment_type: 'credits_package', + description: '10 credit package', product_id: 'ten-credits', + product_grants: { credits: 10 }, provider_id: 'native-monero', + expires_at: new Date(Date.now() + 3600000).toISOString(), address_index: 5 +}; + +function fixture({ resumed = true, row = pending } = {}) { + const events = []; + const socket = { + id: 'socket-7', connected: true, on: jest.fn(), + emit: jest.fn((event, payload) => events.push([event, payload])) + }; + const sessions = new Map([[socket.id, { id: 7 }]]); + const monitored = new Map(); + const manager = Object.create(GameModeManager.prototype); + Object.assign(manager, { + paymentsEnabled: true, creditsModeEnabled: true, directModeEnabled: false, + cryptoType: 'WOW', network: 'mainnet', currencyDecimals: 11, + formatAtomicHuman: () => '1.235', + db: { query: jest.fn(async () => ({ rows: row ? [row] : [] })) }, + getGameModeInfo: jest.fn(() => ({ + paymentsEnabled: manager.isPaymentIntakeEnabled('credits_package') + })) + }); + const handlers = Object.create(SocketHandlers.prototype); + Object.assign(handlers, { + _isShuttingDown: false, + connectionHandler: { + handleConnection: jest.fn(async () => ({ + sessionInfo: { resumed, user: { id: 7 } } + })), + getUserBySocket: () => ({ serverId: socket.id }) + }, + sessionManager: { sessions }, + suspendedGameManager: { hasSuspendedGame: () => false }, + queueManager: { updateSocketId: () => false }, + gameModeManager: manager, + paymentHandlers: { + socketPaymentMap: monitored, + stopMonitoringForSocket: jest.fn(id => monitored.delete(id)), + _monitorAddress: jest.fn((sock, request) => monitored.set(sock.id, { + paymentId: request.id, address: request.address + })) + }, + walletService: { addressToUser: new Map() }, + broadcastManager: { sendStatusUpdate: jest.fn() }, + debugManager: { CONSOLE_LOGGING: false }, + _emitFairnessOffer: jest.fn(), + _emitIdentity: jest.fn(async () => null) + }); + return { handlers, socket, sessions, monitored, manager, events }; +} + +describe('pending invoice restoration on authenticated session resume', () => { + const previous = { + OPERATED_PRODUCT_PROFILE: process.env.OPERATED_PRODUCT_PROFILE, + PAYMENTS_ENABLED: process.env.PAYMENTS_ENABLED + }; + beforeEach(() => { + generatePaymentQR.mockReset(); + generatePaymentQR.mockResolvedValue('data:image/png;base64,fixture'); + delete process.env.OPERATED_PRODUCT_PROFILE; + process.env.PAYMENTS_ENABLED = 'true'; + }); + afterAll(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + test('resumes a standalone invoice after capabilities without a game or queue', async () => { + const { handlers, socket, manager, events } = fixture(); + await handlers.handleConnection(socket); + await new Promise(resolve => setImmediate(resolve)); + + const modes = events.findIndex(([event]) => event === 'game_mode_info'); + const invoice = events.findIndex(([event]) => event === 'payment_created'); + expect(modes).toBeGreaterThanOrEqual(0); + expect(invoice).toBeGreaterThan(modes); + expect(events[invoice][1]).toMatchObject({ + paymentId: pending.id, ownerUserId: pending.user_id, + amount: pending.expected_amount, productId: pending.product_id, + memo: pending.description, restored: true + }); + expect(manager.db.query).toHaveBeenCalledTimes(1); + expect(manager.db.query.mock.calls[0][1]).toEqual([7]); + expect(handlers.paymentHandlers._monitorAddress).toHaveBeenCalledTimes(1); + }); + + test('does not restore to a newly created or different owner session', async () => { + const fresh = fixture({ resumed: false }); + await fresh.handlers.handleConnection(fresh.socket); + expect(fresh.manager.db.query).not.toHaveBeenCalled(); + + const wrongOwner = fixture({ row: { ...pending, user_id: 8 } }); + await wrongOwner.handlers._restorePaymentMonitoring(wrongOwner.socket, 7); + expect(wrongOwner.events.some(([event]) => event === 'payment_created')).toBe(false); + expect(wrongOwner.handlers.paymentHandlers._monitorAddress).not.toHaveBeenCalled(); + }); + + test('drops an in-flight restore when the session is adopted elsewhere', async () => { + const { handlers, socket, sessions, manager, events } = fixture(); + let finishQuery; + manager.db.query = jest.fn(() => new Promise(resolve => { finishQuery = resolve; })); + const restore = handlers._restorePaymentMonitoring(socket, 7); + sessions.delete(socket.id); + socket.connected = false; + finishQuery({ rows: [pending] }); + await restore; + expect(events.some(([event]) => event === 'payment_created')).toBe(false); + expect(handlers.paymentHandlers._monitorAddress).not.toHaveBeenCalled(); + }); + + test('does not deliver or watch after adoption during QR creation', async () => { + const { handlers, socket, sessions, events } = fixture(); + let finishQr; + generatePaymentQR.mockImplementationOnce(() => new Promise(resolve => { finishQr = resolve; })); + const restore = handlers._restorePaymentMonitoring(socket, 7); + await new Promise(resolve => setImmediate(resolve)); + expect(generatePaymentQR).toHaveBeenCalledTimes(1); + sessions.delete(socket.id); + finishQr('data:image/png;base64,fixture'); + await restore; + expect(events.some(([event]) => event === 'payment_created')).toBe(false); + expect(handlers.paymentHandlers._monitorAddress).not.toHaveBeenCalled(); + }); + + test('does not replace a newer watcher installed while QR creation awaited', async () => { + const { handlers, socket, monitored, events } = fixture(); + let finishQr; + generatePaymentQR.mockImplementationOnce(() => new Promise(resolve => { finishQr = resolve; })); + const restore = handlers._restorePaymentMonitoring(socket, 7); + await new Promise(resolve => setImmediate(resolve)); + expect(generatePaymentQR).toHaveBeenCalledTimes(1); + const newer = { paymentId: 32, address: 'newer-address' }; + monitored.set(socket.id, newer); + finishQr('data:image/png;base64,fixture'); + await restore; + expect(monitored.get(socket.id)).toBe(newer); + expect(handlers.paymentHandlers.stopMonitoringForSocket).not.toHaveBeenCalled(); + expect(handlers.walletService.addressToUser.size).toBe(0); + expect(events.some(([event]) => event === 'payment_created')).toBe(false); + }); + + test('keeps one watcher and one invoice event for the same pending payment', async () => { + const { handlers, socket, events } = fixture(); + await handlers._restorePaymentMonitoring(socket, 7); + await handlers._restorePaymentMonitoring(socket, 7); + expect(handlers.paymentHandlers._monitorAddress).toHaveBeenCalledTimes(1); + expect(events.filter(([event]) => event === 'payment_created')).toHaveLength(1); + }); + + test('stops a prior socket watcher before binding and monitoring the pending invoice', async () => { + const { handlers, socket, monitored } = fixture(); + monitored.set(socket.id, { paymentId: 2, address: 'old-address' }); + const bind = jest.spyOn(handlers.walletService.addressToUser, 'set'); + await handlers._restorePaymentMonitoring(socket, 7); + const stopped = handlers.paymentHandlers.stopMonitoringForSocket.mock.invocationCallOrder[0]; + const rebound = bind.mock.invocationCallOrder[0]; + const restarted = handlers.paymentHandlers._monitorAddress.mock.invocationCallOrder[0]; + expect(stopped).toBeLessThan(rebound); + expect(rebound).toBeLessThan(restarted); + expect(monitored.get(socket.id).paymentId).toBe(pending.id); + }); + + test('free and official paused profiles do not query or advertise an invoice', async () => { + const free = fixture(); + free.manager.paymentsEnabled = false; + await free.handlers.handleConnection(free.socket); + expect(free.manager.db.query).not.toHaveBeenCalled(); + expect(free.events.find(([event]) => event === 'game_mode_info')[1].paymentsEnabled).toBe(false); + expect(free.events.some(([event]) => event === 'payment_created')).toBe(false); + + process.env.OPERATED_PRODUCT_PROFILE = 'such-play-wow-prestige'; + process.env.PAYMENTS_ENABLED = 'false'; + const paused = fixture(); + await paused.handlers.handleConnection(paused.socket); + expect(paused.manager.db.query).not.toHaveBeenCalled(); + expect(paused.events.find(([event]) => event === 'game_mode_info')[1].paymentsEnabled).toBe(false); + expect(paused.events.some(([event]) => event === 'payment_created')).toBe(false); + }); + + test('uses persisted routed invoice identity and refuses a missing gateway reference', async () => { + const gatewayRow = { ...pending, provider_id: 'btcpay', provider_invoice_id: 'invoice-31' }; + const routed = fixture({ row: gatewayRow }); + routed.manager.paymentProviders = { getProvider: () => ({ id: 'btcpay' }) }; + await routed.handlers._restorePaymentMonitoring(routed.socket, 7); + const request = routed.handlers.paymentHandlers._monitorAddress.mock.calls[0][1]; + expect(request.invoiceId).toBe(gatewayRow.provider_invoice_id); + + const missing = fixture({ row: { ...gatewayRow, provider_invoice_id: null } }); + missing.manager.paymentProviders = routed.manager.paymentProviders; + await missing.handlers._restorePaymentMonitoring(missing.socket, 7); + expect(missing.handlers.paymentHandlers._monitorAddress).not.toHaveBeenCalled(); + }); +});