From d27df3b6cf459202123e61336ea2eb8e939314f7 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 1 Feb 2026 17:38:30 -0500 Subject: [PATCH] Feat: add IP-based rate limiting and house balance thresholds --- html/js/network/socketHandlers.js | 85 ++++++++++++++++++++ src/index.js | 3 + src/network/paymentHandlers.js | 27 ++++++- src/network/rateLimiter.js | 34 ++++++-- src/services/alertService.js | 128 ++++++++++++++++++++++++++---- 5 files changed, 254 insertions(+), 23 deletions(-) diff --git a/html/js/network/socketHandlers.js b/html/js/network/socketHandlers.js index 4512fe4..5f5fb9c 100644 --- a/html/js/network/socketHandlers.js +++ b/html/js/network/socketHandlers.js @@ -139,6 +139,7 @@ const SocketHandlers = { socket.on('payment_confirmed', this.onPaymentConfirmed); socket.on('payment_detected', this.onPaymentDetected); socket.on('show_payment_options', this.onShowPaymentOptions); + socket.on('balance_critical', this.onBalanceCritical); socket.on('credits_update', this.onCreditsUpdate); socket.on('user_count', this.onUserCount); @@ -729,6 +730,90 @@ const SocketHandlers = { } }, + onBalanceCritical: function(data) { + console.warn('Balance critical - games halted:', data); + + const message = data?.message || 'Sorry, the house balance is too low to initiate new games. Please try again later.'; + + // Show a modal to the user + SocketHandlers._showBalanceCriticalModal(message); + + // Also show in chat + $('#messages').append($('
  • ').text('⚠️ ' + message)); + UI.scrollChat(); + + SocketHandlers._setBannerStatus('Unavailable', '#ff6600'); + }, + + _showBalanceCriticalModal: function(message) { + // Remove any existing modal + $('#balanceCriticalModal').remove(); + + const $modal = $(` +
    +
    +
    ⚠️
    +

    Service Temporarily Unavailable

    +

    + ${message} +

    + +
    +
    + `); + + $('body').append($modal); + + // Close handlers + $modal.on('click', '#balanceCriticalOK', function() { + $modal.fadeOut(200, function() { $modal.remove(); }); + }); + + // Also close on clicking outside + $modal.on('click', function(e) { + if (e.target === $modal[0]) { + $modal.fadeOut(200, function() { $modal.remove(); }); + } + }); + + // Close on ESC key + $(document).one('keydown.balanceCritical', function(e) { + if (e.key === 'Escape') { + $modal.fadeOut(200, function() { $modal.remove(); }); + } + }); + }, + _lastDisplayedConfirmation: null, _confirmationTimestamps: {}, // paymentId -> ts onPaymentConfirmed: function(data) { diff --git a/src/index.js b/src/index.js index 50a60c7..5ddaafa 100644 --- a/src/index.js +++ b/src/index.js @@ -1612,6 +1612,9 @@ async function startServer() { debugManager }); + // Make alertService available to gameModeManager/paymentHandlers for balance checks + gameModeManager.alertService = alertService; + const payoutRetryService = new PayoutRetryService({ db: databaseManager, walletService: walletRPCService, diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index 27dff65..7b861d6 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -47,6 +47,19 @@ class PaymentHandlers { this.io.to(socket.id).emit('payment_error', { error: 'Payment system not available' }); return; } + + // Check if house balance is critically low before proceeding with payment + if (this.gameModeManager.alertService) { + const balanceCheck = await this.gameModeManager.alertService.checkBalanceForGameStart(); + if (balanceCheck.halted) { + this.io.to(socket.id).emit('balance_critical', { + reason: 'low_balance', + message: balanceCheck.reason || 'Sorry, the house balance is too low to initiate new games. Please try again later.' + }); + return; + } + } + try { const { type, packageId } = data; const paymentType = type || data.gameMode || 'single_game'; @@ -104,7 +117,19 @@ class PaymentHandlers { async createAndShowPaymentRequest(socket, options = {}) { if (!this.gameModeManager) return; - + + // Check if house balance is critically low before proceeding with payment + if (this.gameModeManager.alertService) { + const balanceCheck = await this.gameModeManager.alertService.checkBalanceForGameStart(); + if (balanceCheck.halted) { + this.io.to(socket.id).emit('balance_critical', { + reason: 'low_balance', + message: balanceCheck.reason || 'Sorry, the house balance is too low to initiate new games. Please try again later.' + }); + return; + } + } + // If both direct and credits modes are enabled, let the user choose const bothModesEnabled = this.gameModeManager.directModeEnabled && this.gameModeManager.creditsModeEnabled; const forceShowOptions = options.showOptions === true; diff --git a/src/network/rateLimiter.js b/src/network/rateLimiter.js index 6f96ce6..b4167dc 100644 --- a/src/network/rateLimiter.js +++ b/src/network/rateLimiter.js @@ -43,13 +43,14 @@ class RateLimiter { const now = Date.now(); const key = `${userId}:${action}`; - + // Check user-based limits const userResult = this._checkSingleLimit(key, limit, now); - - // Check IP-based limits if IP provided and it's a connection-type action + + // Check IP-based limits if IP provided + // Apply to: connection actions, payment actions, and game actions let ipResult = { allowed: true, remaining: Infinity, retryAfter: 0 }; - if (ip && action.startsWith('connection:')) { + if (ip && this._shouldApplyIpLimit(action)) { const ipKey = `${ip}:${action}`; ipResult = this._checkSingleLimit(ipKey, limit, now, this.ipStorage); } @@ -68,6 +69,23 @@ class RateLimiter { return result; } + /** + * Determine if an action should have IP-based rate limiting applied + * @param {string} action - Action type + * @returns {boolean} + */ + _shouldApplyIpLimit(action) { + // Actions that should be rate limited by IP (in addition to socket ID) + // This prevents abuse when users reconnect to bypass socket-based limits + const ipLimitedPrefixes = [ + 'connection:', // Connection attempts + 'payment:', // Payment creation/operations + 'game:', // Game start/queue operations + 'address:' // Address changes + ]; + return ipLimitedPrefixes.some(prefix => action.startsWith(prefix)); + } + /** * Record an action attempt (increment counter) * @param {string} userId - User identifier @@ -77,12 +95,12 @@ class RateLimiter { async recordAttempt(userId, action, ip = null) { const now = Date.now(); const key = `${userId}:${action}`; - + // Record user attempt this._recordSingleAttempt(key, now); - - // Record IP attempt for connection actions - if (ip && action.startsWith('connection:')) { + + // Record IP attempt for actions that should have IP-based limiting + if (ip && this._shouldApplyIpLimit(action)) { const ipKey = `${ip}:${action}`; this._recordSingleAttempt(ipKey, now, this.ipStorage); } diff --git a/src/services/alertService.js b/src/services/alertService.js index 0fc2405..2481a7d 100644 --- a/src/services/alertService.js +++ b/src/services/alertService.js @@ -19,10 +19,17 @@ class AlertService { this.adminEmail = process.env.ADMIN_EMAIL; this.fromEmail = process.env.ALERT_FROM_EMAIL || 'alerts@monerogue.app'; - // Thresholds - this.lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR + // Balance thresholds (atomic units) + // BALANCE_WARN: sends email alert + // BALANCE_CRITICAL: sends email AND halts new games + this.balanceWarnThreshold = parseInt(process.env.BALANCE_WARN) || parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR default + this.balanceCriticalThreshold = parseInt(process.env.BALANCE_CRITICAL) || 10000000000; // 0.01 XMR default this.highPendingThreshold = parseInt(process.env.HIGH_PENDING_THRESHOLD) || 10; + // Track critical balance state for game halt + this.isBalanceCritical = false; + this.lastBalanceCheck = null; + // Cooldowns (prevent alert spam) this.alertCooldown = parseInt(process.env.ALERT_COOLDOWN_MS) || 3600000; // 1 hour this.lastAlertSent = new Map(); @@ -83,7 +90,9 @@ class AlertService { } /** - * Check wallet balance and alert if low + * Check wallet balance and alert if low or critical + * BALANCE_WARN: sends email alert + * BALANCE_CRITICAL: sends email AND sets isBalanceCritical flag (halts new games) */ async checkWalletBalance() { if (!this.walletService?.isHealthy) { @@ -92,23 +101,37 @@ class AlertService { try { const balance = await this.walletService.getBalance(); + const unlockedBalance = balance.unlocked_balance || 0; + this.lastBalanceCheck = Date.now(); - if (balance.unlocked_balance < this.lowBalanceThreshold) { - const balanceFormatted = (balance.unlocked_balance / 1e12).toFixed(6); - const thresholdFormatted = (this.lowBalanceThreshold / 1e12).toFixed(6); - const cryptoType = process.env.CRYPTO_TYPE || 'XMR'; + const cryptoType = process.env.CRYPTO_TYPE || 'XMR'; + const decimals = cryptoType === 'WOW' ? 11 : 12; + const divisor = Math.pow(10, decimals); - await this.sendAlert('low_balance', { - subject: `⚠️ Wallet Balance Low - ${balanceFormatted} ${cryptoType}`, + const balanceFormatted = (unlockedBalance / divisor).toFixed(6); + + // Check CRITICAL threshold first (more severe) + if (unlockedBalance < this.balanceCriticalThreshold) { + // Set critical flag - this will halt new games + if (!this.isBalanceCritical) { + this.isBalanceCritical = true; + console.warn(`🚨 CRITICAL: Wallet balance is critically low (${balanceFormatted} ${cryptoType}). New games are HALTED.`); + } + + const thresholdFormatted = (this.balanceCriticalThreshold / divisor).toFixed(6); + + await this.sendAlert('balance_critical', { + subject: `🚨 CRITICAL: Wallet Balance Depleted - ${balanceFormatted} ${cryptoType}`, html: ` -

    Low Wallet Balance Warning

    -

    The payout wallet balance is running low:

    +

    🚨 CRITICAL: Wallet Balance Depleted

    +

    NEW GAMES HAVE BEEN HALTED.

    +

    The payout wallet balance is critically low:

    -

    Please top up the wallet to ensure payouts can continue.

    +

    Users cannot start new paid games until the wallet is topped up.

    +

    Please add funds immediately to restore service.


    Server: ${process.env.NODE_ENV || 'development'}
    @@ -117,11 +140,88 @@ class AlertService { ` }); } + // Check WARN threshold (less severe) + else if (unlockedBalance < this.balanceWarnThreshold) { + // Clear critical flag if we were critical but now just warning + if (this.isBalanceCritical) { + this.isBalanceCritical = false; + console.log(`✅ Wallet balance restored above critical threshold. Games can resume.`); + } + + const thresholdFormatted = (this.balanceWarnThreshold / divisor).toFixed(6); + + await this.sendAlert('balance_warn', { + subject: `⚠️ Wallet Balance Low - ${balanceFormatted} ${cryptoType}`, + html: ` +

    ⚠️ Low Wallet Balance Warning

    +

    The payout wallet balance is running low:

    + +

    Please top up the wallet soon to avoid service interruption.

    +

    If balance drops below the critical threshold, new games will be automatically halted.

    +
    +

    + Server: ${process.env.NODE_ENV || 'development'}
    + Time: ${new Date().toISOString()} +

    + ` + }); + } else { + // Balance is healthy - clear critical flag if set + if (this.isBalanceCritical) { + this.isBalanceCritical = false; + console.log(`✅ Wallet balance restored above thresholds. Games can resume.`); + } + } } catch (error) { console.error('Failed to check wallet balance for alerts:', error.message); } } + /** + * Check if new paid games should be halted due to critical balance + * @returns {boolean} true if games should be halted + */ + shouldHaltGames() { + return this.isBalanceCritical; + } + + /** + * Force refresh the balance check (useful when called before game start) + * @returns {Object} { halted: boolean, reason?: string } + */ + async checkBalanceForGameStart() { + if (!this.walletService?.isHealthy) { + return { halted: true, reason: 'Wallet service is unavailable. Please try again later.' }; + } + + try { + const balance = await this.walletService.getBalance(); + const unlockedBalance = balance.unlocked_balance || 0; + + if (unlockedBalance < this.balanceCriticalThreshold) { + this.isBalanceCritical = true; + const cryptoType = process.env.CRYPTO_TYPE || 'XMR'; + const decimals = cryptoType === 'WOW' ? 11 : 12; + const balanceFormatted = (unlockedBalance / Math.pow(10, decimals)).toFixed(4); + return { + halted: true, + reason: `Sorry, the house balance is too low to initiate new games (${balanceFormatted} ${cryptoType}). Please try again later.` + }; + } + + this.isBalanceCritical = false; + return { halted: false }; + } catch (error) { + console.error('Failed to check balance for game start:', error.message); + // On error, don't halt games - let them proceed + return { halted: false }; + } + } + /** * Check for wallet disconnection */