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:
- Current Balance: ${balanceFormatted} ${cryptoType}
- - Unlocked: ${balanceFormatted} ${cryptoType}
- - Threshold: ${thresholdFormatted} ${cryptoType}
+ - Critical Threshold: ${thresholdFormatted} ${cryptoType}
- 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:
+
+ - Current Balance: ${balanceFormatted} ${cryptoType}
+ - Warning Threshold: ${thresholdFormatted} ${cryptoType}
+ - Critical Threshold: ${(this.balanceCriticalThreshold / divisor).toFixed(6)} ${cryptoType}
+
+ 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
*/