Files
wownerogue/src/network/paymentHandlers.js
T

379 lines
20 KiB
JavaScript

/**
* Payment Handlers
* Encapsulates payment request creation, monitoring, mempool detection,
* confirmation handling, and queue integration.
*/
const { normalizeError } = require('../utils/errors');
class PaymentHandlers {
constructor({ io, gameModeManager, walletService, debugManager, queueManager, broadcastManager, sessionManager }) {
this.io = io;
this.gameModeManager = gameModeManager;
this.walletService = walletService;
this.debugManager = debugManager;
this.queueManager = queueManager;
this.broadcastManager = broadcastManager;
this.sessionManager = sessionManager;
this.mempoolNotified = new Set();
this.paymentMonitors = new Map();
// Track pending payment metadata per socket (may reuse existing)
this.socketPaymentMap = new Map(); // socketId -> { address, paymentId, amount, cryptoType, createdAt }
// Track paymentIds already confirmed (avoid duplicate emits / queue actions)
this.confirmedPayments = new Set();
// Periodic cleanup (confirmed payment IDs older than retention window)
this._confirmedTimestamps = new Map();
this._confirmedRetentionMs = 6 * 60 * 60 * 1000; // 6 hours
// Track expiry timeouts so they can be cleared / unref'd to avoid keeping process open
this._expiryTimeouts = new Map(); // socketId -> timeout
// Keep reference so tests can dispose / and unref so it doesn't keep event loop alive
this._cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [pid, ts] of this._confirmedTimestamps.entries()) {
if (now - ts > this._confirmedRetentionMs) {
this._confirmedTimestamps.delete(pid);
this.confirmedPayments.delete(pid);
}
}
}, 30 * 60 * 1000); // sweep every 30 min
if (this._cleanupInterval && this._cleanupInterval.unref) {
// Allow process (including Jest) to exit naturally without waiting for this interval
this._cleanupInterval.unref();
}
}
async handlePaymentRequest(socket, data) {
if (!this.gameModeManager) {
this.io.to(socket.id).emit('payment_error', { error: 'Payment system not available' });
return;
}
try {
const { type, packageId } = data;
const paymentType = type || data.gameMode || 'single_game';
const options = { packageId, reuseExisting: true };
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, options);
const cryptoType = this.gameModeManager.cryptoType;
// Generate QR code
let qrDataUrl = null;
try {
const { generatePaymentQR } = require('../payments/qrService');
qrDataUrl = await generatePaymentQR(
paymentRequest.address,
paymentRequest.amount,
cryptoType,
paymentType === 'credits_package' ? 'Credits package' : 'Single game',
this.gameModeManager.currencyDecimals
);
} catch (qrErr) {
console.warn('QR generation failed:', qrErr.message);
}
this.io.to(socket.id).emit('payment_created', {
paymentId: paymentRequest.id,
address: paymentRequest.address,
amount: paymentRequest.amount,
amountFormatted: paymentRequest.amountFormatted,
humanAmount: paymentRequest.amountFormatted,
currency: cryptoType,
cryptoType: cryptoType,
package: paymentRequest.package,
paymentType: paymentType,
qr: qrDataUrl,
reused: !!paymentRequest.reused
});
// CRITICAL: Start payment monitoring!
// Stop any existing monitoring for this socket first
this.stopMonitoringForSocket(socket.id);
// Get current user for queue management
const currentUser = this.queueManager?.getUserBySocket ?
this.queueManager.getUserBySocket(socket.id) : { serverId: socket.id };
// Start monitoring for payment
this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser, paymentType);
if (this.debugManager.CONSOLE_LOGGING) console.log(`💳 Payment request created for ${socket.id}: ${paymentRequest.amount}`);
} catch (e) {
const err = normalizeError(e, 'Failed to create payment request');
console.error('Error creating payment request:', err.message);
this.io.to(socket.id).emit('payment_error', { error: err.safeMessage });
}
}
async createAndShowPaymentRequest(socket, options = {}) {
if (!this.gameModeManager) return;
// If both direct and credits modes are enabled, let the user choose
const bothModesEnabled = this.gameModeManager.directModeEnabled && this.gameModeManager.creditsModeEnabled;
const forceShowOptions = options.showOptions === true;
if (bothModesEnabled && !options.paymentType) {
// Send event to show payment options modal on client
this.io.to(socket.id).emit('show_payment_options', {
reason: 'choose_payment_method',
message: 'Choose how you want to play'
});
return;
}
try {
// Ensure payout address exists for modes that may payout (PAID_SINGLE or PAID_CREDITS with payouts)
const mode = this.gameModeManager.gameMode;
const needsAddress = (mode === 'PAID_SINGLE') || (mode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled);
if (needsAddress) {
try {
const userRow = await this.gameModeManager.getOrCreateUser(socket.id);
if (!userRow.payout_address) {
this.broadcastManager.sendStatusUpdate(socket.id, 'payment', '💳 Before paying, paste your payout address (XMR/WOW) then type confirm.');
// Do not proceed until address is set
return;
}
} catch (e) {
const err = normalizeError(e, 'Failed to verify payout address');
console.error('Address pre-check failed:', err.message);
}
}
const currentUser = socket.id && this.queueManager.getUserBySocket ? this.queueManager.getUserBySocket(socket.id) : null;
// caller (SocketHandlers) will handle user existence; keep method generic
const gameMode = this.gameModeManager.gameMode;
const cryptoType = this.gameModeManager.cryptoType;
let paymentType = options.paymentType;
let amount;
let description;
// Determine payment type if not specified
if (!paymentType) {
if (gameMode === 'PAID_SINGLE' || this.gameModeManager.directModeEnabled) {
paymentType = 'single_game';
} else if (gameMode === 'PAID_CREDITS') {
paymentType = 'credits_package';
}
}
if (paymentType === 'single_game') {
amount = this.gameModeManager.singleGamePrice;
description = 'Single game entry';
} else if (paymentType === 'credits_package') {
const primaryPackage = this.gameModeManager.getPrimaryCreditPackage();
amount = Number(primaryPackage?.price ?? this.gameModeManager.creditsPackagePrice);
const credits = primaryPackage?.credits ?? this.gameModeManager.creditsPerGameCost * 10;
description = `${credits} credit package`;
} else {
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.');
return;
}
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, {
reuseExisting: true,
packageId: options.packageId
});
const reused = !!paymentRequest.reused;
const formattedAmount = paymentRequest.amountFormatted ?? this.gameModeManager.formatAtomicHuman(amount, 3);
let qrDataUrl = null;
try {
const { generatePaymentQR } = require('../payments/qrService');
qrDataUrl = await generatePaymentQR(
paymentRequest.address,
paymentRequest.amount,
cryptoType,
description,
this.gameModeManager.currencyDecimals
);
} catch (e) {}
this.io.to(socket.id).emit('payment_created', {
paymentId: paymentRequest.id,
address: paymentRequest.address,
amount: paymentRequest.amount,
amountFormatted: formattedAmount,
humanAmount: formattedAmount,
paymentType,
gameMode,
cryptoType,
description,
expiresAt: paymentRequest.expiresAt,
qr: qrDataUrl,
package: paymentRequest.package,
reused
});
const statusHeader = reused
? '🔁 Existing payment request still pending. Use the details below to pay.'
: `💳 PAYMENT REQUIRED (${description})`;
const statusBody = `\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${formattedAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`;
this.broadcastManager.sendStatusUpdate(
socket.id,
'payment',
`${statusHeader}${statusBody}`
);
// If we reused an existing request, ensure we refresh monitoring to avoid duplicate watchers
this.stopMonitoringForSocket(socket.id);
this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser, paymentType);
} catch (e) {
const err = normalizeError(e, 'Failed to create payment request');
console.error('Error creating payment request:', err.message);
this.broadcastManager.sendStatusUpdate(socket.id, 'error', err.safeMessage);
}
}
_monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser, paymentType = 'single_game') {
// Record mapping so we can stop later (replace existing entry)
this.socketPaymentMap.set(socket.id, {
address: paymentRequest.address,
paymentId: paymentRequest.id,
amount: paymentRequest.amount,
cryptoType,
paymentType,
package: paymentRequest.package,
createdAt: Date.now()
});
this.walletService.startPaymentMonitoring(paymentRequest.address, async (status) => {
if (status.in_mempool && !status.confirmed) {
if (this.mempoolNotified.has(paymentRequest.address)) return;
this.mempoolNotified.add(paymentRequest.address);
socket.emit('payment_detected', { paymentId: paymentRequest.id, message: 'Payment detected in mempool! Adding you to the game queue...', amount: status.amount, confirmations: 0 });
const existingIdx = this.queueManager.getPlayerIndex(socket.id);
if (existingIdx === -1) {
// 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: true,
confirmed: false
});
}
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 });
}
} 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: 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 });
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 && this.debugManager.CONSOLE_LOGGING) {
console.log(`[PaymentHandlers] Immediate start skipped (not queued or still unconfirmed) for ${socket.id}`);
}
}
}
}
// Clean up monitoring for this socket (even if duplicate)
this.stopMonitoringForSocket(socket.id);
}
}, 2000);
// Expire request after 30 minutes if not confirmed
const expiryTimeout = setTimeout(() => {
const mapping = this.socketPaymentMap.get(socket.id);
if (mapping && mapping.address === paymentRequest.address) { // still active and not replaced
this.stopMonitoringForSocket(socket.id);
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.');
}
}, 30 * 60 * 1000);
// Store & unref so tests / process can exit
this._expiryTimeouts.set(socket.id, expiryTimeout);
}
/**
* Stop monitoring (if any) for a given socket (disconnect/expiry)
*/
stopMonitoringForSocket(socketId) {
const mapping = this.socketPaymentMap.get(socketId);
if (mapping) {
this.walletService.stopPaymentMonitoring(mapping.address);
this.socketPaymentMap.delete(socketId);
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`🛑 Stopped monitoring for socket ${socketId}`);
}
}
const expiry = this._expiryTimeouts.get(socketId);
if (expiry) {
clearTimeout(expiry);
this._expiryTimeouts.delete(socketId);
}
}
/**
* Dispose resources (intervals, monitors) - useful for tests / graceful shutdown.
*/
dispose() {
if (this._cleanupInterval) {
clearInterval(this._cleanupInterval);
this._cleanupInterval = null;
}
for (const socketId of Array.from(this.socketPaymentMap.keys())) {
this.stopMonitoringForSocket(socketId);
}
for (const [socketId, to] of this._expiryTimeouts.entries()) {
clearTimeout(to);
this._expiryTimeouts.delete(socketId);
}
}
}
module.exports = PaymentHandlers;