diff --git a/app/api/player/seen/route.ts b/app/api/player/seen/route.ts index 64374b8..a8c6df3 100644 --- a/app/api/player/seen/route.ts +++ b/app/api/player/seen/route.ts @@ -18,7 +18,7 @@ import { getAdminDb } from '@/firebase-config' // // This route writes one small node per player and nothing else: // -// players/{id} = { firstSeen, lastSeen, guest } +// players/{id} = { firstSeen, lastSeen, guest, kind, payout } // // `firstSeen` is written once and never overwritten, so the acquisition date // survives every later visit. `lastSeen` moves every session, which finally @@ -31,6 +31,26 @@ import { getAdminDb } from '@/firebase-config' // people and duplicate browsers. It cannot classify ids recorded before today, // but from here the distinction is kept. // +// `kind` exists because `guest` cannot answer the only question that costs +// money. It is written from useWallet's isGuest, which is FALSE for a player +// signed into an account — WalletProvider stops minting a guest id once an +// account address exists — so a key that is SHA-256 of a uid, with no private +// key anywhere in the world, was recorded as indistinguishable from a real +// wallet. Send USDT to one of those and it is destroyed, not delayed. +// +// wallet a real wallet. Can receive and can spend. +// account a signed-in player's derived key. Can receive, can NEVER spend. +// guest a random local id. Same, and it dies with localStorage. +// +// `payout` is the way out for the middle case: Privy gives a Google/email +// player an embedded wallet they actually control, and that address is where a +// prize can go. Absent for everyone who signed in before this shipped, which is +// why lib/server/seasonClose.ts treats "unknown" as a person to ask rather than +// an address to try. +// +// Both are additive. Nothing already stored changes meaning, and `guest` keeps +// being written exactly as before so /api/stats is untouched. +// // No authentication, on purpose, and it is safe not to have any: the route // writes only three fields under a caller-supplied id, moves no value, grants // nothing, and reads nothing back. The worst a spammer achieves is inflating a @@ -50,6 +70,14 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Invalid id' }, { status: 400 }) } const guest = body?.guest === true + // Anything unrecognised is recorded as unknown rather than guessed at. A + // wrong `kind` here is a wrong answer to "can this player be paid". + const rawKind = String(body?.kind || '') + const kind = rawKind === 'wallet' || rawKind === 'account' || rawKind === 'guest' + ? rawKind + : null + const rawPayout = String(body?.payout || '').toLowerCase() + const payout = /^0x[a-f0-9]{40}$/.test(rawPayout) ? rawPayout : null const db = getAdminDb() if (!db) return NextResponse.json({ error: 'Server storage unavailable' }, { status: 500 }) @@ -58,10 +86,17 @@ export async function POST(req: NextRequest) { const ref = db.ref(`players/${id}`) // A transaction rather than a read-then-write: two tabs opening at once // must not race each other into overwriting firstSeen. - const res = await ref.transaction((cur: { firstSeen?: number } | null) => ({ + const res = await ref.transaction((cur: { + firstSeen?: number; kind?: string | null; payout?: string | null + } | null) => ({ firstSeen: cur?.firstSeen || now, lastSeen: now, guest, + // Kept rather than overwritten when this request does not know. A player + // who opens the game in a second browser, signed out, must not erase the + // payout address their signed-in session recorded. + kind: kind ?? cur?.kind ?? null, + payout: payout ?? cur?.payout ?? null, })) const val = res.snapshot?.val() as { firstSeen?: number } | null diff --git a/components/game/GameFlowManager.tsx b/components/game/GameFlowManager.tsx index fcc149a..5cbf8bc 100644 --- a/components/game/GameFlowManager.tsx +++ b/components/game/GameFlowManager.tsx @@ -7,7 +7,7 @@ import { useContractPlayer } from '@/lib/useContractPlayer' import { PlayerProfile, LeaderboardEntry } from '@/lib/contract' import { loadGameSession, loadGameSessionDraft, clearGameSession, saveGameSession, saveGameSessionDraft } from '@/lib/gameSessionService' import { migrateGuestProgress, getStoredGuestId } from '@/lib/guestMigration' -import { getStoredAuthAddress, hasSkippedSignIn } from '@/lib/authIdentity' +import { getStoredAuthAddress, hasSkippedSignIn, storePayoutAddress, getStoredPayoutAddress } from '@/lib/authIdentity' import { useAccountActions } from '@/lib/useAccountActions' import { readHighestAct, recordHighestAct, stashCampaignResume, takeCampaignResume } from '@/lib/campaignProgress' import MainMenu from './MainMenu' @@ -113,7 +113,7 @@ type GamePhase = 'menu' | 'sign-in' | 'username-setup' | 'character-select' | 'g * All player progress is stored ON-CHAIN via the contract. */ export default function GameFlowManager() { - const { address, isConnected, realAddress, isGuest, isMiniPay, walletReady, connect } = useWallet() + const { address, isConnected, realAddress, isGuest, isSignedIn, isMiniPay, walletReady, connect } = useWallet() const { playerProfile, isLoading: isLoadingProfile, @@ -292,9 +292,20 @@ export default function GameFlowManager() { fetch('/api/player/seen', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ wallet: address, guest: !!isGuest }), + body: JSON.stringify({ + wallet: address, + guest: !!isGuest, + // `guest` alone cannot answer the only question that costs money. + // isGuest is false for an ACCOUNT player — see WalletProvider, where + // guestAddress is not minted once an account address exists — so a + // hash-derived key that can never release a transfer was recorded as + // indistinguishable from a real wallet. `kind` says which it is. + kind: realAddress ? 'wallet' : isSignedIn ? 'account' : 'guest', + // And where a prize could actually go, when there is such a place. + payout: getStoredPayoutAddress(), + }), }).catch(() => { /* a missed count is not worth a single visible failure */ }) - }, [address, isGuest]) + }, [address, isGuest, realAddress, isSignedIn]) // If wallet disconnects, go back to menu useEffect(() => { @@ -754,13 +765,16 @@ export default function GameFlowManager() { if (showPrivyGate) { return ( { + onAuthenticated={async (uid, label, payoutAddress) => { // Through the SAME adopt() the Firebase screen uses: derive an // address from the id, carry the guest's progress onto it, store the // identity. Nothing about Privy is special here — adopt() only ever // wanted a stable id, and this is one. Without this the screen's own // promise ("it follows you anywhere") would be false. try { + // The embedded wallet FIRST, so that even a failed migration leaves + // the player with somewhere a prize could be sent. + storePayoutAddress(payoutAddress) await acct.adopt(uid, label) } finally { // Even a failed migration must not trap the player on this screen. diff --git a/components/game/PrivyGate.tsx b/components/game/PrivyGate.tsx index 81e3543..86ffc3f 100644 --- a/components/game/PrivyGate.tsx +++ b/components/game/PrivyGate.tsx @@ -50,7 +50,17 @@ interface PrivyGateProps { * signature for, which is exactly the two-layer split lib/authIdentity.ts * describes — account keys identify, they never authorise. */ - onAuthenticated: (uid: string, label?: string | null) => void | Promise + onAuthenticated: ( + uid: string, + label?: string | null, + /** + * The embedded wallet Privy creates for a player who arrived without one. + * A REAL address with a real key the player controls, which is the only + * thing a season prize can be sent to — the derived account address above + * is a hash of the id and can receive money but never release it. + */ + payoutAddress?: string | null, + ) => void | Promise /** "Skip for now" — the player declines and plays as a guest. */ onSkip: () => void } @@ -82,6 +92,7 @@ function GateInner({ onAuthenticated, onSkip }: PrivyGateProps) { void onAuthenticated( user.id, user.google?.email ?? user.email?.address ?? user.wallet?.address ?? null, + user.wallet?.address ?? null, ) }, [ready, authenticated, user, onAuthenticated]) diff --git a/docs/OWNER-RUNBOOK.md b/docs/OWNER-RUNBOOK.md index 3ae0346..6835759 100644 --- a/docs/OWNER-RUNBOOK.md +++ b/docs/OWNER-RUNBOOK.md @@ -83,10 +83,43 @@ node scripts/deposit-reward.js pay --token USDT --to 0x.. --amount 1 # rank 4 # … through rank 10 ``` -**Check every address before signing.** Nothing is filtered out server-side on -purpose — your review is the safeguard, which is why the payout was never -automated end to end. The `pay` command is a plain transfer out of your own -wallet and cannot be undone. +### Lines starting with `#` are for you, not for pasting + +A player who signs in with Google is ranked under an address derived from their +account id — SHA-256 of it. That address can RECEIVE money and can never send +it, because no private key for it exists anywhere. USDT paid there is destroyed, +not delayed. + +So the list now tells you three things before it tells you anything else: + +``` +# ⚠ NO ADDRESS — do NOT pay these, the money would be destroyed: +# rank 4 Rondo $1 (ranked as 0x…, which is an account key, not a wallet) +# Ask each of them for a wallet address, then pay by hand. +``` +No command is generated for them at all. Ask them for a wallet and send it +yourself. + +``` +# ⛔ ON-CHAIN PAYOUT WITHHELD. Rank 2 cannot claim: +``` +The contract pays by **claim**, and someone who cannot sign can never claim. If +a podium winner has no wallet, funding the pool locks your deposit in a contract +with no way out — so the whole on-chain half is withheld until you have an +address from them. Ranks 4-10 still pay normally; one blocked winner does not +stop the rest. + +``` +# ⚠ UNVERIFIED — ranked before payout addresses were recorded. +``` +These are paid at their ranked address, as before. Unknown is not the same as +safe: it means nobody has checked. Confirm with them that it is a wallet they +control before you run the line. + +**Check every address before signing.** Nothing is filtered out server-side +beyond the destroyed-money case above — your review is the safeguard, which is +why the payout was never automated end to end. The `pay` command is a plain +transfer out of your own wallet and cannot be undone. ### 3. Mark it paid diff --git a/lib/authIdentity.ts b/lib/authIdentity.ts index da20881..942cc72 100644 --- a/lib/authIdentity.ts +++ b/lib/authIdentity.ts @@ -111,6 +111,9 @@ export function clearAuthIdentity() { window.localStorage.removeItem(UID_KEY) window.localStorage.removeItem(ADDR_KEY) window.localStorage.removeItem(LABEL_KEY) + // The payout address belonged to that account's embedded wallet. Leaving it + // behind would offer the next player's prize to the last player's wallet. + window.localStorage.removeItem('nullstate-payout-address') } catch { /* ignore */ } @@ -141,3 +144,43 @@ export function rememberSignInSkipped() { /* ignore */ } } + +// ─── The address that can actually receive money ───────────────────────────── +// +// The account address above is a KEY, not a wallet: it is SHA-256 of a uid, and +// nobody holds its private key. That is fine for saving progress and fatal for +// paying a prize — USDT sent there is gone, not "stuck". +// +// Privy hands a Google/email player a real embedded wallet on sign-in, which +// they can control and export. That address is recorded here so the season +// payout has somewhere to send the prize. It is deliberately NOT used as the +// player's key: keying on it would give account-level access to an address the +// app cannot verify a signature for, and would break every save already stored +// under the derived address. +// +// Empty for a plain guest, and for anyone who signed in before this existed. +// The season code treats "no payout address" as a name to ask, never as an +// address to try. + +const PAYOUT_KEY = 'nullstate-payout-address' + +export function getStoredPayoutAddress(): string | null { + if (typeof window === 'undefined') return null + try { + const a = window.localStorage.getItem(PAYOUT_KEY) + return a && ADDRESS_RE.test(a) ? a.toLowerCase() : null + } catch { + return null + } +} + +export function storePayoutAddress(address: string | null | undefined) { + if (typeof window === 'undefined') return + try { + if (address && ADDRESS_RE.test(address)) { + window.localStorage.setItem(PAYOUT_KEY, address.toLowerCase()) + } + } catch { + /* private mode — the payout address is re-read from Privy on next sign-in */ + } +} diff --git a/lib/server/seasonClose.ts b/lib/server/seasonClose.ts index 54ed08b..72e4af6 100644 --- a/lib/server/seasonClose.ts +++ b/lib/server/seasonClose.ts @@ -76,6 +76,19 @@ export interface SeasonWinner { username: string xp: number rewardUsd: number + /** + * Where this prize can actually be sent, or null when there is nowhere. + * + * `wallet` is the player's KEY, and for a signed-in player that key is + * SHA-256 of their account id — an address with no private key anywhere in + * the world. It can receive USDT and can never release it, so paying it + * destroys the prize rather than delaying it. Resolved once, at freeze time, + * and stored in the snapshot so the answer cannot drift between the review + * and the transfer. + */ + payout?: string | null + /** How that answer was reached — see resolvePayout(). */ + payable?: 'wallet' | 'embedded' | 'none' | 'unknown' } export interface SeasonSnapshot { @@ -170,6 +183,41 @@ export function toWinners(rows: Array<{ wallet: string; username: string; xp: nu })) } +/** + * Can this winner be paid, and where? + * + * Reads the one record that knows: players/{id}, written by /api/player/seen. + * + * wallet they connected a real wallet. Pay the wallet. + * embedded a signed-in player with a Privy embedded wallet. Pay THAT, never + * the derived key they are ranked under. + * none a signed-in or guest player with no wallet anywhere. There is no + * address to pay. Somebody has to ask them for one. + * unknown recorded before `kind` existed, or never recorded at all. NOT the + * same as safe: it means nobody has checked. + * + * The distinction between `none` and `unknown` is the entire point. Treating + * unknown as unpayable would strand legitimate winners who predate this field; + * treating none as payable destroys money. They get different treatment in + * payoutCommands() for exactly that reason. + */ +export async function resolvePayout( + db: AdminDb, + wallet: string, +): Promise<{ payout: string | null; payable: NonNullable }> { + let rec: { kind?: string | null; payout?: string | null } | null = null + try { + rec = (await db.ref(`players/${wallet.toLowerCase()}`).get()).val() + } catch { + // A read failure is not evidence of anything. Say so rather than guessing. + return { payout: null, payable: 'unknown' } + } + if (rec?.payout) return { payout: String(rec.payout).toLowerCase(), payable: 'embedded' } + if (rec?.kind === 'wallet') return { payout: wallet.toLowerCase(), payable: 'wallet' } + if (rec?.kind === 'account' || rec?.kind === 'guest') return { payout: null, payable: 'none' } + return { payout: null, payable: 'unknown' } +} + /** Read-only status. Never writes, so any screen can poll it. */ export async function readSeasonStatus(db: AdminDb, now = Date.now()): Promise { const currentSeasonId = getCurrentSeasonId(new Date(now)) @@ -206,9 +254,16 @@ export async function prepareSeason( // reason the payout stayed manual. A server-side exclusion list would be a // second, invisible policy that nobody reads until it pays the wrong person. const rows = await readTopByXp(fs, seasonId) + // Resolved HERE, while the ranking is being frozen, and stored with it. If it + // were resolved when the commands are generated instead, the owner could + // review a list on Monday and pay a different one on Tuesday because somebody + // signed in over the weekend. + const winners = await Promise.all( + toWinners(rows).map(async (w) => ({ ...w, ...(await resolvePayout(db, w.wallet)) })), + ) const snapshot: SeasonSnapshot = { seasonId, - winners: toWinners(rows), + winners, preparedAt: Date.now(), paidAt: null, } @@ -454,33 +509,89 @@ export function payoutCommands(snapshot: SeasonSnapshot): string[] { // The contract needs exactly three addresses. Fewer than three ranked players // is not a payout that can be prepared, on-chain or off. if (w.length < ONCHAIN_RANKS) return [] + + // Where each prize can actually go. `payout` was resolved when the ranking + // was frozen; a snapshot taken before that field existed has neither, and + // falls back to the old behaviour of paying the ranked address — which is + // exactly what the warnings below are for. + const dest = (x: SeasonWinner) => x.payout || x.wallet + const unpayable = w.filter((x) => x.payable === 'none') + const unchecked = w.filter((x) => x.payable === 'unknown' || x.payable === undefined) + + const lines: string[] = [] + + // ── The warnings come FIRST, because they change what you should run ─────── + // + // A prize sent to a signed-in player's ranked address is DESTROYED, not + // delayed: that address is SHA-256 of their account id and no private key for + // it exists anywhere. So a winner with nowhere to be paid does not get a + // command — they get a name to contact. + if (unpayable.length) { + lines.push('# ⚠ NO ADDRESS — do NOT pay these, the money would be destroyed:') + for (const x of unpayable) { + lines.push(`# rank ${x.rank} ${x.username} $${x.rewardUsd}` + + ` (ranked as ${x.wallet}, which is an account key, not a wallet)`) + } + lines.push('# Ask each of them for a wallet address, then pay by hand.') + } + if (unchecked.length) { + lines.push('# ⚠ UNVERIFIED — ranked before payout addresses were recorded.') + lines.push('# The commands below pay their ranked address. Check with each') + lines.push('# of them that it is a wallet they control BEFORE running it:') + for (const x of unchecked) { + lines.push(`# rank ${x.rank} ${x.username} ${x.wallet}`) + } + } + const onchain = w.slice(0, ONCHAIN_RANKS) const direct = w.slice(ONCHAIN_RANKS) - // Only what the POOL pays. Funding it with the whole $35 would leave the - // seven tail prizes sitting in a contract that has no way to release them. - const total = onchain.reduce((s, x) => s + x.rewardUsd, 0) - return [ - // FIRST, and the reason it exists: this is what makes the contract agree - // with RANK_REWARDS_USD. `setRankRewards` is global rather than per-season, - // so re-sending unchanged figures is a cheap no-op — and the one time it is - // NOT a no-op is the time it would otherwise have paid the wrong amount. - // See the note on RANK_REWARDS_USD for the drift this closes. - `node scripts/deposit-reward.js season-rewards --token ${SEASON_PAYOUT_TOKEN}` - + ` --r1 ${RANK_REWARDS_USD[0]} --r2 ${RANK_REWARDS_USD[1]} --r3 ${RANK_REWARDS_USD[2]}`, - `node scripts/deposit-reward.js update-leaderboard --season ${snapshot.seasonId}` - + ` --p1 ${w[0].wallet} --p2 ${w[1].wallet} --p3 ${w[2].wallet}` - + ` --s1 ${w[0].xp} --s2 ${w[1].xp} --s3 ${w[2].xp}`, - // --token is NOT optional: resolveToken() in that script dies with - // "missing --token" when it is absent, so the first version of this line - // handed the owner a command that failed on paste — the exact thing the - // comment above swears this must never do. USDT because that is what - // OWNER-RUNBOOK.md and rewards-system.md both specify for season bonuses. - `node scripts/deposit-reward.js season-deposit --season ${snapshot.seasonId}` - + ` --token ${SEASON_PAYOUT_TOKEN} --amount ${total}`, - // Ranks 4-10. One transfer each, because the contract has nowhere to put - // them. Ordered by rank so the list reads like the leaderboard it came from. - ...direct.map((x) => - `node scripts/deposit-reward.js pay --token ${SEASON_PAYOUT_TOKEN}` - + ` --to ${x.wallet} --amount ${x.rewardUsd} # rank ${x.rank}`), - ] + + // The top three are paid by the CONTRACT, which holds the money until the + // winner claims it. A winner who cannot sign can never claim, so funding the + // pool for them locks the deposit away with no way to release it — worse + // than a failed transfer, because the money is already gone from the + // treasury. The whole on-chain half is withheld until that is resolved. + const blockedOnchain = onchain.filter((x) => x.payable === 'none') + if (blockedOnchain.length) { + lines.push('#') + lines.push('# ⛔ ON-CHAIN PAYOUT WITHHELD.' + + ` Rank ${blockedOnchain.map((x) => x.rank).join(', ')} cannot claim:`) + lines.push('# the reward contract pays by CLAIM, and an account key cannot') + lines.push('# sign one. Depositing would lock the pool with no way out.') + lines.push('# Get a wallet address from them, re-freeze, and run this again.') + } else { + const total = onchain.reduce((s, x) => s + x.rewardUsd, 0) + lines.push( + // FIRST, and the reason it exists: this is what makes the contract agree + // with RANK_REWARDS_USD. `setRankRewards` is global rather than + // per-season, so re-sending unchanged figures is a cheap no-op — and the + // one time it is NOT a no-op is the time it would otherwise have paid the + // wrong amount. See the note on RANK_REWARDS_USD for the drift this closes. + `node scripts/deposit-reward.js season-rewards --token ${SEASON_PAYOUT_TOKEN}` + + ` --r1 ${RANK_REWARDS_USD[0]} --r2 ${RANK_REWARDS_USD[1]} --r3 ${RANK_REWARDS_USD[2]}`, + `node scripts/deposit-reward.js update-leaderboard --season ${snapshot.seasonId}` + + ` --p1 ${dest(w[0])} --p2 ${dest(w[1])} --p3 ${dest(w[2])}` + + ` --s1 ${w[0].xp} --s2 ${w[1].xp} --s3 ${w[2].xp}`, + // --token is NOT optional: resolveToken() in that script dies with + // "missing --token" when it is absent, so the first version of this line + // handed the owner a command that failed on paste. USDT because that is + // what OWNER-RUNBOOK.md and rewards-system.md both specify. + `node scripts/deposit-reward.js season-deposit --season ${snapshot.seasonId}` + + ` --token ${SEASON_PAYOUT_TOKEN} --amount ${total}`, + ) + } + + // Ranks 4-10. One transfer each, because the contract has nowhere to put + // them. Ordered by rank so the list reads like the leaderboard it came from, + // and the ones with nowhere to go are simply absent — they are named in the + // warning block instead. + for (const x of direct) { + if (x.payable === 'none') continue + lines.push(`node scripts/deposit-reward.js pay --token ${SEASON_PAYOUT_TOKEN}` + + ` --to ${dest(x)} --amount ${x.rewardUsd} # rank ${x.rank}` + + (x.payout && x.payout !== x.wallet ? ' (embedded wallet)' : '')) + } + + return lines } + diff --git a/scripts/test-season-close.js b/scripts/test-season-close.js index 8e8a143..7f957f9 100644 --- a/scripts/test-season-close.js +++ b/scripts/test-season-close.js @@ -169,10 +169,19 @@ const W = (n) => '0x' + String(n).padStart(2, '0').repeat(20) (await S.markSeasonPaid(db, 209912)) === null) // ── the handover: the commands must actually run ────────────────────── - const cmds = S.payoutCommands(first.snapshot) + const all = S.payoutCommands(first.snapshot) + // Comment lines are guidance for the owner, not things to paste. They are + // asserted on their own further down; the runnable half is what these check. + const notes = all.filter((c) => c.startsWith('#')) + const cmds = all.filter((c) => !c.startsWith('#')) // 3 on-chain + one direct transfer per rank 4-10 = 10. ok('ten commands: three on-chain, seven direct transfers', cmds.length === 10, String(cmds.length)) + // These winners have no players/ record at all, which is what every player + // ranked before payout addresses existed looks like. Unknown is NOT treated + // as safe — the commands still run, but the owner is told to check first. + ok('winners with no record are flagged UNVERIFIED, not silently paid', + notes.some((n) => /UNVERIFIED/.test(n)), notes[0] || 'none') const cli = fs.readFileSync(path.join(__dirname, 'deposit-reward.js'), 'utf8') // deposit-reward.js validates args.p1..p3 and args.s1..s3 for // update-leaderboard, and args.season/args.amount for season-deposit. If it @@ -343,6 +352,83 @@ const W = (n) => '0x' + String(n).padStart(2, '0').repeat(20) ok('and all ten places are still filled', withJunk.snapshot.winners.length === 10, String(withJunk.snapshot.winners.length)) + // ── THE PRIZE THAT WOULD HAVE BEEN DESTROYED ────────────────────────── + // + // A signed-in player is ranked under SHA-256 of their account id. That + // address can receive USDT and can never release it: no private key for it + // exists anywhere in the world. Paying it does not delay the prize, it + // destroys it — and nothing distinguished that address from a real wallet, + // because /api/player/seen recorded isGuest, which is FALSE for an account. + // + // players/{id}.kind is what tells them apart, and .payout is the way out for + // a Privy player who has an embedded wallet they actually control. + { + const W = (n) => '0x' + String(n).padStart(2, '0').repeat(20) + const tenRows = Array.from({ length: 10 }, (_, i) => ({ + walletAddress: W(i + 1), username: 'P' + (i + 1), xp: 1000 - i * 10, + })) + const db = makeDb({ + players: { + // rank 1: a real wallet. Pays itself. + [W(1)]: { kind: 'wallet' }, + // rank 2: signed in, WITH an embedded wallet. Pays the wallet, never + // the key they are ranked under. + [W(2)]: { kind: 'account', payout: '0x' + 'ab'.repeat(20) }, + // rank 3: signed in with nowhere to be paid. This is the one that + // used to burn money. + [W(3)]: { kind: 'account' }, + // rank 4: a guest. Same problem, smaller prize. + [W(4)]: { kind: 'guest' }, + // rank 5: has an embedded wallet too, out in the direct-transfer tail. + [W(5)]: { kind: 'account', payout: '0x' + 'cd'.repeat(20) }, + }, + }) + const { snapshot } = await S.prepareSeason(db, makeFs(tenRows), 202610) + const by = (r) => snapshot.winners.find((w) => w.rank === r) + + ok('a real wallet resolves to itself', by(1).payable === 'wallet' && by(1).payout === W(1)) + ok('an account with an embedded wallet resolves to the WALLET, not the key', + by(2).payable === 'embedded' && by(2).payout === '0x' + 'ab'.repeat(20)) + ok('an account with no wallet resolves to nowhere', by(3).payable === 'none' && by(3).payout === null) + ok('so does a guest', by(4).payable === 'none') + ok('and a player with no record at all is UNKNOWN, not assumed payable', + by(6).payable === 'unknown') + // Frozen INTO the snapshot: the list reviewed on Monday must be the list + // paid on Tuesday, even if somebody signs in over the weekend. + ok('the answer is stored with the ranking, not recomputed later', + typeof by(1).payout === 'string' && typeof by(3).payable === 'string') + + const out = S.payoutCommands(snapshot) + const notes = out.filter((c) => c.startsWith('#')) + const runs = out.filter((c) => !c.startsWith('#')) + + // THE HEADLINE: no transfer may be generated to an address that cannot + // release it. + ok('no command pays the account key of rank 3', + !runs.some((c) => c.includes(W(3))), runs.filter((c) => c.includes(W(3))).join(' | ')) + ok('nor the guest at rank 4', !runs.some((c) => c.includes(W(4)))) + ok('and both are named so the owner can go and ask them', + notes.some((n) => /NO ADDRESS/.test(n)) && + notes.some((n) => /rank 3/.test(n)) && notes.some((n) => /rank 4/.test(n))) + + // The contract pays by CLAIM. A winner who cannot sign can never claim, so + // funding the pool for them locks the deposit away — worse than a failed + // transfer, because the treasury has already paid. + ok('the on-chain half is withheld when a podium winner cannot claim', + !runs.some((c) => /season-deposit/.test(c)) && !runs.some((c) => /update-leaderboard/.test(c))) + ok('and it says why, in words the owner can act on', + notes.some((n) => /WITHHELD/.test(n)) && notes.some((n) => /claim/i.test(n))) + + // The tail still pays everyone who CAN be paid — a blocked winner must not + // take the others down with them. + ok('rank 5 is still paid, at their embedded wallet', + runs.some((c) => c.includes('0x' + 'cd'.repeat(20)) && /--amount 1/.test(c))) + ok('and the line says it went to the embedded wallet, not the ranked key', + runs.some((c) => c.includes('0x' + 'cd'.repeat(20)) && /embedded wallet/.test(c))) + ok('ranks 6-10 are unaffected', + [6, 7, 8, 9, 10].every((r) => runs.some((c) => c.includes(W(r))))) + } + // ── pruning finished buckets ────────────────────────────────────────── // Added after the audit noted nothing ever deleted the daily/weekly rows. // The dangerous mistake here is not leaving data behind, it is deleting the