mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-23 09:08:32 +02:00
3110 lines
151 KiB
JavaScript
3110 lines
151 KiB
JavaScript
/**
|
|
* Game Mode Manager
|
|
* Handles different game modes (FREE, PAID_SINGLE, PAID_CREDITS)
|
|
*/
|
|
|
|
const {
|
|
DEFAULT_DECIMALS,
|
|
parseAtomicEnvValue,
|
|
inferCurrencyDecimals: inferCurrencyDecimalsForSymbol,
|
|
formatAtomic,
|
|
formatAtomicHuman,
|
|
getDecimalDivisor,
|
|
gameNameFor,
|
|
currencyLabelFor
|
|
} = require('./helpers/gameModeUtils');
|
|
const { ValidationError, normalizeError } = require('../utils/errors');
|
|
const paymentConfig = require('../config/paymentConfig');
|
|
const money = require('../money/atomic');
|
|
const Entitlements = require('../multiplayer/entitlements');
|
|
const ProductGrants = require('../payments/productGrants');
|
|
const { hashSeed, normalizeClientSeed } = require('./provablyFair');
|
|
const { buildProviderRegistry } = require('../payments/providers');
|
|
const { listMatchRulesets, resolveMatchRuleset } = require('./rulesets');
|
|
const { matchPayoutAdmissionPolicy } = require('../network/matchEconomyPolicy');
|
|
const { isSmirkEnabled } = require('../auth/smirkPolicy');
|
|
const { reservePayoutCapacity } = require('../services/payoutAdmissionService');
|
|
const { getOperatedProductProfile } = require('../config/operatedProductProfiles');
|
|
|
|
// A wallet "not enough (unlocked) money" error is raised BEFORE the tx is broadcast, so it is
|
|
// safe to retry (no double-pay risk), unlike an ambiguous post-broadcast failure. Monero locks
|
|
// spent outputs (including change) for ~10 blocks, so this is the expected error when all
|
|
// outputs are temporarily locked.
|
|
function payoutErrorText(error, depth = 0) {
|
|
if (depth > 4 || error == null) return '';
|
|
if (typeof error === 'string') return error;
|
|
const parts = [error.message, error.details && JSON.stringify(error.details)];
|
|
if (error.cause && error.cause !== error) parts.push(payoutErrorText(error.cause, depth + 1));
|
|
return parts.filter(Boolean).join(' ');
|
|
}
|
|
|
|
/**
|
|
* A batch that wallet-rpc legitimately broadcast as MORE THAN ONE transaction.
|
|
*
|
|
* Not an error in the usual sense: the money is out. It is raised so the quarantine path records
|
|
* the batch for attribution review while carrying the transaction hashes with it, rather than
|
|
* leaving the rows with tx_hash NULL.
|
|
*/
|
|
class BatchSplitEvidence extends Error {
|
|
constructor(hashes, totalFee) {
|
|
super(`Batch was broadcast as ${hashes.length} transactions: ${hashes.join(', ')}`);
|
|
this.name = 'BatchSplitEvidence';
|
|
this.hashes = hashes;
|
|
this.totalFee = totalFee ?? null;
|
|
}
|
|
}
|
|
|
|
function isInsufficientFundsError(error) {
|
|
return /not enough (unlocked )?(money|balance|outputs)|insufficient|no unlocked|unlocked balance/i
|
|
.test(payoutErrorText(error));
|
|
}
|
|
|
|
const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW depending on currency decimals
|
|
const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000;
|
|
const NO_PAYOUT_ADDRESS_SENTINEL = 'PENDING_NO_ADDRESS';
|
|
const TX_HASH_PATTERN = /^[0-9a-f]{64}$/i;
|
|
|
|
function validTxHash(value) {
|
|
return typeof value === 'string' && TX_HASH_PATTERN.test(value.trim());
|
|
}
|
|
|
|
class GameModeManager {
|
|
constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null, paymentProviders = null) {
|
|
this.db = databaseManager;
|
|
this.walletService = walletRPCService;
|
|
this.debugManager = debugManager;
|
|
this.paymentConfigManager = paymentConfigManager || null;
|
|
|
|
// Modular payment providers. Defaults to a registry whose only member is the native
|
|
// Monero/Wownero provider wrapping this walletService, so with no gateway env set every
|
|
// chain routes to the wallet-RPC path. Injectable for tests.
|
|
// See src/payments/providers/index.js.
|
|
this.paymentProviders = paymentProviders || buildProviderRegistry({ walletService: walletRPCService });
|
|
|
|
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
|
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
|
|
|
|
// Network configuration (mainnet/stagenet/testnet) - only applies to Monero
|
|
// Wownero only has mainnet, so this is ignored for WOW
|
|
this.network = (process.env.MONERO_NETWORK || 'mainnet').toLowerCase();
|
|
this.isTestNetwork = this.network === 'stagenet' || this.network === 'testnet';
|
|
|
|
this.singleGamePrice = DEFAULT_SINGLE_GAME_PRICE;
|
|
this.creditsPackagePrice = DEFAULT_CREDITS_PACKAGE_PRICE;
|
|
// PAYOUTS_ENABLED is the operator's emergency/master kill switch. Per-mode flags are
|
|
// subordinate to it; keeping the values separate makes hot config changes reversible
|
|
// without losing the configured direct/credits policy.
|
|
this.payoutsEnabled = true;
|
|
this.creditsPayoutEnabled = false;
|
|
this.directPayoutEnabled = true;
|
|
this.directRequiresAddress = true;
|
|
this.creditsRequiresAddress = true;
|
|
this.creditsPayoutBaseValue = DEFAULT_SINGLE_GAME_PRICE;
|
|
this.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
|
this.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
|
this.creditsPerGameCost = 1;
|
|
this.preferCreditsFirst = true;
|
|
this.paymentsEnabled = false;
|
|
this.directModeEnabled = false;
|
|
this.creditsModeEnabled = false;
|
|
this.configSnapshot = null;
|
|
this._batchPayoutTimer = null;
|
|
this._isBatchProcessing = false;
|
|
this._isShuttingDown = false;
|
|
this._gameAdmissionClosed = false;
|
|
this._gameStartAdmissions = new Set();
|
|
// The composition root replaces this with the dynamic startup/runtime financial
|
|
// reconciliation gate. Default-open keeps isolated free/test instances compatible.
|
|
this.financialAdmissionAllowed = () => true;
|
|
|
|
this.applyLegacyEnvConfig();
|
|
|
|
if (this.paymentConfigManager) {
|
|
const config = this.paymentConfigManager.getConfig();
|
|
this.applyConfigSnapshot(config, { emitLog: false });
|
|
this.setLegacyGameMode(this.paymentConfigManager.getLegacyGameMode());
|
|
|
|
if (this.paymentConfigManager.eventBus && typeof this.paymentConfigManager.eventBus.on === 'function') {
|
|
this.paymentConfigManager.eventBus.on('paymentConfig:update', (updatedConfig) => {
|
|
this.applyConfigSnapshot(updatedConfig, { emitLog: true, context: 'updated' });
|
|
this.setLegacyGameMode(this.paymentConfigManager.getLegacyGameMode());
|
|
});
|
|
}
|
|
}
|
|
|
|
this.logConfiguration('initialized');
|
|
}
|
|
|
|
inferCurrencyDecimals(symbol) {
|
|
return inferCurrencyDecimalsForSymbol(symbol);
|
|
}
|
|
|
|
// Product name (Monerogue/Wownerogue) and network-aware currency label (sXMR on stagenet,
|
|
// XMR on mainnet, WOW for Wownero), derived so they always track config.
|
|
get gameName() { return gameNameFor(this.cryptoType); }
|
|
get currencyLabel() { return currencyLabelFor(this.cryptoType, this.network); }
|
|
|
|
applyLegacyEnvConfig() {
|
|
this.setLegacyGameMode(process.env.GAME_MODE || 'FREE');
|
|
this.cryptoType = process.env.CRYPTO_TYPE || this.cryptoType;
|
|
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
|
|
// Support both DIRECT_GAME_PRICE and legacy SINGLE_GAME_PRICE
|
|
this.singleGamePrice = parseAtomicEnvValue(process.env.DIRECT_GAME_PRICE || process.env.SINGLE_GAME_PRICE, this.singleGamePrice);
|
|
this.creditsPackagePrice = parseAtomicEnvValue(process.env.CREDITS_PACKAGE_PRICE, this.creditsPackagePrice);
|
|
this.creditsPerGameCost = parseAtomicEnvValue(process.env.CREDITS_PER_GAME, 1) || 1;
|
|
this.payoutsEnabled = !/^false$/i.test(process.env.PAYOUTS_ENABLED || 'true');
|
|
this.creditsPayoutEnabled = /^true$/i.test(process.env.CREDITS_PAYOUTS_ENABLED || process.env.CREDITS_PAYOUT_ENABLED || 'false');
|
|
// Direct (per-game / entry-fee) payouts must be independently gateable, like credits.
|
|
// Default true for backward compatibility; set DIRECT_PAYOUTS_ENABLED=false for a
|
|
// no-payout instance (sell entry/credits for prestige only, e.g. mainnet legitimacy).
|
|
this.directPayoutEnabled = !/^false$/i.test(process.env.DIRECT_PAYOUTS_ENABLED || 'true');
|
|
this.directRequiresAddress = !/^false$/i.test(process.env.DIRECT_REQUIRES_ADDRESS || 'true');
|
|
this.creditsRequiresAddress = !/^false$/i.test(process.env.CREDITS_REQUIRES_ADDRESS || 'true');
|
|
this.creditsPayoutBaseValue = parseAtomicEnvValue(process.env.CREDITS_PAYOUT_BASE, this.singleGamePrice);
|
|
process.env.CREDITS_PER_GAME = String(this.creditsPerGameCost);
|
|
|
|
const directEscape = Number(process.env.DIRECT_PAYOUT_ESCAPE);
|
|
if (Number.isFinite(directEscape) && directEscape > 0) {
|
|
this.directPayoutMultipliers.escape = directEscape;
|
|
}
|
|
const directTreasure = Number(process.env.DIRECT_PAYOUT_TREASURE);
|
|
if (Number.isFinite(directTreasure) && directTreasure > 0) {
|
|
this.directPayoutMultipliers.escapeWithTreasure = directTreasure;
|
|
}
|
|
const creditsEscape = Number(process.env.CREDITS_PAYOUT_ESCAPE);
|
|
if (Number.isFinite(creditsEscape) && creditsEscape > 0) {
|
|
this.creditPayoutMultipliers.escape = creditsEscape;
|
|
}
|
|
const creditsTreasure = Number(process.env.CREDITS_PAYOUT_TREASURE);
|
|
if (Number.isFinite(creditsTreasure) && creditsTreasure > 0) {
|
|
this.creditPayoutMultipliers.escapeWithTreasure = creditsTreasure;
|
|
}
|
|
|
|
if (process.env.PREFER_CREDITS_FIRST) {
|
|
this.preferCreditsFirst = /^true$/i.test(process.env.PREFER_CREDITS_FIRST);
|
|
}
|
|
|
|
this.paymentsEnabled = this.gameMode !== 'FREE';
|
|
this.directModeEnabled = this.gameMode === 'PAID_SINGLE';
|
|
this.creditsModeEnabled = this.gameMode === 'PAID_CREDITS';
|
|
|
|
// FREE_PLAY_ENABLED lets players CHOOSE free play even on an instance that also
|
|
// sells credits/entry (their score goes to the Pleb board; paid games go to the
|
|
// Hall of Champions). Always true when payments are off (free is the only option).
|
|
this.freePlayEnabled = !this.paymentsEnabled || /^true$/i.test(process.env.FREE_PLAY_ENABLED || 'false');
|
|
}
|
|
|
|
applyConfigSnapshot(config, options = {}) {
|
|
if (!config || typeof config !== 'object') {
|
|
return;
|
|
}
|
|
|
|
this.configSnapshot = config;
|
|
this.paymentsEnabled = !!config.paymentsEnabled;
|
|
|
|
if (config.currency) {
|
|
if (config.currency.symbol) {
|
|
this.cryptoType = config.currency.symbol;
|
|
}
|
|
if (config.currency.decimals !== undefined) {
|
|
this.currencyDecimals = Number(config.currency.decimals);
|
|
} else {
|
|
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
|
|
}
|
|
}
|
|
|
|
if (config.modes && config.modes.direct) {
|
|
const { price, enabled, requiresAddress } = config.modes.direct;
|
|
if (price !== undefined && price !== null) {
|
|
this.singleGamePrice = money.toSafe(money.toBig(price));
|
|
}
|
|
this.directModeEnabled = !!enabled;
|
|
if (requiresAddress !== undefined) {
|
|
this.directRequiresAddress = !!requiresAddress;
|
|
}
|
|
}
|
|
|
|
if (config.modes && config.modes.credits) {
|
|
const creditsMode = config.modes.credits;
|
|
if (creditsMode.packages && creditsMode.packages.length > 0) {
|
|
const primaryPackage = creditsMode.packages[0];
|
|
if (primaryPackage.price !== undefined && primaryPackage.price !== null) {
|
|
this.creditsPackagePrice = money.toSafe(money.toBig(primaryPackage.price));
|
|
}
|
|
if (primaryPackage.credits) {
|
|
process.env.CREDITS_PER_PACKAGE = String(primaryPackage.credits);
|
|
}
|
|
}
|
|
if (creditsMode.creditsPerGame !== undefined) {
|
|
this.creditsPerGameCost = Number(creditsMode.creditsPerGame) || 1;
|
|
}
|
|
this.creditsModeEnabled = !!creditsMode.enabled;
|
|
if (creditsMode.requiresAddress !== undefined) {
|
|
this.creditsRequiresAddress = !!creditsMode.requiresAddress;
|
|
}
|
|
}
|
|
|
|
if (config.payouts && config.payouts.enabled !== undefined) {
|
|
this.payoutsEnabled = !!config.payouts.enabled;
|
|
}
|
|
|
|
if (config.payouts && config.payouts.rules) {
|
|
const directRule = config.payouts.rules.direct || {};
|
|
const creditsRule = config.payouts.rules.credits || {};
|
|
|
|
if (directRule.enabled !== undefined) {
|
|
this.directPayoutEnabled = !!directRule.enabled;
|
|
}
|
|
|
|
if (directRule.multipliers) {
|
|
if (directRule.multipliers.escape !== undefined) {
|
|
this.directPayoutMultipliers.escape = Number(directRule.multipliers.escape);
|
|
}
|
|
if (directRule.multipliers.escapeWithTreasure !== undefined) {
|
|
this.directPayoutMultipliers.escapeWithTreasure = Number(directRule.multipliers.escapeWithTreasure);
|
|
}
|
|
}
|
|
|
|
if (creditsRule.multipliers) {
|
|
if (creditsRule.multipliers.escape !== undefined) {
|
|
this.creditPayoutMultipliers.escape = Number(creditsRule.multipliers.escape);
|
|
}
|
|
if (creditsRule.multipliers.escapeWithTreasure !== undefined) {
|
|
this.creditPayoutMultipliers.escapeWithTreasure = Number(creditsRule.multipliers.escapeWithTreasure);
|
|
}
|
|
}
|
|
|
|
if (creditsRule.baseValue !== undefined) {
|
|
this.creditsPayoutBaseValue = money.toSafe(money.toBig(creditsRule.baseValue));
|
|
}
|
|
|
|
if (creditsRule.enabled !== undefined) {
|
|
this.creditsPayoutEnabled = !!creditsRule.enabled;
|
|
}
|
|
}
|
|
|
|
if (config.preferences) {
|
|
if (config.preferences.preferCreditsFirst !== undefined) {
|
|
this.preferCreditsFirst = !!config.preferences.preferCreditsFirst;
|
|
}
|
|
}
|
|
|
|
process.env.SINGLE_GAME_PRICE = String(this.singleGamePrice);
|
|
process.env.CREDITS_PACKAGE_PRICE = String(this.creditsPackagePrice);
|
|
process.env.CREDITS_PER_GAME = String(this.creditsPerGameCost);
|
|
process.env.CREDITS_PAYOUT_ENABLED = this.creditsPayoutEnabled ? 'true' : 'false';
|
|
process.env.CREDITS_PAYOUTS_ENABLED = process.env.CREDITS_PAYOUT_ENABLED;
|
|
|
|
// applyLegacyEnvConfig() runs before the unified config snapshot. Without recomputing
|
|
// here, an instance with no legacy GAME_MODE starts from FREE and accidentally keeps
|
|
// free play enabled even when FREE_PLAY_ENABLED=false and paid modes are active.
|
|
this.freePlayEnabled = !this.paymentsEnabled
|
|
|| /^true$/i.test(process.env.FREE_PLAY_ENABLED || 'false');
|
|
|
|
if (options.emitLog) {
|
|
this.logConfiguration(options.context || 'updated');
|
|
}
|
|
}
|
|
|
|
setLegacyGameMode(mode) {
|
|
this.gameMode = (mode || 'FREE').toUpperCase();
|
|
process.env.GAME_MODE = this.gameMode;
|
|
}
|
|
|
|
formatAtomic(value) {
|
|
return formatAtomic({
|
|
value,
|
|
decimals: Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS
|
|
});
|
|
}
|
|
|
|
getDecimalDivisor() {
|
|
return getDecimalDivisor(Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS);
|
|
}
|
|
|
|
formatAtomicHuman(value, digits = 3) {
|
|
return formatAtomicHuman({
|
|
value,
|
|
decimals: Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS,
|
|
digits
|
|
});
|
|
}
|
|
|
|
getPrimaryCreditPackage() {
|
|
const packages = this.configSnapshot?.modes?.credits?.packages;
|
|
if (Array.isArray(packages) && packages.length > 0) {
|
|
return packages[0];
|
|
}
|
|
return {
|
|
id: 'default',
|
|
credits: 10,
|
|
price: this.creditsPackagePrice,
|
|
bonus: 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* PAYMENTS_ENABLED is the intake kill switch. Product-specific switches narrow it further;
|
|
* they can never create an invoice when the master switch is off. Keep this predicate pure
|
|
* so Socket.IO can refuse before consuming a fairness offer or touching wallet/DB state, and
|
|
* enforce it again in createPaymentRequest for non-Socket.IO callers.
|
|
*/
|
|
isPaymentIntakeEnabled(paymentType) {
|
|
if (!this._isFinancialAdmissionAllowed()) return false;
|
|
if (!this.paymentsEnabled) return false;
|
|
if (paymentType === 'single_game') return Boolean(this.directModeEnabled);
|
|
if (paymentType === 'credits_package') return Boolean(this.creditsModeEnabled);
|
|
if (paymentType === 'cosmetic_pack') return true;
|
|
// Unknown products are rejected by the canonical switch below; keeping them distinct
|
|
// from a disabled product produces the correct operator/client diagnostic.
|
|
return true;
|
|
}
|
|
|
|
_isFinancialAdmissionAllowed() {
|
|
try {
|
|
return typeof this.financialAdmissionAllowed !== 'function'
|
|
|| this.financialAdmissionAllowed() === true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
_financialRecoveryPendingResult() {
|
|
return {
|
|
success: false,
|
|
code: 'FINANCIAL_RECOVERY_PENDING',
|
|
reason: 'Financial recovery is still pending; paid entry is temporarily unavailable.'
|
|
};
|
|
}
|
|
|
|
_assertPaymentIntakeEnabled(paymentType) {
|
|
if (this.isPaymentIntakeEnabled(paymentType)) return;
|
|
const product = paymentType === 'single_game'
|
|
? 'Direct entry'
|
|
: (paymentType === 'credits_package' ? 'Credit purchases' : 'Payment intake');
|
|
throw new ValidationError(`${product} is disabled by runtime policy`, {
|
|
code: 'PAYMENT_INTAKE_DISABLED',
|
|
safeMessage: 'That paid product is not available on this server.'
|
|
});
|
|
}
|
|
|
|
_requiresPaidFairnessV2() {
|
|
// Production invoices are never allowed to opt out: the proof must be committed
|
|
// before funds can arrive and survive a restart on the durable payment row.
|
|
if (process.env.NODE_ENV === 'production') return true;
|
|
if (/^false$/i.test(process.env.REQUIRE_PAID_FAIRNESS_V2 || '')) return false;
|
|
return /^true$/i.test(process.env.REQUIRE_PAID_FAIRNESS_V2 || '');
|
|
}
|
|
|
|
/** Validate and reduce private offer material before binding it to a payment row. */
|
|
_normalizePaymentFairnessProof(input, { required = this._requiresPaidFairnessV2() } = {}) {
|
|
if (!input || typeof input !== 'object') {
|
|
if (required) {
|
|
throw new ValidationError('A v2 fairness offer is required before creating a paid entry', {
|
|
safeMessage: 'Request a new fairness offer before paying.'
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
const proofVersion = Number(input.proofVersion);
|
|
const offerId = typeof input.offerId === 'string' ? input.offerId.trim() : '';
|
|
const serverSeed = typeof input.serverSeed === 'string' ? input.serverSeed.trim().toLowerCase() : '';
|
|
const commitment = typeof input.commitment === 'string' ? input.commitment.trim().toLowerCase() : '';
|
|
const clientSeed = normalizeClientSeed(input.clientSeed);
|
|
const issuedMs = input.offerIssuedAt instanceof Date
|
|
? input.offerIssuedAt.getTime()
|
|
: Number(input.offerIssuedAt);
|
|
if (proofVersion !== 2
|
|
|| !offerId || offerId.length > 64
|
|
|| !/^[0-9a-f]{64}$/.test(serverSeed)
|
|
|| !/^[0-9a-f]{64}$/.test(commitment)
|
|
|| hashSeed(serverSeed) !== commitment
|
|
|| clientSeed === null
|
|
|| !Number.isFinite(issuedMs) || issuedMs <= 0) {
|
|
throw new ValidationError('Invalid v2 fairness proof supplied for paid entry', {
|
|
safeMessage: 'The fairness offer is invalid or expired. Request a new offer.'
|
|
});
|
|
}
|
|
return {
|
|
proofVersion: 2,
|
|
offerId,
|
|
offerIssuedAt: issuedMs,
|
|
serverSeed,
|
|
commitment,
|
|
clientSeed
|
|
};
|
|
}
|
|
|
|
_paymentFairnessProofFromRow(row) {
|
|
if (!row || row.fairness_bound_at == null) return null;
|
|
try {
|
|
return this._normalizePaymentFairnessProof({
|
|
proofVersion: row.fairness_proof_version,
|
|
offerId: row.fairness_offer_id,
|
|
offerIssuedAt: row.fairness_offer_issued_at instanceof Date
|
|
? row.fairness_offer_issued_at.getTime()
|
|
: Date.parse(row.fairness_offer_issued_at),
|
|
serverSeed: row.fairness_server_seed,
|
|
commitment: row.fairness_commitment,
|
|
clientSeed: row.fairness_client_seed || ''
|
|
}, { required: true });
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
_normalizeConfirmationReceipts(receipts, providerId) {
|
|
if (!Array.isArray(receipts)) return [];
|
|
const expectedProvider = String(providerId || 'native-monero');
|
|
const normalized = new Map();
|
|
for (const receipt of receipts) {
|
|
const evidenceType = String(receipt?.evidenceType || '');
|
|
const receiptProvider = String(receipt?.providerId || expectedProvider);
|
|
const evidenceId = String(receipt?.evidenceId || '').trim();
|
|
const txHash = typeof receipt?.txHash === 'string'
|
|
? receipt.txHash.trim().toLowerCase()
|
|
: null;
|
|
const rawOutputId = typeof receipt?.outputId === 'string'
|
|
? receipt.outputId.trim().toLowerCase()
|
|
: null;
|
|
const addressIndex = receipt?.addressIndex == null ? null : Number(receipt.addressIndex);
|
|
const amount = money.toBig(receipt?.amount);
|
|
if (receipt?.confirmed !== true || receiptProvider !== expectedProvider
|
|
|| amount <= 0n || !evidenceId || evidenceId.length > 160) {
|
|
throw new ValidationError('Invalid confirmed payment receipt evidence');
|
|
}
|
|
let outputId = rawOutputId;
|
|
if (evidenceType === 'chain_output') {
|
|
if (outputId?.startsWith('global:')) {
|
|
const rawIndex = outputId.slice('global:'.length);
|
|
if (!/^\d+$/.test(rawIndex)) {
|
|
throw new ValidationError('Invalid native chain-output receipt evidence');
|
|
}
|
|
const index = BigInt(rawIndex);
|
|
if (index > 18446744073709551615n) {
|
|
throw new ValidationError('Invalid native chain-output receipt evidence');
|
|
}
|
|
outputId = `global:${index.toString()}`;
|
|
}
|
|
if (expectedProvider !== 'native-monero' || !validTxHash(txHash)
|
|
|| (!/^[0-9a-f]{64}$/.test(outputId || '')
|
|
&& !/^global:(0|[1-9]\d*)$/.test(outputId || ''))
|
|
|| evidenceId.toLowerCase() !== `${txHash}:${outputId}`
|
|
|| !Number.isInteger(addressIndex) || addressIndex < 0) {
|
|
throw new ValidationError('Invalid native chain-output receipt evidence');
|
|
}
|
|
} else if (evidenceType === 'provider_invoice') {
|
|
if (expectedProvider === 'native-monero' || txHash !== null
|
|
|| outputId !== null || addressIndex !== null) {
|
|
throw new ValidationError('Invalid provider-invoice receipt evidence');
|
|
}
|
|
} else {
|
|
throw new ValidationError('Unknown payment receipt evidence type');
|
|
}
|
|
const value = {
|
|
providerId: expectedProvider,
|
|
evidenceType,
|
|
evidenceId: evidenceType === 'chain_output' ? `${txHash}:${outputId}` : evidenceId,
|
|
txHash,
|
|
outputId,
|
|
addressIndex,
|
|
amount: amount.toString()
|
|
};
|
|
const prior = normalized.get(value.evidenceId);
|
|
if (prior) {
|
|
if (JSON.stringify(prior) !== JSON.stringify(value)) {
|
|
throw new ValidationError('Conflicting duplicate payment receipt evidence');
|
|
}
|
|
continue;
|
|
}
|
|
normalized.set(value.evidenceId, value);
|
|
}
|
|
return Array.from(normalized.values());
|
|
}
|
|
|
|
async _persistConfirmationReceipts(client, payment, receipts, { requireCoverage = true } = {}) {
|
|
const providerId = String(payment.provider_id || 'native-monero');
|
|
const normalized = this._normalizeConfirmationReceipts(receipts, providerId);
|
|
if (normalized.length === 0) {
|
|
if (process.env.NODE_ENV === 'production') {
|
|
throw new ValidationError('Confirmed payment has no durable receipt evidence');
|
|
}
|
|
return null;
|
|
}
|
|
if (providerId === 'native-monero') {
|
|
const paymentAddressIndex = Number(payment.address_index);
|
|
if (!Number.isInteger(paymentAddressIndex) || paymentAddressIndex < 0
|
|
|| normalized.some(receipt => receipt.addressIndex !== paymentAddressIndex)) {
|
|
throw new ValidationError('Chain-output receipt does not belong to this invoice address');
|
|
}
|
|
}
|
|
for (const receipt of normalized) {
|
|
await client.query(`
|
|
INSERT INTO payment_receipts (
|
|
payment_id, provider_id, evidence_type, evidence_id,
|
|
tx_hash, output_id, address_index, amount, confirmed
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::bigint, TRUE)
|
|
ON CONFLICT (provider_id, evidence_id) DO NOTHING
|
|
`, [payment.id, receipt.providerId, receipt.evidenceType, receipt.evidenceId,
|
|
receipt.txHash, receipt.outputId, receipt.addressIndex, receipt.amount]);
|
|
}
|
|
const evidence = await client.query(`
|
|
SELECT COALESCE(SUM(amount), 0)::text AS total,
|
|
MIN(tx_hash) FILTER (WHERE tx_hash IS NOT NULL) AS primary_tx_hash,
|
|
COUNT(*)::text AS receipt_count
|
|
FROM payment_receipts
|
|
WHERE payment_id = $1 AND provider_id = $2 AND confirmed = TRUE
|
|
AND (($2 = 'native-monero' AND evidence_type = 'chain_output')
|
|
OR ($2 <> 'native-monero' AND evidence_type = 'provider_invoice'))
|
|
`, [payment.id, providerId]);
|
|
const total = money.toBig(evidence.rows[0]?.total || 0);
|
|
const expected = money.toBig(payment.expected_amount);
|
|
if (requireCoverage && total < expected) {
|
|
throw new ValidationError('Confirmed receipts do not cover the invoice amount');
|
|
}
|
|
return {
|
|
total,
|
|
primaryTxHash: evidence.rows[0]?.primary_tx_hash || null,
|
|
receiptCount: Number(evidence.rows[0]?.receipt_count || 0)
|
|
};
|
|
}
|
|
|
|
async confirmSingleGamePayment(paymentId, receivedAmount, receipts = []) {
|
|
return this.db.withTransaction(async (client) => {
|
|
const locked = await client.query(`
|
|
SELECT id, status, expected_amount, provider_id, address_index
|
|
FROM payments WHERE id = $1 FOR UPDATE
|
|
`, [paymentId]);
|
|
const payment = locked.rows[0];
|
|
if (!payment || payment.status !== 'pending') return { updated: false };
|
|
const evidence = await this._persistConfirmationReceipts(client, payment, receipts);
|
|
const received = evidence?.total ?? money.toBig(receivedAmount || 0);
|
|
if (received < money.toBig(payment.expected_amount)) return { updated: false, underpaid: true };
|
|
const update = await client.query(`
|
|
UPDATE payments
|
|
SET status = 'confirmed', confirmed_at = NOW(), received_amount = $2::bigint,
|
|
tx_hash = COALESCE($3, tx_hash), confirmation_evidence_at = NOW()
|
|
WHERE id = $1 AND status = 'pending'
|
|
RETURNING id
|
|
`, [paymentId, received.toString(), evidence?.primaryTxHash || null]);
|
|
return { updated: update.rows.length === 1 };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Persist receipts discovered after invoice expiry without granting a game or product.
|
|
* The payment is left expired and a durable manual-review item is upserted. This makes late
|
|
* mining/refund cases visible while keeping stale or unbound entries out of the game queue.
|
|
*/
|
|
async reconcileLatePaymentForReview(paymentId, receipts = []) {
|
|
return this.db.withTransaction(async (client) => {
|
|
const locked = await client.query(`
|
|
SELECT id, status, expected_amount, provider_id, address_index
|
|
FROM payments
|
|
WHERE id = $1
|
|
FOR UPDATE
|
|
`, [paymentId]);
|
|
const payment = locked.rows[0];
|
|
if (!payment || !['pending', 'expired'].includes(payment.status)) {
|
|
return { checked: false, needsReview: false };
|
|
}
|
|
|
|
let evidence = null;
|
|
if (Array.isArray(receipts) && receipts.length > 0) {
|
|
evidence = await this._persistConfirmationReceipts(client, payment, receipts, {
|
|
requireCoverage: false
|
|
});
|
|
}
|
|
const observed = evidence?.total || 0n;
|
|
|
|
await client.query(`
|
|
UPDATE payments
|
|
SET status = CASE WHEN status = 'pending' THEN 'expired' ELSE status END,
|
|
late_receipt_checked_at = NOW(),
|
|
received_amount = GREATEST(COALESCE(received_amount, 0), $2::bigint)
|
|
WHERE id = $1
|
|
`, [paymentId, observed.toString()]);
|
|
|
|
if (observed <= 0n || !evidence?.receiptCount) {
|
|
return { checked: true, needsReview: false };
|
|
}
|
|
|
|
const coverageComplete = observed >= money.toBig(payment.expected_amount);
|
|
await client.query(`
|
|
INSERT INTO payment_late_reviews (
|
|
payment_id, provider_id, observed_amount, expected_amount,
|
|
receipt_count, metadata
|
|
) VALUES ($1, $2, $3::bigint, $4::bigint, $5, $6::jsonb)
|
|
ON CONFLICT (payment_id) DO UPDATE
|
|
SET provider_id = EXCLUDED.provider_id,
|
|
status = 'needs_review',
|
|
observed_amount = GREATEST(payment_late_reviews.observed_amount,
|
|
EXCLUDED.observed_amount),
|
|
expected_amount = EXCLUDED.expected_amount,
|
|
receipt_count = GREATEST(payment_late_reviews.receipt_count,
|
|
EXCLUDED.receipt_count),
|
|
metadata = EXCLUDED.metadata,
|
|
last_observed_at = NOW(),
|
|
resolved_at = NULL
|
|
`, [paymentId, payment.provider_id || 'native-monero', observed.toString(),
|
|
String(payment.expected_amount), evidence.receiptCount,
|
|
JSON.stringify({ coverageComplete, entitlementGranted: false })]);
|
|
|
|
return {
|
|
checked: true,
|
|
needsReview: true,
|
|
observedAmount: observed.toString(),
|
|
coverageComplete
|
|
};
|
|
});
|
|
}
|
|
|
|
|
|
_getMatchEconomies() {
|
|
const out = { free: true };
|
|
if (this.creditsModeEnabled) {
|
|
out.credits_prestige = true;
|
|
}
|
|
// Crypto admission has its own explicit switch, payout cap, ruleset contract, and the
|
|
// global payout safety gate. Existing accepted matches settle from their DB snapshot;
|
|
// this policy controls only new queue admission.
|
|
const cryptoPolicy = matchPayoutAdmissionPolicy({
|
|
env: process.env,
|
|
gameModeManager: this,
|
|
ruleset: resolveMatchRuleset(process.env.MATCH_RULESET_ID || 'race'),
|
|
requestedMaxPlayers: process.env.MATCH_MAX_PLAYERS
|
|
});
|
|
if (cryptoPolicy.enabled) {
|
|
out.crypto_race = true;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
_getMatchRulesetInfo() {
|
|
const active = resolveMatchRuleset(process.env.MATCH_RULESET_ID || 'race');
|
|
const catalog = listMatchRulesets();
|
|
return {
|
|
activeRuleset: catalog.find(r => r.id === active.id) || catalog.find(r => r.id === 'race'),
|
|
rulesets: catalog
|
|
};
|
|
}
|
|
|
|
/** Effective payout policy for a recorded game mode (master switch + per-mode rule). */
|
|
isPayoutEnabledForMode(mode) {
|
|
if (!this.payoutsEnabled) return false;
|
|
const normalized = String(mode || '').toUpperCase();
|
|
if (normalized === 'PAID_SINGLE') {
|
|
return !!this.directPayoutEnabled
|
|
&& (Number(this.directPayoutMultipliers.escape) > 0
|
|
|| Number(this.directPayoutMultipliers.escapeWithTreasure) > 0);
|
|
}
|
|
if (normalized === 'PAID_CREDITS') {
|
|
return !!this.creditsPayoutEnabled
|
|
&& (Number(this.creditPayoutMultipliers.escape) > 0
|
|
|| Number(this.creditPayoutMultipliers.escapeWithTreasure) > 0);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Whether this mode must have a payout address before entry/payment is accepted. */
|
|
requiresPayoutAddressForMode(mode) {
|
|
const normalized = String(mode || '').toUpperCase();
|
|
if (!this.isPayoutEnabledForMode(normalized)) return false;
|
|
if (normalized === 'PAID_SINGLE') return !!this.directRequiresAddress;
|
|
if (normalized === 'PAID_CREDITS') return !!this.creditsRequiresAddress;
|
|
return false;
|
|
}
|
|
|
|
getCosmeticProducts() {
|
|
const products = this.configSnapshot?.products?.cosmetic;
|
|
return Array.isArray(products) ? products : [];
|
|
}
|
|
|
|
getCosmeticProduct(productId) {
|
|
const products = this.getCosmeticProducts();
|
|
if (!productId) return products[0] || null;
|
|
return products.find(p => p.id === productId) || null;
|
|
}
|
|
|
|
/**
|
|
* Return the complete solo-payout outcome contract implemented by calculatePayout().
|
|
* Explicitly selecting the two reachable outcomes prevents stale or experimental config
|
|
* keys from being advertised as payout promises by public APIs.
|
|
*/
|
|
getImplementedPayoutMultipliersForMode(mode) {
|
|
const normalizedMode = (mode || this.gameMode || 'FREE').toUpperCase();
|
|
const configured = normalizedMode === 'PAID_CREDITS'
|
|
? this.creditPayoutMultipliers
|
|
: this.directPayoutMultipliers;
|
|
const escape = configured?.escape ?? 0;
|
|
|
|
return {
|
|
escape,
|
|
escapeWithTreasure: configured?.escapeWithTreasure ?? escape
|
|
};
|
|
}
|
|
|
|
calculatePayout(mode, { treasureFound = false } = {}) {
|
|
const normalizedMode = (mode || this.gameMode || 'FREE').toUpperCase();
|
|
const usingCredits = normalizedMode === 'PAID_CREDITS';
|
|
const base = usingCredits ? (this.creditsPayoutBaseValue || this.singleGamePrice) : this.singleGamePrice;
|
|
const multipliers = this.getImplementedPayoutMultipliersForMode(normalizedMode);
|
|
const multiplier = treasureFound
|
|
? (multipliers.escapeWithTreasure ?? multipliers.escape ?? 0)
|
|
: (multipliers.escape ?? 0);
|
|
|
|
// Exact integer math: base (atomic units) * decimal multiplier via BigInt, then narrow
|
|
// back to a number only when exactly representable. Float arithmetic would lose
|
|
// precision on large atomic amounts.
|
|
const amount = money.toSafe(money.mulByDecimal(base, multiplier));
|
|
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log(`🧮 calculatePayout: mode=${normalizedMode}, usingCredits=${usingCredits}, treasureFound=${treasureFound}`);
|
|
console.log(` base=${base}, multipliers=${JSON.stringify(multipliers)}, chosen multiplier=${multiplier}`);
|
|
console.log(` final amount=${amount} (base * multiplier = ${base} * ${multiplier})`);
|
|
}
|
|
|
|
return { amount, multiplier, base };
|
|
}
|
|
|
|
/**
|
|
* Apply a confirmed product payment: credits, premium tier, race entries and pack grants.
|
|
* SECURITY: uses atomic check-and-update so a restart cannot double-credit.
|
|
* @param {string} socketId - Socket ID of the user
|
|
* @param {number} paymentId - Payment record ID
|
|
* @param {object} productInfo - Catalog product info (credits, bonus, grants)
|
|
* @returns {object} Result with success, creditsAdded, newBalance, alreadyProcessed
|
|
*/
|
|
async processProductPaymentConfirmation(socketId, paymentId, productInfo = null, receivedAmount = null, receipts = []) {
|
|
try {
|
|
// Resolve the user from the payment record's user_id, which is stable across socket
|
|
// reconnects: a refresh during confirmation changes the socket ID.
|
|
const paymentLookup = await this.db.query(`
|
|
SELECT user_id, description, status, payment_type, product_id, product_grants,
|
|
received_amount, expected_amount, provider_id, provider_invoice_id,
|
|
address_index
|
|
FROM payments
|
|
WHERE id = $1
|
|
`, [paymentId]);
|
|
|
|
if (paymentLookup.rows.length === 0) {
|
|
console.error(`Payment ${paymentId} not found`);
|
|
return { success: false, reason: 'Payment not found' };
|
|
}
|
|
|
|
// Fast-fail if already confirmed (avoids unnecessary transaction)
|
|
if (paymentLookup.rows[0].status === 'confirmed') {
|
|
console.log(`Payment ${paymentId} already confirmed, skipping duplicate processing`);
|
|
return { success: false, alreadyProcessed: true, reason: 'Payment already processed' };
|
|
}
|
|
|
|
const userId = paymentLookup.rows[0].user_id;
|
|
const paymentRow = paymentLookup.rows[0];
|
|
paymentRow.id = paymentId;
|
|
|
|
// Determine credits to add from package info or payment description
|
|
const paymentType = paymentRow.payment_type || 'credits_package';
|
|
let fallbackCredits = paymentType === 'credits_package' ? 10 : 0;
|
|
if (productInfo && productInfo.credits) {
|
|
fallbackCredits = Number(productInfo.credits) + (Number(productInfo.bonus) || 0);
|
|
} else {
|
|
const desc = paymentLookup.rows[0].description || '';
|
|
const match = desc.match(/(\d+)\s*credits?/i);
|
|
if (match) {
|
|
fallbackCredits = parseInt(match[1], 10) || fallbackCredits;
|
|
}
|
|
}
|
|
const durableProduct = {
|
|
id: paymentRow.product_id,
|
|
grants: paymentRow.product_grants || undefined
|
|
};
|
|
// New payments snapshot normalized grants at invoice creation. Prefer that durable
|
|
// promise over the live catalog so a product edit cannot change ticket backing while
|
|
// an invoice is awaiting confirmations. Legacy rows with an empty grant object retain
|
|
// the historical catalog/description fallback.
|
|
const hasDurableGrants = paymentRow.product_grants
|
|
&& Object.keys(paymentRow.product_grants).length > 0;
|
|
const product = hasDurableGrants ? durableProduct : (productInfo || durableProduct);
|
|
const productGrants = ProductGrants.normalizeProductGrants(product, { credits: fallbackCredits });
|
|
const creditsToAdd = productGrants.credits;
|
|
const productId = product?.id || paymentRow.product_id || paymentType;
|
|
const grantJson = ProductGrants.serializeProductGrants(productGrants);
|
|
const raceEntriesToAdd = productGrants.raceEntries || 0;
|
|
const raceEntryValueAtomic = productGrants.raceEntryValueAtomic
|
|
? BigInt(productGrants.raceEntryValueAtomic)
|
|
: null;
|
|
const backingRequired = raceEntryValueAtomic === null
|
|
? null
|
|
: raceEntryValueAtomic * BigInt(raceEntriesToAdd);
|
|
let paidAtomic = null;
|
|
for (const candidate of [paymentRow.received_amount, receivedAmount]) {
|
|
if (candidate == null || candidate === '') continue;
|
|
try {
|
|
const parsed = BigInt(String(candidate).split('.')[0]);
|
|
if (parsed >= 0n && (paidAtomic === null || parsed > paidAtomic)) {
|
|
paidAtomic = parsed;
|
|
}
|
|
} catch (_) { /* try the next trusted source */ }
|
|
}
|
|
const cryptoTicketFundingRequired = process.env.MATCH_CRYPTO_RACE_ENABLED === 'true'
|
|
&& process.env.MATCH_PAYOUTS_ENABLED === 'true';
|
|
let configuredMatchEntryFee = null;
|
|
try {
|
|
const rawEntryFee = String(process.env.MATCH_ENTRY_FEE_ATOMIC ?? '').trim().replace(/_/g, '');
|
|
if (/^\d+$/.test(rawEntryFee)) {
|
|
const parsed = BigInt(rawEntryFee);
|
|
if (parsed > 0n) configuredMatchEntryFee = parsed;
|
|
}
|
|
} catch (_) { /* handled by the fail-closed condition below */ }
|
|
if (cryptoTicketFundingRequired && raceEntriesToAdd > 0
|
|
&& (backingRequired === null
|
|
|| configuredMatchEntryFee === null
|
|
|| raceEntryValueAtomic !== configuredMatchEntryFee
|
|
|| paidAtomic === null
|
|
|| paidAtomic < backingRequired)) {
|
|
throw new Error(`Race-entry product ${productId} lacks confirmed per-ticket payout backing`);
|
|
}
|
|
|
|
// Every grant runs in one transaction: if any step fails the payment stays 'pending'
|
|
// and can be recovered.
|
|
const result = await this.db.withTransaction(async (client) => {
|
|
// Lock and finalize the durable product promise before inserting append-only
|
|
// receipt evidence. New invoices already contain this snapshot; this also gives
|
|
// legacy pending invoices one safe, pre-receipt upgrade path.
|
|
const snapshotResult = await client.query(`
|
|
UPDATE payments
|
|
SET product_id = COALESCE(product_id, $2),
|
|
product_grants = CASE
|
|
WHEN product_grants = '{}'::jsonb THEN $3::jsonb
|
|
ELSE product_grants
|
|
END
|
|
WHERE id = $1 AND status = 'pending'
|
|
RETURNING id, status, expected_amount, provider_id, provider_invoice_id,
|
|
address_index, product_id, product_grants
|
|
`, [paymentId, productId, JSON.stringify(grantJson)]);
|
|
if (snapshotResult.rows.length === 0) {
|
|
return { alreadyProcessed: true };
|
|
}
|
|
const confirmationPayment = { ...paymentRow, ...snapshotResult.rows[0], id: paymentId };
|
|
const receiptEvidence = await this._persistConfirmationReceipts(client, confirmationPayment, receipts);
|
|
const durableReceived = receiptEvidence?.total?.toString() || receivedAmount;
|
|
// Step 1: Atomically mark payment as confirmed (prevents double-crediting)
|
|
const paymentUpdateResult = await client.query(`
|
|
UPDATE payments
|
|
SET status = 'confirmed',
|
|
credits_purchased = $1,
|
|
confirmed_at = NOW(),
|
|
received_amount = COALESCE($3, received_amount),
|
|
tx_hash = COALESCE($4, tx_hash),
|
|
confirmation_evidence_at = CASE WHEN $4::text IS NULL
|
|
THEN confirmation_evidence_at ELSE NOW() END
|
|
WHERE id = $2 AND status = 'pending'
|
|
RETURNING id
|
|
`, [creditsToAdd, paymentId, durableReceived,
|
|
receiptEvidence?.primaryTxHash || null]);
|
|
|
|
if (paymentUpdateResult.rows.length === 0) {
|
|
// Already processed by another instance - not an error
|
|
return { alreadyProcessed: true };
|
|
}
|
|
|
|
// Preserve the value needed to reverse a payment-specific premium upgrade.
|
|
// Lock order matches refund processing: payment first, then user.
|
|
const priorUser = await client.query(`
|
|
SELECT id, premium_level
|
|
FROM users
|
|
WHERE id = $1
|
|
FOR UPDATE
|
|
`, [userId]);
|
|
if (!priorUser.rows[0]) {
|
|
throw new Error(`Payment ${paymentId} user no longer exists`);
|
|
}
|
|
const previousPremiumLevel = priorUser.rows[0].premium_level || 'free';
|
|
const premiumChanged = !!productGrants.premiumLevel
|
|
&& previousPremiumLevel !== productGrants.premiumLevel;
|
|
|
|
// Step 2: Add credits / premium tier to user (using payment's user_id, not socket lookup)
|
|
const updateResult = await client.query(`
|
|
UPDATE users
|
|
SET credits = credits + $1,
|
|
total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1,
|
|
premium_level = CASE
|
|
WHEN $3::text IS NULL THEN premium_level
|
|
ELSE $3::text
|
|
END,
|
|
updated_at = NOW()
|
|
WHERE id = $2
|
|
RETURNING id, credits, total_credits_purchased, premium_level
|
|
`, [creditsToAdd, userId, productGrants.premiumLevel]);
|
|
|
|
const newBalance = updateResult.rows[0]?.credits ?? creditsToAdd;
|
|
const totalCreditsPurchased = updateResult.rows[0]?.total_credits_purchased ?? creditsToAdd;
|
|
const premiumLevel = updateResult.rows[0]?.premium_level || 'free';
|
|
|
|
// Step 3: Record credit transaction for audit trail when credits were granted.
|
|
if (creditsToAdd > 0) {
|
|
await client.query(`
|
|
INSERT INTO credit_transactions (
|
|
user_id, amount, reason, balance_after, transaction_type, payment_id
|
|
) VALUES ($1, $2, 'package_purchase', $3, 'purchase', $4)
|
|
`, [userId, creditsToAdd, newBalance, paymentId]);
|
|
}
|
|
|
|
// Step 4: Grant race entry tickets if the product includes them.
|
|
if (raceEntriesToAdd > 0) {
|
|
const raceEntryUpdate = await client.query(`
|
|
UPDATE users
|
|
SET race_entries = race_entries + $1
|
|
WHERE id = $2
|
|
RETURNING race_entries
|
|
`, [raceEntriesToAdd, userId]);
|
|
if (backingRequired !== null && paidAtomic !== null && paidAtomic >= backingRequired) {
|
|
const lotInsert = await client.query(`
|
|
INSERT INTO race_entry_lots (
|
|
user_id, payment_id, unit_value_atomic, original_entries,
|
|
remaining_entries, product_id
|
|
) VALUES ($1, $2, $3, $4, $4, $5)
|
|
ON CONFLICT (payment_id) DO NOTHING
|
|
RETURNING id
|
|
`, [userId, paymentId, raceEntryValueAtomic.toString(), raceEntriesToAdd, productId]);
|
|
if (lotInsert.rowCount !== 1) {
|
|
throw new Error(`Race-entry backing lot already exists for payment ${paymentId}`);
|
|
}
|
|
}
|
|
await client.query(`
|
|
INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, payment_id, metadata)
|
|
VALUES ($1, $2, $3, 'purchase', $4, $5::jsonb)
|
|
`, [
|
|
userId,
|
|
raceEntriesToAdd,
|
|
raceEntryUpdate.rows[0].race_entries,
|
|
paymentId,
|
|
JSON.stringify({
|
|
productId,
|
|
raceEntryValueAtomic: raceEntryValueAtomic?.toString() || null,
|
|
backingRequiredAtomic: backingRequired?.toString() || null
|
|
})
|
|
]);
|
|
}
|
|
|
|
// Step 5: Grant cosmetic/render packs. Only newly inserted rows belong to
|
|
// this payment; a refund must never delete ownership from an earlier source.
|
|
const newlyGrantedPacks = [];
|
|
for (const pack of productGrants.packs) {
|
|
const packInsert = await client.query(`
|
|
INSERT INTO user_pack_entitlements (user_id, pack_id, source, expires_at, metadata)
|
|
VALUES ($1, $2, $3, $4, $5::jsonb)
|
|
ON CONFLICT (user_id, pack_id) DO NOTHING
|
|
RETURNING pack_id
|
|
`, [
|
|
userId,
|
|
pack.id,
|
|
pack.source || 'product_purchase',
|
|
pack.expiresAt || null,
|
|
JSON.stringify({ productId, paymentId })
|
|
]);
|
|
if (packInsert.rows?.[0]?.pack_id) {
|
|
newlyGrantedPacks.push({
|
|
id: packInsert.rows[0].pack_id,
|
|
source: pack.source || 'product_purchase',
|
|
expiresAt: pack.expiresAt || null
|
|
});
|
|
}
|
|
}
|
|
|
|
// Unique payment-scoped grant claim. All balance/entitlement mutations above
|
|
// roll back if this marker already exists, so confirmation is exactly once.
|
|
const entitlementGrant = 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, premium_level_previous, metadata
|
|
) VALUES (
|
|
$1, $2, 'product_confirmation', $3, $3, $4, $5::jsonb,
|
|
$6, $7, $8::jsonb
|
|
)
|
|
ON CONFLICT (payment_id) DO NOTHING
|
|
RETURNING payment_id
|
|
`, [
|
|
paymentId,
|
|
userId,
|
|
creditsToAdd,
|
|
raceEntriesToAdd,
|
|
JSON.stringify(newlyGrantedPacks),
|
|
premiumChanged ? productGrants.premiumLevel : null,
|
|
premiumChanged ? previousPremiumLevel : null,
|
|
JSON.stringify({ productId, requestedPacks: productGrants.packs.map(pack => pack.id) })
|
|
]);
|
|
if (entitlementGrant.rowCount === 0) {
|
|
throw new Error(`Payment ${paymentId} entitlement grant already exists`);
|
|
}
|
|
|
|
const grantRows = await client.query(`
|
|
SELECT pack_id
|
|
FROM user_pack_entitlements
|
|
WHERE user_id = $1
|
|
AND (expires_at IS NULL OR expires_at > NOW())
|
|
`, [userId]);
|
|
|
|
return { newBalance, totalCreditsPurchased, premiumLevel, packGrants: grantRows.rows || [] };
|
|
});
|
|
|
|
if (result.alreadyProcessed) {
|
|
console.log(`Payment ${paymentId} already confirmed (atomic check), skipping duplicate`);
|
|
return { success: false, alreadyProcessed: true, reason: 'Payment already processed' };
|
|
}
|
|
|
|
const entitlements = Entitlements.snapshotForUser({
|
|
id: userId,
|
|
credits: result.newBalance,
|
|
total_credits_purchased: result.totalCreditsPurchased,
|
|
premium_level: result.premiumLevel
|
|
}, result.packGrants);
|
|
|
|
console.log(`Product payment confirmed: ${productId} (+${creditsToAdd} credits) for user ${userId}, new balance: ${result.newBalance}`);
|
|
|
|
return {
|
|
success: true,
|
|
creditsAdded: creditsToAdd,
|
|
newBalance: result.newBalance,
|
|
totalCreditsPurchased: result.totalCreditsPurchased,
|
|
grantsApplied: grantJson,
|
|
entitlements
|
|
};
|
|
} catch (error) {
|
|
const normalized = normalizeError(error, 'Failed to process product payment confirmation');
|
|
console.error('Error processing product payment:', normalized.message);
|
|
return {
|
|
success: false,
|
|
reason: normalized.safeMessage
|
|
};
|
|
}
|
|
}
|
|
|
|
async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null, receivedAmount = null, receipts = []) {
|
|
return this.processProductPaymentConfirmation(socketId, paymentId, packageInfo, receivedAmount, receipts);
|
|
}
|
|
|
|
/**
|
|
* Record a direct/single_game entry as "buy 1 credit and immediately spend it on this game":
|
|
* the balance nets to zero but total_credits_purchased advances, so a direct payment unlocks
|
|
* the same tier/threshold cosmetics as buying credits. Idempotency is the caller's: the
|
|
* single_game confirmation runs once per payment via its status='pending' -> 'confirmed'
|
|
* guard. Returns { totalCreditsPurchased, balance, entitlements } or null.
|
|
*/
|
|
async recordDirectEntryPurchase(socketId) {
|
|
try {
|
|
const user = await this.getOrCreateUser(socketId, { create: false });
|
|
if (!user || user.id == null) return null;
|
|
let totalCreditsPurchased = 0;
|
|
let balance = 0;
|
|
let premiumLevel = null;
|
|
await this.db.withTransaction(async (client) => {
|
|
const upd = await client.query(
|
|
`UPDATE users
|
|
SET total_credits_purchased = COALESCE(total_credits_purchased, 0) + 1
|
|
WHERE id = $1
|
|
RETURNING total_credits_purchased, credits, premium_level`,
|
|
[user.id]
|
|
);
|
|
if (!upd.rows.length) return;
|
|
totalCreditsPurchased = Number(upd.rows[0].total_credits_purchased || 0);
|
|
balance = Number(upd.rows[0].credits || 0);
|
|
premiumLevel = upd.rows[0].premium_level || null;
|
|
// Audit trail: bought 1 credit + spent it on this game (net 0 balance).
|
|
await client.query(
|
|
`INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
|
VALUES ($1, 1, 'direct_entry', $2, 'purchase'),
|
|
($1, -1, 'game_entry', $2, 'spend')`,
|
|
[user.id, balance]
|
|
);
|
|
});
|
|
|
|
let packGrants = [];
|
|
try {
|
|
const g = await this.db.query(
|
|
`SELECT pack_id, source, granted_at, expires_at FROM user_pack_entitlements
|
|
WHERE user_id = $1 AND (expires_at IS NULL OR expires_at > NOW())`,
|
|
[user.id]
|
|
);
|
|
packGrants = g.rows || [];
|
|
} catch (_) { packGrants = []; }
|
|
|
|
const entitlements = Entitlements.snapshotForUser({
|
|
id: user.id,
|
|
credits: balance,
|
|
total_credits_purchased: totalCreditsPurchased,
|
|
premium_level: premiumLevel
|
|
}, packGrants);
|
|
|
|
return { totalCreditsPurchased, balance, entitlements };
|
|
} catch (err) {
|
|
const normalized = normalizeError(err, 'Failed to record direct entry purchase');
|
|
console.error('❌ recordDirectEntryPurchase error:', normalized.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async _findReusablePayment(userId, paymentType, productId = null) {
|
|
if (!userId) return null;
|
|
const result = await this.db.query(`
|
|
SELECT id, user_id, subaddress, expected_amount, payment_type, status, created_at, expires_at,
|
|
description, product_id, product_grants,
|
|
fairness_proof_version, fairness_offer_id, fairness_offer_issued_at,
|
|
fairness_commitment, fairness_server_seed, fairness_client_seed,
|
|
fairness_bound_at, fairness_consumed_at
|
|
FROM payments
|
|
WHERE user_id = $1
|
|
AND payment_type = $2
|
|
AND ($3::text IS NULL OR product_id = $3)
|
|
AND status = 'pending'
|
|
AND (expires_at IS NULL OR expires_at > NOW())
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`, [userId, paymentType, productId || null]);
|
|
const row = result.rows[0] || null;
|
|
if (row && paymentType === 'single_game' && this._requiresPaidFairnessV2()
|
|
&& (!this._paymentFairnessProofFromRow(row) || row.fairness_consumed_at != null)) {
|
|
return null;
|
|
}
|
|
return row;
|
|
}
|
|
|
|
/**
|
|
* Convert package info to JSON-safe format (BigInt price -> Number)
|
|
*/
|
|
_serializePackageInfo(packageInfo) {
|
|
if (!packageInfo) return null;
|
|
return {
|
|
...packageInfo,
|
|
price: packageInfo.price == null ? packageInfo.price : money.toSafe(money.toBig(packageInfo.price)),
|
|
grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(packageInfo, {
|
|
credits: Number(packageInfo.credits || 0) + Number(packageInfo.bonus || 0)
|
|
}))
|
|
};
|
|
}
|
|
|
|
_mapPaymentRowToRequest(row, paymentType, packageInfo) {
|
|
if (!row) return null;
|
|
const amount = money.toSafe(money.toBig(row.expected_amount));
|
|
return {
|
|
id: row.id,
|
|
ownerUserId: row.user_id,
|
|
address: row.subaddress,
|
|
amount,
|
|
amountFormatted: this.formatAtomicHuman(amount, 4),
|
|
currency: this.cryptoType,
|
|
expiresAt: row.expires_at,
|
|
paymentType,
|
|
description: row.description,
|
|
productId: row.product_id || null,
|
|
grants: row.product_grants || null,
|
|
package: this._serializePackageInfo(packageInfo),
|
|
fairnessProof: paymentType === 'single_game'
|
|
? this._paymentFairnessProofFromRow(row)
|
|
: null,
|
|
reused: true
|
|
};
|
|
}
|
|
|
|
logConfiguration(context = 'initialized') {
|
|
console.log(`🎮 Game Mode Manager ${context}: ${this.gameMode} mode`);
|
|
console.log(`💰 Currency: ${this.cryptoType} (decimals: ${this.currencyDecimals})`);
|
|
console.log(`💵 Single game price: ${this.singleGamePrice} atomic units (~${this.formatAtomic(this.singleGamePrice)} ${this.cryptoType})`);
|
|
console.log(`💵 Credits payout base: ${this.creditsPayoutBaseValue} atomic units (~${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`);
|
|
console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units (~${this.formatAtomic(this.creditsPackagePrice)} ${this.cryptoType})`);
|
|
console.log(`🎯 Credits per game cost: ${this.creditsPerGameCost}`);
|
|
console.log(`🧮 Payout multipliers - direct: ${JSON.stringify(this.directPayoutMultipliers)}, credits: ${JSON.stringify(this.creditPayoutMultipliers)}`);
|
|
console.log(`🔁 Mode availability - direct: ${this.directModeEnabled}, credits: ${this.creditsModeEnabled}, preferCreditsFirst: ${this.preferCreditsFirst}`);
|
|
console.log(`🛡️ Payout master switch: ${this.payoutsEnabled ? 'ENABLED' : 'DISABLED'}`);
|
|
if (this.isPayoutEnabledForMode('PAID_CREDITS')) {
|
|
console.log(`🎁 Credits payout mode ENABLED - base value: ${this.creditsPayoutBaseValue} atomic (~${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`);
|
|
}
|
|
console.log(`⚙️ Payments enabled: ${this.paymentsEnabled}`);
|
|
}
|
|
|
|
/**
|
|
* Get effective game mode for a specific user, considering:
|
|
* - Whether both modes are enabled (mixed mode)
|
|
* - User's credit balance
|
|
* - preferCreditsFirst setting
|
|
* @param {object} user - User object with credits field
|
|
* @returns {object} { mode, canUseCredits, canUseDirect, hasCredits, creditsBalance }
|
|
*/
|
|
getEffectiveModeForUser(user) {
|
|
const hasCredits = (user?.credits || 0) >= this.creditsPerGameCost;
|
|
const bothModesEnabled = this.directModeEnabled && this.creditsModeEnabled;
|
|
|
|
// Determine available options
|
|
const canUseCredits = this.creditsModeEnabled && hasCredits;
|
|
const canUseDirect = this.directModeEnabled;
|
|
|
|
// Determine effective mode
|
|
let effectiveMode;
|
|
if (!this.paymentsEnabled) {
|
|
effectiveMode = 'FREE';
|
|
} else if (bothModesEnabled) {
|
|
// Mixed mode: prefer based on config and availability
|
|
if (this.preferCreditsFirst && hasCredits) {
|
|
effectiveMode = 'PAID_CREDITS';
|
|
} else if (hasCredits) {
|
|
effectiveMode = 'PAID_CREDITS'; // Has credits, can use them
|
|
} else {
|
|
effectiveMode = 'PAID_SINGLE'; // No credits, must pay
|
|
}
|
|
} else if (this.creditsModeEnabled) {
|
|
effectiveMode = 'PAID_CREDITS';
|
|
} else if (this.directModeEnabled) {
|
|
effectiveMode = 'PAID_SINGLE';
|
|
} else {
|
|
effectiveMode = 'FREE';
|
|
}
|
|
|
|
return {
|
|
mode: effectiveMode,
|
|
canUseCredits,
|
|
canUseDirect,
|
|
hasCredits,
|
|
creditsBalance: user?.credits || 0,
|
|
creditsRequired: this.creditsPerGameCost,
|
|
bothModesEnabled,
|
|
preferCreditsFirst: this.preferCreditsFirst,
|
|
creditsPayoutsEnabled: this.isPayoutEnabledForMode('PAID_CREDITS'),
|
|
directPayoutsEnabled: this.isPayoutEnabledForMode('PAID_SINGLE')
|
|
};
|
|
}
|
|
|
|
getPublicPreferredPaymentMode(user) {
|
|
if (!this.paymentsEnabled) {
|
|
return 'free';
|
|
}
|
|
|
|
const preferred = String(user?.preferred_payment_mode || '').trim().toLowerCase();
|
|
if (preferred === 'direct' && this.directModeEnabled) {
|
|
return 'direct';
|
|
}
|
|
if (preferred === 'credits' && this.creditsModeEnabled) {
|
|
return 'credits';
|
|
}
|
|
if (this.creditsModeEnabled) {
|
|
return 'credits';
|
|
}
|
|
if (this.directModeEnabled) {
|
|
return 'direct';
|
|
}
|
|
return 'free';
|
|
}
|
|
|
|
/**
|
|
* Get available payment options for a user
|
|
* @param {string} socketId - Socket ID
|
|
* @returns {object} Available options for the user
|
|
*/
|
|
async getPaymentOptionsForUser(socketId) {
|
|
try {
|
|
const user = await this.getOrCreateUser(socketId);
|
|
const effective = this.getEffectiveModeForUser(user);
|
|
|
|
const options = [];
|
|
|
|
// Free play (Pleb board, no payout) is offered alongside paid options when enabled.
|
|
if (this.freePlayEnabled && this.paymentsEnabled) {
|
|
options.push({
|
|
type: 'play_free',
|
|
label: 'Play Free (Pleb leaderboard)',
|
|
mode: 'FREE',
|
|
cost: 0,
|
|
costDisplay: 'Free',
|
|
payoutEligible: false,
|
|
recommended: false
|
|
});
|
|
}
|
|
|
|
if (effective.canUseCredits) {
|
|
options.push({
|
|
type: 'use_credit',
|
|
label: `Use 1 Credit (${effective.creditsBalance} available)`,
|
|
mode: 'PAID_CREDITS',
|
|
cost: 0,
|
|
costDisplay: '1 credit',
|
|
payoutEligible: effective.creditsPayoutsEnabled,
|
|
recommended: effective.preferCreditsFirst
|
|
});
|
|
}
|
|
|
|
if (effective.canUseDirect) {
|
|
options.push({
|
|
type: 'pay_direct',
|
|
label: `Pay ${this.formatAtomicHuman(this.singleGamePrice, 2)} ${this.cryptoType}`,
|
|
mode: 'PAID_SINGLE',
|
|
cost: this.singleGamePrice,
|
|
costDisplay: `${this.formatAtomicHuman(this.singleGamePrice, 2)} ${this.cryptoType}`,
|
|
payoutEligible: effective.directPayoutsEnabled,
|
|
recommended: !effective.preferCreditsFirst || !effective.hasCredits
|
|
});
|
|
}
|
|
|
|
if (this.creditsModeEnabled) {
|
|
const pkg = this.getPrimaryCreditPackage();
|
|
options.push({
|
|
type: 'buy_credits',
|
|
label: `Buy ${pkg.credits} Credits`,
|
|
mode: 'PURCHASE',
|
|
cost: money.toSafe(money.toBig(pkg.price)),
|
|
costDisplay: `${this.formatAtomicHuman(pkg.price, 2)} ${this.cryptoType}`,
|
|
credits: pkg.credits + (pkg.bonus || 0),
|
|
payoutEligible: false,
|
|
recommended: false
|
|
});
|
|
}
|
|
|
|
return {
|
|
user: {
|
|
credits: effective.creditsBalance,
|
|
hasPayoutAddress: !!user.payout_address
|
|
},
|
|
effective,
|
|
options
|
|
};
|
|
} catch (error) {
|
|
const normalized = normalizeError(error, 'Failed to get payment options');
|
|
console.error('❌ Error getting payment options:', normalized.message);
|
|
return {
|
|
user: { credits: 0, hasPayoutAddress: false },
|
|
effective: { mode: 'FREE' },
|
|
options: []
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user can start a game
|
|
*/
|
|
async canUserStartGame(socketId) {
|
|
try {
|
|
const user = await this.getOrCreateUser(socketId);
|
|
const effective = this.getEffectiveModeForUser(user);
|
|
|
|
// FREE mode or payments disabled
|
|
if (effective.mode === 'FREE') {
|
|
return { allowed: true, reason: 'Free mode' };
|
|
}
|
|
|
|
// Mixed mode: mirror _dispatchGameStart's exact precedence so eligibility/address
|
|
// checks approve the same mode that will actually be consumed.
|
|
if (effective.bothModesEnabled) {
|
|
// Credits win immediately only when the operator prefers them.
|
|
if (effective.hasCredits && this.preferCreditsFirst) {
|
|
return {
|
|
allowed: true,
|
|
reason: `${effective.creditsBalance} credits available`,
|
|
useCredits: true,
|
|
creditsRequired: this.creditsPerGameCost,
|
|
balance: effective.creditsBalance,
|
|
effectiveMode: 'PAID_CREDITS'
|
|
};
|
|
}
|
|
|
|
// Otherwise an unclaimed confirmed single_game payment admits the player.
|
|
const pendingPayment = await this.db.query(`
|
|
SELECT * FROM payments
|
|
WHERE user_id = $1 AND status = 'confirmed'
|
|
AND payment_type = 'single_game'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM games
|
|
WHERE games.payment_id = payments.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM payment_entitlement_grants peg
|
|
WHERE peg.payment_id = payments.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM credit_transactions ct
|
|
WHERE ct.user_id = payments.user_id
|
|
AND ct.reason = 'single_game_recovered:' || payments.id
|
|
)
|
|
AND ($2::boolean = FALSE OR (
|
|
fairness_bound_at IS NOT NULL
|
|
AND fairness_consumed_at IS NULL
|
|
AND fairness_proof_version = 2
|
|
))
|
|
ORDER BY confirmed_at DESC
|
|
LIMIT 1
|
|
`, [user.id, this._requiresPaidFairnessV2()]);
|
|
|
|
if (pendingPayment.rows.length > 0) {
|
|
return {
|
|
allowed: true,
|
|
reason: 'Payment confirmed',
|
|
paymentId: pendingPayment.rows[0].id,
|
|
fairnessProof: this._paymentFairnessProofFromRow(pendingPayment.rows[0]),
|
|
effectiveMode: 'PAID_SINGLE'
|
|
};
|
|
}
|
|
|
|
// With no direct entry to claim, credits remain the fallback even when direct
|
|
// is preferred.
|
|
if (effective.hasCredits) {
|
|
return {
|
|
allowed: true,
|
|
reason: `${effective.creditsBalance} credits available`,
|
|
useCredits: true,
|
|
creditsRequired: this.creditsPerGameCost,
|
|
balance: effective.creditsBalance,
|
|
effectiveMode: 'PAID_CREDITS'
|
|
};
|
|
}
|
|
|
|
// Neither credits nor payment available
|
|
return {
|
|
allowed: false,
|
|
reason: 'Payment or credits required',
|
|
action: 'choose_payment',
|
|
options: await this.getPaymentOptionsForUser(socketId)
|
|
};
|
|
}
|
|
|
|
// Single mode logic (backwards compatible)
|
|
switch (effective.mode) {
|
|
case 'PAID_CREDITS':
|
|
if (effective.hasCredits) {
|
|
return {
|
|
allowed: true,
|
|
reason: `${effective.creditsBalance} credits remaining`,
|
|
creditsRequired: this.creditsPerGameCost,
|
|
effectiveMode: 'PAID_CREDITS'
|
|
};
|
|
}
|
|
return {
|
|
allowed: false,
|
|
reason: 'Insufficient credits',
|
|
action: 'purchase_credits',
|
|
creditsRequired: this.creditsPerGameCost,
|
|
balance: effective.creditsBalance
|
|
};
|
|
|
|
case 'PAID_SINGLE':
|
|
const payment = await this.db.query(`
|
|
SELECT * FROM payments
|
|
WHERE user_id = $1 AND status = 'confirmed'
|
|
AND payment_type = 'single_game'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM games
|
|
WHERE games.payment_id = payments.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM payment_entitlement_grants peg
|
|
WHERE peg.payment_id = payments.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM credit_transactions ct
|
|
WHERE ct.user_id = payments.user_id
|
|
AND ct.reason = 'single_game_recovered:' || payments.id
|
|
)
|
|
AND ($2::boolean = FALSE OR (
|
|
fairness_bound_at IS NOT NULL
|
|
AND fairness_consumed_at IS NULL
|
|
AND fairness_proof_version = 2
|
|
))
|
|
ORDER BY confirmed_at DESC
|
|
LIMIT 1
|
|
`, [user.id, this._requiresPaidFairnessV2()]);
|
|
|
|
if (payment.rows.length > 0) {
|
|
return {
|
|
allowed: true,
|
|
reason: 'Payment confirmed',
|
|
paymentId: payment.rows[0].id,
|
|
fairnessProof: this._paymentFairnessProofFromRow(payment.rows[0]),
|
|
effectiveMode: 'PAID_SINGLE'
|
|
};
|
|
}
|
|
|
|
return {
|
|
allowed: false,
|
|
reason: 'Payment required',
|
|
action: 'make_payment'
|
|
};
|
|
|
|
default:
|
|
return { allowed: false, reason: 'Invalid game mode' };
|
|
}
|
|
} catch (error) {
|
|
const normalized = normalizeError(error, 'Unable to verify game eligibility');
|
|
console.error('❌ Error checking user game eligibility:', normalized.message);
|
|
return { allowed: false, reason: normalized.safeMessage };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process game start (deduct credits or link payment). Fairness input is deliberately not
|
|
* accepted here: the one-time offer/client seed must already have been consumed by
|
|
* SocketHandlers and passed into Game before its dungeon is generated.
|
|
*/
|
|
async processGameStart(socketId, gameId, options = {}) {
|
|
if (this._gameAdmissionClosed) {
|
|
return {
|
|
success: false,
|
|
code: 'SERVER_SHUTTING_DOWN',
|
|
reason: 'Game admission is temporarily closed while the server restarts.'
|
|
};
|
|
}
|
|
const admission = this._dispatchGameStart(socketId, gameId, options);
|
|
this._gameStartAdmissions.add(admission);
|
|
try {
|
|
return await admission;
|
|
} finally {
|
|
this._gameStartAdmissions.delete(admission);
|
|
}
|
|
}
|
|
|
|
/** Close new solo entry consumption synchronously at the restart boundary. */
|
|
beginGameAdmissionShutdown() {
|
|
this._gameAdmissionClosed = true;
|
|
}
|
|
|
|
async drainGameStartAdmissions() {
|
|
while (this._gameStartAdmissions.size > 0) {
|
|
await Promise.allSettled(Array.from(this._gameStartAdmissions));
|
|
}
|
|
return { pending: 0 };
|
|
}
|
|
|
|
async _dispatchGameStart(socketId, gameId, options = {}) {
|
|
try {
|
|
const user = await this.getOrCreateUser(socketId);
|
|
|
|
// The player explicitly chose FREE play (even on an instance that also sells
|
|
// credits/entry). Record this game as FREE so it lands on the Pleb leaderboard,
|
|
// with no payment and no payout.
|
|
if (options.forceFree) {
|
|
return await this._processGameStartFree(user, gameId);
|
|
}
|
|
|
|
const effective = this.getEffectiveModeForUser(user);
|
|
|
|
// FREE mode (instance is free-only)
|
|
if (effective.mode === 'FREE') {
|
|
return { success: true, effectiveMode: 'FREE' };
|
|
}
|
|
if (!this._isFinancialAdmissionAllowed()) {
|
|
return this._financialRecoveryPendingResult();
|
|
}
|
|
|
|
// Mixed mode: determine which method to use
|
|
if (effective.bothModesEnabled) {
|
|
// Prefer credits if available and preferCreditsFirst is true
|
|
if (effective.hasCredits && this.preferCreditsFirst) {
|
|
return await this._processGameStartWithCredits(user, socketId, gameId);
|
|
}
|
|
|
|
// Check for confirmed direct payment
|
|
const payment = await this.db.query(`
|
|
SELECT * FROM payments
|
|
WHERE user_id = $1 AND status = 'confirmed'
|
|
AND payment_type = 'single_game'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM games
|
|
WHERE games.payment_id = payments.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM payment_entitlement_grants peg
|
|
WHERE peg.payment_id = payments.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM credit_transactions ct
|
|
WHERE ct.user_id = payments.user_id
|
|
AND ct.reason = 'single_game_recovered:' || payments.id
|
|
)
|
|
AND ($2::boolean = FALSE OR (
|
|
fairness_bound_at IS NOT NULL
|
|
AND fairness_consumed_at IS NULL
|
|
AND fairness_proof_version = 2
|
|
))
|
|
ORDER BY confirmed_at DESC
|
|
LIMIT 1
|
|
`, [user.id, this._requiresPaidFairnessV2()]);
|
|
|
|
if (payment.rows.length > 0) {
|
|
return await this._processGameStartWithPayment(user, payment.rows[0], gameId);
|
|
}
|
|
|
|
// Fall back to credits if available
|
|
if (effective.hasCredits) {
|
|
return await this._processGameStartWithCredits(user, socketId, gameId);
|
|
}
|
|
|
|
return { success: false, reason: 'No valid payment or credits found' };
|
|
}
|
|
|
|
// Single mode logic
|
|
switch (effective.mode) {
|
|
case 'PAID_CREDITS':
|
|
return await this._processGameStartWithCredits(user, socketId, gameId);
|
|
|
|
case 'PAID_SINGLE':
|
|
const payment = await this.db.query(`
|
|
SELECT * FROM payments
|
|
WHERE user_id = $1 AND status = 'confirmed'
|
|
AND payment_type = 'single_game'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM games
|
|
WHERE games.payment_id = payments.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM payment_entitlement_grants peg
|
|
WHERE peg.payment_id = payments.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM credit_transactions ct
|
|
WHERE ct.user_id = payments.user_id
|
|
AND ct.reason = 'single_game_recovered:' || payments.id
|
|
)
|
|
AND ($2::boolean = FALSE OR (
|
|
fairness_bound_at IS NOT NULL
|
|
AND fairness_consumed_at IS NULL
|
|
AND fairness_proof_version = 2
|
|
))
|
|
ORDER BY confirmed_at DESC
|
|
LIMIT 1
|
|
`, [user.id, this._requiresPaidFairnessV2()]);
|
|
|
|
if (payment.rows.length === 0) {
|
|
return { success: false, reason: 'No valid payment found' };
|
|
}
|
|
|
|
return await this._processGameStartWithPayment(user, payment.rows[0], gameId);
|
|
|
|
default:
|
|
return { success: false, reason: 'Invalid game mode' };
|
|
}
|
|
} catch (error) {
|
|
const normalized = normalizeError(error, 'Failed to process game start');
|
|
console.error('❌ Error processing game start:', normalized.message);
|
|
return { success: false, reason: normalized.safeMessage };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute the payout terms to snapshot on a game at start, so an admin changing
|
|
* config mid-game can't alter an in-flight game's payout. Returns atomic amounts as
|
|
* strings (for the BIGINT columns) plus the multipliers for the audit trail.
|
|
*/
|
|
_computePayoutSnapshot(mode) {
|
|
const escape = this._effectivePayoutOutcome(mode, false);
|
|
const treasure = this._effectivePayoutOutcome(mode, true);
|
|
const bounds = this._payoutBoundsForMode(mode);
|
|
const eligible = this.isPayoutEnabledForMode(mode);
|
|
const snapshot = {
|
|
// These are exact payable amounts after applying the configured bounds. Settlement,
|
|
// admission accounting, and the browser all consume the same contract.
|
|
escapeAmount: escape.amountAtomic,
|
|
treasureAmount: treasure.amountAtomic,
|
|
escapeMult: escape.multiplier,
|
|
treasureMult: treasure.multiplier,
|
|
eligible
|
|
};
|
|
snapshot.terms = {
|
|
version: 2,
|
|
mode: String(mode || '').toUpperCase(),
|
|
eligible,
|
|
currency: this.cryptoType,
|
|
currencyDecimals: this.currencyDecimals,
|
|
escapeAmount: snapshot.escapeAmount,
|
|
treasureAmount: snapshot.treasureAmount,
|
|
escapeMultiplier: snapshot.escapeMult,
|
|
treasureMultiplier: snapshot.treasureMult,
|
|
minAmount: bounds?.min == null ? null : String(bounds.min),
|
|
maxAmount: bounds?.max == null ? null : String(bounds.max),
|
|
outcomes: {
|
|
escape,
|
|
escapeWithTreasure: treasure
|
|
}
|
|
};
|
|
return snapshot;
|
|
}
|
|
|
|
/**
|
|
* One exact, user-visible payout outcome. Raw multiplier math is retained for audit, while
|
|
* amountAtomic is what can actually be committed and paid after min/max policy.
|
|
*/
|
|
_effectivePayoutOutcome(mode, treasureFound) {
|
|
const normalizedMode = String(mode || '').toUpperCase();
|
|
const calculated = this.calculatePayout(normalizedMode, { treasureFound });
|
|
const rawAmount = money.toBig(calculated.amount);
|
|
const bounds = this._payoutBoundsForMode(normalizedMode);
|
|
const eligible = this.isPayoutEnabledForMode(normalizedMode);
|
|
let effective = eligible ? rawAmount : 0n;
|
|
let capApplied = false;
|
|
let suppressedReason = eligible ? null : 'payouts_disabled';
|
|
|
|
if (eligible && bounds?.max !== null && bounds?.max !== undefined && effective > bounds.max) {
|
|
effective = bounds.max;
|
|
capApplied = true;
|
|
}
|
|
if (eligible && (effective <= 0n
|
|
|| (bounds?.min !== null && bounds?.min !== undefined && effective < bounds.min))) {
|
|
effective = 0n;
|
|
suppressedReason = 'below_minimum';
|
|
}
|
|
|
|
const decimals = Number.isInteger(this.currencyDecimals) && this.currencyDecimals >= 0
|
|
? this.currencyDecimals
|
|
: inferCurrencyDecimalsForSymbol(this.cryptoType);
|
|
return {
|
|
payable: eligible && effective > 0n,
|
|
amountAtomic: effective.toString(),
|
|
amountFormatted: money.format(effective, decimals),
|
|
rawAmountAtomic: rawAmount.toString(),
|
|
multiplier: calculated.multiplier,
|
|
capApplied,
|
|
suppressedReason
|
|
};
|
|
}
|
|
|
|
_publicPayoutOutcomes(mode) {
|
|
return {
|
|
escape: this._effectivePayoutOutcome(mode, false),
|
|
escapeWithTreasure: this._effectivePayoutOutcome(mode, true)
|
|
};
|
|
}
|
|
|
|
_snapshotMaxLiability(snapshot) {
|
|
if (!snapshot?.eligible) return 0n;
|
|
const escape = money.toBig(snapshot.escapeAmount || 0);
|
|
const treasure = money.toBig(snapshot.treasureAmount || 0);
|
|
return escape > treasure ? escape : treasure;
|
|
}
|
|
|
|
/**
|
|
* Start a game the player explicitly chose to play for FREE. Records game_mode='FREE'
|
|
* on the game (overriding the instance default, so it goes to the Pleb leaderboard) and
|
|
* counts the game. No payment, no payout.
|
|
*/
|
|
async _processGameStartFree(user, gameId) {
|
|
await this.db.withTransaction(async (client) => {
|
|
const gameUpdate = await client.query(`
|
|
UPDATE games SET game_mode = 'FREE', payout_eligible = FALSE,
|
|
payout_terms = $2::jsonb, payout_committed_at = NOW(),
|
|
entry_consumed_at = NOW(), entry_credits_spent = NULL
|
|
WHERE dungeon_seed = $1
|
|
RETURNING id
|
|
`, [gameId, JSON.stringify({ version: 1, mode: 'FREE', eligible: false })]);
|
|
if (gameUpdate.rowCount === 0) {
|
|
throw Object.assign(new Error('Durable game row not found'), { code: 'GAME_ROW_REQUIRED' });
|
|
}
|
|
await client.query(`
|
|
UPDATE users SET total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $1
|
|
`, [user.id]);
|
|
});
|
|
return { success: true, effectiveMode: 'FREE' };
|
|
}
|
|
|
|
async _processGameStartWithCredits(user, socketId, gameId) {
|
|
if (!this._isFinancialAdmissionAllowed()) {
|
|
return this._financialRecoveryPendingResult();
|
|
}
|
|
const creditsToSpend = this.creditsPerGameCost;
|
|
const snap = this._computePayoutSnapshot('PAID_CREDITS');
|
|
const requiresAddress = this.requiresPayoutAddressForMode('PAID_CREDITS');
|
|
if (requiresAddress && (typeof user.payout_address !== 'string' || !user.payout_address.trim())) {
|
|
return { success: false, reason: 'A payout address is required before starting this game.' };
|
|
}
|
|
|
|
const result = await this.db.withTransaction(async (client) => {
|
|
if (snap.eligible) {
|
|
await reservePayoutCapacity({
|
|
client,
|
|
walletService: this.walletService,
|
|
newLiability: this._snapshotMaxLiability(snap),
|
|
gameModeManager: this
|
|
});
|
|
}
|
|
// Conditional update prevents credits going negative
|
|
const updateRes = await client.query(`
|
|
UPDATE users
|
|
SET credits = credits - $1,
|
|
total_games_played = total_games_played + 1,
|
|
updated_at = NOW()
|
|
WHERE id = $2 AND credits >= $1
|
|
AND ($3::boolean = FALSE OR NULLIF(BTRIM(payout_address), '') IS NOT NULL)
|
|
RETURNING credits, total_credits_purchased, payout_address
|
|
`, [creditsToSpend, user.id, requiresAddress]);
|
|
|
|
if (updateRes.rows.length === 0) {
|
|
return { admissionDenied: true };
|
|
}
|
|
|
|
const remainingCredits = updateRes.rows[0].credits;
|
|
const totalCreditsPurchased = updateRes.rows[0].total_credits_purchased || user.total_credits_purchased || 0;
|
|
|
|
const gameUpdate = await client.query(`
|
|
UPDATE games SET game_mode = 'PAID_CREDITS', payout_address = $2,
|
|
payout_escape_amount = $3, payout_treasure_amount = $4,
|
|
payout_escape_mult = $5, payout_treasure_mult = $6,
|
|
payout_eligible = $7, payout_terms = $8::jsonb,
|
|
payout_committed_at = NOW(), entry_consumed_at = NOW(),
|
|
entry_credits_spent = $9
|
|
WHERE dungeon_seed = $1
|
|
RETURNING id
|
|
`, [gameId, updateRes.rows[0].payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult, snap.eligible, JSON.stringify(snap.terms), creditsToSpend]);
|
|
if (gameUpdate.rowCount === 0) {
|
|
throw Object.assign(new Error('Durable game row not found'), { code: 'GAME_ROW_REQUIRED' });
|
|
}
|
|
|
|
await client.query(`
|
|
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
|
VALUES ($1, $2, 'game_entry', $3, 'spend')
|
|
`, [user.id, -creditsToSpend, remainingCredits]);
|
|
|
|
return { remainingCredits, totalCreditsPurchased };
|
|
});
|
|
|
|
if (result.admissionDenied) {
|
|
return {
|
|
success: false,
|
|
reason: requiresAddress
|
|
? 'Insufficient credits or payout address is missing'
|
|
: 'Insufficient credits',
|
|
creditsRequired: creditsToSpend,
|
|
creditsAvailable: user.credits
|
|
};
|
|
}
|
|
|
|
console.log(`Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${result.remainingCredits} remaining`);
|
|
|
|
return {
|
|
success: true,
|
|
creditsRemaining: result.remainingCredits,
|
|
totalCreditsPurchased: result.totalCreditsPurchased,
|
|
creditsSpent: creditsToSpend,
|
|
effectiveMode: 'PAID_CREDITS'
|
|
};
|
|
}
|
|
|
|
async _processGameStartWithPayment(user, payment, gameId) {
|
|
if (!this._isFinancialAdmissionAllowed()) {
|
|
return this._financialRecoveryPendingResult();
|
|
}
|
|
const snap = this._computePayoutSnapshot('PAID_SINGLE');
|
|
// Claiming a confirmed single_game payment must be atomic: the payment is re-verified as
|
|
// unclaimed and locked FOR UPDATE inside the transaction so two concurrent starts cannot
|
|
// both consume it. The unique index on games.payment_id (migration 023) is the final
|
|
// backstop; a 23505 there means "already consumed".
|
|
try {
|
|
const result = await this.db.withTransaction(async (client) => {
|
|
if (snap.eligible) {
|
|
await reservePayoutCapacity({
|
|
client,
|
|
walletService: this.walletService,
|
|
newLiability: this._snapshotMaxLiability(snap),
|
|
gameModeManager: this
|
|
});
|
|
}
|
|
const claim = await client.query(`
|
|
SELECT * FROM payments
|
|
WHERE id = $1 AND user_id = $2
|
|
AND status = 'confirmed' AND payment_type = 'single_game'
|
|
AND NOT EXISTS (SELECT 1 FROM games WHERE games.payment_id = payments.id)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM payment_entitlement_grants peg
|
|
WHERE peg.payment_id = payments.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM credit_transactions ct
|
|
WHERE ct.user_id = payments.user_id
|
|
AND ct.reason = 'single_game_recovered:' || payments.id
|
|
)
|
|
AND ($3::boolean = FALSE OR (
|
|
fairness_bound_at IS NOT NULL
|
|
AND fairness_consumed_at IS NULL
|
|
AND fairness_proof_version = 2
|
|
))
|
|
FOR UPDATE
|
|
`, [payment.id, user.id, this._requiresPaidFairnessV2()]);
|
|
|
|
if (claim.rows.length === 0) {
|
|
// Locked+re-checked: a concurrent start already linked this payment (or it is
|
|
// no longer claimable). Abort WITHOUT linking or creating a duplicate game.
|
|
return { alreadyConsumed: true };
|
|
}
|
|
|
|
const claimedPayment = claim.rows[0];
|
|
const payoutUser = await client.query(`
|
|
SELECT id, payout_address
|
|
FROM users
|
|
WHERE id = $1
|
|
FOR UPDATE
|
|
`, [user.id]);
|
|
const lockedPayoutAddress = typeof payoutUser.rows[0]?.payout_address === 'string'
|
|
? payoutUser.rows[0].payout_address.trim()
|
|
: '';
|
|
if (this.requiresPayoutAddressForMode('PAID_SINGLE') && !lockedPayoutAddress) {
|
|
return { addressRequired: true };
|
|
}
|
|
const boundProof = this._paymentFairnessProofFromRow(claimedPayment);
|
|
if (this._requiresPaidFairnessV2() && !boundProof) {
|
|
return { alreadyConsumed: true };
|
|
}
|
|
|
|
if (boundProof) {
|
|
const gameProof = await client.query(`
|
|
SELECT proof_version, fairness_offer_id, fairness_offer_issued_at,
|
|
proof_commitment, server_seed, client_seed
|
|
FROM games
|
|
WHERE dungeon_seed = $1
|
|
FOR UPDATE
|
|
`, [gameId]);
|
|
const persisted = gameProof.rows[0];
|
|
const issuedAt = persisted?.fairness_offer_issued_at instanceof Date
|
|
? persisted.fairness_offer_issued_at.getTime()
|
|
: Date.parse(persisted?.fairness_offer_issued_at);
|
|
if (!persisted
|
|
|| Number(persisted.proof_version) !== 2
|
|
|| persisted.fairness_offer_id !== boundProof.offerId
|
|
|| String(persisted.proof_commitment || '').toLowerCase() !== boundProof.commitment
|
|
|| String(persisted.server_seed || '').toLowerCase() !== boundProof.serverSeed
|
|
|| String(persisted.client_seed || '').toLowerCase() !== boundProof.clientSeed
|
|
|| issuedAt !== boundProof.offerIssuedAt) {
|
|
throw Object.assign(new Error('Game fairness proof does not match the paid invoice binding'), {
|
|
code: 'PAYMENT_FAIRNESS_MISMATCH'
|
|
});
|
|
}
|
|
}
|
|
|
|
const gameUpdate = await client.query(`
|
|
UPDATE games
|
|
SET payment_id = $1, game_mode = 'PAID_SINGLE', payout_address = $3,
|
|
payout_escape_amount = $4, payout_treasure_amount = $5,
|
|
payout_escape_mult = $6, payout_treasure_mult = $7,
|
|
payout_eligible = $8, payout_terms = $9::jsonb,
|
|
payout_committed_at = NOW(), entry_consumed_at = NOW()
|
|
WHERE dungeon_seed = $2
|
|
RETURNING id
|
|
`, [payment.id, gameId, lockedPayoutAddress || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult, snap.eligible, JSON.stringify(snap.terms)]);
|
|
if (gameUpdate.rowCount === 0) {
|
|
throw Object.assign(new Error('Durable game row not found'), { code: 'GAME_ROW_REQUIRED' });
|
|
}
|
|
|
|
if (boundProof) {
|
|
const consumed = await client.query(`
|
|
UPDATE payments
|
|
SET fairness_consumed_at = NOW()
|
|
WHERE id = $1 AND fairness_consumed_at IS NULL
|
|
RETURNING id
|
|
`, [payment.id]);
|
|
if (consumed.rowCount !== 1) {
|
|
throw Object.assign(new Error('Payment fairness offer was already consumed'), {
|
|
code: 'PAYMENT_FAIRNESS_CONSUMED'
|
|
});
|
|
}
|
|
}
|
|
|
|
await client.query(`
|
|
UPDATE users
|
|
SET total_games_played = total_games_played + 1,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, [user.id]);
|
|
|
|
return { linked: true };
|
|
});
|
|
|
|
if (result.alreadyConsumed) {
|
|
console.warn(`⚠️ Payment ${payment.id} already consumed by a concurrent start; aborting duplicate for game ${gameId}.`);
|
|
return { success: false, reason: 'Payment already consumed', alreadyConsumed: true };
|
|
}
|
|
if (result.addressRequired) {
|
|
return { success: false, reason: 'A payout address is required before starting this game.' };
|
|
}
|
|
|
|
console.log(`Linked game ${gameId} to payment ${payment.id}`);
|
|
return {
|
|
success: true,
|
|
paymentId: payment.id,
|
|
effectiveMode: 'PAID_SINGLE'
|
|
};
|
|
} catch (error) {
|
|
// 23505 = unique_violation on games.payment_id: a concurrent start linked this
|
|
// payment first. Treat as already-consumed, never an uncaught throw / duplicate.
|
|
if (error && error.code === '23505') {
|
|
console.warn(`⚠️ payment_id unique violation linking game ${gameId} to payment ${payment.id}; treating as already consumed.`);
|
|
return { success: false, reason: 'Payment already consumed', alreadyConsumed: true };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create payment request
|
|
*/
|
|
async createPaymentRequest(socketId, paymentType, options = {}) {
|
|
try {
|
|
this._assertPaymentIntakeEnabled(paymentType);
|
|
// Prefer session-resolved userId (stable across reconnects) over socket_id lookup
|
|
let user;
|
|
if (options.userId) {
|
|
const result = await this.db.query('SELECT * FROM users WHERE id = $1', [options.userId]);
|
|
user = result.rows[0] || await this.getOrCreateUser(socketId);
|
|
} else {
|
|
user = await this.getOrCreateUser(socketId);
|
|
}
|
|
const reuseExisting = options.reuseExisting !== false;
|
|
const requestedPackageId = options.packageId;
|
|
|
|
let amount;
|
|
let description;
|
|
let packageInfo = null;
|
|
let productId = null;
|
|
let productGrants = ProductGrants.normalizeProductGrants({}, { credits: 0 });
|
|
let paymentFairnessProof = null;
|
|
|
|
switch (paymentType) {
|
|
case 'single_game': {
|
|
amount = this.singleGamePrice;
|
|
description = `${this.gameName} single game entry (${this.currencyLabel})`;
|
|
productId = 'single_game';
|
|
paymentFairnessProof = this._normalizePaymentFairnessProof(options.fairnessProof, {
|
|
required: this._requiresPaidFairnessV2()
|
|
});
|
|
break;
|
|
}
|
|
case 'credits_package': {
|
|
// Find the requested package, or fall back to primary
|
|
const packages = this.configSnapshot?.modes?.credits?.packages || [];
|
|
let selectedPackage = null;
|
|
|
|
if (requestedPackageId && packages.length > 0) {
|
|
selectedPackage = packages.find(p => p.id === requestedPackageId);
|
|
}
|
|
|
|
// Fall back to first package if not found
|
|
if (!selectedPackage) {
|
|
selectedPackage = this.getPrimaryCreditPackage();
|
|
}
|
|
|
|
const packagePrice = selectedPackage?.price ?? this.creditsPackagePrice;
|
|
amount = money.toSafe(money.toBig(packagePrice));
|
|
packageInfo = selectedPackage;
|
|
productId = selectedPackage?.id || 'credits_package';
|
|
const creditCount = selectedPackage?.credits ?? 10;
|
|
const bonusCredits = selectedPackage?.bonus ?? 0;
|
|
productGrants = ProductGrants.normalizeProductGrants(selectedPackage, {
|
|
credits: Number(creditCount) + Number(bonusCredits || 0)
|
|
});
|
|
const bonusText = bonusCredits > 0 ? ` (+${bonusCredits} bonus)` : '';
|
|
description = `${this.gameName} ${creditCount}${bonusText} credits package (${this.currencyLabel})`;
|
|
break;
|
|
}
|
|
case 'cosmetic_pack': {
|
|
const product = this.getCosmeticProduct(options.productId || options.packageId);
|
|
if (!product) {
|
|
throw new ValidationError('Unknown cosmetic product requested', {
|
|
safeMessage: 'Unsupported product requested.'
|
|
});
|
|
}
|
|
const price = product.price ?? 0;
|
|
amount = money.toSafe(money.toBig(price));
|
|
packageInfo = product;
|
|
productId = product.id;
|
|
productGrants = ProductGrants.normalizeProductGrants(product, { credits: 0 });
|
|
description = `${this.gameName} ${product.label || product.id} (${this.currencyLabel})`;
|
|
break;
|
|
}
|
|
default:
|
|
throw new ValidationError(`Invalid payment type requested: ${paymentType}`, {
|
|
safeMessage: 'Unsupported payment type requested.'
|
|
});
|
|
}
|
|
|
|
if (reuseExisting) {
|
|
const existingRow = await this._findReusablePayment(user.id, paymentType, productId);
|
|
if (existingRow) {
|
|
const existing = this._mapPaymentRowToRequest(existingRow, paymentType, packageInfo);
|
|
if (existing && !existing.description) {
|
|
existing.description = description;
|
|
}
|
|
return existing;
|
|
}
|
|
}
|
|
|
|
// Expire any stale pending payments for this user before creating a new one.
|
|
// A user should only have one active pending payment at a time.
|
|
const expiredResult = await this.db.query(`
|
|
UPDATE payments SET status = 'expired'
|
|
WHERE user_id = $1 AND status = 'pending'
|
|
RETURNING subaddress
|
|
`, [user.id]);
|
|
const expiredAddresses = expiredResult.rows.map(r => r.subaddress);
|
|
|
|
// Create the payment request through the routed provider. The native provider (the
|
|
// default for every chain when no gateway env is set) delegates to
|
|
// walletService.createPaymentRequest, giving the single-chain config the same
|
|
// subaddress and the same walletService monitoring maps. A BTCPay/xmrcheckout/
|
|
// wowcheckout gateway takes over only when the operator routes this.cryptoType to it.
|
|
const provider = this.paymentProviders && this.paymentProviders.getProvider(this.cryptoType);
|
|
let paymentResult;
|
|
if (provider) {
|
|
const invoice = await provider.createInvoice({
|
|
chain: this.cryptoType,
|
|
amountAtomic: amount,
|
|
description,
|
|
userId: user.id,
|
|
orderId: socketId
|
|
});
|
|
paymentResult = {
|
|
address: invoice.address,
|
|
addressIndex: invoice.addressIndex ?? (invoice.raw && invoice.raw.addressIndex) ?? null,
|
|
expiresAt: invoice.expiresAt,
|
|
invoiceId: invoice.invoiceId || invoice.address
|
|
};
|
|
} else {
|
|
// Defensive fallback: registry somehow empty -> exact legacy call.
|
|
paymentResult = await this.walletService.createPaymentRequest(amount, description, user.id, socketId);
|
|
}
|
|
|
|
const expiresAt = paymentResult.expiresAt || new Date(Date.now() + 30 * 60 * 1000);
|
|
|
|
// Store payment info in database (address_index enables monitoring restoration after restart)
|
|
const insertResult = await this.db.query(`
|
|
INSERT INTO payments (
|
|
user_id, socket_id, subaddress, expected_amount, payment_type, status,
|
|
description, created_at, expires_at, address_index, product_id, product_grants,
|
|
fairness_proof_version, fairness_offer_id, fairness_offer_issued_at,
|
|
fairness_commitment, fairness_server_seed, fairness_client_seed, fairness_bound_at,
|
|
provider_id, provider_invoice_id
|
|
)
|
|
VALUES (
|
|
$1, $2, $3, $4, $5, 'pending', $6, NOW(), $7, $8, $9, $10::jsonb,
|
|
$11, $12::varchar(64), $13, $14, $15, $16,
|
|
CASE WHEN $12::varchar(64) IS NULL THEN NULL ELSE NOW() END,
|
|
$17, $18
|
|
)
|
|
RETURNING id, expires_at
|
|
`, [
|
|
user.id,
|
|
socketId,
|
|
paymentResult.address,
|
|
amount,
|
|
paymentType,
|
|
description,
|
|
expiresAt,
|
|
paymentResult.addressIndex ?? null,
|
|
productId,
|
|
JSON.stringify(ProductGrants.serializeProductGrants(productGrants)),
|
|
paymentFairnessProof?.proofVersion || null,
|
|
paymentFairnessProof?.offerId || null,
|
|
paymentFairnessProof ? new Date(paymentFairnessProof.offerIssuedAt) : null,
|
|
paymentFairnessProof?.commitment || null,
|
|
paymentFairnessProof?.serverSeed || null,
|
|
paymentFairnessProof?.clientSeed ?? null,
|
|
provider?.id || 'native-monero',
|
|
paymentResult.invoiceId || paymentResult.address
|
|
]);
|
|
|
|
const insertedRow = insertResult.rows[0];
|
|
|
|
return {
|
|
id: insertedRow?.id,
|
|
ownerUserId: user.id,
|
|
address: paymentResult.address,
|
|
invoiceId: paymentResult.invoiceId || paymentResult.address,
|
|
amount: money.toSafe(money.toBig(amount)),
|
|
amountFormatted: this.formatAtomicHuman(amount, 4),
|
|
currency: this.cryptoType,
|
|
expiresAt: insertedRow?.expires_at || expiresAt,
|
|
package: this._serializePackageInfo(packageInfo),
|
|
paymentType,
|
|
productId,
|
|
grants: ProductGrants.publicGrantSummary(productGrants),
|
|
fairnessProof: paymentFairnessProof,
|
|
description,
|
|
reused: false,
|
|
expiredAddresses
|
|
};
|
|
|
|
} catch (error) {
|
|
const normalized = normalizeError(error, 'Failed to create payment request');
|
|
console.error('❌ Error creating payment request:', normalized.message);
|
|
throw normalized;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the database user for a socket.
|
|
*
|
|
* The stable identity is `users.id`, established from the client's `anon_token` by
|
|
* SessionManager at connection time. Resolution goes through that session identity first,
|
|
* so every money/credit/payout path operates on the same stable row. The legacy `socket_id`
|
|
* lookup-or-create is used only when no session exists (SessionManager not wired, or a REST
|
|
* call for a socket with no live session): `socket_id` is mutable and non-unique, so it must
|
|
* never be the primary money key.
|
|
*/
|
|
async getOrCreateUser(socketId, { create = true } = {}) {
|
|
try {
|
|
// 1. Prefer the stable identity established by SessionManager (anon_token -> id).
|
|
if (this.sessionManager && typeof this.sessionManager.getBySocket === 'function') {
|
|
try {
|
|
const sessionUser = await this.sessionManager.getBySocket(socketId);
|
|
if (sessionUser && sessionUser.id != null) {
|
|
// Return a FRESH row by stable id (the cached session row may be stale).
|
|
const fresh = await this.db.query(`SELECT * FROM users WHERE id = $1`, [sessionUser.id]);
|
|
if (fresh.rows.length > 0) {
|
|
await this.db.query(`UPDATE users SET last_active = NOW() WHERE id = $1`, [fresh.rows[0].id]);
|
|
return fresh.rows[0];
|
|
}
|
|
}
|
|
} catch (sessErr) {
|
|
// Fall through to legacy resolution on any session-lookup error.
|
|
}
|
|
}
|
|
|
|
// 2. Legacy fallback: look up by socket_id.
|
|
let userResult = await this.db.query(`
|
|
SELECT * FROM users WHERE socket_id = $1
|
|
`, [socketId]);
|
|
|
|
if (userResult.rows.length > 0) {
|
|
await this.db.query(`
|
|
UPDATE users
|
|
SET last_active = NOW()
|
|
WHERE id = $1
|
|
`, [userResult.rows[0].id]);
|
|
|
|
return userResult.rows[0];
|
|
}
|
|
|
|
// Read-only callers (REST reads) pass { create: false }: never mint an orphan row
|
|
// for a socket that has no user yet. They get null and handle it themselves.
|
|
if (!create) {
|
|
return null;
|
|
}
|
|
|
|
// 3. Last resort: create a new user. Every connection creates a session, so this
|
|
// only fires when none was established for the socket; it is logged to make
|
|
// orphan-row creation visible.
|
|
userResult = await this.db.query(`
|
|
INSERT INTO users (socket_id, ip_address)
|
|
VALUES ($1, $2)
|
|
RETURNING *
|
|
`, [socketId, null]);
|
|
|
|
console.warn(`👤 Created new user without a session (socket ${socketId}): no anon_token identity resolved.`);
|
|
return userResult.rows[0];
|
|
|
|
} catch (error) {
|
|
const normalized = normalizeError(error, 'Failed to load user');
|
|
console.error('❌ Error getting/creating user:', normalized.message);
|
|
throw normalized;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a candidate payout address for the configured chain and network.
|
|
*/
|
|
async validatePayoutAddress(payoutAddress) {
|
|
const address = typeof payoutAddress === 'string' ? payoutAddress.trim() : '';
|
|
if (!address) return { valid: false, error: 'Address is empty' };
|
|
|
|
// Payouts are sent by the native wallet service even when invoice creation is routed
|
|
// through a checkout gateway, so its network-aware validator is authoritative.
|
|
if (this.walletService && typeof this.walletService.validateAddress === 'function') {
|
|
return this.walletService.validateAddress(address);
|
|
}
|
|
|
|
const provider = this.paymentProviders?.getProvider?.(this.cryptoType);
|
|
if (provider && typeof provider.validateAddress === 'function') {
|
|
return provider.validateAddress(this.cryptoType, address);
|
|
}
|
|
return { valid: false, error: 'No payout-address validator is available' };
|
|
}
|
|
|
|
async setUserPayoutAddress(socketId, payoutAddress) {
|
|
try {
|
|
const user = await this.getOrCreateUser(socketId);
|
|
|
|
await this.db.query(`
|
|
UPDATE users
|
|
SET payout_address = $1,
|
|
updated_at = NOW()
|
|
WHERE id = $2
|
|
`, [payoutAddress, user.id]);
|
|
|
|
const addressText = typeof payoutAddress === 'string' ? payoutAddress.trim() : '';
|
|
const redactedAddress = addressText.length > 18
|
|
? `${addressText.slice(0, 10)}…${addressText.slice(-6)}`
|
|
: (addressText ? '[redacted]' : '[cleared]');
|
|
console.log(`💰 Set payout address for user ${user.id}: ${redactedAddress}`);
|
|
|
|
// Reconcile any claimable winnings this user earned with no address on file. A winner
|
|
// without a payout address is recorded as a durable 'needs_review' liability (reason
|
|
// 'match_winner_no_address' or 'solo_winner_no_address', sentinel address
|
|
// 'PENDING_NO_ADDRESS'); with a real address on file those become sendable 'pending'
|
|
// payouts and the batcher is kicked. The reason filter is deliberately narrow so this
|
|
// never touches the ambiguous-broadcast 'needs_review' rows from the payout paths
|
|
// (which carry a real address and a different reason): converting those could
|
|
// double-pay.
|
|
if (typeof payoutAddress === 'string' && payoutAddress.trim().length > 0) {
|
|
try {
|
|
const reconciled = await this.db.query(`
|
|
UPDATE payouts
|
|
SET payout_address = $1, status = 'pending', last_error = NULL
|
|
WHERE user_id = $2
|
|
AND status = 'needs_review'
|
|
AND reason IN ('match_winner_no_address', 'solo_winner_no_address')
|
|
AND payout_address = 'PENDING_NO_ADDRESS'
|
|
RETURNING id
|
|
`, [payoutAddress.trim(), user.id]);
|
|
if (reconciled.rowCount > 0) {
|
|
console.log(`💰 Reconciled ${reconciled.rowCount} claimable payout(s) for user ${user.id} now that an address is set`);
|
|
if (typeof this._scheduleBatchPayout === 'function') this._scheduleBatchPayout();
|
|
}
|
|
} catch (reconErr) {
|
|
// Never let reconciliation failure break address-setting.
|
|
const n = normalizeError(reconErr, 'Failed to reconcile claimable match payouts');
|
|
console.error('⚠️ Payout reconciliation error (address still saved):', n.message);
|
|
}
|
|
}
|
|
return true;
|
|
|
|
} catch (error) {
|
|
const normalized = normalizeError(error, 'Failed to update payout address');
|
|
console.error('❌ Error setting payout address:', normalized.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user statistics
|
|
*/
|
|
async getUserStats(socketId) {
|
|
try {
|
|
const result = await this.db.query(`
|
|
SELECT * FROM get_user_stats($1)
|
|
`, [socketId]);
|
|
|
|
if (result.rows.length > 0) {
|
|
return result.rows[0];
|
|
}
|
|
|
|
return {
|
|
total_games: 0,
|
|
games_won: 0,
|
|
win_rate: 0,
|
|
total_paid: 0,
|
|
total_won: 0,
|
|
net_profit: 0,
|
|
credits_remaining: 0
|
|
};
|
|
|
|
} catch (error) {
|
|
const normalized = normalizeError(error, 'Failed to load user stats');
|
|
console.error('❌ Error getting user stats:', normalized.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get game mode info for frontend
|
|
*/
|
|
getGameModeInfo() {
|
|
if (this.debugManager?.CONSOLE_LOGGING) {
|
|
console.log(`📤 getGameModeInfo: creditsPayoutBaseValue = ${this.creditsPayoutBaseValue} (${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`);
|
|
}
|
|
|
|
// Determine if we should show a testnet warning
|
|
// Only for XMR on stagenet/testnet - WOW only has mainnet
|
|
const showTestnetWarning = this.cryptoType === 'XMR' && this.isTestNetwork;
|
|
const testnetWarning = showTestnetWarning
|
|
? `⚠️ STAGENET MODE: This server is using ${this.network} XMR. Do NOT send real mainnet XMR! Only ${this.network} XMR will be accepted.`
|
|
: null;
|
|
|
|
// Serialize atomic prices as exact numbers when safe, decimal strings otherwise.
|
|
const packages = this.configSnapshot?.modes?.credits?.packages || [];
|
|
const creditPackages = packages.map(pkg => ({
|
|
id: pkg.id,
|
|
label: pkg.label || pkg.id,
|
|
credits: pkg.credits,
|
|
price: money.toSafe(money.toBig(pkg.price)),
|
|
bonus: pkg.bonus || 0,
|
|
grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(pkg, {
|
|
credits: Number(pkg.credits || 0) + Number(pkg.bonus || 0)
|
|
})),
|
|
priceFormatted: this.formatAtomicHuman(
|
|
money.toSafe(money.toBig(pkg.price)),
|
|
2
|
|
)
|
|
}));
|
|
const cosmeticProducts = this.getCosmeticProducts().map(product => ({
|
|
id: product.id,
|
|
label: product.label || product.id,
|
|
price: money.toSafe(money.toBig(product.price)),
|
|
grants: ProductGrants.publicGrantSummary(ProductGrants.normalizeProductGrants(product, { credits: 0 })),
|
|
priceFormatted: this.formatAtomicHuman(
|
|
money.toSafe(money.toBig(product.price)),
|
|
2
|
|
)
|
|
}));
|
|
|
|
return {
|
|
release: this.releaseIdentity || {
|
|
verified: false,
|
|
id: null,
|
|
commit: null
|
|
},
|
|
operatedProductProfileId: getOperatedProductProfile(process.env)?.id || null,
|
|
mode: this.gameMode,
|
|
freePlayEnabled: !!this.freePlayEnabled, // players may choose free play even on a paid instance
|
|
cryptoType: this.cryptoType,
|
|
currencyLabel: this.currencyLabel, // sXMR on stagenet, XMR on mainnet, WOW for Wownero
|
|
gameName: this.gameName, // Monerogue / Wownerogue
|
|
network: this.network,
|
|
isTestNetwork: this.isTestNetwork,
|
|
testnetWarning: testnetWarning,
|
|
singleGamePrice: this.singleGamePrice,
|
|
singleGamePriceFormatted: this.formatAtomicHuman(this.singleGamePrice, 2),
|
|
creditsPackagePrice: this.creditsPackagePrice,
|
|
creditsPerGame: this.creditsPerGameCost,
|
|
creditPackages: creditPackages,
|
|
cosmeticProducts: cosmeticProducts,
|
|
creditsPayoutBaseValue: this.creditsPayoutBaseValue,
|
|
paymentsEnabled: this.paymentsEnabled,
|
|
freePlayEnabled: this.freePlayEnabled,
|
|
directModeEnabled: this.directModeEnabled,
|
|
creditsModeEnabled: this.creditsModeEnabled,
|
|
payoutsEnabled: this.payoutsEnabled,
|
|
directPayoutsEnabled: this.isPayoutEnabledForMode('PAID_SINGLE'),
|
|
creditsPayoutsEnabled: this.isPayoutEnabledForMode('PAID_CREDITS'),
|
|
payoutMultipliers: {
|
|
direct: this.getImplementedPayoutMultipliersForMode('PAID_SINGLE'),
|
|
credits: this.getImplementedPayoutMultipliersForMode('PAID_CREDITS')
|
|
},
|
|
// Exact server-calculated amounts. The browser must never recreate money promises
|
|
// with floating point multiplier arithmetic.
|
|
payoutOutcomes: {
|
|
direct: this._publicPayoutOutcomes('PAID_SINGLE'),
|
|
credits: this._publicPayoutOutcomes('PAID_CREDITS')
|
|
},
|
|
features: {
|
|
paymentRequired: this.paymentsEnabled,
|
|
creditsSystem: this.creditsModeEnabled,
|
|
payouts: this.isPayoutEnabledForMode('PAID_SINGLE')
|
|
|| this.isPayoutEnabledForMode('PAID_CREDITS')
|
|
},
|
|
earlyEntry: {
|
|
enabled: this.configSnapshot?.earlyEntry?.enabled ?? false,
|
|
allowInFreeMode: this.configSnapshot?.earlyEntry?.allowInFreeMode ?? false,
|
|
allowInCreditsMode: this.configSnapshot?.earlyEntry?.allowInCreditsMode ?? false
|
|
},
|
|
// Smirk wallet integration. Smirk only works on mainnet, so it is forced off on
|
|
// any test network (stagenet/testnet) regardless of SMIRK_ENABLED.
|
|
smirkEnabled: isSmirkEnabled(process.env),
|
|
explorerTxUrl: process.env.EXPLORER_TX_URL || null,
|
|
// Which top-level modes this instance offers. Solo (the single-player dungeon) is
|
|
// on unless explicitly disabled; Tavern and Multiplayer are opt-in. The client uses
|
|
// this to show/hide entry points, so any single mode can run on its own.
|
|
modes: {
|
|
solo: process.env.SOLO_ENABLED !== 'false',
|
|
tavern: process.env.TAVERN_ENABLED === 'true',
|
|
multiplayer: process.env.MULTIPLAYER_ENABLED === 'true',
|
|
match: {
|
|
enabled: process.env.MATCH_ENABLED === 'true',
|
|
economies: this._getMatchEconomies(),
|
|
maxPlayers: parseInt(process.env.MATCH_MAX_PLAYERS, 10) || 4,
|
|
...this._getMatchRulesetInfo()
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/** Fire-and-forget operator alert (no-op when no alertService is wired). */
|
|
_alert(type, subject, html) {
|
|
if (this.alertService && typeof this.alertService.sendAlert === 'function') {
|
|
this.alertService.sendAlert(type, { subject, html }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the configured payout min/max (atomic BigInt) for a recorded game mode from
|
|
* the runtime config snapshot, or null when unavailable (older config / no snapshot).
|
|
*/
|
|
_payoutBoundsForMode(recordedPaymentMode) {
|
|
const rules = this.configSnapshot?.payouts?.rules;
|
|
if (!rules) return null;
|
|
const rule = recordedPaymentMode === 'PAID_CREDITS' ? rules.credits : rules.direct;
|
|
if (!rule) return null;
|
|
const toBigOrNull = (v) => {
|
|
if (v === undefined || v === null) return null;
|
|
try { return money.toBig(v); } catch { return null; }
|
|
};
|
|
return { min: toBigOrNull(rule.minPayout), max: toBigOrNull(rule.maxPayout) };
|
|
}
|
|
|
|
_soloPayoutCommitment(gameRow, recordedPaymentMode, treasureFound) {
|
|
let terms = gameRow.payout_terms || null;
|
|
if (typeof terms === 'string') {
|
|
try { terms = JSON.parse(terms); } catch (_) { terms = null; }
|
|
}
|
|
const hasCommittedTerms = !!(terms && Number(terms.version) >= 1);
|
|
// New rows carry an immutable boolean. NULL is reserved for games that predate the
|
|
// commitment migration and therefore retain the legacy live-policy fallback.
|
|
const eligible = typeof gameRow.payout_eligible === 'boolean'
|
|
? gameRow.payout_eligible
|
|
: this.isPayoutEnabledForMode(recordedPaymentMode);
|
|
if (!eligible) return { eligible: false, terms };
|
|
|
|
let amount;
|
|
let multiplier;
|
|
if (hasCommittedTerms) {
|
|
amount = treasureFound ? terms.treasureAmount : terms.escapeAmount;
|
|
multiplier = treasureFound ? terms.treasureMultiplier : terms.escapeMultiplier;
|
|
} else if (gameRow.payout_escape_amount != null || gameRow.payout_treasure_amount != null) {
|
|
amount = treasureFound ? gameRow.payout_treasure_amount : gameRow.payout_escape_amount;
|
|
multiplier = treasureFound ? gameRow.payout_treasure_mult : gameRow.payout_escape_mult;
|
|
} else {
|
|
const live = this.calculatePayout(recordedPaymentMode, { treasureFound });
|
|
amount = live.amount;
|
|
multiplier = live.multiplier;
|
|
}
|
|
|
|
let amountBig;
|
|
try { amountBig = money.toBig(amount); } catch (_) {
|
|
throw new Error(`Invalid committed payout amount for game ${gameRow.id}`);
|
|
}
|
|
const toBigOrNull = (value) => {
|
|
if (value === undefined || value === null || value === '') return null;
|
|
try { return money.toBig(value); } catch (_) { return null; }
|
|
};
|
|
const liveBounds = hasCommittedTerms ? null : this._payoutBoundsForMode(recordedPaymentMode);
|
|
const min = hasCommittedTerms ? toBigOrNull(terms.minAmount) : liveBounds?.min ?? null;
|
|
const max = hasCommittedTerms ? toBigOrNull(terms.maxAmount) : liveBounds?.max ?? null;
|
|
let capped = false;
|
|
if (max !== null && amountBig > max) {
|
|
amountBig = max;
|
|
amount = max.toString();
|
|
capped = true;
|
|
}
|
|
if (amountBig <= 0n || (min !== null && amountBig < min)) {
|
|
return { eligible: true, belowMinimum: true, amount, multiplier, min, max, terms };
|
|
}
|
|
return { eligible: true, amount, amountBig, multiplier, min, max, capped, terms };
|
|
}
|
|
|
|
/**
|
|
* Complete a game (called from socket handlers when game ends)
|
|
* @param {string} socketId - Player's socket ID
|
|
* @param {string} gameId - Game UUID
|
|
* @param {boolean} won - Whether the player won (escaped)
|
|
* @param {boolean} treasureFound - Whether treasure was found
|
|
* @returns {object} payout / completion info
|
|
*/
|
|
async completeGame(socketId, gameId, won, treasureFound, metrics = {}) {
|
|
try {
|
|
const outcome = await this.db.withTransaction(async (client) => {
|
|
// Lock by immutable dungeon seed. Completion and any payout obligation are
|
|
// committed in this one transaction; neither half can survive alone.
|
|
const gameResult = await client.query(`
|
|
SELECT id, user_id, game_mode, status, treasure_found, payout_address,
|
|
payout_eligible, payout_terms,
|
|
payout_escape_amount, payout_treasure_amount,
|
|
payout_escape_mult, payout_treasure_mult
|
|
FROM games
|
|
WHERE dungeon_seed = $1
|
|
LIMIT 1
|
|
FOR UPDATE
|
|
`, [gameId]);
|
|
const game = gameResult.rows[0];
|
|
if (!game) {
|
|
throw Object.assign(new Error('Durable game row not found'), { code: 'GAME_ROW_REQUIRED' });
|
|
}
|
|
|
|
const recordedPaymentMode = (game.game_mode || this.gameMode || 'FREE').toUpperCase();
|
|
const finalStatuses = new Set(['won', 'lost', 'expired']);
|
|
const recordedStatus = String(game.status || '').toLowerCase();
|
|
const alreadyFinal = finalStatuses.has(recordedStatus);
|
|
if (alreadyFinal) {
|
|
const existing = await client.query(`SELECT id, status FROM payouts WHERE game_id = $1 LIMIT 1`, [game.id]);
|
|
if (existing.rows.length || recordedStatus !== 'won' || game.payout_eligible !== true) {
|
|
return {
|
|
success: true,
|
|
mode: recordedPaymentMode,
|
|
payout: null,
|
|
reason: existing.rows.length ? 'Payout already processed' : 'Game already completed',
|
|
score: metrics.score ?? null
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!alreadyFinal) {
|
|
await client.query(`
|
|
UPDATE games
|
|
SET status = $1,
|
|
outcome = COALESCE($2, outcome),
|
|
treasure_found = $3,
|
|
moves_made = COALESCE($4, moves_made),
|
|
duration_seconds = COALESCE($5, duration_seconds),
|
|
score = COALESCE($6, score),
|
|
completed_at = NOW(),
|
|
proof_revealed_at = NOW()
|
|
WHERE id = $7
|
|
`, [
|
|
won ? 'won' : 'lost',
|
|
metrics.outcome || metrics.reason || (won ? 'escaped' : null),
|
|
treasureFound,
|
|
metrics.moves ?? null,
|
|
metrics.durationSeconds ?? null,
|
|
metrics.score ?? null,
|
|
game.id
|
|
]);
|
|
|
|
if (Number(metrics.score) > 0 && game.user_id != null) {
|
|
await client.query(`
|
|
UPDATE users SET high_score = GREATEST(COALESCE(high_score, 0), $1)
|
|
WHERE id = $2
|
|
`, [metrics.score, game.user_id]);
|
|
}
|
|
}
|
|
|
|
const completedAsWin = alreadyFinal ? recordedStatus === 'won' : won;
|
|
const completedWithTreasure = alreadyFinal ? !!game.treasure_found : treasureFound;
|
|
if (!completedAsWin) {
|
|
return { success: true, mode: recordedPaymentMode, payout: null, score: metrics.score ?? null };
|
|
}
|
|
|
|
const commitment = this._soloPayoutCommitment(game, recordedPaymentMode, completedWithTreasure);
|
|
if (!commitment.eligible) {
|
|
return { success: true, mode: recordedPaymentMode, payout: null, score: metrics.score ?? null };
|
|
}
|
|
if (commitment.belowMinimum) {
|
|
return {
|
|
success: true,
|
|
mode: recordedPaymentMode,
|
|
payout: null,
|
|
reason: 'Below minimum payout',
|
|
score: metrics.score ?? null
|
|
};
|
|
}
|
|
|
|
const existing = await client.query(`SELECT id, status FROM payouts WHERE game_id = $1 LIMIT 1`, [game.id]);
|
|
if (existing.rows.length > 0) {
|
|
return {
|
|
success: true,
|
|
mode: recordedPaymentMode,
|
|
payout: null,
|
|
reason: 'Payout already processed',
|
|
score: metrics.score ?? null
|
|
};
|
|
}
|
|
|
|
let user = null;
|
|
if (game.user_id != null) {
|
|
const userResult = await client.query(`SELECT id, payout_address FROM users WHERE id = $1 LIMIT 1`, [game.user_id]);
|
|
user = userResult.rows[0] || null;
|
|
}
|
|
const lockedAddress = typeof game.payout_address === 'string' ? game.payout_address.trim() : '';
|
|
const fallbackAddress = typeof user?.payout_address === 'string' ? user.payout_address.trim() : '';
|
|
const payoutAddress = lockedAddress || fallbackAddress;
|
|
const identityResolved = game.user_id != null && !!user;
|
|
const sendable = identityResolved && !!payoutAddress;
|
|
const status = sendable ? 'pending' : 'needs_review';
|
|
const reason = !identityResolved
|
|
? 'solo_winner_identity_review'
|
|
: (sendable
|
|
? (completedWithTreasure ? 'escape_with_treasure' : 'escape')
|
|
: 'solo_winner_no_address');
|
|
const inserted = await client.query(`
|
|
INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
|
ON CONFLICT (game_id) WHERE game_id IS NOT NULL DO NOTHING
|
|
RETURNING id
|
|
`, [
|
|
game.user_id,
|
|
game.id,
|
|
payoutAddress || NO_PAYOUT_ADDRESS_SENTINEL,
|
|
String(commitment.amount),
|
|
commitment.multiplier,
|
|
reason,
|
|
status
|
|
]);
|
|
if (inserted.rows.length === 0) {
|
|
return {
|
|
success: true,
|
|
mode: recordedPaymentMode,
|
|
payout: null,
|
|
reason: 'Payout already processed',
|
|
score: metrics.score ?? null
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
mode: recordedPaymentMode,
|
|
payout: {
|
|
status: sendable ? 'queued' : 'needs_review',
|
|
payoutId: inserted.rows[0].id,
|
|
amount: commitment.amount,
|
|
multiplier: commitment.multiplier,
|
|
treasure: completedWithTreasure
|
|
},
|
|
capped: commitment.capped,
|
|
needsReview: !sendable,
|
|
identityResolved,
|
|
score: metrics.score ?? null
|
|
};
|
|
});
|
|
|
|
if (outcome.capped) {
|
|
console.warn(`⚠️ Committed payout for game ${gameId} exceeded its start-time cap and was capped.`);
|
|
this._alert('payout_over_max', '⚠️ Payout capped at committed maximum', `<p>The committed payout for game <b>${gameId}</b> was capped at its start-time maximum.</p>`);
|
|
}
|
|
if (outcome.needsReview) {
|
|
this._alert('payout_liability_needs_review', '⚠️ Solo payout liability needs review', `<p>Game <b>${gameId}</b> completed with a payout liability that cannot be dispatched automatically.</p>`);
|
|
}
|
|
if (outcome.payout?.status === 'queued') this._scheduleBatchPayout();
|
|
return outcome;
|
|
} catch (err) {
|
|
const normalized = normalizeError(err, 'Failed to complete game');
|
|
console.error('❌ completeGame error:', normalized.message);
|
|
if (err?.code === 'GAME_ROW_REQUIRED') {
|
|
this._alert('payout_unresolved_game', '⚠️ Game completion not committed', `<p>No durable games row exists for dungeon seed <b>${gameId}</b> (socket ${socketId}). Completion and payout were not committed.</p>`);
|
|
}
|
|
return { success: false, error: normalized.safeMessage, reason: err?.code === 'GAME_ROW_REQUIRED' ? 'Game row not resolved' : undefined };
|
|
}
|
|
}
|
|
|
|
// ---- Batch Payout Processing ----
|
|
|
|
/**
|
|
* Repair historical/legacy half-completions that already carry an explicit committed
|
|
* liability. New completions create the row atomically; this scan is the crash/migration
|
|
* reconciliation path and deliberately ignores NULL/false eligibility.
|
|
*/
|
|
async reconcileCompletedSoloLiabilities({ limit = 100 } = {}) {
|
|
if (!this.db || this._isReconcilingSoloLiabilities) return { scanned: 0, created: 0, failed: 0 };
|
|
this._isReconcilingSoloLiabilities = true;
|
|
try {
|
|
const candidates = await this.db.query(`
|
|
SELECT g.dungeon_seed, g.treasure_found
|
|
FROM games g
|
|
WHERE g.status = 'won'
|
|
AND g.payout_eligible = TRUE
|
|
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.game_id = g.id)
|
|
ORDER BY g.completed_at ASC NULLS LAST, g.id ASC
|
|
LIMIT $1
|
|
`, [Math.max(1, Math.min(1000, parseInt(limit, 10) || 100))]);
|
|
let created = 0;
|
|
let failed = 0;
|
|
for (const game of candidates.rows || []) {
|
|
const result = await this.completeGame(
|
|
'solo-liability-reconciler',
|
|
game.dungeon_seed,
|
|
true,
|
|
!!game.treasure_found,
|
|
{ reconcile: true }
|
|
);
|
|
if (result?.payout?.payoutId) created += 1;
|
|
else if (!result?.success) failed += 1;
|
|
}
|
|
return { scanned: candidates.rows?.length || 0, created, failed };
|
|
} finally {
|
|
this._isReconcilingSoloLiabilities = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule batch payout processing with a short debounce.
|
|
* Multiple wins within the debounce window (5s) are batched into one transfer.
|
|
* Also called on new block events as a safety net.
|
|
*/
|
|
_scheduleBatchPayout() {
|
|
if (!this.payoutsEnabled || this._isShuttingDown) return;
|
|
if (typeof this.payoutDispatchAllowed === 'function' && !this.payoutDispatchAllowed()) return;
|
|
if (this._batchPayoutTimer) clearTimeout(this._batchPayoutTimer);
|
|
this._batchPayoutTimer = setTimeout(() => {
|
|
this._batchPayoutTimer = null;
|
|
if (this._isShuttingDown) return;
|
|
if (typeof this.payoutDispatchAllowed === 'function' && !this.payoutDispatchAllowed()) return;
|
|
this._processPendingPayouts().catch(err => {
|
|
console.error('❌ Batch payout processing error:', err.message);
|
|
});
|
|
}, 5000);
|
|
}
|
|
|
|
/** Stop accepting scheduled payout work and cancel any debounce that has not fired yet. */
|
|
shutdown() {
|
|
this._isShuttingDown = true;
|
|
this.beginGameAdmissionShutdown();
|
|
if (this._batchPayoutTimer) {
|
|
clearTimeout(this._batchPayoutTimer);
|
|
this._batchPayoutTimer = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process all pending payouts that haven't been sent yet.
|
|
* Batches them into a single transfer_split call for efficiency.
|
|
* Called by debounce timer and on new block events.
|
|
*/
|
|
async _processPendingPayouts() {
|
|
// Emergency/operator kill switch. Return before locking or changing any rows so pending
|
|
// liabilities remain intact and can be resumed deliberately when payouts are re-enabled.
|
|
if (!this.payoutsEnabled || this._isShuttingDown) return;
|
|
if (typeof this.payoutDispatchAllowed === 'function' && !this.payoutDispatchAllowed()) return;
|
|
// Prevent concurrent batch processing
|
|
if (this._isBatchProcessing) return;
|
|
this._isBatchProcessing = true;
|
|
|
|
try {
|
|
// Reconcile any explicit committed wins left by an older deployment before
|
|
// dispatching pending rows. The kill switch above still prevents any wallet send.
|
|
await this.reconcileCompletedSoloLiabilities({ limit: 100 });
|
|
|
|
// Gather pending payouts inside a transaction with row-level locks.
|
|
// FOR UPDATE SKIP LOCKED prevents the retry service from grabbing the same rows.
|
|
const pending = await this.db.withTransaction(async (client) => {
|
|
const result = await client.query(`
|
|
SELECT id, user_id, game_id, payout_address, amount, multiplier, reason
|
|
FROM payouts
|
|
WHERE status = 'pending' AND tx_hash IS NULL
|
|
ORDER BY created_at ASC
|
|
FOR UPDATE SKIP LOCKED
|
|
`);
|
|
// Mark as 'processing' so retry service won't touch them even after this tx commits
|
|
if (result.rows.length > 0) {
|
|
const ids = result.rows.map(r => r.id);
|
|
await client.query(
|
|
`UPDATE payouts SET status = 'processing', last_retry_at = NOW() WHERE id = ANY($1)`,
|
|
[ids]
|
|
);
|
|
}
|
|
return result;
|
|
});
|
|
|
|
if (pending.rows.length === 0) return;
|
|
|
|
// Monero output locking: a spent output (including change) is locked for ~10 blocks.
|
|
// When the unlocked balance does not cover this batch, the rows are deferred (reverted
|
|
// to pending) rather than attempting a transfer that would fail. The next batch
|
|
// interval and every new-block tick re-check, so it sends as soon as outputs unlock.
|
|
// Batching already minimises how many change outputs are created; this handles the
|
|
// "all outputs currently locked" window.
|
|
try {
|
|
const totalNeeded = money.sum(pending.rows.map(p => p.amount)); // BigInt atomic
|
|
const bal = await this.walletService.getBalance();
|
|
// Keep wallet atomic units exact even above Number.MAX_SAFE_INTEGER.
|
|
const unlocked = (bal && !bal.error) ? money.toBig(bal.unlocked_balance) : null;
|
|
if (unlocked === null) {
|
|
throw new Error('Wallet returned no authoritative unlocked balance');
|
|
}
|
|
if (unlocked !== null && unlocked < totalNeeded) {
|
|
const ids = pending.rows.map(p => p.id);
|
|
await this.db.query(
|
|
`UPDATE payouts SET status = 'pending', last_error = $2, last_retry_at = NOW() WHERE id = ANY($1)`,
|
|
[ids, 'Deferred: insufficient unlocked balance (Monero outputs locked ~10 blocks)']
|
|
).catch(() => {});
|
|
console.warn(`⏳ Deferring ${pending.rows.length} payout(s): unlocked ${unlocked} < needed ${totalNeeded}. Outputs likely locked; will retry next block/interval.`);
|
|
return;
|
|
}
|
|
} catch (balErr) {
|
|
// A failed reserve/liveness probe is not permission to attempt a transfer. Keep
|
|
// the liabilities pending; a later healthy worker can re-evaluate them without an
|
|
// ambiguous broadcast outcome.
|
|
const ids = pending.rows.map(p => p.id);
|
|
await this.db.query(
|
|
`UPDATE payouts SET status = 'pending', last_error = $2, last_retry_at = NOW()
|
|
WHERE id = ANY($1) AND status = 'processing'`,
|
|
[ids, 'Deferred: wallet unlocked balance could not be verified']
|
|
).catch(() => {});
|
|
console.error('Payout balance pre-check failed; batch deferred:', balErr.message);
|
|
return;
|
|
}
|
|
|
|
// The operator/health gate may flip while rows are being claimed or the wallet is
|
|
// probed. Recheck immediately before the first possible broadcast and release the
|
|
// claims without incrementing retries if dispatch is no longer authorized.
|
|
if (typeof this.payoutDispatchAllowed === 'function' && !this.payoutDispatchAllowed()) {
|
|
const ids = pending.rows.map(p => p.id);
|
|
await this.db.query(
|
|
`UPDATE payouts SET status = 'pending', last_error = $2, last_retry_at = NOW()
|
|
WHERE id = ANY($1) AND status = 'processing'`,
|
|
[ids, 'Payout dispatch paused before wallet transfer']
|
|
).catch(() => {});
|
|
return;
|
|
}
|
|
|
|
console.log(`📦 Processing batch of ${pending.rows.length} pending payout(s)`);
|
|
|
|
// Build destinations for transfer_split
|
|
const destinations = pending.rows.map(p => ({
|
|
amount: p.amount,
|
|
address: p.payout_address
|
|
}));
|
|
|
|
// Generate batch_id to link payouts processed together
|
|
const batchId = require('uuid').v4();
|
|
|
|
if (pending.rows.length === 1) {
|
|
// Single payout: processPayout is the simpler flow
|
|
const p = pending.rows[0];
|
|
let observedTxHash = null;
|
|
try {
|
|
const result = await this.walletService.processPayout({
|
|
userId: p.user_id,
|
|
gameId: p.game_id,
|
|
address: p.payout_address,
|
|
amount: p.amount,
|
|
multiplier: p.multiplier
|
|
});
|
|
const txHash = typeof result?.txHash === 'string' ? result.txHash.trim() : '';
|
|
if (result?.success !== true || !validTxHash(txHash)) {
|
|
throw new Error('Wallet did not return explicit success with one valid transaction hash');
|
|
}
|
|
observedTxHash = txHash;
|
|
|
|
// Store tx_hash + status + user stats atomically in one transaction
|
|
await this.db.withTransaction(async (client) => {
|
|
await client.query(
|
|
`UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3, status = 'completed', processed_at = NOW() WHERE id = $4`,
|
|
[txHash, result.fee || null, batchId, p.id]
|
|
);
|
|
await client.query(
|
|
`UPDATE users SET total_amount_won = total_amount_won + $1, total_payouts_received = COALESCE(total_payouts_received, 0) + 1 WHERE id = $2`,
|
|
[p.amount, p.user_id]
|
|
);
|
|
});
|
|
console.log(`💸 Single payout ${p.id} completed: ${txHash}`);
|
|
} catch (err) {
|
|
// Retryable only when nothing can have been broadcast: insufficient unlocked
|
|
// funds, or a failure flagged preBroadcast (transfer gate, identity check,
|
|
// address validation, all strictly before transfer_split). Those revert to
|
|
// 'pending' and cannot double-pay.
|
|
// Any other error is ambiguous: transfer_split may have broadcast on-chain
|
|
// even though the RPC response errored, and the row has a null tx_hash so the
|
|
// retry service's blockchain guard cannot protect it. Those go to
|
|
// 'needs_review' (the retry service skips it) plus an alert.
|
|
// Once a valid hash is observed, broadcast is proven even if the following DB
|
|
// transaction failed, so that evidence is preserved and the row never returns
|
|
// to the automatic retry pool.
|
|
const preBroadcast = !observedTxHash && err?.preBroadcast === true;
|
|
const fundsIssue = !observedTxHash && isInsufficientFundsError(err);
|
|
const retryable = fundsIssue || preBroadcast;
|
|
const status = retryable ? 'pending' : 'needs_review';
|
|
console.error(`❌ Single payout ${p.id} failed -> ${status}:`, err.message);
|
|
await this.db.query(
|
|
`UPDATE payouts
|
|
SET status = $1, last_error = $2,
|
|
tx_hash = COALESCE($3, tx_hash), last_retry_at = NOW()
|
|
WHERE id = $4`,
|
|
[status, String(err.message).slice(0, 500), observedTxHash, p.id]
|
|
).catch(() => {});
|
|
if (!fundsIssue && this.alertService && typeof this.alertService.sendAlert === 'function') {
|
|
this.alertService.sendAlert('single_payout_failed', {
|
|
subject: '⚠️ Payout failed: manual review required',
|
|
html: `<p>Single payout <b>${p.id}</b> failed and was marked <b>needs_review</b> to avoid a possible double-payout.</p>`
|
|
+ `<p>Amount: ${p.amount} → ${p.payout_address}</p><p>Error: ${err.message}</p>`
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
} else {
|
|
// Multiple payouts: batch via transfer_split with multiple destinations
|
|
const ids = pending.rows.map(p => p.id);
|
|
let observedTxHash = null;
|
|
try {
|
|
const result = await this.walletService.processBatchPayout(destinations);
|
|
|
|
// A shared hash is safe only when the RPC explicitly reports one transaction
|
|
// for the complete destination set. Multiple split hashes cannot be mapped to
|
|
// individual liabilities from wallet-rpc's response and require review.
|
|
const hashes = Array.isArray(result?.tx_hash_list)
|
|
? result.tx_hash_list.map(hash => String(hash || '').trim())
|
|
: [];
|
|
if (result?.success !== true || hashes.length === 0 || !hashes.every(validTxHash)) {
|
|
throw new Error('Wallet returned ambiguous batch transaction-hash evidence');
|
|
}
|
|
if (hashes.length > 1) {
|
|
// wallet-rpc split the send, which is what transfer_split does when the
|
|
// destination set does not fit one transaction. The coins are already
|
|
// broadcast, so this is not a failure; the response does not say which
|
|
// destination landed in which tx, so per-row attribution stays a human
|
|
// decision. BatchSplitEvidence carries the hashes into the quarantine
|
|
// path so the evidence that the money left the wallet is preserved.
|
|
observedTxHash = hashes[0];
|
|
throw new BatchSplitEvidence(hashes, result.totalFee);
|
|
}
|
|
const txHash = hashes[0];
|
|
observedTxHash = txHash;
|
|
const feePerPayout = result.totalFee != null
|
|
? money.toSafe((money.toBig(result.totalFee) + BigInt(pending.rows.length) - 1n)
|
|
/ BigInt(pending.rows.length))
|
|
: null;
|
|
|
|
// The entire batch is marked completed in one transaction. A per-row loop
|
|
// would strand rows mid-batch if the shared tx_hash hit the unique index.
|
|
await this.db.withTransaction(async (client) => {
|
|
await client.query(
|
|
`UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3, status = 'completed', processed_at = NOW() WHERE id = ANY($4)`,
|
|
[txHash, feePerPayout, batchId, ids]
|
|
);
|
|
// Per-row stat increments inside the same transaction. Amounts are passed
|
|
// as DB values so Postgres does the BIGINT arithmetic (no JS float).
|
|
for (const p of pending.rows) {
|
|
await client.query(
|
|
`UPDATE users SET total_amount_won = total_amount_won + $1::bigint, total_payouts_received = COALESCE(total_payouts_received, 0) + 1 WHERE id = $2`,
|
|
[p.amount, p.user_id]
|
|
);
|
|
}
|
|
});
|
|
console.log(`💸 Batch payout completed: ${pending.rows.length} payouts in tx ${txHash}`);
|
|
} catch (err) {
|
|
// An insufficient-unlocked-funds error is raised before broadcast (no tx went
|
|
// out), so the rows revert to 'pending' and the next run sends them once
|
|
// outputs unlock. This is transient and raises no operator alert.
|
|
//
|
|
// The same holds for failures flagged preBroadcast (transfer gate, identity
|
|
// check, address validation, all strictly before transfer_split): nothing was
|
|
// sent, so returning them to 'pending' cannot double-pay.
|
|
//
|
|
// Any other error is ambiguous: transfer_split may have broadcast on-chain
|
|
// even though the RPC response errored. Those rows have no tx_hash, so the
|
|
// retry service's blockchain guard cannot protect them and an auto-retry
|
|
// could double-pay. They go to 'needs_review' (the retry service skips it)
|
|
// plus an alert.
|
|
const preBroadcast = !observedTxHash && err?.preBroadcast === true;
|
|
const fundsIssue = !observedTxHash && isInsufficientFundsError(err);
|
|
const retryable = fundsIssue || preBroadcast;
|
|
const status = retryable ? 'pending' : 'needs_review';
|
|
console.error(`❌ Batch payout failed (${pending.rows.length} payouts) -> ${status}:`, err.message);
|
|
await this.db.query(
|
|
`UPDATE payouts
|
|
SET status = $1, last_error = $2,
|
|
tx_hash = COALESCE($4, tx_hash), last_retry_at = NOW()
|
|
WHERE id = ANY($3)`,
|
|
[status, String(err.message).slice(0, 500), ids, observedTxHash]
|
|
).catch(() => {});
|
|
if (!retryable && this.alertService && typeof this.alertService.sendAlert === 'function') {
|
|
const split = err instanceof BatchSplitEvidence;
|
|
this.alertService.sendAlert('batch_payout_failed', {
|
|
subject: split
|
|
? '⚠️ Batch payout SPLIT: attribution review required'
|
|
: '⚠️ Batch payout failed: manual review required',
|
|
html: split
|
|
? `<p>A batch of ${pending.rows.length} payout(s) was <b>broadcast as ${err.hashes.length} transactions</b>.`
|
|
+ ` The funds have been sent; only the per-row attribution needs confirming.</p>`
|
|
+ `<p>Payout IDs: ${ids.join(', ')}</p>`
|
|
+ `<p>Transaction hashes:<br>${err.hashes.join('<br>')}</p>`
|
|
: `<p>A batch of ${pending.rows.length} payout(s) failed and was marked <b>needs_review</b> to avoid a possible double-payout.</p>`
|
|
+ `<p>Payout IDs: ${ids.join(', ')}</p><p>Error: ${err.message}</p>`
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('❌ _processPendingPayouts error:', err.message);
|
|
} finally {
|
|
this._isBatchProcessing = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = GameModeManager;
|