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',