From b2dc400fa316fbd3044b7089ca139bc3bdbda86e Mon Sep 17 00:00:00 2001 From: vikramarun Date: Sun, 12 Jul 2026 02:03:18 -0400 Subject: [PATCH 01/10] Web UX pass: unified sign-in, header wallet menu, /profile hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the entry experience per a full UX review of the home page. Auth: replace the separate Connect Wallet + Sign in buttons with one AuthButton (RainbowKit ConnectButton.Custom). Connecting auto-switches to the server's expected chain and, on a wagering server, auto-prompts the SIWE signature — a single flow instead of a two-step process. Signed-in state shows a compact account chip. The session token is now bound to the wallet it was issued for and cleared on disconnect / account switch, fixing a latent stale-token bug. Bankroll: move deposit/withdraw into a top-right WalletMenu (balance pill + popover reusing BankrollPanel); remove the inline bankroll panels from Home, Tournament, and Gauntlet. Home: drop the browser-bot panel, bankroll panel, and player-lookup box. Profile: new /profile hub with stats + game history (shared ProfileStats), an Engine section combining browser-bot config and the connect-your-engine pairing flow (extracted to ConnectEngine), and the player-lookup box. Remove Connect Engine from the nav; /player/[address] stays public and /connect remains as a deep link. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + apps/web/app/connect/page.tsx | 292 +----------------------- apps/web/app/gauntlet/page.tsx | 7 - apps/web/app/globals.css | 85 +++++++ apps/web/app/page.tsx | 22 -- apps/web/app/player/[address]/page.tsx | 159 +------------ apps/web/app/profile/page.tsx | 81 +++++++ apps/web/app/tournament/page.tsx | 7 - apps/web/components/AuthButton.tsx | 143 ++++++++++++ apps/web/components/ConnectEngine.tsx | 297 +++++++++++++++++++++++++ apps/web/components/Header.tsx | 12 +- apps/web/components/Lobby.tsx | 13 +- apps/web/components/ProfileLink.tsx | 24 -- apps/web/components/ProfileStats.tsx | 170 ++++++++++++++ apps/web/components/SignIn.tsx | 62 ------ apps/web/components/WalletButton.tsx | 13 -- apps/web/components/WalletMenu.tsx | 74 ++++++ apps/web/lib/escrow.ts | 13 ++ apps/web/lib/siwe.ts | 3 + 19 files changed, 883 insertions(+), 595 deletions(-) create mode 100644 apps/web/app/profile/page.tsx create mode 100644 apps/web/components/AuthButton.tsx create mode 100644 apps/web/components/ConnectEngine.tsx delete mode 100644 apps/web/components/ProfileLink.tsx create mode 100644 apps/web/components/ProfileStats.tsx delete mode 100644 apps/web/components/SignIn.tsx delete mode 100644 apps/web/components/WalletButton.tsx create mode 100644 apps/web/components/WalletMenu.tsx diff --git a/.gitignore b/.gitignore index 3ca45c4..11b1b72 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ apps/web/.next/ .turbo/ +*.tsbuildinfo # Foundry build output (run `forge build` to regenerate) contracts/out/ diff --git a/apps/web/app/connect/page.tsx b/apps/web/app/connect/page.tsx index 4538dac..6704f7a 100644 --- a/apps/web/app/connect/page.tsx +++ b/apps/web/app/connect/page.tsx @@ -1,297 +1,21 @@ "use client"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; -import { loadBotOptions, saveBotOptions, useBotStatus } from "@/lib/bot"; -import { GITHUB_REPO, SERVER_HTTP } from "@/lib/config"; -import { authToken } from "@/lib/escrow"; +import { ConnectEngine } from "@/components/ConnectEngine"; -/** Prebuilt client binaries published by .github/workflows/release.yml — - * artifact names there are load-bearing for these URLs (the workflow's - * publish job asserts they exist before releasing). */ -const RELEASES = `${GITHUB_REPO}/releases/latest/download`; -const DOWNLOADS = [ - { key: "macos-arm64", label: "macOS (Apple Silicon)", file: "chess-client-macos-arm64.tar.gz" }, - { key: "macos-x64", label: "macOS (Intel)", file: "chess-client-macos-x64.tar.gz" }, - { key: "linux-x64", label: "Linux (x64)", file: "chess-client-linux-x64.tar.gz" }, - { key: "windows-x64", label: "Windows (x64)", file: "chess-client-windows-x64.zip" }, -] as const; - -/** Best-effort platform guess for the primary download button. Browsers hide - * Apple Silicon vs Intel, so default Macs to arm64 (the common case) and - * list every platform below it. iOS UAs contain "like Mac OS X" and Android - * contains "Linux", so mobile must be detected FIRST — those visitors get a - * "runs on desktop" note instead of a binary they can't execute. */ -function guessPlatform(): (typeof DOWNLOADS)[number]["key"] | "mobile" { - const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; - if (/iPhone|iPad|iPod|Android|Mobile/i.test(ua)) return "mobile"; - if (/Windows/i.test(ua)) return "windows-x64"; - if (/Mac/i.test(ua)) return "macos-arm64"; - return "linux-x64"; -} - -/** "Connect your engine": pair a UCI engine running on your machine with your - * wallet, once. After that the website is the remote control — start or join - * games in the lobby and your bot plays them. */ +/** Standalone page for the native-engine pairing flow. The primary entry point + * is now the profile's Engine section; this route is kept for deep links and + * the CLI docs. */ export default function ConnectPage() { - const [token, setToken] = useState(null); - const [code, setCode] = useState(null); - const [codeErr, setCodeErr] = useState(null); - const [enginePath, setEnginePath] = useState("stockfish"); - const [bookPath, setBookPath] = useState(""); - const [name, setName] = useState(""); - const [copied, setCopied] = useState(false); - const [opts, setOpts] = useState>({}); - const [platform, setPlatform] = - useState<(typeof DOWNLOADS)[number]["key"] | "mobile">("macos-arm64"); - - useEffect(() => { - setToken(authToken()); - setOpts(loadBotOptions()); - setPlatform(guessPlatform()); - }, []); - - // Signed in → mint the pairing code automatically (single-use, 10 min). - useEffect(() => { - if (!token || code) return; - (async () => { - try { - const r = await fetch(`${SERVER_HTTP}/auth/link`, { - method: "POST", - headers: { authorization: `Bearer ${token}` }, - }); - if (!r.ok) return setCodeErr("Couldn't create a pairing code — try signing in again."); - setCode((await r.json()).code); - } catch { - setCodeErr("Server unreachable."); - } - })(); - }, [token, code]); - - // Live status: flips to "online" the moment the client connects (poll fast). - const bot = useBotStatus(token, 3000); - - const isWindows = platform === "windows-x64"; - const command = useMemo(() => { - // PowerShell needs the .\ prefix to run a CWD executable, and neither - // PowerShell nor cmd accept bash's backslash line continuations — so the - // Windows command is a single line. - const bin = isWindows ? ".\\chess-client.exe" : "./chess-client"; - const parts = [ - `${bin} connect`, - `--server ${SERVER_HTTP}`, - `--engine ${enginePath || "stockfish"}`, - ]; - if (bookPath.trim()) parts.push(`--book ${bookPath.trim()}`); - if (name.trim()) parts.push(`--name "${name.trim().replace(/"/g, "")}"`); - parts.push(`--code ${code ?? ""}`); - return parts.join(isWindows ? " " : " \\\n "); - }, [enginePath, bookPath, name, code, isWindows]); - - const copy = async () => { - try { - await navigator.clipboard.writeText(command); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - /* clipboard unavailable */ - } - }; - - const setOpt = (k: string, v: string) => { - const next = { ...opts, [k]: v }; - if (!v.trim()) delete next[k]; - setOpts(next); - saveBotOptions(next); - }; - return (

Connect your engine

-

- Run any UCI engine — and optionally a Polyglot opening book — on your own computer{" "} - and pair it with your wallet, once. After that this site is the remote control: start or - join games in the lobby and your bot plays them. The server stays - the referee — legality, clocks and results are decided server-side; your machine only - picks moves. +

+ Prefer everything in one place? This also lives under{" "} + My Profile → Engine.

- - {bot.online ? ( -
- - ✓ {bot.name ?? bot.engine} is online{bot.busy ? " — playing right now" : ""} - -

- Engine: {bot.engine}. Head to the lobby, pick a time control or - join an open challenge — your bot plays the seat while you watch live. -

- - {bot.options.length > 0 && ( -
- - Engine settings ({bot.options.length} options) — applied to every game your bot - plays - -
- {bot.options - .filter((o) => o.kind !== "button") - .map((o) => ( - - ))} -
-

- Blank = engine default. Saved in this browser and sent with each game (e.g. - Threads, Hash, Skill Level). -

-
- )} -
- ) : ( - <> -
- 1 · Get the client -

- A single small binary that drives any UCI engine — Stockfish, Lc0, or your own. No - Rust toolchain needed. -

- {(() => { - const dl = DOWNLOADS.find((d) => d.key === platform); - return dl ? ( -

- - ⬇ Download for {dl.label} - -

- ) : ( -

- 📱 The client runs on a desktop or server — grab the right build from your - computer: -

- ); - })()} -
- {platform === "mobile" ? "Downloads:" : "Other platforms:"}{" "} - {DOWNLOADS.filter((d) => d.key !== platform).map((d, i) => ( - - {i > 0 && " · "} - {d.label} - - ))} -
-
-              {isWindows
-                ? "# unzip, then run from a terminal in that folder"
-                : "tar -xzf chess-client-*.tar.gz   # then run ./chess-client from that folder"}
-            
-

- You also need a UCI engine on your machine —{" "} - {isWindows ? ( - <> - download Stockfish from - stockfishchess.org - {" "} - and point --engine at the unzipped .exe - - ) : ( - <> - e.g. brew install stockfish (macOS) or{" "} - apt install stockfish (Linux), or point --engine at - any engine binary - - )} - . -

-

- If the download 404s, the first release hasn't been cut yet — build from source - below. If macOS blocks the app ("developer cannot be verified"), run{" "} - xattr -d com.apple.quarantine chess-client or right-click → Open once. -

-
- - Prefer building from source? - -
-                {`git clone ${GITHUB_REPO} && cd openchess\ncargo build --release -p byo-client\n# binary: ./target/release/chess-client`}
-              
-
-
- -
- 2 · Run it - {!token && ( -

- Sign in with your wallet first (top right) — bots are wallet-bound, so your - games count toward your profile and can carry stakes. A single-use pairing code is - added to the command automatically. -

- )} -
- - - -
-
{command}
- - {codeErr &&
{codeErr}
} - {token && ( -

- Waiting for your bot to connect… this page updates the moment it's online. The - code is single-use and expires in 10 minutes; reload for a fresh one. Headless - bots can use OPENCHESS_WALLET_KEY instead, and{" "} - --auto makes the bot matchmake unattended. -

- )} -
- - )} +
); } diff --git a/apps/web/app/gauntlet/page.tsx b/apps/web/app/gauntlet/page.tsx index 09503b0..29aa18e 100644 --- a/apps/web/app/gauntlet/page.tsx +++ b/apps/web/app/gauntlet/page.tsx @@ -4,7 +4,6 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { useAccount } from "wagmi"; -import { BankrollPanel } from "@/components/BankrollPanel"; import { SeatGame } from "@/components/SeatGame"; import { loadBotOptions, useBotStatus } from "@/lib/bot"; import { SERVER_HTTP } from "@/lib/config"; @@ -326,12 +325,6 @@ function GauntletClient() { - {wagerOn && config?.escrow && ( -
- -
- )} -
Run a gauntlet
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a3f6b17..2b16a0f 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -125,6 +125,91 @@ a:hover { } } +/* wallet / bankroll menu */ +.wallet-menu { + position: relative; +} +.wallet-pill { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 10px; + border-radius: 999px; + background: var(--bg-2); + border: 1px solid var(--border); + color: var(--text-strong); + font-size: 13px; + cursor: pointer; +} +.wallet-pill:hover { + border-color: var(--accent); +} +.wp-coin { + color: var(--accent); +} +.wp-amt { + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.wp-caret { + color: var(--muted); + font-size: 10px; +} +.wallet-pop { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 340px; + max-width: 90vw; + z-index: 50; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + padding: 4px; +} +.wallet-pop .panel { + margin: 0; + border: none; + background: transparent; +} + +/* account chip (signed-in state of the single auth button) */ +.account-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 12px 4px 4px; + border-radius: 999px; + background: var(--bg-2); + border: 1px solid var(--border); + color: var(--text-strong); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +.account-chip:hover { + border-color: var(--accent); +} +.chip-av { + width: 22px; + height: 22px; + border-radius: 50%; + object-fit: cover; + display: inline-flex; + align-items: center; + justify-content: center; +} +.chip-av-fallback { + background: var(--accent); + color: #0d1b12; + font-size: 12px; +} +.auth-err { + color: var(--danger); + font-size: 13px; +} + /* ---------- layout ---------- */ .container { max-width: 1040px; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index b7c5a0b..c77d968 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,7 +1,6 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { Leaderboard } from "@/components/Leaderboard"; @@ -10,8 +9,6 @@ import { useEngine } from "@/lib/engineContext"; export default function Home() { const { status } = useEngine(); - const router = useRouter(); - const [addr, setAddr] = useState(""); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); @@ -79,25 +76,6 @@ export default function Home() {
- -
- Look up a player: - setAddr(e.target.value)} - placeholder="0x… wallet address" - style={{ flex: 1 }} - onKeyDown={(e) => { - if (e.key === "Enter" && addr.trim()) router.push(`/player/${addr.trim()}`); - }} - /> - -
); } diff --git a/apps/web/app/player/[address]/page.tsx b/apps/web/app/player/[address]/page.tsx index 156b472..10ea4cd 100644 --- a/apps/web/app/player/[address]/page.tsx +++ b/apps/web/app/player/[address]/page.tsx @@ -1,169 +1,14 @@ "use client"; import { useParams } from "next/navigation"; -import { useEffect, useState } from "react"; -import { shortAddress } from "@/lib/address"; -import { SERVER_HTTP } from "@/lib/config"; - -type Profile = { - address: string; - rating: number; - games: number; - wins: number; - losses: number; - draws: number; - net: string; -}; -type GameItem = { - game_id: string; - mode: string; - white: string | null; - black: string | null; - result: string | null; - reason: string | null; - stake: string | null; - moves: number; - finished_at: string | null; -}; - -const usdc = (base?: string | null) => { - if (!base) return "—"; - const n = Number(base) / 1e6; - return `${n > 0 ? "+" : ""}${n.toFixed(2)}`; -}; - -function outcome(g: GameItem, me: string): "win" | "loss" | "draw" | "-" { - if (g.result === "draw") return "draw"; - const iWhite = g.white?.toLowerCase() === me; - const iBlack = g.black?.toLowerCase() === me; - if ((iWhite && g.result === "white") || (iBlack && g.result === "black")) return "win"; - if (iWhite || iBlack) return "loss"; - return "-"; -} +import { ProfileStats } from "@/components/ProfileStats"; export default function PlayerPage() { const address = String(useParams().address).toLowerCase(); - const [p, setP] = useState(null); - const [games, setGames] = useState([]); - const [err, setErr] = useState(null); - - useEffect(() => { - let live = true; - (async () => { - try { - const [pr, gr] = await Promise.all([ - fetch(`${SERVER_HTTP}/players/${address}`).then((r) => r.json()), - fetch(`${SERVER_HTTP}/players/${address}/games`).then((r) => r.json()), - ]); - if (live) { - setP(pr); - setGames(Array.isArray(gr) ? gr : []); - } - } catch { - if (live) setErr("could not load profile — is the server running?"); - } - })(); - return () => { - live = false; - }; - }, [address]); - - const winRate = p && p.games > 0 ? Math.round((p.wins / p.games) * 100) : 0; - const netClass = p && Number(p.net) > 0 ? "pos" : p && Number(p.net) < 0 ? "neg" : ""; - return (
-
-
-
-
{shortAddress(address)}
-
- {address} -
-
-
-
-
{p ? p.rating : "…"}
-
Rating (Elo)
-
-
-
- - {err &&
{err}
} - -
-
-
{p ? p.games : "…"}
-
Games
-
-
-
{p ? `${winRate}%` : "…"}
-
Win rate
-
-
-
- {p ? ( - - {p.wins} /{" "} - {p.losses} / {p.draws} - - ) : ( - "…" - )} -
-
W / L / D
-
-
-
{p ? usdc(p.net) : "…"}
-
Net winnings (USDC)
-
-
- -
-
- Game History -
- {games.length === 0 ? ( -
No finished games yet.
- ) : ( - - - - - - - - - - - - - {games.map((g) => { - const oc = outcome(g, address); - const opp = g.white?.toLowerCase() === address ? g.black : g.white; - return ( - - - - - - - - - ); - })} - -
ModeOpponentResultStakeMovesDate
{g.mode}{shortAddress(opp, "—")} - - {oc === "win" ? "W" : oc === "loss" ? "L" : oc === "draw" ? "½" : "-"} - {" "} - {g.reason} - {g.stake ? usdc(g.stake).replace("+", "") : "—"}{g.moves} - {g.finished_at ? new Date(g.finished_at).toLocaleDateString() : "—"} -
- )} -
+
); } diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx new file mode 100644 index 0000000..f0f5f4d --- /dev/null +++ b/apps/web/app/profile/page.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useAccount } from "wagmi"; + +import { BrowserBotPanel } from "@/components/BrowserBotPanel"; +import { ConnectEngine } from "@/components/ConnectEngine"; +import { ProfileStats } from "@/components/ProfileStats"; + +export default function ProfilePage() { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return ( +
+
+

My Profile

+
+ {mounted ? : null} +
+ ); +} + +const headingStyle = { margin: "28px 0 4px", color: "var(--text-strong)", fontSize: 22 } as const; + +function ProfileClient() { + const { address, isConnected } = useAccount(); + const router = useRouter(); + const [addr, setAddr] = useState(""); + + const lookup = () => { + const a = addr.trim(); + if (a) router.push(`/player/${a}`); + }; + + return ( + <> + {isConnected && address ? ( + + ) : ( +
+ Sign in to see your profile +
+ Connect your wallet (top right) to view your rating, game history, and net winnings. +
+
+ )} + + {/* Engine: how the bot that plays your seats is configured. */} +

Engine

+

+ Set up the bot that plays your seats — a full-strength engine in your browser, or your own + engine running on your machine. +

+ +

+ Bring your own engine +

+ + + {/* Look up any player by wallet. */} +

Look up a player

+
+ Wallet address: + setAddr(e.target.value)} + placeholder="0x… wallet address" + style={{ flex: 1 }} + onKeyDown={(e) => e.key === "Enter" && lookup()} + /> + +
+ + ); +} diff --git a/apps/web/app/tournament/page.tsx b/apps/web/app/tournament/page.tsx index 171fa1e..94a64b8 100644 --- a/apps/web/app/tournament/page.tsx +++ b/apps/web/app/tournament/page.tsx @@ -4,7 +4,6 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { useAccount } from "wagmi"; -import { BankrollPanel } from "@/components/BankrollPanel"; import { SeatGame } from "@/components/SeatGame"; import { loadBotOptions, useBotStatus } from "@/lib/bot"; import { SERVER_HTTP } from "@/lib/config"; @@ -415,12 +414,6 @@ function TournamentClient() { - {wagerOn && config?.escrow && ( -
- -
- )} -
Create a tournament
diff --git a/apps/web/components/AuthButton.tsx b/apps/web/components/AuthButton.tsx new file mode 100644 index 0000000..c910594 --- /dev/null +++ b/apps/web/components/AuthButton.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAccount, useAccountEffect, useChainId, useSignMessage, useSwitchChain } from "wagmi"; + +import { authAddress, authToken, clearAuth, fetchConfig } from "@/lib/escrow"; +import { signInWithEthereum } from "@/lib/siwe"; + +/** Mount gate: the wagmi hooks live in AuthButtonInner, which only renders once + * the client-only WagmiProvider (app/providers.tsx) is in the tree. */ +export function AuthButton() { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + if (!mounted) return
; + return ; +} + +/** One button for the whole entry flow. Connecting a wallet auto-switches to the + * server's expected chain and, on a wagering server, immediately prompts the + * SIWE signature — so there's a single "Sign in", never a separate connect + + * sign-in step. The session token is bound to the wallet it was issued for and + * cleared on disconnect / account switch. */ +function AuthButtonInner() { + const { address, isConnected } = useAccount(); + const chainId = useChainId(); + const { switchChainAsync } = useSwitchChain(); + const { signMessageAsync } = useSignMessage(); + + const [expected, setExpected] = useState(null); + const [wagerOn, setWagerOn] = useState(false); + const [signedIn, setSignedIn] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Only auto-switch / auto-sign once per address, so a rejected prompt doesn't + // loop. Reset when the connected account changes. + const switchTried = useRef(null); + const signTried = useRef(null); + + useEffect(() => { + fetchConfig().then((c) => { + setExpected(c.chainId); + setWagerOn(c.wagerEnabled); + }); + }, []); + + // Recompute sign-in state from storage on account change, dropping a token + // that belongs to a different wallet. + useEffect(() => { + const key = address?.toLowerCase() ?? null; + if (key && authAddress() && authAddress() !== key) clearAuth(); + setSignedIn(!!authToken() && !!key && authAddress() === key); + switchTried.current = null; + signTried.current = null; + setError(null); + }, [address]); + + useAccountEffect({ + onDisconnect() { + clearAuth(); + setSignedIn(false); + }, + }); + + const runSignIn = useCallback(async () => { + if (!address || expected == null) return; + setError(null); + setBusy(true); + try { + if (chainId !== expected) await switchChainAsync({ chainId: expected }); + await signInWithEthereum(address, expected, (a) => signMessageAsync(a)); + setSignedIn(true); + } catch (e: any) { + setError(e?.shortMessage ?? e?.message ?? "sign-in failed"); + } finally { + setBusy(false); + } + }, [address, chainId, expected, signMessageAsync, switchChainAsync]); + + const ready = isConnected && !!address && expected != null; + + // Auto-switch to the expected chain while completing sign-in. + useEffect(() => { + if (!ready || !wagerOn || signedIn) return; + if (chainId === expected) return; + const key = address!.toLowerCase(); + if (switchTried.current === key) return; + switchTried.current = key; + switchChainAsync({ chainId: expected! }).catch(() => {}); + }, [ready, wagerOn, signedIn, chainId, expected, address, switchChainAsync]); + + // Auto-prompt the SIWE signature once the chain is right. + useEffect(() => { + if (!ready || !wagerOn || signedIn || busy) return; + if (chainId !== expected) return; + const key = address!.toLowerCase(); + if (signTried.current === key) return; + signTried.current = key; + runSignIn(); + }, [ready, wagerOn, signedIn, busy, chainId, expected, address, runSignIn]); + + return ( + + {({ account, chain, openAccountModal, openConnectModal, mounted: rkMounted }) => { + if (!rkMounted) return
; + + if (!account || !chain) { + return ( + + ); + } + + // Connected but the wagering session isn't established yet. + if (wagerOn && !signedIn) { + return ( + + {error && {error}} + + + ); + } + + // Signed in (or a casual server that needs no signature) → account chip. + return ( + + ); + }} + + ); +} diff --git a/apps/web/components/ConnectEngine.tsx b/apps/web/components/ConnectEngine.tsx new file mode 100644 index 0000000..4a03d50 --- /dev/null +++ b/apps/web/components/ConnectEngine.tsx @@ -0,0 +1,297 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; + +import { loadBotOptions, saveBotOptions, useBotStatus } from "@/lib/bot"; +import { GITHUB_REPO, SERVER_HTTP } from "@/lib/config"; +import { authToken } from "@/lib/escrow"; + +/** Prebuilt client binaries published by .github/workflows/release.yml — + * artifact names there are load-bearing for these URLs (the workflow's + * publish job asserts they exist before releasing). */ +const RELEASES = `${GITHUB_REPO}/releases/latest/download`; +const DOWNLOADS = [ + { key: "macos-arm64", label: "macOS (Apple Silicon)", file: "chess-client-macos-arm64.tar.gz" }, + { key: "macos-x64", label: "macOS (Intel)", file: "chess-client-macos-x64.tar.gz" }, + { key: "linux-x64", label: "Linux (x64)", file: "chess-client-linux-x64.tar.gz" }, + { key: "windows-x64", label: "Windows (x64)", file: "chess-client-windows-x64.zip" }, +] as const; + +/** Best-effort platform guess for the primary download button. Browsers hide + * Apple Silicon vs Intel, so default Macs to arm64 (the common case) and + * list every platform below it. iOS UAs contain "like Mac OS X" and Android + * contains "Linux", so mobile must be detected FIRST — those visitors get a + * "runs on desktop" note instead of a binary they can't execute. */ +function guessPlatform(): (typeof DOWNLOADS)[number]["key"] | "mobile" { + const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; + if (/iPhone|iPad|iPod|Android|Mobile/i.test(ua)) return "mobile"; + if (/Windows/i.test(ua)) return "windows-x64"; + if (/Mac/i.test(ua)) return "macos-arm64"; + return "linux-x64"; +} + +/** "Connect your engine": pair a UCI engine running on your machine with your + * wallet, once. After that the website is the remote control — start or join + * games in the lobby and your bot plays them. Rendered inside the profile's + * Engine section and on the standalone /connect page. */ +export function ConnectEngine() { + const [token, setToken] = useState(null); + const [code, setCode] = useState(null); + const [codeErr, setCodeErr] = useState(null); + const [enginePath, setEnginePath] = useState("stockfish"); + const [bookPath, setBookPath] = useState(""); + const [name, setName] = useState(""); + const [copied, setCopied] = useState(false); + const [opts, setOpts] = useState>({}); + const [platform, setPlatform] = + useState<(typeof DOWNLOADS)[number]["key"] | "mobile">("macos-arm64"); + + useEffect(() => { + setToken(authToken()); + setOpts(loadBotOptions()); + setPlatform(guessPlatform()); + }, []); + + // Signed in → mint the pairing code automatically (single-use, 10 min). + useEffect(() => { + if (!token || code) return; + (async () => { + try { + const r = await fetch(`${SERVER_HTTP}/auth/link`, { + method: "POST", + headers: { authorization: `Bearer ${token}` }, + }); + if (!r.ok) return setCodeErr("Couldn't create a pairing code — try signing in again."); + setCode((await r.json()).code); + } catch { + setCodeErr("Server unreachable."); + } + })(); + }, [token, code]); + + // Live status: flips to "online" the moment the client connects (poll fast). + const bot = useBotStatus(token, 3000); + + const isWindows = platform === "windows-x64"; + const command = useMemo(() => { + // PowerShell needs the .\ prefix to run a CWD executable, and neither + // PowerShell nor cmd accept bash's backslash line continuations — so the + // Windows command is a single line. + const bin = isWindows ? ".\\chess-client.exe" : "./chess-client"; + const parts = [ + `${bin} connect`, + `--server ${SERVER_HTTP}`, + `--engine ${enginePath || "stockfish"}`, + ]; + if (bookPath.trim()) parts.push(`--book ${bookPath.trim()}`); + if (name.trim()) parts.push(`--name "${name.trim().replace(/"/g, "")}"`); + parts.push(`--code ${code ?? ""}`); + return parts.join(isWindows ? " " : " \\\n "); + }, [enginePath, bookPath, name, code, isWindows]); + + const copy = async () => { + try { + await navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable */ + } + }; + + const setOpt = (k: string, v: string) => { + const next = { ...opts, [k]: v }; + if (!v.trim()) delete next[k]; + setOpts(next); + saveBotOptions(next); + }; + + return ( + <> +

+ Run any UCI engine — and optionally a Polyglot opening book — on your own computer{" "} + and pair it with your wallet, once. After that this site is the remote control: start or + join games in the lobby and your bot plays them. The server stays + the referee — legality, clocks and results are decided server-side; your machine only + picks moves. +

+ + {bot.online ? ( +
+ + ✓ {bot.name ?? bot.engine} is online{bot.busy ? " — playing right now" : ""} + +

+ Engine: {bot.engine}. Head to the lobby, pick a time control or + join an open challenge — your bot plays the seat while you watch live. +

+ + {bot.options.length > 0 && ( +
+ + Engine settings ({bot.options.length} options) — applied to every game your bot + plays + +
+ {bot.options + .filter((o) => o.kind !== "button") + .map((o) => ( + + ))} +
+

+ Blank = engine default. Saved in this browser and sent with each game (e.g. + Threads, Hash, Skill Level). +

+
+ )} +
+ ) : ( + <> +
+ 1 · Get the client +

+ A single small binary that drives any UCI engine — Stockfish, Lc0, or your own. No + Rust toolchain needed. +

+ {(() => { + const dl = DOWNLOADS.find((d) => d.key === platform); + return dl ? ( +

+ + ⬇ Download for {dl.label} + +

+ ) : ( +

+ 📱 The client runs on a desktop or server — grab the right build from your + computer: +

+ ); + })()} +
+ {platform === "mobile" ? "Downloads:" : "Other platforms:"}{" "} + {DOWNLOADS.filter((d) => d.key !== platform).map((d, i) => ( + + {i > 0 && " · "} + {d.label} + + ))} +
+
+              {isWindows
+                ? "# unzip, then run from a terminal in that folder"
+                : "tar -xzf chess-client-*.tar.gz   # then run ./chess-client from that folder"}
+            
+

+ You also need a UCI engine on your machine —{" "} + {isWindows ? ( + <> + download Stockfish from + stockfishchess.org + {" "} + and point --engine at the unzipped .exe + + ) : ( + <> + e.g. brew install stockfish (macOS) or{" "} + apt install stockfish (Linux), or point --engine at + any engine binary + + )} + . +

+

+ If the download 404s, the first release hasn't been cut yet — build from source + below. If macOS blocks the app ("developer cannot be verified"), run{" "} + xattr -d com.apple.quarantine chess-client or right-click → Open once. +

+
+ + Prefer building from source? + +
+                {`git clone ${GITHUB_REPO} && cd openchess\ncargo build --release -p byo-client\n# binary: ./target/release/chess-client`}
+              
+
+
+ +
+ 2 · Run it + {!token && ( +

+ Sign in with your wallet first (top right) — bots are wallet-bound, so your + games count toward your profile and can carry stakes. A single-use pairing code is + added to the command automatically. +

+ )} +
+ + + +
+
{command}
+ + {codeErr &&
{codeErr}
} + {token && ( +

+ Waiting for your bot to connect… this page updates the moment it's online. The + code is single-use and expires in 10 minutes; reload for a fresh one. Headless + bots can use OPENCHESS_WALLET_KEY instead, and{" "} + --auto makes the bot matchmake unattended. +

+ )} +
+ + )} + + ); +} diff --git a/apps/web/components/Header.tsx b/apps/web/components/Header.tsx index 3060750..8ff55f7 100644 --- a/apps/web/components/Header.tsx +++ b/apps/web/components/Header.tsx @@ -3,9 +3,8 @@ import Link from "next/link"; import { useEngine } from "@/lib/engineContext"; -import { ProfileLink } from "./ProfileLink"; -import { SignIn } from "./SignIn"; -import { WalletButton } from "./WalletButton"; +import { AuthButton } from "./AuthButton"; +import { WalletMenu } from "./WalletMenu"; export function Header() { const { status } = useEngine(); @@ -25,15 +24,14 @@ export function Header() {
{label} - - + +
); diff --git a/apps/web/components/Lobby.tsx b/apps/web/components/Lobby.tsx index 419f4c1..fb06ab4 100644 --- a/apps/web/components/Lobby.tsx +++ b/apps/web/components/Lobby.tsx @@ -5,8 +5,6 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useAccount } from "wagmi"; -import { BankrollPanel } from "@/components/BankrollPanel"; -import { BrowserBotPanel } from "@/components/BrowserBotPanel"; import { SeatGame } from "@/components/SeatGame"; import { shortAddress } from "@/lib/address"; import { loadBotOptions, useBotStatus } from "@/lib/bot"; @@ -345,7 +343,7 @@ export function Lobby() { <> {" "} Want your own engine to play instead?{" "} - Connect it. + Connect it. )}
@@ -503,15 +501,6 @@ export function Lobby() { )}
- {/* Personalize the in-browser bot (name / opening book). Hidden - only while the native connected bot is the chosen seat driver. */} - {!botPlays && } - - {/* Optional: deposit for staked play */} - {wagerOn && config?.escrow && ( - - )} - {/* Stake modal (opens after picking a time control) */} {pickTc && (
setPickTc(null)}> diff --git a/apps/web/components/ProfileLink.tsx b/apps/web/components/ProfileLink.tsx deleted file mode 100644 index 336e341..0000000 --- a/apps/web/components/ProfileLink.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useEffect, useState } from "react"; -import { useAccount } from "wagmi"; - -/** "My Profile" link, shown once a wallet is connected. The wagmi hook lives in - * the inner component so it only runs client-side (inside WagmiProvider). */ -export function ProfileLink() { - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); - if (!mounted) return null; - return ; -} - -function ProfileLinkInner() { - const { address, isConnected } = useAccount(); - if (!isConnected || !address) return null; - return ( - - My Profile - - ); -} diff --git a/apps/web/components/ProfileStats.tsx b/apps/web/components/ProfileStats.tsx new file mode 100644 index 0000000..f2b3957 --- /dev/null +++ b/apps/web/components/ProfileStats.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { shortAddress } from "@/lib/address"; +import { SERVER_HTTP } from "@/lib/config"; + +type Profile = { + address: string; + rating: number; + games: number; + wins: number; + losses: number; + draws: number; + net: string; +}; +type GameItem = { + game_id: string; + mode: string; + white: string | null; + black: string | null; + result: string | null; + reason: string | null; + stake: string | null; + moves: number; + finished_at: string | null; +}; + +const usdc = (base?: string | null) => { + if (!base) return "—"; + const n = Number(base) / 1e6; + return `${n > 0 ? "+" : ""}${n.toFixed(2)}`; +}; + +function outcome(g: GameItem, me: string): "win" | "loss" | "draw" | "-" { + if (g.result === "draw") return "draw"; + const iWhite = g.white?.toLowerCase() === me; + const iBlack = g.black?.toLowerCase() === me; + if ((iWhite && g.result === "white") || (iBlack && g.result === "black")) return "win"; + if (iWhite || iBlack) return "loss"; + return "-"; +} + +/** Public rating + record + game history for a wallet. Rendered by the public + * /player/[address] page and by the signed-in /profile hub. */ +export function ProfileStats({ address }: { address: string }) { + const me = address.toLowerCase(); + const [p, setP] = useState(null); + const [games, setGames] = useState([]); + const [err, setErr] = useState(null); + + useEffect(() => { + let live = true; + (async () => { + try { + const [pr, gr] = await Promise.all([ + fetch(`${SERVER_HTTP}/players/${me}`).then((r) => r.json()), + fetch(`${SERVER_HTTP}/players/${me}/games`).then((r) => r.json()), + ]); + if (live) { + setP(pr); + setGames(Array.isArray(gr) ? gr : []); + } + } catch { + if (live) setErr("could not load profile — is the server running?"); + } + })(); + return () => { + live = false; + }; + }, [me]); + + const winRate = p && p.games > 0 ? Math.round((p.wins / p.games) * 100) : 0; + const netClass = p && Number(p.net) > 0 ? "pos" : p && Number(p.net) < 0 ? "neg" : ""; + + return ( + <> +
+
+
+
{shortAddress(me)}
+
+ {me} +
+
+
+
+
{p ? p.rating : "…"}
+
Rating (Elo)
+
+
+
+ + {err &&
{err}
} + +
+
+
{p ? p.games : "…"}
+
Games
+
+
+
{p ? `${winRate}%` : "…"}
+
Win rate
+
+
+
+ {p ? ( + + {p.wins} /{" "} + {p.losses} / {p.draws} + + ) : ( + "…" + )} +
+
W / L / D
+
+
+
{p ? usdc(p.net) : "…"}
+
Net winnings (USDC)
+
+
+ +
+
+ Game History +
+ {games.length === 0 ? ( +
No finished games yet.
+ ) : ( + + + + + + + + + + + + + {games.map((g) => { + const oc = outcome(g, me); + const opp = g.white?.toLowerCase() === me ? g.black : g.white; + return ( + + + + + + + + + ); + })} + +
ModeOpponentResultStakeMovesDate
{g.mode}{shortAddress(opp, "—")} + + {oc === "win" ? "W" : oc === "loss" ? "L" : oc === "draw" ? "½" : "-"} + {" "} + {g.reason} + {g.stake ? usdc(g.stake).replace("+", "") : "—"}{g.moves} + {g.finished_at ? new Date(g.finished_at).toLocaleDateString() : "—"} +
+ )} +
+ + ); +} diff --git a/apps/web/components/SignIn.tsx b/apps/web/components/SignIn.tsx deleted file mode 100644 index 5348ff5..0000000 --- a/apps/web/components/SignIn.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useAccount, useChainId, useSignMessage } from "wagmi"; - -import { fetchConfig } from "@/lib/escrow"; -import { signInWithEthereum } from "@/lib/siwe"; - -/** Mount gate: the wagmi hooks live in SignInInner, which only renders on the - * client where the (client-only) WagmiProvider is present. */ -export function SignIn() { - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); - if (!mounted) return null; - return ; -} - -function SignInInner() { - const { address, isConnected } = useAccount(); - const chainId = useChainId(); - const { signMessageAsync } = useSignMessage(); - const [signedIn, setSignedIn] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [wagerOn, setWagerOn] = useState(false); - - useEffect(() => { - setSignedIn(!!localStorage.getItem("chess_token")); - fetchConfig().then((c) => setWagerOn(c.wagerEnabled)); - }, []); - - if (!isConnected || !address) return null; - // Sign-in (SIWE) is only needed for wagered play. On a casual-only server it's - // pure noise, so hide it unless wagering is enabled. - if (!wagerOn) return null; - if (signedIn) return signed in ✓; - - return ( - - {error && {error}} - - - ); -} diff --git a/apps/web/components/WalletButton.tsx b/apps/web/components/WalletButton.tsx deleted file mode 100644 index 013ba79..0000000 --- a/apps/web/components/WalletButton.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; - -import { ConnectButton } from "@rainbow-me/rainbowkit"; -import { useEffect, useState } from "react"; - -export function WalletButton() { - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); - // Render nothing until mounted so ConnectButton only appears once the - // client-only WagmiProvider (see app/providers.tsx) is in the tree. - if (!mounted) return
; - return ; -} diff --git a/apps/web/components/WalletMenu.tsx b/apps/web/components/WalletMenu.tsx new file mode 100644 index 0000000..a386eaf --- /dev/null +++ b/apps/web/components/WalletMenu.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useAccount } from "wagmi"; + +import { BankrollPanel } from "@/components/BankrollPanel"; +import { fetchConfig, fmtUsdc, type OnchainConfig } from "@/lib/escrow"; +import { useAvailable } from "@/lib/useBankroll"; + +/** Mount gate: the wagmi hook lives in WalletMenuInner so it only runs once the + * client-only WagmiProvider (app/providers.tsx) is in the tree. */ +export function WalletMenu() { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + if (!mounted) return null; + return ; +} + +/** Top-right bankroll widget: a balance pill you refill. Clicking it opens the + * deposit / withdraw popover (the existing BankrollPanel). Only shown on a + * wagering server once a wallet is connected — the funds live in escrow. */ +function WalletMenuInner() { + const { isConnected } = useAccount(); + const [config, setConfig] = useState(null); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + fetchConfig().then(setConfig); + }, []); + + // Close the popover on outside click or Escape. + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const { available } = useAvailable(config?.escrow); + + const wagerOn = !!config?.wagerEnabled && !!config?.escrow; + if (!isConnected || !wagerOn || !config?.escrow) return null; + + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/lib/escrow.ts b/apps/web/lib/escrow.ts index 357de67..75911ad 100644 --- a/apps/web/lib/escrow.ts +++ b/apps/web/lib/escrow.ts @@ -56,6 +56,19 @@ export function authToken(): string | null { return localStorage.getItem("chess_token"); } +/** The wallet address the stored session was issued for (lowercased), or null. */ +export function authAddress(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem("chess_addr"); +} + +/** Drop the stored SIWE session (on disconnect or account switch). */ +export function clearAuth() { + if (typeof window === "undefined") return; + localStorage.removeItem("chess_token"); + localStorage.removeItem("chess_addr"); +} + /** USDC display string (base units → "1.50"). */ export function fmtUsdc(base: bigint | string | number | undefined | null): string { if (base === undefined || base === null) return "—"; diff --git a/apps/web/lib/siwe.ts b/apps/web/lib/siwe.ts index 406e2ce..1684d6f 100644 --- a/apps/web/lib/siwe.ts +++ b/apps/web/lib/siwe.ts @@ -33,5 +33,8 @@ export async function signInWithEthereum( if (!resp.ok) throw new Error(`sign-in failed (${resp.status})`); const { token } = await resp.json(); localStorage.setItem("chess_token", token); + // Bind the session to the wallet it was issued for, so a disconnect or account + // switch can invalidate a stale token (see clearAuth / authAddress in escrow.ts). + localStorage.setItem("chess_addr", address.toLowerCase()); return token; } From 1d9503e6f0651d623bc5058f1e2c1d59f1bfc82f Mon Sep 17 00:00:00 2001 From: vikramarun Date: Sun, 12 Jul 2026 02:08:07 -0400 Subject: [PATCH 02/10] Perf: rewrite leaderboard query as GROUP BY join + wallet indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leaderboard ran a correlated COUNT(*) per user over the whole games table — O(users × games) — with lower() on unindexed wallet columns. Rewrite it to count finished rated games per wallet in a single GROUP BY pass, then join the counts back to users: O(users + games). EXPLAIN confirms one hash join over a single grouped scan, no per-user subquery. COUNT(DISTINCT id) preserves the old OR semantics (a hypothetical self-play game counts once) and the inner join makes the "at least one game" filter implicit. Add functional lower(wallet) indexes (migration 0007) on games' white/black wallet columns and users.wallet, matching the lower() predicates used across the leaderboard join and the per-address profile queries (player_stats / player_games / player_rating / update_ratings). Add a DATABASE_URL-gated test covering case-insensitive count folding, exclusion of players with no finished games, pending games not counting, and Elo-driven ordering. Co-Authored-By: Claude Opus 4.8 --- .../migrations/0007_wallet_lower_indexes.sql | 8 ++ crates/persistence/src/lib.rs | 111 ++++++++++++++++-- 2 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 crates/persistence/migrations/0007_wallet_lower_indexes.sql diff --git a/crates/persistence/migrations/0007_wallet_lower_indexes.sql b/crates/persistence/migrations/0007_wallet_lower_indexes.sql new file mode 100644 index 0000000..77aa0df --- /dev/null +++ b/crates/persistence/migrations/0007_wallet_lower_indexes.sql @@ -0,0 +1,8 @@ +-- Wallet lookups across the app match case-insensitively via lower(wallet) +-- (the leaderboard GROUP BY join, per-address profile stats/history, rating +-- updates), but the only wallet indexes were on the raw values — unusable for a +-- lower() predicate. Add functional (lowercased) indexes so those queries hit an +-- index instead of scanning. +CREATE INDEX IF NOT EXISTS games_white_wallet_lower_idx ON games (lower(white_wallet)); +CREATE INDEX IF NOT EXISTS games_black_wallet_lower_idx ON games (lower(black_wallet)); +CREATE INDEX IF NOT EXISTS users_wallet_lower_idx ON users (lower(wallet)); diff --git a/crates/persistence/src/lib.rs b/crates/persistence/src/lib.rs index 17cfa9b..b4b1e24 100644 --- a/crates/persistence/src/lib.rs +++ b/crates/persistence/src/lib.rs @@ -594,19 +594,29 @@ impl Db { /// sitting at the default 1500 don't pad the board. Powers the lobby /// leaderboard. pub async fn leaderboard(&self, limit: i64) -> Result> { + // Count finished rated games per wallet in a single GROUP BY pass over + // `games` (each qualifying game contributes one row per side), then join + // the counts back to `users`. This is O(users + games) — the old form ran + // a correlated COUNT(*) per user (O(users × games)). COUNT(DISTINCT id) + // keeps a hypothetical self-play game (white == black) counted once, and + // the inner join makes the "at least one game" filter implicit. let rows = sqlx::query_as::<_, LeaderboardRow>( - r#"SELECT wallet, rating, games FROM ( - SELECT u.wallet AS wallet, u.rating AS rating, - (SELECT COUNT(*) FROM games g - WHERE g.status='finished' AND g.result IS NOT NULL - AND g.white_wallet IS NOT NULL AND g.black_wallet IS NOT NULL - AND (lower(g.white_wallet)=lower(u.wallet) - OR lower(g.black_wallet)=lower(u.wallet)) - ) AS games - FROM users u - ) t - WHERE games > 0 - ORDER BY rating DESC, games DESC + r#"SELECT u.wallet AS wallet, u.rating AS rating, gc.games AS games + FROM users u + JOIN ( + SELECT wallet, COUNT(DISTINCT game_id) AS games + FROM ( + SELECT id AS game_id, lower(white_wallet) AS wallet FROM games + WHERE status='finished' AND result IS NOT NULL + AND white_wallet IS NOT NULL AND black_wallet IS NOT NULL + UNION ALL + SELECT id AS game_id, lower(black_wallet) AS wallet FROM games + WHERE status='finished' AND result IS NOT NULL + AND white_wallet IS NOT NULL AND black_wallet IS NOT NULL + ) sides + GROUP BY wallet + ) gc ON gc.wallet = lower(u.wallet) + ORDER BY u.rating DESC, gc.games DESC LIMIT $1"#, ) .bind(limit) @@ -720,4 +730,81 @@ mod tests { assert_eq!(g.pgn.as_deref(), Some("1. e4 e5")); Ok(()) } + + // Runs only when DATABASE_URL is set (local Postgres). + #[tokio::test] + async fn leaderboard_counts_and_ordering() -> Result<()> { + let Ok(url) = std::env::var("DATABASE_URL") else { + eprintln!("skipping: DATABASE_URL not set"); + return Ok(()); + }; + let db = Db::connect(&url).await?; + db.migrate().await?; + + // Unique wallets per run so the assertions don't collide with other data. + let tag = Uuid::new_v4().simple().to_string(); + let alice = format!("0xA_{tag}"); // mixed case on purpose (see below) + let bob = format!("0xb_{tag}"); + let carol = format!("0xc_{tag}"); // signs in but never finishes a game + + db.upsert_user(&alice).await?; + db.upsert_user(&bob).await?; + db.upsert_user(&carol).await?; + + // Two finished games between alice and bob, alice winning both (so her + // Elo ends above bob's — deterministic ordering, no test-only setter). + // The second game stores alice's wallet lowercased, so the case-insensitive + // count must fold the two together. + let finish = |white: String, black: String, result: &'static str| { + let db = db.clone(); + async move { + let id = Uuid::new_v4(); + db.create_game( + id, + "park", + Some(&white), + Some(&black), + Tc { initial_ms: 60000, increment_ms: 1000 }, + None, + ) + .await?; + db.set_game_active(id).await?; + db.finish_game(id, result, "checkmate", "hash", "1. e4 e5").await?; + db.update_ratings(id).await?; + Ok::<_, anyhow::Error>(()) + } + }; + finish(alice.clone(), bob.clone(), "white").await?; // alice (white) wins + finish(bob.clone(), alice.to_lowercase(), "black").await?; // alice (black) wins + + // A pending (unfinished) game must not count. + let pending = Uuid::new_v4(); + db.create_game( + pending, + "park", + Some(&alice), + Some(&bob), + Tc { initial_ms: 60000, increment_ms: 1000 }, + None, + ) + .await?; + + let board = db.leaderboard(100).await?; + let get = |addr: &str| { + let addr = addr.to_lowercase(); + board.iter().find(move |r| r.wallet.to_lowercase() == addr) + }; + + let a = get(&alice).expect("alice on the board"); + let b = get(&bob).expect("bob on the board"); + assert_eq!(a.games, 2, "both finished games count for alice (case-folded)"); + assert_eq!(b.games, 2, "both finished games count for bob"); + assert!(get(&carol).is_none(), "no finished games => not on the board"); + + // Ordered by rating desc: alice (won both -> higher Elo) before bob. + let ai = board.iter().position(|r| r.wallet.to_lowercase() == alice.to_lowercase()); + let bi = board.iter().position(|r| r.wallet.to_lowercase() == bob.to_lowercase()); + assert!(ai < bi, "higher rating ranks first"); + Ok(()) + } } From 0fe2bb54d7181ce1d18ef8a924bd93924cc13cdd Mon Sep 17 00:00:00 2001 From: vikramarun Date: Sun, 12 Jul 2026 02:08:18 -0400 Subject: [PATCH 03/10] Feat: in-app tournament payout claim + refund UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Winners of a root-settled (large) tournament and entrants of an abandoned one had no in-app way to collect — they'd have to call the escrow contract directly. Wire the buttons to ChessEscrow: - TournamentClaim component reads on-chain state for the connected wallet and shows the right action or nothing: a Merkle "Claim payout" (fetches the proof from GET /tournaments/{id}/claim/{address}, then claimTournament), a "Claim refund" past settleTimeout (claimRefund), or an idle "claimed ✓" / "refund available in ~Nh" state. Renders nothing for non-participants, disconnected wallets, or direct-settled fields (already credited to bankroll). - escrow.ts: extend ESCROW_ABI with claimTournament/claimRefund and the tournaments/tournamentClaimed/tournamentEntered/settleTimeout getters; add tidToBytes32 (matches the server's game_id_to_bytes32) and a fetchClaimProof helper. - Surface the widget in the lobby card and both finished play views; claims credit the escrow bankroll, withdrawn via the Bankroll panel. Mirrors the existing BankrollPanel write pattern (writeContractAsync → waitForTransactionReceipt, chain guard, shortMessage errors). Co-Authored-By: Claude Opus 4.8 --- apps/web/app/tournament/page.tsx | 44 ++++- apps/web/components/TournamentClaim.tsx | 210 ++++++++++++++++++++++++ apps/web/lib/escrow.ts | 36 +++- 3 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 apps/web/components/TournamentClaim.tsx diff --git a/apps/web/app/tournament/page.tsx b/apps/web/app/tournament/page.tsx index 171fa1e..f0e3f74 100644 --- a/apps/web/app/tournament/page.tsx +++ b/apps/web/app/tournament/page.tsx @@ -6,6 +6,7 @@ import { useAccount } from "wagmi"; import { BankrollPanel } from "@/components/BankrollPanel"; import { SeatGame } from "@/components/SeatGame"; +import { TournamentClaim } from "@/components/TournamentClaim"; import { loadBotOptions, useBotStatus } from "@/lib/bot"; import { SERVER_HTTP } from "@/lib/config"; import { authToken, fetchConfig, fmtUsdc, parseUsdc, type OnchainConfig } from "@/lib/escrow"; @@ -122,7 +123,7 @@ function TournamentClient() { }; }, [playingTid]); - const { available } = useAvailable(config?.escrow); + const { available, refetch: refetchAvailable } = useAvailable(config?.escrow); const wagerOn = !!config?.wagerEnabled && !!config?.escrow; const identityIn = (t: Tourney): string | null => { @@ -350,6 +351,17 @@ function TournamentClient() {

