Skip to content

A prize sent to an account key is destroyed, not delayed - #227

Merged
0xward merged 1 commit into
mainfrom
claude/nullstate-onchain-text-q6g2a3
Aug 2, 2026
Merged

A prize sent to an account key is destroyed, not delayed#227
0xward merged 1 commit into
mainfrom
claude/nullstate-onchain-text-q6g2a3

Conversation

@0xward

@0xward 0xward commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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. The one field meant 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 kindwallet | 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, withholds the entire on-chain half when a podium winner cannot claim, pays everyone else at their embedded wallet, and marks pre-existing winners UNVERIFIED rather than silently paying 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.

What the owner actually sees

# ⚠ NO ADDRESS — do NOT pay these, the money would be destroyed:
#   rank 3  Usna  $3  (ranked as 0x0303…, which is an account key, not a wallet)
#   rank 4  Vex  $1   (ranked as 0x0404…, which is an account key, not a wallet)
#   Ask each of them for a wallet address, then pay by hand.
# ⚠ UNVERIFIED — ranked before payout addresses were recorded.
#   rank 6  Dala  0x0606…
#
# ⛔ ON-CHAIN PAYOUT WITHHELD. Rank 3 cannot claim:
#   the reward contract pays by CLAIM, and an account key cannot
#   sign one. Depositing would lock the pool with no way out.
node scripts/deposit-reward.js pay --token USDT --to 0xcdcd… --amount 1  # rank 5 (embedded wallet)
node scripts/deposit-reward.js pay --token USDT --to 0x0606… --amount 1  # rank 6

Once rank 3 supplies an address, the same season emits the full on-chain half again, with rank 2 and 3 published at their embedded wallets rather than the keys they are ranked under.

Why the on-chain half is withheld, not just flagged

The reward contract pays by claim. A winner who cannot sign can never claim, so funding the pool for them locks the deposit in a contract with no way out — strictly worse than a failed transfer, because the treasury has already paid.

Testing

✓ 128/128 test-season-close — 14 new, covering exactly this
✓ test-season-routes  ✓ test:privy  ✓ test:season-ui
✓ tsc clean  ✓ build 142/242 kB (unchanged)
✓ check:copy  ✓ check:cssvars  ✓ check:market  ✓ audit  ✓ lint 35

docs/OWNER-RUNBOOK.md gains the three warning shapes and what to do about each.

No contract changes. guest and /api/stats are untouched; both new fields are additive and nothing already stored changes meaning.


Generated by Claude Code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017A764RdnwpyWnG7uCNhMiQ
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nullstate Ready Ready Preview Aug 2, 2026 5:24pm

@0xward
0xward merged commit 083b12f into main Aug 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant