mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* AlertService - Email alerts for admin monitoring
|
||||
*
|
||||
* Sends alerts via Resend for:
|
||||
* - Low wallet balance
|
||||
* - Failed payouts
|
||||
* - Wallet disconnection
|
||||
* - High pending payout queue
|
||||
* - Manual-review and stale financial outbox rows
|
||||
*/
|
||||
|
||||
const money = require('../money/atomic');
|
||||
|
||||
function positiveAtomic(value, fallback) {
|
||||
try {
|
||||
const input = value == null || value === '' ? fallback : String(value).replace(/_/g, '');
|
||||
const exact = money.toBig(input);
|
||||
return exact > 0n ? exact : money.toBig(fallback);
|
||||
} catch (_) {
|
||||
return money.toBig(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
function formatAtomicFixed(value, decimals, digits) {
|
||||
const exact = money.toBig(value);
|
||||
const negative = exact < 0n;
|
||||
const abs = negative ? -exact : exact;
|
||||
const scale = 10n ** BigInt(decimals);
|
||||
const whole = abs / scale;
|
||||
const fraction = (abs % scale).toString().padStart(decimals, '0')
|
||||
.slice(0, digits).padEnd(digits, '0');
|
||||
return `${negative ? '-' : ''}${whole}${digits > 0 ? `.${fraction}` : ''}`;
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
class AlertService {
|
||||
constructor({ walletService, db, debugManager }) {
|
||||
this.walletService = walletService;
|
||||
this.db = db;
|
||||
this.debugManager = debugManager;
|
||||
|
||||
// Notifications hub (preferred): DB-backed dedup/throttle that survives restarts, so a stuck
|
||||
// condition is ~5 emails, not thousands. Falls back to direct Resend if NOTIFY_HUB_URL unset.
|
||||
this.hubUrl = process.env.NOTIFY_HUB_URL || ''; // e.g. http://notify.internal.example:8765/api/notify
|
||||
this.notifySource = process.env.NOTIFY_SOURCE || process.env.GAME_NAME || 'wowngeon';
|
||||
|
||||
// Resend configuration: used only when no hub URL is set.
|
||||
this.resendApiKey = process.env.RESEND_API_KEY;
|
||||
this.adminEmail = process.env.ADMIN_EMAIL;
|
||||
this.fromEmail = process.env.ALERT_FROM_EMAIL || 'alerts@example.invalid';
|
||||
|
||||
// Balance thresholds, in atomic units.
|
||||
// BALANCE_WARN: sends an email alert.
|
||||
// BALANCE_CRITICAL: sends an email and halts new games.
|
||||
this.balanceWarnThreshold = positiveAtomic(
|
||||
process.env.BALANCE_WARN || process.env.LOW_BALANCE_THRESHOLD,
|
||||
100000000000n
|
||||
); // 0.1 XMR default
|
||||
this.balanceCriticalThreshold = positiveAtomic(process.env.BALANCE_CRITICAL, 10000000000n); // 0.01 XMR default
|
||||
this.highPendingThreshold = parseInt(process.env.HIGH_PENDING_THRESHOLD) || 10;
|
||||
this.financialReviewStaleMs = positiveInteger(
|
||||
process.env.FINANCIAL_REVIEW_STALE_MS,
|
||||
15 * 60 * 1000
|
||||
);
|
||||
|
||||
this.isBalanceCritical = false;
|
||||
this.lastBalanceCheck = null;
|
||||
|
||||
// Per-type cooldown for the direct-Resend path only; it does not survive a restart.
|
||||
this.alertCooldown = parseInt(process.env.ALERT_COOLDOWN_MS) || 3600000; // 1 hour
|
||||
this.lastAlertSent = new Map();
|
||||
|
||||
this.walletDisconnectedSince = null;
|
||||
this.walletDisconnectAlertThreshold = 300000; // 5 minutes
|
||||
|
||||
this.enabled = !!(this.hubUrl || (this.resendApiKey && this.adminEmail));
|
||||
|
||||
if (this.hubUrl) {
|
||||
console.log('📧 Alerts → notifications hub:', this.hubUrl, `(source=${this.notifySource})`);
|
||||
} else if (this.enabled) {
|
||||
console.log('📧 Alert service enabled (direct Resend) - alerts to', this.adminEmail);
|
||||
} else {
|
||||
console.log('📧 Alert service disabled - set NOTIFY_HUB_URL (or RESEND_API_KEY + ADMIN_EMAIL)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 alert through the notifications hub, or directly via Resend when no hub is set.
|
||||
*/
|
||||
async sendAlert(type, { subject, html, level = 'warn', body = '' }) {
|
||||
if (!this.enabled) {
|
||||
return { sent: false, reason: 'Alert service not configured' };
|
||||
}
|
||||
|
||||
// The central hub dedups and throttles in the DB, so throttling survives a restart; the
|
||||
// in-memory cooldown below does not. Dedup key is stable: source:type.
|
||||
if (this.hubUrl) {
|
||||
return this._postHub({
|
||||
key: `${this.notifySource}:${type}`,
|
||||
level,
|
||||
title: subject,
|
||||
body: body || this._stripHtml(html),
|
||||
});
|
||||
}
|
||||
|
||||
// Without a hub, send directly through Resend under the in-memory cooldown.
|
||||
if (this.isOnCooldown(type)) {
|
||||
return { sent: false, reason: 'On cooldown' };
|
||||
}
|
||||
try {
|
||||
const { Resend } = require('resend');
|
||||
const resend = new Resend(this.resendApiKey);
|
||||
const result = await resend.emails.send({
|
||||
from: this.fromEmail, to: this.adminEmail, subject, 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a firing alert in the hub, which sends a single resolve notice if it had alerted.
|
||||
* No-op without the hub.
|
||||
*/
|
||||
async resolveAlert(type) {
|
||||
if (!this.hubUrl) return { sent: false, reason: 'no hub' };
|
||||
return this._postHub({ key: `${this.notifySource}:${type}`, resolve: true });
|
||||
}
|
||||
|
||||
async _postHub({ key, level, title = '', body = '', resolve = false }) {
|
||||
try {
|
||||
const res = await fetch(this.hubUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: this.notifySource, key, level, title, body, resolve }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.sent) console.log(`📧 hub ${resolve ? 'resolve' : 'alert'} sent: ${key}`);
|
||||
return { sent: !!data.sent, deduped: !!data.deduped, hub: true };
|
||||
} catch (error) {
|
||||
console.error(`❌ notify hub post failed (${key}): ${error.message}`);
|
||||
return { sent: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
_stripHtml(html) {
|
||||
return String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check wallet balance and alert if low or critical.
|
||||
* Below BALANCE_WARN: sends an email alert.
|
||||
* Below BALANCE_CRITICAL: sends an email and sets isBalanceCritical, which halts new games.
|
||||
*/
|
||||
async checkWalletBalance() {
|
||||
if (!this.walletService?.isHealthy) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const balance = await this.walletService.getBalance();
|
||||
const unlockedBalance = money.toBig(balance.unlocked_balance ?? 0);
|
||||
this.lastBalanceCheck = Date.now();
|
||||
|
||||
const cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
const decimals = cryptoType === 'WOW' ? 11 : 12;
|
||||
const balanceFormatted = formatAtomicFixed(unlockedBalance, decimals, 6);
|
||||
|
||||
// Critical is checked first because it is the stricter of the two thresholds.
|
||||
if (unlockedBalance < this.balanceCriticalThreshold) {
|
||||
if (!this.isBalanceCritical) {
|
||||
this.isBalanceCritical = true;
|
||||
console.warn(`🚨 CRITICAL: Wallet balance is critically low (${balanceFormatted} ${cryptoType}). New games are HALTED.`);
|
||||
}
|
||||
|
||||
const thresholdFormatted = formatAtomicFixed(this.balanceCriticalThreshold, decimals, 6);
|
||||
|
||||
await this.sendAlert('balance_critical', {
|
||||
subject: `🚨 CRITICAL: Wallet Balance Depleted - ${balanceFormatted} ${cryptoType}`,
|
||||
level: 'crit',
|
||||
html: `
|
||||
<h2 style="color: #dc3545;">🚨 CRITICAL: Wallet Balance Depleted</h2>
|
||||
<p><strong>NEW GAMES HAVE BEEN HALTED.</strong></p>
|
||||
<p>The payout wallet balance is critically low:</p>
|
||||
<ul>
|
||||
<li><strong>Current Balance:</strong> ${balanceFormatted} ${cryptoType}</li>
|
||||
<li><strong>Critical Threshold:</strong> ${thresholdFormatted} ${cryptoType}</li>
|
||||
</ul>
|
||||
<p style="color: #dc3545;"><strong>Users cannot start new paid games until the wallet is topped up.</strong></p>
|
||||
<p>Please add funds immediately to restore service.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Server: ${process.env.NODE_ENV || 'development'}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
else if (unlockedBalance < this.balanceWarnThreshold) {
|
||||
// Above critical again, so admission reopens even though the balance is still low.
|
||||
if (this.isBalanceCritical) {
|
||||
this.isBalanceCritical = false;
|
||||
console.log(`✅ Wallet balance restored above critical threshold. Games can resume.`);
|
||||
}
|
||||
|
||||
const thresholdFormatted = formatAtomicFixed(this.balanceWarnThreshold, decimals, 6);
|
||||
|
||||
await this.sendAlert('balance_warn', {
|
||||
subject: `⚠️ Wallet Balance Low - ${balanceFormatted} ${cryptoType}`,
|
||||
html: `
|
||||
<h2>⚠️ Low Wallet Balance Warning</h2>
|
||||
<p>The payout wallet balance is running low:</p>
|
||||
<ul>
|
||||
<li><strong>Current Balance:</strong> ${balanceFormatted} ${cryptoType}</li>
|
||||
<li><strong>Warning Threshold:</strong> ${thresholdFormatted} ${cryptoType}</li>
|
||||
<li><strong>Critical Threshold:</strong> ${formatAtomicFixed(this.balanceCriticalThreshold, decimals, 6)} ${cryptoType}</li>
|
||||
</ul>
|
||||
<p>Please top up the wallet soon to avoid service interruption.</p>
|
||||
<p>If balance drops below the critical threshold, new games will be automatically halted.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Server: ${process.env.NODE_ENV || 'development'}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`
|
||||
});
|
||||
} else {
|
||||
// Healthy balance clears the halt flag and any firing balance alerts in the hub.
|
||||
if (this.isBalanceCritical) {
|
||||
this.isBalanceCritical = false;
|
||||
console.log(`✅ Wallet balance restored above thresholds. Games can resume.`);
|
||||
}
|
||||
this.resolveAlert('balance_critical');
|
||||
this.resolveAlert('balance_warn');
|
||||
}
|
||||
} catch (error) {
|
||||
// Unknown balance is not evidence of sufficient reserves. Admission remains
|
||||
// closed until a later exact wallet reading proves the reserve is healthy.
|
||||
this.isBalanceCritical = true;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the balance exactly at game start rather than trusting the periodic check.
|
||||
* @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 = money.toBig(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 = formatAtomicFixed(unlockedBalance, decimals, 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);
|
||||
this.isBalanceCritical = true;
|
||||
return { halted: true, reason: 'Wallet balance could not be verified. Please try again later.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert once the wallet RPC has been unreachable past the disconnect threshold.
|
||||
*/
|
||||
async checkWalletConnection() {
|
||||
if (this.walletService?.isHealthy) {
|
||||
if (this.walletDisconnectedSince) { // Recovered: clear the firing hub alert.
|
||||
this.walletDisconnectedSince = null;
|
||||
this.resolveAlert('wallet_disconnect');
|
||||
}
|
||||
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: `
|
||||
<h2>Wallet RPC Connection Lost</h2>
|
||||
<p>The wallet RPC has been unreachable for <strong>${minutes} minutes</strong>.</p>
|
||||
<p>Payouts cannot be processed while the wallet is disconnected.</p>
|
||||
<p>Please check:</p>
|
||||
<ul>
|
||||
<li>Is the wallet-rpc process running?</li>
|
||||
<li>Is the wallet file accessible?</li>
|
||||
<li>Is there enough disk space?</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Endpoint: ${process.env.PRIMARY_WALLET_ENDPOINT || 'not configured'}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert when the pending or failed payout queue exceeds HIGH_PENDING_THRESHOLD.
|
||||
*/
|
||||
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: `
|
||||
<h2>High Pending Payout Queue</h2>
|
||||
<p>There are <strong>${pendingCount}</strong> pending or failed payouts in the queue.</p>
|
||||
<p>This may indicate:</p>
|
||||
<ul>
|
||||
<li>Wallet connectivity issues</li>
|
||||
<li>Insufficient balance</li>
|
||||
<li>Network congestion</li>
|
||||
</ul>
|
||||
<p>Please check the admin dashboard for details.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Threshold: ${this.highPendingThreshold}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check pending payouts for alerts:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert on ambiguous transfer outcomes and financial outbox rows that have stopped moving.
|
||||
* These are visibility alerts only: neither state is changed and no transfer is retried.
|
||||
*/
|
||||
async checkFinancialReviewRows() {
|
||||
if (!this.db) return;
|
||||
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM payouts
|
||||
WHERE status = 'needs_review') AS payout_review_count,
|
||||
(SELECT COUNT(*) FROM payment_refunds
|
||||
WHERE status = 'needs_review') AS refund_review_count,
|
||||
(SELECT COUNT(*) FROM payouts
|
||||
WHERE status = 'processing'
|
||||
AND COALESCE(last_retry_at, created_at)
|
||||
< NOW() - ($1::bigint * INTERVAL '1 millisecond'))
|
||||
AS stale_payout_processing_count,
|
||||
(SELECT COUNT(*) FROM payment_refunds
|
||||
WHERE status IN ('requested', 'processing')
|
||||
AND COALESCE(processing_started_at, requested_at, updated_at, created_at)
|
||||
< NOW() - ($1::bigint * INTERVAL '1 millisecond'))
|
||||
AS stale_refund_nonterminal_count
|
||||
`, [this.financialReviewStaleMs]);
|
||||
|
||||
const row = result.rows[0] || {};
|
||||
const payoutReviewCount = parseInt(row.payout_review_count) || 0;
|
||||
const refundReviewCount = parseInt(row.refund_review_count) || 0;
|
||||
const stalePayoutCount = parseInt(row.stale_payout_processing_count) || 0;
|
||||
const staleRefundCount = parseInt(row.stale_refund_nonterminal_count) || 0;
|
||||
const reviewCount = payoutReviewCount + refundReviewCount;
|
||||
const staleCount = stalePayoutCount + staleRefundCount;
|
||||
|
||||
if (reviewCount > 0) {
|
||||
await this.sendAlert('financial_needs_review', {
|
||||
subject: `🚨 Financial transfers need review: ${reviewCount}`,
|
||||
level: 'crit',
|
||||
body: `${payoutReviewCount} payout(s) and ${refundReviewCount} refund(s) are in needs_review. Do not retry them; reconcile against wallet history and transaction evidence.`,
|
||||
html: `
|
||||
<h2>Financial transfers require manual review</h2>
|
||||
<ul>
|
||||
<li><strong>Payouts:</strong> ${payoutReviewCount}</li>
|
||||
<li><strong>Refunds:</strong> ${refundReviewCount}</li>
|
||||
</ul>
|
||||
<p>Do not resend these transfers. Reconcile each row against wallet history and transaction evidence.</p>
|
||||
`
|
||||
});
|
||||
} else {
|
||||
await this.resolveAlert('financial_needs_review');
|
||||
}
|
||||
|
||||
if (staleCount > 0) {
|
||||
const staleMinutes = Math.ceil(this.financialReviewStaleMs / 60000);
|
||||
await this.sendAlert('financial_nonterminal_stale', {
|
||||
subject: `🚨 Stale financial transfers: ${staleCount}`,
|
||||
level: 'crit',
|
||||
body: `${stalePayoutCount} payout processing row(s) and ${staleRefundCount} refund requested/processing row(s) have not moved for at least ${staleMinutes} minutes. Treat processing as an ambiguous wallet outcome.`,
|
||||
html: `
|
||||
<h2>Financial transfers are stale</h2>
|
||||
<ul>
|
||||
<li><strong>Processing payouts:</strong> ${stalePayoutCount}</li>
|
||||
<li><strong>Requested/processing refunds:</strong> ${staleRefundCount}</li>
|
||||
<li><strong>Age threshold:</strong> ${staleMinutes} minutes</li>
|
||||
</ul>
|
||||
<p>A processing row may represent a broadcast transfer. Do not retry it until wallet reconciliation proves no transfer occurred.</p>
|
||||
`
|
||||
});
|
||||
} else {
|
||||
await this.resolveAlert('financial_nonterminal_stale');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check financial review rows for alerts:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert when a payout permanently fails.
|
||||
*/
|
||||
async alertPayoutFailed(payout) {
|
||||
const cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
const amountFormatted = formatAtomicFixed(payout.amount, cryptoType === 'WOW' ? 11 : 12, 6);
|
||||
|
||||
await this.sendAlert('payout_failed', {
|
||||
subject: `💀 Payout Permanently Failed - ${amountFormatted} ${cryptoType}`,
|
||||
level: 'crit',
|
||||
html: `
|
||||
<h2>Payout Permanently Failed</h2>
|
||||
<p>A payout has failed after all retry attempts:</p>
|
||||
<ul>
|
||||
<li><strong>Payout ID:</strong> ${payout.id}</li>
|
||||
<li><strong>Game ID:</strong> ${payout.game_id}</li>
|
||||
<li><strong>Amount:</strong> ${amountFormatted} ${cryptoType}</li>
|
||||
<li><strong>Address:</strong> ${payout.payout_address?.substring(0, 20)}...</li>
|
||||
<li><strong>Retry Count:</strong> ${payout.retry_count}</li>
|
||||
<li><strong>Last Error:</strong> ${payout.last_error || 'Unknown'}</li>
|
||||
</ul>
|
||||
<p>Manual intervention may be required.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every check once.
|
||||
*/
|
||||
async runChecks() {
|
||||
await this.checkWalletBalance();
|
||||
await this.checkWalletConnection();
|
||||
await this.checkPendingPayouts();
|
||||
await this.checkFinancialReviewRows();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic checking.
|
||||
*/
|
||||
startPeriodicChecks(intervalMs = 300000) { // Default 5 minutes
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delay the first pass so wallet and DB connections have time to come up.
|
||||
setTimeout(() => this.runChecks(), 10000);
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Database/process identity handshake for the isolated XMR stagenet canary.
|
||||
*
|
||||
* The endpoint is absent in every normal deployment. When explicitly enabled, it reads the
|
||||
* PostgreSQL cluster id, database OID, and database name through the application's own pool and
|
||||
* signs that exact tuple plus a caller challenge with a one-run nonce. The financial canary reads
|
||||
* the same tuple through its independent read-only connection and verifies the signature before it
|
||||
* is allowed to create an invoice or ask the funding wallet to broadcast.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
|
||||
const CANARY_HANDSHAKE_CONFIRM = 'I_AM_AN_ISOLATED_XMR_STAGENET_CANARY';
|
||||
const CANARY_HANDSHAKE_PROTOCOL = 'wowngeon-canary-database-identity-v1';
|
||||
const HEX_256 = /^[0-9a-f]{64}$/;
|
||||
const SIMPLE_DATABASE = /^[a-z0-9_]+$/i;
|
||||
|
||||
function invariant(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function isCanaryDatabaseName(name) {
|
||||
const value = String(name || '');
|
||||
return SIMPLE_DATABASE.test(value)
|
||||
&& /canary/i.test(value)
|
||||
&& /e2e/i.test(value)
|
||||
&& (/(?:^|_)direct(?:_|$)/i.test(value) || /(?:^|_)credits(?:_|$)/i.test(value));
|
||||
}
|
||||
|
||||
function isLoopbackAddress(address) {
|
||||
const value = String(address || '').toLowerCase();
|
||||
return value === '127.0.0.1'
|
||||
|| value === '::1'
|
||||
|| value === '::ffff:127.0.0.1';
|
||||
}
|
||||
|
||||
function readNonceFile(fileName, label = 'canary database nonce file') {
|
||||
invariant(typeof fileName === 'string' && fileName.startsWith('/'),
|
||||
`${label} must be an absolute path`);
|
||||
const descriptor = fs.openSync(fileName, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
||||
try {
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
invariant(stat.isFile(), `${label} must be a regular non-symlink file`);
|
||||
invariant((stat.mode & 0o077) === 0, `${label} must not be group/world accessible`);
|
||||
const nonce = fs.readFileSync(descriptor, 'utf8').trim();
|
||||
invariant(HEX_256.test(nonce), `${label} must contain exactly 256 bits of lowercase hex`);
|
||||
return nonce;
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdentity(value) {
|
||||
const identity = {
|
||||
clusterId: String(value?.cluster_id ?? value?.clusterId ?? ''),
|
||||
databaseOid: String(value?.database_oid ?? value?.databaseOid ?? ''),
|
||||
databaseName: String(value?.database_name ?? value?.databaseName ?? '')
|
||||
};
|
||||
invariant(/^\d+$/.test(identity.clusterId), 'database cluster identity is unavailable');
|
||||
invariant(/^\d+$/.test(identity.databaseOid), 'database OID is unavailable');
|
||||
invariant(SIMPLE_DATABASE.test(identity.databaseName), 'database name is invalid');
|
||||
return Object.freeze(identity);
|
||||
}
|
||||
|
||||
async function readDatabaseIdentity(db) {
|
||||
invariant(db && typeof db.query === 'function', 'database query interface is unavailable');
|
||||
const result = await db.query(`
|
||||
SELECT controls.system_identifier::text AS cluster_id,
|
||||
databases.oid::text AS database_oid,
|
||||
current_database() AS database_name
|
||||
FROM pg_catalog.pg_database AS databases
|
||||
CROSS JOIN pg_catalog.pg_control_system() AS controls
|
||||
WHERE databases.datname = current_database()
|
||||
`);
|
||||
invariant(result?.rows?.length === 1, 'database identity query returned an ambiguous result');
|
||||
return normalizeIdentity(result.rows[0]);
|
||||
}
|
||||
|
||||
function proofPayload(identity, challenge) {
|
||||
const normalized = normalizeIdentity(identity);
|
||||
invariant(HEX_256.test(String(challenge || '')), 'canary challenge must be 256-bit lowercase hex');
|
||||
return [
|
||||
CANARY_HANDSHAKE_PROTOCOL,
|
||||
normalized.clusterId,
|
||||
normalized.databaseOid,
|
||||
normalized.databaseName,
|
||||
challenge
|
||||
].join('\0');
|
||||
}
|
||||
|
||||
function computeProof(nonce, identity, challenge) {
|
||||
invariant(HEX_256.test(String(nonce || '')), 'canary nonce is invalid');
|
||||
return crypto.createHmac('sha256', Buffer.from(nonce, 'hex'))
|
||||
.update(proofPayload(identity, challenge), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function equalProof(actual, expected) {
|
||||
if (!HEX_256.test(String(actual || '')) || !HEX_256.test(String(expected || ''))) {
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(Buffer.from(actual, 'hex'), Buffer.from(expected, 'hex'));
|
||||
}
|
||||
|
||||
function configuredCanaryHandshake(db, env = process.env) {
|
||||
const relevant = [
|
||||
env.CANARY_DATABASE_HANDSHAKE,
|
||||
env.CANARY_DATABASE_NONCE_FILE,
|
||||
env.CANARY_EXPECT_DATABASE
|
||||
].some(value => String(value || '').trim() !== '');
|
||||
if (!relevant) return null;
|
||||
|
||||
invariant(env.CANARY_DATABASE_HANDSHAKE === CANARY_HANDSHAKE_CONFIRM,
|
||||
`CANARY_DATABASE_HANDSHAKE must equal ${CANARY_HANDSHAKE_CONFIRM}`);
|
||||
invariant(String(env.NODE_ENV || '').toLowerCase() === 'production',
|
||||
'canary database handshake requires NODE_ENV=production');
|
||||
invariant(String(env.CRYPTO_TYPE || '').toUpperCase() === 'XMR'
|
||||
&& String(env.MONERO_NETWORK || '').toLowerCase() === 'stagenet',
|
||||
'canary database handshake requires XMR stagenet');
|
||||
invariant(String(env.PORT || '') === '3102',
|
||||
'canary database handshake requires the dedicated application port 3102');
|
||||
|
||||
const expectedDatabase = String(env.CANARY_EXPECT_DATABASE || '').trim();
|
||||
invariant(isCanaryDatabaseName(expectedDatabase),
|
||||
'CANARY_EXPECT_DATABASE must be a scenario-named canary E2E database');
|
||||
invariant(String(env.DB_NAME || '').trim() === expectedDatabase,
|
||||
'application DB_NAME differs from CANARY_EXPECT_DATABASE');
|
||||
const nonce = readNonceFile(String(env.CANARY_DATABASE_NONCE_FILE || '').trim());
|
||||
|
||||
return Object.freeze({ db, expectedDatabase, nonce });
|
||||
}
|
||||
|
||||
function createCanaryDatabaseIdentityHandler({ db, env = process.env } = {}) {
|
||||
const configured = configuredCanaryHandshake(db, env);
|
||||
if (!configured) return null;
|
||||
|
||||
return async function canaryDatabaseIdentity(req, res) {
|
||||
// Use the TCP peer, never proxy-derived req.ip. Even an accidentally routed canary must
|
||||
// not disclose its PostgreSQL identity to a non-loopback caller.
|
||||
if (!isLoopbackAddress(req.socket?.remoteAddress)) {
|
||||
return res.status(404).json({ error: 'Not found.' });
|
||||
}
|
||||
const challenge = String(req.get('X-Canary-Database-Challenge') || '');
|
||||
if (!HEX_256.test(challenge)) {
|
||||
return res.status(400).json({ error: 'A valid canary database challenge is required.' });
|
||||
}
|
||||
|
||||
const identity = await readDatabaseIdentity(configured.db);
|
||||
if (identity.databaseName !== configured.expectedDatabase) {
|
||||
return res.status(503).json({ error: 'Canary database identity mismatch.' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
protocol: CANARY_HANDSHAKE_PROTOCOL,
|
||||
challenge,
|
||||
database: identity,
|
||||
proof: computeProof(configured.nonce, identity, challenge)
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CANARY_HANDSHAKE_CONFIRM,
|
||||
CANARY_HANDSHAKE_PROTOCOL,
|
||||
computeProof,
|
||||
configuredCanaryHandshake,
|
||||
createCanaryDatabaseIdentityHandler,
|
||||
equalProof,
|
||||
isCanaryDatabaseName,
|
||||
isLoopbackAddress,
|
||||
normalizeIdentity,
|
||||
readDatabaseIdentity,
|
||||
readNonceFile
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
const { DEFAULT_CATALOG } = require('../multiplayer/entitlements');
|
||||
|
||||
/**
|
||||
* Loads the operator-owned cosmetic catalog (migration 024 `cosmetic_catalog`): the single
|
||||
* server-authoritative source for pack definitions + unlock rules, replacing the split-brain
|
||||
* hardcoded PACKS in entitlements.js (server) and assetPacks.js (client, via the served snapshot).
|
||||
*
|
||||
* Cached with a short TTL. Falls back to the built-in DEFAULT_CATALOG when the table is absent
|
||||
* (fresh/partly-migrated DBs, tests) so nothing regresses without it.
|
||||
*/
|
||||
class CatalogService {
|
||||
constructor({ db = null, ttlMs = 60000 } = {}) {
|
||||
this.db = db;
|
||||
this.ttlMs = ttlMs;
|
||||
this._cache = null;
|
||||
this._cacheAt = 0;
|
||||
}
|
||||
|
||||
async getCatalog() {
|
||||
const now = Date.now();
|
||||
if (this._cache && (now - this._cacheAt) < this.ttlMs) return this._cache;
|
||||
const loaded = await this._load();
|
||||
this._cache = loaded;
|
||||
this._cacheAt = now;
|
||||
return loaded;
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this._cache = null;
|
||||
this._cacheAt = 0;
|
||||
}
|
||||
|
||||
async _load() {
|
||||
if (!this.db || typeof this.db.query !== 'function') return DEFAULT_CATALOG;
|
||||
try {
|
||||
const res = await this.db.query(`
|
||||
SELECT pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, active, metadata
|
||||
FROM cosmetic_catalog
|
||||
WHERE active = TRUE
|
||||
ORDER BY sort_order, pack_id
|
||||
`);
|
||||
const rows = (res && res.rows) || [];
|
||||
if (rows.length === 0) return DEFAULT_CATALOG;
|
||||
const out = {};
|
||||
for (const r of rows) {
|
||||
// Defend against non-catalog rows (e.g. a mock/query returning grant rows, which
|
||||
// have pack_id but no label). Real catalog rows have both NOT-NULL fields.
|
||||
if (!r || typeof r.pack_id !== 'string' || !r.pack_id || typeof r.label !== 'string' || !r.label) continue;
|
||||
const tier = Number(r.tier || 0);
|
||||
const minCredits = r.unlock_min_credits == null ? null : Number(r.unlock_min_credits);
|
||||
const grantOnly = !!r.grant_only;
|
||||
const premium = !(tier === 0 && minCredits == null && !grantOnly);
|
||||
out[r.pack_id] = {
|
||||
id: r.pack_id,
|
||||
label: r.label,
|
||||
kind: r.kind || 'render-pack',
|
||||
projection: r.projection || null,
|
||||
tier,
|
||||
unlockMinCredits: minCredits,
|
||||
grantOnly,
|
||||
premium,
|
||||
metadata: r.metadata || {}
|
||||
};
|
||||
}
|
||||
if (Object.keys(out).length === 0) return DEFAULT_CATALOG; // no valid catalog rows
|
||||
return out;
|
||||
} catch (err) {
|
||||
// Table missing (fresh/partly-migrated DB): degrade to the built-in default.
|
||||
if (err && (err.code === '42P01' || /cosmetic_catalog/i.test(err.message || ''))) {
|
||||
return DEFAULT_CATALOG;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CatalogService;
|
||||
@@ -0,0 +1,458 @@
|
||||
'use strict';
|
||||
|
||||
const { getProfile } = require('../chain/chainProfile');
|
||||
const money = require('../money/atomic');
|
||||
|
||||
const PLACEHOLDER = /(change[_-]?me|replace[_-]?with|example|password|secret)/i;
|
||||
const ACCOUNT_REF = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const DELIVERY_CONTRACT = Object.freeze({
|
||||
guarantee: 'at-least-once',
|
||||
deduplication: 'Idempotency-Key equals the immutable financial event id',
|
||||
acknowledgement: 'delivery is complete only after a 2xx response is durably recorded'
|
||||
});
|
||||
|
||||
function boundedInteger(value, fallback, min, max) {
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isInteger(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
|
||||
}
|
||||
|
||||
function isoTimestamp(value) {
|
||||
if (value == null || (typeof value === 'string' && !value.trim())) {
|
||||
throw new Error('Financial event has no valid timestamp');
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) throw new Error('Financial event has no valid timestamp');
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function safeText(value, maxLength = 300) {
|
||||
return String(value ?? '').slice(0, maxLength);
|
||||
}
|
||||
|
||||
function parseJsonObject(value, label) {
|
||||
let parsed = value;
|
||||
if (typeof parsed === 'string') {
|
||||
try { parsed = JSON.parse(parsed); } catch (_) { throw new Error(`${label} is not valid JSON`); }
|
||||
}
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
throw new Error(`${label} is not an object`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sourceSnapshotFromPayment(payment) {
|
||||
if (!payment || payment.id == null) throw new Error('Payment identity is required');
|
||||
return {
|
||||
schema: 'financial-event-source/v1',
|
||||
event_id: `payment-confirmed-${payment.id}`,
|
||||
event_kind: 'payment.confirmed',
|
||||
source_type: 'payment',
|
||||
source_id: payment.id,
|
||||
aggregate_id: payment.id,
|
||||
occurred_at: payment.confirmed_at,
|
||||
atomic_amount: String(payment.received_amount ?? payment.expected_amount ?? 0),
|
||||
payment_type: safeText(payment.payment_type, 40),
|
||||
product_id: safeText(payment.product_id, 100),
|
||||
provider_id: safeText(payment.provider_id, 100),
|
||||
receipt_count: Array.isArray(payment.receipts) ? payment.receipts.length : 0
|
||||
};
|
||||
}
|
||||
|
||||
function networkIdentity(profile, env) {
|
||||
const network = safeText(env.MONERO_NETWORK || 'mainnet', 30).trim().toLowerCase();
|
||||
return profile.id === 'WOW' ? 'wownero:mainnet' : `monero:${network}`;
|
||||
}
|
||||
|
||||
function buildFinancialEvent(sourceOrPayment, env = process.env) {
|
||||
const source = sourceOrPayment?.schema === 'financial-event-source/v1'
|
||||
? parseJsonObject(sourceOrPayment, 'Financial event source snapshot')
|
||||
: sourceSnapshotFromPayment(sourceOrPayment);
|
||||
if (!source.event_id || !source.event_kind || source.source_id == null) {
|
||||
throw new Error('Financial event source identity is incomplete');
|
||||
}
|
||||
|
||||
const profile = getProfile(env.CRYPTO_TYPE || 'WOW');
|
||||
const atomic = money.toBig(source.atomic_amount ?? 0);
|
||||
const isRefund = source.event_kind === 'payment.refund';
|
||||
if ((!isRefund && atomic <= 0n) || (isRefund && atomic < 0n)) {
|
||||
throw new Error('Financial event has an invalid atomic amount');
|
||||
}
|
||||
if (!isRefund && source.event_kind !== 'payment.confirmed') {
|
||||
throw new Error('Unsupported financial event kind');
|
||||
}
|
||||
|
||||
const accountRef = String(env.FINANCIAL_EVENT_ACCOUNT_REF || 'wowngeon:receipts').trim();
|
||||
if (!ACCOUNT_REF.test(accountRef)) throw new Error('Invalid non-PII financial event account reference');
|
||||
const refundStatus = safeText(source.refund_status, 20);
|
||||
if (isRefund && !['recorded', 'completed'].includes(refundStatus)) {
|
||||
throw new Error('Refund snapshot is not in a final exportable state');
|
||||
}
|
||||
|
||||
const sourceRef = isRefund
|
||||
? `payment-refund-${source.source_id}`
|
||||
: `payment-${source.aggregate_id}`;
|
||||
const event = {
|
||||
schema: 'financial-event/v1',
|
||||
id: safeText(source.event_id, 120),
|
||||
occurred_at: isoTimestamp(source.occurred_at),
|
||||
producer: 'wowngeon',
|
||||
product: 'wowngeon',
|
||||
activity: isRefund
|
||||
? (refundStatus === 'completed' ? 'customer-refund' : 'customer-payment-reversal')
|
||||
: 'customer-payment',
|
||||
account_refs: [accountRef],
|
||||
provenance: [{
|
||||
kind: isRefund ? 'wowngeon-refund' : 'wowngeon-payment',
|
||||
ref: sourceRef
|
||||
}],
|
||||
amounts: {},
|
||||
legs: [{
|
||||
asset: profile.symbol,
|
||||
quantity: money.format(atomic, profile.decimals),
|
||||
direction: isRefund ? 'out' : 'in',
|
||||
network: networkIdentity(profile, env),
|
||||
account_ref: accountRef,
|
||||
usd_value: '0',
|
||||
price_source: '',
|
||||
price_confidence: ''
|
||||
}],
|
||||
memo: isRefund
|
||||
? 'Final customer refund/reversal; USD valuation is required before accounting confirmation.'
|
||||
: 'Confirmed customer payment; USD valuation is required before accounting confirmation.',
|
||||
attributes: {
|
||||
valuation_required: true,
|
||||
atomic_amount: atomic.toString(),
|
||||
atomic_decimals: profile.decimals,
|
||||
source_type: safeText(source.source_type, 32),
|
||||
source_id: safeText(source.source_id, 40),
|
||||
aggregate_id: safeText(source.aggregate_id, 40)
|
||||
}
|
||||
};
|
||||
|
||||
if (isRefund) {
|
||||
event.attributes.refund_status = refundStatus;
|
||||
} else {
|
||||
event.attributes.payment_type = safeText(source.payment_type, 40);
|
||||
event.attributes.product_id = safeText(source.product_id, 100);
|
||||
event.attributes.provider_id = safeText(source.provider_id, 100);
|
||||
event.attributes.receipt_count = boundedInteger(source.receipt_count, 0, 0, 1000000);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
function countValue(row, name) {
|
||||
const value = Number.parseInt(String(row?.[name] ?? '0'), 10);
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
class FinancialEventExporter {
|
||||
constructor({ db, env = process.env, fetchFn = global.fetch, logger = console } = {}) {
|
||||
if (!db) throw new Error('FinancialEventExporter requires db');
|
||||
this.db = db;
|
||||
this.env = env;
|
||||
this.fetchFn = fetchFn;
|
||||
this.logger = logger;
|
||||
this.endpoint = String(env.FINANCIAL_EVENT_SINK_URL || '').trim();
|
||||
this.token = String(env.FINANCIAL_EVENT_SINK_TOKEN || '').trim();
|
||||
this.enabled = Boolean(this.endpoint || this.token);
|
||||
this.intervalMs = boundedInteger(env.FINANCIAL_EVENT_POLL_MS, 60000, 5000, 3600000);
|
||||
this.batchSize = boundedInteger(env.FINANCIAL_EVENT_BATCH_SIZE, 20, 1, 100);
|
||||
this.maxAttempts = boundedInteger(env.FINANCIAL_EVENT_MAX_ATTEMPTS, 8, 1, 100);
|
||||
this.running = false;
|
||||
this.timer = null;
|
||||
this.profile = getProfile(env.CRYPTO_TYPE || 'WOW');
|
||||
this.network = safeText(env.MONERO_NETWORK || 'mainnet', 30).trim().toLowerCase();
|
||||
this.exportAllowed = this.network === 'mainnet';
|
||||
this.health = {
|
||||
contract: DELIVERY_CONTRACT.guarantee,
|
||||
enabled: this.enabled,
|
||||
exportAllowed: this.exportAllowed,
|
||||
running: false,
|
||||
runs: 0,
|
||||
attempted: 0,
|
||||
delivered: 0,
|
||||
deferred: 0,
|
||||
deadLettered: 0,
|
||||
ignored: 0,
|
||||
backlog: 0,
|
||||
deadLetters: 0,
|
||||
ignoredRows: 0,
|
||||
lastRunAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null
|
||||
};
|
||||
|
||||
if (Boolean(this.endpoint) !== Boolean(this.token)) {
|
||||
throw new Error('Financial event sink URL and token must be configured together');
|
||||
}
|
||||
if (!this.enabled) return;
|
||||
if (typeof this.fetchFn !== 'function') throw new Error('Financial event HTTP transport is unavailable');
|
||||
let parsed;
|
||||
try { parsed = new URL(this.endpoint); } catch (_) { throw new Error('Financial event sink URL is invalid'); }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)
|
||||
|| (String(env.NODE_ENV).toLowerCase() === 'production' && parsed.protocol !== 'https:')) {
|
||||
throw new Error('Financial event sink must use HTTPS in production');
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error('Financial event sink URL must not embed credentials');
|
||||
}
|
||||
if (this.token.length < 32 || PLACEHOLDER.test(this.token)) {
|
||||
throw new Error('Financial event sink token must be a strong non-placeholder secret');
|
||||
}
|
||||
}
|
||||
|
||||
getDeliveryContract() {
|
||||
return { ...DELIVERY_CONTRACT };
|
||||
}
|
||||
|
||||
getHealth() {
|
||||
return { ...this.health, running: this.running };
|
||||
}
|
||||
|
||||
// Safe for the unauthenticated liveness payload: no endpoint, token, account reference,
|
||||
// row/event identifiers, error text, or timestamps. These counters are informational and
|
||||
// do not participate in application readiness.
|
||||
getPublicHealth() {
|
||||
const health = this.getHealth();
|
||||
return {
|
||||
contract: DELIVERY_CONTRACT.guarantee,
|
||||
enabled: health.enabled,
|
||||
exportAllowed: health.exportAllowed,
|
||||
running: health.running,
|
||||
backlog: health.backlog,
|
||||
deadLetters: health.deadLetters,
|
||||
ignoredRows: health.ignoredRows,
|
||||
delivered: health.delivered,
|
||||
deferred: health.deferred,
|
||||
deadLettered: health.deadLettered,
|
||||
ignored: health.ignored
|
||||
};
|
||||
}
|
||||
|
||||
async _suppressUnsafeNetwork() {
|
||||
const result = await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'ignored', lease_until = NULL, delivered_at = NULL,
|
||||
ignored_at = NOW(), ignored_reason = 'non_mainnet_network',
|
||||
dead_lettered_at = NULL, last_error = NULL, updated_at = NOW()
|
||||
WHERE status IN ('pending', 'in_flight', 'dead_letter')
|
||||
RETURNING id
|
||||
`);
|
||||
return result.rowCount ?? result.rows?.length ?? 0;
|
||||
}
|
||||
|
||||
async _deadLetterExhausted() {
|
||||
const result = await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'dead_letter', lease_until = NULL,
|
||||
dead_lettered_at = COALESCE(dead_lettered_at, NOW()),
|
||||
last_error = COALESCE(last_error, 'Maximum delivery attempts exhausted'),
|
||||
updated_at = NOW()
|
||||
WHERE attempts >= $1
|
||||
AND (status = 'pending' OR (status = 'in_flight' AND lease_until < NOW()))
|
||||
RETURNING id
|
||||
`, [this.maxAttempts]);
|
||||
return result.rowCount ?? result.rows?.length ?? 0;
|
||||
}
|
||||
|
||||
async _claim() {
|
||||
const result = await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'in_flight', attempts = attempts + 1,
|
||||
lease_until = NOW() + INTERVAL '5 minutes', updated_at = NOW()
|
||||
WHERE id = (
|
||||
SELECT id FROM financial_event_outbox
|
||||
WHERE delivered_at IS NULL
|
||||
AND next_attempt_at <= NOW()
|
||||
AND attempts < $1
|
||||
AND (status = 'pending' OR (status = 'in_flight' AND lease_until < NOW()))
|
||||
ORDER BY created_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, event_id, event_kind, source_type, source_id, aggregate_id,
|
||||
payload_snapshot, delivery_payload, attempts
|
||||
`, [this.maxAttempts]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async _ensureDeliveryPayload(claim) {
|
||||
if (claim.delivery_payload) {
|
||||
const existing = parseJsonObject(claim.delivery_payload, 'Stored financial event payload');
|
||||
if (existing.id !== claim.event_id) throw new Error('Stored financial event id is inconsistent');
|
||||
return existing;
|
||||
}
|
||||
|
||||
const snapshot = parseJsonObject(claim.payload_snapshot, 'Financial event source snapshot');
|
||||
if (snapshot.event_id !== claim.event_id || snapshot.event_kind !== claim.event_kind) {
|
||||
throw new Error('Financial event source identity is inconsistent');
|
||||
}
|
||||
const payload = buildFinancialEvent(snapshot, this.env);
|
||||
const result = await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET delivery_payload = $2::jsonb, updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'in_flight' AND delivery_payload IS NULL
|
||||
RETURNING delivery_payload
|
||||
`, [claim.id, JSON.stringify(payload)]);
|
||||
if (!result.rows[0]?.delivery_payload) {
|
||||
throw new Error('Financial event payload could not be durably initialized');
|
||||
}
|
||||
return parseJsonObject(result.rows[0].delivery_payload, 'Stored financial event payload');
|
||||
}
|
||||
|
||||
async _deliver(claim) {
|
||||
const payload = await this._ensureDeliveryPayload(claim);
|
||||
const response = await this.fetchFn(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${this.token}`,
|
||||
'content-type': 'application/json',
|
||||
'idempotency-key': claim.event_id
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(`Financial event sink returned HTTP ${response?.status || 0}`);
|
||||
}
|
||||
const result = await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'delivered', delivered_at = NOW(), lease_until = NULL,
|
||||
last_error = NULL, updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'in_flight'
|
||||
RETURNING id
|
||||
`, [claim.id]);
|
||||
if ((result.rowCount ?? result.rows?.length ?? 0) !== 1) {
|
||||
throw new Error('Financial event delivery acknowledgement was not durably recorded');
|
||||
}
|
||||
}
|
||||
|
||||
async _retry(claim, error) {
|
||||
const message = safeText(error?.message || 'Financial event delivery failed', 500);
|
||||
if (Number(claim.attempts || 0) >= this.maxAttempts) {
|
||||
const result = await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'dead_letter', lease_until = NULL,
|
||||
dead_lettered_at = NOW(), last_error = $2, updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'in_flight'
|
||||
RETURNING id
|
||||
`, [claim.id, message]);
|
||||
const changed = (result.rowCount ?? result.rows?.length ?? 0) === 1;
|
||||
if (changed) this.logger.warn?.(`[FinancialEvent] Delivery ${claim.id} moved to dead letter: ${message}`);
|
||||
return { deadLettered: changed };
|
||||
}
|
||||
|
||||
const seconds = Math.min(3600, 30 * (2 ** Math.min(Number(claim.attempts || 1) - 1, 7)));
|
||||
await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'pending', lease_until = NULL,
|
||||
next_attempt_at = NOW() + ($2::integer * INTERVAL '1 second'),
|
||||
last_error = $3, updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'in_flight'
|
||||
`, [claim.id, seconds, message]);
|
||||
this.logger.warn?.(`[FinancialEvent] Delivery ${claim.id} deferred: ${message}`);
|
||||
return { deadLettered: false };
|
||||
}
|
||||
|
||||
async _refreshHealth() {
|
||||
const result = await this.db.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status IN ('pending', 'in_flight')) AS backlog,
|
||||
COUNT(*) FILTER (WHERE status = 'dead_letter') AS dead_letters,
|
||||
COUNT(*) FILTER (WHERE status = 'ignored') AS ignored_rows
|
||||
FROM financial_event_outbox
|
||||
`);
|
||||
const row = result.rows[0] || {};
|
||||
this.health.backlog = countValue(row, 'backlog');
|
||||
this.health.deadLetters = countValue(row, 'dead_letters');
|
||||
this.health.ignoredRows = countValue(row, 'ignored_rows');
|
||||
}
|
||||
|
||||
async runOnce() {
|
||||
if (this.running) {
|
||||
return { skipped: true, delivered: 0, deferred: 0, deadLettered: 0, ignored: 0 };
|
||||
}
|
||||
this.running = true;
|
||||
this.health.running = true;
|
||||
this.health.runs += 1;
|
||||
this.health.lastRunAt = new Date().toISOString();
|
||||
let delivered = 0;
|
||||
let deferred = 0;
|
||||
let deadLettered = 0;
|
||||
let ignored = 0;
|
||||
let succeeded = false;
|
||||
let healthRefreshed = true;
|
||||
try {
|
||||
if (!this.exportAllowed) {
|
||||
ignored = await this._suppressUnsafeNetwork();
|
||||
this.health.ignored += ignored;
|
||||
succeeded = true;
|
||||
return { skipped: false, delivered, deferred, deadLettered, ignored };
|
||||
}
|
||||
if (!this.enabled) {
|
||||
succeeded = true;
|
||||
return { skipped: true, delivered, deferred, deadLettered, ignored };
|
||||
}
|
||||
|
||||
deadLettered += await this._deadLetterExhausted();
|
||||
for (let index = 0; index < this.batchSize; index += 1) {
|
||||
const claim = await this._claim();
|
||||
if (!claim) break;
|
||||
this.health.attempted += 1;
|
||||
try {
|
||||
await this._deliver(claim);
|
||||
delivered += 1;
|
||||
} catch (error) {
|
||||
const retry = await this._retry(claim, error);
|
||||
if (retry.deadLettered) deadLettered += 1;
|
||||
else deferred += 1;
|
||||
}
|
||||
}
|
||||
this.health.delivered += delivered;
|
||||
this.health.deferred += deferred;
|
||||
this.health.deadLettered += deadLettered;
|
||||
succeeded = true;
|
||||
return { skipped: false, delivered, deferred, deadLettered, ignored };
|
||||
} catch (error) {
|
||||
this.health.lastError = safeText(error?.message || error, 500);
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await this._refreshHealth();
|
||||
} catch (healthError) {
|
||||
healthRefreshed = false;
|
||||
this.health.lastError = safeText(healthError?.message || healthError, 500);
|
||||
this.logger.warn?.(`[FinancialEvent] Health counters unavailable: ${this.health.lastError}`);
|
||||
}
|
||||
if (succeeded && healthRefreshed) {
|
||||
this.health.lastSuccessAt = new Date().toISOString();
|
||||
this.health.lastError = null;
|
||||
}
|
||||
this.running = false;
|
||||
this.health.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
// Polls even without a sink configured: public health must report a real backlog, and
|
||||
// non-mainnet deployments must continuously suppress newly appended rows.
|
||||
if (this.timer) return false;
|
||||
this.runOnce().catch((error) => this.logger.warn?.(`[FinancialEvent] Export tick failed: ${error.message}`));
|
||||
this.timer = setInterval(() => {
|
||||
this.runOnce().catch((error) => this.logger.warn?.(`[FinancialEvent] Export tick failed: ${error.message}`));
|
||||
}, this.intervalMs);
|
||||
this.timer.unref?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FinancialEventExporter;
|
||||
module.exports.buildFinancialEvent = buildFinancialEvent;
|
||||
module.exports.sourceSnapshotFromPayment = sourceSnapshotFromPayment;
|
||||
module.exports.DELIVERY_CONTRACT = DELIVERY_CONTRACT;
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Build the public, non-sensitive health payload used by probes and the status UI. */
|
||||
function buildPublicHealth(input = {}) {
|
||||
const releaseCandidate = input.releaseIdentity;
|
||||
const releaseValid = releaseCandidate?.verified === true
|
||||
&& /^git-[0-9a-f]{12}$/.test(releaseCandidate.id || '')
|
||||
&& /^[0-9a-f]{40}$/.test(releaseCandidate.commit || '')
|
||||
&& releaseCandidate.id.slice(4) === releaseCandidate.commit.slice(0, 12);
|
||||
const release = releaseValid ? {
|
||||
verified: true,
|
||||
id: releaseCandidate.id,
|
||||
commit: releaseCandidate.commit
|
||||
} : {
|
||||
verified: false,
|
||||
id: null,
|
||||
commit: null
|
||||
};
|
||||
const sanitizeIdentity = (identity, required) => {
|
||||
const pair = (value) => value && typeof value === 'object' ? {
|
||||
cryptoType: typeof value.cryptoType === 'string' ? value.cryptoType : null,
|
||||
network: typeof value.network === 'string' ? value.network : null
|
||||
} : null;
|
||||
return {
|
||||
required: Boolean(required),
|
||||
verified: identity?.verified === true,
|
||||
expected: pair(identity?.expected),
|
||||
actual: pair(identity?.actual)
|
||||
};
|
||||
};
|
||||
const databaseReady = Boolean(input.databaseReady);
|
||||
const hasBlock = Number(input.blockHeight) > 0;
|
||||
const paymentsEnabled = Boolean(input.paymentsEnabled);
|
||||
const payoutsEnabled = Boolean(input.payoutsEnabled);
|
||||
const walletRequired = input.walletRequired === undefined
|
||||
? (paymentsEnabled || payoutsEnabled)
|
||||
: Boolean(input.walletRequired);
|
||||
const identityRequired = Boolean(input.identityRequired);
|
||||
const daemonIdentity = input.daemonIdentity || input.chainIdentity || null;
|
||||
const daemonIdentityRequired = identityRequired && !Boolean(input.simulatedBlocks);
|
||||
const walletIdentityRequired = identityRequired && walletRequired;
|
||||
const chainIdentityReady = !daemonIdentityRequired || daemonIdentity?.verified === true;
|
||||
const walletIdentityReady = !walletIdentityRequired || input.walletIdentity?.verified === true;
|
||||
const chainReady = Boolean(input.simulatedBlocks)
|
||||
|| (hasBlock && input.chainHealthy !== false && chainIdentityReady);
|
||||
const walletReady = !walletRequired
|
||||
|| (Boolean(input.walletHealthy) && walletIdentityReady);
|
||||
// The composition root supplies false until every startup financial reconciliation pass
|
||||
// has committed or proved there was nothing to do. Default true keeps this helper usable by
|
||||
// callers that do not own durable financial state.
|
||||
const financialRecoveryReady = input.financialRecoveryReady !== false;
|
||||
const ready = databaseReady && chainReady && walletReady && financialRecoveryReady;
|
||||
|
||||
const blockHeight = Number(input.blockHeight || 0);
|
||||
const network = daemonIdentity?.verified === true && daemonIdentity?.actual?.network
|
||||
? daemonIdentity.actual.network
|
||||
: (input.network || 'mainnet');
|
||||
const gameMode = input.gameMode || 'FREE';
|
||||
|
||||
return {
|
||||
status: ready ? 'ok' : 'degraded',
|
||||
ready,
|
||||
timestamp: new Date(input.now || Date.now()).toISOString(),
|
||||
uptime: Number(input.uptime || 0),
|
||||
checks: {
|
||||
database: databaseReady ? 'up' : 'down',
|
||||
chain: chainReady ? 'up' : 'down',
|
||||
wallet: walletRequired ? (walletReady ? 'up' : 'down') : 'not_required',
|
||||
financialRecovery: financialRecoveryReady ? 'reconciled' : 'pending',
|
||||
daemonIdentity: daemonIdentityRequired
|
||||
? (chainIdentityReady ? 'verified' : 'unverified')
|
||||
: 'not_required',
|
||||
walletIdentity: walletIdentityRequired
|
||||
? (walletIdentityReady ? 'verified' : 'unverified')
|
||||
: 'not_required'
|
||||
},
|
||||
chain: {
|
||||
height: blockHeight,
|
||||
network,
|
||||
source: input.blockSource || 'daemon'
|
||||
},
|
||||
games: {
|
||||
active: Number(input.activeGames || 0),
|
||||
queued: Number(input.queuedGames || 0),
|
||||
connected: Number(input.connectedPlayers || 0),
|
||||
mode: gameMode
|
||||
},
|
||||
money: {
|
||||
paymentsEnabled,
|
||||
payoutsEnabled
|
||||
},
|
||||
release,
|
||||
identities: {
|
||||
daemon: sanitizeIdentity(daemonIdentity, daemonIdentityRequired),
|
||||
wallet: sanitizeIdentity(input.walletIdentity, walletIdentityRequired)
|
||||
},
|
||||
// Compatibility aliases used by existing smoke probes and lightweight status clients.
|
||||
blockHeight,
|
||||
network,
|
||||
gameMode,
|
||||
paymentsEnabled,
|
||||
walletHealthy: walletRequired ? walletReady : Boolean(input.walletHealthy)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildPublicHealth };
|
||||
@@ -0,0 +1,124 @@
|
||||
const money = require('../money/atomic');
|
||||
|
||||
function boundedInteger(value, fallback, min, max) {
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isInteger(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds recently expired invoices whose transfer may have mined after the browser watcher
|
||||
* stopped. It never confirms a payment or applies an entitlement: valid durable receipts are
|
||||
* attached to the expired payment and surfaced through payment_late_reviews for manual handling.
|
||||
*/
|
||||
class LatePaymentReconciler {
|
||||
constructor({ db, gameModeManager, walletService, logger = console,
|
||||
lookbackHours = 72, batchSize = 100 } = {}) {
|
||||
this.db = db;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.walletService = walletService;
|
||||
this.logger = logger;
|
||||
this.lookbackHours = boundedInteger(lookbackHours, 72, 1, 168);
|
||||
this.batchSize = boundedInteger(batchSize, 100, 1, 500);
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
async _nativeStatus(payment) {
|
||||
if (!this.walletService || typeof this.walletService.checkPaymentStatus !== 'function') {
|
||||
throw new Error('Native wallet receipt checker is unavailable');
|
||||
}
|
||||
const address = payment.subaddress;
|
||||
const priorUser = this.walletService.addressToUser?.get(address);
|
||||
const priorSocket = this.walletService.addressToSocket?.get(address);
|
||||
this.walletService.addressToUser?.set(address, {
|
||||
userId: String(payment.user_id),
|
||||
socketId: `late-reconcile:${payment.id}`,
|
||||
amount: money.toSafe(money.toBig(payment.expected_amount)),
|
||||
addressIndex: payment.address_index,
|
||||
accountIndex: this.walletService.accountIndex ?? 0,
|
||||
detected: false,
|
||||
confirmed: false,
|
||||
status: 'expired'
|
||||
});
|
||||
try {
|
||||
return await this.walletService.checkPaymentStatus(address);
|
||||
} finally {
|
||||
if (priorUser) this.walletService.addressToUser?.set(address, priorUser);
|
||||
else this.walletService.addressToUser?.delete(address);
|
||||
if (priorSocket !== undefined) this.walletService.addressToSocket?.set(address, priorSocket);
|
||||
else this.walletService.addressToSocket?.delete(address);
|
||||
}
|
||||
}
|
||||
|
||||
async _status(payment) {
|
||||
const providerId = payment.provider_id || 'native-monero';
|
||||
if (providerId === 'native-monero') return this._nativeStatus(payment);
|
||||
const provider = this.gameModeManager?.paymentProviders?.get?.(providerId);
|
||||
if (!provider || typeof provider.getWalletStatus !== 'function') {
|
||||
throw new Error(`Payment provider ${providerId} cannot reconcile invoice status`);
|
||||
}
|
||||
return provider.getWalletStatus({
|
||||
invoiceId: payment.provider_invoice_id,
|
||||
address: payment.subaddress
|
||||
});
|
||||
}
|
||||
|
||||
_stopExpiredWatch(payment) {
|
||||
const providerId = payment.provider_id || 'native-monero';
|
||||
const provider = this.gameModeManager?.paymentProviders?.get?.(providerId);
|
||||
const ref = providerId === 'native-monero'
|
||||
? payment.subaddress
|
||||
: (payment.provider_invoice_id || payment.subaddress);
|
||||
try { provider?.stopWatch?.(ref); } catch (_) { /* best effort */ }
|
||||
try { this.walletService?.stopPaymentMonitoring?.(payment.subaddress); } catch (_) { /* best effort */ }
|
||||
this.walletService?.addressToUser?.delete(payment.subaddress);
|
||||
this.walletService?.addressToSocket?.delete(payment.subaddress);
|
||||
}
|
||||
|
||||
async runOnce() {
|
||||
if (this.running) return { skipped: true, checked: 0, reviews: 0, errors: 0 };
|
||||
this.running = true;
|
||||
try {
|
||||
const candidates = await this.db.query(`
|
||||
SELECT id, user_id, status, subaddress, address_index, expected_amount,
|
||||
provider_id, provider_invoice_id, expires_at
|
||||
FROM payments
|
||||
WHERE status IN ('pending', 'expired')
|
||||
AND expires_at <= NOW()
|
||||
AND expires_at >= NOW() - ($1::integer * INTERVAL '1 hour')
|
||||
AND (late_receipt_checked_at IS NULL
|
||||
OR late_receipt_checked_at < NOW() - INTERVAL '5 minutes')
|
||||
ORDER BY late_receipt_checked_at ASC NULLS FIRST, expires_at ASC
|
||||
LIMIT $2
|
||||
`, [this.lookbackHours, this.batchSize]);
|
||||
|
||||
let checked = 0;
|
||||
let reviews = 0;
|
||||
let errors = 0;
|
||||
for (const payment of candidates.rows) {
|
||||
try {
|
||||
const status = await this._status(payment);
|
||||
const result = await this.gameModeManager.reconcileLatePaymentForReview(
|
||||
payment.id,
|
||||
Array.isArray(status?.receipts) ? status.receipts : []
|
||||
);
|
||||
if (result?.checked) {
|
||||
checked += 1;
|
||||
this._stopExpiredWatch(payment);
|
||||
}
|
||||
if (result?.needsReview) {
|
||||
reviews += 1;
|
||||
this.logger.warn?.(`[LatePayment] Payment ${payment.id} has durable late receipt evidence and requires review`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors += 1;
|
||||
this.logger.error?.(`[LatePayment] Reconciliation failed for payment ${payment.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
return { skipped: false, checked, reviews, errors };
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LatePaymentReconciler;
|
||||
@@ -0,0 +1,634 @@
|
||||
const { AppError, NotFoundError } = require('../utils/errors');
|
||||
|
||||
const MAX_ERROR_LENGTH = 2000;
|
||||
const TX_HASH_PATTERN = /^[0-9a-f]{64}$/i;
|
||||
|
||||
function parseAtomic(value, fieldName) {
|
||||
try {
|
||||
const parsed = BigInt(value == null ? 0 : value);
|
||||
if (parsed < 0n) {
|
||||
throw new Error('negative value');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new AppError(`Invalid ${fieldName} stored for payment refund`, {
|
||||
code: 'INVALID_REFUND_DATA',
|
||||
safeMessage: 'The payment record cannot be refunded automatically.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function jsonInteger(value) {
|
||||
const parsed = typeof value === 'bigint' ? value : BigInt(value || 0);
|
||||
return parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : parsed.toString();
|
||||
}
|
||||
|
||||
function errorMessage(error) {
|
||||
const message = error && typeof error.message === 'string'
|
||||
? error.message
|
||||
: 'Wallet returned an ambiguous refund result.';
|
||||
return message.slice(0, MAX_ERROR_LENGTH) || 'Wallet returned an ambiguous refund result.';
|
||||
}
|
||||
|
||||
function jsonObject(value) {
|
||||
if (value && typeof value === 'object') return value;
|
||||
if (typeof value !== 'string' || !value.trim()) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function packIds(value) {
|
||||
const source = Array.isArray(value) ? value : [];
|
||||
return [...new Set(source.map(item => {
|
||||
if (typeof item === 'string') return item.trim();
|
||||
return String(item?.id || item?.packId || item?.pack_id || '').trim();
|
||||
}).filter(Boolean))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable payment-refund state machine.
|
||||
*
|
||||
* Safety invariant: a wallet transfer is attempted only by the request that atomically
|
||||
* changes `requested` to `processing`. No code path changes `processing` back to
|
||||
* `requested`, so a crash or timeout can never cause an automatic second transfer.
|
||||
*/
|
||||
class PaymentRefundService {
|
||||
constructor({ db, walletService, logger = console, isSendEnabled = null } = {}) {
|
||||
if (!db) throw new Error('PaymentRefundService requires db');
|
||||
this.db = db;
|
||||
this.walletService = walletService;
|
||||
this.logger = logger;
|
||||
// The application injects its single payout master/readiness predicate. Keep the
|
||||
// service independently testable, but never let an admin refund become a second wallet
|
||||
// transfer path that bypasses PAYOUTS_ENABLED, startup recovery, or wallet health.
|
||||
this.isSendEnabled = typeof isSendEnabled === 'function' ? isSendEnabled : () => true;
|
||||
}
|
||||
|
||||
_assertSendEnabled() {
|
||||
if (!this.isSendEnabled()) {
|
||||
throw new AppError('Automatic refund transfer is disabled by the payout safety gate.', {
|
||||
statusCode: 503,
|
||||
code: 'PAYOUT_DISPATCH_DISABLED',
|
||||
safeMessage: 'Automatic refund sending is disabled. Record the refund without sending funds, or restore the payout safety gate first.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async refundPayment({ paymentId, reason = 'Admin refund', sendFunds = false }) {
|
||||
if (sendFunds === true) this._assertSendEnabled();
|
||||
const normalizedReason = typeof reason === 'string' && reason.trim()
|
||||
? reason.trim()
|
||||
: 'Admin refund';
|
||||
|
||||
const recorded = await this._recordOnce({
|
||||
paymentId,
|
||||
reason: normalizedReason,
|
||||
sendFunds: sendFunds === true
|
||||
});
|
||||
|
||||
let refund = recorded.refund;
|
||||
let walletClaimed = false;
|
||||
|
||||
// A recorded-only refund is terminal. An existing requested row may be safely
|
||||
// claimed after a crash that happened before any wallet call was started.
|
||||
if (sendFunds === true && refund.status === 'requested') {
|
||||
const attempt = await this._claimAndSend(paymentId);
|
||||
refund = attempt.refund;
|
||||
walletClaimed = attempt.claimed;
|
||||
}
|
||||
|
||||
return {
|
||||
payment: recorded.payment,
|
||||
refund,
|
||||
existing: recorded.existing,
|
||||
legacyImported: recorded.legacyImported,
|
||||
walletClaimed
|
||||
};
|
||||
}
|
||||
|
||||
async _recordOnce({ paymentId, reason, sendFunds }) {
|
||||
const client = await this.db.getClient();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// This is the serialization point for all refund requests for one payment.
|
||||
const paymentResult = await client.query(`
|
||||
SELECT p.*
|
||||
FROM payments p
|
||||
WHERE p.id = $1
|
||||
FOR UPDATE
|
||||
`, [paymentId]);
|
||||
|
||||
if (paymentResult.rows.length === 0) {
|
||||
throw new NotFoundError('Payment not found', {
|
||||
safeMessage: `Payment ${paymentId} not found.`
|
||||
});
|
||||
}
|
||||
|
||||
const payment = paymentResult.rows[0];
|
||||
const existingResult = await client.query(`
|
||||
SELECT *
|
||||
FROM payment_refunds
|
||||
WHERE payment_id = $1
|
||||
FOR UPDATE
|
||||
`, [paymentId]);
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
await client.query('COMMIT');
|
||||
return {
|
||||
payment,
|
||||
refund: existingResult.rows[0],
|
||||
existing: true,
|
||||
legacyImported: false
|
||||
};
|
||||
}
|
||||
|
||||
const receivedAmount = parseAtomic(payment.received_amount, 'received amount');
|
||||
const expectedAmount = parseAtomic(payment.expected_amount, 'expected amount');
|
||||
// Never pay an unconfirmed invoice merely because it has an expected amount.
|
||||
// Confirmed legacy rows may lack received_amount, hence the narrow fallback.
|
||||
const refundAmount = receivedAmount > 0n
|
||||
? receivedAmount
|
||||
: (payment.status === 'confirmed' ? expectedAmount : 0n);
|
||||
|
||||
let user = null;
|
||||
if (payment.user_id != null) {
|
||||
const userResult = await client.query(`
|
||||
SELECT id, credits, race_entries, total_credits_purchased,
|
||||
premium_level, payout_address
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [payment.user_id]);
|
||||
user = userResult.rows[0] || null;
|
||||
}
|
||||
|
||||
const payoutAddress = typeof user?.payout_address === 'string' && user.payout_address.trim()
|
||||
? user.payout_address.trim()
|
||||
: null;
|
||||
|
||||
// Historic refunded rows predate the outbox. Snapshot them as recorded, but
|
||||
// never deduct grants or send funds because their prior side effects cannot be
|
||||
// proven from the old schema.
|
||||
const legacyImported = payment.status === 'refunded';
|
||||
let grant = null;
|
||||
let creditsDeducted = 0n;
|
||||
let raceEntriesDeducted = 0;
|
||||
let packsRevoked = [];
|
||||
let premiumLevelRestored = null;
|
||||
let reviewReason = null;
|
||||
let lot = null;
|
||||
|
||||
if (!legacyImported && payment.status === 'confirmed') {
|
||||
const grantResult = await client.query(`
|
||||
SELECT *
|
||||
FROM payment_entitlement_grants
|
||||
WHERE payment_id = $1
|
||||
FOR UPDATE
|
||||
`, [paymentId]);
|
||||
grant = grantResult.rows[0] || null;
|
||||
|
||||
// Upgrade fallback for a confirmed product that predates migration 035. The
|
||||
// marker is still created under the payment lock before any reversal.
|
||||
if (!grant && ['credits_package', 'cosmetic_pack'].includes(payment.payment_type)) {
|
||||
const durable = jsonObject(payment.product_grants);
|
||||
const legacyCredits = parseAtomic(
|
||||
durable.credits ?? payment.credits_purchased ?? 0,
|
||||
'legacy credits grant'
|
||||
);
|
||||
const legacyRace = Number(durable.raceEntries ?? durable.race_entries ?? 0);
|
||||
const legacyPacks = Array.isArray(durable.packs) ? durable.packs : [];
|
||||
const legacyPremium = durable.premiumLevel || durable.premium_level || null;
|
||||
const inserted = await client.query(`
|
||||
INSERT INTO payment_entitlement_grants (
|
||||
payment_id, user_id, source, credits_granted,
|
||||
purchase_progress_granted, race_entries_granted, packs_granted,
|
||||
premium_level_granted, metadata
|
||||
) VALUES (
|
||||
$1, $2, 'legacy_product_backfill', $3::bigint, $3::bigint,
|
||||
$4, $5::jsonb, $6, $7::jsonb
|
||||
)
|
||||
ON CONFLICT (payment_id) DO NOTHING
|
||||
RETURNING *
|
||||
`, [
|
||||
paymentId,
|
||||
payment.user_id,
|
||||
legacyCredits.toString(),
|
||||
Number.isSafeInteger(legacyRace) && legacyRace > 0 ? legacyRace : 0,
|
||||
JSON.stringify(legacyPacks),
|
||||
legacyPremium,
|
||||
JSON.stringify({ runtimeLegacyBackfill: true })
|
||||
]);
|
||||
grant = inserted.rows[0] || null;
|
||||
}
|
||||
|
||||
// A linked game proves that a direct entry was consumed. Never send its coins
|
||||
// automatically; gameplay value and any payout liability require reconciliation.
|
||||
const consumedGame = await client.query(
|
||||
`SELECT id FROM games WHERE payment_id = $1 LIMIT 1`,
|
||||
[paymentId]
|
||||
);
|
||||
if (consumedGame.rows.length > 0) {
|
||||
reviewReason = 'Payment was consumed by a game; automatic refund is unsafe.';
|
||||
}
|
||||
}
|
||||
|
||||
const creditsGranted = grant ? parseAtomic(grant.credits_granted, 'credits grant') : 0n;
|
||||
const progressGranted = grant ? parseAtomic(grant.purchase_progress_granted, 'purchase progress grant') : 0n;
|
||||
const raceGranted = grant ? Number(grant.race_entries_granted || 0) : 0;
|
||||
const grantedPackIds = grant ? packIds(jsonObject({ value: grant.packs_granted }).value || grant.packs_granted) : [];
|
||||
// PostgreSQL returns JSONB arrays directly; string-backed test/adapter rows need parsing.
|
||||
const parsedPacks = typeof grant?.packs_granted === 'string'
|
||||
? (() => { try { return JSON.parse(grant.packs_granted); } catch (_) { return []; } })()
|
||||
: grant?.packs_granted;
|
||||
const actualPackIds = grant ? packIds(parsedPacks) : grantedPackIds;
|
||||
|
||||
if (!legacyImported && !reviewReason && payment.status !== 'confirmed') {
|
||||
reviewReason = 'Only a confirmed payment can be refunded automatically.';
|
||||
}
|
||||
// Direct-entry confirmation historically advanced lifetime purchase progression
|
||||
// outside the payment grant snapshot. Until that benefit has payment-scoped
|
||||
// provenance, neither a bare direct payment nor its recovery-credit marker can be
|
||||
// reversed exactly. Keep the coins in manual review instead of refunding while
|
||||
// leaving a tier/cosmetic unlock behind.
|
||||
if (!legacyImported && !reviewReason && payment.payment_type === 'single_game'
|
||||
&& (!grant || grant.source === 'single_game_recovery')) {
|
||||
reviewReason = 'Direct-entry purchase progression requires manual reconciliation.';
|
||||
}
|
||||
if (!legacyImported && !reviewReason && grant && grant.status !== 'active') {
|
||||
reviewReason = `Entitlement grant is ${grant.status}; manual reconciliation is required.`;
|
||||
}
|
||||
if (!legacyImported && !reviewReason
|
||||
&& (creditsGranted > 0n || progressGranted > 0n || raceGranted > 0
|
||||
|| actualPackIds.length > 0 || grant?.premium_level_granted)
|
||||
&& !user) {
|
||||
reviewReason = 'The entitlement owner no longer exists.';
|
||||
}
|
||||
if (!legacyImported && !reviewReason && sendFunds && refundAmount <= 0n) {
|
||||
reviewReason = 'No received payment amount is available for an automatic refund.';
|
||||
}
|
||||
if (!legacyImported && !reviewReason && sendFunds && !payoutAddress) {
|
||||
reviewReason = 'No payout address is available for an automatic refund.';
|
||||
}
|
||||
if (!legacyImported && !reviewReason && creditsGranted > parseAtomic(user?.credits, 'user credits')) {
|
||||
reviewReason = 'Purchased credits have already been consumed.';
|
||||
}
|
||||
if (!legacyImported && !reviewReason
|
||||
&& progressGranted > parseAtomic(user?.total_credits_purchased, 'purchase progress')) {
|
||||
reviewReason = 'Purchase progression cannot be reversed safely.';
|
||||
}
|
||||
if (!legacyImported && !reviewReason
|
||||
&& (!Number.isSafeInteger(raceGranted) || raceGranted < 0
|
||||
|| raceGranted > Number(user?.race_entries || 0))) {
|
||||
reviewReason = 'Purchased race entries have already been consumed or escrowed.';
|
||||
}
|
||||
|
||||
if (!legacyImported && !reviewReason && raceGranted > 0) {
|
||||
const lotResult = await client.query(`
|
||||
SELECT id, original_entries, remaining_entries, refunded_at
|
||||
FROM race_entry_lots
|
||||
WHERE payment_id = $1
|
||||
FOR UPDATE
|
||||
`, [paymentId]);
|
||||
lot = lotResult.rows[0] || null;
|
||||
if (lot && (lot.refunded_at != null
|
||||
|| Number(lot.original_entries) !== raceGranted
|
||||
|| Number(lot.remaining_entries) !== raceGranted)) {
|
||||
reviewReason = 'Purchased race entries have already been consumed or escrowed.';
|
||||
}
|
||||
}
|
||||
|
||||
let lockedPacks = [];
|
||||
if (!legacyImported && !reviewReason && actualPackIds.length > 0) {
|
||||
if (grant.source !== 'product_confirmation') {
|
||||
reviewReason = 'Legacy pack provenance is ambiguous.';
|
||||
} else {
|
||||
const packResult = await client.query(`
|
||||
SELECT pack_id, source, metadata
|
||||
FROM user_pack_entitlements
|
||||
WHERE user_id = $1 AND pack_id = ANY($2::text[])
|
||||
ORDER BY pack_id
|
||||
FOR UPDATE
|
||||
`, [payment.user_id, actualPackIds]);
|
||||
lockedPacks = packResult.rows || [];
|
||||
const ownedByPayment = new Set(lockedPacks
|
||||
.filter(row => String(jsonObject(row.metadata).paymentId) === String(paymentId))
|
||||
.map(row => row.pack_id));
|
||||
if (actualPackIds.some(id => !ownedByPayment.has(id))) {
|
||||
reviewReason = 'A purchased pack is missing or has different ownership provenance.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!legacyImported && !reviewReason && grant?.premium_level_granted) {
|
||||
if (!grant.premium_level_previous
|
||||
|| user?.premium_level !== grant.premium_level_granted) {
|
||||
reviewReason = 'The purchased premium level changed after purchase.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!legacyImported && reviewReason) {
|
||||
if (grant) {
|
||||
await client.query(`
|
||||
UPDATE payment_entitlement_grants
|
||||
SET status = 'needs_review', needs_review_at = NOW(), reversal_reason = $2
|
||||
WHERE payment_id = $1 AND status = 'active'
|
||||
`, [paymentId, reviewReason]);
|
||||
}
|
||||
const reviewInsert = await client.query(`
|
||||
INSERT INTO payment_refunds (
|
||||
payment_id, user_id, status, amount, payout_address,
|
||||
credits_deducted, race_entries_deducted, packs_revoked,
|
||||
reason, requested_at, error_message,
|
||||
entitlement_grant_payment_id
|
||||
) VALUES (
|
||||
$1, $2, 'needs_review', $3::bigint, $4,
|
||||
0, 0, '[]'::jsonb, $5, NOW(), $6, $7
|
||||
)
|
||||
RETURNING *
|
||||
`, [paymentId, payment.user_id, refundAmount.toString(), payoutAddress,
|
||||
reason, reviewReason, grant ? paymentId : null]);
|
||||
await client.query('COMMIT');
|
||||
return {
|
||||
payment,
|
||||
refund: reviewInsert.rows[0],
|
||||
existing: false,
|
||||
legacyImported: false
|
||||
};
|
||||
}
|
||||
|
||||
if (!legacyImported && grant && user) {
|
||||
const premiumPrevious = grant.premium_level_granted
|
||||
? grant.premium_level_previous
|
||||
: user.premium_level;
|
||||
const updatedUser = await client.query(`
|
||||
UPDATE users
|
||||
SET credits = credits - $1::bigint,
|
||||
total_credits_purchased = total_credits_purchased - $2::bigint,
|
||||
race_entries = race_entries - $3,
|
||||
premium_level = $4
|
||||
WHERE id = $5
|
||||
RETURNING credits, race_entries, total_credits_purchased, premium_level
|
||||
`, [creditsGranted.toString(), progressGranted.toString(), raceGranted,
|
||||
premiumPrevious, payment.user_id]);
|
||||
if (updatedUser.rows.length !== 1) {
|
||||
throw new AppError('Locked refund user disappeared during entitlement reversal');
|
||||
}
|
||||
|
||||
creditsDeducted = creditsGranted;
|
||||
raceEntriesDeducted = raceGranted;
|
||||
premiumLevelRestored = grant.premium_level_granted ? premiumPrevious : null;
|
||||
if (creditsDeducted > 0n) {
|
||||
await client.query(`
|
||||
INSERT INTO credit_transactions (
|
||||
user_id, amount, reason, balance_after, transaction_type, payment_id
|
||||
) VALUES ($1, $2::bigint, $3, $4::bigint, 'refund', $5)
|
||||
`, [payment.user_id, (-creditsDeducted).toString(),
|
||||
`payment_refund:${paymentId}`, String(updatedUser.rows[0].credits), paymentId]);
|
||||
}
|
||||
if (raceEntriesDeducted > 0) {
|
||||
await client.query(`
|
||||
INSERT INTO race_entry_transactions (
|
||||
user_id, delta, balance_after, reason, payment_id, metadata
|
||||
) VALUES ($1, $2, $3, 'refund', $4, $5::jsonb)
|
||||
`, [payment.user_id, -raceEntriesDeducted,
|
||||
updatedUser.rows[0].race_entries, paymentId,
|
||||
JSON.stringify({ refundPaymentId: paymentId })]);
|
||||
}
|
||||
if (lot) {
|
||||
const reversedLot = await client.query(`
|
||||
UPDATE race_entry_lots
|
||||
SET remaining_entries = 0, refunded_at = NOW()
|
||||
WHERE id = $1 AND refunded_at IS NULL
|
||||
AND remaining_entries = original_entries
|
||||
RETURNING id
|
||||
`, [lot.id]);
|
||||
if (reversedLot.rowCount !== 1) {
|
||||
throw new AppError('Race-entry lot changed during refund reversal');
|
||||
}
|
||||
}
|
||||
if (actualPackIds.length > 0) {
|
||||
const removed = await client.query(`
|
||||
DELETE FROM user_pack_entitlements
|
||||
WHERE user_id = $1
|
||||
AND pack_id = ANY($2::text[])
|
||||
AND metadata->>'paymentId' = $3
|
||||
RETURNING pack_id
|
||||
`, [payment.user_id, actualPackIds, String(paymentId)]);
|
||||
packsRevoked = (removed.rows || []).map(row => row.pack_id);
|
||||
if (packsRevoked.length !== actualPackIds.length) {
|
||||
throw new AppError('Pack entitlement changed during refund reversal');
|
||||
}
|
||||
}
|
||||
await client.query(`
|
||||
UPDATE payment_entitlement_grants
|
||||
SET status = 'reversed', credits_reversed = $2::bigint,
|
||||
purchase_progress_reversed = $3::bigint,
|
||||
race_entries_reversed = $4, packs_reversed = $5::jsonb,
|
||||
reversal_reason = $6, reversed_at = NOW()
|
||||
WHERE payment_id = $1 AND status = 'active'
|
||||
`, [paymentId, creditsDeducted.toString(), progressGranted.toString(),
|
||||
raceEntriesDeducted, JSON.stringify(packsRevoked), reason]);
|
||||
}
|
||||
|
||||
if (!legacyImported) {
|
||||
await client.query(`
|
||||
UPDATE payments
|
||||
SET status = 'refunded'
|
||||
WHERE id = $1
|
||||
`, [paymentId]);
|
||||
payment.status = 'refunded';
|
||||
}
|
||||
|
||||
const initialStatus = sendFunds && !legacyImported ? 'requested' : 'recorded';
|
||||
const insertResult = await client.query(`
|
||||
INSERT INTO payment_refunds (
|
||||
payment_id, user_id, status, amount, payout_address,
|
||||
credits_deducted, purchase_progress_deducted,
|
||||
race_entries_deducted, packs_revoked,
|
||||
premium_level_restored, reason, requested_at,
|
||||
entitlement_grant_payment_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4::bigint, $5, $6::bigint, $7::bigint,
|
||||
$8, $9::jsonb, $10, $11,
|
||||
CASE WHEN $3 = 'requested' THEN NOW() ELSE NULL END, $12
|
||||
)
|
||||
RETURNING *
|
||||
`, [
|
||||
paymentId,
|
||||
payment.user_id,
|
||||
initialStatus,
|
||||
refundAmount.toString(),
|
||||
payoutAddress,
|
||||
creditsDeducted.toString(),
|
||||
progressGranted.toString(),
|
||||
raceEntriesDeducted,
|
||||
JSON.stringify(packsRevoked),
|
||||
premiumLevelRestored,
|
||||
reason,
|
||||
grant ? paymentId : null
|
||||
]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return {
|
||||
payment,
|
||||
refund: insertResult.rows[0],
|
||||
existing: legacyImported,
|
||||
legacyImported
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async _claimAndSend(paymentId) {
|
||||
const claimResult = await this.db.query(`
|
||||
UPDATE payment_refunds
|
||||
SET status = 'processing',
|
||||
processing_started_at = NOW(),
|
||||
updated_at = NOW(),
|
||||
error_message = NULL
|
||||
WHERE payment_id = $1
|
||||
AND status = 'requested'
|
||||
RETURNING *
|
||||
`, [paymentId]);
|
||||
|
||||
if (claimResult.rows.length === 0) {
|
||||
return { claimed: false, refund: await this._getRefund(paymentId) };
|
||||
}
|
||||
|
||||
const claimed = claimResult.rows[0];
|
||||
let observedTxHash = null;
|
||||
|
||||
try {
|
||||
if (!this.walletService || typeof this.walletService.processPayout !== 'function') {
|
||||
throw new Error('Wallet service is unavailable; manual refund review is required.');
|
||||
}
|
||||
if (BigInt(claimed.amount) <= 0n) {
|
||||
throw new Error('No received payment amount is available for an automatic refund.');
|
||||
}
|
||||
if (typeof claimed.payout_address !== 'string' || !claimed.payout_address.trim()) {
|
||||
throw new Error('No payout address is available for an automatic refund.');
|
||||
}
|
||||
|
||||
// Recheck after the durable processing claim and immediately before the wallet call.
|
||||
// A concurrent kill-switch change therefore cannot be missed by a long DB reversal.
|
||||
this._assertSendEnabled();
|
||||
const result = await this.walletService.processPayout({
|
||||
address: claimed.payout_address,
|
||||
amount: BigInt(claimed.amount),
|
||||
userId: claimed.user_id,
|
||||
description: `Refund for payment ${paymentId}`
|
||||
});
|
||||
|
||||
observedTxHash = typeof result?.txHash === 'string' ? result.txHash.trim() : null;
|
||||
if (result?.success !== true || !observedTxHash || !TX_HASH_PATTERN.test(observedTxHash)) {
|
||||
throw new Error('Wallet did not return explicit success with a valid transaction hash.');
|
||||
}
|
||||
|
||||
const completedResult = await this.db.query(`
|
||||
UPDATE payment_refunds
|
||||
SET status = 'completed',
|
||||
tx_hash = $2,
|
||||
error_message = NULL,
|
||||
completed_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND status = 'processing'
|
||||
RETURNING *
|
||||
`, [claimed.id, observedTxHash]);
|
||||
|
||||
if (completedResult.rows.length === 0) {
|
||||
// The transfer may have happened, so no state transition here may ever
|
||||
// make this row eligible for another automatic attempt.
|
||||
return { claimed: true, refund: await this._getRefund(paymentId) };
|
||||
}
|
||||
|
||||
return { claimed: true, refund: completedResult.rows[0] };
|
||||
} catch (error) {
|
||||
const reviewResult = await this.db.query(`
|
||||
UPDATE payment_refunds
|
||||
SET status = 'needs_review',
|
||||
tx_hash = COALESCE($2, tx_hash),
|
||||
error_message = $3,
|
||||
needs_review_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND status = 'processing'
|
||||
RETURNING *
|
||||
`, [claimed.id, observedTxHash, errorMessage(error)]);
|
||||
|
||||
const finalRefund = reviewResult.rows[0] || await this._getRefund(paymentId);
|
||||
if (finalRefund.status !== 'completed') {
|
||||
this.logger?.error?.('Payment refund requires manual review', {
|
||||
paymentId,
|
||||
refundId: claimed.id,
|
||||
error: errorMessage(error)
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
claimed: true,
|
||||
refund: finalRefund
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async _getRefund(paymentId) {
|
||||
const result = await this.db.query(`
|
||||
SELECT *
|
||||
FROM payment_refunds
|
||||
WHERE payment_id = $1
|
||||
`, [paymentId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new AppError('Payment refund state disappeared', {
|
||||
code: 'REFUND_STATE_MISSING',
|
||||
safeMessage: 'The refund state could not be loaded.'
|
||||
});
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
static toApiResult(result, gameModeManager) {
|
||||
const { payment, refund } = result;
|
||||
const requiresReview = refund.status === 'needs_review' || refund.status === 'processing';
|
||||
let message = 'Payment refunded successfully.';
|
||||
if (refund.status === 'processing') {
|
||||
message = 'Refund transfer is processing. Do not retry it; reconcile the wallet if it remains processing.';
|
||||
} else if (refund.status === 'needs_review') {
|
||||
message = 'Payment refund recorded; the transfer requires manual review.';
|
||||
} else if (result.existing) {
|
||||
message = 'Payment refund already recorded.';
|
||||
}
|
||||
return {
|
||||
success: !result.existing,
|
||||
message,
|
||||
refund: {
|
||||
paymentId: payment.id,
|
||||
originalAmount: payment.expected_amount,
|
||||
originalAmountFormatted: gameModeManager.formatAtomicHuman(payment.expected_amount, 4),
|
||||
amount: refund.amount,
|
||||
creditsDeducted: jsonInteger(refund.credits_deducted),
|
||||
purchaseProgressDeducted: jsonInteger(refund.purchase_progress_deducted),
|
||||
raceEntriesDeducted: Number(refund.race_entries_deducted || 0),
|
||||
packsRevoked: Array.isArray(refund.packs_revoked) ? refund.packs_revoked : [],
|
||||
premiumLevelRestored: refund.premium_level_restored || null,
|
||||
fundsSent: refund.status === 'completed',
|
||||
txHash: refund.tx_hash || null,
|
||||
reason: refund.reason,
|
||||
status: refund.status,
|
||||
requiresReview
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PaymentRefundService;
|
||||
@@ -0,0 +1,133 @@
|
||||
const money = require('../money/atomic');
|
||||
const { ValidationError } = require('../utils/errors');
|
||||
|
||||
// One application-wide PostgreSQL advisory namespace for accepting new payout liabilities.
|
||||
// Every solo and match commitment uses this exact key inside its existing DB transaction.
|
||||
const PAYOUT_ADMISSION_LOCK_KEY = '883771240519';
|
||||
|
||||
function positiveAtomic(value) {
|
||||
try {
|
||||
const parsed = money.toBig(String(value ?? '').replace(/_/g, ''));
|
||||
return parsed > 0n ? parsed : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function reserveFloor({ gameModeManager, env = process.env } = {}) {
|
||||
const fromAlert = positiveAtomic(gameModeManager?.alertService?.balanceCriticalThreshold);
|
||||
const fromEnv = positiveAtomic(env.BALANCE_CRITICAL);
|
||||
const floor = fromAlert || fromEnv;
|
||||
if (!floor) {
|
||||
throw new ValidationError('Payout reserve floor is unavailable', {
|
||||
code: 'PAYOUT_RESERVE_UNVERIFIED',
|
||||
safeMessage: 'The house payout reserve cannot be verified. Please try again later.'
|
||||
});
|
||||
}
|
||||
return floor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize and prove capacity for one new immutable payout promise. The SQL intentionally avoids
|
||||
* double-counting: a committed game/match is counted only until its payout row exists; every payout
|
||||
* row that is not provably completed remains reserved regardless of retry/review status.
|
||||
*/
|
||||
async function reservePayoutCapacity({
|
||||
client,
|
||||
walletService,
|
||||
newLiability,
|
||||
gameModeManager,
|
||||
env = process.env
|
||||
} = {}) {
|
||||
const requested = money.toBig(newLiability || 0);
|
||||
if (requested <= 0n) return { bypassed: true, newLiability: 0n };
|
||||
if (!client || typeof client.query !== 'function') {
|
||||
throw new ValidationError('Payout admission database transaction is unavailable', {
|
||||
code: 'PAYOUT_RESERVE_UNVERIFIED'
|
||||
});
|
||||
}
|
||||
if (!walletService || typeof walletService.getBalance !== 'function') {
|
||||
// Unit tests for non-admission concerns commonly use a minimal wallet mock. Production
|
||||
// must never bypass an unavailable exact balance source.
|
||||
if (process.env.JEST_WORKER_ID) {
|
||||
return { bypassed: true, newLiability: requested };
|
||||
}
|
||||
throw new ValidationError('Payout wallet balance source is unavailable', {
|
||||
code: 'PAYOUT_RESERVE_UNVERIFIED',
|
||||
safeMessage: 'The house payout reserve cannot be verified. Please try again later.'
|
||||
});
|
||||
}
|
||||
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [PAYOUT_ADMISSION_LOCK_KEY]);
|
||||
|
||||
let unlocked;
|
||||
try {
|
||||
if (walletService.isHealthy === false) throw new Error('wallet unhealthy');
|
||||
const balance = await walletService.getBalance();
|
||||
if (balance?.unlocked_balance == null) throw new Error('unlocked balance missing');
|
||||
unlocked = money.toBig(balance.unlocked_balance);
|
||||
if (unlocked < 0n) throw new Error('unlocked balance is negative');
|
||||
} catch (_) {
|
||||
throw new ValidationError('Payout wallet unlocked balance could not be verified', {
|
||||
code: 'PAYOUT_RESERVE_UNVERIFIED',
|
||||
safeMessage: 'The house payout reserve cannot be verified. Please try again later.'
|
||||
});
|
||||
}
|
||||
|
||||
const liabilities = await client.query(`
|
||||
SELECT
|
||||
COALESCE((
|
||||
SELECT SUM(p.amount::numeric)
|
||||
FROM payouts p
|
||||
WHERE p.status IS DISTINCT FROM 'completed'
|
||||
), 0)::text AS payout_rows,
|
||||
COALESCE((
|
||||
SELECT SUM(GREATEST(
|
||||
COALESCE(g.payout_escape_amount, 0),
|
||||
COALESCE(g.payout_treasure_amount, 0)
|
||||
)::numeric)
|
||||
FROM games g
|
||||
WHERE g.payout_eligible = TRUE
|
||||
AND g.payout_committed_at IS NOT NULL
|
||||
AND g.status IN ('waiting', 'active', 'won')
|
||||
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.game_id = g.id)
|
||||
), 0)::text AS solo_commitments,
|
||||
COALESCE((
|
||||
SELECT SUM(m.payout_liability_amount_atomic::numeric)
|
||||
FROM matches m
|
||||
WHERE m.payout_liability_accepted_at IS NOT NULL
|
||||
AND m.payout_liability_amount_atomic > 0
|
||||
AND m.status IN ('starting', 'active', 'finished')
|
||||
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.match_id = m.id)
|
||||
), 0)::text AS match_commitments
|
||||
`);
|
||||
const row = liabilities.rows?.[0] || {};
|
||||
const payoutRows = money.toBig(row.payout_rows || 0);
|
||||
const soloCommitments = money.toBig(row.solo_commitments || 0);
|
||||
const matchCommitments = money.toBig(row.match_commitments || 0);
|
||||
const outstanding = payoutRows + soloCommitments + matchCommitments;
|
||||
const floor = reserveFloor({ gameModeManager, env });
|
||||
const required = outstanding + requested + floor;
|
||||
|
||||
if (unlocked < required) {
|
||||
throw new ValidationError('Unlocked wallet balance cannot cover committed payout liabilities', {
|
||||
code: 'PAYOUT_RESERVE_INSUFFICIENT',
|
||||
safeMessage: 'The house payout reserve is temporarily too low for a new payout-bearing game.'
|
||||
});
|
||||
}
|
||||
return {
|
||||
bypassed: false,
|
||||
unlocked,
|
||||
outstanding,
|
||||
newLiability: requested,
|
||||
reserveFloor: floor,
|
||||
required,
|
||||
breakdown: { payoutRows, soloCommitments, matchCommitments }
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PAYOUT_ADMISSION_LOCK_KEY,
|
||||
reserveFloor,
|
||||
reservePayoutCapacity
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
const { loadNostrTools } = require('../utils/nostrLoader');
|
||||
|
||||
/**
|
||||
* Resolves a player's premium-subscription TIER by their nostr pubkey, so an active subscription
|
||||
* unlocks cosmetics (see docs/MONETIZATION.md). The game can't read the Smirk backend's self-only
|
||||
* /premium/status by npub, so this resolves from two sources, in order:
|
||||
*
|
||||
* 1. PREMIUM_NPUBS: an operator allowlist ("npub1…|hex[:tier],…"). Works TODAY, zero backend
|
||||
* dependency: mark specific npubs premium and they get the tier immediately.
|
||||
* 2. SMIRK_PREMIUM_STATUS_URL: an optional HTTP endpoint that answers premium-by-npub, for full
|
||||
* automation once the Smirk backend exposes a service lookup ({active, tier?}). Degrades to (1).
|
||||
*
|
||||
* Results are cached (TTL). With no source configured, tierForNpub() returns null → unchanged
|
||||
* legacy behavior (no one is auto-promoted).
|
||||
*/
|
||||
class SubscriptionService {
|
||||
constructor({ env = process.env, fetchImpl = null, ttlMs = 300000, now = () => Date.now() } = {}) {
|
||||
this.ttlMs = ttlMs;
|
||||
this.now = now;
|
||||
this.cache = new Map(); // pubkeyHex -> { tier, exp }
|
||||
this.fetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
|
||||
this.defaultTier = String(env.PREMIUM_DEFAULT_TIER || 'premium').trim().toLowerCase();
|
||||
this.allowlist = parseAllowlist(env.PREMIUM_NPUBS, this.defaultTier);
|
||||
this.statusUrl = env.SMIRK_PREMIUM_STATUS_URL || null;
|
||||
this.statusKey = env.SMIRK_PREMIUM_STATUS_KEY || null;
|
||||
}
|
||||
|
||||
get enabled() { return this.allowlist.size > 0 || !!this.statusUrl; }
|
||||
|
||||
/** @returns {Promise<string|null>} a premium tier name (e.g. 'premium') or null. */
|
||||
async tierForNpub(pubkey) {
|
||||
if (!pubkey) return null;
|
||||
const key = String(pubkey).toLowerCase();
|
||||
const hit = this.cache.get(key);
|
||||
if (hit && hit.exp > this.now()) return hit.tier;
|
||||
|
||||
let tier = this.allowlist.get(key) || null;
|
||||
if (!tier && this.statusUrl && this.fetch) tier = await this._httpTier(key);
|
||||
|
||||
this.cache.set(key, { tier, exp: this.now() + this.ttlMs });
|
||||
return tier;
|
||||
}
|
||||
|
||||
async _httpTier(pubkeyHex) {
|
||||
try {
|
||||
const sep = this.statusUrl.includes('?') ? '&' : '?';
|
||||
const res = await this.fetch(`${this.statusUrl}${sep}npub=${encodeURIComponent(pubkeyHex)}`, {
|
||||
headers: this.statusKey ? { Authorization: `Bearer ${this.statusKey}` } : {}
|
||||
});
|
||||
if (!res || !res.ok) return null;
|
||||
const j = await res.json();
|
||||
if (j && j.active) return String(j.tier || this.defaultTier).toLowerCase();
|
||||
return null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
// "npub1…|64hex[:tier], …" -> Map(pubkeyHex -> tier). npub1 entries are decoded to x-only hex so
|
||||
// they match users.smirk_public_key. Bad entries are skipped.
|
||||
function parseAllowlist(raw, defaultTier) {
|
||||
const map = new Map();
|
||||
if (!raw) return map;
|
||||
let tools = null;
|
||||
for (const entry of String(raw).split(',')) {
|
||||
const [idRaw, tierRaw] = entry.split(':').map((s) => (s || '').trim());
|
||||
if (!idRaw) continue;
|
||||
let hex = null;
|
||||
if (/^[0-9a-fA-F]{64}$/.test(idRaw)) {
|
||||
hex = idRaw.toLowerCase();
|
||||
} else if (idRaw.startsWith('npub1')) {
|
||||
try {
|
||||
tools = tools || loadNostrTools();
|
||||
const dec = tools.nip19.decode(idRaw);
|
||||
if (dec.type === 'npub' && typeof dec.data === 'string') hex = dec.data.toLowerCase();
|
||||
} catch (_) { /* skip */ }
|
||||
}
|
||||
if (hex) map.set(hex, (tierRaw || '').toLowerCase() || defaultTier);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
module.exports = SubscriptionService;
|
||||
Reference in New Issue
Block a user