Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions app/api/player/seen/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 })
Expand All @@ -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
Expand Down
24 changes: 19 additions & 5 deletions components/game/GameFlowManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -754,13 +765,16 @@ export default function GameFlowManager() {
if (showPrivyGate) {
return (
<PrivyGate
onAuthenticated={async (uid, label) => {
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.
Expand Down
13 changes: 12 additions & 1 deletion components/game/PrivyGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
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<void>
/** "Skip for now" — the player declines and plays as a guest. */
onSkip: () => void
}
Expand Down Expand Up @@ -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])

Expand Down
41 changes: 37 additions & 4 deletions docs/OWNER-RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions lib/authIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
}
Expand Down Expand Up @@ -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 */
}
}
Loading
Loading