From e7e6dab7140e3d3dec49e58e48e96e00bb8d2a44 Mon Sep 17 00:00:00 2001 From: 0xward <0xward.dev@gmail.com> Date: Sun, 2 Aug 2026 17:22:17 +0000 Subject: [PATCH] A prize sent to an account key is destroyed, not delayed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A player who signs in with Google is ranked under an address derived from their account id — SHA-256 of it, per lib/authIdentity.ts. It looks exactly like a wallet, it can RECEIVE USDT, and it can never send any, because no private key for it exists anywhere in the world. Transfer a season prize there and the money is gone: not stuck, not recoverable, gone. Nothing distinguished that address from a real wallet. /api/player/seen recorded `guest`, which comes from useWallet's isGuest — and isGuest is FALSE for an account player, because WalletProvider stops minting a guest id once an account address exists. So the one field that was supposed to separate people from browsers quietly answered "real wallet" for the case that matters most. This has been true since the Firebase sign-in shipped. The Privy gate did not create it; it made it likely, because the talent.app judges will arrive through Google rather than a wallet. Three parts: RECORD WHAT THE ID IS. players/{id} gains `kind` — wallet | account | guest — written from the same call that already reported `guest`, which keeps being written unchanged so /api/stats is untouched. Unrecognised values are stored as unknown rather than guessed at. RECORD WHERE MONEY CAN GO. Privy hands a Google player an embedded wallet they actually control. That address is now kept as `payout`, and is deliberately NOT used as their key: keying on it would grant account-level access to an address the app cannot verify a signature for, and would orphan every save already stored under the derived one. REFUSE TO BURN IT. prepareSeason resolves each winner's payable address while the ranking is being frozen and stores the answer in the snapshot, so the list reviewed on Monday is the list paid on Tuesday. payoutCommands then: · emits NO transfer for a winner with nowhere to be paid, and names them so the owner can go and ask; · withholds the ENTIRE on-chain half when a podium winner cannot claim — the contract pays by claim, so funding the pool for someone who cannot sign locks the deposit away, which is worse than a failed transfer because the treasury has already paid; · pays everyone else at their embedded wallet, saying so on the line; · and marks winners ranked before any of this existed as UNVERIFIED rather than either paying them silently or blocking them. Unknown is not the same as safe — it means nobody has checked — and treating it as unpayable would strand legitimate winners who predate the field. One blocked winner never stops the others. docs/OWNER-RUNBOOK.md gains the three warning shapes and what to do about each. Verified: 128/128 test-season-close (14 of them new and covering exactly this), test-season-routes, test:privy, test:season-ui, tsc clean, build unchanged at 142/242 kB, check:copy, check:cssvars, check:market, audit, lint 35. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017A764RdnwpyWnG7uCNhMiQ --- app/api/player/seen/route.ts | 39 ++++++- components/game/GameFlowManager.tsx | 24 +++- components/game/PrivyGate.tsx | 13 ++- docs/OWNER-RUNBOOK.md | 41 ++++++- lib/authIdentity.ts | 43 +++++++ lib/server/seasonClose.ts | 167 +++++++++++++++++++++++----- scripts/test-season-close.js | 88 ++++++++++++++- 7 files changed, 374 insertions(+), 41 deletions(-) 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