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:
jwinterm
2026-07-27 08:23:01 -04:00
commit fad2a695bd
578 changed files with 102281 additions and 0 deletions
+530
View File
@@ -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;
+177
View File
@@ -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
};
+77
View File
@@ -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;
+458
View File
@@ -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;
+104
View File
@@ -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 };
+124
View File
@@ -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;
+634
View File
@@ -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;
+133
View File
@@ -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
};
+82
View File
@@ -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;