Files
wownerogue/src/network/paymentHandlers.js
T
jwinterm 3e6e3b858b security: fix underpayment exploit and make provably-fair real
Phase 0.1 — Underpayment confirmation bug (CRITICAL):
Payment confirmation gated on `status.confirmed`, which checkPaymentStatus
sets for ANY incoming tx regardless of amount, so sending a single atomic
unit granted a full game/credits. Now gate on `status.complete` (received
>= expected). Underpayers are warned once and the payment stays pending so
a top-up can complete it. Persist `received_amount` for single-game and
credits confirmations for reconciliation. Adds an underpayment regression
test.

Phase 0.2 — Provably-fair was decorative (CRITICAL):
The committed seed had zero effect on the dungeon (created seededRNG was
unused; generation used Math.random + a globally-seeded ROT.RNG). Thread a
per-game seeded RNG derived from the committed seed through dungeon
generation (reseed the Digger via seedToInt + replace all Math.random) and
monster movement (seededShuffle). Add DungeonGenerator.regenerateFromSeed
and layoutFingerprint; /api/verify now regenerates the dungeon from the
seed and returns a fingerprint a player can independently reproduce. Adds a
determinism test suite.

Full suite: 11 suites / 113 tests passing.

Plan and progress tracked in docs/REMEDIATION_PLAN.md.
2026-05-31 09:14:21 -04:00

532 lines
28 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();
// Track underpaid payments we've already warned about (notify once, keep monitoring for top-up)
this.underpaidNotified = 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;
}
// Check if house balance is critically low before proceeding with payment
if (this.gameModeManager.alertService) {
const balanceCheck = await this.gameModeManager.alertService.checkBalanceForGameStart();
if (balanceCheck.halted) {
this.io.to(socket.id).emit('balance_critical', {
reason: 'low_balance',
message: balanceCheck.reason || 'Sorry, the house balance is too low to initiate new games. Please try again later.'
});
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;
// Check if house balance is critically low before proceeding with payment
if (this.gameModeManager.alertService) {
const balanceCheck = await this.gameModeManager.alertService.checkBalanceForGameStart();
if (balanceCheck.halted) {
this.io.to(socket.id).emit('balance_critical', {
reason: 'low_balance',
message: balanceCheck.reason || 'Sorry, the house balance is too low to initiate new games. Please try again later.'
});
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;
}
// 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,
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 {
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);
// Check payment type — credits_package should NOT queue for a game
const mapping = this.socketPaymentMap.get(socket.id);
const isCredits = mapping && mapping.paymentType === 'credits_package';
const detectMessage = isCredits
? 'Payment detected in mempool! Awaiting block confirmation to add credits...'
: 'Payment detected in mempool! Adding you to the game queue...';
socket.emit('payment_detected', { paymentId: paymentRequest.id, message: detectMessage, amount: status.amount, confirmations: 0 });
// Only queue for single_game payments (credits just add balance, no game queue)
if (!isCredits) {
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) {
// SECURITY (Phase 0.1): A confirmed transaction is NOT sufficient to grant a
// game/credits — we must verify the RECEIVED amount covers what was expected.
// checkPaymentStatus() sets `confirmed` for ANY incoming tx on the subaddress,
// while `complete` means totalReceived >= required. Without this gate a player
// could send a single atomic unit and play (or buy credits) for free.
if (!status.complete) {
// Underpaid. Keep monitoring so a later top-up to the same address can
// complete it; warn the user once to avoid spamming on every 2s poll.
if (!this.underpaidNotified.has(paymentRequest.id)) {
this.underpaidNotified.add(paymentRequest.id);
const required = status.required || 0;
const received = status.amount || 0;
const shortfall = Math.max(0, required - received);
console.warn(`[PaymentHandlers] Underpaid payment ${paymentRequest.id}: received ${received}, required ${required} (shortfall ${shortfall})`);
try {
socket.emit('payment_underpaid', {
paymentId: paymentRequest.id,
received,
required,
shortfall
});
} catch (_) {}
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
`⚠️ UNDERPAYMENT DETECTED\n\nReceived less than the required amount. Send the remaining balance to the SAME address to complete your payment, or wait for it to expire and try again.`);
}
return;
}
// 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,
Math.round(status.amount || 0)
);
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 });
}
// Defensively remove from queue — credits purchases should never be queued
this.queueManager.removePlayer(socket.id);
} 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(),
received_amount = $2
WHERE id = $1 AND status = 'pending'
RETURNING id
`, [paymentRequest.id, Math.round(status.amount || 0)]);
wasUpdated = updateResult.rows.length > 0;
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`[PaymentHandlers] Payment ${paymentRequest.id} status update: ${wasUpdated ? 'success' : 'already confirmed'}`);
}
}
} 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 {
console.log(`[PaymentHandlers] No block height available - player will start on next block`);
}
}
// 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);
}
/**
* Check if a socket has active payment monitoring
* @param {string} socketId - The socket ID to check
* @returns {boolean} True if monitoring is active
*/
hasActiveMonitoring(socketId) {
return this.socketPaymentMap.has(socketId);
}
/**
* 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 (mapping.paymentId != null) {
this.underpaidNotified.delete(mapping.paymentId);
}
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;