const express = require('express'); const path = require('path'); const crypto = require('crypto'); var app = express(); var http = require('http').Server(app); var io = require('socket.io')(http); // Load environment configuration require('dotenv').config(); // Import payment system components const DatabaseManager = require('./db/databaseManager'); const WalletRPCService = require('./payments/walletRPCService'); const PayoutRetryService = require('./payments/payoutRetryService'); const AlertService = require('./services/alertService'); const GameModeManager = require('./game/gameModeManager'); const RpcService = require('./rpc/rpcService'); const PaymentConfigManager = require('./config/paymentConfig'); const EnvironmentValidator = require('./config/environmentValidator'); const asyncHandler = require('./middleware/asyncHandler'); const createErrorMiddleware = require('./middleware/errorHandler'); const { AppError, ValidationError, NotFoundError } = require('./utils/errors'); // Import modular components const BroadcastManager = require('./network/broadcastManager'); const DebugManager = require('./debug/debugManager'); const SocketHandlers = require('./network/socketHandlers'); const { verifyGame, hashSeed } = require('./game/provablyFair'); // Initialize payment configuration const paymentConfigManager = new PaymentConfigManager({ logger: console }); const environmentValidator = new EnvironmentValidator({ logger: console }); environmentValidator.validate(paymentConfigManager.getConfig()); process.env.GAME_MODE = paymentConfigManager.getLegacyGameMode(); // Initialize modular components first const broadcastManager = new BroadcastManager(io); const debugManager = new DebugManager(broadcastManager); broadcastManager.setDebugManager(debugManager); // Initialize payment system components (debugManager is now available) const databaseManager = new DatabaseManager(); const db = databaseManager; // Alias for convenience in API endpoints const rpcService = new RpcService(); const walletRPCService = new WalletRPCService(debugManager); const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager); // Provide io reference so GameModeManager can emit events (e.g., credits_update) gameModeManager.io = io; // Initialize remaining components 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); // Security headers (defense in depth alongside output escaping). // CSP locks down where scripts/styles/connections may come from. Inline scripts/styles // are still permitted ('unsafe-inline') because the current pages rely on them heavily; // removing that is tracked as Phase 4.4 (nonce-based CSP). connect-src 'self' covers // same-origin Socket.IO websockets under CSP Level 3. app.use((req, res, next) => { res.setHeader('Content-Security-Policy', [ "default-src 'self'", "script-src 'self' 'unsafe-inline' https://cdn.socket.io https://cdn.jsdelivr.net", "style-src 'self' 'unsafe-inline'", "img-src 'self' data:", "font-src 'self'", "connect-src 'self' https://smirk.cash", "object-src 'none'", "base-uri 'self'", "frame-ancestors 'self'", "form-action 'self'" ].join('; ')); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('Referrer-Policy', 'no-referrer'); next(); }); // Configure static file serving const htmlPath = path.join(__dirname, '../html'); app.use(express.static(htmlPath, { etag: true, lastModified: true, setHeaders: (res, filePath) => { if (filePath.endsWith('.html') || filePath.endsWith('.js')) { res.setHeader('Cache-Control', 'no-cache'); } } })); app.use(express.json()); // Parse JSON for API endpoints // Serve main page app.get('/', function(req, res) { res.sendFile('index.html', { root: htmlPath }); }); // Serve admin panel app.get('/admin', function(req, res) { res.sendFile('admin.html', { root: htmlPath }); }); // Debug endpoint to receive client-side debug info app.post('/debug', (req, res) => { if (debugManager.CONSOLE_LOGGING) { console.log('๐Ÿ“ CLIENT DEBUG INFO:', req.body); } res.json({ received: true, timestamp: Date.now() }); }); // Payment system API endpoints app.post('/api/payment/create', asyncHandler(async (req, res) => { const { userId, gameMode } = req.body || {}; if (!userId || !gameMode) { throw new ValidationError('Missing userId or gameMode', { safeMessage: 'userId and gameMode are required to create a payment.' }); } throw new AppError('REST payment creation endpoint is not implemented for unified payments', { statusCode: 501, code: 'NOT_IMPLEMENTED', safeMessage: 'Payment creation via REST API is not available. Please use the in-game flow.' }); })); const restNotImplemented = () => new AppError('Endpoint not available in unified payment system', { statusCode: 501, code: 'NOT_IMPLEMENTED', safeMessage: 'This API endpoint is not available. Please use the supported in-game flow.' }); app.post('/api/payment/callback', asyncHandler(async (req, res) => { throw restNotImplemented(); })); app.get('/api/payment/status/:paymentId', asyncHandler(async (req, res) => { throw restNotImplemented(); })); app.get('/api/user/:socketId/credits', asyncHandler(async (req, res) => { const { socketId } = req.params; if (!socketId) { throw new ValidationError('Missing socketId', { safeMessage: 'socketId parameter is required.' }); } try { const user = await gameModeManager.getOrCreateUser(socketId); res.json({ socketId, credits: user.credits || 0, totalCreditsPurchased: user.total_credits_purchased || 0 }); } catch (error) { throw new AppError('Failed to retrieve credits', { statusCode: 500, safeMessage: 'Unable to retrieve credit balance.', cause: error }); } })); app.get('/api/user/:socketId/mode', asyncHandler(async (req, res) => { const { socketId } = req.params; if (!socketId) { throw new ValidationError('Missing socketId', { safeMessage: 'socketId parameter is required.' }); } try { const user = await gameModeManager.getOrCreateUser(socketId); res.json({ socketId, preferredPaymentMode: user.preferred_payment_mode || 'direct', hasPayoutAddress: !!user.payout_address, paymentsEnabled: gameModeManager.paymentsEnabled, directModeEnabled: gameModeManager.directModeEnabled, creditsModeEnabled: gameModeManager.creditsModeEnabled }); } catch (error) { throw new AppError('Failed to retrieve user mode', { statusCode: 500, safeMessage: 'Unable to retrieve user mode.', cause: error }); } })); app.post('/api/user/:socketId/address', asyncHandler(async (req, res) => { const { socketId } = req.params; const { address } = req.body || {}; if (!socketId) { throw new ValidationError('Missing socketId', { safeMessage: 'socketId parameter is required.' }); } if (!address || typeof address !== 'string') { throw new ValidationError('Missing or invalid address', { safeMessage: 'A valid payout address is required.' }); } // Basic address validation (XMR/WOW addresses) const ADDRESS_REGEX = /((?:4|8)[1-9A-HJ-NP-Za-km-z]{90,110}|(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})/; if (!ADDRESS_REGEX.test(address.trim())) { throw new ValidationError('Invalid address format', { safeMessage: 'The provided address does not appear to be a valid XMR/WOW address.' }); } try { const success = await gameModeManager.setUserPayoutAddress(socketId, address.trim()); if (!success) { throw new AppError('Failed to save address', { statusCode: 500, safeMessage: 'Unable to save payout address.' }); } res.json({ success: true, message: 'Payout address saved successfully.' }); } catch (error) { if (error instanceof AppError || error instanceof ValidationError) { throw error; } throw new AppError('Failed to save address', { statusCode: 500, safeMessage: 'Unable to save payout address.', cause: error }); } })); // Health check endpoint - enhanced for monitoring dashboard app.get('/health', async (req, res) => { const memUsage = process.memoryUsage(); // Get wallet balance - always attempt let walletBalance = null; let lowBalanceWarning = false; const lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR default try { walletBalance = await walletRPCService.getBalance(); if (walletBalance.error) { console.warn('Wallet balance returned error:', walletBalance.error); } else { lowBalanceWarning = walletBalance.unlocked_balance < lowBalanceThreshold; } } catch (e) { console.error('Failed to get wallet balance for health check:', e.message); walletBalance = { balance: 0, unlocked_balance: 0, error: e.message }; } const health = { status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime(), nodeVersion: process.version, environment: process.env.NODE_ENV || 'development', // Memory details memory: { heapUsed: memUsage.heapUsed, heapTotal: memUsage.heapTotal, rss: memUsage.rss, external: memUsage.external || 0 }, // Game statistics games: { active: activeGames.size, queued: socketHandlers?.queueManager?.getQueueLength?.() || 0, connected: io.sockets.sockets.size || 0, mode: gameModeManager.gameMode }, // Blockchain blockHeight: debugManager.getCurrentBlockHeight(), blockSource: process.env.BLOCK_SOURCE || 'daemon', network: process.env.MONERO_NETWORK || 'mainnet', // Wallet status wallet: { available: gameModeManager.paymentsEnabled, status: walletRPCService.isHealthy ? 'connected' : 'disconnected', endpoint: process.env.PRIMARY_WALLET_ENDPOINT ? '...' + process.env.PRIMARY_WALLET_ENDPOINT.slice(-15) : null, pendingPayouts: 0, balance: walletBalance, lowBalanceWarning }, // Rate limiter stats (if available) rateLimiter: socketHandlers?.rateLimiter ? { trackedEntries: socketHandlers.rateLimiter.getTrackedCount?.() || 0, blockedCount: socketHandlers.rateLimiter.getBlockedCount?.() || 0 } : null, // Limits limits: { maxGamesPerHour: parseInt(process.env.MAX_GAMES_PER_HOUR) || 60, maxPayoutsPerDay: parseInt(process.env.MAX_PAYOUTS_PER_DAY) || 100 }, // Legacy fields for compatibility gameMode: gameModeManager.gameMode, paymentsEnabled: gameModeManager.paymentsEnabled, directModeEnabled: gameModeManager.directModeEnabled, creditsModeEnabled: gameModeManager.creditsModeEnabled, walletHealthy: walletRPCService.isHealthy, activeGames: activeGames.size, debugMode: debugManager.getDebugStatus().debugMode }; res.json(health); }); // Get payment options for a user (mixed mode support) app.get('/api/user/:socketId/payment-options', asyncHandler(async (req, res) => { const { socketId } = req.params; if (!socketId) { throw new ValidationError('Missing socketId', { safeMessage: 'socketId parameter is required.' }); } try { const options = await gameModeManager.getPaymentOptionsForUser(socketId); res.json(options); } catch (error) { throw new AppError('Failed to retrieve payment options', { statusCode: 500, safeMessage: 'Unable to retrieve payment options.', cause: error }); } })); // Get payment history for a user app.get('/api/user/:socketId/payments', asyncHandler(async (req, res) => { const { socketId } = req.params; const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100); const offset = parseInt(req.query.offset, 10) || 0; if (!socketId) { throw new ValidationError('Missing socketId', { safeMessage: 'socketId parameter is required.' }); } try { const user = await gameModeManager.getOrCreateUser(socketId); const result = await db.query(` SELECT id, payment_type, expected_amount, status, credits_purchased, created_at, confirmed_at, description FROM payments WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 `, [user.id, limit, offset]); const countResult = await db.query( `SELECT COUNT(*) as total, COALESCE(SUM(CASE WHEN status = 'confirmed' THEN expected_amount ELSE 0 END), 0) as total_paid FROM payments WHERE user_id = $1`, [user.id] ); const totalPaid = parseInt(countResult.rows[0].total_paid, 10) || 0; res.json({ payments: result.rows.map(row => ({ id: row.id, type: row.payment_type, amount: row.expected_amount, amountFormatted: gameModeManager.formatAtomicHuman(row.expected_amount, 4), status: row.status, creditsReceived: row.credits_purchased || 0, createdAt: row.created_at, confirmedAt: row.confirmed_at, description: row.description })), total: parseInt(countResult.rows[0].total, 10), totalPaid: totalPaid, totalPaidFormatted: gameModeManager.formatAtomicHuman(totalPaid, 4), limit, offset, currency: gameModeManager.cryptoType }); } catch (error) { throw new AppError('Failed to retrieve payment history', { statusCode: 500, safeMessage: 'Unable to retrieve payment history.', cause: error }); } })); // Get payout history for a user app.get('/api/user/:socketId/payouts', asyncHandler(async (req, res) => { const { socketId } = req.params; const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100); const offset = parseInt(req.query.offset, 10) || 0; if (!socketId) { throw new ValidationError('Missing socketId', { safeMessage: 'socketId parameter is required.' }); } try { const user = await gameModeManager.getOrCreateUser(socketId); const result = await db.query(` SELECT p.id, p.amount, p.multiplier, p.reason, p.status, p.tx_hash, p.created_at, p.processed_at, g.outcome as game_outcome FROM payouts p LEFT JOIN games g ON p.game_id = g.id WHERE p.user_id = $1 ORDER BY p.created_at DESC LIMIT $2 OFFSET $3 `, [user.id, limit, offset]); const countResult = await db.query( `SELECT COUNT(*) as total FROM payouts WHERE user_id = $1`, [user.id] ); // Calculate total received (confirmed payouts) const totalResult = await db.query( `SELECT COALESCE(SUM(amount), 0) as total_received FROM payouts WHERE user_id = $1 AND status = 'completed'`, [user.id] ); res.json({ payouts: result.rows.map(row => ({ id: row.id, amount: row.amount, amountFormatted: gameModeManager.formatAtomicHuman(row.amount, 4), multiplier: parseFloat(row.multiplier) || 0, reason: row.reason, status: row.status, txHash: row.tx_hash, createdAt: row.created_at, processedAt: row.processed_at, gameOutcome: row.game_outcome })), total: parseInt(countResult.rows[0].total, 10), totalReceived: parseInt(totalResult.rows[0].total_received, 10), totalReceivedFormatted: gameModeManager.formatAtomicHuman(totalResult.rows[0].total_received, 4), limit, offset, currency: gameModeManager.cryptoType }); } catch (error) { throw new AppError('Failed to retrieve payout history', { statusCode: 500, safeMessage: 'Unable to retrieve payout history.', cause: error }); } })); // ============================================================================= // Smirk Wallet Authentication Endpoints // ============================================================================= // Smirk is a browser extension wallet for Wownero/Monero. // Disable for Monero stagenet deployments (Smirk doesn't support stagenet). const smirkEnabled = process.env.SMIRK_ENABLED !== 'false'; if (smirkEnabled) { /** * POST /api/auth/smirk/challenge * Generate a challenge for Smirk wallet signature verification * Body: { socketId: string } */ app.post('/api/auth/smirk/challenge', asyncHandler(async (req, res) => { const { socketId } = req.body || {}; if (!socketId || typeof socketId !== 'string') { throw new ValidationError('Missing socketId', { safeMessage: 'socketId is required to generate a challenge.' }); } // Generate a cryptographically secure challenge const challenge = crypto.randomBytes(32).toString('hex'); // Store challenge in database (expires in 5 minutes) await db.query(` INSERT INTO smirk_challenges (challenge, socket_id) VALUES ($1, $2) `, [challenge, socketId]); // Clean up old/expired challenges periodically await db.query(` DELETE FROM smirk_challenges WHERE expires_at < NOW() OR (used = TRUE AND created_at < NOW() - INTERVAL '1 hour') `); res.json({ challenge, expiresIn: 300 // 5 minutes in seconds }); })); /** * POST /api/auth/smirk/verify * Verify a Smirk wallet signature and link to user session * Body: { socketId: string, challenge: string, publicKey: string, signature: string } */ app.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => { const { socketId, challenge, publicKey, signature } = req.body || {}; if (!socketId || !challenge || !publicKey || !signature) { throw new ValidationError('Missing required fields', { safeMessage: 'socketId, challenge, publicKey, and signature are required.' }); } // 1. Verify challenge exists, is not used, and has not expired const challengeResult = await db.query(` SELECT * FROM smirk_challenges WHERE challenge = $1 AND socket_id = $2 AND used = FALSE AND expires_at > NOW() `, [challenge, socketId]); if (challengeResult.rows.length === 0) { throw new ValidationError('Invalid or expired challenge', { safeMessage: 'The challenge is invalid, expired, or has already been used.' }); } // 2. Mark challenge as used immediately (prevent replay attacks) await db.query(` UPDATE smirk_challenges SET used = TRUE WHERE id = $1 `, [challengeResult.rows[0].id]); // 3. Verify signature using tweetnacl // Accepts both formats during transition: // - RFC 8032 standard: signature over raw UTF-8 message bytes (new extension) // - Legacy: signature over SHA256(message) (old extension, remove after 2026-07-01) let isValidSignature = false; try { const nacl = require('tweetnacl'); const signatureBytes = Buffer.from(signature, 'hex'); const publicKeyBytes = Buffer.from(publicKey, 'hex'); // Try RFC 8032 standard first (raw message bytes) const challengeBytes = Buffer.from(challenge, 'utf8'); isValidSignature = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes); // Fall back to legacy SHA256 pre-hash (DEPRECATED โ€” remove after 2026-07-01) if (!isValidSignature) { const challengeHash = crypto.createHash('sha256').update(challenge).digest(); isValidSignature = nacl.sign.detached.verify(challengeHash, signatureBytes, publicKeyBytes); } } catch (verifyError) { console.error('Signature verification error:', verifyError.message); throw new ValidationError('Signature verification failed', { safeMessage: 'Unable to verify the wallet signature.' }); } if (!isValidSignature) { throw new ValidationError('Invalid signature', { safeMessage: 'The wallet signature is invalid.' }); } // 4. Link public key to user session const userResult = await db.query(` SELECT id FROM users WHERE socket_id = $1 `, [socketId]); if (userResult.rows.length === 0) { throw new NotFoundError('User session not found', { safeMessage: 'No active session found. Please connect to the game first.' }); } const userId = userResult.rows[0].id; // Check if this public key is already linked to another user const existingLink = await db.query(` SELECT id FROM users WHERE smirk_public_key = $1 AND id != $2 `, [publicKey, userId]); if (existingLink.rows.length > 0) { throw new ValidationError('Wallet already linked', { safeMessage: 'This wallet is already linked to another account.' }); } // Link the wallet to this user await db.query(` UPDATE users SET smirk_public_key = $1 WHERE id = $2 `, [publicKey, userId]); res.json({ success: true, linked: true, message: 'Smirk wallet linked successfully' }); })); /** * GET /api/auth/smirk/status * Check if a session has a linked Smirk wallet * Query: socketId=string */ app.get('/api/auth/smirk/status', asyncHandler(async (req, res) => { const { socketId } = req.query; if (!socketId) { throw new ValidationError('Missing socketId', { safeMessage: 'socketId query parameter is required.' }); } const result = await db.query(` SELECT smirk_public_key, payout_address FROM users WHERE socket_id = $1 `, [socketId]); if (result.rows.length === 0) { res.json({ linked: false, hasPayoutAddress: false }); return; } const user = result.rows[0]; res.json({ linked: !!user.smirk_public_key, hasPayoutAddress: !!user.payout_address }); })); } // End of smirkEnabled block // ============================================================================= // Admin API Endpoints (requires ADMIN_API_KEY) // ============================================================================= /** * Admin authentication middleware * Requires X-Admin-Key header to match ADMIN_API_KEY env variable */ const adminAuth = (req, res, next) => { const adminKey = process.env.ADMIN_API_KEY; if (!adminKey) { return res.status(503).json({ error: 'Admin API not configured', message: 'Set ADMIN_API_KEY environment variable to enable admin endpoints.' }); } // Origin check โ€” reject cross-origin requests to admin endpoints // Same-origin requests (Origin matches Host) are always allowed const origin = req.headers.origin || req.headers.referer; if (origin) { try { const requestOrigin = new URL(origin).origin; const host = req.headers.host; const protocol = req.secure || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'; const selfOrigin = `${protocol}://${host}`; // Allow same-origin requests (admin.html served from same server) if (requestOrigin !== selfOrigin) { // Check explicit allowlist for cross-origin requests const allowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()) : [`http://localhost:${process.env.PORT || 3000}`, `http://127.0.0.1:${process.env.PORT || 3000}`]; if (!allowedOrigins.includes(requestOrigin)) { return res.status(403).json({ error: 'Forbidden', message: 'Origin not allowed.' }); } } } catch (e) { return res.status(403).json({ error: 'Forbidden', message: 'Invalid origin.' }); } } const providedKey = req.headers['x-admin-key']; if (!providedKey || providedKey.length !== adminKey.length || !crypto.timingSafeEqual(Buffer.from(providedKey), Buffer.from(adminKey))) { return res.status(401).json({ error: 'Unauthorized', message: 'Invalid or missing X-Admin-Key header.' }); } next(); }; /** * POST /api/admin/refund/payment * Refund a payment - marks it as refunded and optionally sends funds back * Body: { paymentId: number, reason?: string, sendFunds?: boolean } */ app.post('/api/admin/refund/payment', adminAuth, asyncHandler(async (req, res) => { const { paymentId, reason, sendFunds } = req.body || {}; if (!paymentId || typeof paymentId !== 'number') { throw new ValidationError('Invalid paymentId', { safeMessage: 'paymentId (number) is required.' }); } // Get payment record const paymentResult = await db.query(` SELECT p.*, u.payout_address, u.socket_id FROM payments p LEFT JOIN users u ON p.user_id = u.id WHERE p.id = $1 `, [paymentId]); if (paymentResult.rows.length === 0) { throw new NotFoundError('Payment not found', { safeMessage: `Payment ${paymentId} not found.` }); } const payment = paymentResult.rows[0]; if (payment.status === 'refunded') { return res.json({ success: false, message: 'Payment already refunded.', payment: { id: payment.id, status: payment.status } }); } // Begin transaction const client = await db.getClient(); try { await client.query('BEGIN'); // Mark payment as refunded await client.query(` UPDATE payments SET status = 'refunded', description = COALESCE(description, '') || ' | Refunded: ' || $2 WHERE id = $1 `, [paymentId, reason || 'Admin refund']); // 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 FOR UPDATE `, [payment.user_id]); const currentCredits = userResult.rows[0]?.credits || 0; creditsDeducted = Math.min(payment.credits_purchased, currentCredits); if (creditsDeducted > 0) { // 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 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]); } } } await client.query('COMMIT'); // Optionally send funds back via wallet RPC. Refund what the user ACTUALLY paid // (received_amount, recorded at confirmation) when available, falling back to the // expected amount for older records. let fundsSent = false; let txHash = null; const refundAmount = (payment.received_amount != null && Number(payment.received_amount) > 0) ? payment.received_amount : payment.expected_amount; if (sendFunds && payment.payout_address && Number(refundAmount) > 0) { try { // processPayout validates the address and performs the transfer; returns // { success, txHash, fee }. (The previous code called a nonexistent sendPayment(), // so refunds were silently never sent while still reporting success.) const result = await walletRPCService.processPayout({ address: payment.payout_address, amount: refundAmount, userId: payment.user_id, description: `Refund for payment ${paymentId}` }); fundsSent = !!(result && result.success); txHash = (result && result.txHash) || null; } catch (err) { console.error('Failed to send refund funds:', err.message); // Don't fail the request - refund is recorded, funds can be sent manually } } res.json({ success: true, message: 'Payment refunded successfully.', refund: { paymentId, originalAmount: payment.expected_amount, originalAmountFormatted: gameModeManager.formatAtomicHuman(payment.expected_amount, 4), creditsDeducted, fundsSent, txHash, reason: reason || 'Admin refund' } }); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } })); /** * POST /api/admin/credits/adjust * Adjust a user's credit balance (add or subtract) * Body: { socketId: string, amount: number, reason?: string } * amount can be positive (add) or negative (subtract) */ app.post('/api/admin/credits/adjust', adminAuth, asyncHandler(async (req, res) => { const { socketId, amount, reason } = req.body || {}; if (!socketId || typeof socketId !== 'string') { throw new ValidationError('Invalid socketId', { safeMessage: 'socketId (string) is required.' }); } if (typeof amount !== 'number' || amount === 0) { throw new ValidationError('Invalid amount', { safeMessage: 'amount (non-zero number) is required. Positive to add, negative to subtract.' }); } // Get user const user = await gameModeManager.getOrCreateUser(socketId); if (!user) { throw new NotFoundError('User not found', { safeMessage: `User with socket ${socketId} not found.` }); } const currentCredits = user.credits || 0; // 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.', user: { credits: currentCredits } }); } // Record transaction const transactionType = actualAdjustment > 0 ? 'admin_credit' : 'admin_debit'; await db.query(` INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) VALUES ($1, $2, $3, $4, $5) `, [user.id, actualAdjustment, reason || 'Admin adjustment', newBalance, transactionType]); // Notify user if connected try { io.to(socketId).emit('credits_update', { balance: newBalance, creditsPerGame: gameModeManager.creditsPerGameCost }); } catch (_) {} res.json({ success: true, message: `Credits ${actualAdjustment > 0 ? 'added' : 'deducted'} successfully.`, adjustment: { userId: user.id, previousBalance: currentCredits, adjustment: actualAdjustment, newBalance, reason: reason || 'Admin adjustment' } }); })); /** * POST /api/admin/alerts/test-email * Send a test email to verify alert service configuration * Requires: X-Admin-Key header */ app.post('/api/admin/alerts/test-email', adminAuth, asyncHandler(async (req, res) => { if (!alertService) { return res.status(503).json({ success: false, message: 'Alert service not initialized. Payment system may not be enabled.' }); } const result = await alertService.sendAlert('test_email', { subject: `๐Ÿงช Wownerogue Alert Test - ${process.env.NODE_ENV || 'development'}`, html: `

