Wownerogue

A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
This commit is contained in:
jwinterm
2026-07-27 08:23:01 -04:00
commit fad2a695bd
578 changed files with 102281 additions and 0 deletions
+634
View File
@@ -0,0 +1,634 @@
const { AppError, NotFoundError } = require('../utils/errors');
const MAX_ERROR_LENGTH = 2000;
const TX_HASH_PATTERN = /^[0-9a-f]{64}$/i;
function parseAtomic(value, fieldName) {
try {
const parsed = BigInt(value == null ? 0 : value);
if (parsed < 0n) {
throw new Error('negative value');
}
return parsed;
} catch (error) {
throw new AppError(`Invalid ${fieldName} stored for payment refund`, {
code: 'INVALID_REFUND_DATA',
safeMessage: 'The payment record cannot be refunded automatically.'
});
}
}
function jsonInteger(value) {
const parsed = typeof value === 'bigint' ? value : BigInt(value || 0);
return parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : parsed.toString();
}
function errorMessage(error) {
const message = error && typeof error.message === 'string'
? error.message
: 'Wallet returned an ambiguous refund result.';
return message.slice(0, MAX_ERROR_LENGTH) || 'Wallet returned an ambiguous refund result.';
}
function jsonObject(value) {
if (value && typeof value === 'object') return value;
if (typeof value !== 'string' || !value.trim()) return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
function packIds(value) {
const source = Array.isArray(value) ? value : [];
return [...new Set(source.map(item => {
if (typeof item === 'string') return item.trim();
return String(item?.id || item?.packId || item?.pack_id || '').trim();
}).filter(Boolean))];
}
/**
* Durable payment-refund state machine.
*
* Safety invariant: a wallet transfer is attempted only by the request that atomically
* changes `requested` to `processing`. No code path changes `processing` back to
* `requested`, so a crash or timeout can never cause an automatic second transfer.
*/
class PaymentRefundService {
constructor({ db, walletService, logger = console, isSendEnabled = null } = {}) {
if (!db) throw new Error('PaymentRefundService requires db');
this.db = db;
this.walletService = walletService;
this.logger = logger;
// The application injects its single payout master/readiness predicate. Keep the
// service independently testable, but never let an admin refund become a second wallet
// transfer path that bypasses PAYOUTS_ENABLED, startup recovery, or wallet health.
this.isSendEnabled = typeof isSendEnabled === 'function' ? isSendEnabled : () => true;
}
_assertSendEnabled() {
if (!this.isSendEnabled()) {
throw new AppError('Automatic refund transfer is disabled by the payout safety gate.', {
statusCode: 503,
code: 'PAYOUT_DISPATCH_DISABLED',
safeMessage: 'Automatic refund sending is disabled. Record the refund without sending funds, or restore the payout safety gate first.'
});
}
}
async refundPayment({ paymentId, reason = 'Admin refund', sendFunds = false }) {
if (sendFunds === true) this._assertSendEnabled();
const normalizedReason = typeof reason === 'string' && reason.trim()
? reason.trim()
: 'Admin refund';
const recorded = await this._recordOnce({
paymentId,
reason: normalizedReason,
sendFunds: sendFunds === true
});
let refund = recorded.refund;
let walletClaimed = false;
// A recorded-only refund is terminal. An existing requested row may be safely
// claimed after a crash that happened before any wallet call was started.
if (sendFunds === true && refund.status === 'requested') {
const attempt = await this._claimAndSend(paymentId);
refund = attempt.refund;
walletClaimed = attempt.claimed;
}
return {
payment: recorded.payment,
refund,
existing: recorded.existing,
legacyImported: recorded.legacyImported,
walletClaimed
};
}
async _recordOnce({ paymentId, reason, sendFunds }) {
const client = await this.db.getClient();
try {
await client.query('BEGIN');
// This is the serialization point for all refund requests for one payment.
const paymentResult = await client.query(`
SELECT p.*
FROM payments p
WHERE p.id = $1
FOR UPDATE
`, [paymentId]);
if (paymentResult.rows.length === 0) {
throw new NotFoundError('Payment not found', {
safeMessage: `Payment ${paymentId} not found.`
});
}
const payment = paymentResult.rows[0];
const existingResult = await client.query(`
SELECT *
FROM payment_refunds
WHERE payment_id = $1
FOR UPDATE
`, [paymentId]);
if (existingResult.rows.length > 0) {
await client.query('COMMIT');
return {
payment,
refund: existingResult.rows[0],
existing: true,
legacyImported: false
};
}
const receivedAmount = parseAtomic(payment.received_amount, 'received amount');
const expectedAmount = parseAtomic(payment.expected_amount, 'expected amount');
// Never pay an unconfirmed invoice merely because it has an expected amount.
// Confirmed legacy rows may lack received_amount, hence the narrow fallback.
const refundAmount = receivedAmount > 0n
? receivedAmount
: (payment.status === 'confirmed' ? expectedAmount : 0n);
let user = null;
if (payment.user_id != null) {
const userResult = await client.query(`
SELECT id, credits, race_entries, total_credits_purchased,
premium_level, payout_address
FROM users
WHERE id = $1
FOR UPDATE
`, [payment.user_id]);
user = userResult.rows[0] || null;
}
const payoutAddress = typeof user?.payout_address === 'string' && user.payout_address.trim()
? user.payout_address.trim()
: null;
// Historic refunded rows predate the outbox. Snapshot them as recorded, but
// never deduct grants or send funds because their prior side effects cannot be
// proven from the old schema.
const legacyImported = payment.status === 'refunded';
let grant = null;
let creditsDeducted = 0n;
let raceEntriesDeducted = 0;
let packsRevoked = [];
let premiumLevelRestored = null;
let reviewReason = null;
let lot = null;
if (!legacyImported && payment.status === 'confirmed') {
const grantResult = await client.query(`
SELECT *
FROM payment_entitlement_grants
WHERE payment_id = $1
FOR UPDATE
`, [paymentId]);
grant = grantResult.rows[0] || null;
// Upgrade fallback for a confirmed product that predates migration 035. The
// marker is still created under the payment lock before any reversal.
if (!grant && ['credits_package', 'cosmetic_pack'].includes(payment.payment_type)) {
const durable = jsonObject(payment.product_grants);
const legacyCredits = parseAtomic(
durable.credits ?? payment.credits_purchased ?? 0,
'legacy credits grant'
);
const legacyRace = Number(durable.raceEntries ?? durable.race_entries ?? 0);
const legacyPacks = Array.isArray(durable.packs) ? durable.packs : [];
const legacyPremium = durable.premiumLevel || durable.premium_level || null;
const inserted = await client.query(`
INSERT INTO payment_entitlement_grants (
payment_id, user_id, source, credits_granted,
purchase_progress_granted, race_entries_granted, packs_granted,
premium_level_granted, metadata
) VALUES (
$1, $2, 'legacy_product_backfill', $3::bigint, $3::bigint,
$4, $5::jsonb, $6, $7::jsonb
)
ON CONFLICT (payment_id) DO NOTHING
RETURNING *
`, [
paymentId,
payment.user_id,
legacyCredits.toString(),
Number.isSafeInteger(legacyRace) && legacyRace > 0 ? legacyRace : 0,
JSON.stringify(legacyPacks),
legacyPremium,
JSON.stringify({ runtimeLegacyBackfill: true })
]);
grant = inserted.rows[0] || null;
}
// A linked game proves that a direct entry was consumed. Never send its coins
// automatically; gameplay value and any payout liability require reconciliation.
const consumedGame = await client.query(
`SELECT id FROM games WHERE payment_id = $1 LIMIT 1`,
[paymentId]
);
if (consumedGame.rows.length > 0) {
reviewReason = 'Payment was consumed by a game; automatic refund is unsafe.';
}
}
const creditsGranted = grant ? parseAtomic(grant.credits_granted, 'credits grant') : 0n;
const progressGranted = grant ? parseAtomic(grant.purchase_progress_granted, 'purchase progress grant') : 0n;
const raceGranted = grant ? Number(grant.race_entries_granted || 0) : 0;
const grantedPackIds = grant ? packIds(jsonObject({ value: grant.packs_granted }).value || grant.packs_granted) : [];
// PostgreSQL returns JSONB arrays directly; string-backed test/adapter rows need parsing.
const parsedPacks = typeof grant?.packs_granted === 'string'
? (() => { try { return JSON.parse(grant.packs_granted); } catch (_) { return []; } })()
: grant?.packs_granted;
const actualPackIds = grant ? packIds(parsedPacks) : grantedPackIds;
if (!legacyImported && !reviewReason && payment.status !== 'confirmed') {
reviewReason = 'Only a confirmed payment can be refunded automatically.';
}
// Direct-entry confirmation historically advanced lifetime purchase progression
// outside the payment grant snapshot. Until that benefit has payment-scoped
// provenance, neither a bare direct payment nor its recovery-credit marker can be
// reversed exactly. Keep the coins in manual review instead of refunding while
// leaving a tier/cosmetic unlock behind.
if (!legacyImported && !reviewReason && payment.payment_type === 'single_game'
&& (!grant || grant.source === 'single_game_recovery')) {
reviewReason = 'Direct-entry purchase progression requires manual reconciliation.';
}
if (!legacyImported && !reviewReason && grant && grant.status !== 'active') {
reviewReason = `Entitlement grant is ${grant.status}; manual reconciliation is required.`;
}
if (!legacyImported && !reviewReason
&& (creditsGranted > 0n || progressGranted > 0n || raceGranted > 0
|| actualPackIds.length > 0 || grant?.premium_level_granted)
&& !user) {
reviewReason = 'The entitlement owner no longer exists.';
}
if (!legacyImported && !reviewReason && sendFunds && refundAmount <= 0n) {
reviewReason = 'No received payment amount is available for an automatic refund.';
}
if (!legacyImported && !reviewReason && sendFunds && !payoutAddress) {
reviewReason = 'No payout address is available for an automatic refund.';
}
if (!legacyImported && !reviewReason && creditsGranted > parseAtomic(user?.credits, 'user credits')) {
reviewReason = 'Purchased credits have already been consumed.';
}
if (!legacyImported && !reviewReason
&& progressGranted > parseAtomic(user?.total_credits_purchased, 'purchase progress')) {
reviewReason = 'Purchase progression cannot be reversed safely.';
}
if (!legacyImported && !reviewReason
&& (!Number.isSafeInteger(raceGranted) || raceGranted < 0
|| raceGranted > Number(user?.race_entries || 0))) {
reviewReason = 'Purchased race entries have already been consumed or escrowed.';
}
if (!legacyImported && !reviewReason && raceGranted > 0) {
const lotResult = await client.query(`
SELECT id, original_entries, remaining_entries, refunded_at
FROM race_entry_lots
WHERE payment_id = $1
FOR UPDATE
`, [paymentId]);
lot = lotResult.rows[0] || null;
if (lot && (lot.refunded_at != null
|| Number(lot.original_entries) !== raceGranted
|| Number(lot.remaining_entries) !== raceGranted)) {
reviewReason = 'Purchased race entries have already been consumed or escrowed.';
}
}
let lockedPacks = [];
if (!legacyImported && !reviewReason && actualPackIds.length > 0) {
if (grant.source !== 'product_confirmation') {
reviewReason = 'Legacy pack provenance is ambiguous.';
} else {
const packResult = await client.query(`
SELECT pack_id, source, metadata
FROM user_pack_entitlements
WHERE user_id = $1 AND pack_id = ANY($2::text[])
ORDER BY pack_id
FOR UPDATE
`, [payment.user_id, actualPackIds]);
lockedPacks = packResult.rows || [];
const ownedByPayment = new Set(lockedPacks
.filter(row => String(jsonObject(row.metadata).paymentId) === String(paymentId))
.map(row => row.pack_id));
if (actualPackIds.some(id => !ownedByPayment.has(id))) {
reviewReason = 'A purchased pack is missing or has different ownership provenance.';
}
}
}
if (!legacyImported && !reviewReason && grant?.premium_level_granted) {
if (!grant.premium_level_previous
|| user?.premium_level !== grant.premium_level_granted) {
reviewReason = 'The purchased premium level changed after purchase.';
}
}
if (!legacyImported && reviewReason) {
if (grant) {
await client.query(`
UPDATE payment_entitlement_grants
SET status = 'needs_review', needs_review_at = NOW(), reversal_reason = $2
WHERE payment_id = $1 AND status = 'active'
`, [paymentId, reviewReason]);
}
const reviewInsert = await client.query(`
INSERT INTO payment_refunds (
payment_id, user_id, status, amount, payout_address,
credits_deducted, race_entries_deducted, packs_revoked,
reason, requested_at, error_message,
entitlement_grant_payment_id
) VALUES (
$1, $2, 'needs_review', $3::bigint, $4,
0, 0, '[]'::jsonb, $5, NOW(), $6, $7
)
RETURNING *
`, [paymentId, payment.user_id, refundAmount.toString(), payoutAddress,
reason, reviewReason, grant ? paymentId : null]);
await client.query('COMMIT');
return {
payment,
refund: reviewInsert.rows[0],
existing: false,
legacyImported: false
};
}
if (!legacyImported && grant && user) {
const premiumPrevious = grant.premium_level_granted
? grant.premium_level_previous
: user.premium_level;
const updatedUser = await client.query(`
UPDATE users
SET credits = credits - $1::bigint,
total_credits_purchased = total_credits_purchased - $2::bigint,
race_entries = race_entries - $3,
premium_level = $4
WHERE id = $5
RETURNING credits, race_entries, total_credits_purchased, premium_level
`, [creditsGranted.toString(), progressGranted.toString(), raceGranted,
premiumPrevious, payment.user_id]);
if (updatedUser.rows.length !== 1) {
throw new AppError('Locked refund user disappeared during entitlement reversal');
}
creditsDeducted = creditsGranted;
raceEntriesDeducted = raceGranted;
premiumLevelRestored = grant.premium_level_granted ? premiumPrevious : null;
if (creditsDeducted > 0n) {
await client.query(`
INSERT INTO credit_transactions (
user_id, amount, reason, balance_after, transaction_type, payment_id
) VALUES ($1, $2::bigint, $3, $4::bigint, 'refund', $5)
`, [payment.user_id, (-creditsDeducted).toString(),
`payment_refund:${paymentId}`, String(updatedUser.rows[0].credits), paymentId]);
}
if (raceEntriesDeducted > 0) {
await client.query(`
INSERT INTO race_entry_transactions (
user_id, delta, balance_after, reason, payment_id, metadata
) VALUES ($1, $2, $3, 'refund', $4, $5::jsonb)
`, [payment.user_id, -raceEntriesDeducted,
updatedUser.rows[0].race_entries, paymentId,
JSON.stringify({ refundPaymentId: paymentId })]);
}
if (lot) {
const reversedLot = await client.query(`
UPDATE race_entry_lots
SET remaining_entries = 0, refunded_at = NOW()
WHERE id = $1 AND refunded_at IS NULL
AND remaining_entries = original_entries
RETURNING id
`, [lot.id]);
if (reversedLot.rowCount !== 1) {
throw new AppError('Race-entry lot changed during refund reversal');
}
}
if (actualPackIds.length > 0) {
const removed = await client.query(`
DELETE FROM user_pack_entitlements
WHERE user_id = $1
AND pack_id = ANY($2::text[])
AND metadata->>'paymentId' = $3
RETURNING pack_id
`, [payment.user_id, actualPackIds, String(paymentId)]);
packsRevoked = (removed.rows || []).map(row => row.pack_id);
if (packsRevoked.length !== actualPackIds.length) {
throw new AppError('Pack entitlement changed during refund reversal');
}
}
await client.query(`
UPDATE payment_entitlement_grants
SET status = 'reversed', credits_reversed = $2::bigint,
purchase_progress_reversed = $3::bigint,
race_entries_reversed = $4, packs_reversed = $5::jsonb,
reversal_reason = $6, reversed_at = NOW()
WHERE payment_id = $1 AND status = 'active'
`, [paymentId, creditsDeducted.toString(), progressGranted.toString(),
raceEntriesDeducted, JSON.stringify(packsRevoked), reason]);
}
if (!legacyImported) {
await client.query(`
UPDATE payments
SET status = 'refunded'
WHERE id = $1
`, [paymentId]);
payment.status = 'refunded';
}
const initialStatus = sendFunds && !legacyImported ? 'requested' : 'recorded';
const insertResult = await client.query(`
INSERT INTO payment_refunds (
payment_id, user_id, status, amount, payout_address,
credits_deducted, purchase_progress_deducted,
race_entries_deducted, packs_revoked,
premium_level_restored, reason, requested_at,
entitlement_grant_payment_id
) VALUES (
$1, $2, $3, $4::bigint, $5, $6::bigint, $7::bigint,
$8, $9::jsonb, $10, $11,
CASE WHEN $3 = 'requested' THEN NOW() ELSE NULL END, $12
)
RETURNING *
`, [
paymentId,
payment.user_id,
initialStatus,
refundAmount.toString(),
payoutAddress,
creditsDeducted.toString(),
progressGranted.toString(),
raceEntriesDeducted,
JSON.stringify(packsRevoked),
premiumLevelRestored,
reason,
grant ? paymentId : null
]);
await client.query('COMMIT');
return {
payment,
refund: insertResult.rows[0],
existing: legacyImported,
legacyImported
};
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
throw error;
} finally {
client.release();
}
}
async _claimAndSend(paymentId) {
const claimResult = await this.db.query(`
UPDATE payment_refunds
SET status = 'processing',
processing_started_at = NOW(),
updated_at = NOW(),
error_message = NULL
WHERE payment_id = $1
AND status = 'requested'
RETURNING *
`, [paymentId]);
if (claimResult.rows.length === 0) {
return { claimed: false, refund: await this._getRefund(paymentId) };
}
const claimed = claimResult.rows[0];
let observedTxHash = null;
try {
if (!this.walletService || typeof this.walletService.processPayout !== 'function') {
throw new Error('Wallet service is unavailable; manual refund review is required.');
}
if (BigInt(claimed.amount) <= 0n) {
throw new Error('No received payment amount is available for an automatic refund.');
}
if (typeof claimed.payout_address !== 'string' || !claimed.payout_address.trim()) {
throw new Error('No payout address is available for an automatic refund.');
}
// Recheck after the durable processing claim and immediately before the wallet call.
// A concurrent kill-switch change therefore cannot be missed by a long DB reversal.
this._assertSendEnabled();
const result = await this.walletService.processPayout({
address: claimed.payout_address,
amount: BigInt(claimed.amount),
userId: claimed.user_id,
description: `Refund for payment ${paymentId}`
});
observedTxHash = typeof result?.txHash === 'string' ? result.txHash.trim() : null;
if (result?.success !== true || !observedTxHash || !TX_HASH_PATTERN.test(observedTxHash)) {
throw new Error('Wallet did not return explicit success with a valid transaction hash.');
}
const completedResult = await this.db.query(`
UPDATE payment_refunds
SET status = 'completed',
tx_hash = $2,
error_message = NULL,
completed_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND status = 'processing'
RETURNING *
`, [claimed.id, observedTxHash]);
if (completedResult.rows.length === 0) {
// The transfer may have happened, so no state transition here may ever
// make this row eligible for another automatic attempt.
return { claimed: true, refund: await this._getRefund(paymentId) };
}
return { claimed: true, refund: completedResult.rows[0] };
} catch (error) {
const reviewResult = await this.db.query(`
UPDATE payment_refunds
SET status = 'needs_review',
tx_hash = COALESCE($2, tx_hash),
error_message = $3,
needs_review_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND status = 'processing'
RETURNING *
`, [claimed.id, observedTxHash, errorMessage(error)]);
const finalRefund = reviewResult.rows[0] || await this._getRefund(paymentId);
if (finalRefund.status !== 'completed') {
this.logger?.error?.('Payment refund requires manual review', {
paymentId,
refundId: claimed.id,
error: errorMessage(error)
});
}
return {
claimed: true,
refund: finalRefund
};
}
}
async _getRefund(paymentId) {
const result = await this.db.query(`
SELECT *
FROM payment_refunds
WHERE payment_id = $1
`, [paymentId]);
if (result.rows.length === 0) {
throw new AppError('Payment refund state disappeared', {
code: 'REFUND_STATE_MISSING',
safeMessage: 'The refund state could not be loaded.'
});
}
return result.rows[0];
}
static toApiResult(result, gameModeManager) {
const { payment, refund } = result;
const requiresReview = refund.status === 'needs_review' || refund.status === 'processing';
let message = 'Payment refunded successfully.';
if (refund.status === 'processing') {
message = 'Refund transfer is processing. Do not retry it; reconcile the wallet if it remains processing.';
} else if (refund.status === 'needs_review') {
message = 'Payment refund recorded; the transfer requires manual review.';
} else if (result.existing) {
message = 'Payment refund already recorded.';
}
return {
success: !result.existing,
message,
refund: {
paymentId: payment.id,
originalAmount: payment.expected_amount,
originalAmountFormatted: gameModeManager.formatAtomicHuman(payment.expected_amount, 4),
amount: refund.amount,
creditsDeducted: jsonInteger(refund.credits_deducted),
purchaseProgressDeducted: jsonInteger(refund.purchase_progress_deducted),
raceEntriesDeducted: Number(refund.race_entries_deducted || 0),
packsRevoked: Array.isArray(refund.packs_revoked) ? refund.packs_revoked : [],
premiumLevelRestored: refund.premium_level_restored || null,
fundsSent: refund.status === 'completed',
txHash: refund.tx_hash || null,
reason: refund.reason,
status: refund.status,
requiresReview
}
};
}
}
module.exports = PaymentRefundService;