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 const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, debugManager, gameModeManager, walletRPCService); // Configure static file serving const htmlPath = path.join(__dirname, '../html'); app.use(express.static(htmlPath)); app.use(express.json()); // Parse JSON for API endpoints // Serve main page app.get('/', function(req, res) { res.sendFile('index.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 if healthy let walletBalance = null; let lowBalanceWarning = false; const lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR default if (walletRPCService.isHealthy) { try { walletBalance = await walletRPCService.getBalance(); lowBalanceWarning = walletBalance.unlocked_balance < lowBalanceThreshold; } catch (e) { // Ignore balance fetch errors } } 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 = 'confirmed'`, [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 // ============================================================================= /** * 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 // The signature should be the challenge signed by the wallet's private key let isValidSignature = false; try { const nacl = require('tweetnacl'); const challengeBytes = Buffer.from(challenge, 'hex'); const signatureBytes = Buffer.from(signature, 'hex'); const publicKeyBytes = Buffer.from(publicKey, 'hex'); // tweetnacl.sign.detached.verify(message, signature, publicKey) isValidSignature = nacl.sign.detached.verify(challengeBytes, 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 }); })); // ============================================================================= // 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.' }); } 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) { const userResult = await client.query(` SELECT credits FROM users WHERE id = $1 `, [payment.user_id]); const currentCredits = userResult.rows[0]?.credits || 0; creditsDeducted = Math.min(payment.credits_purchased, currentCredits); if (creditsDeducted > 0) { const newBalance = currentCredits - creditsDeducted; await client.query(` UPDATE users SET credits = $1 WHERE id = $2 `, [newBalance, payment.user_id]); // Record credit transaction 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 let fundsSent = false; let txHash = null; if (sendFunds && payment.payout_address && payment.expected_amount > 0) { try { if (walletRPCService && typeof walletRPCService.sendPayment === 'function') { const result = await walletRPCService.sendPayment( payment.payout_address, payment.expected_amount ); fundsSent = true; txHash = result?.tx_hash || 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; const newBalance = Math.max(0, currentCredits + amount); // Prevent negative balance const actualAdjustment = newBalance - currentCredits; if (actualAdjustment === 0) { return res.json({ success: false, message: 'No adjustment made (would result in same balance).', user: { credits: currentCredits } }); } // Update credits await db.query(` UPDATE users SET credits = $1, updated_at = NOW() WHERE id = $2 `, [newBalance, user.id]); // 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' } }); })); /** * 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 let walletBalance = null; if (walletRPCService.isHealthy) { try { walletBalance = await walletRPCService.getBalance(); } catch (e) { // Ignore } } const user = userStats.rows[0]; const payout = payoutStats.rows[0]; const game = gameStats.rows[0]; 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, 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 }); })); 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); 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); }); // 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; setInterval(async () => { try { await walletRPCService.processBatchPayouts(); } 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 const alertService = new AlertService({ walletService: walletRPCService, db: databaseManager, debugManager }); const 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); } } 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();