Alert Service Test

If you received this email, the alert notifications are working correctly!


Environment: ${process.env.NODE_ENV || 'development'}

Time: ${new Date().toISOString()}

Server: ${process.env.CRYPTO_TYPE || 'WOW'}

` }); res.json({ success: result.sent, message: result.sent ? 'Test email sent successfully!' : (result.reason || 'Failed to send'), details: result }); })); // ============================================================================= // Admin Queue Management // ============================================================================= /** * GET /api/admin/queue * Get current queue details for admin dashboard * Returns full queue entries (not anonymized) */ app.get('/api/admin/queue', adminAuth, asyncHandler(async (req, res) => { if (!socketHandlers?.queueManager) { return res.status(503).json({ success: false, message: 'Queue manager not available' }); } const queueDetails = socketHandlers.queueManager.getQueueDetailsForAdmin(); res.json({ success: true, queue: queueDetails, count: queueDetails.length }); })); /** * POST /api/admin/queue/remove * Remove a stuck queue entry by serverId * Body: { serverId: string, reason?: string } */ app.post('/api/admin/queue/remove', adminAuth, asyncHandler(async (req, res) => { const { serverId, reason } = req.body || {}; if (!serverId || typeof serverId !== 'string') { throw new ValidationError('Missing serverId', { safeMessage: 'serverId is required to remove a queue entry.' }); } if (!socketHandlers?.queueManager) { return res.status(503).json({ success: false, message: 'Queue manager not available' }); } // Check if entry exists first const isQueued = socketHandlers.queueManager.isPlayerQueued(serverId); if (!isQueued) { return res.status(404).json({ success: false, message: `No queue entry found for serverId: ${serverId}` }); } // Remove the entry const removed = socketHandlers.queueManager.removePlayer(serverId); // Log admin action console.log(`[Admin] Queue entry removed: serverId=${serverId}, reason="${reason || 'No reason provided'}", by admin at ${new Date().toISOString()}`); // Optionally notify the user via socket (if still connected) try { io.to(serverId).emit('queue_cancelled', { reason: 'admin_removed', message: 'Your queue entry was removed by an administrator.' }); } catch (notifyErr) { // Ignore errors - user may be disconnected } res.json({ success: removed, message: removed ? 'Queue entry removed successfully' : 'Failed to remove queue entry', serverId: serverId }); })); // ============================================================================= // ADMIN CHAT MODERATION ENDPOINTS // ============================================================================= /** * GET /api/admin/chat * List recent chat messages for moderation * Query: ?limit=100&includeDeleted=false */ app.get('/api/admin/chat', adminAuth, asyncHandler(async (req, res) => { const limit = Math.min(parseInt(req.query.limit) || 100, 500); const includeDeleted = req.query.includeDeleted === 'true'; const messages = await socketHandlers.chatHandler.chatHistory.getMessagesForAdmin(limit, includeDeleted); res.json({ messages, count: messages.length }); })); /** * DELETE /api/admin/chat/:id * Soft delete a chat message * Body: { reason?: string } */ app.delete('/api/admin/chat/:id', adminAuth, asyncHandler(async (req, res) => { const messageId = parseInt(req.params.id); const { reason } = req.body; if (!messageId || isNaN(messageId)) { throw new ValidationError('Invalid message ID'); } const deleted = await socketHandlers.chatHandler.chatHistory.deleteMessage( messageId, 'admin', reason || 'Admin deleted' ); if (deleted) { // Broadcast deletion to all connected clients io.emit('chat_deleted', { messageId }); console.log(`[Admin] Chat message deleted: id=${messageId}, reason="${reason || 'No reason provided'}"`); } res.json({ success: deleted, message: deleted ? 'Message deleted' : 'Message not found or already deleted' }); })); /** * POST /api/admin/users/:id/chat-ban * Ban or unban a user from chat * Body: { banned: boolean, reason?: string } */ app.post('/api/admin/users/:id/chat-ban', adminAuth, asyncHandler(async (req, res) => { const userId = parseInt(req.params.id); const { banned, reason } = req.body; if (!userId || isNaN(userId)) { throw new ValidationError('Invalid user ID'); } if (typeof banned !== 'boolean') { throw new ValidationError('banned must be a boolean'); } await db.query(` UPDATE users SET chat_banned = $2, chat_banned_at = CASE WHEN $2 THEN NOW() ELSE NULL END, chat_banned_reason = $3 WHERE id = $1 `, [userId, banned, reason || null]); console.log(`[Admin] User ${userId} chat ${banned ? 'banned' : 'unbanned'}: reason="${reason || 'No reason provided'}"`); res.json({ success: true, userId, banned, reason: reason || null }); })); /** * GET /api/admin/users/search * Search for users by socket ID prefix or payout address * Query: ?q=searchterm&limit=20 */ app.get('/api/admin/users/search', adminAuth, asyncHandler(async (req, res) => { const { q, limit = 20 } = req.query; if (!q || q.length < 3) { throw new ValidationError('Invalid search query', { safeMessage: 'Search query must be at least 3 characters.' }); } const searchLimit = Math.min(parseInt(limit, 10) || 20, 100); const result = await db.query(` SELECT id, socket_id, payout_address, credits, total_games_played, total_credits_purchased, created_at FROM users WHERE socket_id ILIKE $1 OR payout_address ILIKE $1 ORDER BY created_at DESC LIMIT $2 `, [`%${q}%`, searchLimit]); res.json({ users: result.rows.map(u => ({ id: u.id, socketId: u.socket_id, payoutAddress: u.payout_address ? `${u.payout_address.substring(0, 10)}...` : null, credits: u.credits || 0, gamesPlayed: u.total_games_played || 0, createdAt: u.created_at })), total: result.rows.length }); })); // ============================================================================= // Admin Stats Endpoints // ============================================================================= // GET /api/admin/stats/overview - Server-wide statistics app.get('/api/admin/stats/overview', adminAuth, asyncHandler(async (req, res) => { // Get user counts const userStats = await db.query(` SELECT COUNT(*) as total_users, SUM(total_games_played) as total_games, SUM(total_games_won) as total_wins, SUM(total_amount_won) as total_payout_volume, COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours') as new_users_24h FROM users `); // Get payout counts const payoutStats = await db.query(` SELECT COUNT(*) FILTER (WHERE status = 'pending') as pending_payouts, COUNT(*) FILTER (WHERE status = 'failed') as failed_payouts, COUNT(*) FILTER (WHERE status = 'completed') as completed_payouts, COUNT(*) FILTER (WHERE status = 'completed' AND processed_at > NOW() - INTERVAL '24 hours') as payouts_24h FROM payouts `); // Get game counts for last 24h const gameStats = await db.query(` SELECT COUNT(*) as games_24h, COUNT(*) FILTER (WHERE status = 'won') as wins_24h, COUNT(*) FILTER (WHERE status = 'lost') as losses_24h FROM games WHERE created_at > NOW() - INTERVAL '24 hours' `); // Get wallet balance - always attempt, even if isHealthy is false let walletBalance = null; try { walletBalance = await walletRPCService.getBalance(); if (walletBalance.error) { console.warn('Wallet balance returned error:', walletBalance.error); } } catch (e) { console.error('Failed to get wallet balance for admin stats:', e.message); walletBalance = { balance: 0, unlocked_balance: 0, error: e.message }; } const user = userStats.rows[0]; const payout = payoutStats.rows[0]; const game = gameStats.rows[0]; const cryptoType = gameModeManager.cryptoType || process.env.CRYPTO_TYPE || 'WOW'; const { inferCurrencyDecimals, getDecimalDivisor } = require('./game/helpers/gameModeUtils'); const atomicDivisor = getDecimalDivisor(inferCurrencyDecimals(cryptoType)); res.json({ totalUsers: parseInt(user.total_users) || 0, totalGamesPlayed: parseInt(user.total_games) || 0, totalGamesWon: parseInt(user.total_wins) || 0, totalPayoutVolume: user.total_payout_volume || '0', pendingPayouts: parseInt(payout.pending_payouts) || 0, failedPayouts: parseInt(payout.failed_payouts) || 0, completedPayouts: parseInt(payout.completed_payouts) || 0, walletBalance: walletBalance, cryptoType, atomicDivisor, last24h: { games: parseInt(game.games_24h) || 0, wins: parseInt(game.wins_24h) || 0, losses: parseInt(game.losses_24h) || 0, payouts: parseInt(payout.payouts_24h) || 0, newUsers: parseInt(user.new_users_24h) || 0 } }); })); // GET /api/admin/stats/payouts - Payout details app.get('/api/admin/stats/payouts', adminAuth, asyncHandler(async (req, res) => { const { status, limit = 50, offset = 0 } = req.query; const limitNum = Math.min(parseInt(limit) || 50, 200); const offsetNum = parseInt(offset) || 0; let whereClause = ''; const params = [limitNum, offsetNum]; if (status && ['pending', 'failed', 'completed', 'permanently_failed'].includes(status)) { whereClause = 'WHERE status = $3'; params.push(status); } const result = await db.query(` SELECT p.id, p.game_id, p.user_id, p.amount, p.payout_address, p.status, p.tx_hash, p.fee, p.retry_count, p.last_error, p.created_at, p.processed_at FROM payouts p ${whereClause} ORDER BY p.created_at DESC LIMIT $1 OFFSET $2 `, params); // Get totals const totals = await db.query(` SELECT COUNT(*) FILTER (WHERE status = 'pending') as pending_count, COUNT(*) FILTER (WHERE status = 'failed') as failed_count, COUNT(*) FILTER (WHERE status = 'completed') as completed_count, COUNT(*) FILTER (WHERE status = 'permanently_failed') as permanently_failed_count, COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) as total_volume FROM payouts `); res.json({ payouts: result.rows.map(p => ({ id: p.id, gameId: p.game_id, userId: p.user_id, amount: p.amount, address: p.payout_address, status: p.status, txHash: p.tx_hash, fee: p.fee, retryCount: p.retry_count, lastError: p.last_error, createdAt: p.created_at, processedAt: p.processed_at })), totals: { pending: parseInt(totals.rows[0].pending_count) || 0, failed: parseInt(totals.rows[0].failed_count) || 0, completed: parseInt(totals.rows[0].completed_count) || 0, permanentlyFailed: parseInt(totals.rows[0].permanently_failed_count) || 0, totalVolume: totals.rows[0].total_volume || '0' } }); })); // GET /api/admin/stats/games - Game statistics app.get('/api/admin/stats/games', adminAuth, asyncHandler(async (req, res) => { const { period = '24h', limit = 50 } = req.query; const limitNum = Math.min(parseInt(limit) || 50, 200); // Parse period let interval = '24 hours'; if (period === '7d') interval = '7 days'; else if (period === '30d') interval = '30 days'; // Summary stats const summary = await db.query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'won') as won, COUNT(*) FILTER (WHERE status = 'lost') as lost, COUNT(*) FILTER (WHERE outcome = 'escaped') as escaped, COUNT(*) FILTER (WHERE outcome = 'caught_by_monster') as caught, COUNT(*) FILTER (WHERE outcome = 'expired') as expired, AVG(duration_seconds) as avg_duration FROM games WHERE created_at > NOW() - INTERVAL '${interval}' `); // By game mode const byMode = await db.query(` SELECT game_mode, COUNT(*) as count FROM games WHERE created_at > NOW() - INTERVAL '${interval}' GROUP BY game_mode `); // Top winners const topWinners = await db.query(` SELECT u.socket_id, u.total_games_won as wins, u.total_amount_won as total_won FROM users u WHERE u.total_games_won > 0 ORDER BY u.total_amount_won DESC LIMIT 10 `); // Recent games const recentGames = await db.query(` SELECT g.id, g.game_mode, g.status, g.outcome, g.duration_seconds, g.created_at, p.amount as payout_amount FROM games g LEFT JOIN payouts p ON p.game_id = g.id AND p.status = 'completed' ORDER BY g.created_at DESC LIMIT $1 `, [limitNum]); const s = summary.rows[0]; const modeMap = {}; byMode.rows.forEach(r => { modeMap[r.game_mode] = parseInt(r.count) || 0; }); res.json({ summary: { total: parseInt(s.total) || 0, won: parseInt(s.won) || 0, lost: parseInt(s.lost) || 0, escaped: parseInt(s.escaped) || 0, caught: parseInt(s.caught) || 0, expired: parseInt(s.expired) || 0 }, avgDuration: parseFloat(s.avg_duration) || 0, byMode: modeMap, topWinners: topWinners.rows.map(w => ({ socketId: w.socket_id?.substring(0, 20) + '...', wins: parseInt(w.wins) || 0, totalWon: w.total_won || '0' })), recentGames: recentGames.rows.map(g => ({ id: g.id, mode: g.game_mode, status: g.status, outcome: g.outcome, duration: g.duration_seconds, payoutAmount: g.payout_amount, createdAt: g.created_at })) }); })); // GET /api/admin/users - Paginated user list app.get('/api/admin/users', adminAuth, asyncHandler(async (req, res) => { const { search, limit = 50, offset = 0 } = req.query; const limitNum = Math.min(parseInt(limit) || 50, 200); const offsetNum = parseInt(offset) || 0; let whereClause = ''; const params = [limitNum, offsetNum]; if (search && search.length >= 3) { whereClause = 'WHERE socket_id ILIKE $3 OR payout_address ILIKE $3'; params.push(`%${search}%`); } const result = await db.query(` SELECT id, socket_id, payout_address, credits, total_games_played, total_games_won, total_amount_paid, total_amount_won, total_credits_purchased, created_at FROM users ${whereClause} ORDER BY created_at DESC LIMIT $1 OFFSET $2 `, params); const countResult = await db.query(` SELECT COUNT(*) as total FROM users ${whereClause} `, search && search.length >= 3 ? [`%${search}%`] : []); res.json({ users: result.rows.map(u => ({ id: u.id, socketId: u.socket_id, payoutAddress: u.payout_address ? `${u.payout_address.substring(0, 15)}...` : null, credits: u.credits || 0, gamesPlayed: parseInt(u.total_games_played) || 0, gamesWon: parseInt(u.total_games_won) || 0, totalPaid: u.total_amount_paid || '0', totalWon: u.total_amount_won || '0', creditsPurchased: parseInt(u.total_credits_purchased) || 0, createdAt: u.created_at })), total: parseInt(countResult.rows[0]?.total) || 0, limit: limitNum, offset: offsetNum }); })); // GET /api/admin/users/:id - Detailed user info app.get('/api/admin/users/:id', adminAuth, asyncHandler(async (req, res) => { const { id } = req.params; const userResult = await db.query(` SELECT * FROM users WHERE id = $1 `, [id]); if (!userResult.rows.length) { throw new NotFoundError('User not found'); } const user = userResult.rows[0]; // Get user's games const gamesResult = await db.query(` SELECT id, game_mode, status, outcome, treasure_found, moves_made, duration_seconds, created_at FROM games WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50 `, [id]); // Get user's payments const paymentsResult = await db.query(` SELECT id, amount, status, tx_hash, credits_purchased, created_at FROM payments WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50 `, [id]); // Get user's payouts const payoutsResult = await db.query(` SELECT id, amount, status, tx_hash, fee, payout_address, created_at, processed_at FROM payouts WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50 `, [id]); res.json({ user: { id: user.id, socketId: user.socket_id, payoutAddress: user.payout_address, credits: user.credits || 0, gamesPlayed: parseInt(user.total_games_played) || 0, gamesWon: parseInt(user.total_games_won) || 0, totalPaid: user.total_amount_paid || '0', totalWon: user.total_amount_won || '0', creditsPurchased: parseInt(user.total_credits_purchased) || 0, createdAt: user.created_at }, games: gamesResult.rows, payments: paymentsResult.rows, payouts: payoutsResult.rows }); })); // POST /api/admin/payouts/:id/retry - Manually retry a failed payout app.post('/api/admin/payouts/:id/retry', adminAuth, asyncHandler(async (req, res) => { const { id } = req.params; const payoutResult = await db.query(` SELECT * FROM payouts WHERE id = $1 `, [id]); if (!payoutResult.rows.length) { throw new NotFoundError('Payout not found'); } const payout = payoutResult.rows[0]; if (payout.status === 'completed') { throw new ValidationError('Payout already completed'); } // Reset status to pending for retry await db.query(` UPDATE payouts SET status = 'pending', last_error = 'Manual retry requested' WHERE id = $1 `, [id]); res.json({ success: true, message: 'Payout queued for retry', payoutId: id }); })); // ============================================================================= // LEADERBOARD // ============================================================================= app.get('/api/leaderboard', asyncHandler(async (req, res) => { const period = req.query.period || 'all'; const limit = Math.min(parseInt(req.query.limit) || 20, 50); let timeFilter = ''; if (period === 'week') timeFilter = "AND g.completed_at > NOW() - INTERVAL '7 days'"; else if (period === 'month') timeFilter = "AND g.completed_at > NOW() - INTERVAL '30 days'"; const result = await db.query(` SELECT u.id, COALESCE(u.display_name, CASE WHEN u.payout_address IS NOT NULL THEN LEFT(u.payout_address, 4) || '...' || RIGHT(u.payout_address, 4) ELSE 'Anon#' || u.id END ) as name, MAX(g.score) as best_score, COUNT(*) FILTER (WHERE g.status = 'won') as wins, COUNT(*) as games_played FROM games g JOIN users u ON g.user_id = u.id WHERE g.status IN ('won', 'lost') AND g.score > 0 ${timeFilter} GROUP BY u.id, u.display_name, u.payout_address ORDER BY best_score DESC LIMIT $1 `, [limit]); res.json({ leaderboard: result.rows, period }); })); app.get('/api/game-modes', (req, res) => { const config = paymentConfigManager.getConfig(); const decimals = Number(config.currency?.decimals ?? 12); const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1; const toDisplay = (value) => { if (value === null || value === undefined) { return 0; } const numeric = typeof value === 'bigint' ? Number(value) : Number(value); return Number.isFinite(numeric) ? numeric / divisor : 0; }; const directMode = config.modes.direct; const creditsMode = config.modes.credits; // Include hosted by info if configured let hostedBy = null; if (process.env.HOSTED_BY) { let hostname = process.env.HOSTED_BY_NAME; if (!hostname) { try { hostname = new URL(process.env.HOSTED_BY).hostname; } catch (e) { hostname = process.env.HOSTED_BY; // Use raw value if URL parsing fails } } hostedBy = { url: process.env.HOSTED_BY, name: hostname }; } res.json({ FREE: { name: 'Free Play', cost: 0, payoutMultiplier: 0, enabled: !config.paymentsEnabled }, PAID_SINGLE: { name: 'Paid Single Game', cost: toDisplay(directMode.price), enabled: !!directMode.enabled, payoutMultiplier: config.payouts.rules.direct.multipliers }, PAID_CREDITS: { name: 'Credits Package', cost: toDisplay(creditsMode.packages?.[0]?.price ?? 0), credits: creditsMode.packages?.[0]?.credits ?? creditsMode.creditsPerGame, enabled: !!creditsMode.enabled, payoutMultiplier: creditsMode.enabled && config.payouts.rules.credits.enabled ? config.payouts.rules.credits.multipliers : 0 }, hostedBy }); }); // ============================================================================ // Provably Fair Verification Endpoint // ============================================================================ /** * Verify a game's fairness by checking if the seed matches the commitment hash * * GET /verify/:gameId - Returns HTML verification page * GET /api/verify - Verify seed and commitment (query params: seed, commitment) */ app.get('/verify/:gameId', asyncHandler(async (req, res) => { const { gameId } = req.params; // Try to look up the game in the database let gameRecord = null; try { const result = await databaseManager.query( 'SELECT dungeon_seed, status, treasure_found, moves_made, duration_seconds, created_at FROM games WHERE dungeon_seed = $1', [gameId] ); if (result.rows.length > 0) { gameRecord = result.rows[0]; } } catch (err) { // Database lookup failed, continue without game record } // Return a simple HTML verification page res.send(` Wownerogue - Game Verification

