/** * AlertService - Email alerts for admin monitoring * * Sends alerts via Resend for: * - Low wallet balance * - Failed payouts * - Wallet disconnection * - High pending payout queue */ class AlertService { constructor({ walletService, db, debugManager }) { this.walletService = walletService; this.db = db; this.debugManager = debugManager; // Resend configuration this.resendApiKey = process.env.RESEND_API_KEY; this.adminEmail = process.env.ADMIN_EMAIL; this.fromEmail = process.env.ALERT_FROM_EMAIL || 'alerts@monerogue.app'; // 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(); // Track wallet disconnect duration this.walletDisconnectedSince = null; this.walletDisconnectAlertThreshold = 300000; // 5 minutes this.enabled = !!(this.resendApiKey && this.adminEmail); if (this.enabled) { console.log('📧 Alert service enabled - alerts will be sent to', this.adminEmail); } else { console.log('📧 Alert service disabled - set RESEND_API_KEY and ADMIN_EMAIL to enable'); } } /** * Check if an alert type is on cooldown */ isOnCooldown(alertType) { const lastSent = this.lastAlertSent.get(alertType) || 0; return Date.now() - lastSent < this.alertCooldown; } /** * Send an email alert via Resend */ async sendAlert(type, { subject, html }) { if (!this.enabled) { return { sent: false, reason: 'Alert service not configured' }; } if (this.isOnCooldown(type)) { return { sent: false, reason: 'On cooldown' }; } try { // Dynamic import to avoid requiring resend if not configured const { Resend } = require('resend'); const resend = new Resend(this.resendApiKey); const result = await resend.emails.send({ from: this.fromEmail, to: this.adminEmail, subject: subject, html: html }); this.lastAlertSent.set(type, Date.now()); console.log(`📧 Alert sent: ${type} - ${subject}`); return { sent: true, id: result.data?.id }; } catch (error) { console.error(`❌ Failed to send alert: ${error.message}`); return { sent: false, error: error.message }; } } /** * 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) { return; } try { const balance = await this.walletService.getBalance(); const unlockedBalance = balance.unlocked_balance || 0; this.lastBalanceCheck = Date.now(); const cryptoType = process.env.CRYPTO_TYPE || 'XMR'; const decimals = cryptoType === 'WOW' ? 11 : 12; const divisor = Math.pow(10, decimals); 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: `

🚨 CRITICAL: Wallet Balance Depleted

NEW GAMES HAVE BEEN HALTED.

The payout wallet balance is critically low:

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'}
Time: ${new Date().toISOString()}

` }); } // 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 */ async checkWalletConnection() { if (this.walletService?.isHealthy) { this.walletDisconnectedSince = null; return; } if (!this.walletDisconnectedSince) { this.walletDisconnectedSince = Date.now(); return; } const disconnectedFor = Date.now() - this.walletDisconnectedSince; if (disconnectedFor > this.walletDisconnectAlertThreshold) { const minutes = Math.floor(disconnectedFor / 60000); await this.sendAlert('wallet_disconnect', { subject: `🔴 Wallet RPC Disconnected (${minutes}+ min)`, html: `

Wallet RPC Connection Lost

The wallet RPC has been unreachable for ${minutes} minutes.

Payouts cannot be processed while the wallet is disconnected.

Please check:


Endpoint: ${process.env.PRIMARY_WALLET_ENDPOINT || 'not configured'}
Time: ${new Date().toISOString()}

` }); } } /** * Check for pending payouts queue */ async checkPendingPayouts() { if (!this.db) return; try { const result = await this.db.query(` SELECT COUNT(*) as count FROM payouts WHERE status = 'pending' OR status = 'failed' `); const pendingCount = parseInt(result.rows[0]?.count) || 0; if (pendingCount >= this.highPendingThreshold) { await this.sendAlert('high_pending', { subject: `⚠️ High Pending Payouts: ${pendingCount}`, html: `

High Pending Payout Queue

There are ${pendingCount} pending or failed payouts in the queue.

This may indicate:

Please check the admin dashboard for details.


Threshold: ${this.highPendingThreshold}
Time: ${new Date().toISOString()}

` }); } } catch (error) { console.error('Failed to check pending payouts for alerts:', error.message); } } /** * Alert when a payout permanently fails */ async alertPayoutFailed(payout) { const cryptoType = process.env.CRYPTO_TYPE || 'XMR'; const amountFormatted = (payout.amount / 1e12).toFixed(6); await this.sendAlert('payout_failed', { subject: `💀 Payout Permanently Failed - ${amountFormatted} ${cryptoType}`, html: `

Payout Permanently Failed

A payout has failed after all retry attempts:

Manual intervention may be required.


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

` }); } /** * Run all checks (call this periodically) */ async runChecks() { await this.checkWalletBalance(); await this.checkWalletConnection(); await this.checkPendingPayouts(); } /** * Start periodic checking */ startPeriodicChecks(intervalMs = 300000) { // Default 5 minutes if (!this.enabled) { return; } // Run initial check after a short delay setTimeout(() => this.runChecks(), 10000); // Then run periodically this.checkInterval = setInterval(() => { this.runChecks(); }, intervalMs); console.log(`📧 Alert checks will run every ${intervalMs / 60000} minutes`); } /** * Stop periodic checking */ stopPeriodicChecks() { if (this.checkInterval) { clearInterval(this.checkInterval); this.checkInterval = null; } } } module.exports = AlertService;