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..ebc968b 100644 --- a/apps/web/app/gauntlet/page.tsx +++ b/apps/web/app/gauntlet/page.tsx @@ -2,14 +2,15 @@ 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"; -import { authToken, 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 = { @@ -23,8 +24,7 @@ type Stats = { type Cur = { gameId: string; token: string; color: "white" | "black" }; export default function GauntletPage() { - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); + const mounted = useMounted(); return (
@@ -40,9 +40,8 @@ export default function GauntletPage() { } function GauntletClient() { - const { address, isConnected } = useAccount(); - const [config, setConfig] = useState(null); - const [token, setToken] = useState(null); + const token = useAuthToken(); + const { config, wagerOn } = useOnchainConfig(); const [stake, setStake] = useState(""); const [tc, setTc] = useState(DEFAULT_TC); @@ -58,13 +57,6 @@ function GauntletClient() { // Latest games-count, read without re-triggering the queue loop. const gamesRef = useRef(0); - useEffect(() => { - fetchConfig().then(setConfig); - }, []); - useEffect(() => { - setToken(authToken()); - }, [address, isConnected]); - const bot = useBotStatus(token); const botPlays = bot.online && useBot; useEffect(() => { @@ -72,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 = (() => { @@ -326,12 +317,6 @@ function GauntletClient() {
- {wagerOn && config?.escrow && ( -
- -
- )} -
Run a gauntlet
@@ -394,7 +379,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 a3f6b17..7f1742a 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -125,6 +125,105 @@ 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; +} +.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 { max-width: 1040px; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index b7c5a0b..321ee88 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,19 +1,15 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; import { Leaderboard } from "@/components/Leaderboard"; import { Lobby } from "@/components/Lobby"; import { useEngine } from "@/lib/engineContext"; +import { useMounted } from "@/lib/useMounted"; export default function Home() { const { status } = useEngine(); - const router = useRouter(); - const [addr, setAddr] = useState(""); - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); + const mounted = useMounted(); const banner = status === "ready" ? ( @@ -79,25 +75,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..916aac9 --- /dev/null +++ b/apps/web/app/profile/page.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useAccount } from "wagmi"; + +import { BrowserBotPanel } from "@/components/BrowserBotPanel"; +import { ConnectEngine } from "@/components/ConnectEngine"; +import { ProfileStats } from "@/components/ProfileStats"; +import { useMounted } from "@/lib/useMounted"; + +export default function ProfilePage() { + const mounted = useMounted(); + 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..1e8d407 100644 --- a/apps/web/app/tournament/page.tsx +++ b/apps/web/app/tournament/page.tsx @@ -4,21 +4,25 @@ 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"; -import { authToken, 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. 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"; @@ -26,20 +30,9 @@ 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, setMounted] = useState(false); - useEffect(() => setMounted(true), []); + const mounted = useMounted(); return (
@@ -55,10 +48,10 @@ export default function TournamentPage() { } function TournamentClient() { - const { address, isConnected } = useAccount(); - const [config, setConfig] = useState(null); - const [token, setToken] = useState(null); - const [tourneys, setTourneys] = useState([]); + const { address } = useAccount(); + const token = useAuthToken(); + 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,13 +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); - }, []); - useEffect(() => { - setToken(authToken()); - }, [address, isConnected]); - const bot = useBotStatus(token); // Poll tournaments. In the lobby, refresh the whole list; while playing or @@ -92,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 */ @@ -123,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( @@ -171,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( @@ -207,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 { @@ -229,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."); @@ -348,7 +321,8 @@ function TournamentClient() { <> Tournament finished 🎉

- Standings decide the pool; a winning share is credited to your bankroll. + Standings decide the pool; a winning share is credited to your bankroll — claim + any payout or refund from the wallet menu (top right).

{backBtn} @@ -389,9 +363,9 @@ 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 — claim it from + the wallet menu (top right).

) : ( @@ -415,12 +389,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..4dfddf6 --- /dev/null +++ b/apps/web/components/AuthButton.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAccount, useAccountEffect, useChainId, useSignMessage } from "wagmi"; + +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. */ +export function AuthButton() { + if (!useMounted()) 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 ensureChain = useEnsureChain(); + const { signMessageAsync } = useSignMessage(); + + const { config, wagerOn } = useOnchainConfig(); + const expected = config?.chainId ?? null; + const [signedIn, setSignedIn] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Only auto-sign once per address, so a rejected prompt doesn't loop. Reset + // when the connected account changes. + const signTried = useRef(null); + // Latest connected address, readable from inside async callbacks. + const addressRef = useRef(address); + + // 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 && authToken() && authAddress() !== key) clearAuth(); + setSignedIn(!!authToken() && !!key && authAddress() === key); + signTried.current = null; + setError(null); + }, [address]); + + useAccountEffect({ + onDisconnect() { + clearAuth(); + setSignedIn(false); + }, + }); + + const runSignIn = useCallback(async () => { + if (!address || expected == null) return; + const signingFor = address.toLowerCase(); + setError(null); + setBusy(true); + try { + 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. + if (addressRef.current?.toLowerCase() !== signingFor) { + clearAuth(); + return; + } + setSignedIn(true); + } catch (e: any) { + setError(e?.shortMessage ?? e?.message ?? "sign-in failed"); + } finally { + setBusy(false); + } + }, [address, expected, signMessageAsync, ensureChain]); + + const ready = isConnected && !!address && expected != null; + + // Auto-complete sign-in once connected on a wagering server: runSignIn + // switches to the expected chain (if needed) and then prompts the SIWE + // signature, so this is the whole connect → switch → sign flow in one step. + useEffect(() => { + if (!ready || !wagerOn || signedIn || busy) return; + const key = address!.toLowerCase(); + if (signTried.current === key) return; + signTried.current = key; + runSignIn(); + }, [ready, wagerOn, signedIn, busy, 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 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 ( + + ); + }} + + ); +} diff --git a/apps/web/components/BankrollPanel.tsx b/apps/web/components/BankrollPanel.tsx index ecb2a27..f95152d 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,8 +23,10 @@ export function BankrollPanel({ }) { const { address, isConnected } = useAccount(); const chainId = useChainId(); - const { switchChainAsync } = useSwitchChain(); - const publicClient = usePublicClient(); + const ensureChain = useEnsureChain(); + // 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(""); @@ -77,10 +79,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 +91,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 +134,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 new file mode 100644 index 0000000..8a4a50d --- /dev/null +++ b/apps/web/components/ClaimWinnings.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useAccount } from "wagmi"; + +import { TournamentClaim } from "@/components/TournamentClaim"; +import { fetchClaimableTournaments } from "@/lib/tournaments"; + +type Candidate = { id: string; name: string; status: string }; + +/** Tournament payouts / refunds, surfaced alongside the bankroll (they credit + * the same escrow balance) instead of scattered across the tournament page. + * Discovers the connected wallet's finished buy-in tournaments and renders a + * claim / refund per one that has something to collect. Mounted only inside the + * open wallet popover, so the discovery fetch is lazy. */ +export function ClaimWinnings({ escrow, chainId }: { escrow: `0x${string}`; chainId: number }) { + const { address, isConnected } = useAccount(); + const [items, setItems] = useState([]); + // Which candidates actually rendered an action — used to hide the header when + // nothing is claimable (e.g. small fields were credited to bankroll directly). + const [resolved, setResolved] = useState>({}); + + const onResolved = useCallback((id: string, has: boolean) => { + setResolved((prev) => (prev[id] === has ? prev : { ...prev, [id]: has })); + }, []); + + 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([]); + return; + } + let live = true; + (async () => { + try { + // 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([]); + } + })(); + return () => { + live = false; + }; + }, [address, isConnected]); + + if (!isConnected || !address || items.length === 0) return null; + + // Keep the panel mounted (children need to run their on-chain reads) but hide + // it until at least one tournament resolves to something claimable. + const anyClaimable = Object.values(resolved).some(Boolean); + + return ( +
+ Tournament winnings +
+ {items.map((t) => ( + onResolved(t.id, has)} + /> + ))} +
+
+ ); +} diff --git a/apps/web/components/ConnectEngine.tsx b/apps/web/components/ConnectEngine.tsx new file mode 100644 index 0000000..01bd40c --- /dev/null +++ b/apps/web/components/ConnectEngine.tsx @@ -0,0 +1,306 @@ +"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 { useAuthToken } from "@/lib/useAuthToken"; + +/** 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() { + // Reactive: reflects a sign-in / sign-out that happens on the same page (e.g. + // the header AuthButton on /profile) without a reload. + const token = useAuthToken(); + 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(() => { + setOpts(loadBotOptions()); + setPlatform(guessPlatform()); + }, []); + + // Session changed (signed out, or switched wallet) → drop the stale pairing + // code so a fresh one mints for the new session instead of pairing the engine + // to the previous wallet. + useEffect(() => { + setCode(null); + setCodeErr(null); + }, [token]); + + // 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..7ea09b7 100644 --- a/apps/web/components/Lobby.tsx +++ b/apps/web/components/Lobby.tsx @@ -5,15 +5,15 @@ 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"; import { browserEngineLabel, getBrowserBotConfig } from "@/lib/browserBot"; import { SERVER_HTTP } from "@/lib/config"; -import { authToken, 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 { @@ -79,9 +79,9 @@ const seatLabel = (name: string | null, addr: string | null, fallback: string) = * challenge (your engine vs theirs), watch games in progress, or stake USDC. */ export function Lobby() { const router = useRouter(); - const { address, isConnected } = useAccount(); - const [config, setConfig] = useState(null); - const [token, setToken] = useState(null); + const { address } = useAccount(); + const token = useAuthToken(); + const { config, wagerOn } = useOnchainConfig(); const [offers, setOffers] = useState([]); const [live, setLive] = useState([]); const [err, setErr] = useState(null); @@ -93,13 +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); - }, []); - useEffect(() => { - setToken(authToken()); - }, [address, isConnected]); - const bot = useBotStatus(token); const botPlays = bot.online && useBot; @@ -175,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 = @@ -345,7 +337,7 @@ export function Lobby() { <> {" "} Want your own engine to play instead?{" "} - Connect it. + Connect it. )}
@@ -503,15 +495,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..9844ff3 --- /dev/null +++ b/apps/web/components/ProfileStats.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { shortAddress } from "@/lib/address"; +import { SERVER_HTTP } from "@/lib/config"; +import { fmtUsdc, fmtUsdcSigned } from "@/lib/escrow"; + +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; +}; + +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 ? fmtUsdcSigned(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 ? fmtUsdc(g.stake) : "—"}{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/TournamentClaim.tsx b/apps/web/components/TournamentClaim.tsx new file mode 100644 index 0000000..93e80a5 --- /dev/null +++ b/apps/web/components/TournamentClaim.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useEffect, useState } from "react"; +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)}`; + +/** 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, + label, + onResolved, +}: { + tid: string; + status: string; + escrow: `0x${string}`; + chainId: number; + /** Optional tournament name shown above the action (for the bankroll list). */ + label?: string; + /** 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; +}) { + const { address, isConnected } = useAccount(); + const ensureChain = useEnsureChain(); + // 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); + 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]); + + const now = Math.floor(Date.now() / 1000); + const refundReady = + !settled && settleTimeout != null && now > openedAt + settleTimeout && !hasClaimed; + + // 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 (kind == null) return null; + + const run = async (fn: () => Promise<`0x${string}`>) => { + setError(null); + setBusy(true); + try { + await ensureChain(expected); + const hash = await fn(); + await publicClient!.waitForTransactionReceipt({ hash }); + refetchTourn(); + refetchClaimed(); + } 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; + + // 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 (kind === "claim") { + // Winner of a root-settled field → Merkle claim (proof is set when kind==="claim"). + node = ( + + ); + } else if (kind === "refund") { + // Never settled past the timeout → reclaim the buy-in. + node = ( + + ); + } else { + // pending: abandoned but the refund window hasn't opened yet — say when. + 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`; + node = ( + + Refund available in {dur} + + ); + } + + return ( + + {label && ( + + {label} + + )} + {node} + {errLine} + + ); +} 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..590f957 --- /dev/null +++ b/apps/web/components/WalletMenu.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useAccount } from "wagmi"; + +import { BankrollPanel } from "@/components/BankrollPanel"; +import { ClaimWinnings } from "@/components/ClaimWinnings"; +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. */ +export function WalletMenu() { + if (!useMounted()) 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, wagerOn } = useOnchainConfig(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + // 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]); + + // The pill only needs a slow background refresh; an open BankrollPanel shares + // the same query key and drives faster polling while it's on screen. + const { available } = useAvailable(config?.escrow, { refetchInterval: 30000 }); + + 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..c6e611c 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). */ @@ -50,12 +60,44 @@ export async function fetchConfig(): Promise { return configCache; } +/** Fired (same-tab) whenever the stored session changes, so useAuthToken + * subscribers re-read instead of snapshotting a stale token. Cross-tab changes + * arrive via the native `storage` event. */ +export const AUTH_EVENT = "chess-auth-changed"; + +function notifyAuthChanged() { + if (typeof window !== "undefined") window.dispatchEvent(new Event(AUTH_EVENT)); +} + /** The stored SIWE session token (set by the sign-in flow). */ export function authToken(): string | null { if (typeof window === "undefined") return 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"); +} + +/** Persist a SIWE session bound to the wallet it was issued for, and notify + * subscribers. */ +export function setAuth(token: string, address: string) { + if (typeof window === "undefined") return; + localStorage.setItem("chess_token", token); + localStorage.setItem("chess_addr", address.toLowerCase()); + notifyAuthChanged(); +} + +/** 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"); + notifyAuthChanged(); +} + /** USDC display string (base units → "1.50"). */ export function fmtUsdc(base: bigint | string | number | undefined | null): string { if (base === undefined || base === null) return "—"; @@ -66,7 +108,48 @@ 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); } + +/** 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; + } +} diff --git a/apps/web/lib/siwe.ts b/apps/web/lib/siwe.ts index 406e2ce..51878ad 100644 --- a/apps/web/lib/siwe.ts +++ b/apps/web/lib/siwe.ts @@ -1,4 +1,5 @@ import { SERVER_HTTP } from "./config"; +import { setAuth } from "./escrow"; /** Run the SIWE flow: fetch nonce, sign an EIP-4361 message, verify, store the * session token. `signMessageAsync` comes from wagmi's useSignMessage. */ @@ -32,6 +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) and notify useAuthToken subscribers. + setAuth(token, address); return token; } diff --git a/apps/web/lib/tournaments.ts b/apps/web/lib/tournaments.ts new file mode 100644 index 0000000..72fc76f --- /dev/null +++ b/apps/web/lib/tournaments.ts @@ -0,0 +1,56 @@ +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; +}; + +export type ClaimableTournament = { + tournament_id: string; + name: string; + 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 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 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() : [], + ); + 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/apps/web/lib/useAuthToken.ts b/apps/web/lib/useAuthToken.ts new file mode 100644 index 0000000..0dd1e42 --- /dev/null +++ b/apps/web/lib/useAuthToken.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { AUTH_EVENT, authToken } from "./escrow"; + +/** The current SIWE session token, reactive to sign-in / sign-out and cross-tab + * changes. Consumers use this instead of snapshotting authToken() into their + * own state, so a sign-in completed elsewhere (e.g. the header AuthButton's + * auto-SIWE) or a session cleared on account switch propagates immediately — + * no stale "sign in to stake" errors or dead pairing codes until reload. */ +export function useAuthToken(): string | null { + const [token, setToken] = useState(null); + useEffect(() => { + const sync = () => setToken(authToken()); + sync(); + window.addEventListener(AUTH_EVENT, sync); + window.addEventListener("storage", sync); + return () => { + window.removeEventListener(AUTH_EVENT, sync); + window.removeEventListener("storage", sync); + }; + }, []); + return token; +} diff --git a/apps/web/lib/useBankroll.ts b/apps/web/lib/useBankroll.ts index 21e98ed..54e253d 100644 --- a/apps/web/lib/useBankroll.ts +++ b/apps/web/lib/useBankroll.ts @@ -7,7 +7,10 @@ import { ESCROW_ABI } from "./escrow"; /** Read the connected wallet's available (unlocked) escrow balance in USDC base * units. Used to gate wager actions before they fail on-chain. Returns * `undefined` while loading / when no escrow or wallet. */ -export function useAvailable(escrow?: `0x${string}` | null): { +export function useAvailable( + escrow?: `0x${string}` | null, + opts?: { refetchInterval?: number }, +): { available: bigint | undefined; refetch: () => void; } { @@ -19,7 +22,10 @@ export function useAvailable(escrow?: `0x${string}` | null): { args: address ? [address] : undefined, query: { enabled: !!escrow && !!address, - refetchInterval: 8000, + // Shared query key across observers, so react-query polls at the smallest + // active interval — the header pill can idle slower while an open + // BankrollPanel keeps it fresh. + refetchInterval: opts?.refetchInterval ?? 8000, }, }); return { available: data as bigint | undefined, refetch }; 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/useMounted.ts b/apps/web/lib/useMounted.ts new file mode 100644 index 0000000..8ecd6c8 --- /dev/null +++ b/apps/web/lib/useMounted.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** True only after the first client render. Used to gate components that touch + * browser-only APIs or the client-only WagmiProvider (see app/providers.tsx), + * so their markup matches during SSR / static prerender. */ +export function useMounted(): boolean { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return mounted; +} 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 }; +} diff --git a/crates/ledger/src/lib.rs b/crates/ledger/src/lib.rs index f405166..51eaf42 100644 --- a/crates/ledger/src/lib.rs +++ b/crates/ledger/src/lib.rs @@ -810,4 +810,181 @@ mod tests { ); Ok(()) } + + /// Live end-to-end tournament **claim + refund** against a real chain (Base + /// Sepolia). Runs the exact production Merkle code — `merkle_root` / + /// `merkle_proof` / `tournament_leaf` — plus the same EIP-712 root-settle the + /// server's `OnchainSettlement` performs, so it proves a Rust-built proof + /// verifies against the deployed Solidity `_verifyProof`, on real block timing + /// and gas (the Anvil test above only covers a local dev chain, and nothing + /// else exercises `claimRefund`). One provider sends every `me`-signed tx to + /// keep nonces in step — see the note below. + /// + /// Opt-in and `#[ignore]`d: never runs in a normal `cargo test`. Drive it via + /// `scripts/test-sepolia-tournament.sh`, which sets: + /// LEDGER_TEST_RPC — Base Sepolia RPC URL + /// LEDGER_TEST_KEY — a funded testnet private key (deployer/oracle/entrant) + /// LEDGER_TEST_TIMEOUT — refund window in seconds (default 30) + /// It deploys throwaway MockUSDC + escrows, so it's repeatable and the refund + /// window is short enough to wait out. + #[tokio::test] + #[ignore = "live testnet run; use scripts/test-sepolia-tournament.sh"] + async fn tournament_claim_and_refund_live() -> anyhow::Result<()> { + let (rpc, key) = match ( + std::env::var("LEDGER_TEST_RPC"), + std::env::var("LEDGER_TEST_KEY"), + ) { + (Ok(r), Ok(k)) => (r, k), + _ => { + eprintln!( + "skipping tournament_claim_and_refund_live: set LEDGER_TEST_RPC + LEDGER_TEST_KEY" + ); + return Ok(()); + } + }; + let refund_timeout: u64 = std::env::var("LEDGER_TEST_TIMEOUT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(30); + + let url = rpc.parse::()?; + let signer: PrivateKeySigner = key.parse()?; // deployer + oracle + the funded entrant + let me = signer.address(); + let provider = ProviderBuilder::new() + .wallet(EthereumWallet::from(signer.clone())) + .connect_http(url.clone()); + // A fee recipient distinct from the entrant (entering as the fee sink reverts). + let fee_recipient = Address::from([0xEE; 20]); + let explorer = "https://sepolia.basescan.org/tx"; + eprintln!("live tournament test: rpc={rpc} signer={me} refund_timeout={refund_timeout}s"); + + let usdc = MockUSDC::deploy(&provider).await?; + // Long window for the claim flow (settle never races the timeout); short + // window for the refund flow (so we can actually wait past it). + let escrow_pay = + ChessEscrow::deploy(&provider, *usdc.address(), me, fee_recipient, 0u16, 3600u64).await?; + let escrow_ref = + ChessEscrow::deploy(&provider, *usdc.address(), me, fee_recipient, 0u16, refund_timeout) + .await?; + eprintln!( + "deployed: usdc={} escrow_pay={} escrow_ref={}", + usdc.address(), + escrow_pay.address(), + escrow_ref.address() + ); + + // mint + approve + deposit `amt` into `me`'s bankroll on `escrow`. + let fund = |escrow: Address, amt: U256| { + let provider = &provider; + let usdc_addr = *usdc.address(); + async move { + MockUSDC::new(usdc_addr, provider) + .mint(me, amt) + .send() + .await? + .get_receipt() + .await?; + MockUSDC::new(usdc_addr, provider) + .approve(escrow, amt) + .send() + .await? + .get_receipt() + .await?; + ChessEscrow::new(escrow, provider) + .deposit(amt) + .send() + .await? + .get_receipt() + .await?; + anyhow::Ok(()) + } + }; + + // Everything `me` signs goes through this ONE provider so nonces stay in + // step (the escrow's oracle is `me`, so `signer` also signs the EIP-712 + // root). This mirrors OnchainSettlement — itself covered by the Anvil test + // above — while keeping a single sender to avoid cross-provider nonce + // races; the Merkle tree is the real production code either way. + + // ---- payout claim flow (root-settled, "large" field) ---- + // One large entry funds the pool; the payout tree is decoupled from the + // entrant set (the contract never ties leaves to entrants), so a single + // key can stand in for a full field. buy_in covers a 17-leaf tree. + let buy_in = U256::from(17_000_000u64); // 17 USDC (6dp) + let leaf_amt = U256::from(1_000_000u64); // 1 USDC per winner leaf + fund(*escrow_pay.address(), buy_in).await?; + let pay = ChessEscrow::new(*escrow_pay.address(), &provider); + let tid = Uuid::new_v4(); + let gid = game_id_to_bytes32(tid); + pay.openTournament(gid, buy_in).send().await?.get_receipt().await?; + pay.enterTournament(gid, me).send().await?.get_receipt().await?; // pool = 17 USDC + + // 17 leaves: index 0 is our test winner, the rest synthetic. Total == pool. + let winner = Address::from([0x11; 20]); + let mut leaves = vec![(winner, leaf_amt)]; + for i in 0u8..16 { + leaves.push((Address::from([0x40 + i; 20]), leaf_amt)); + } + let leaf_hashes: Vec = leaves + .iter() + .map(|(a, amt)| tournament_leaf(*a, *amt)) + .collect(); + let root = merkle_root(&leaf_hashes); + let total = leaves.iter().fold(U256::ZERO, |acc, (_, amt)| acc + *amt); + let deadline = U256::from(unix_now().saturating_add(3600)); + let digest = pay + .digestTournamentRoot(gid, root, total, deadline) + .call() + .await?; + let sig = signer.sign_hash(&digest).await?; + let v: u8 = if sig.v() { 28 } else { 27 }; + pay.settleTournamentRoot(gid, root, total, deadline, v, B256::from(sig.r()), B256::from(sig.s())) + .send() + .await? + .get_receipt() + .await?; + eprintln!("root committed: root={root} tid_bytes32={gid} (== web tidToBytes32)"); + + let proof = merkle_proof(&leaf_hashes, 0); + let before = pay.bankroll(winner).call().await?; + let rcpt = pay + .claimTournament(gid, winner, leaf_amt, proof.clone()) + .send() + .await? + .get_receipt() + .await?; + let after = pay.bankroll(winner).call().await?; + assert_eq!(after - before, leaf_amt, "payout credited to winner bankroll"); + eprintln!("claim OK: winner bankroll += 1 USDC tx={}/{}", explorer, rcpt.transaction_hash); + + // A second claim must revert (AlreadyClaimed). Assert via a static call + // (eth_call) — an actual send that reverts would leave the sender's cached + // nonce with a phantom gap and stall the next transaction. + let dbl = pay + .claimTournament(gid, winner, leaf_amt, proof) + .call() + .await; + assert!(dbl.is_err(), "double-claim must revert (AlreadyClaimed)"); + eprintln!("double-claim rejected ✓"); + + // ---- refund flow (never settled past the timeout) ---- + fund(*escrow_ref.address(), buy_in).await?; + let re = ChessEscrow::new(*escrow_ref.address(), &provider); + let tid2 = Uuid::new_v4(); + let gid2 = game_id_to_bytes32(tid2); + re.openTournament(gid2, buy_in).send().await?.get_receipt().await?; + re.enterTournament(gid2, me).send().await?.get_receipt().await?; // bankroll[me] -= buy_in + let before_ref = re.bankroll(me).call().await?; + + // Wait out the settle window (opened ~now; over-wait to clear the boundary). + let wait = refund_timeout + 15; + eprintln!("waiting {wait}s for the refund window to open…"); + std::thread::sleep(std::time::Duration::from_secs(wait)); + let rcpt2 = re.claimRefund(gid2, me).send().await?.get_receipt().await?; + let after_ref = re.bankroll(me).call().await?; + assert_eq!(after_ref - before_ref, buy_in, "refund restored the buy-in"); + eprintln!("refund OK: entrant bankroll += buy-in tx={}/{}", explorer, rcpt2.transaction_hash); + eprintln!("LIVE TOURNAMENT CLAIM + REFUND: PASS"); + Ok(()) + } } 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/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 17cfa9b..b74b734 100644 --- a/crates/persistence/src/lib.rs +++ b/crates/persistence/src/lib.rs @@ -82,6 +82,13 @@ pub struct TournamentGameRow { pub game_result: Option, } +#[derive(Debug, sqlx::FromRow)] +pub struct ClaimableTournamentRow { + pub id: Uuid, + pub name: String, + pub status: String, +} + #[derive(Debug, sqlx::FromRow)] pub struct TournamentOutboxRow { pub id: Uuid, @@ -364,9 +371,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 +383,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 +399,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, 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) @@ -594,19 +621,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 +757,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(()) + } } diff --git a/crates/server/src/matchmaking.rs b/crates/server/src/matchmaking.rs index 63b1db3..a3529d8 100644 --- a/crates/server/src/matchmaking.rs +++ b/crates/server/src/matchmaking.rs @@ -1284,8 +1284,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 +1311,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 +1330,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 +1339,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; } } 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)] diff --git a/scripts/test-sepolia-tournament.sh b/scripts/test-sepolia-tournament.sh new file mode 100755 index 0000000..64c525f --- /dev/null +++ b/scripts/test-sepolia-tournament.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Live Base Sepolia verification of the tournament CLAIM + REFUND money paths. +# +# Runs the env-gated, #[ignore]d integration test `tournament_claim_and_refund_live` +# (crates/ledger/src/lib.rs) against a real chain. It exercises the exact +# production code — merkle_root / merkle_proof / tournament_leaf + OnchainSettlement +# — so it proves: +# * a Rust-built Merkle proof verifies against the deployed Solidity _verifyProof +# (claimTournament credits the winner's bankroll), on real block timing + gas +# * a double-claim is rejected (AlreadyClaimed) +# * an entrant reclaims their buy-in after the settle window (claimRefund) +# * game_id_to_bytes32(tid) == the web app's tidToBytes32(tid) (printed to compare) +# +# It DEPLOYS throwaway MockUSDC + escrows each run (so it's repeatable and the +# refund window is short enough to wait out) — it does NOT touch the live mainnet +# deployment. What it does NOT cover: the browser button itself (needs a wallet + +# running server); see the manual step printed at the end. +# +# Usage: +# LEDGER_TEST_RPC=https://sepolia.base.org \ +# LEDGER_TEST_KEY=0x \ +# [LEDGER_TEST_TIMEOUT=30] \ +# scripts/test-sepolia-tournament.sh +# +# The key must hold a little Base Sepolia ETH for gas (2 deploys + ~10 txs; +# ~0.02 ETH is plenty). Faucet: https://docs.base.org/docs/tools/network-faucets +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +: "${LEDGER_TEST_RPC:?set LEDGER_TEST_RPC to a Base Sepolia RPC URL (e.g. https://sepolia.base.org)}" +: "${LEDGER_TEST_KEY:?set LEDGER_TEST_KEY to a funded Base Sepolia private key (0x...)}" +LEDGER_TEST_TIMEOUT="${LEDGER_TEST_TIMEOUT:-30}" + +echo "== Base Sepolia tournament claim/refund test ==" +echo " rpc = $LEDGER_TEST_RPC" +echo " refund window = ${LEDGER_TEST_TIMEOUT}s" + +# Best-effort gas sanity check (skipped if foundry's `cast` isn't installed). +if command -v cast >/dev/null 2>&1; then + ADDR="$(cast wallet address --private-key "$LEDGER_TEST_KEY" 2>/dev/null || true)" + if [ -n "$ADDR" ]; then + BAL="$(cast balance "$ADDR" --rpc-url "$LEDGER_TEST_RPC" 2>/dev/null || echo 0)" + echo " signer = $ADDR" + echo " balance (wei) = $BAL" + if [ "$BAL" = "0" ]; then + echo "!! signer has 0 ETH on this RPC — fund it from a Base Sepolia faucet first." >&2 + exit 1 + fi + fi +else + echo " (install foundry's \`cast\` for a pre-flight balance check)" +fi + +echo "== running (deploys throwaway contracts; refund step waits out the window) ==" +cd "$ROOT" +LEDGER_TEST_RPC="$LEDGER_TEST_RPC" \ +LEDGER_TEST_KEY="$LEDGER_TEST_KEY" \ +LEDGER_TEST_TIMEOUT="$LEDGER_TEST_TIMEOUT" \ + cargo test -p ledger tournament_claim_and_refund_live \ + -- --ignored --nocapture --exact tests::tournament_claim_and_refund_live + +cat <<'EOF' + +== on-chain paths verified. Optional manual web check == +The automated test covers the contract + Merkle/settlement layer. To also +click the actual buttons in the web app against a fresh escrow: + 1. Note the escrow_pay address the test printed above. + 2. Run the server pointed at it (a settled root tournament whose UUID matches + the printed tid), e.g.: + RPC_URL=$LEDGER_TEST_RPC ESCROW_ADDR= ORACLE_KEY=$LEDGER_TEST_KEY \ + SIWE_CHAIN_ID=84532 cargo run -p server + 3. In apps/web (pnpm dev), open /tournament with the winner wallet connected — + the "Claim USDC" button should appear and complete; for an + abandoned tournament the "Claim refund" button appears past the timeout. +EOF +echo "DONE"