From 9ba1460905500021e3802dbc31d02d7419ce229d Mon Sep 17 00:00:00 2001 From: 0xward <0xward.dev@gmail.com> Date: Mon, 3 Aug 2026 11:48:15 +0000 Subject: [PATCH] The vault paid nothing, and then told him the pool was empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OWNER, week 202632: he found the Paper, found the Golden Key, entered the right code, and no USDT arrived. He recovered it only by reading the contract on celoscan himself. A player cannot do that — for them the sequence ends at "won, paid nothing". WHAT WAS MEASURED BEFORE ANY CODE WAS WRITTEN, none of it guessed: pool balance ............... 0.85 USDT (the reward is 0.05) enough deposited 1.00 / claimed 0.15 ................................ enough backend signer ............. 0.645 CELO enough isCodeSetForWeek(202632) ... true simulate submitVaultCode ... does NOT revert celoscan ................... "Store Weekly Vault Code" SUCCEEDED, "Submit Vault Code" NEVER BROADCAST Not a revert, not funding, not permissions. The payout transaction never existed. finalizeVaultPayout() did TWO sequential on-chain writes in ONE player request — store this week's code, then pay — and only for the FIRST winner of any given week. Everyone after them found the code already stored and took a single write, which is why it looked random and survived weeks. Forno is load-balanced, so the node answering the nonce read for write #2 need not have seen write #1; the transaction was rejected at the RPC boundary before it was a transaction. The catch swallowed it and reported "pending". THE FIX IS TO DELETE THE SHAPE, NOT TO HARDEN IT. Storing the code is now its own errand, done when the vault door is OPENED by anybody — hours or days before a code is solved. POST /api/vault/prepare, fired and forgotten by openVaultWindow(), plus the daily cron as a net for a week nobody opens a door in. By the time someone wins, the payout is one write. The two-write path is not more reliable; for real players it is gone. When it is unavoidable — the first person to open this week's vault also solves it in the same request — storeThenPay() reads the nonce ONCE and numbers both writes locally. The RPC is never asked twice, so it cannot answer twice differently. The payout also retries three times with backoff, re-checking hasClaimedThisWeek before each one so a receipt we failed to hear is never paid twice. A REWARD CAN NO LONGER BE LOST BY NOT REACHING THE DOOR AGAIN. The self-heal on re-open was the ONLY retry, and it is gated behind re-entering Bunker 5, clearing it and killing the final boss. sweepPendingVaultPayouts() now collects unpaid wins from the cron that already runs daily, across this week AND last week so a Sunday-night win is not stranded by the Monday rollover. The player does nothing. AND THE MESSAGE STOPS ACCUSING THE POOL. Every failed payout printed "the transfer completes as soon as the reward pool is topped up". The server had never read the pool. That sentence is what sent the owner, and then an agent, into the treasury for an hour while a dropped transaction sat unexamined. Each cause is now CHECKED before it is named — pool_empty reads getAvailableVaultFunds against vaultReward, signer_out_of_gas reads the signer's balance — and the true one is sent down and printed. Verified: 51/51 test:vaultpay (new; its FAILS-ON-OLD sections run the deleted implementation against the same fakes and it fails all four), 18/18 test:vaultwin, 8/8 test:kills, 19/19 test:seasonxp, 128/128 test-season-close, tsc clean, build succeeds with /game unchanged at 143/242 kB, lint 35 (baseline), check:copy and audit clean, engine minifies 14/14. NOT verified, and the test file says so in its header: there is no Celo node in this sandbox, so every client in the new tests is a fake whose behaviour I chose. They prove the code does the right thing when an RPC misbehaves the way the chain evidence says it did. They do not prove that is the only way it can. The sweep exists because I cannot promise this was the last failure mode. Contracts untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RLrEEtpTAcJq334heqKGit --- app/api/cron/season/route.ts | 31 +++ app/api/paper/status/route.ts | 22 +- app/api/vault/prepare/route.ts | 86 ++++++ app/api/vault/submit/route.ts | 187 ++++++------- lib/server/vaultChain.ts | 393 +++++++++++++++++++++++++++ lib/server/vaultCode.ts | 47 ++++ lib/server/vaultSweep.ts | 165 ++++++++++++ lib/vault-utils.ts | 16 ++ package.json | 3 +- public/game-engine/game.js | 51 +++- scripts/test-vault-payout.js | 479 +++++++++++++++++++++++++++++++++ scripts/test-vault-win.js | 28 +- 12 files changed, 1374 insertions(+), 134 deletions(-) create mode 100644 app/api/vault/prepare/route.ts create mode 100644 lib/server/vaultChain.ts create mode 100644 lib/server/vaultCode.ts create mode 100644 lib/server/vaultSweep.ts create mode 100644 scripts/test-vault-payout.js diff --git a/app/api/cron/season/route.ts b/app/api/cron/season/route.ts index ffcf531..a7be8c1 100644 --- a/app/api/cron/season/route.ts +++ b/app/api/cron/season/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server' import { getAdminDb, getAdminFirestore } from '@/firebase-config' import { prepareSeason, previousSeasonId, payoutCommands, prune } from '@/lib/server/seasonClose' import { getCurrentSeasonId } from '@/lib/web3-client' +import { sweepPendingVaultPayouts } from '@/lib/server/vaultSweep' +import { recentWeekIdStrings } from '@/lib/vault-utils' export const dynamic = 'force-dynamic' @@ -79,11 +81,40 @@ export async function GET(req: NextRequest) { console.error('[cron/season] prune failed (season still frozen):', err) } + // ── Unpaid vault rewards ──────────────────────────────────────────────── + // Rides the same daily wake-up, for the same reasons as the prune above, + // and for one more: it is the ONLY route to a stuck reward that does not + // ask the player to re-clear Bunker 5 and kill the final boss to reach the + // vault door again. The owner had to read the contract on celoscan to + // recover his; a player cannot. See lib/server/vaultSweep.ts. + // + // Caught, never thrown: a payout that cannot be retried must not stop a + // season being frozen. + let vault = { checked: 0, paid: 0, stillPending: 0 } + try { + const swept = await sweepPendingVaultPayouts(db, { weekIds: recentWeekIdStrings() }) + vault = { checked: swept.checked, paid: swept.paid, stillPending: swept.stillPending } + if (swept.checked) { + console.log( + `[cron/season] vault sweep: ${swept.paid}/${swept.checked} paid` + + (swept.stillPending + ? ' · still pending: ' + swept.outcomes + .filter((o) => o.status === 'pending') + .map((o) => `${o.wallet}@${o.weekId} (${o.reason})`) + .join(' · ') + : ''), + ) + } + } catch (err) { + console.error('[cron/season] vault sweep failed (season still frozen):', err) + } + return NextResponse.json({ seasonId, created, snapshot, pruned: pruned.length, + vault, commands: payoutCommands(snapshot), note: created ? 'Season frozen. Run the commands above, then POST /api/season/status to mark it paid.' diff --git a/app/api/paper/status/route.ts b/app/api/paper/status/route.ts index 8d262bf..0aef809 100644 --- a/app/api/paper/status/route.ts +++ b/app/api/paper/status/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getAdminDb } from '@/firebase-config' import { walletAddressSchema } from '@/lib/validation' import { normalizeWalletAddress, getCurrentWeekIdString } from '@/lib/vault-utils' +import { ensureWeeklyCodeInDb } from '@/lib/server/vaultCode' // Reads ?walletAddress, so it's always dynamic — declare it so the build // doesn't try to prerender it statically and log a DYNAMIC_SERVER_USAGE error. @@ -35,25 +36,6 @@ export const dynamic = 'force-dynamic' // Response: { weekId, claimed: boolean, canClaim: boolean, code: string|null } // ============================================= -async function ensureWeeklyCode(db: NonNullable>, weekId: string): Promise { - const ref = db.ref(`vaultCodes/${weekId}`) - const snap = await ref.get() - const existing = snap.val() - if (existing && typeof existing.code === 'string' && /^\d{4}$/.test(existing.code)) { - return existing.code - } - // Not generated yet — atomic transaction so a race between two players' - // first-of-the-week requests can only ever commit one code. - const generated = String(Math.floor(1000 + Math.random() * 9000)) - const txResult = await ref.transaction((current: unknown) => { - const cur = current as { code?: string } | null - if (cur && typeof cur.code === 'string' && /^\d{4}$/.test(cur.code)) return undefined // abort — already set - return { code: generated, generatedAt: Date.now() } - }) - const finalVal = txResult.snapshot?.val() as { code?: string } | null - return finalVal?.code ?? generated -} - export async function GET(req: NextRequest) { try { const walletAddress = req.nextUrl.searchParams.get('walletAddress') ?? '' @@ -81,7 +63,7 @@ export async function GET(req: NextRequest) { let code: string | null = null if (claimed) { - code = await ensureWeeklyCode(db, weekId) + code = await ensureWeeklyCodeInDb(db, weekId) } return NextResponse.json({ weekId, claimed, canClaim: !claimed, code }, { status: 200 }) diff --git a/app/api/vault/prepare/route.ts b/app/api/vault/prepare/route.ts new file mode 100644 index 0000000..cf2811a --- /dev/null +++ b/app/api/vault/prepare/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from 'next/server' +import { getAdminDb } from '@/firebase-config' +import { getCurrentWeekIdString } from '@/lib/vault-utils' +import { ensureWeeklyCodeInDb } from '@/lib/server/vaultCode' +import { ensureWeeklyCodeOnChain, getVaultClients, isCodeOnChain } from '@/lib/server/vaultChain' + +export const dynamic = 'force-dynamic' + +// Storing a code is one write and normally finishes in a couple of seconds, +// but it waits for a Celo receipt and Vercel's 10s default is not a margin. +export const maxDuration = 30 + +// ============================================= +// VAULT PREPARE — put this week's code on chain BEFORE anybody wins +// POST /api/vault/prepare -> { weekId, onChain, stored } +// +// PRIORITY #1 OF THE VAULT FIX, and the reason the other three are small. +// +// /api/vault/submit used to do two on-chain writes in one request the first +// time anyone won in a given week: store the week's code, then pay. Only the +// FIRST winner of the week took that path, which is why the failure looked +// random and survived for weeks — and the second write is the one that got +// dropped by a load-balanced Forno before it ever reached the mempool. Measured +// on chain for week 202632: "Store Weekly Vault Code" succeeded, "Submit Vault +// Code" was never broadcast at all. The pool was fine. Gas was fine. The code +// was set. The transaction simply did not exist. +// +// So the two writes are pulled apart in TIME rather than made more reliable. +// The engine calls this the moment the vault door is opened by anyone — hours +// or days before a code is solved — and by the time a player wins, the payout +// is a single write. The failure mode is not mitigated; the shape that produced +// it is gone. +// +// WHY IT NEEDS NO AUTH. It reveals nothing and grants nothing: the week's code +// is ALREADY public on chain (weeklyVaultCodes is a public getter on the +// deployed contract) and this route never returns it. Everything it can do, +// it can do at most once per week — after that `isCodeSetForWeek` is true and +// the call is one cheap RPC read. The worst an attacker achieves by hammering +// it is making our own server do a read it would have done anyway. +// +// The daily cron (/api/cron/season) calls the same helper as a net, so a week +// where nobody opens the vault door still has its code stored before the first +// winner appears. +// ============================================= + +export async function POST() { + const weekId = getCurrentWeekIdString() + + try { + const db = getAdminDb() + if (!db) { + // Degrades OPEN, like every other weekly route here. With Firebase down + // there is no code to store, and refusing loudly would only turn a + // backend outage into a broken vault door. + return NextResponse.json({ weekId, onChain: false, stored: false, skipped: 'no_db' }, { status: 200 }) + } + + const clients = getVaultClients() + if (!clients) { + return NextResponse.json({ weekId, onChain: false, stored: false, skipped: 'not_configured' }, { status: 200 }) + } + + // Cheapest possible early exit, and the one taken on all but the first + // call of the week: one RPC read, no Firebase write, no transaction. + if (await isCodeOnChain(clients.publicClient, Number(weekId))) { + return NextResponse.json({ weekId, onChain: true, stored: false }, { status: 200 }) + } + + const code = await ensureWeeklyCodeInDb(db, weekId) + const result = await ensureWeeklyCodeOnChain(Number(weekId), code) + + if (!result.onChain) { + console.error(`[vault/prepare] could not store week ${weekId} on chain:`, result.error ?? 'unknown') + } + return NextResponse.json( + { weekId, onChain: result.onChain, stored: result.stored, txHash: result.txHash ?? null }, + { status: 200 }, + ) + } catch (error) { + // Never fails the caller. This is a background errand fired by a game + // screen; a player opening a vault door must not see an error because a + // preparation step they never asked for did not work. + console.error('[vault/prepare] error:', error instanceof Error ? error.message : error) + return NextResponse.json({ weekId, onChain: false, stored: false, skipped: 'error' }, { status: 200 }) + } +} diff --git a/app/api/vault/submit/route.ts b/app/api/vault/submit/route.ts index edf0bb4..1637e08 100644 --- a/app/api/vault/submit/route.ts +++ b/app/api/vault/submit/route.ts @@ -1,19 +1,4 @@ import { NextRequest, NextResponse } from 'next/server' -import { createPublicClient, createWalletClient } from 'viem' -import { privateKeyToAccount } from 'viem/accounts' -import { celo } from 'viem/chains' -import { celoTransport } from '@/lib/celoRpc' -import { formatUnits } from 'viem' -import { TREASURE_VAULT_ABI, TREASURE_VAULT_ADDRESS } from '@/lib/contract-abi' -import { MARKETPLACE_TOKENS } from '@/lib/constants/tokens' - -// Minimal read ABI matching the DEPLOYED TreasureVault (public `vaultReward` -// getter is lowercase; TREASURE_VAULT_ABI still carries the old `VAULT_REWARD` -// constant name the live contract no longer exposes). -const VAULT_READ_ABI = [ - { name: 'vaultReward', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] }, - { name: 'currentRewardToken', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] }, -] as const import { getAdminDb } from '@/firebase-config' import { vaultSubmitBodySchema } from '@/lib/validation' import { @@ -21,16 +6,17 @@ import { parseWeekId, normalizeWalletAddress, } from '@/lib/vault-utils' -import { getServerAttributionSuffix } from '@/lib/attribution-tag' +import { + storeThenPay, + VAULT_FAILURE_MESSAGE, + type VaultPayoutFailure, +} from '@/lib/server/vaultChain' -// The on-chain payout can involve up to TWO sequential transactions the first -// time anyone wins in a given week (store the week's code on-chain, then pay), -// each of which waits for a Celo receipt. On Vercel the default function -// timeout (10s) is not enough for that and the request was being killed AFTER -// the code was stored but BEFORE submitVaultCode was ever broadcast — the -// player's win was recorded off-chain but no USDT ever moved (confirmed -// on-chain: code stored, pool claimed still 0, no submitVaultCode tx at all). -// Give the route real headroom so both txs can confirm in one request. +// Headroom for the on-chain payout to confirm inside the request. It is now +// normally ONE write (lib/server/vaultChain.ts explains why), but a payout that +// waits for a Celo receipt still needs more than Vercel's 10s default — that +// default is what killed the request after the code was stored and before +// submitVaultCode was ever broadcast. export const maxDuration = 60 // `amount`/`token` are what was ACTUALLY paid, read back off the vault contract @@ -44,13 +30,23 @@ type PayoutResult = { txHash: string | null amount?: number token?: string + // WHY a pending result now carries a reason. The popup used to print one + // sentence for every failure alike — "the transfer completes as soon as the + // reward pool is topped up" — and the server had never looked at the pool. + // On the week this was measured the pool held 0.85 USDT against a 0.05 + // reward and the real fault was a dropped RPC broadcast; that sentence cost + // the owner, and then an agent, an hour of looking at treasury balances. See + // VAULT_FAILURE_MESSAGE in lib/server/vaultChain.ts. + reason?: VaultPayoutFailure + message?: string } -// Best-effort on-chain finalize: make sure this week's code is stored on-chain -// (backend signer is authorized), then pay the winner. NEVER throws — a failure -// here leaves the reward `pending`, it never turns a correct code into an error. -// Both the first-win path and the self-heal path (re-open a vault whose reward -// is still pending) funnel through here so the logic lives in one place. +// Best-effort on-chain finalize. NEVER throws — a failure here leaves the +// reward `pending`, it never turns a correct code into an error. +// +// All the on-chain reasoning moved to lib/server/vaultChain.ts. What is left +// here is the Firebase side: stamp what was paid, or record WHY it was not so +// the daily sweep and the player see the same, true reason. async function finalizeVaultPayout(params: { weekId: number walletAddress: string @@ -60,86 +56,51 @@ async function finalizeVaultPayout(params: { }): Promise { const { weekId, walletAddress, normalizedWallet, expectedCode, db } = params - const backendPrivateKey = process.env.BACKEND_PRIVATE_KEY as `0x${string}` | undefined - if (!backendPrivateKey || !TREASURE_VAULT_ADDRESS || TREASURE_VAULT_ADDRESS === '0x') { - return { rewardStatus: 'pending', txHash: null } - } - - try { - const account = privateKeyToAccount(backendPrivateKey) - const transport = celoTransport() - const publicClient = createPublicClient({ chain: celo, transport }) - const walletClient = createWalletClient({ chain: celo, transport, account }) - const weekBig = BigInt(weekId) - - // AUTO on-chain code sync — submitVaultCode() reverts unless this week's - // code is already stored on-chain. Guarded by isCodeSetForWeek so it runs - // at most once per week; a race that reverts with "already set" is caught - // and we proceed to pay. - const alreadySet = await publicClient - .readContract({ address: TREASURE_VAULT_ADDRESS, abi: TREASURE_VAULT_ABI, functionName: 'isCodeSetForWeek', args: [weekBig] }) - .catch(() => false) - if (!alreadySet) { - try { - const storeHash = await walletClient.writeContract({ - address: TREASURE_VAULT_ADDRESS, - abi: TREASURE_VAULT_ABI, - functionName: 'storeWeeklyVaultCode', - args: [weekBig, expectedCode], // the Firebase code the Paper shows - account, - dataSuffix: getServerAttributionSuffix(), - }) - await publicClient.waitForTransactionReceipt({ hash: storeHash }) - } catch (storeErr) { - const nowSet = await publicClient - .readContract({ address: TREASURE_VAULT_ADDRESS, abi: TREASURE_VAULT_ABI, functionName: 'isCodeSetForWeek', args: [weekBig] }) - .catch(() => false) - if (!nowSet) throw storeErr - } - } + const result = await storeThenPay({ weekId, walletAddress, expectedCode }) - // Pay the winner. We submit the EXPECTED (canonical) code rather than the - // raw user input: correctness was already authenticated off-chain against - // Firebase, so this guarantees the on-chain string comparison matches and - // the reward is released (a self-heal re-open must pay even if the player - // fat-fingers a digit the second time). - const hash = await walletClient.writeContract({ - address: TREASURE_VAULT_ADDRESS, - abi: TREASURE_VAULT_ABI, - functionName: 'submitVaultCode', - args: [walletAddress as `0x${string}`, weekBig, expectedCode], - account, - dataSuffix: getServerAttributionSuffix(), + if (result.ok) { + await db.ref(`vaultCompleted/${weekId}/${normalizedWallet}`).update({ + txHash: result.txHash, + ...(result.amount !== undefined ? { amount: result.amount, token: result.token } : {}), + // Clear any reason left by an earlier failed attempt — a paid reward that + // still carries "pool_empty" is how a fixed problem keeps being reported. + pendingReason: null, + pendingDetail: null, }) - await publicClient.waitForTransactionReceipt({ hash }) - - // Stamp what was actually paid (amount + token symbol) so the Rewards - // history can show "+0.05 USDT" without a later RPC read. Best-effort — - // the payout already succeeded, so a failed read here must not undo it. - let paidAmount: number | undefined - let paidToken: string | undefined - try { - const [reward, tokenAddr] = await Promise.all([ - publicClient.readContract({ address: TREASURE_VAULT_ADDRESS, abi: VAULT_READ_ABI, functionName: 'vaultReward' }) as Promise, - publicClient.readContract({ address: TREASURE_VAULT_ADDRESS, abi: VAULT_READ_ABI, functionName: 'currentRewardToken' }) as Promise<`0x${string}`>, - ]) - const match = Object.values(MARKETPLACE_TOKENS).find((t) => t.address.toLowerCase() === String(tokenAddr).toLowerCase()) - const decimals = match?.decimals ?? 18 - paidAmount = Number(formatUnits(reward, decimals)) - paidToken = match?.symbol ?? 'USD' - } catch { /* leave amount/token unset; history falls back to a live read */ } + return { rewardStatus: 'paid', txHash: result.txHash, amount: result.amount, token: result.token } + } + // `already_claimed` means the contract has ALREADY paid this wallet for this + // week — a payout that landed while we were failing to hear about it. That is + // a success with a missing receipt, not a failure, and it must not be + // reported as pending or the sweep will keep retrying a settled reward. + // + // Recorded as `paidOnChain` rather than by inventing a txHash: the popup + // turns txHash into a celoscan link, and a link to a hash that does not exist + // is worse than no link. The settled-check below reads both. + if (result.reason === 'already_claimed') { await db.ref(`vaultCompleted/${weekId}/${normalizedWallet}`).update({ - txHash: hash, - ...(paidAmount !== undefined ? { amount: paidAmount, token: paidToken } : {}), + paidOnChain: true, + pendingReason: null, + pendingDetail: null, }) - return { rewardStatus: 'paid', txHash: hash, amount: paidAmount, token: paidToken } - } catch (payErr) { - // Correct code, but the on-chain payout couldn't complete (RPC hiccup, - // gas, timeout). The player keeps their CORRECT result; the reward stays - // pending and self-heals the next time they open the vault. - console.error('[vault/submit] payout failed (kept pending):', payErr instanceof Error ? payErr.message : payErr) - return { rewardStatus: 'pending', txHash: null } + return { rewardStatus: 'paid', txHash: null } + } + + console.error( + `[vault/submit] payout pending (${result.reason}) for ${normalizedWallet} week ${weekId}:`, + result.detail ?? '', + ) + await db.ref(`vaultCompleted/${weekId}/${normalizedWallet}`).update({ + pendingReason: result.reason, + pendingDetail: result.detail ?? null, + pendingAt: Date.now(), + }) + return { + rewardStatus: 'pending', + txHash: null, + reason: result.reason, + message: VAULT_FAILURE_MESSAGE[result.reason], } } @@ -205,8 +166,12 @@ export async function POST(req: NextRequest) { // fast tx). This self-heals a stuck reward the instant the player re-opens // the vault, without spending an attempt or re-validating the code. if (solvedSnap.exists()) { - const solved = solvedSnap.val() as { txHash?: string | null; amount?: number; token?: string } | null - const alreadyPaid = !!(solved && typeof solved.txHash === 'string' && solved.txHash.length > 0) + const solved = solvedSnap.val() as { + txHash?: string | null; amount?: number; token?: string; paidOnChain?: boolean + } | null + const alreadyPaid = !!( + solved && ((typeof solved.txHash === 'string' && solved.txHash.length > 0) || solved.paidOnChain === true) + ) if (alreadyPaid) { return NextResponse.json( { @@ -230,7 +195,8 @@ export async function POST(req: NextRequest) { attemptsRemaining: 0, message: finalize.rewardStatus === 'paid' ? 'Correct code! Reward sent.' - : 'Correct code! Your reward is being finalized — reopen the vault in a moment to claim it.', + : finalize.message ?? 'Your win is recorded and the reward retries automatically.', + reason: finalize.reason, txHash: finalize.txHash, amount: finalize.amount, token: finalize.token, }, @@ -259,6 +225,8 @@ export async function POST(req: NextRequest) { let rewardStatus: 'paid' | 'pending' | 'none' = isCorrect ? 'pending' : 'none' let paidAmount: number | undefined let paidToken: string | undefined + let pendingReason: VaultPayoutFailure | undefined + let pendingMessage: string | undefined if (isCorrect) { // Record the win immediately — independent of the payout. @@ -273,6 +241,8 @@ export async function POST(req: NextRequest) { rewardStatus = finalize.rewardStatus paidAmount = finalize.amount paidToken = finalize.token + pendingReason = finalize.reason + pendingMessage = finalize.message } return NextResponse.json( @@ -285,8 +255,9 @@ export async function POST(req: NextRequest) { message: isCorrect ? (rewardStatus === 'paid' ? 'Correct code! Reward sent.' - : 'Correct code! Your reward is being finalized — reopen the vault in a moment to claim it.') + : pendingMessage ?? 'Your win is recorded and the reward retries automatically.') : 'Wrong code. Try again.', + reason: pendingReason, txHash, amount: paidAmount, token: paidToken, diff --git a/lib/server/vaultChain.ts b/lib/server/vaultChain.ts new file mode 100644 index 0000000..14422da --- /dev/null +++ b/lib/server/vaultChain.ts @@ -0,0 +1,393 @@ +import { createPublicClient, createWalletClient, formatUnits, type PublicClient, type WalletClient } from 'viem' +import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts' +import { celo } from 'viem/chains' +import { celoTransport } from '@/lib/celoRpc' +import { TREASURE_VAULT_ABI, TREASURE_VAULT_ADDRESS } from '@/lib/contract-abi' +import { MARKETPLACE_TOKENS } from '@/lib/constants/tokens' +import { getServerAttributionSuffix } from '@/lib/attribution-tag' + +// ─── Every on-chain thing the Treasure Vault does, in one place ────────────── +// +// THE BUG THIS FILE EXISTS FOR. app/api/vault/submit/route.ts used to do TWO +// sequential on-chain writes inside a single player request: store the week's +// code if it was not stored yet, then pay the winner. Only the FIRST winner of +// any given week took that path — everyone after them found the code already +// stored and did a single write — which is exactly why it survived so long and +// why it looked random. +// +// Measured on Celo, week 202632, with the owner's own wallet: +// +// pool balance ................ 0.85 USDT (reward is 0.05) — enough +// deposited 1.00 / claimed 0.15 ............................. — enough +// backend signer .............. 0.645 CELO — enough gas +// isCodeSetForWeek(202632) .... true +// simulate submitVaultCode .... does NOT revert +// celoscan .................... "Store Weekly Vault Code" SUCCEEDED, +// "Submit Vault Code" WAS NEVER BROADCAST +// +// Not a revert. Not a funding problem. The second write never reached the +// mempool at all. Forno is load-balanced, so the node that answered the nonce +// read for write #2 was not necessarily the node that had already seen write +// #1 — a stale nonce or stale state gets the transaction rejected at the RPC +// boundary, before it is ever a transaction. The route caught that, logged it, +// and reported "pending". +// +// THREE THINGS CHANGE HERE, and the first one is the one that matters: +// +// 1. STORING THE CODE IS NOT PART OF PAYING ANYMORE. ensureWeeklyCodeOnChain() +// is called when the vault is first opened by anybody that week (and again +// by the daily cron as a net), so by the time someone actually wins, the +// code has been on chain for hours and the payout is ONE write. The +// two-write path stops existing for real players rather than being made +// more reliable. +// +// 2. WHEN TWO WRITES STILL HAVE TO BE SEQUENTIAL — the degenerate case where +// the very first person to open the vault this week also solves it in the +// same request — the nonce is read ONCE and incremented locally. The RPC is +// never asked twice, so it can never answer twice differently. +// +// 3. A FAILURE NOW HAS A REASON. Every exit below names what actually went +// wrong, checked rather than guessed: the pool balance is READ before it +// is blamed. See VaultPayoutFailure. +// +// WHAT IS DELIBERATELY NOT DONE: no retry loop around storeWeeklyVaultCode. +// Storing is idempotent and cheap to re-attempt on the next open, and a retry +// there would put the two-write path back on the request that (1) exists to +// eliminate. Only the payout retries, because only the payout is the money. + +export type VaultPayoutFailure = + | 'not_configured' // no BACKEND_PRIVATE_KEY or no vault address + | 'code_not_on_chain' // this week's code has never been stored — submit would revert + | 'pool_empty' // READ from the contract, not assumed + | 'signer_out_of_gas' // the backend signer cannot pay for the transaction + | 'already_claimed' // the contract says this wallet was already paid this week + | 'reverted' // broadcast, mined, and rejected by the contract + | 'rpc' // never made it into a block — the failure that started all this + +// What the player is told, per reason. These are the STRINGS THAT REPLACE the +// single line the popup used to show for every failure alike: +// +// "The transfer completes as soon as the reward pool is topped up" +// +// The pool was never checked before that sentence was printed. On the week it +// was measured the pool held 0.85 USDT against a 0.05 reward — seventeen +// payouts' worth — and the sentence sent the owner, and then an agent, looking +// at treasury balances for an hour while the real fault was an RPC that had +// dropped a transaction. A diagnosis nobody verified is worse than no +// diagnosis, because it is where everybody looks first. +export const VAULT_FAILURE_MESSAGE: Record = { + not_configured: + 'Your win is recorded. Payouts are not switched on for this server yet — the reward is safe and will be sent once they are.', + code_not_on_chain: + "Your win is recorded. This week's vault is still being opened on-chain — the reward sends itself within a few minutes.", + pool_empty: + 'Your win is recorded. The reward pool needs topping up — the transfer completes by itself as soon as it is funded.', + signer_out_of_gas: + 'Your win is recorded. The payout account needs a top-up before it can send — this finishes on its own, nothing is lost.', + already_claimed: + 'This wallet has already been paid for this week.', + reverted: + 'Your win is recorded, but the payout was rejected on-chain. It is being looked at — the win does not expire.', + rpc: + 'Your win is recorded. The network dropped the payout transaction — it retries automatically and completes within a day.', +} + +// The DEPLOYED contract exposes a lowercase `vaultReward` getter; the shared +// TREASURE_VAULT_ABI still carries the old `VAULT_REWARD` constant name that +// the live contract no longer has. Reading the wrong one throws, which is how +// the amount silently went missing from the win popup. +const VAULT_READ_ABI = [ + { name: 'vaultReward', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] }, + { name: 'currentRewardToken', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] }, +] as const + +// Enough CELO to pay for one write with room to spare. The signer had 0.645 +// CELO when this was measured and a vault write costs a small fraction of a +// cent, so this threshold is a smoke alarm, not a budget. +const MIN_SIGNER_CELO_WEI = BigInt(2) * BigInt(10) ** BigInt(16) // 0.02 CELO + +const PAYOUT_ATTEMPTS = 3 +const PAYOUT_BACKOFF_MS = [700, 1800] + +export type VaultClients = { + publicClient: PublicClient + walletClient: WalletClient + account: PrivateKeyAccount +} + +/** null when the server has no signer or no vault address — never throws. */ +export function getVaultClients(): VaultClients | null { + const key = process.env.BACKEND_PRIVATE_KEY as `0x${string}` | undefined + if (!key || !TREASURE_VAULT_ADDRESS || TREASURE_VAULT_ADDRESS === '0x') return null + try { + const account = privateKeyToAccount(key) + const transport = celoTransport() + return { + publicClient: createPublicClient({ chain: celo, transport }) as PublicClient, + walletClient: createWalletClient({ chain: celo, transport, account }), + account, + } + } catch { + return null + } +} + +export async function isCodeOnChain(publicClient: PublicClient, weekId: number): Promise { + return publicClient + .readContract({ + address: TREASURE_VAULT_ADDRESS, + abi: TREASURE_VAULT_ABI, + functionName: 'isCodeSetForWeek', + args: [BigInt(weekId)], + }) + .then((v) => !!v) + .catch(() => false) +} + +export type EnsureCodeResult = { + onChain: boolean + /** true only when THIS call was the one that stored it */ + stored: boolean + txHash?: string + error?: string +} + +/** + * Put this week's code on chain, on its own, well before anybody wins. + * + * This is priority #1 made concrete. It is safe to call on every vault open: + * the first thing it does is a cheap `isCodeSetForWeek` read, and a week whose + * code is already stored costs one RPC read and returns. + * + * Idempotent under races. Two players opening the vault in the same second can + * both pass the read and both broadcast; the contract rejects the loser, and a + * re-read confirms the code is set either way, so a lost race is a success. + * The alternative — a Firebase lock — would add a failure mode (a lock held by + * a crashed request) to protect against a duplicate transaction that costs a + * fraction of a cent. + */ +export async function ensureWeeklyCodeOnChain( + weekId: number, + code: string, + injected?: VaultClients | null, +): Promise { + const clients = injected !== undefined ? injected : getVaultClients() + if (!clients) return { onChain: false, stored: false, error: 'not_configured' } + if (!/^\d{4}$/.test(code)) return { onChain: false, stored: false, error: 'invalid_code' } + + const { publicClient, walletClient, account } = clients + try { + if (await isCodeOnChain(publicClient, weekId)) return { onChain: true, stored: false } + + const hash = await walletClient.writeContract({ + address: TREASURE_VAULT_ADDRESS, + abi: TREASURE_VAULT_ABI, + functionName: 'storeWeeklyVaultCode', + args: [BigInt(weekId), code], + account, + chain: celo, + dataSuffix: getServerAttributionSuffix(), + }) + await publicClient.waitForTransactionReceipt({ hash }) + return { onChain: true, stored: true, txHash: hash } + } catch (err) { + // Lost a race, or the write failed. Re-read: if the code is there, whoever + // put it there did our job for us. + const nowSet = await isCodeOnChain(clients.publicClient, weekId) + return { + onChain: nowSet, + stored: false, + error: nowSet ? undefined : err instanceof Error ? err.message : String(err), + } + } +} + +export type VaultPayoutResult = + | { ok: true; txHash: string; amount?: number; token?: string } + | { ok: false; reason: VaultPayoutFailure; detail?: string } + +/** + * Pay one winner. ONE on-chain write in the normal case. + * + * `expectedCode` is the canonical Firebase code, not the player's raw input: + * correctness was already authenticated off-chain, so submitting the canonical + * string guarantees the contract's own string comparison matches. A self-heal + * re-run must pay even if the player fat-fingers a digit the second time. + */ +export async function payVaultWinner(params: { + weekId: number + walletAddress: string + expectedCode: string + /** Injected by tests. Production never passes this. */ + clients?: VaultClients | null +}): Promise { + const { weekId, walletAddress, expectedCode } = params + const clients = params.clients !== undefined ? params.clients : getVaultClients() + if (!clients) return { ok: false, reason: 'not_configured' } + + const { publicClient, walletClient, account } = clients + const weekBig = BigInt(weekId) + const user = walletAddress as `0x${string}` + + // ── Preflight. Every one of these is a READ, and each maps to a reason the + // player is actually told. This is what makes rule #4 true: nothing below + // blames the pool without having looked at the pool. + try { + const claimed = await publicClient + .readContract({ address: TREASURE_VAULT_ADDRESS, abi: TREASURE_VAULT_ABI, functionName: 'hasClaimedThisWeek', args: [user, weekBig] }) + .catch(() => false) + if (claimed) return { ok: false, reason: 'already_claimed' } + + if (!(await isCodeOnChain(publicClient, weekId))) { + // The caller is expected to have run ensureWeeklyCodeOnChain first. If it + // still is not there, submitting would revert and burn gas for nothing. + return { ok: false, reason: 'code_not_on_chain' } + } + + const [available, reward] = await Promise.all([ + publicClient.readContract({ address: TREASURE_VAULT_ADDRESS, abi: TREASURE_VAULT_ABI, functionName: 'getAvailableVaultFunds' }) as Promise, + publicClient.readContract({ address: TREASURE_VAULT_ADDRESS, abi: VAULT_READ_ABI, functionName: 'vaultReward' }) as Promise, + ]) + if (available < reward) return { ok: false, reason: 'pool_empty' } + + const gas = await publicClient.getBalance({ address: account.address }) + if (gas < MIN_SIGNER_CELO_WEI) return { ok: false, reason: 'signer_out_of_gas' } + } catch { + // A preflight that cannot be read is an RPC problem, not a verdict. Fall + // through and let the write itself decide — never refuse to pay because a + // balance read timed out. + } + + // ── The write, retried. A dropped broadcast is the failure this whole file + // is about, and it is transient by nature: the next attempt usually lands on + // a node that has caught up. Retrying is safe because the contract enforces + // one claim per wallet per week — a duplicate that somehow does land reverts + // rather than paying twice, and `already_claimed` above turns a + // previously-succeeded attempt into a no-op instead of a second payment. + let lastDetail: string | undefined + for (let attempt = 0; attempt < PAYOUT_ATTEMPTS; attempt++) { + try { + const hash = await walletClient.writeContract({ + address: TREASURE_VAULT_ADDRESS, + abi: TREASURE_VAULT_ABI, + functionName: 'submitVaultCode', + args: [user, weekBig, expectedCode], + account, + chain: celo, + dataSuffix: getServerAttributionSuffix(), + }) + const receipt = await publicClient.waitForTransactionReceipt({ hash, timeout: 60_000 }) + if (receipt.status !== 'success') { + lastDetail = `reverted in ${hash}` + return { ok: false, reason: 'reverted', detail: lastDetail } + } + const paid = await readPaidAmount(publicClient) + return { ok: true, txHash: hash, ...paid } + } catch (err) { + lastDetail = err instanceof Error ? err.message : String(err) + + // The tx may have landed even though we failed to hear about it. Ask the + // contract before retrying — paying twice is far worse than reporting a + // pending reward that is actually paid. + const nowClaimed = await publicClient + .readContract({ address: TREASURE_VAULT_ADDRESS, abi: TREASURE_VAULT_ABI, functionName: 'hasClaimedThisWeek', args: [user, weekBig] }) + .catch(() => false) + if (nowClaimed) return { ok: false, reason: 'already_claimed', detail: lastDetail } + + if (attempt < PAYOUT_ATTEMPTS - 1) { + await new Promise((r) => setTimeout(r, PAYOUT_BACKOFF_MS[attempt] ?? 1800)) + } + } + } + return { ok: false, reason: 'rpc', detail: lastDetail } +} + +/** + * Store the code AND pay, sharing one nonce sequence. + * + * Only reachable when the very first person to open this week's vault also + * solves it in the same request — priority #1 removes it for everyone else. + * When it does happen, the nonce is read ONCE and incremented locally, because + * asking a load-balanced RPC for the nonce a second time is precisely how the + * second write got rejected before it was ever a transaction. + */ +export async function storeThenPay(params: { + weekId: number + walletAddress: string + expectedCode: string + /** Injected by tests. Production never passes this. */ + clients?: VaultClients | null +}): Promise { + const { weekId, walletAddress, expectedCode } = params + const clients = params.clients !== undefined ? params.clients : getVaultClients() + if (!clients) return { ok: false, reason: 'not_configured' } + const { publicClient, walletClient, account } = clients + + if (await isCodeOnChain(publicClient, weekId)) { + return payVaultWinner({ ...params, clients }) + } + + try { + // ONE read. Both writes below are numbered from it. + const baseNonce = await publicClient.getTransactionCount({ address: account.address, blockTag: 'pending' }) + + const storeHash = await walletClient.writeContract({ + address: TREASURE_VAULT_ADDRESS, + abi: TREASURE_VAULT_ABI, + functionName: 'storeWeeklyVaultCode', + args: [BigInt(weekId), expectedCode], + account, + chain: celo, + nonce: baseNonce, + dataSuffix: getServerAttributionSuffix(), + }) + + const payHash = await walletClient.writeContract({ + address: TREASURE_VAULT_ADDRESS, + abi: TREASURE_VAULT_ABI, + functionName: 'submitVaultCode', + args: [walletAddress as `0x${string}`, BigInt(weekId), expectedCode], + account, + chain: celo, + nonce: baseNonce + 1, + dataSuffix: getServerAttributionSuffix(), + }) + + await publicClient.waitForTransactionReceipt({ hash: storeHash, timeout: 60_000 }) + const receipt = await publicClient.waitForTransactionReceipt({ hash: payHash, timeout: 60_000 }) + if (receipt.status !== 'success') return { ok: false, reason: 'reverted', detail: `reverted in ${payHash}` } + + const paid = await readPaidAmount(publicClient) + return { ok: true, txHash: payHash, ...paid } + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + // Whatever went wrong, the code may now be stored — in which case the + // single-write path is available and is the better thing to fall back to. + if (await isCodeOnChain(publicClient, weekId)) { + return payVaultWinner({ ...params, clients }) + } + return { ok: false, reason: 'rpc', detail } + } +} + +/** + * What was actually paid, read off the contract. Best-effort by design: the + * payout has already succeeded by the time this runs, so a failed read must + * leave the amount unset rather than undo anything. The popup says "sent" + * rather than inventing a figure — a wrong number there is worse than none. + */ +export async function readPaidAmount(publicClient: PublicClient): Promise<{ amount?: number; token?: string }> { + try { + const [reward, tokenAddr] = await Promise.all([ + publicClient.readContract({ address: TREASURE_VAULT_ADDRESS, abi: VAULT_READ_ABI, functionName: 'vaultReward' }) as Promise, + publicClient.readContract({ address: TREASURE_VAULT_ADDRESS, abi: VAULT_READ_ABI, functionName: 'currentRewardToken' }) as Promise<`0x${string}`>, + ]) + const match = Object.values(MARKETPLACE_TOKENS).find( + (t) => t.address.toLowerCase() === String(tokenAddr).toLowerCase(), + ) + const decimals = match?.decimals ?? 18 + return { amount: Number(formatUnits(reward, decimals)), token: match?.symbol ?? 'USD' } + } catch { + return {} + } +} diff --git a/lib/server/vaultCode.ts b/lib/server/vaultCode.ts new file mode 100644 index 0000000..9d042fb --- /dev/null +++ b/lib/server/vaultCode.ts @@ -0,0 +1,47 @@ +import type { getAdminDb } from '@/firebase-config' + +type Db = NonNullable> + +// ─── The week's vault code, in Firebase ────────────────────────────────────── +// +// This used to live inside /api/paper/status as a private helper, which was +// fine while that route was the only thing that needed it. It is not anymore: +// /api/vault/prepare has to be able to put the code ON CHAIN before anybody +// wins (see lib/server/vaultChain.ts for why that matters), and it cannot do +// that without knowing what the code is. +// +// A second copy of "generate a 4-digit code, unless one already exists" is +// exactly the kind of duplicate that drifts into two different codes for the +// same week — one shown on the player's Paper, the other stored on chain, and +// a vault that rejects a code the game itself printed. +// +// GENERATING IT REVEALS NOTHING. The code is returned to a wallet only after +// that wallet has claimed this week's Paper (/api/paper/status enforces that, +// and still does). Creating the row is not the same as handing it out. + +/** + * This week's code, generating it once if it does not exist yet. + * + * The transaction is what makes it safe to call from several routes at once: + * two "first request of the week" callers can both find the path empty, and + * only one of their writes can commit — the other aborts and reads back the + * winner's code. Without it, the player's Paper and the on-chain copy could be + * generated independently and disagree forever. + */ +export async function ensureWeeklyCodeInDb(db: Db, weekId: string | number): Promise { + const ref = db.ref(`vaultCodes/${weekId}`) + const snap = await ref.get() + const existing = snap.val() as { code?: string } | null + if (existing && typeof existing.code === 'string' && /^\d{4}$/.test(existing.code)) { + return existing.code + } + + const generated = String(Math.floor(1000 + Math.random() * 9000)) + const txResult = await ref.transaction((current: unknown) => { + const cur = current as { code?: string } | null + if (cur && typeof cur.code === 'string' && /^\d{4}$/.test(cur.code)) return undefined // abort — already set + return { code: generated, generatedAt: Date.now() } + }) + const finalVal = txResult.snapshot?.val() as { code?: string } | null + return finalVal?.code ?? generated +} diff --git a/lib/server/vaultSweep.ts b/lib/server/vaultSweep.ts new file mode 100644 index 0000000..25e163e --- /dev/null +++ b/lib/server/vaultSweep.ts @@ -0,0 +1,165 @@ +import type { getAdminDb } from '@/firebase-config' +import { ensureWeeklyCodeOnChain, payVaultWinner, type VaultPayoutFailure } from '@/lib/server/vaultChain' + +type Db = NonNullable> + +// ─── Nobody should have to earn a reward twice ─────────────────────────────── +// +// THE FAILURE THIS EXISTS FOR, in the owner's own words: he solved the vault, +// the payout did not go through, and the ONLY way the game offered to retry it +// was to open the vault door again — which means re-entering Bunker 5, clearing +// it, killing the final boss, and getting back to the door. He recovered his +// reward by reading the contract on celoscan and working out what had happened. +// A player cannot do that. For them the sequence is: win, get nothing, and have +// no route back to the money at all. +// +// The self-heal on re-open is a good thing and it stays. What was missing is +// that it is the ONLY route, and it is gated behind the most expensive thing in +// the game. So a pending reward is now collected by the server, on a schedule, +// with the player doing nothing: +// +// - runs from the daily cron that already exists (/api/cron/season) +// - covers this week AND last week, so a reward that goes pending on a Sunday +// night is not stranded by the week rolling over on Monday +// - stores the week's code first, then pays — one write each, never both in +// the same breath (that pairing is the original bug; lib/server/vaultChain.ts +// has the measurements) +// +// WHAT MAKES IT SAFE TO RUN EVERY DAY. Paying twice is the only outcome worse +// than not paying, and three separate things prevent it: the contract enforces +// one claim per wallet per week; payVaultWinner() reads hasClaimedThisWeek +// before it writes and again if a write appears to fail; and a settled row is +// skipped here entirely. A sweep over a week with nothing pending does one +// Firebase read and stops. + +export type SweepOutcome = { + weekId: string + wallet: string + status: 'paid' | 'pending' + txHash?: string + reason?: VaultPayoutFailure +} + +export type SweepResult = { + checked: number + paid: number + stillPending: number + outcomes: SweepOutcome[] +} + +type CompletedRow = { + txHash?: string | null + paidOnChain?: boolean + amount?: number + token?: string +} + +/** A row is settled when it has a real tx hash, or the contract told us it was + * already paid. Anything else is money the player is owed. */ +export function isSettled(row: CompletedRow | null | undefined): boolean { + if (!row) return false + if (row.paidOnChain === true) return true + return typeof row.txHash === 'string' && row.txHash.length > 0 +} + +/** + * Retry every unpaid vault win in the given weeks. + * + * `deps` exists so the whole thing can be tested without a chain: the sweep's + * job is deciding WHICH rows to retry and what to write back, and that logic is + * worth a test far more than viem's ability to send a transaction is. + */ +export async function sweepPendingVaultPayouts( + db: Db, + opts: { + weekIds: (string | number)[] + /** hard stop, so one bad week cannot turn the daily cron into a long job */ + limit?: number + deps?: { + ensureCode?: typeof ensureWeeklyCodeOnChain + pay?: typeof payVaultWinner + } + }, +): Promise { + const ensureCode = opts.deps?.ensureCode ?? ensureWeeklyCodeOnChain + const pay = opts.deps?.pay ?? payVaultWinner + const limit = opts.limit ?? 25 + + const outcomes: SweepOutcome[] = [] + let checked = 0 + let paid = 0 + + for (const rawWeek of opts.weekIds) { + if (outcomes.length >= limit) break + const weekId = String(rawWeek) + + const [completedSnap, codeSnap] = await Promise.all([ + db.ref(`vaultCompleted/${weekId}`).get(), + db.ref(`vaultCodes/${weekId}`).get(), + ]) + if (!completedSnap.exists()) continue + + const rows = (completedSnap.val() ?? {}) as Record + const unpaid = Object.entries(rows).filter(([, row]) => !isSettled(row)) + checked += unpaid.length + if (unpaid.length === 0) continue + + // No code for the week means nothing can be submitted for it. That is a + // broken week, not a broken payout — say so rather than burning gas on + // writes that must revert. + const expectedCode = String((codeSnap.val() as { code?: string } | null)?.code ?? '') + if (!/^\d{4}$/.test(expectedCode)) { + for (const [wallet] of unpaid) { + outcomes.push({ weekId, wallet, status: 'pending', reason: 'code_not_on_chain' }) + } + continue + } + + // ONE store attempt for the whole week, before any payout. This is the + // separation the fix is built on: by the time a single submitVaultCode goes + // out below, the code has been on chain for at least a transaction. + await ensureCode(Number(weekId), expectedCode) + + for (const [wallet] of unpaid) { + if (outcomes.length >= limit) break + + const result = await pay({ weekId: Number(weekId), walletAddress: wallet, expectedCode }) + + // `already_claimed` is a payout that landed while we were failing to hear + // about it — settle the row rather than retrying it every night forever. + if (result.ok || result.reason === 'already_claimed') { + await db.ref(`vaultCompleted/${weekId}/${wallet}`).update({ + ...(result.ok ? { txHash: result.txHash } : { paidOnChain: true }), + ...(result.ok && result.amount !== undefined ? { amount: result.amount, token: result.token } : {}), + pendingReason: null, + pendingDetail: null, + sweptAt: Date.now(), + }) + paid++ + outcomes.push({ + weekId, + wallet, + status: 'paid', + ...(result.ok ? { txHash: result.txHash } : {}), + }) + continue + } + + // Still stuck. Record WHY — the reason is checked, never assumed, and it + // is what the player is shown next time they ask. + await db.ref(`vaultCompleted/${weekId}/${wallet}`).update({ + pendingReason: result.reason, + pendingDetail: result.detail ?? null, + sweptAt: Date.now(), + }) + outcomes.push({ weekId, wallet, status: 'pending', reason: result.reason }) + } + } + + return { + checked, + paid, + stillPending: outcomes.filter((o) => o.status === 'pending').length, + outcomes, + } +} diff --git a/lib/vault-utils.ts b/lib/vault-utils.ts index 79c9233..3dd3f70 100644 --- a/lib/vault-utils.ts +++ b/lib/vault-utils.ts @@ -11,6 +11,22 @@ export function getCurrentWeekIdString(): string { return String(getISOWeekId()) } +// The weeks the pending-payout sweep has to look at (newest first). +// +// Not just the current one: a reward that goes pending late on a Sunday would +// otherwise be stranded by the ISO week rolling over at Monday 00:00 UTC before +// the cron next runs — the exact window in which a player is least able to go +// back and re-open the vault themselves. Derived by stepping back seven real +// days rather than subtracting 1 from the id, because week 1 of a year does not +// follow week 0 of the same year. +export function recentWeekIdStrings(now: number = Date.now(), count = 2): string[] { + const out: string[] = [] + for (let i = 0; i < count; i++) { + out.push(String(getISOWeekId(new Date(now - i * 7 * 86400000)))) + } + return out +} + export function normalizeWalletAddress(address: string): string { return address.trim().toLowerCase() } diff --git a/package.json b/package.json index 2ed872e..c5e6962 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "test:kills": "node scripts/test-leaderboard-writes.js", "test:proploot": "node scripts/test-prop-loot.js", "test:network": "node scripts/test-wrong-network.js", - "test:privypay": "node scripts/test-privy-pay.js" + "test:privypay": "node scripts/test-privy-pay.js", + "test:vaultpay": "node scripts/test-vault-payout.js" }, "overrides": { "permissionless": { diff --git a/public/game-engine/game.js b/public/game-engine/game.js index dc2102d..10ab60a 100644 --- a/public/game-engine/game.js +++ b/public/game-engine/game.js @@ -2070,6 +2070,32 @@ function openVaultWindow(decor){ win.classList.remove('hidden'); // NO input.focus() here on purpose — see the keypad note below. refreshVaultRequirements(); + prepareVaultOnChain(); +} + +// Tell the server to put this week's vault code on chain, NOW, while the player +// is still looking at the keypad. +// +// This is the client half of the main vault fix. /api/vault/submit used to +// store the code and pay the winner in the SAME request, and only for the first +// winner of each week — and the second of those two writes is the one Forno +// kept dropping before it reached the mempool, which is how a solved vault paid +// nothing. Storing the code when the DOOR opens instead of when someone WINS +// means the payout is a single write by the time it matters. +// +// Fire-and-forget on purpose: the player must never wait for it, and it must +// never surface an error. Solving the code is not blocked by this — the server +// falls back to storing and paying together (with a shared nonce) if it has to. +// The daily cron does the same errand, so a week nobody opens a door in is +// still covered. +let _vaultPrepared = null; +function prepareVaultOnChain(){ + const week = String(getVaultWeekId()); + if(_vaultPrepared === week) return; // once per week per session is plenty + _vaultPrepared = week; + try{ + fetch('/api/vault/prepare', { method:'POST' }).catch(()=>{}); + }catch(e){ /* offline — the cron still has it */ } } // ---- the 4-digit entry (in-game keypad, not the phone's) ---- @@ -2207,12 +2233,16 @@ async function submitVaultCode(){ } else if(data && data.isCorrect){ const _paid = data.rewardStatus==='paid'; if(msg){ + // Same rule as the popup note below: do not name a cause the server + // did not check. "Once the pool is funded" was printed for dropped + // transactions, an unconfigured signer and an empty pool alike. msg.textContent = _paid ? '✓ Correct! Reward sent to your wallet.' - : '✓ Correct! Vault unlocked — your reward arrives once the pool is funded.'; + : '✓ Correct! Vault unlocked — the reward finishes sending itself.'; msg.className='vault-msg ok'; } A.levelup(); spark(G.player.x, G.player.y-30, '#b46bff', 40, 260); - log(_paid ? 'Vault unlocked — reward claimed.' : 'Vault unlocked — reward pending pool funding.', 'reward'); + log(_paid ? 'Vault unlocked — reward claimed.' + : 'Vault unlocked — reward pending' + (data.reason ? ' ('+data.reason+')' : '') + '.', 'reward'); if(submitBtn){ submitBtn.textContent='DONE'; } // THE WIN SCREEN. This used to be the line above and a 1.4s timer, which // meant the one moment real money moves went by without naming the @@ -2267,9 +2297,24 @@ function openVaultWinPopup(data){ : 'Reward pending'; } if(noteEl){ + // A PENDING REWARD NOW SAYS WHY IT IS PENDING. + // + // This line used to read "the transfer completes as soon as the reward pool + // is topped up" for EVERY failure, and the server had never once looked at + // the pool. The week the owner hit it, the pool held 0.85 USDT against a + // 0.05 reward — seventeen payouts' worth — and the real fault was a + // transaction Forno dropped before it reached the mempool. That sentence + // sent him, and then an agent, to the treasury for an hour. + // + // The server now checks each cause before naming it (see + // VAULT_FAILURE_MESSAGE in lib/server/vaultChain.ts) and sends the sentence + // down with the response. The fallback below claims nothing it has not been + // told — it says the win is safe and the payout retries, which is true of + // every pending case. + const why = (data && typeof data.message === 'string' && data.message) ? data.message : null; noteEl.innerHTML = paid ? 'Sent straight to your wallet — no claim needed.
It may take a few seconds to show in MiniPay.' - : 'Your code was correct and the win is recorded. The transfer completes as soon as the reward pool is topped up — reopen the vault later and it finishes by itself.'; + : (why || 'Your code was correct and the win is recorded. The payout retries by itself — nothing is lost.'); } if(txEl){ const hash = data && typeof data.txHash === 'string' && data.txHash; diff --git a/scripts/test-vault-payout.js b/scripts/test-vault-payout.js new file mode 100644 index 0000000..14fc7b4 --- /dev/null +++ b/scripts/test-vault-payout.js @@ -0,0 +1,479 @@ +#!/usr/bin/env node +/** + * test-vault-payout.js — the week a solved vault paid nothing. + * + * OWNER, week 202632: he found the Paper, found the Golden Key, entered the + * right code, and no USDT arrived. He got his reward back only by reading the + * contract on celoscan himself and working out what had happened. A player + * cannot do that, and for them the sequence ends at "won, paid nothing". + * + * WHAT WAS MEASURED ON CHAIN BEFORE ANY CODE WAS WRITTEN — none of it guessed: + * + * pool balance ................ 0.85 USDT (the reward is 0.05) — enough + * deposited 1.00 / claimed 0.15 ........................................ — enough + * backend signer .............. 0.645 CELO — enough gas + * isCodeSetForWeek(202632) .... true + * simulate submitVaultCode .... does NOT revert + * celoscan .................... "Store Weekly Vault Code" SUCCEEDED, + * "Submit Vault Code" NEVER BROADCAST AT ALL + * + * So: not a revert, not a funding problem, not a permissions problem. The + * payout transaction never existed. /api/vault/submit was doing TWO sequential + * on-chain writes in ONE request — store the week's code, then pay — and only + * for the FIRST winner of any given week, which is why it looked random and + * survived weeks. Forno is load-balanced, so the node answering the nonce read + * for write #2 need not have seen write #1; the transaction was rejected at the + * RPC boundary before it was a transaction, the route caught it, and the player + * was told the reward pool needed topping up. + * + * ── WHAT THIS FILE CAN AND CANNOT PROVE ───────────────────────────────────── + * + * CAN: that the two-write path is gone from the normal case; that when two + * writes are unavoidable they share ONE nonce read; that a dropped broadcast + * retries; that a failure names a reason it actually CHECKED; and that an + * unpaid win is collected later by the sweep without the player doing anything. + * + * CANNOT: reproduce Forno. There is no Celo node in this sandbox and no + * backend key, so every client below is a fake whose behaviour I chose. What + * that means honestly: these tests prove the CODE does the right thing when an + * RPC misbehaves in the way the chain evidence says it misbehaved. They do not + * prove that is the only way it misbehaves. The real verification is the next + * vault win on mainnet, and the sweep exists precisely because I cannot promise + * this is the last failure mode. + * + * Several assertions below are marked FAILS-ON-OLD and run the DELETED + * implementation (reconstructed verbatim as oldStoreThenPay) against the same + * fakes, so "this test would have caught it" is demonstrated rather than + * claimed. + * + * node scripts/test-vault-payout.js + */ +const { execFileSync } = require('child_process') +const path = require('path'), os = require('os') + +const bundle = (src, name) => { + const out = path.join(os.tmpdir(), `ns-${name}-${process.pid}.cjs`) + execFileSync('npx', ['esbuild', src, '--bundle', '--platform=node', '--format=cjs', + '--log-level=error', '--alias:@=' + path.resolve('.'), '--outfile=' + out], + { stdio: ['ignore', 'ignore', 'inherit'] }) + return require(out) +} + +const CHAIN = bundle('lib/server/vaultChain.ts', 'vaultchain') +const SWEEP = bundle('lib/server/vaultSweep.ts', 'vaultsweep') +const UTILS = bundle('lib/vault-utils.ts', 'vaultutils') + +let fails = 0 +const ok = (label, cond, detail) => { + console.log((cond ? ' ✓ ' : ' ✗ FAIL: ') + label + (detail !== undefined ? ' (' + detail + ')' : '')) + if (!cond) fails++ +} +const section = (t) => console.log('\n' + t) + +const WALLET = '0x1111111111111111111111111111111111111111' +const WEEK = 202632 +const CODE = '4821' + +// ── a fake chain ──────────────────────────────────────────────────────────── +// Records every call so the assertions can be about WHAT WAS SENT, not about +// what the function returned. The bug was never visible in a return value. +function makeChain(opts = {}) { + const state = { + codeSet: opts.codeSet ?? false, + claimed: opts.claimed ?? false, + available: opts.available ?? BigInt(850000), // 0.85 USDT, 6 decimals + reward: opts.reward ?? BigInt(50000), // 0.05 USDT + gas: opts.gas ?? BigInt('645000000000000000'), // 0.645 CELO + nonce: opts.nonce ?? 7, + } + const calls = { writes: [], nonceReads: 0, receipts: [] } + // A load-balanced RPC that has not caught up: it keeps answering with the + // SAME nonce no matter how many transactions it has already accepted. This is + // the behaviour the chain evidence points at. + const staleNonce = opts.staleNonce ?? false + let accepted = 0 + let failWrites = opts.failWrites ?? 0 + + const publicClient = { + async readContract({ functionName }) { + if (functionName === 'isCodeSetForWeek') return state.codeSet + if (functionName === 'hasClaimedThisWeek') return state.claimed + if (functionName === 'getAvailableVaultFunds') return state.available + if (functionName === 'vaultReward') return state.reward + if (functionName === 'currentRewardToken') return '0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e' + throw new Error('unstubbed read: ' + functionName) + }, + async getBalance() { return state.gas }, + async getTransactionCount() { + calls.nonceReads++ + return staleNonce ? state.nonce : state.nonce + accepted + }, + async waitForTransactionReceipt({ hash }) { + calls.receipts.push(hash) + if (opts.revert) return { status: 'reverted' } + return { status: 'success' } + }, + } + + const walletClient = { + async writeContract(args) { + // The RPC rejects a transaction whose nonce it has already seen — this is + // the "never broadcast" case, and it throws BEFORE producing a hash. + const seen = calls.writes.some((w) => w.nonce !== undefined && w.nonce === args.nonce) + if (args.nonce !== undefined && seen) { + throw new Error('nonce too low: known transaction') + } + if (failWrites > 0 && args.functionName === 'submitVaultCode') { + failWrites-- + calls.writes.push({ ...args, rejected: true }) + throw new Error('failed to send raw transaction: connection reset') + } + calls.writes.push(args) + accepted++ + if (args.functionName === 'storeWeeklyVaultCode') state.codeSet = true + if (args.functionName === 'submitVaultCode' && opts.claimOnPay !== false) state.claimed = true + return '0x' + String(calls.writes.length).padStart(64, 'a') + }, + } + + return { clients: { publicClient, walletClient, account: { address: WALLET } }, calls, state } +} + +const writesOf = (calls, fn) => calls.writes.filter((w) => w.functionName === fn && !w.rejected) + +// ── the DELETED implementation, reconstructed ─────────────────────────────── +// This is what app/api/vault/submit/route.ts did, verbatim in shape: check +// isCodeSetForWeek, store if unset and wait for the receipt, then submit — +// with NO nonce passed to either write, so viem asks the RPC for one each time. +// It exists so the FAILS-ON-OLD assertions run against real old behaviour. +async function oldStoreThenPay({ clients, weekId, walletAddress, expectedCode }) { + const { publicClient, walletClient, account } = clients + try { + const alreadySet = await publicClient + .readContract({ functionName: 'isCodeSetForWeek', args: [BigInt(weekId)] }).catch(() => false) + if (!alreadySet) { + const storeHash = await walletClient.writeContract({ + functionName: 'storeWeeklyVaultCode', args: [BigInt(weekId), expectedCode], account, + nonce: await publicClient.getTransactionCount({ address: account.address }), + }) + await publicClient.waitForTransactionReceipt({ hash: storeHash }) + } + const hash = await walletClient.writeContract({ + functionName: 'submitVaultCode', args: [walletAddress, BigInt(weekId), expectedCode], account, + nonce: await publicClient.getTransactionCount({ address: account.address }), + }) + await publicClient.waitForTransactionReceipt({ hash }) + return { ok: true, txHash: hash } + } catch (e) { + // Exactly what the old route did: swallow it and report pending. + return { ok: false, reason: 'pending', detail: e.message } + } +} + +async function main() { + // ═══════════════════════════════════════════════════════════════════════════ + section('1. The two-write path is gone from the request that pays') + { + const { clients, calls } = makeChain({ codeSet: true }) + const r = await CHAIN.storeThenPay({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients }) + ok('a winner is paid', r.ok === true, r.ok ? r.txHash : r.reason) + ok('exactly ONE on-chain write', calls.writes.length === 1, calls.writes.length + ' writes') + ok('and it is submitVaultCode, not storeWeeklyVaultCode', + writesOf(calls, 'storeWeeklyVaultCode').length === 0 && writesOf(calls, 'submitVaultCode').length === 1) + ok('the nonce is never even read on the single-write path', calls.nonceReads === 0, String(calls.nonceReads)) + } + + // ═══════════════════════════════════════════════════════════════════════════ + section('2. Storing the code is its own errand, done before anybody wins') + { + const { clients, calls, state } = makeChain({ codeSet: false }) + const r = await CHAIN.ensureWeeklyCodeOnChain(WEEK, CODE, clients) + ok('the code goes on chain', r.onChain === true && r.stored === true) + ok('one write, and it stores — it does not pay', + calls.writes.length === 1 && writesOf(calls, 'submitVaultCode').length === 0) + ok('the contract now reports the code as set', state.codeSet === true) + + const again = await CHAIN.ensureWeeklyCodeOnChain(WEEK, CODE, clients) + ok('calling it again writes nothing (safe on every vault open)', + again.onChain === true && again.stored === false && calls.writes.length === 1) + } + { + const { clients } = makeChain({ codeSet: false }) + const r = await CHAIN.ensureWeeklyCodeOnChain(WEEK, '12', clients) + ok('a malformed code is never stored', r.onChain === false && r.error === 'invalid_code') + } + + // ═══════════════════════════════════════════════════════════════════════════ + section('3. FAILS-ON-OLD — two writes, one stale RPC') + // The degenerate case the fix cannot remove: the first person to open this + // week's vault also solves it in the same request. Both writes must go out + // back to back against an RPC that has not caught up. + { + const oldChain = makeChain({ codeSet: false, staleNonce: true }) + const oldResult = await oldStoreThenPay({ + clients: oldChain.clients, weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, + }) + ok('OLD: asks the RPC for a nonce twice', oldChain.calls.nonceReads === 2, String(oldChain.calls.nonceReads)) + ok('OLD: the payout is rejected before it is ever a transaction', oldResult.ok === false, oldResult.detail) + ok('OLD: storeWeeklyVaultCode succeeded, submitVaultCode never landed — the exact celoscan trace', + writesOf(oldChain.calls, 'storeWeeklyVaultCode').length === 1 + && writesOf(oldChain.calls, 'submitVaultCode').length === 0) + + const { clients, calls } = makeChain({ codeSet: false, staleNonce: true }) + const r = await CHAIN.storeThenPay({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients }) + ok('NEW: reads the nonce ONCE', calls.nonceReads === 1, String(calls.nonceReads)) + const store = writesOf(calls, 'storeWeeklyVaultCode')[0] + const pay = writesOf(calls, 'submitVaultCode')[0] + ok('NEW: the two writes are numbered locally, N and N+1', + !!store && !!pay && pay.nonce === store.nonce + 1, store && pay ? `${store.nonce} -> ${pay.nonce}` : 'missing') + ok('NEW: the winner is paid against the same stale RPC', r.ok === true, r.ok ? r.txHash : r.reason) + } + + // ═══════════════════════════════════════════════════════════════════════════ + section('4. FAILS-ON-OLD — a dropped broadcast retries instead of giving up') + { + const oldChain = makeChain({ codeSet: true, failWrites: 1 }) + const oldResult = await oldStoreThenPay({ + clients: oldChain.clients, weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, + }) + ok('OLD: one dropped broadcast and the reward is pending forever', oldResult.ok === false) + + const { clients, calls } = makeChain({ codeSet: true, failWrites: 1 }) + const r = await CHAIN.payVaultWinner({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients }) + ok('NEW: the same dropped broadcast is retried and paid', r.ok === true, r.ok ? r.txHash : r.reason) + ok('NEW: it retried rather than sending twice at once', + calls.writes.filter((w) => w.rejected).length === 1 && writesOf(calls, 'submitVaultCode').length === 1) + } + { + const { clients } = makeChain({ codeSet: true, failWrites: 99 }) + const r = await CHAIN.payVaultWinner({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients }) + ok('a permanently dropped broadcast gives up as `rpc`, not as a pool problem', + r.ok === false && r.reason === 'rpc', r.reason) + } + + // ═══════════════════════════════════════════════════════════════════════════ + section('5. FAILS-ON-OLD — the message stops blaming the pool') + // The old route had ONE sentence for every failure: "the transfer completes + // as soon as the reward pool is topped up". It had never read the pool. On + // the measured week the pool held 0.85 USDT against a 0.05 reward. + { + const { clients } = makeChain({ codeSet: true, failWrites: 99, available: BigInt(850000) }) + const r = await CHAIN.payVaultWinner({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients }) + ok('a funded pool is NEVER reported as empty', r.reason !== 'pool_empty', r.reason) + ok('the player is told the network dropped it', + /network dropped/i.test(CHAIN.VAULT_FAILURE_MESSAGE[r.reason]), CHAIN.VAULT_FAILURE_MESSAGE[r.reason]) + ok('and no message invents a pool problem that was not checked', + !/pool/i.test(CHAIN.VAULT_FAILURE_MESSAGE.rpc) + && !/pool/i.test(CHAIN.VAULT_FAILURE_MESSAGE.signer_out_of_gas) + && !/pool/i.test(CHAIN.VAULT_FAILURE_MESSAGE.code_not_on_chain)) + } + { + const { clients, calls } = makeChain({ codeSet: true, available: BigInt(10000) }) // 0.01 < 0.05 + const r = await CHAIN.payVaultWinner({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients }) + ok('a genuinely empty pool IS reported as empty', r.reason === 'pool_empty', r.reason) + ok('and no gas is burned on a write that must fail', calls.writes.length === 0) + ok('that message is the only one allowed to mention the pool', + /pool/i.test(CHAIN.VAULT_FAILURE_MESSAGE.pool_empty)) + } + { + const { clients, calls } = makeChain({ codeSet: true, gas: BigInt('1000000000000000') }) // 0.001 CELO + const r = await CHAIN.payVaultWinner({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients }) + ok('an unfunded signer is named as such, not as an empty pool', r.reason === 'signer_out_of_gas', r.reason) + ok('and it too writes nothing', calls.writes.length === 0) + } + { + const { clients, calls } = makeChain({ codeSet: false }) + const r = await CHAIN.payVaultWinner({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients }) + ok('paying before the code is stored is refused, not attempted', + r.reason === 'code_not_on_chain' && calls.writes.length === 0, r.reason) + } + { + const r = await CHAIN.payVaultWinner({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients: null }) + ok('an unconfigured server says so plainly', r.reason === 'not_configured') + ok('every reason has a sentence for the player', + Object.keys(CHAIN.VAULT_FAILURE_MESSAGE).length === 7 + && Object.values(CHAIN.VAULT_FAILURE_MESSAGE).every((m) => typeof m === 'string' && m.length > 20)) + } + + // ═══════════════════════════════════════════════════════════════════════════ + section('6. Nobody is ever paid twice') + { + const { clients, calls } = makeChain({ codeSet: true, claimed: true }) + const r = await CHAIN.payVaultWinner({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients }) + ok('a wallet the contract has already paid is not paid again', + r.ok === false && r.reason === 'already_claimed' && calls.writes.length === 0) + } + { + // The nastiest case: the write lands but we never hear the receipt. A blind + // retry here is a double payment. + const chain = makeChain({ codeSet: true, failWrites: 1, claimOnPay: true }) + const realWrite = chain.clients.walletClient.writeContract + chain.clients.walletClient.writeContract = async (args) => { + try { return await realWrite(args) } finally { chain.state.claimed = true } + } + const r = await CHAIN.payVaultWinner({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients: chain.clients }) + ok('a payout that landed unheard settles as already_claimed, not as a second write', + r.reason === 'already_claimed' && writesOf(chain.calls, 'submitVaultCode').length === 0, r.reason) + } + { + const { clients } = makeChain({ codeSet: true, revert: true, claimOnPay: false }) + const r = await CHAIN.payVaultWinner({ weekId: WEEK, walletAddress: WALLET, expectedCode: CODE, clients }) + ok('a transaction that mines and reverts is reported as reverted, not retried blindly', + r.ok === false && r.reason === 'reverted', r.reason) + } + + // ═══════════════════════════════════════════════════════════════════════════ + section('7. FAILS-ON-OLD — an unpaid win is collected without the player') + // Before the sweep, the ONLY retry was re-opening the vault door: re-enter + // Bunker 5, clear it, kill the final boss, walk back. The owner recovered his + // reward by reading the contract on celoscan. A player cannot. + { + const db = makeDb({ + vaultCodes: { [WEEK]: { code: CODE } }, + vaultCompleted: { + [WEEK]: { + [WALLET]: { completedAt: 1, txHash: null, pendingReason: 'rpc' }, + '0x2222222222222222222222222222222222222222': { completedAt: 2, txHash: '0xdead' }, + }, + }, + }) + const paid = [] + const r = await SWEEP.sweepPendingVaultPayouts(db, { + weekIds: [WEEK], + deps: { + ensureCode: async () => ({ onChain: true, stored: false }), + pay: async ({ walletAddress }) => { paid.push(walletAddress); return { ok: true, txHash: '0xfeed' } }, + }, + }) + ok('the unpaid win is retried', paid.length === 1 && paid[0] === WALLET, paid.join(',')) + ok('the already-paid win is left alone', r.checked === 1 && r.paid === 1) + ok('the row is settled with the real hash', + db.tree.vaultCompleted[WEEK][WALLET].txHash === '0xfeed') + ok('and the stale reason is cleared, not left to be re-reported', + db.tree.vaultCompleted[WEEK][WALLET].pendingReason === null) + } + { + const db = makeDb({ + vaultCodes: { [WEEK]: { code: CODE } }, + vaultCompleted: { [WEEK]: { [WALLET]: { txHash: null } } }, + }) + const r = await SWEEP.sweepPendingVaultPayouts(db, { + weekIds: [WEEK], + deps: { + ensureCode: async () => ({ onChain: true, stored: false }), + pay: async () => ({ ok: false, reason: 'already_claimed' }), + }, + }) + ok('a reward the contract already paid is settled, not retried every night', + r.paid === 1 && db.tree.vaultCompleted[WEEK][WALLET].paidOnChain === true) + } + { + const db = makeDb({ + vaultCodes: { [WEEK]: { code: CODE } }, + vaultCompleted: { [WEEK]: { [WALLET]: { txHash: null } } }, + }) + const r = await SWEEP.sweepPendingVaultPayouts(db, { + weekIds: [WEEK], + deps: { + ensureCode: async () => ({ onChain: true, stored: false }), + pay: async () => ({ ok: false, reason: 'pool_empty' }), + }, + }) + ok('a still-stuck reward keeps its true reason for next time', + r.stillPending === 1 && db.tree.vaultCompleted[WEEK][WALLET].pendingReason === 'pool_empty') + ok('and the win itself is never discarded', db.tree.vaultCompleted[WEEK][WALLET].txHash === null) + } + { + let ensured = 0, payCalls = 0 + const db = makeDb({ vaultCompleted: { [WEEK]: { [WALLET]: { txHash: null } } } }) // no vaultCodes + const r = await SWEEP.sweepPendingVaultPayouts(db, { + weekIds: [WEEK], + deps: { + ensureCode: async () => { ensured++; return { onChain: true, stored: false } }, + pay: async () => { payCalls++; return { ok: true, txHash: '0x1' } }, + }, + }) + ok('a week with no code pays nobody and burns no gas', + ensured === 0 && payCalls === 0 && r.stillPending === 1) + } + { + let ensured = 0 + const db = makeDb({ + vaultCodes: { [WEEK]: { code: CODE } }, + vaultCompleted: { [WEEK]: { [WALLET]: { txHash: '0xabc' } } }, + }) + const r = await SWEEP.sweepPendingVaultPayouts(db, { + weekIds: [WEEK], + deps: { ensureCode: async () => { ensured++; return { onChain: true } }, pay: async () => ({ ok: true, txHash: '0x1' }) }, + }) + ok('a week with nothing pending does no chain work at all', + ensured === 0 && r.checked === 0 && r.paid === 0) + } + { + const rows = {} + for (let i = 0; i < 40; i++) rows['0x' + String(i).padStart(40, '0')] = { txHash: null } + const db = makeDb({ vaultCodes: { [WEEK]: { code: CODE } }, vaultCompleted: { [WEEK]: rows } }) + const r = await SWEEP.sweepPendingVaultPayouts(db, { + weekIds: [WEEK], limit: 5, + deps: { ensureCode: async () => ({ onChain: true }), pay: async () => ({ ok: true, txHash: '0x1' }) }, + }) + ok('the daily cron cannot be turned into a long job by one bad week', + r.outcomes.length === 5, String(r.outcomes.length)) + } + + // ═══════════════════════════════════════════════════════════════════════════ + section('8. A reward pending across a week boundary is not stranded') + { + // Monday 2026-08-03 00:30 UTC — half an hour into a new ISO week. + const monday = Date.UTC(2026, 7, 3, 0, 30) + const weeks = UTILS.recentWeekIdStrings(monday) + ok('the sweep looks at this week AND last week', weeks.length === 2, weeks.join(',')) + ok('and they really are different weeks', weeks[0] !== weeks[1], weeks.join(' -> ')) + const sunday = UTILS.recentWeekIdStrings(monday - 3600 * 1000)[0] + ok('last week is the week a Sunday-night win was recorded in', weeks[1] === sunday, `${weeks[1]} vs ${sunday}`) + } + { + // Stepping back seven real days, not subtracting 1 from the id — week 1 of + // a year does not follow week 0 of the same year. + const jan = Date.UTC(2027, 0, 6) // ISO week 1 of 2027 + const weeks = UTILS.recentWeekIdStrings(jan) + ok('a new year does not produce a week 0', !/00$/.test(weeks[1]), weeks.join(' -> ')) + ok('it rolls back into the previous year', Number(weeks[1]) < Number(weeks[0]) && weeks[1].startsWith('2026'), + weeks.join(' -> ')) + } + + // ═══════════════════════════════════════════════════════════════════════════ + section('9. isSettled — what counts as money that arrived') + { + ok('a real hash is settled', SWEEP.isSettled({ txHash: '0xabc' }) === true) + ok('paidOnChain is settled (a receipt we never heard)', SWEEP.isSettled({ paidOnChain: true }) === true) + ok('an empty hash is NOT settled', SWEEP.isSettled({ txHash: '' }) === false) + ok('a null hash is NOT settled', SWEEP.isSettled({ txHash: null }) === false) + ok('a missing row is NOT settled', SWEEP.isSettled(null) === false) + } + + console.log('\n' + (fails === 0 ? '✓ all vault payout checks passed' : `✗ ${fails} failing`)) + process.exit(fails === 0 ? 0 : 1) +} + +// Minimal RTDB stub — same shape as scripts/test-season-close.js uses. +function makeDb(tree = {}) { + const read = (p) => p.split('/').reduce((o, k) => (o == null ? undefined : o[k]), tree) + const write = (p, v) => { + const parts = p.split('/'); let o = tree + for (const k of parts.slice(0, -1)) { if (typeof o[k] !== 'object' || o[k] === null) o[k] = {}; o = o[k] } + o[parts[parts.length - 1]] = v + } + return { + tree, + ref(p) { + return { + async get() { const v = read(p); return { val: () => (v === undefined ? null : v), exists: () => v !== undefined } }, + async update(patch) { write(p, Object.assign({}, read(p) || {}, patch)) }, + async set(v) { write(p, v) }, + } + }, + } +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/scripts/test-vault-win.js b/scripts/test-vault-win.js index ca3a078..5073731 100644 --- a/scripts/test-vault-win.js +++ b/scripts/test-vault-win.js @@ -127,10 +127,34 @@ async function openWith(page, payload) { // ── a pending payout must not invent a figure ───────────────────────── const pending = await openWith(page, { isCorrect: true, rewardStatus: 'pending', txHash: null }) ok('a pending reward says pending, not a number', /pending/i.test(pending.amount), pending.amount) - ok('and explains it completes by itself', /tops? up|topped up|finishes by itself/i.test(pending.note), - pending.note.slice(0, 80)) + // It must say the win is SAFE and the payout retries — but it must NOT name a + // cause the server never checked. This assertion used to require the words + // "topped up", which is how the popup came to tell every failed payout that + // the reward pool was empty. On the week the owner hit it the pool held 0.85 + // USDT against a 0.05 reward and the real fault was a dropped transaction; + // that sentence cost him, and then an agent, an hour in the treasury. + ok('and explains the win is kept and the payout retries', + /retries|recorded|by itself/i.test(pending.note), pending.note.slice(0, 80)) + ok('and does NOT blame the reward pool when nothing checked it', + !/pool/i.test(pending.note), pending.note.slice(0, 80)) ok('with no transaction link to a transaction that does not exist', !pending.txShown) + // The server sends the real reason down with the response (see + // VAULT_FAILURE_MESSAGE in lib/server/vaultChain.ts). The popup must print + // THAT, not a guess of its own. + const poolPending = await openWith(page, { + isCorrect: true, rewardStatus: 'pending', txHash: null, reason: 'pool_empty', + message: 'Your win is recorded. The reward pool needs topping up — the transfer completes by itself as soon as it is funded.', + }) + ok('a pool problem the server DID check is named as one', /pool/i.test(poolPending.note), + poolPending.note.slice(0, 80)) + const rpcPending = await openWith(page, { + isCorrect: true, rewardStatus: 'pending', txHash: null, reason: 'rpc', + message: 'Your win is recorded. The network dropped the payout transaction — it retries automatically and completes within a day.', + }) + ok('a dropped transaction is named as one, and never as a pool problem', + /network dropped/i.test(rpcPending.note) && !/pool/i.test(rpcPending.note), rpcPending.note.slice(0, 80)) + // ── the amount read failed server-side: say "sent", never guess ─────── const noAmount = await openWith(page, { isCorrect: true, rewardStatus: 'paid', txHash: '0xdef456' }) ok('a paid win with no amount says "sent" rather than inventing one',