Standings decide the pool; a winning share is credited to your bankroll.

+ {wagerOn && config?.escrow && activeT.buy_in && ( +
+ +
+ )} {backBtn} ) : ( @@ -389,10 +401,20 @@ function TournamentClient() { <> You’ve finished your games 🎉

- Standings are tallied as every pairing completes. A winning share of the pool is - credited to your bankroll (large fields settle a Merkle root — claim from your - profile). + Standings are tallied as every pairing completes. Small fields credit your winning + share to your bankroll directly; large fields settle a Merkle root you claim below.

+ {wagerOn && config?.escrow && activeT.buy_in && ( +
+ +
+ )} ) : (

Waiting for your next round to start…

@@ -529,6 +551,20 @@ function TournamentClient() { {t.status !== "open" && joined && mine.length === 0 && ( no games )} + {wagerOn && + config?.escrow && + t.buy_in && + (t.status === "settled" || + t.status === "complete" || + t.status === "abandoned") && ( + + )}
); diff --git a/apps/web/components/TournamentClaim.tsx b/apps/web/components/TournamentClaim.tsx new file mode 100644 index 0000000..212ec3f --- /dev/null +++ b/apps/web/components/TournamentClaim.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + useAccount, + useChainId, + usePublicClient, + useReadContract, + useSwitchChain, + useWriteContract, +} from "wagmi"; + +import { ESCROW_ABI, fetchClaimProof, fmtUsdc, tidToBytes32, type ClaimProof } from "@/lib/escrow"; + +const ZERO32 = `0x${"0".repeat(64)}`; + +/** Collect a tournament's on-chain proceeds for the connected wallet: a Merkle + * payout claim for a root-settled field, or a buy-in refund for one that never + * settled past the timeout. Both credit the wallet's escrow bankroll (withdraw + * via the Bankroll panel). Renders nothing unless the wallet actually entered + * this tournament and has something to do — safe to drop on any finished card. */ +export function TournamentClaim({ + tid, + status, + escrow, + chainId: expected, + onClaimed, +}: { + tid: string; + status: string; + escrow: `0x${string}`; + chainId: number; + onClaimed?: () => void; +}) { + const { address, isConnected } = useAccount(); + const chainId = useChainId(); + const { switchChainAsync } = useSwitchChain(); + const publicClient = usePublicClient(); + const { writeContractAsync } = useWriteContract(); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [proof, setProof] = useState(null); + + const tidHex = tidToBytes32(tid); + const enabled = !!address && isConnected; + const poll = { query: { enabled, refetchInterval: 8000 } } as const; + + const { data: tourn, refetch: refetchTourn } = useReadContract({ + address: escrow, + abi: ESCROW_ABI, + functionName: "tournaments", + args: [tidHex], + ...poll, + }); + const { data: claimed, refetch: refetchClaimed } = useReadContract({ + address: escrow, + abi: ESCROW_ABI, + functionName: "tournamentClaimed", + args: address ? [tidHex, address] : undefined, + ...poll, + }); + const { data: entered } = useReadContract({ + address: escrow, + abi: ESCROW_ABI, + functionName: "tournamentEntered", + args: address ? [tidHex, address] : undefined, + query: { enabled }, + }); + const { data: timeout } = useReadContract({ + address: escrow, + abi: ESCROW_ABI, + functionName: "settleTimeout", + query: { enabled }, + }); + + // tournaments() → [buyIn, pool, claimedAmount, entrants, openedAt, settled, payoutRoot, exists] + const t = tourn as + | readonly [bigint, bigint, bigint, number, bigint, boolean, `0x${string}`, boolean] + | undefined; + const exists = t?.[7] ?? false; + const settled = t?.[5] ?? false; + const openedAt = t ? Number(t[4]) : 0; + const buyIn = t?.[0] ?? 0n; + const payoutRoot = t?.[6]; + const rootSet = !!payoutRoot && payoutRoot !== ZERO32; + const hasClaimed = claimed === true; + const hasEntered = entered === true; + const settleTimeout = timeout != null ? Number(timeout) : null; + + // Root-settled + unclaimed → ask the server whether this wallet is a winner + // (404 = not a winner / not root-settled, so `proof` stays null and no button). + useEffect(() => { + if (!address || !rootSet || hasClaimed) { + setProof(null); + return; + } + let live = true; + fetchClaimProof(tid, address).then((p) => { + if (live) setProof(p); + }); + return () => { + live = false; + }; + }, [tid, address, rootSet, hasClaimed]); + + if (!enabled || !exists || !hasEntered) return null; + + const now = Math.floor(Date.now() / 1000); + const refundReady = + !settled && settleTimeout != null && now > openedAt + settleTimeout && !hasClaimed; + + const ensureChain = async () => { + if (chainId !== expected) await switchChainAsync({ chainId: expected }); + }; + + const run = async (fn: () => Promise<`0x${string}`>) => { + setError(null); + setBusy(true); + try { + await ensureChain(); + const hash = await fn(); + await publicClient!.waitForTransactionReceipt({ hash }); + refetchTourn(); + refetchClaimed(); + onClaimed?.(); + } catch (e: any) { + setError(e?.shortMessage ?? e?.message ?? "transaction failed"); + } finally { + setBusy(false); + } + }; + + const doClaim = () => + proof && + address && + run(() => + writeContractAsync({ + address: escrow, + abi: ESCROW_ABI, + functionName: "claimTournament", + args: [tidHex, address, proof.amount, proof.proof], + }), + ); + + const doRefund = () => + address && + run(() => + writeContractAsync({ + address: escrow, + abi: ESCROW_ABI, + functionName: "claimRefund", + args: [tidHex, address], + }), + ); + + const errLine = error ? {error} : null; + const wrap = (node: React.ReactNode) => ( + + {node} + {errLine} + + ); + + if (hasClaimed) { + return ( + + {rootSet ? "Payout claimed ✓" : "Refund claimed ✓"} + + ); + } + + // Winner of a root-settled field → Merkle claim. + if (rootSet && proof) { + return wrap( + , + ); + } + + // Never settled past the timeout → reclaim the buy-in. + if (refundReady) { + return wrap( + , + ); + } + + // Abandoned but the refund window hasn't opened yet — tell the entrant when. + if (status === "abandoned" && !settled && settleTimeout != null) { + const left = openedAt + settleTimeout - now; + const dur = + left <= 0 + ? "soon" + : left >= 86400 + ? `~${Math.ceil(left / 86400)}d` + : left >= 3600 + ? `~${Math.ceil(left / 3600)}h` + : `~${Math.max(1, Math.ceil(left / 60))}m`; + return ( + + Refund available in {dur} + + ); + } + + return null; +} diff --git a/apps/web/lib/escrow.ts b/apps/web/lib/escrow.ts index 357de67..fdd7bd9 100644 --- a/apps/web/lib/escrow.ts +++ b/apps/web/lib/escrow.ts @@ -8,7 +8,8 @@ import { SERVER_HTTP } from "./config"; export const USDC_DECIMALS = 6; -/** Minimal ChessEscrow ABI — only the user-facing bankroll functions. */ +/** Minimal ChessEscrow ABI — the user-facing bankroll + tournament claim + * functions and the getters the claim UI reads. */ export const ESCROW_ABI = [ { type: "function", name: "deposit", stateMutability: "nonpayable", inputs: [{ name: "amount", type: "uint256" }], outputs: [] }, { type: "function", name: "withdraw", stateMutability: "nonpayable", inputs: [{ name: "amount", type: "uint256" }], outputs: [] }, @@ -16,6 +17,15 @@ export const ESCROW_ABI = [ { type: "function", name: "bankroll", stateMutability: "view", inputs: [{ name: "", type: "address" }], outputs: [{ type: "uint256" }] }, { type: "function", name: "locked", stateMutability: "view", inputs: [{ name: "", type: "address" }], outputs: [{ type: "uint256" }] }, { type: "function", name: "token", stateMutability: "view", inputs: [], outputs: [{ type: "address" }] }, + // Tournament payout (root-settled fields) — winner claims their signed share. + { type: "function", name: "claimTournament", stateMutability: "nonpayable", inputs: [{ name: "tid", type: "bytes32" }, { name: "account", type: "address" }, { name: "amount", type: "uint256" }, { name: "proof", type: "bytes32[]" }], outputs: [] }, + // Reclaim a buy-in from a tournament that never settled past the timeout. + { type: "function", name: "claimRefund", stateMutability: "nonpayable", inputs: [{ name: "tid", type: "bytes32" }, { name: "account", type: "address" }], outputs: [] }, + // tournaments(tid) → (buyIn, pool, claimedAmount, entrants, openedAt, settled, payoutRoot, exists) + { type: "function", name: "tournaments", stateMutability: "view", inputs: [{ name: "", type: "bytes32" }], outputs: [{ name: "buyIn", type: "uint256" }, { name: "pool", type: "uint256" }, { name: "claimedAmount", type: "uint256" }, { name: "entrants", type: "uint32" }, { name: "openedAt", type: "uint64" }, { name: "settled", type: "bool" }, { name: "payoutRoot", type: "bytes32" }, { name: "exists", type: "bool" }] }, + { type: "function", name: "tournamentClaimed", stateMutability: "view", inputs: [{ name: "", type: "bytes32" }, { name: "", type: "address" }], outputs: [{ type: "bool" }] }, + { type: "function", name: "tournamentEntered", stateMutability: "view", inputs: [{ name: "", type: "bytes32" }, { name: "", type: "address" }], outputs: [{ type: "bool" }] }, + { type: "function", name: "settleTimeout", stateMutability: "view", inputs: [], outputs: [{ type: "uint64" }] }, ] as const; /** Minimal ERC-20 ABI for USDC (approve + reads). */ @@ -70,3 +80,27 @@ export function fmtUsdc(base: bigint | string | number | undefined | null): stri export function parseUsdc(human: string): bigint { return parseUnits(human.trim(), USDC_DECIMALS); } + +/** UUID → on-chain tournament id: the 16 UUID bytes left-aligned in a bytes32 + * (right-padded with zeros), matching the server's `game_id_to_bytes32`. */ +export function tidToBytes32(uuid: string): `0x${string}` { + const hex = uuid.replace(/-/g, "").toLowerCase(); + return `0x${hex}${"0".repeat(32)}` as `0x${string}`; +} + +/** A winner's Merkle claim: the signed payout amount + its proof path. */ +export type ClaimProof = { amount: bigint; proof: `0x${string}`[] }; + +/** Fetch a winner's Merkle claim proof for a root-settled tournament. Returns + * null when the address isn't a winner or the tournament isn't root-settled + * (the server answers 404). */ +export async function fetchClaimProof(tid: string, address: string): Promise { + try { + const r = await fetch(`${SERVER_HTTP}/tournaments/${tid}/claim/${address}`); + if (!r.ok) return null; + const j = await r.json(); + return { amount: BigInt(j.amount), proof: (j.proof ?? []) as `0x${string}`[] }; + } catch { + return null; + } +} From e75ccc87a6193d1d6bc6249a98bbdb6dfda94c03 Mon Sep 17 00:00:00 2001 From: vikramarun Date: Sun, 12 Jul 2026 02:29:52 -0400 Subject: [PATCH 04/10] Address code review: reactive auth token + auth-flow hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from the review of the prior commit. Reactive session (fixes the /profile pairing regression + related staleness): add a shared useAuthToken() hook backed by a same-tab "chess-auth-changed" event (dispatched by setAuth/clearAuth) plus the cross-tab storage event. ConnectEngine, Lobby, tournament, and gauntlet now consume it instead of snapshotting authToken() into per-component state, so a sign-in completed on the same page (e.g. the header AuthButton's auto-SIWE on /profile) or a session cleared on account switch propagates immediately — no dead pairing code or "sign in to stake" errors until reload. ConnectEngine also drops its minted code when the session changes so it can't pair an engine to a previous wallet. AuthButton hardening: - clear a legacy token (no bound chess_addr) or a token bound to a different wallet on account change, so pre-upgrade sessions and switches re-sign cleanly and don't disagree with other components. - guard the in-flight SIWE signature against an account switch: if the account changed while the signature was pending, clear instead of marking signed-in. - restore a "Wrong network — switch" affordance for a signed-in user who drifted off the expected chain (the custom chip had dropped what the default ConnectButton showed). Minor: gauntlet underfunded copy pointed "above" at the removed inline deposit panel → point at the header wallet menu. The header balance pill polls on a slow 30s interval (shares the query key, so an open BankrollPanel still drives 8s refreshes) instead of 8s on every route. Co-Authored-By: Claude Opus 4.8 --- apps/web/app/gauntlet/page.tsx | 12 ++++------- apps/web/app/globals.css | 14 +++++++++++++ apps/web/app/tournament/page.tsx | 10 ++++----- apps/web/components/AuthButton.tsx | 30 ++++++++++++++++++++++++--- apps/web/components/ConnectEngine.tsx | 15 +++++++++++--- apps/web/components/Lobby.tsx | 10 ++++----- apps/web/components/WalletMenu.tsx | 4 +++- apps/web/lib/escrow.ts | 19 +++++++++++++++++ apps/web/lib/siwe.ts | 8 +++---- apps/web/lib/useAuthToken.ts | 25 ++++++++++++++++++++++ apps/web/lib/useBankroll.ts | 10 +++++++-- 11 files changed, 124 insertions(+), 33 deletions(-) create mode 100644 apps/web/lib/useAuthToken.ts diff --git a/apps/web/app/gauntlet/page.tsx b/apps/web/app/gauntlet/page.tsx index 29aa18e..1ea7396 100644 --- a/apps/web/app/gauntlet/page.tsx +++ b/apps/web/app/gauntlet/page.tsx @@ -2,12 +2,12 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; -import { useAccount } from "wagmi"; import { SeatGame } from "@/components/SeatGame"; import { loadBotOptions, useBotStatus } from "@/lib/bot"; import { SERVER_HTTP } from "@/lib/config"; -import { authToken, fetchConfig, fmtUsdc, parseUsdc, type OnchainConfig } from "@/lib/escrow"; +import { fetchConfig, fmtUsdc, parseUsdc, type OnchainConfig } from "@/lib/escrow"; +import { useAuthToken } from "@/lib/useAuthToken"; import { useAvailable } from "@/lib/useBankroll"; import { DEFAULT_TC, TIME_CONTROLS, type TimeControl } from "@/lib/timeControls"; @@ -39,9 +39,8 @@ export default function GauntletPage() { } function GauntletClient() { - const { address, isConnected } = useAccount(); + const token = useAuthToken(); const [config, setConfig] = useState(null); - const [token, setToken] = useState(null); const [stake, setStake] = useState(""); const [tc, setTc] = useState(DEFAULT_TC); @@ -60,9 +59,6 @@ function GauntletClient() { useEffect(() => { fetchConfig().then(setConfig); }, []); - useEffect(() => { - setToken(authToken()); - }, [address, isConnected]); const bot = useBotStatus(token); const botPlays = bot.online && useBot; @@ -387,7 +383,7 @@ function GauntletClient() { {startUnderfunded && stakeBig != null && (
Available balance {fmtUsdc(available)} USDC < stake {fmtUsdc(stakeBig)} per game — - deposit more above. + deposit more from the wallet menu (top right).
)} {err &&
{err}
} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 2b16a0f..7f1742a 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -209,6 +209,20 @@ a:hover { color: var(--danger); font-size: 13px; } +.wrong-net { + padding: 6px 12px; + border-radius: 6px; + background: transparent; + border: 1px solid var(--danger); + color: var(--danger); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +.wrong-net:hover { + background: var(--danger); + color: #fff; +} /* ---------- layout ---------- */ .container { diff --git a/apps/web/app/tournament/page.tsx b/apps/web/app/tournament/page.tsx index 94a64b8..4dbe1cc 100644 --- a/apps/web/app/tournament/page.tsx +++ b/apps/web/app/tournament/page.tsx @@ -7,7 +7,8 @@ import { useAccount } from "wagmi"; import { SeatGame } from "@/components/SeatGame"; import { loadBotOptions, useBotStatus } from "@/lib/bot"; import { SERVER_HTTP } from "@/lib/config"; -import { authToken, fetchConfig, fmtUsdc, parseUsdc, type OnchainConfig } from "@/lib/escrow"; +import { fetchConfig, fmtUsdc, parseUsdc, type OnchainConfig } from "@/lib/escrow"; +import { useAuthToken } from "@/lib/useAuthToken"; import { useAvailable } from "@/lib/useBankroll"; import { DEFAULT_TC, TIME_CONTROLS, type TimeControl } from "@/lib/timeControls"; @@ -54,9 +55,9 @@ export default function TournamentPage() { } function TournamentClient() { - const { address, isConnected } = useAccount(); + const { address } = useAccount(); + const token = useAuthToken(); const [config, setConfig] = useState(null); - const [token, setToken] = useState(null); const [tourneys, setTourneys] = useState([]); const [err, setErr] = useState(null); // Tournaments this browser entered with its connected bot (→ spectate). @@ -77,9 +78,6 @@ function TournamentClient() { useEffect(() => { fetchConfig().then(setConfig); }, []); - useEffect(() => { - setToken(authToken()); - }, [address, isConnected]); const bot = useBotStatus(token); diff --git a/apps/web/components/AuthButton.tsx b/apps/web/components/AuthButton.tsx index c910594..d0dee8d 100644 --- a/apps/web/components/AuthButton.tsx +++ b/apps/web/components/AuthButton.tsx @@ -37,6 +37,8 @@ function AuthButtonInner() { // loop. Reset when the connected account changes. const switchTried = useRef(null); const signTried = useRef(null); + // Latest connected address, readable from inside async callbacks. + const addressRef = useRef(address); useEffect(() => { fetchConfig().then((c) => { @@ -45,11 +47,13 @@ function AuthButtonInner() { }); }, []); - // Recompute sign-in state from storage on account change, dropping a token - // that belongs to a different wallet. + // Recompute sign-in state from storage on account change. Drop a token that + // belongs to a different wallet OR a legacy token with no bound address + // (pre-address-binding sessions) — both force a clean re-sign for this wallet. useEffect(() => { + addressRef.current = address; const key = address?.toLowerCase() ?? null; - if (key && authAddress() && authAddress() !== key) clearAuth(); + if (key && authToken() && authAddress() !== key) clearAuth(); setSignedIn(!!authToken() && !!key && authAddress() === key); switchTried.current = null; signTried.current = null; @@ -65,11 +69,18 @@ function AuthButtonInner() { const runSignIn = useCallback(async () => { if (!address || expected == null) return; + const signingFor = address.toLowerCase(); setError(null); setBusy(true); try { if (chainId !== expected) await switchChainAsync({ chainId: expected }); await signInWithEthereum(address, expected, (a) => signMessageAsync(a)); + // The account may have switched while the signature was pending — never + // claim signed-in for a wallet the token wasn't issued to. + if (addressRef.current?.toLowerCase() !== signingFor) { + clearAuth(); + return; + } setSignedIn(true); } catch (e: any) { setError(e?.shortMessage ?? e?.message ?? "sign-in failed"); @@ -125,6 +136,19 @@ function AuthButtonInner() { ); } + // Signed-in user drifted to the wrong network — surface a switch control + // (the old default ConnectButton did this; the custom chip must too). + if (wagerOn && expected != null && chainId !== expected) { + return ( + + ); + } + // Signed in (or a casual server that needs no signature) → account chip. return ( , + ); - } - - // Never settled past the timeout → reclaim the buy-in. - if (refundReady) { - return wrap( + } else if (refundReady) { + // Never settled past the timeout → reclaim the buy-in. + node = ( , + ); - } - - // Abandoned but the refund window hasn't opened yet — tell the entrant when. - if (status === "abandoned" && !settled && settleTimeout != null) { + } else if (status === "abandoned" && !settled && settleTimeout != null) { + // Abandoned but the refund window hasn't opened yet — tell the entrant when. const left = openedAt + settleTimeout - now; const dur = left <= 0 @@ -199,12 +210,23 @@ export function TournamentClaim({ : left >= 3600 ? `~${Math.ceil(left / 3600)}h` : `~${Math.max(1, Math.ceil(left / 60))}m`; - return ( + node = ( Refund available in {dur} ); } - return null; + if (!node) return null; + return ( + + {label && ( + + {label} + + )} + {node} + {errLine} + + ); } diff --git a/apps/web/components/WalletMenu.tsx b/apps/web/components/WalletMenu.tsx index d9e4a82..e003189 100644 --- a/apps/web/components/WalletMenu.tsx +++ b/apps/web/components/WalletMenu.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useAccount } from "wagmi"; import { BankrollPanel } from "@/components/BankrollPanel"; +import { ClaimWinnings } from "@/components/ClaimWinnings"; import { fetchConfig, fmtUsdc, type OnchainConfig } from "@/lib/escrow"; import { useAvailable } from "@/lib/useBankroll"; import { useMounted } from "@/lib/useMounted"; @@ -68,6 +69,7 @@ function WalletMenuInner() { {open && (
+
)}
From 3b415ad41120db8bc5b161d0a79dc18f8d944133 Mon Sep 17 00:00:00 2001 From: vikramarun Date: Sun, 12 Jul 2026 03:06:52 -0400 Subject: [PATCH 08/10] Streamline: shared config / chain-switch / tournament-fetch hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De-duplicate cross-page logic surfaced while wiring the claim UI, so the pages compose shared primitives instead of each re-implementing them. - useOnchainConfig(): one source of the server config + `wagerOn` gate, replacing the fetchConfig-into-state + `!!wagerEnabled && !!escrow` pattern duplicated in Lobby, tournament, gauntlet, WalletMenu, and AuthButton. - useEnsureChain(): the single "switch to the expected chain before a write" step, replacing the inline ensureChain in BankrollPanel, AuthButton, and TournamentClaim. - lib/tournaments.ts: fetchTournaments()/fetchTournament() + the shared Tournament type, replacing the ids→details fan-out duplicated between the tournament lobby and the bankroll claim discovery (ClaimWinnings). No behavior change; tsc clean and all pages render. Co-Authored-By: Claude Opus 4.8 --- apps/web/app/gauntlet/page.tsx | 10 ++-- apps/web/app/tournament/page.tsx | 66 ++++++++----------------- apps/web/components/AuthButton.tsx | 28 ++++------- apps/web/components/BankrollPanel.tsx | 12 ++--- apps/web/components/ClaimWinnings.tsx | 15 +----- apps/web/components/Lobby.tsx | 10 ++-- apps/web/components/TournamentClaim.tsx | 19 ++----- apps/web/components/WalletMenu.tsx | 10 ++-- apps/web/lib/tournaments.ts | 34 +++++++++++++ apps/web/lib/useEnsureChain.ts | 18 +++++++ apps/web/lib/useOnchainConfig.ts | 18 +++++++ 11 files changed, 120 insertions(+), 120 deletions(-) create mode 100644 apps/web/lib/tournaments.ts create mode 100644 apps/web/lib/useEnsureChain.ts create mode 100644 apps/web/lib/useOnchainConfig.ts diff --git a/apps/web/app/gauntlet/page.tsx b/apps/web/app/gauntlet/page.tsx index 86e558e..ebc968b 100644 --- a/apps/web/app/gauntlet/page.tsx +++ b/apps/web/app/gauntlet/page.tsx @@ -6,10 +6,11 @@ import { useEffect, useRef, useState } from "react"; import { SeatGame } from "@/components/SeatGame"; import { loadBotOptions, useBotStatus } from "@/lib/bot"; import { SERVER_HTTP } from "@/lib/config"; -import { fetchConfig, fmtUsdc, parseUsdc, type OnchainConfig } from "@/lib/escrow"; +import { fmtUsdc, parseUsdc } from "@/lib/escrow"; import { useAuthToken } from "@/lib/useAuthToken"; import { useAvailable } from "@/lib/useBankroll"; import { useMounted } from "@/lib/useMounted"; +import { useOnchainConfig } from "@/lib/useOnchainConfig"; import { DEFAULT_TC, TIME_CONTROLS, type TimeControl } from "@/lib/timeControls"; type Stats = { @@ -40,7 +41,7 @@ export default function GauntletPage() { function GauntletClient() { const token = useAuthToken(); - const [config, setConfig] = useState(null); + const { config, wagerOn } = useOnchainConfig(); const [stake, setStake] = useState(""); const [tc, setTc] = useState(DEFAULT_TC); @@ -56,10 +57,6 @@ function GauntletClient() { // Latest games-count, read without re-triggering the queue loop. const gamesRef = useRef(0); - useEffect(() => { - fetchConfig().then(setConfig); - }, []); - const bot = useBotStatus(token); const botPlays = bot.online && useBot; useEffect(() => { @@ -67,7 +64,6 @@ function GauntletClient() { }, [stats]); const { available } = useAvailable(config?.escrow); - const wagerOn = !!config?.wagerEnabled && !!config?.escrow; const wantStake = stake.trim().length > 0; const running = !!session && stats?.status !== "stopped"; const stakeBig = (() => { diff --git a/apps/web/app/tournament/page.tsx b/apps/web/app/tournament/page.tsx index 021e65b..05837d6 100644 --- a/apps/web/app/tournament/page.tsx +++ b/apps/web/app/tournament/page.tsx @@ -7,19 +7,22 @@ import { useAccount } from "wagmi"; import { SeatGame } from "@/components/SeatGame"; import { loadBotOptions, useBotStatus } from "@/lib/bot"; import { SERVER_HTTP } from "@/lib/config"; -import { fetchConfig, fmtUsdc, parseUsdc, type OnchainConfig } from "@/lib/escrow"; +import { fmtUsdc, parseUsdc } from "@/lib/escrow"; +import { + fetchTournament, + fetchTournaments, + type Tournament, + type TournamentGame, +} from "@/lib/tournaments"; import { useAuthToken } from "@/lib/useAuthToken"; import { useAvailable } from "@/lib/useBankroll"; import { useMounted } from "@/lib/useMounted"; +import { useOnchainConfig } from "@/lib/useOnchainConfig"; import { DEFAULT_TC, TIME_CONTROLS, type TimeControl } from "@/lib/timeControls"; -type TGame = { - game_id: string; - white: string; - black: string; - round: number; - // seat tokens are NOT in the public view — fetched per-entrant via /my-games -}; +// Tournament + TournamentGame come from @/lib/tournaments (shared with the +// bankroll claim discovery). MyGame is this page's per-entrant seat view +// (tokens are NOT in the public tournament view — fetched via /my-games). type MyGame = { game_id: string; color: "white" | "black"; @@ -27,16 +30,6 @@ type MyGame = { round: number; seat: string; // "bot" | "browser" }; -type Tourney = { - id: string; - name: string; - buy_in: string | null; - status: string; - players: string[]; - games: TGame[]; - current_round: number; - total_rounds: number; -}; export default function TournamentPage() { const mounted = useMounted(); @@ -57,8 +50,8 @@ export default function TournamentPage() { function TournamentClient() { const { address } = useAccount(); const token = useAuthToken(); - const [config, setConfig] = useState(null); - const [tourneys, setTourneys] = useState([]); + const { config, wagerOn } = useOnchainConfig(); + const [tourneys, setTourneys] = useState([]); const [err, setErr] = useState(null); // Tournaments this browser entered with its connected bot (→ spectate). const [joinedAsBot, setJoinedAsBot] = useState>({}); @@ -75,10 +68,6 @@ function TournamentClient() { // My own seat tokens for the tournament I'm playing (game_id -> {token,color}). const [myTokens, setMyTokens] = useState>({}); - useEffect(() => { - fetchConfig().then(setConfig); - }, []); - const bot = useBotStatus(token); // Poll tournaments. In the lobby, refresh the whole list; while playing or @@ -89,23 +78,11 @@ function TournamentClient() { const tick = async () => { try { if (playingTid) { - const d = await (await fetch(`${SERVER_HTTP}/tournaments/${playingTid}`)).json(); - if (live) - setTourneys((prev) => [ - ...prev.filter((t) => t.id !== playingTid), - { id: playingTid, ...d } as Tourney, - ]); + const t = await fetchTournament(playingTid); + if (live) setTourneys((prev) => [...prev.filter((x) => x.id !== playingTid), t]); return; } - const ids: { tournament_id: string }[] = await ( - await fetch(`${SERVER_HTTP}/tournaments`) - ).json(); - const details = await Promise.all( - ids.map(async ({ tournament_id }) => { - const d = await (await fetch(`${SERVER_HTTP}/tournaments/${tournament_id}`)).json(); - return { id: tournament_id, ...d } as Tourney; - }), - ); + const details = await fetchTournaments(); if (live) setTourneys(details); } catch { /* ignore */ @@ -120,13 +97,12 @@ function TournamentClient() { }, [playingTid]); const { available } = useAvailable(config?.escrow); - const wagerOn = !!config?.wagerEnabled && !!config?.escrow; - const identityIn = (t: Tourney): string | null => { + const identityIn = (t: Tournament): string | null => { if (t.buy_in) return address ? address.toLowerCase() : null; return joinedAs[t.id] ?? null; }; - const myGames = (t: Tourney): TGame[] => { + const myGames = (t: Tournament): TournamentGame[] => { const me = identityIn(t); if (!me) return []; return t.games.filter( @@ -168,7 +144,7 @@ function TournamentClient() { } }; - const join = async (t: Tourney, asBot = false) => { + const join = async (t: Tournament, asBot = false) => { setErr(null); if ((t.buy_in || asBot) && !token) return setErr( @@ -204,7 +180,7 @@ function TournamentClient() { } }; - const startT = async (t: Tourney) => { + const startT = async (t: Tournament) => { setErr(null); if (t.buy_in && !token) return setErr("Sign in (top right) to start your tournament."); try { @@ -226,7 +202,7 @@ function TournamentClient() { }; /// Fetch only MY seat tokens (never exposed in the public view) and enter play. - const enterPlay = async (t: Tourney) => { + const enterPlay = async (t: Tournament) => { setErr(null); const me = identityIn(t); if (!me) return setErr("Join the tournament first."); diff --git a/apps/web/components/AuthButton.tsx b/apps/web/components/AuthButton.tsx index 9f8f7fd..4dfddf6 100644 --- a/apps/web/components/AuthButton.tsx +++ b/apps/web/components/AuthButton.tsx @@ -2,11 +2,13 @@ import { ConnectButton } from "@rainbow-me/rainbowkit"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useAccount, useAccountEffect, useChainId, useSignMessage, useSwitchChain } from "wagmi"; +import { useAccount, useAccountEffect, useChainId, useSignMessage } from "wagmi"; -import { authAddress, authToken, clearAuth, fetchConfig } from "@/lib/escrow"; +import { authAddress, authToken, clearAuth } from "@/lib/escrow"; import { signInWithEthereum } from "@/lib/siwe"; +import { useEnsureChain } from "@/lib/useEnsureChain"; import { useMounted } from "@/lib/useMounted"; +import { useOnchainConfig } from "@/lib/useOnchainConfig"; /** Mount gate: the wagmi hooks live in AuthButtonInner, which only renders once * the client-only WagmiProvider (app/providers.tsx) is in the tree. */ @@ -23,11 +25,11 @@ export function AuthButton() { function AuthButtonInner() { const { address, isConnected } = useAccount(); const chainId = useChainId(); - const { switchChainAsync } = useSwitchChain(); + const ensureChain = useEnsureChain(); const { signMessageAsync } = useSignMessage(); - const [expected, setExpected] = useState(null); - const [wagerOn, setWagerOn] = useState(false); + const { config, wagerOn } = useOnchainConfig(); + const expected = config?.chainId ?? null; const [signedIn, setSignedIn] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -38,13 +40,6 @@ function AuthButtonInner() { // Latest connected address, readable from inside async callbacks. const addressRef = useRef(address); - useEffect(() => { - fetchConfig().then((c) => { - setExpected(c.chainId); - setWagerOn(c.wagerEnabled); - }); - }, []); - // Recompute sign-in state from storage on account change. Drop a token that // belongs to a different wallet OR a legacy token with no bound address // (pre-address-binding sessions) — both force a clean re-sign for this wallet. @@ -70,7 +65,7 @@ function AuthButtonInner() { setError(null); setBusy(true); try { - if (chainId !== expected) await switchChainAsync({ chainId: expected }); + await ensureChain(expected); await signInWithEthereum(address, expected, (a) => signMessageAsync(a)); // The account may have switched while the signature was pending — never // claim signed-in for a wallet the token wasn't issued to. @@ -84,7 +79,7 @@ function AuthButtonInner() { } finally { setBusy(false); } - }, [address, chainId, expected, signMessageAsync, switchChainAsync]); + }, [address, expected, signMessageAsync, ensureChain]); const ready = isConnected && !!address && expected != null; @@ -128,10 +123,7 @@ function AuthButtonInner() { // (the old default ConnectButton did this; the custom chip must too). if (wagerOn && expected != null && chainId !== expected) { return ( - ); diff --git a/apps/web/components/BankrollPanel.tsx b/apps/web/components/BankrollPanel.tsx index ecb2a27..fbc7c3f 100644 --- a/apps/web/components/BankrollPanel.tsx +++ b/apps/web/components/BankrollPanel.tsx @@ -6,11 +6,11 @@ import { useChainId, usePublicClient, useReadContract, - useSwitchChain, useWriteContract, } from "wagmi"; import { ERC20_ABI, ESCROW_ABI, fmtUsdc, parseUsdc } from "@/lib/escrow"; +import { useEnsureChain } from "@/lib/useEnsureChain"; /** Deposit / withdraw USDC into the non-custodial escrow bankroll, and show the * available (unlocked) balance. Funds live in the contract — never with us. */ @@ -23,7 +23,7 @@ export function BankrollPanel({ }) { const { address, isConnected } = useAccount(); const chainId = useChainId(); - const { switchChainAsync } = useSwitchChain(); + const ensureChain = useEnsureChain(); const publicClient = usePublicClient(); const { writeContractAsync } = useWriteContract(); @@ -77,10 +77,6 @@ export function BankrollPanel({ refetchAllowance(); }; - const ensureChain = async () => { - if (chainId !== expected) await switchChainAsync({ chainId: expected }); - }; - const doDeposit = async () => { setError(null); let amt: bigint; @@ -93,7 +89,7 @@ export function BankrollPanel({ if (amt <= 0n) return setError("amount must be positive"); setBusy("deposit"); try { - await ensureChain(); + await ensureChain(expected); if (!token) throw new Error("token not loaded"); if (((allowance as bigint) ?? 0n) < amt) { setStage("approving USDC…"); @@ -136,7 +132,7 @@ export function BankrollPanel({ if (amt > ((available as bigint) ?? 0n)) return setError("exceeds available balance"); setBusy("withdraw"); try { - await ensureChain(); + await ensureChain(expected); setStage("withdrawing…"); const h = await writeContractAsync({ address: escrow, diff --git a/apps/web/components/ClaimWinnings.tsx b/apps/web/components/ClaimWinnings.tsx index d802d1d..15c3501 100644 --- a/apps/web/components/ClaimWinnings.tsx +++ b/apps/web/components/ClaimWinnings.tsx @@ -4,10 +4,9 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount } from "wagmi"; import { TournamentClaim } from "@/components/TournamentClaim"; -import { SERVER_HTTP } from "@/lib/config"; +import { fetchTournaments } from "@/lib/tournaments"; type Candidate = { id: string; name: string; status: string }; -type TournDetail = { name: string; buy_in: string | null; status: string; players: string[] }; /** Tournament payouts / refunds, surfaced alongside the bankroll (they credit * the same escrow balance) instead of scattered across the tournament page. @@ -35,17 +34,7 @@ export function ClaimWinnings({ escrow, chainId }: { escrow: `0x${string}`; chai const me = address.toLowerCase(); (async () => { try { - const ids: { tournament_id: string }[] = await fetch(`${SERVER_HTTP}/tournaments`).then( - (r) => (r.ok ? r.json() : []), - ); - const details = await Promise.all( - ids.map(async ({ tournament_id }) => { - const d: TournDetail = await fetch(`${SERVER_HTTP}/tournaments/${tournament_id}`).then( - (r) => r.json(), - ); - return { id: tournament_id, ...d }; - }), - ); + const details = await fetchTournaments(); if (!live) return; // Buy-in tournaments the wallet entered that have reached a state where a // payout or refund is possible; TournamentClaim decides per one whether diff --git a/apps/web/components/Lobby.tsx b/apps/web/components/Lobby.tsx index a92bae4..7ea09b7 100644 --- a/apps/web/components/Lobby.tsx +++ b/apps/web/components/Lobby.tsx @@ -10,9 +10,10 @@ import { shortAddress } from "@/lib/address"; import { loadBotOptions, useBotStatus } from "@/lib/bot"; import { browserEngineLabel, getBrowserBotConfig } from "@/lib/browserBot"; import { SERVER_HTTP } from "@/lib/config"; -import { fetchConfig, fmtUsdc, parseUsdc, type OnchainConfig } from "@/lib/escrow"; +import { fmtUsdc, parseUsdc } from "@/lib/escrow"; import { useAuthToken } from "@/lib/useAuthToken"; import { useAvailable } from "@/lib/useBankroll"; +import { useOnchainConfig } from "@/lib/useOnchainConfig"; import { TIME_CONTROLS, type TimeControl } from "@/lib/timeControls"; function tryParse(s: string): bigint | null { @@ -80,7 +81,7 @@ export function Lobby() { const router = useRouter(); const { address } = useAccount(); const token = useAuthToken(); - const [config, setConfig] = useState(null); + const { config, wagerOn } = useOnchainConfig(); const [offers, setOffers] = useState([]); const [live, setLive] = useState([]); const [err, setErr] = useState(null); @@ -92,10 +93,6 @@ export function Lobby() { const [active, setActive] = useState(null); const [useBot, setUseBot] = useState(true); // prefer the bot when it's online - useEffect(() => { - fetchConfig().then(setConfig); - }, []); - const bot = useBotStatus(token); const botPlays = bot.online && useBot; @@ -171,7 +168,6 @@ export function Lobby() { }, [pending, token]); const { available } = useAvailable(config?.escrow); - const wagerOn = !!config?.wagerEnabled && !!config?.escrow; const modalStakeBig = modalStake.trim() ? tryParse(modalStake) : 0n; const modalUnderfunded = diff --git a/apps/web/components/TournamentClaim.tsx b/apps/web/components/TournamentClaim.tsx index 57e3830..458a391 100644 --- a/apps/web/components/TournamentClaim.tsx +++ b/apps/web/components/TournamentClaim.tsx @@ -1,16 +1,10 @@ "use client"; import { useEffect, useState } from "react"; -import { - useAccount, - useChainId, - usePublicClient, - useReadContract, - useSwitchChain, - useWriteContract, -} from "wagmi"; +import { useAccount, usePublicClient, useReadContract, useWriteContract } from "wagmi"; import { ESCROW_ABI, fetchClaimProof, fmtUsdc, tidToBytes32, type ClaimProof } from "@/lib/escrow"; +import { useEnsureChain } from "@/lib/useEnsureChain"; const ZERO32 = `0x${"0".repeat(64)}`; @@ -40,8 +34,7 @@ export function TournamentClaim({ onClaimed?: () => void; }) { const { address, isConnected } = useAccount(); - const chainId = useChainId(); - const { switchChainAsync } = useSwitchChain(); + const ensureChain = useEnsureChain(); const publicClient = usePublicClient(); const { writeContractAsync } = useWriteContract(); @@ -130,15 +123,11 @@ export function TournamentClaim({ if (!enabled || !exists || !hasEntered) return null; - const ensureChain = async () => { - if (chainId !== expected) await switchChainAsync({ chainId: expected }); - }; - const run = async (fn: () => Promise<`0x${string}`>) => { setError(null); setBusy(true); try { - await ensureChain(); + await ensureChain(expected); const hash = await fn(); await publicClient!.waitForTransactionReceipt({ hash }); refetchTourn(); diff --git a/apps/web/components/WalletMenu.tsx b/apps/web/components/WalletMenu.tsx index e003189..590f957 100644 --- a/apps/web/components/WalletMenu.tsx +++ b/apps/web/components/WalletMenu.tsx @@ -5,9 +5,10 @@ import { useAccount } from "wagmi"; import { BankrollPanel } from "@/components/BankrollPanel"; import { ClaimWinnings } from "@/components/ClaimWinnings"; -import { fetchConfig, fmtUsdc, type OnchainConfig } from "@/lib/escrow"; +import { fmtUsdc } from "@/lib/escrow"; import { useAvailable } from "@/lib/useBankroll"; import { useMounted } from "@/lib/useMounted"; +import { useOnchainConfig } from "@/lib/useOnchainConfig"; /** Mount gate: the wagmi hook lives in WalletMenuInner so it only runs once the * client-only WagmiProvider (app/providers.tsx) is in the tree. */ @@ -21,14 +22,10 @@ export function WalletMenu() { * wagering server once a wallet is connected — the funds live in escrow. */ function WalletMenuInner() { const { isConnected } = useAccount(); - const [config, setConfig] = useState(null); + const { config, wagerOn } = useOnchainConfig(); const [open, setOpen] = useState(false); const ref = useRef(null); - useEffect(() => { - fetchConfig().then(setConfig); - }, []); - // Close the popover on outside click or Escape. useEffect(() => { if (!open) return; @@ -48,7 +45,6 @@ function WalletMenuInner() { // the same query key and drives faster polling while it's on screen. const { available } = useAvailable(config?.escrow, { refetchInterval: 30000 }); - const wagerOn = !!config?.wagerEnabled && !!config?.escrow; if (!isConnected || !wagerOn || !config?.escrow) return null; return ( diff --git a/apps/web/lib/tournaments.ts b/apps/web/lib/tournaments.ts new file mode 100644 index 0000000..3203bd7 --- /dev/null +++ b/apps/web/lib/tournaments.ts @@ -0,0 +1,34 @@ +import { SERVER_HTTP } from "./config"; + +export type TournamentGame = { + game_id: string; + white: string; + black: string; + round: number; +}; + +export type Tournament = { + id: string; + name: string; + buy_in: string | null; + status: string; + players: string[]; + games: TournamentGame[]; + current_round: number; + total_rounds: number; +}; + +/** Fetch one tournament's full detail. */ +export async function fetchTournament(id: string): Promise { + const d = await fetch(`${SERVER_HTTP}/tournaments/${id}`).then((r) => r.json()); + return { id, ...d } as Tournament; +} + +/** Fetch every tournament with full detail (ids → detail per id). Shared by the + * tournament lobby and the bankroll claim discovery so the fan-out lives once. */ +export async function fetchTournaments(): Promise { + const ids: { tournament_id: string }[] = await fetch(`${SERVER_HTTP}/tournaments`).then((r) => + r.ok ? r.json() : [], + ); + return Promise.all(ids.map(({ tournament_id }) => fetchTournament(tournament_id))); +} diff --git a/apps/web/lib/useEnsureChain.ts b/apps/web/lib/useEnsureChain.ts new file mode 100644 index 0000000..ec149bc --- /dev/null +++ b/apps/web/lib/useEnsureChain.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useCallback } from "react"; +import { useChainId, useSwitchChain } from "wagmi"; + +/** Returns a function that switches the wallet to `expected` if it isn't already + * there — the one place the "be on the right chain before a write" step lives + * (bankroll deposit/withdraw, SIWE sign-in, tournament claim/refund). */ +export function useEnsureChain(): (expected: number) => Promise { + const chainId = useChainId(); + const { switchChainAsync } = useSwitchChain(); + return useCallback( + async (expected: number) => { + if (chainId !== expected) await switchChainAsync({ chainId: expected }); + }, + [chainId, switchChainAsync], + ); +} diff --git a/apps/web/lib/useOnchainConfig.ts b/apps/web/lib/useOnchainConfig.ts new file mode 100644 index 0000000..9119de5 --- /dev/null +++ b/apps/web/lib/useOnchainConfig.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { fetchConfig, type OnchainConfig } from "./escrow"; + +/** The server's on-chain config (escrow address + expected chain + wager flag), + * fetched once (fetchConfig is module-memoized) and shared by the header and + * every wager surface. `wagerOn` is the single source of the "wagering is live" + * gate — escrow configured AND enabled — instead of each page re-deriving it. */ +export function useOnchainConfig(): { config: OnchainConfig | null; wagerOn: boolean } { + const [config, setConfig] = useState(null); + useEffect(() => { + fetchConfig().then(setConfig); + }, []); + const wagerOn = !!config?.wagerEnabled && !!config?.escrow; + return { config, wagerOn }; +} From f8efb8bc1c1dfb447ad42d691d918375b4cd97d7 Mon Sep 17 00:00:00 2001 From: vikramarun Date: Sun, 12 Jul 2026 04:08:56 -0400 Subject: [PATCH 09/10] Address full-PR review: claim discovery, tx-receipt, cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from the entire-PR review. Finding 1/8 (high) — DB-backed claimable-tournaments endpoint. The claim UI discovered tournaments via the in-memory GET /tournaments list, which isn't rehydrated on restart, so settled/abandoned payouts/refunds became undiscoverable in-app after any deploy. Add GET /tournaments/claimable/{address} sourced from Postgres: migration 0008 adds a tournaments.name column, upsert_tournament now persists it, and claimable_tournaments(address) returns the wallet's finished (complete|settled|abandoned) buy-in tournaments via a JSONB players containment query. ClaimWinnings now calls this (one request, server-filtered) instead of an N+1 client enumeration — also fixes the O(all-tournaments) cost. Finding 2 — stale publicClient. usePublicClient({ chainId: expected }) in BankrollPanel + TournamentClaim, so a receipt read after ensureChain switches doesn't hit an undefined/other-chain client and falsely report a succeeded tx as failed. Finding 3 — reset ClaimWinnings' resolved map on account change (not just disconnect), so a prior wallet's claimable state can't keep the header shown. Finding 4 — include 'complete' (settlement-enqueue-failed) tournaments in the claimable set, so a stuck buy-in stays refundable via the UI. (Now server-side.) Finding 5 — fetchTournaments tolerates a single failing detail fetch instead of blanking the whole list. Findings 6/7/9 — single-source TournamentClaim's state via one `kind` discriminant (drops the duplicated hasAction conditions); remove the dead onClaimed prop; already-claimed no longer counts toward showing the header. Finding 10 — fmtUsdcSigned (BigInt, no float) in escrow.ts, used by ProfileStats. Co-Authored-By: Claude Opus 4.8 --- apps/web/components/BankrollPanel.tsx | 4 +- apps/web/components/ClaimWinnings.tsx | 28 +++------ apps/web/components/ProfileStats.tsx | 11 +--- apps/web/components/TournamentClaim.tsx | 62 ++++++++++--------- apps/web/lib/escrow.ts | 17 +++++ apps/web/lib/tournaments.ts | 33 ++++++++-- .../migrations/0008_tournament_name.sql | 5 ++ crates/persistence/src/lib.rs | 34 +++++++++- crates/server/src/matchmaking.rs | 45 ++++++++++++-- 9 files changed, 172 insertions(+), 67 deletions(-) create mode 100644 crates/persistence/migrations/0008_tournament_name.sql diff --git a/apps/web/components/BankrollPanel.tsx b/apps/web/components/BankrollPanel.tsx index fbc7c3f..f95152d 100644 --- a/apps/web/components/BankrollPanel.tsx +++ b/apps/web/components/BankrollPanel.tsx @@ -24,7 +24,9 @@ export function BankrollPanel({ const { address, isConnected } = useAccount(); const chainId = useChainId(); const ensureChain = useEnsureChain(); - const publicClient = usePublicClient(); + // Pin the receipt-reading client to the escrow chain: after ensureChain + // switches, the connected chain's client would otherwise be stale/undefined. + const publicClient = usePublicClient({ chainId: expected }); const { writeContractAsync } = useWriteContract(); const [amount, setAmount] = useState(""); diff --git a/apps/web/components/ClaimWinnings.tsx b/apps/web/components/ClaimWinnings.tsx index 15c3501..8a4a50d 100644 --- a/apps/web/components/ClaimWinnings.tsx +++ b/apps/web/components/ClaimWinnings.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount } from "wagmi"; import { TournamentClaim } from "@/components/TournamentClaim"; -import { fetchTournaments } from "@/lib/tournaments"; +import { fetchClaimableTournaments } from "@/lib/tournaments"; type Candidate = { id: string; name: string; status: string }; @@ -25,30 +25,22 @@ export function ClaimWinnings({ escrow, chainId }: { escrow: `0x${string}`; chai }, []); useEffect(() => { + // Reset the resolved map on every account change so a prior wallet's + // claimable state can't keep the header visible for the new one. + setResolved({}); if (!isConnected || !address) { setItems([]); - setResolved({}); return; } let live = true; - const me = address.toLowerCase(); (async () => { try { - const details = await fetchTournaments(); - if (!live) return; - // Buy-in tournaments the wallet entered that have reached a state where a - // payout or refund is possible; TournamentClaim decides per one whether - // there's actually anything to collect. - setItems( - details - .filter( - (t) => - t.buy_in && - (t.status === "settled" || t.status === "abandoned") && - (t.players ?? []).some((p) => p.toLowerCase() === me), - ) - .map((t) => ({ id: t.id, name: t.name, status: t.status })), - ); + // Server-filtered to this wallet's finished buy-in tournaments; + // TournamentClaim decides per one whether there's anything to collect. + const rows = await fetchClaimableTournaments(address); + if (live) { + setItems(rows.map((t) => ({ id: t.tournament_id, name: t.name, status: t.status }))); + } } catch { if (live) setItems([]); } diff --git a/apps/web/components/ProfileStats.tsx b/apps/web/components/ProfileStats.tsx index f2b3957..48f9222 100644 --- a/apps/web/components/ProfileStats.tsx +++ b/apps/web/components/ProfileStats.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { shortAddress } from "@/lib/address"; import { SERVER_HTTP } from "@/lib/config"; +import { fmtUsdcSigned } from "@/lib/escrow"; type Profile = { address: string; @@ -26,12 +27,6 @@ type GameItem = { finished_at: string | null; }; -const usdc = (base?: string | null) => { - if (!base) return "—"; - const n = Number(base) / 1e6; - return `${n > 0 ? "+" : ""}${n.toFixed(2)}`; -}; - function outcome(g: GameItem, me: string): "win" | "loss" | "draw" | "-" { if (g.result === "draw") return "draw"; const iWhite = g.white?.toLowerCase() === me; @@ -116,7 +111,7 @@ export function ProfileStats({ address }: { address: string }) {
W / L / D
-
{p ? usdc(p.net) : "…"}
+
{p ? fmtUsdcSigned(p.net) : "…"}
Net winnings (USDC)
@@ -153,7 +148,7 @@ export function ProfileStats({ address }: { address: string }) { {" "} {g.reason} - {g.stake ? usdc(g.stake).replace("+", "") : "—"} + {g.stake ? fmtUsdcSigned(g.stake).replace("+", "") : "—"} {g.moves} {g.finished_at ? new Date(g.finished_at).toLocaleDateString() : "—"} diff --git a/apps/web/components/TournamentClaim.tsx b/apps/web/components/TournamentClaim.tsx index 458a391..93e80a5 100644 --- a/apps/web/components/TournamentClaim.tsx +++ b/apps/web/components/TournamentClaim.tsx @@ -20,7 +20,6 @@ export function TournamentClaim({ chainId: expected, label, onResolved, - onClaimed, }: { tid: string; status: string; @@ -28,14 +27,15 @@ export function TournamentClaim({ chainId: number; /** Optional tournament name shown above the action (for the bankroll list). */ label?: string; - /** Reports whether this tournament actually renders an action/state, so a + /** Reports whether this tournament actually renders a claimable action, so a * parent list can hide its header when nothing is claimable. */ onResolved?: (hasAction: boolean) => void; - onClaimed?: () => void; }) { const { address, isConnected } = useAccount(); const ensureChain = useEnsureChain(); - const publicClient = usePublicClient(); + // Pin the receipt-reading client to the settlement chain: after ensureChain + // switches, the connected chain's client would otherwise be stale/undefined. + const publicClient = usePublicClient({ chainId: expected }); const { writeContractAsync } = useWriteContract(); const [busy, setBusy] = useState(false); @@ -107,21 +107,30 @@ export function TournamentClaim({ const now = Math.floor(Date.now() / 1000); const refundReady = !settled && settleTimeout != null && now > openedAt + settleTimeout && !hasClaimed; - // Whether this tournament will render an action/state (vs null), reported up - // so a "Tournament winnings" list can hide its header when nothing applies. - const hasAction = - enabled && - exists && - hasEntered && - (hasClaimed || - (rootSet && !!proof) || - refundReady || - (status === "abandoned" && !settled && settleTimeout != null)); + + // Single source of truth for what this tournament shows — both the rendered + // node and the parent's header gate derive from it (no duplicated conditions). + const kind: "claimed" | "claim" | "refund" | "pending" | null = + !enabled || !exists || !hasEntered + ? null + : hasClaimed + ? "claimed" + : rootSet && proof + ? "claim" + : refundReady + ? "refund" + : status === "abandoned" && !settled && settleTimeout != null + ? "pending" + : null; + + // Already-claimed is informational only, so it doesn't count toward showing + // the parent's "Tournament winnings" header. + const hasAction = kind != null && kind !== "claimed"; useEffect(() => { onResolved?.(hasAction); }, [hasAction, onResolved]); - if (!enabled || !exists || !hasEntered) return null; + if (kind == null) return null; const run = async (fn: () => Promise<`0x${string}`>) => { setError(null); @@ -132,7 +141,6 @@ export function TournamentClaim({ await publicClient!.waitForTransactionReceipt({ hash }); refetchTourn(); refetchClaimed(); - onClaimed?.(); } catch (e: any) { setError(e?.shortMessage ?? e?.message ?? "transaction failed"); } finally { @@ -165,32 +173,31 @@ export function TournamentClaim({ const errLine = error ? {error} : null; - // The single action/state for this tournament, or null when there's nothing - // to show (non-winner, already credited to bankroll, etc.). - let node: React.ReactNode = null; - if (hasClaimed) { + // The single action/state for this tournament, built from `kind` above. + let node: React.ReactNode; + if (kind === "claimed") { node = ( {rootSet ? "Payout claimed ✓" : "Refund claimed ✓"} ); - } else if (rootSet && proof) { - // Winner of a root-settled field → Merkle claim. + } else if (kind === "claim") { + // Winner of a root-settled field → Merkle claim (proof is set when kind==="claim"). node = ( ); - } else if (refundReady) { + } else if (kind === "refund") { // Never settled past the timeout → reclaim the buy-in. node = ( ); - } else if (status === "abandoned" && !settled && settleTimeout != null) { - // Abandoned but the refund window hasn't opened yet — tell the entrant when. - const left = openedAt + settleTimeout - now; + } else { + // pending: abandoned but the refund window hasn't opened yet — say when. + const left = openedAt + settleTimeout! - now; const dur = left <= 0 ? "soon" @@ -206,7 +213,6 @@ export function TournamentClaim({ ); } - if (!node) return null; return ( {label && ( diff --git a/apps/web/lib/escrow.ts b/apps/web/lib/escrow.ts index b10cd4c..c6e611c 100644 --- a/apps/web/lib/escrow.ts +++ b/apps/web/lib/escrow.ts @@ -108,6 +108,23 @@ export function fmtUsdc(base: bigint | string | number | undefined | null): stri } } +/** Signed USDC display for net figures ("+1.50" / "-1.50" / "0.00") from base + * units, without float math (truncates to cents). */ +export function fmtUsdcSigned(base: bigint | string | number | undefined | null): string { + if (base === undefined || base === null) return "—"; + let v: bigint; + try { + v = BigInt(base); + } catch { + return "—"; + } + const neg = v < 0n; + const abs = neg ? -v : v; + const cents = (abs % 1_000_000n) / 10_000n; + const sign = neg ? "-" : v > 0n ? "+" : ""; + return `${sign}${abs / 1_000_000n}.${cents.toString().padStart(2, "0")}`; +} + /** Parse a human USDC amount ("1.5") to base units. Throws on bad input. */ export function parseUsdc(human: string): bigint { return parseUnits(human.trim(), USDC_DECIMALS); diff --git a/apps/web/lib/tournaments.ts b/apps/web/lib/tournaments.ts index 3203bd7..f4082ec 100644 --- a/apps/web/lib/tournaments.ts +++ b/apps/web/lib/tournaments.ts @@ -18,17 +18,40 @@ export type Tournament = { total_rounds: number; }; -/** Fetch one tournament's full detail. */ +export type ClaimableTournament = { + tournament_id: string; + name: string; + buy_in: string | null; + status: string; +}; + +/** The connected wallet's finished buy-in tournaments that may have a payout or + * refund to collect. DB-sourced server-side (survives a restart, unlike the + * in-memory GET /tournaments list) and already filtered to the wallet's + * finished entries — the bankroll claim UI just renders these. */ +export async function fetchClaimableTournaments(address: string): Promise { + const r = await fetch(`${SERVER_HTTP}/tournaments/claimable/${address}`); + if (!r.ok) return []; + return r.json(); +} + +/** Fetch one tournament's full detail. Throws on a non-OK response. */ export async function fetchTournament(id: string): Promise { - const d = await fetch(`${SERVER_HTTP}/tournaments/${id}`).then((r) => r.json()); - return { id, ...d } as Tournament; + const r = await fetch(`${SERVER_HTTP}/tournaments/${id}`); + if (!r.ok) throw new Error(`tournament ${id} (${r.status})`); + return { id, ...(await r.json()) } as Tournament; } /** Fetch every tournament with full detail (ids → detail per id). Shared by the - * tournament lobby and the bankroll claim discovery so the fan-out lives once. */ + * tournament lobby so the fan-out lives once. Tolerates a tournament that + * vanished between the list and detail fetch — one bad row shouldn't blank the + * whole list. */ export async function fetchTournaments(): Promise { const ids: { tournament_id: string }[] = await fetch(`${SERVER_HTTP}/tournaments`).then((r) => r.ok ? r.json() : [], ); - return Promise.all(ids.map(({ tournament_id }) => fetchTournament(tournament_id))); + const settled = await Promise.all( + ids.map(({ tournament_id }) => fetchTournament(tournament_id).catch(() => null)), + ); + return settled.filter((t): t is Tournament => t !== null); } diff --git a/crates/persistence/migrations/0008_tournament_name.sql b/crates/persistence/migrations/0008_tournament_name.sql new file mode 100644 index 0000000..b787f92 --- /dev/null +++ b/crates/persistence/migrations/0008_tournament_name.sql @@ -0,0 +1,5 @@ +-- Tournament display name, persisted so the per-wallet "claimable tournaments" +-- endpoint can label payouts/refunds from Postgres alone — the in-memory +-- tournaments map is NOT rehydrated on restart, so the claim UI can't rely on +-- GET /tournaments (which serves that map). +ALTER TABLE tournaments ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''; diff --git a/crates/persistence/src/lib.rs b/crates/persistence/src/lib.rs index b4b1e24..481c311 100644 --- a/crates/persistence/src/lib.rs +++ b/crates/persistence/src/lib.rs @@ -82,6 +82,14 @@ pub struct TournamentGameRow { pub game_result: Option, } +#[derive(Debug, sqlx::FromRow)] +pub struct ClaimableTournamentRow { + pub id: Uuid, + pub name: String, + pub buy_in: Option, + pub status: String, +} + #[derive(Debug, sqlx::FromRow)] pub struct TournamentOutboxRow { pub id: Uuid, @@ -364,9 +372,11 @@ impl Db { // -- tournament durable state ----------------------------------------- + #[allow(clippy::too_many_arguments)] pub async fn upsert_tournament( &self, id: Uuid, + name: &str, buy_in: Option<&str>, initial_secs: i64, increment_secs: i64, @@ -374,11 +384,12 @@ impl Db { players: &serde_json::Value, ) -> Result<()> { sqlx::query( - r#"INSERT INTO tournaments (id, buy_in, initial_secs, increment_secs, status, players) - VALUES ($1,$2,$3,$4,$5,$6) - ON CONFLICT (id) DO UPDATE SET status=EXCLUDED.status, players=EXCLUDED.players"#, + r#"INSERT INTO tournaments (id, name, buy_in, initial_secs, increment_secs, status, players) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, status=EXCLUDED.status, players=EXCLUDED.players"#, ) .bind(id) + .bind(name) .bind(buy_in) .bind(initial_secs) .bind(increment_secs) @@ -389,6 +400,23 @@ impl Db { Ok(()) } + /// Buy-in tournaments the wallet entered that have reached a finished state + /// (a payout or refund may be collectable on-chain). DB-sourced so it + /// survives the restart that wipes the in-memory tournaments map. `address` + /// must be lowercased (entrants are stored lowercased). + pub async fn claimable_tournaments(&self, address: &str) -> Result> { + let rows = sqlx::query_as::<_, ClaimableTournamentRow>( + r#"SELECT id, name, buy_in, status FROM tournaments + WHERE status IN ('complete','settled','abandoned') + AND buy_in IS NOT NULL + AND players @> to_jsonb($1::text)"#, + ) + .bind(address) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + pub async fn set_tournament_status(&self, id: Uuid, status: &str) -> Result<()> { sqlx::query("UPDATE tournaments SET status=$2 WHERE id=$1") .bind(id) diff --git a/crates/server/src/matchmaking.rs b/crates/server/src/matchmaking.rs index 63b1db3..c41febf 100644 --- a/crates/server/src/matchmaking.rs +++ b/crates/server/src/matchmaking.rs @@ -171,6 +171,7 @@ pub fn routes() -> Router { "/tournaments/{id}/claim/{address}", get(tourney_claim_proof), ) + .route("/tournaments/claimable/{address}", get(tourney_claimable)) } fn di() -> u64 { @@ -1284,8 +1285,8 @@ async fn tourney_create( .map_err(|_| StatusCode::BAD_GATEWAY)?; } - let (buy_in, initial_secs, increment_secs) = - (req.buy_in.clone(), req.initial_secs, req.increment_secs); + let (name, buy_in, initial_secs, increment_secs) = + (req.name.clone(), req.buy_in.clone(), req.initial_secs, req.increment_secs); state.0.lobby.tournaments.lock().insert( id, Tournament { @@ -1311,6 +1312,7 @@ async fn tourney_create( let _ = db .upsert_tournament( id, + &name, buy_in.as_deref(), initial_secs as i64, increment_secs as i64, @@ -1329,6 +1331,7 @@ async fn persist_tournament(state: &AppState, tid: Uuid) { let ts = state.0.lobby.tournaments.lock(); ts.get(&tid).map(|t| { ( + t.name.clone(), t.buy_in.clone(), t.initial_secs as i64, t.increment_secs as i64, @@ -1337,9 +1340,9 @@ async fn persist_tournament(state: &AppState, tid: Uuid) { ) }) }; - if let Some((buy_in, init, inc, status, players)) = snap { + if let Some((name, buy_in, init, inc, status, players)) = snap { let _ = db - .upsert_tournament(tid, buy_in.as_deref(), init, inc, &status, &players) + .upsert_tournament(tid, &name, buy_in.as_deref(), init, inc, &status, &players) .await; } } @@ -2078,6 +2081,40 @@ async fn tourney_claim_proof( })) } +#[derive(Serialize)] +struct ClaimableView { + tournament_id: Uuid, + name: String, + buy_in: Option, + status: String, +} + +/// DB-sourced list of the connected wallet's finished buy-in tournaments, so the +/// bankroll claim UI can surface payouts/refunds even after a restart wipes the +/// in-memory tournaments map. Read-only + best-effort: empty when there's no DB. +async fn tourney_claimable( + State(state): State, + Path(address): Path, +) -> Json> { + let Some(db) = &state.0.db else { + return Json(Vec::new()); + }; + let rows = db + .claimable_tournaments(&address.to_lowercase()) + .await + .unwrap_or_default(); + Json( + rows.into_iter() + .map(|r| ClaimableView { + tournament_id: r.id, + name: r.name, + buy_in: r.buy_in, + status: r.status, + }) + .collect(), + ) +} + #[cfg(test)] mod tests { use super::*; From 0ad94a1484df77e4e2559c576efa8b01ada5dda0 Mon Sep 17 00:00:00 2001 From: vikramarun Date: Sun, 12 Jul 2026 04:31:09 -0400 Subject: [PATCH 10/10] Re-review fixes: throttle claimable route, drop unused fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the re-review of the review-fix commit. - Rate-limit the claimable endpoint (CLAUDE.md: "a new HTTP route is unthrottled unless you add it to a throttled router"). It's the one tournament route that hits Postgres (an unindexed JSONB containment scan), so move the handler from matchmaking (unthrottled) into players::routes(), which already carries the per-IP rate_limit_reads layer. - Drop the unused buy_in from the claimable response end-to-end (Rust row + view, TS type) — on-chain buyIn is the source of truth; the WHERE still filters buy_in IS NOT NULL. - ProfileStats: stakes are non-negative, so use fmtUsdc(g.stake) directly instead of fmtUsdcSigned(...).replace("+",""). - Fix a stale comment in the tournament page (Tournament type is no longer "shared with the bankroll claim discovery" — that uses ClaimableTournament). net/stake are Decimal::from(u128) (scale 0) → integer base-unit strings, so fmtUsdcSigned's BigInt parse is correct (verified, no change needed). Co-Authored-By: Claude Opus 4.8 --- apps/web/app/tournament/page.tsx | 6 ++--- apps/web/components/ProfileStats.tsx | 4 ++-- apps/web/lib/tournaments.ts | 1 - crates/persistence/src/lib.rs | 3 +-- crates/server/src/matchmaking.rs | 35 --------------------------- crates/server/src/players.rs | 36 ++++++++++++++++++++++++++++ 6 files changed, 42 insertions(+), 43 deletions(-) diff --git a/apps/web/app/tournament/page.tsx b/apps/web/app/tournament/page.tsx index 05837d6..1e8d407 100644 --- a/apps/web/app/tournament/page.tsx +++ b/apps/web/app/tournament/page.tsx @@ -20,9 +20,9 @@ import { useMounted } from "@/lib/useMounted"; import { useOnchainConfig } from "@/lib/useOnchainConfig"; import { DEFAULT_TC, TIME_CONTROLS, type TimeControl } from "@/lib/timeControls"; -// Tournament + TournamentGame come from @/lib/tournaments (shared with the -// bankroll claim discovery). MyGame is this page's per-entrant seat view -// (tokens are NOT in the public tournament view — fetched via /my-games). +// Tournament + TournamentGame come from @/lib/tournaments. MyGame is this +// page's per-entrant seat view (tokens are NOT in the public tournament view — +// fetched via /my-games). type MyGame = { game_id: string; color: "white" | "black"; diff --git a/apps/web/components/ProfileStats.tsx b/apps/web/components/ProfileStats.tsx index 48f9222..9844ff3 100644 --- a/apps/web/components/ProfileStats.tsx +++ b/apps/web/components/ProfileStats.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { shortAddress } from "@/lib/address"; import { SERVER_HTTP } from "@/lib/config"; -import { fmtUsdcSigned } from "@/lib/escrow"; +import { fmtUsdc, fmtUsdcSigned } from "@/lib/escrow"; type Profile = { address: string; @@ -148,7 +148,7 @@ export function ProfileStats({ address }: { address: string }) { {" "} {g.reason} - {g.stake ? fmtUsdcSigned(g.stake).replace("+", "") : "—"} + {g.stake ? fmtUsdc(g.stake) : "—"} {g.moves} {g.finished_at ? new Date(g.finished_at).toLocaleDateString() : "—"} diff --git a/apps/web/lib/tournaments.ts b/apps/web/lib/tournaments.ts index f4082ec..72fc76f 100644 --- a/apps/web/lib/tournaments.ts +++ b/apps/web/lib/tournaments.ts @@ -21,7 +21,6 @@ export type Tournament = { export type ClaimableTournament = { tournament_id: string; name: string; - buy_in: string | null; status: string; }; diff --git a/crates/persistence/src/lib.rs b/crates/persistence/src/lib.rs index 481c311..b74b734 100644 --- a/crates/persistence/src/lib.rs +++ b/crates/persistence/src/lib.rs @@ -86,7 +86,6 @@ pub struct TournamentGameRow { pub struct ClaimableTournamentRow { pub id: Uuid, pub name: String, - pub buy_in: Option, pub status: String, } @@ -406,7 +405,7 @@ impl Db { /// must be lowercased (entrants are stored lowercased). pub async fn claimable_tournaments(&self, address: &str) -> Result> { let rows = sqlx::query_as::<_, ClaimableTournamentRow>( - r#"SELECT id, name, buy_in, status FROM tournaments + r#"SELECT id, name, status FROM tournaments WHERE status IN ('complete','settled','abandoned') AND buy_in IS NOT NULL AND players @> to_jsonb($1::text)"#, diff --git a/crates/server/src/matchmaking.rs b/crates/server/src/matchmaking.rs index c41febf..a3529d8 100644 --- a/crates/server/src/matchmaking.rs +++ b/crates/server/src/matchmaking.rs @@ -171,7 +171,6 @@ pub fn routes() -> Router { "/tournaments/{id}/claim/{address}", get(tourney_claim_proof), ) - .route("/tournaments/claimable/{address}", get(tourney_claimable)) } fn di() -> u64 { @@ -2081,40 +2080,6 @@ async fn tourney_claim_proof( })) } -#[derive(Serialize)] -struct ClaimableView { - tournament_id: Uuid, - name: String, - buy_in: Option, - status: String, -} - -/// DB-sourced list of the connected wallet's finished buy-in tournaments, so the -/// bankroll claim UI can surface payouts/refunds even after a restart wipes the -/// in-memory tournaments map. Read-only + best-effort: empty when there's no DB. -async fn tourney_claimable( - State(state): State, - Path(address): Path, -) -> Json> { - let Some(db) = &state.0.db else { - return Json(Vec::new()); - }; - let rows = db - .claimable_tournaments(&address.to_lowercase()) - .await - .unwrap_or_default(); - Json( - rows.into_iter() - .map(|r| ClaimableView { - tournament_id: r.id, - name: r.name, - buy_in: r.buy_in, - status: r.status, - }) - .collect(), - ) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/server/src/players.rs b/crates/server/src/players.rs index d14d25e..a66279a 100644 --- a/crates/server/src/players.rs +++ b/crates/server/src/players.rs @@ -6,6 +6,7 @@ use axum::http::StatusCode; use axum::routing::get; use axum::{Json, Router}; use serde::Serialize; +use uuid::Uuid; use crate::AppState; @@ -14,6 +15,41 @@ pub fn routes() -> Router { .route("/leaderboard", get(leaderboard)) .route("/players/{address}", get(profile)) .route("/players/{address}/games", get(games)) + // Lives here (not in matchmaking) so it inherits the read rate-limit + // layer — it's the one tournament route that hits Postgres. + .route("/tournaments/claimable/{address}", get(tourney_claimable)) +} + +#[derive(Serialize)] +struct ClaimableView { + tournament_id: Uuid, + name: String, + status: String, +} + +/// DB-sourced list of the connected wallet's finished buy-in tournaments, so the +/// bankroll claim UI can surface payouts/refunds even after a restart wipes the +/// in-memory tournaments map. Read-only + best-effort: empty when there's no DB. +async fn tourney_claimable( + State(state): State, + Path(address): Path, +) -> Json> { + let Some(db) = state.0.db.as_ref() else { + return Json(Vec::new()); + }; + let rows = db + .claimable_tournaments(&address.to_lowercase()) + .await + .unwrap_or_default(); + Json( + rows.into_iter() + .map(|r| ClaimableView { + tournament_id: r.id, + name: r.name, + status: r.status, + }) + .collect(), + ) } #[derive(Serialize)]