๐Ÿ” Wownerogue Provably Fair Verification

How it works:

  1. Before game start, you received a commitment hash (SHA-256)
  2. After the game, you received the seed
  3. This page verifies: SHA256(seed) === commitment
  4. The seed deterministically generated your dungeon layout

Verify Game

${gameRecord ? `

Game Record

Game ID: ${gameId}

Status: ${gameRecord.status}

Treasure Found: ${gameRecord.treasure_found ? 'Yes' : 'No'}

Moves: ${gameRecord.moves_made || 'N/A'}

Duration: ${gameRecord.duration_seconds ? gameRecord.duration_seconds + 's' : 'N/A'}

Created: ${gameRecord.created_at}

` : ''}

For technical verification, you can also use the API:

GET /api/verify?seed=YOUR_SEED&commitment=YOUR_COMMITMENT
`); })); // API endpoint for programmatic verification app.get('/api/verify', (req, res) => { const { seed, commitment } = req.query; if (!seed || !commitment) { return res.status(400).json({ valid: false, error: 'Missing required parameters: seed and commitment' }); } const result = verifyGame(seed, commitment); // PROVABLY FAIR (Phase 0.2): when the seed matches the commitment, regenerate the // dungeon from the seed and return a layout fingerprint so the player can confirm // the layout is a deterministic function of the committed seed (not crafted after payment). if (result.valid) { try { const DungeonGenerator = require('./game/dungeon'); const dungeon = DungeonGenerator.regenerateFromSeed(seed, process.env.CRYPTO_TYPE || 'WOW'); result.layoutFingerprint = DungeonGenerator.layoutFingerprint(dungeon); result.dungeonSize = { width: dungeon.map[0].length, height: dungeon.map.length }; result.entrance = dungeon.entrance; result.exit = dungeon.exit; result.treasure = dungeon.treasure; } catch (e) { // Regeneration is best-effort; the hash check above is the primary verification. } } res.json(result); }); // Initialize debug manager with new block callbacks debugManager.onNewBlockCallback((blockHeight) => { // Start games for waiting players when new block detected socketHandlers.startGamesForWaiting(blockHeight); // Check active games for timeout socketHandlers.checkGamesTimeout(blockHeight); // Flush any pending payouts (safety net for batch timer) gameModeManager._processPendingPayouts().catch(err => { console.error('โŒ Block-triggered payout processing error:', err.message); }); }); // Initialize payment system async function initializePaymentSystem() { try { console.log('๐Ÿ”ง Initializing payment system...'); const walletInitialized = await walletRPCService.initialize(); if (!walletInitialized) { console.log('โš ๏ธ Wallet RPC not available - falling back to FREE mode'); process.env.GAME_MODE = 'FREE'; paymentConfigManager.refresh(); gameModeManager.setLegacyGameMode('FREE'); gameModeManager.paymentsEnabled = false; gameModeManager.directModeEnabled = false; gameModeManager.creditsModeEnabled = false; } // Initialize database connection and run migrations await databaseManager.initialize(); console.log('โœ… Database initialized successfully'); // Test RPC service connectivity const rpcHealth = await rpcService.healthCheck(); console.log(`โœ… RPC Service: ${rpcHealth.healthy ? 'Connected' : 'Warning - using fallback'}`); console.log('๐Ÿš€ Payment system ready!'); return true; } catch (error) { console.error('โŒ Failed to initialize payment system:', error); console.log('โš ๏ธ Server will continue in FREE mode only'); return false; } } // Start the debug/production system debugManager.initialize(); // Debug function for registered users function debugRegisteredUsers() { if (debugManager.CONSOLE_LOGGING) { console.log("๐Ÿ“Š REGISTERED USERS DEBUG:"); const allUsers = require('./db/user').getAllUsers(); console.log(`Total registered users: ${allUsers.length}`); allUsers.forEach(u => { // u.id is the socket/server id; there is no separate socketId property on User console.log(` - User ID: ${u.id}, Socket: ${u.id}, Client: ${u.clientId}`); }); console.log(`Active games: ${activeGames.size}`); } } // Regular debug logging setInterval(debugRegisteredUsers, 10000); // Start server async function startServer() { try { // Initialize payment system first const paymentSystemReady = await initializePaymentSystem(); // Socket.io connection handler - ONLY after payment system is ready io.on('connection', function(socket) { socketHandlers.handleConnection(socket); }); // Start HTTP server const PORT = process.env.PORT || 3000; http.listen(PORT, function() { console.log(`๐Ÿš€ Wownerogue server listening on *:${PORT}`); console.log(`๐Ÿ› Debug mode: ${debugManager.getDebugStatus().debugMode ? 'ENABLED' : 'DISABLED'}`); console.log(`๐Ÿ’ฐ Payment system: ${paymentSystemReady ? 'ENABLED' : 'FREE MODE ONLY'}`); const summary = paymentConfigManager.summarize(); const enabledModes = []; if (!summary.paymentsEnabled || !paymentSystemReady) { enabledModes.push('FREE'); } if (summary.directEnabled && paymentSystemReady) { enabledModes.push('PAID_SINGLE'); } if (summary.creditsEnabled && paymentSystemReady) { enabledModes.push('PAID_CREDITS'); } if (enabledModes.length === 0) { enabledModes.push(summary.legacyMode || 'FREE'); } console.log(`๐ŸŽฎ Available game modes: ${enabledModes.join(', ')}`); }); // Start batch payout processing if payment system is ready if (paymentSystemReady) { const payoutConfig = paymentConfigManager.getConfig().payouts?.processing || {}; const payoutIntervalSeconds = Math.max(1, Number(payoutConfig.batchInterval || 300)); const payoutIntervalMs = payoutIntervalSeconds * 1000; batchPayoutInterval = setInterval(async () => { try { await gameModeManager._processPendingPayouts(); } catch (error) { console.error('Error in batch payout processing:', error); } }, payoutIntervalMs); // Start payout retry service for failed/stuck payouts const maxRetries = Number(process.env.PAYOUT_MAX_RETRIES) || 3; const retryIntervalMs = Number(process.env.PAYOUT_RETRY_INTERVAL_MS) || 300000; // 5 minutes // Initialize alert service for email notifications alertService = new AlertService({ walletService: walletRPCService, db: databaseManager, debugManager }); // Make alertService available to gameModeManager/paymentHandlers for balance checks gameModeManager.alertService = alertService; payoutRetryService = new PayoutRetryService({ db: databaseManager, walletService: walletRPCService, debugManager, maxRetries, retryIntervalMs, alertService // Pass alert service for failure notifications }); payoutRetryService.start(); // 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); process.exit(1); } } // Attach 404 and error handlers last app.use((req, res, next) => { next(new NotFoundError(`Route not found: ${req.method} ${req.originalUrl}`, { safeMessage: 'The requested resource was not found.' })); }); app.use(createErrorMiddleware({ logger: console })); startServer(); // Graceful shutdown โ€” clean up timers, wait for in-flight payouts, close DB pool async function gracefulShutdown(signal) { console.log(`\n${signal} received. Shutting down gracefully...`); // Stop accepting new connections http.close(); // 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?.(); // Wait for in-flight batch processing to finish (up to 10 seconds) let waitMs = 0; while (gameModeManager._isBatchProcessing && waitMs < 10000) { await new Promise(r => setTimeout(r, 500)); waitMs += 500; } if (waitMs > 0) { console.log(` Waited ${waitMs}ms for in-flight payout processing`); } // Close database pool try { await databaseManager.close(); console.log(' Database pool closed'); } catch (err) { console.error(' Error closing database pool:', err.message); } console.log('Shutdown complete.'); process.exit(0); } process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGINT', () => gracefulShutdown('SIGINT'));