diff --git a/CLAUDE.md b/CLAUDE.md index de90e5c9..5d13bfba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,7 @@ New battles run through the backend-authoritative path (`BATTLE_BACKEND_MODE_ENA ### Settle keeper: the second EVM breed/mint transaction isn't the player's Battles no longer take this path at all as of §L Phase 6 (above). Breed and mint still do — they have no backend-authoritative equivalent and continue to settle on chain. -`GameLogic`'s async flows (`requestBattle`/`requestCreateFromDNA`/`requestMintStarter` → Pyth Entropy reveals → `settleX`) used to have the frontend send the settle transaction itself, meaning two wallet prompts per action even though settle is permissionless. A backend service, `backend/src/features/settle-keeper/`, now watches Pyth Entropy's `Revealed` event and sends the settle transaction from its own wallet; the frontend only falls back to prompting the player if the keeper hasn't settled within ~45s (keeper outage or not configured). Gated by `KEEPER_ENABLED` (off by default); see `backend/env.example` for the full var list. This fixes only the double-signature UX; the related security fix — `requestBattle` snapshotting sim inputs so a level-up between request and settle can't reroll a committed battle — already lives in `GameLogic.sol` itself. See `docs/plan-realtime-battle-ux.md` / `docs/plan-realtime-battle-impl.md` for the full design and threat model. +`GameLogic`'s async flows (`requestBattle`/`requestCreateFromDNA`/`requestMintStarter` → Pyth Entropy reveals → `settleX`) used to have the frontend send the settle transaction itself, meaning two wallet prompts per action even though settle is permissionless. A backend service, `backend/src/features/settle-keeper/`, now watches Pyth Entropy's `Revealed` event and sends the settle transaction from its own wallet; the frontend only falls back to prompting the player if the keeper hasn't settled within ~45s (keeper outage or not configured). Gated by `KEEPER_ENABLED` (off by default); see `backend/env.example` for the full var list. This fixes only the double-signature UX; the related security fix — `requestBattle` snapshotting sim inputs so a level-up between request and settle can't reroll a committed battle — already lives in `GameLogic.sol` itself. ### Battle fee funds the settle keeper's own gas (EVM) — retired Retired with the on-chain battle path (§L Phase 6): `GameConfig.battleFee`, `setBattleFee`, and `useFees().battleFee` are all gone, and the keeper settles breed and mint only. Kept because the `GameConfig` migration it forced is still live and its env-staleness warning still applies. The original text follows. @@ -148,7 +148,7 @@ Retired with the on-chain battle path (§L Phase 6): `GameConfig.battleFee`, `se The EVM settle keeper (above) sends `settleBattle` from its own wallet, but until this was added that transaction (~800k gas, `SETTLE_GAS_LIMIT` in `backend/src/features/settle-keeper/abi.ts`) was entirely unfunded — the player's `requestBattle` payment only ever covered the Pyth Entropy fee. `GameConfig.battleFee` (owner-tunable via `setBattleFee`) is now required on top of the entropy fee at `requestBattle` time, escrowed in the pending record, and refunded on `cancelBattle` (no settle tx is ever sent for a cancelled request); on a normal settle it just adds to the contract's withdrawable balance alongside the other protocol fees — there's no automatic reimbursement to the keeper wallet specifically, so it still needs manual top-ups from `withdraw()` proceeds. The frontend surfaces this via `useFees().battleFee` (chain-neutral — see the Solana section below) and shows it in the Start Battle button label. Because `GameConfig` isn't behind a proxy (see its own doc comment), adding this field required a fresh `GameConfig` deployment plus a new `setGameConfig(address)` setter on both `GameLogic` and `PetCore` (added together, deliberately — `PetCore` reads several other config values like `battleCooldown`/`poolSizes`, and pointing only one proxy at a new instance would let the two silently diverge). `scripts/upgrade-game-config.ts` handles the migration: it replays every existing tunable from the old `GameConfig` onto the new one before repointing anything, so live-tuned values (fees, skill balance, cooldowns) aren't reset to source defaults. This has been run against the live Base Sepolia deployment; any client env (`VITE_GAMECONFIG_ADDRESS`, `KEEPER_GAME_CONFIG_ADDRESS`) pointing at the old `GameConfig` address needs updating too, or fee reads fail outright (the old contract has no `battleFee()` at all) — check `frontend/.env`/`.env.local` and `backend/.env` aren't stale before assuming a deployment issue is something else. ### Solana battles are retired too (§L Phase 6) -`commit_battle`/`settle_battle`/`cancel_battle`, the `BattleRequest` account, the Solana settle keeper (`backend/src/features/settle-keeper-solana/`), and `GlobalState.battle_fee_lamports` are all gone. Solana battles now take the same backend-authoritative path as EVM ones, so `settle_breed`/`settle_mint` are the only remaining commit/settle flows, and both still require the player's own signature (their Metaplex Core mint CPI needs a real payer signature — see `docs/plan-realtime-battle-solana.md` Workstream S2 for why the keeper never generalized to them). +`commit_battle`/`settle_battle`/`cancel_battle`, the `BattleRequest` account, the Solana settle keeper (`backend/src/features/settle-keeper-solana/`), and `GlobalState.battle_fee_lamports` are all gone. Solana battles now take the same backend-authoritative path as EVM ones, so `settle_breed`/`settle_mint` are the only remaining commit/settle flows, and both still require the player's own signature: their Metaplex Core mint CPI needs a real payer signature, which is why the keeper never generalized to them. One thing deliberately stayed: `game/battle_sim.rs` and `game/xp.rs` have no caller left in the program but are **frozen, not deleted**. With `CombatSim.sol` gone, their golden-vector tests are the *only* remaining independent witness that `contracts/test-vectors/{battle,xp}.json` describe what actually settled on chain — the two live ports are the things those vectors check, so they cannot vouch for them. @@ -187,7 +187,9 @@ Both apply the same rule: a pet with a progress row shows backend progression, o The merge is deliberately **not** in `roster.repository.ts`. `snapshot.builder.ts` seeds a first progress row from the roster's on-chain level and `intent.service.ts` checks ownership there; merging in the repository would feed overlaid progression back into the thing that produces it. -Matchmaking is the exception to the two-site split above: `findReadyOpponents` filters, bands and orders on level and cooldown, so it merges in the query itself (a raw `LEFT JOIN` against `pet_battle_progress`) rather than being overlaid afterwards. A post-filter can only drop rows a page already holds, which fixes the cooldown and leaves the level band reading frozen values. The cost is that this one query has **no gRPC fast path**: indexer-go's cache holds chain state and has no view of `pet_battle_progress`, a backend-owned table, so it can no longer answer it correctly. `getPetById` keeps its cache path, because there the resolver does the merge. +Two reads are exceptions to the two-site split above, and for the same reason: what they merge is also what they filter or order on, so the merge has to happen in the query. `findReadyOpponents` filters, bands and orders on level and cooldown; `leaderboard.repository.ts` (pet board, player board, `findPlayerRank`) ranks on win/loss. Both do a raw `LEFT JOIN` against `pet_battle_progress` rather than being overlaid afterwards, because an overlay applied to a page can only rewrite rows the query already chose — for matchmaking that fixes the cooldown and leaves the level band reading frozen values, and for the leaderboard it produces a page ordered by numbers nobody ranked on. The cost is that these queries have **no gRPC fast path**: indexer-go's cache holds chain state and has no view of `pet_battle_progress`, a backend-owned table, so it cannot answer them correctly. `getPetById` keeps its cache path, because there the resolver does the merge. + +The leaderboard is worth knowing about specifically because the naive version of it is wrong in a way that looks right: ranking on `pet_roster.win_count`/`loss_count` alone returns **nothing** on a deployment whose battles are all backend-settled, since those columns froze at §L Phase 6. `docs/plan-future-features-roadmap.md` §1 originally proposed exactly that; its "Built" banner records the correction. ### battle_history comes from the receipt now, not the indexer `battle_history` feeds the AI dialogue service's rivalry / head-to-head context. It used to be written by the indexer from on-chain settle events, with the dialogue endpoint filling gaps from the client's own result report. Both sources are gone: there are no settle events, and a client-reported result must never be able to restate what was signed. @@ -198,6 +200,16 @@ The dialogue endpoint's anti-forgery guard still exists but now compares the cli Consequently `src/grpc/battleStream.ts` is deleted: nothing published to it after indexer-go stopped ingesting battles, and nothing read from it after this. `INDEXER_GRPC_ADDR` still matters for pet-state reads and win estimates. `StreamLiveBattles` and `ListReadyOpponents` are gone from `proto/cryptopets.proto` too, along with indexer-go's whole `BattleEvent` pipeline — no adapter had published one since battles left the chain. +### Every WebSocket channel goes through one upgrade listener +`backend/src/ws/channel.ts` owns the process's single `upgrade` handler and dispatches on path; `battleRoomSocket.ts` and `chatSocket.ts` are thin registrations on top of it. Do **not** add a channel by constructing `new WebSocketServer({ server, path })`. That attaches one upgrade listener *per instance* to the same HTTP server, Node calls every listener on every upgrade, so each connection is handled twice, the client gets two HTTP 101 responses, and the second is parsed as a frame — `RangeError: Invalid WebSocket frame: RSV1 must be clear`. It breaks **every** channel, not just the new one, and per-channel tests will not catch it because each builds its own HTTP server with a single channel attached (`tests/ws/channel.test.ts` is the regression test that does). + +A channel may declare an authorizer, which runs before the handshake so a refused client never becomes a subscriber at all. The battle room has none: its frames carry nothing a client could not re-fetch, which is the only thing that makes an anonymous socket acceptable. Chat has one, and needed it for presence — "is my counterpart online" is a claim about identities, and an anonymous socket has none, so counting connections would report one person with two tabs as two people. Presence therefore counts identities, not sockets. The JWT arrives as a WebSocket subprotocol rather than a query parameter: browsers cannot set headers on a WebSocket, and a URL-borne token is recorded by proxies and access logs. + +### Private chat (roadmap §2 v1): access is derived per request, never stored +`chat_thread` deliberately does not record the marriage that justifies it. Every read, send, and socket upgrade rechecks `pet_roster.spouse_id` through `chat.service.authorizeThread`, so a divorce closes the conversation the moment the indexer sees it, with no revocation step that could be forgotten. The thread row survives — deleting it would destroy the history — it just stops answering. + +Two consequences worth keeping: a non-participant gets **404, not 403**, identical to a thread that does not exist, because 403 would confirm a thread id to anyone probing; and the caller is normalized (`normalizeAccount`) at the service boundary, since the caller doubles as the thread's participant key and an unnormalized spelling would open a second thread beside the first and split the conversation. + ### Known v1 contract limitations (design context, not regressions to "fix") `contracts/plan-contract-upgrade.md` documents intentional v1 gaps that v2 is designed around: no battle authorization (anyone can call `battle()`/`attack()` on anyone's pets), an EVM `changeDna` cheat that lets a level-20 pet set arbitrary DNA, and a Solana `create_starter_pet` that accepts client-supplied dna/rarity. v2 plan: EVM moves to UUPS proxies (`PetCoreProxy` + `GameLogicProxy`, with `CombatSimV1` deployed as a separate contract to stay under the 24KB bytecode ceiling); Solana adds versioned/reserved-space accounts and migrates pets to Metaplex Core NFTs. This is a plan doc; check current contract source before assuming any of it is implemented. @@ -219,8 +231,28 @@ Backend auth is nonce, then wallet-signature, then JWT (`backend/README.md`), gu ### Testing conventions See `docs/testing.md` for the full per-package suite table. Test work is expected to land on dedicated branches per test type/area (e.g. `test/frontend-modules`), not mixed into feature branches, with coverage reported after each change. +### Every new Prisma migration must enable RLS on the tables it creates +The database is Supabase, and `ALTER DEFAULT PRIVILEGES` in `public` grants **every newly created table** to `anon` and `authenticated` with ALL privileges — SELECT, INSERT, UPDATE, DELETE, TRUNCATE. Prisma emits no RLS statements, so a migration that only does `CREATE TABLE` ships a table that anyone holding the project's public anon key can read *and* delete through PostgREST. + +All existing tables have RLS enabled with **zero policies**, which is the correct posture here rather than an unfinished one: with no policy, RLS denies the PostgREST roles everything, while the backend connects as the table owner (`postgres`) and owners bypass RLS unless `FORCE ROW LEVEL SECURITY` is set. So the backend is unaffected and the public roles get nothing. Verified by experiment: as `anon`, a `SELECT` against an RLS-enabled table returns zero rows, while the same query against an identical table without RLS returns the data and a follow-up `DELETE` removes it. + +So every migration that creates a table **MUST** end with: + +```sql +ALTER TABLE "new_table" ENABLE ROW LEVEL SECURITY; +``` + +Do **not** add `FORCE ROW LEVEL SECURITY`: it applies policy-less RLS to the owner too, which denies the backend its own tables. Note the existing tables got RLS out of band (dashboard or manual SQL) rather than from their migrations, so each of them was exposed between deploy and the fix — putting the statement in the migration is what closes that window. + +### Migrations run with `deploy`, never `dev` +`pnpm --filter backend prisma:migrate` is `prisma migrate deploy`. There is one database configured here and it is production, so `migrate dev` is the wrong tool twice over: it wants a shadow database, and when it finds drift it offers to **reset** — against this database that is the whole game. It also *would* find drift, because the RLS above was applied out of band on the older tables and is not in their migration files. + +`prisma migrate dev` is still available as `prisma:migrate:dev` for anyone pointing `DIRECT_URL` at a scratch database of their own. `db:push` deserves the same caution: it reshapes the database from the schema with no migration recorded, which is how a column disappears without a file saying so. + +`prisma.config.ts` sets `connect_timeout=30` on the migration URL. Prisma's default is short enough that a proxy or antivirus doing TLS inspection makes every migration command fail as `P1001: Can't reach database server` — pointing at a database that is up and answering `pg` clients on the same URL at the same moment. Measured behind such an interceptor: the handshake takes ~19s and the engine gives up at ~10s. `sslmode=disable` also makes the error go away and **must not be used**: it works by putting the database password on the wire in clear text. + ## Licensing This monorepo has split licensing; see the table in `README.md`. `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, `protocol`, and `verifier` are MIT; everything else (`frontend`, `backend`, `mobile`, `website`, `shared`, `image-generator`) is PolyForm Noncommercial 1.0.0 (root `LICENSE`). Match the license of whichever package you're editing when adding new files. -`protocol` (`@cryptopets/protocol`) is MIT deliberately: the backend-authoritative battle design (`docs/plan-backend-battle-architecture.md` §H) only holds up if outsiders can run the receipt verifier, and the verifier depends on this package. So it must never import from a PolyForm package (`tests/package.test.ts` enforces it), and it must stay free of clock reads, ambient randomness, and I/O (eslint enforces the first two). The TS combat engine lives here now, re-exported from `shared/src/utils/combat` so existing importers are unchanged. +`protocol` (`@cryptopets/protocol`) is MIT deliberately: the backend-authoritative battle design (`docs/battle-protocol.md` §H) only holds up if outsiders can run the receipt verifier, and the verifier depends on this package. So it must never import from a PolyForm package (`tests/package.test.ts` enforces it), and it must stay free of clock reads, ambient randomness, and I/O (eslint enforces the first two). The TS combat engine lives here now, re-exported from `shared/src/utils/combat` so existing importers are unchanged. diff --git a/README.md b/README.md index 2f6d7712..4b5c74c6 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,21 @@ -# do-not-stop 🚀 +# CryptoPets 🚀 -A continuously evolving Web3 playground built with modern technologies, designed to grow, adapt, and experiment with the latest advancements in Ethereum/Solana development and contemporary React patterns. +A Web3 pet-battling game running on both Ethereum and Solana. Breed, train, and +battle NFT pets whose art is generated from their on-chain DNA, with battle +outcomes resolved from a committed random beacon and published as signed +receipts anyone can replay. **Live Demo:** https://cryptopets.vercel.app +![The CryptoPets gallery: a player's pets with stats, leaderboard, and daily quests](./docs/screenshot.png) + ## 📁 Project Structure This is a monorepo containing multiple interconnected projects: ### Applications - **[Frontend](./frontend)** - React + Vite web application with wallet integration -- **[Backend](./backend)** - Node.js + Express API server +- **[Backend](./backend)** - Node.js + Express API server, battle authority, and settle keeper - **[Mobile](./mobile)** - React Native cross-platform mobile app - **[Website](./website)** - Next.js marketing/documentation site @@ -18,13 +23,19 @@ This is a monorepo containing multiple interconnected projects: - **[Ethereum Contracts](./contracts/ethereum)** - Solidity smart contracts with Hardhat - **[Solana Programs](./contracts/solana)** - Rust-based Solana programs with Anchor +### Services +- **[Indexer](./services/indexer-go)** - Go cross-chain indexer; the only writer of the pet roster +- **[Image Generator](./services/image-generator)** - Pet NFT art and ERC-721 metadata, from Cloudflare Workers AI + ### Shared Code -- **[Shared Core](./shared)** - Common utilities and types used across projects +- **[Shared Core](./shared)** - Common utilities, types, and hooks used across clients +- **[Protocol](./protocol)** - The battle protocol: combat engine, canonical encodings, hashes, seed derivation +- **[Verifier](./verifier)** - Standalone verifier that replays a signed battle receipt against the protocol ## 📖 Documentation -See [docs/](./docs) for testing strategy and an index of all package-level docs. -The component map and data flow live in +See [docs/](./docs) for the battle protocol, testing strategy, and an index of +all package-level docs. The component map and data flow live in [CLAUDE.md](./CLAUDE.md#architecture). ## 🛠️ Development @@ -52,11 +63,13 @@ For detailed setup and commands, see [DEVELOPMENT.md](./DEVELOPMENT.md). **Frontend:** React 19, TypeScript, Vite, Wagmi, Viem, TanStack Query -**Backend:** Node.js, Express.js, TypeScript, JWT, Ethers.js, TweetNaCl +**Backend:** Node.js, Express.js, TypeScript, Prisma, PostgreSQL, GraphQL, JWT, Ethers.js, TweetNaCl **Mobile:** React Native, TypeScript -**Blockchain:** +**Services:** Go (indexer, gRPC), Cloudflare Workers AI + R2 (pet art) + +**Blockchain:** - Ethereum: Solidity, Hardhat - Solana: Rust, Anchor @@ -76,7 +89,11 @@ This monorepo uses two licenses depending on the package: | Package(s) | License | | --- | --- | | `contracts/ethereum`, `contracts/solana`, `services/indexer-go`, `proto`, `protocol`, `verifier` | [MIT](./contracts/LICENSE) — fully permissive | -| `frontend`, `backend`, `mobile`, `website`, `shared` (and anything else) | [PolyForm Noncommercial 1.0.0](./LICENSE) — free for any noncommercial purpose; commercial use requires permission | +| `frontend`, `backend`, `mobile`, `website`, `shared`, `services/image-generator` (and anything else) | [PolyForm Noncommercial 1.0.0](./LICENSE) — free for any noncommercial purpose; commercial use requires permission | + +`protocol` and `verifier` are MIT deliberately: a backend that decides battle +outcomes only holds up if outsiders can replay its receipts, which means the +verifier and everything it depends on has to be freely usable. Each package's `package.json` / `go.mod` directory points at the license that applies to it. For commercial licensing of the app layer, contact diff --git a/backend/API.md b/backend/API.md index cb1af862..d10bbd46 100644 --- a/backend/API.md +++ b/backend/API.md @@ -219,6 +219,48 @@ matchup UI degrades to "odds unavailable". Intended for a single confirmed matchup, not per opponents row. Optional `samples` arg overrides the server default (clamped to 10,000). +### Leaderboards + +```graphql +query($chain: String!, $page: Int, $pageSize: Int) { + leaderboard(chain: $chain, page: $page, pageSize: $pageSize) { + entries { rank id chain owner name dna level rarity winCount lossCount asset } + total page pageSize + } + playerLeaderboard(chain: $chain, page: $page, pageSize: $pageSize) { + entries { rank owner winCount lossCount petCount } + total page pageSize + } + playerRank(chain: $chain) { rank owner winCount lossCount petCount } +} +``` + +Three read-only rankings over the **merged** battle record — `pet_battle_progress` +where a pet has fought a backend battle, the frozen `pet_roster` counters otherwise. +Ranking on the roster alone is not a simplification but a bug: those counters stopped +moving when battles left the chain (§L Phase 6), so on a deployment whose battles are +all backend-settled the roster-only ranking is empty. + +Ordering is wins DESC, then losses ASC, then (pets only) level DESC, then the id or +owner key. The losses tiebreak *is* the win-rate tiebreak — among rows on equal wins, +fewer losses is a strictly higher rate — so nothing is ranked on a ratio drawn from a +handful of fights. Rows with no battles at all are excluded. + +| Field | Type | Notes | +| --- | --- | --- | +| `rank` | Int | 1-based over the **full** ranking, not the page; page 2 continues where page 1 stopped | +| `owner` | String | grouping key on the player board: EVM addresses lowercased, Solana pubkeys untouched, matching `normalizeAccount` | +| `petCount` | Int | pets **with a battle record**, not pets owned | + +`playerRank` reports the authenticated caller's own standing, so a client does not page +the whole board looking for itself. It takes no owner argument — whose rank it is comes +from the session — and returns **`null` for an unranked player** (no pet has fought) +rather than a zeroed row, which would be indistinguishable from genuine last place. + +Neither board has a gRPC fast path, for the same reason `opponents` lost its own: +indexer-go's cache holds chain state and has no view of `pet_battle_progress`, a +backend-owned table, so it cannot answer these correctly. Both read Postgres directly. + ### Battle data `battle_history` carries `loserPetId, seed (0x-hex), rounds, winnerHpRemaining, @@ -238,8 +280,7 @@ requests (the `requestX` → Pyth Entropy reveals → `settleX` flow) from a backend-held wallet once entropy reveals, so the player only signs the request transaction — `settleX` is permissionless and needed no special authorization, it was just being sent from the player's wallet by default. Off unless -`KEEPER_ENABLED=true`. See `docs/plan-realtime-battle-ux.md` / -`docs/plan-realtime-battle-impl.md` for the design and threat model. +`KEEPER_ENABLED=true`. Battles are **not** settled here any more (§L Phase 6). `requestBattle`/`settleBattle` were removed from the contracts entirely, along with the Solana settle keeper and shadow @@ -248,7 +289,7 @@ mode; battles run through the backend-authoritative path below. ### Backend-authoritative battles (v2) `backend/src/routes/battle.ts` — the workflow described in -`docs/plan-backend-battle-architecture.md`. Submission and consent require a JWT +`docs/battle-protocol.md`. Submission and consent require a JWT (the wallet signature inside the body is what actually authorizes the action, per §D); the reads below require nothing, because every value they return is either already public on chain or is itself a signed artifact anyone is meant @@ -275,6 +316,31 @@ switching the mode off stops new battles, it does not retract receipts already i | GET | `/api/battle/rulesets/:rulesetHash` | none | One ruleset's full bundle, for replaying against it. | | POST | `/api/battle/verify-receipt` | none | Body `{ receiptHash }`. Checks the stored signature against a published key and that the payload is well-formed — §A's "operator signature, verified against a published key" row, nothing more. It does **not** re-run the fight, check the drand BLS signature, or recompute progression; that is the standalone verifier's job (§H), which runs with no backend access so its answer cannot depend on this process telling the truth. Passing this check is necessary, not sufficient. | +### Private chat (roadmap §2, v1) + +| Method | Path | Auth | Purpose | +| --- | --- | --- | --- | +| GET | `/api/chat/threads` | JWT | The caller's currently-usable threads, one per married counterpart, with the pet pairs behind each. Creates a thread on first listing — a married pair always ends up with exactly one, so an explicit open call would add a round trip and a null state that resolves one way. | +| GET | `/api/chat/threads/:id/messages` | JWT | A page, oldest first within the page. `before=` pages backwards (a chat is read from its end); `limit` defaults to 50, capped at 100. | +| POST | `/api/chat/threads/:id/messages` | JWT | Body `{ text }`, trimmed, 1-2000 characters. The author is the session wallet; a `sender` in the body is ignored. | + +Access is **derived, never stored**: a thread answers only while the two wallets have a +married pet pair in `pet_roster.spouse_id`, rechecked on every request. A divorce closes +the conversation the moment the indexer sees it, with nothing to revoke. The thread row +survives — deleting it would destroy the history — it just stops answering. + +Status codes carry a deliberate asymmetry. A non-participant gets **404**, the same as a +thread that does not exist, because 403 would confirm the id to anyone probing. A +participant whose marriage has ended gets **403** with a reason, since they already know +the thread exists. + +**What v1 does not have**, each a product call flagged in the roadmap rather than an +oversight: no block or report, no profanity filtering, no read receipts or presence, no +edit or delete, and no retention policy. The abuse controls are a length cap and a rate +limit (20 sends/min per wallet, 120 reads) — volume controls, not content ones. This is +the first endpoint in the API that stores genuine user-authored text, which is what makes +moderation a real question here and not elsewhere. + ### Battle room WebSocket (v2) ``` @@ -305,6 +371,47 @@ client-side by `(chainId, requestId)`. That socket was removed once battles stopped being resolved from chain state, so there is no longer a second channel and nothing left to filter. +### Chat WebSocket (roadmap §2) + +``` +ws(s):///ws/chat?threadId= +``` + +Per-thread, and **authenticated**. Two frame shapes, neither carrying message text: + +```json +{ "type": "thread-updated", "threadId": "c...", "messageId": 42 } +{ "type": "presence", "topic": "c...", "online": ["0xabc…"] } +``` + +`thread-updated` means "re-read this thread"; the text comes from +`GET /api/chat/threads/:id/messages`, which authenticates the caller and rechecks the +marriage. Missing a notification costs latency, never access. `presence` is the roster of +participants currently connected, which is what drives the online dot. + +**Authentication.** The client offers two subprotocols, `cryptopets-auth` followed by the +JWT; the server echoes back only the marker. A subprotocol rather than a query parameter +because browsers cannot set headers on a WebSocket and a URL-borne token is recorded by +proxies and access logs. The upgrade then applies the same participation and live-marriage +gate as the HTTP routes, so a socket can never subscribe to a thread its holder could not +read. A connection with no token, a forged token, or a thread the caller is not in is +refused at the upgrade — it never becomes a subscriber, not even to the fact that the +thread changed. + +This is stricter than the channel shipped with. It was unauthenticated at first, on the +argument that contentless frames made it safe; presence forced the change, because "is my +counterpart online" is a claim about identities and an anonymous socket has none. Counting +connections would have reported one person with two tabs open as two people. Closing the +activity-timing leak came along with it. + +Presence counts identities, not sockets, so a second tab does not double a person and +closing one does not report them as gone. Authorization is checked at connect only: a +marriage that ends mid-session leaves the socket open until it drops, which costs nothing +because every frame is contentless and the read it prompts refuses immediately. + +The battle-room channel above remains unauthenticated. It carries no content and has no +presence, so it has nothing an identity would protect. + ### Public receipt corpus (v2) `backend/src/routes/receipts.ts` — the paginated export §H item 3 calls for. diff --git a/backend/env.example b/backend/env.example index 0e38ea5d..42c2291b 100644 --- a/backend/env.example +++ b/backend/env.example @@ -69,8 +69,7 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # and mint only, which have no backend equivalent. # # Settles requestCreateFromDNA/requestMintStarter requests from this wallet once Pyth -# Entropy reveals, so the player only signs the request transaction -# (see docs/plan-realtime-battle-ux.md, docs/plan-realtime-battle-impl.md Phase 2). +# Entropy reveals, so the player only signs the request transaction. # Off by default. All four of RPC_URL/PRIVATE_KEY/CHAIN_ID/GAME_LOGIC_ADDRESS are # required once enabled; the keeper logs and no-ops (doesn't crash the server) if # any are missing. @@ -88,9 +87,9 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # enable against anything else, so this can't accidentally run on a real network. # KEEPER_MOCK_REVEAL=true -# --- Backend-authoritative battles (docs/plan-backend-battle-architecture.md) --- -# Backend-authoritative battle mode (docs/plan-backend-battle-architecture.md §L Phase 3, -# operated per docs/runbook-backend-battles.md). Off by default, and a separate switch from +# --- Backend-authoritative battles (docs/battle-protocol.md) --- +# Backend-authoritative battle mode (docs/battle-protocol.md §L Phase 3, +# operated per its Appendix B). Off by default, and a separate switch from # the on-chain path rather than a replacement: Phase 3 runs both side by side. # # Off: the write routes (POST /intents, /accept, /authorizations) return 503, the outbox diff --git a/backend/package.json b/backend/package.json index fb15bc91..124eedeb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,7 +14,8 @@ "clean": "rm -rf dist", "postinstall": "prisma generate", "prisma:generate": "prisma generate", - "prisma:migrate": "prisma migrate dev", + "prisma:migrate": "prisma migrate deploy", + "prisma:migrate:dev": "prisma migrate dev", "prisma:status": "prisma migrate status", "prisma:deploy": "prisma migrate deploy", "prisma:studio": "prisma studio", diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts index 5449f0b2..96fe8add 100644 --- a/backend/prisma.config.ts +++ b/backend/prisma.config.ts @@ -3,6 +3,39 @@ import "dotenv/config"; import { defineConfig } from "prisma/config"; +/** Seconds the schema engine waits for a connection before reporting P1001. */ +const CONNECT_TIMEOUT = "30"; + +/** + * The migration connection, with a connect timeout that survives TLS interception. + * + * Prisma's default is short enough that a proxy or antivirus doing TLS inspection makes + * every migration command fail as `P1001: Can't reach database server` — a message that + * points at the database, which is up and answering `pg` clients on the same URL at the + * same moment. Measured against the Supabase pooler behind such an interceptor: the + * handshake takes ~19s and the engine gives up at ~10s. + * + * Applied here rather than in `.env` so it holds for every developer and cannot be lost + * to an edit of a file that is not in the repository. Left alone if the URL already sets + * it. Note `sslmode=disable` also "fixes" this and must not be used: it works by putting + * the database password on the wire in clear text. + */ +function migrationUrl(): string | undefined { + const raw = process.env["DIRECT_URL"] ?? process.env["DATABASE_URL"]; + if (!raw) return undefined; + try { + const url = new URL(raw); + if (!url.searchParams.has("connect_timeout")) { + url.searchParams.set("connect_timeout", CONNECT_TIMEOUT); + } + return url.toString(); + } catch { + // Not a URL we can parse. Hand it back untouched: a malformed connection string is + // the engine's error to report, and it reports it far better than this would. + return raw; + } +} + export default defineConfig({ schema: "prisma/schema.prisma", migrations: { @@ -11,6 +44,6 @@ export default defineConfig({ datasource: { // Migrations/introspection use a direct (session-mode) connection — the // transaction-mode pooler in DATABASE_URL can't run DDL / advisory locks. - url: process.env["DIRECT_URL"] ?? process.env["DATABASE_URL"], + url: migrationUrl(), }, }); diff --git a/backend/prisma/migrations/20260806100000_add_chat_threads/migration.sql b/backend/prisma/migrations/20260806100000_add_chat_threads/migration.sql new file mode 100644 index 00000000..70c53c08 --- /dev/null +++ b/backend/prisma/migrations/20260806100000_add_chat_threads/migration.sql @@ -0,0 +1,51 @@ +-- CreateTable +CREATE TABLE "chat_thread" ( + "id" TEXT NOT NULL, + "participant_a" TEXT NOT NULL, + "participant_b" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "chat_thread_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "chat_message" ( + "id" SERIAL NOT NULL, + "thread_id" TEXT NOT NULL, + "sender" TEXT NOT NULL, + "text" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "chat_message_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "chat_thread_participant_a_participant_b_key" ON "chat_thread"("participant_a", "participant_b"); + +-- CreateIndex +CREATE INDEX "chat_thread_participant_a_idx" ON "chat_thread"("participant_a"); + +-- CreateIndex +CREATE INDEX "chat_thread_participant_b_idx" ON "chat_thread"("participant_b"); + +-- CreateIndex +CREATE INDEX "chat_message_thread_id_created_at_idx" ON "chat_message"("thread_id", "created_at"); + +-- AddForeignKey +ALTER TABLE "chat_message" ADD CONSTRAINT "chat_message_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "chat_thread"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- EnableRowLevelSecurity +-- +-- Prisma emits no RLS statements, and on Supabase `ALTER DEFAULT PRIVILEGES` grants every +-- newly created table in `public` to `anon` and `authenticated` with ALL privileges — +-- including DELETE and TRUNCATE. So a table shipped without this line is readable and +-- writable by anyone holding the project's public anon key. For private messages that is +-- not a hardening gap, it is the whole confidentiality of the feature. +-- +-- Enabled with no policies, matching every other table in this database: that denies all +-- access to the PostgREST roles, while the backend connects as the table owner +-- (`postgres`) and owners bypass RLS unless FORCE is set. Do NOT add FORCE here — it +-- would apply these policy-less tables to the owner too and deny the backend everything. +ALTER TABLE "chat_thread" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "chat_message" ENABLE ROW LEVEL SECURITY; diff --git a/backend/prisma/migrations/20260806120000_chat_message_index_by_id/migration.sql b/backend/prisma/migrations/20260806120000_chat_message_index_by_id/migration.sql new file mode 100644 index 00000000..fbc9dab1 --- /dev/null +++ b/backend/prisma/migrations/20260806120000_chat_message_index_by_id/migration.sql @@ -0,0 +1,7 @@ +-- The message page reads `WHERE thread_id = ? [AND id < ?] ORDER BY id DESC LIMIT n`. +-- The (thread_id, created_at) index could locate a thread's rows but not satisfy that +-- ordering, so every read sorted the whole thread to return one page. `id` is a SERIAL +-- and is already the ordering key the code uses, so this is a swap, not an addition. +DROP INDEX IF EXISTS "chat_message_thread_id_created_at_idx"; + +CREATE INDEX "chat_message_thread_id_id_idx" ON "chat_message"("thread_id", "id"); diff --git a/backend/prisma/migrations/20260807120000_add_chat_read_watermark/migration.sql b/backend/prisma/migrations/20260807120000_add_chat_read_watermark/migration.sql new file mode 100644 index 00000000..9e939f70 --- /dev/null +++ b/backend/prisma/migrations/20260807120000_add_chat_read_watermark/migration.sql @@ -0,0 +1,24 @@ +-- How far each participant has read in a thread. +-- +-- A watermark, not a per-message read flag: two rows per thread instead of two per +-- message, and "have they seen this one" is `message.id <= last_read_id`. +CREATE TABLE "chat_read" ( + "thread_id" TEXT NOT NULL, + "participant" TEXT NOT NULL, + "last_read_id" INTEGER NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "chat_read_pkey" PRIMARY KEY ("thread_id", "participant") +); + +ALTER TABLE "chat_read" + ADD CONSTRAINT "chat_read_thread_id_fkey" + FOREIGN KEY ("thread_id") REFERENCES "chat_thread"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- Supabase grants every newly created table to `anon` and `authenticated` through +-- ALTER DEFAULT PRIVILEGES, so without this the read state of every private conversation +-- is readable and deletable with the project's public key. RLS with no policy denies +-- those roles outright; the backend connects as the owner and bypasses it. +-- Deliberately not FORCE: that would apply the policy-less RLS to the owner too. +ALTER TABLE "chat_read" ENABLE ROW LEVEL SECURITY; diff --git a/backend/prisma/migrations/20260807140000_add_chat_reactions/migration.sql b/backend/prisma/migrations/20260807140000_add_chat_reactions/migration.sql new file mode 100644 index 00000000..ef24ba7a --- /dev/null +++ b/backend/prisma/migrations/20260807140000_add_chat_reactions/migration.sql @@ -0,0 +1,24 @@ +-- One participant's reaction to one message. +-- +-- The primary key is the rule, not a convenience: a person holds at most one reaction per +-- message, so reacting again replaces it and reacting with the same emoji removes it. +CREATE TABLE "chat_reaction" ( + "message_id" INTEGER NOT NULL, + "participant" TEXT NOT NULL, + "emoji" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "chat_reaction_pkey" PRIMARY KEY ("message_id", "participant") +); + +ALTER TABLE "chat_reaction" + ADD CONSTRAINT "chat_reaction_message_id_fkey" + FOREIGN KEY ("message_id") REFERENCES "chat_message"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- Supabase grants every newly created table to `anon` and `authenticated` through +-- ALTER DEFAULT PRIVILEGES, so without this anyone holding the project's public key could +-- read and delete the reactions on every private conversation. RLS with no policy denies +-- those roles outright; the backend connects as the owner and bypasses it. +-- Deliberately not FORCE: that would apply the policy-less RLS to the owner too. +ALTER TABLE "chat_reaction" ENABLE ROW LEVEL SECURITY; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 29ba322a..1476135e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -146,8 +146,98 @@ model BattleConversation { @@map("battle_conversation") } +/// A private thread between two wallets (roadmap §2 v1). +/// +/// No `chain` column, deliberately: the relationship is between wallets, and a wallet +/// address only exists on one chain anyway (0x-hex on EVM, base58 on Solana), so the +/// pair is implicitly single-chain without saying so. +/// +/// The marriage that grants access is NOT stored here. Access is checked against +/// `pet_roster.spouse_id` every time the thread is read or written, the same +/// "derive from indexed state, don't duplicate it" rule the roster reads follow. A +/// copied `marriedPetIds` here would keep granting access after a divorce. +/// +/// `participantA` is the lexicographically smaller address, which is what makes the +/// unique constraint mean "one thread per pair" rather than "one per direction". +model ChatThread { + id String @id @default(cuid()) + participantA String @map("participant_a") + participantB String @map("participant_b") + /// 'marriage' in v1. Present so a later 'direct' scope does not need a migration. + scope String + + createdAt DateTime @default(now()) @map("created_at") + messages ChatMessage[] + reads ChatRead[] + + @@unique([participantA, participantB], name: "chat_thread_pair") + @@index([participantA]) + @@index([participantB]) + @@map("chat_thread") +} + +/// One message. Unlike every other generated text in this schema, this is real +/// user-authored content, which is what makes moderation a live product question +/// rather than a prompt-layer one (roadmap §2). +model ChatMessage { + id Int @id @default(autoincrement()) + threadId String @map("thread_id") + thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) + /// Wallet address of the author, normalized the way the JWT normalizes it. + sender String + text String + + createdAt DateTime @default(now()) @map("created_at") + + /// Indexed on `id`, not `createdAt`, because that is what the read orders by: a page is + /// `WHERE thread_id = ? [AND id < ?] ORDER BY id DESC LIMIT n`. Indexing `created_at` + /// instead let Postgres find the thread's rows but not satisfy the ordering, so it + /// sorted every message in the thread to return fifty of them, on every read. + reactions ChatReaction[] + + @@index([threadId, id]) + @@map("chat_message") +} + +/// One participant's reaction to one message. +/// +/// Keyed on (message, participant), which is the rule rather than a convenience: a person +/// holds at most one reaction per message, so reacting again replaces and reacting with +/// the same emoji removes. The emoji is one of a fixed list the client and server share. +model ChatReaction { + messageId Int @map("message_id") + message ChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade) + /// Wallet address, normalized the way the thread stores its participants. + participant String + emoji String + + createdAt DateTime @default(now()) @map("created_at") + + @@id([messageId, participant]) + @@map("chat_reaction") +} + +/// How far each participant has read in a thread: one row per participant, holding the +/// id of the newest message they have seen. +/// +/// A watermark rather than a per-message read flag. A thread of a thousand messages +/// produces two rows here instead of two thousand, and "have they seen this one" is +/// `message.id <= their watermark` — the same answer, one comparison, no join per row. +model ChatRead { + threadId String @map("thread_id") + thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) + /// Wallet address, normalized the way the thread stores its participants. + participant String + lastReadId Int @map("last_read_id") + + updatedAt DateTime @updatedAt @map("updated_at") + + @@id([threadId, participant]) + @@map("chat_read") +} + // ─── Backend-authoritative battles ──────────────────────────────────────────── -// Models for the design in docs/plan-backend-battle-architecture.md. The existing +// Models for the design in docs/battle-protocol.md. The existing // pet_roster / battle_history tables stay exactly as they are: they are projections // of on-chain events, and the legacy on-chain battle path keeps writing them. // Everything below is the durable workflow for battles the backend resolves itself. @@ -609,7 +699,7 @@ model BattleSigningKey { /// True when this key was retired because it was compromised, rather than routinely /// rotated. Persisted rather than derived: "this key may have signed things we did not /// authorise" is a fact about history that a restart must not downgrade to an ordinary - /// rotation. See docs/runbook-signing-key-compromise.md. + /// rotation. See docs/battle-protocol.md Appendix C. compromised Boolean @default(false) firstSeenAt DateTime @default(now()) @map("first_seen_at") diff --git a/backend/src/app.ts b/backend/src/app.ts index fa22b559..e143d26c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,6 +11,7 @@ import battleRoomRoutes from '@routes/battle-room'; import battleRoutes from '@routes/battle'; import receiptRoutes from '@routes/receipts'; import rewardRoutes from '@routes/rewards'; +import chatRoutes from '@routes/chat'; const app = express(); @@ -38,6 +39,7 @@ app.use('/api/battle-room', battleRoomRoutes); app.use('/api/battle', battleRoutes); app.use('/api/receipts', receiptRoutes); app.use('/api/rewards', rewardRoutes); +app.use('/api/chat', chatRoutes); app.get('/', (_req: Request, res: Response) => { res.json({ @@ -50,6 +52,7 @@ app.get('/', (_req: Request, res: Response) => { graphql: '/graphql', battleDialogue: '/api/battle-dialogue', battleRoom: '/api/battle-room', + chat: '/api/chat', }, }); }); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 3bdcde50..33161fc1 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -86,8 +86,7 @@ export const env = { process.env.ROSTER_READ_SOURCE?.trim().toLowerCase() === 'grpc' ? 'grpc' : 'postgres', /** - * Settle keeper (plan-realtime-battle-ux.md / plan-realtime-battle-impl.md Phase 2): - * settles GameLogic battle/breed/mint requests from this wallet once Pyth Entropy + * Settle keeper: settles GameLogic breed/mint requests from this wallet once Pyth Entropy * reveals, so the player doesn't send the settle transaction themselves. Off unless * KEEPER_ENABLED=true; the four fields below are required once it is (checked at * startSettleKeeper() time so a misconfigured keeper logs and no-ops rather than @@ -115,7 +114,7 @@ export const env = { }, /** - * Backend-authoritative battles (docs/plan-backend-battle-architecture.md). + * Backend-authoritative battles (docs/battle-protocol.md). * * Every wallet-signed object binds `chainId` and `deploymentId`, and that binding only * stops a replay if this server refuses payloads naming a different one. Both values are diff --git a/backend/src/features/battle-anchor/abi.ts b/backend/src/features/battle/anchor/abi.ts similarity index 100% rename from backend/src/features/battle-anchor/abi.ts rename to backend/src/features/battle/anchor/abi.ts diff --git a/backend/src/features/battle-anchor/anchor.service.ts b/backend/src/features/battle/anchor/anchor.service.ts similarity index 100% rename from backend/src/features/battle-anchor/anchor.service.ts rename to backend/src/features/battle/anchor/anchor.service.ts diff --git a/backend/src/features/battle-anchor/index.ts b/backend/src/features/battle/anchor/index.ts similarity index 98% rename from backend/src/features/battle-anchor/index.ts rename to backend/src/features/battle/anchor/index.ts index 5d8307de..bbbb8bc1 100644 --- a/backend/src/features/battle-anchor/index.ts +++ b/backend/src/features/battle/anchor/index.ts @@ -2,7 +2,7 @@ import { createPublicClient, createWalletClient, http, type Address, type Chain import { privateKeyToAccount } from 'viem/accounts'; import { env } from '@config/env'; -import { buildNextBatch } from '@features/battle-batcher'; +import { buildNextBatch } from '@features/battle/batcher'; import { anchorNextBatch, type AnchorContext } from './anchor.service'; diff --git a/backend/src/features/battle-batcher/batch.builder.ts b/backend/src/features/battle/batcher/batch.builder.ts similarity index 100% rename from backend/src/features/battle-batcher/batch.builder.ts rename to backend/src/features/battle/batcher/batch.builder.ts diff --git a/backend/src/features/battle-batcher/batcher.controller.ts b/backend/src/features/battle/batcher/batcher.controller.ts similarity index 100% rename from backend/src/features/battle-batcher/batcher.controller.ts rename to backend/src/features/battle/batcher/batcher.controller.ts diff --git a/backend/src/features/battle-batcher/batcher.service.ts b/backend/src/features/battle/batcher/batcher.service.ts similarity index 100% rename from backend/src/features/battle-batcher/batcher.service.ts rename to backend/src/features/battle/batcher/batcher.service.ts diff --git a/backend/src/features/battle-batcher/index.ts b/backend/src/features/battle/batcher/index.ts similarity index 100% rename from backend/src/features/battle-batcher/index.ts rename to backend/src/features/battle/batcher/index.ts diff --git a/backend/src/features/battle-ledger/accept.controller.ts b/backend/src/features/battle/ledger/accept.controller.ts similarity index 100% rename from backend/src/features/battle-ledger/accept.controller.ts rename to backend/src/features/battle/ledger/accept.controller.ts diff --git a/backend/src/features/battle-ledger/accept.service.ts b/backend/src/features/battle/ledger/accept.service.ts similarity index 98% rename from backend/src/features/battle-ledger/accept.service.ts rename to backend/src/features/battle/ledger/accept.service.ts index 11e81936..3340c7de 100644 --- a/backend/src/features/battle-ledger/accept.service.ts +++ b/backend/src/features/battle/ledger/accept.service.ts @@ -18,8 +18,8 @@ import { BattleState } from '@generated/prisma/enums'; import { prisma } from '@config/prisma'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; -import { activeSigningKey, sign, SignerRefusedError } from '../battle-signer'; -import { chooseCommitmentRound, roundPublishTime } from '../battle-randomness'; +import { activeSigningKey, sign, SignerRefusedError } from '../signer'; +import { chooseCommitmentRound, roundPublishTime } from '../randomness'; import { type ConsentFailure, consumeDailyBudget, findCoveringAuthorization } from './consent.service'; import { servedDeploymentId } from './domain'; diff --git a/backend/src/features/battle-ledger/consent.controller.ts b/backend/src/features/battle/ledger/consent.controller.ts similarity index 100% rename from backend/src/features/battle-ledger/consent.controller.ts rename to backend/src/features/battle/ledger/consent.controller.ts diff --git a/backend/src/features/battle-ledger/consent.service.ts b/backend/src/features/battle/ledger/consent.service.ts similarity index 100% rename from backend/src/features/battle-ledger/consent.service.ts rename to backend/src/features/battle/ledger/consent.service.ts diff --git a/backend/src/features/battle-ledger/corpus.controller.ts b/backend/src/features/battle/ledger/corpus.controller.ts similarity index 100% rename from backend/src/features/battle-ledger/corpus.controller.ts rename to backend/src/features/battle/ledger/corpus.controller.ts diff --git a/backend/src/features/battle-ledger/corpus.service.ts b/backend/src/features/battle/ledger/corpus.service.ts similarity index 100% rename from backend/src/features/battle-ledger/corpus.service.ts rename to backend/src/features/battle/ledger/corpus.service.ts diff --git a/backend/src/features/battle-ledger/domain.ts b/backend/src/features/battle/ledger/domain.ts similarity index 100% rename from backend/src/features/battle-ledger/domain.ts rename to backend/src/features/battle/ledger/domain.ts diff --git a/backend/src/features/battle-ledger/index.ts b/backend/src/features/battle/ledger/index.ts similarity index 98% rename from backend/src/features/battle-ledger/index.ts rename to backend/src/features/battle/ledger/index.ts index c8b28c97..5dc27798 100644 --- a/backend/src/features/battle-ledger/index.ts +++ b/backend/src/features/battle/ledger/index.ts @@ -92,6 +92,7 @@ export { export { ALLOWED_TRANSITIONS, BATTLE_HAPPY_PATH, + canForfeitFrom, classifyTransition, IllegalTransitionError, isCommitted, @@ -101,6 +102,7 @@ export { type TransitionKind, } from './state'; export { + abandonBattle, applyTransition, type BattleLedgerPatch, failBattle, diff --git a/backend/src/features/battle-ledger/intent.controller.ts b/backend/src/features/battle/ledger/intent.controller.ts similarity index 100% rename from backend/src/features/battle-ledger/intent.controller.ts rename to backend/src/features/battle/ledger/intent.controller.ts diff --git a/backend/src/features/battle-ledger/intent.service.ts b/backend/src/features/battle/ledger/intent.service.ts similarity index 100% rename from backend/src/features/battle-ledger/intent.service.ts rename to backend/src/features/battle/ledger/intent.service.ts diff --git a/backend/src/features/battle-ledger/mode.ts b/backend/src/features/battle/ledger/mode.ts similarity index 100% rename from backend/src/features/battle-ledger/mode.ts rename to backend/src/features/battle/ledger/mode.ts diff --git a/backend/src/features/battle-ledger/outbox.ts b/backend/src/features/battle/ledger/outbox.ts similarity index 100% rename from backend/src/features/battle-ledger/outbox.ts rename to backend/src/features/battle/ledger/outbox.ts diff --git a/backend/src/features/battle-ledger/reads.controller.ts b/backend/src/features/battle/ledger/reads.controller.ts similarity index 100% rename from backend/src/features/battle-ledger/reads.controller.ts rename to backend/src/features/battle/ledger/reads.controller.ts diff --git a/backend/src/features/battle-ledger/reads.service.ts b/backend/src/features/battle/ledger/reads.service.ts similarity index 99% rename from backend/src/features/battle-ledger/reads.service.ts rename to backend/src/features/battle/ledger/reads.service.ts index 744e5f32..041bfd74 100644 --- a/backend/src/features/battle-ledger/reads.service.ts +++ b/backend/src/features/battle/ledger/reads.service.ts @@ -2,7 +2,7 @@ import { hashRuleset, SOURCE_DEFAULT_RULESET, type Hex } from '@cryptopets/proto import { ethers } from 'ethers'; import { prisma } from '@config/prisma'; -import { listSigningKeys } from '@features/battle-signer'; +import { listSigningKeys } from '@features/battle/signer'; import { servedChainIds, servedDeploymentId } from './domain'; import { backendBattleModeEnabled } from './mode'; diff --git a/backend/src/features/battle-ledger/snapshot.builder.ts b/backend/src/features/battle/ledger/snapshot.builder.ts similarity index 100% rename from backend/src/features/battle-ledger/snapshot.builder.ts rename to backend/src/features/battle/ledger/snapshot.builder.ts diff --git a/backend/src/features/battle-ledger/state.ts b/backend/src/features/battle/ledger/state.ts similarity index 83% rename from backend/src/features/battle-ledger/state.ts rename to backend/src/features/battle/ledger/state.ts index 62a6973a..238febf7 100644 --- a/backend/src/features/battle-ledger/state.ts +++ b/backend/src/features/battle/ledger/state.ts @@ -1,7 +1,7 @@ import { BattleState } from '@generated/prisma/enums'; /** - * The battle lifecycle from §J of docs/plan-backend-battle-architecture.md, as code. + * The battle lifecycle from §J of docs/battle-protocol.md, as code. * * Two properties are enforced here rather than left to the caller: * @@ -40,19 +40,27 @@ export const TERMINAL_STATES: readonly BattleState[] = [ /** * Legal moves out of each state. * - * `forfeited` is reachable from `committed` and `seeded` because a permanent beacon + * `forfeited` is reachable from `committed`, `seeded` and `computed` because a permanent * outage has to end the battle somehow, and ending it with no progression change plus a - * cooldown is the option that does not reward manufacturing an outage (§E). + * cooldown is the option that does not reward manufacturing an outage (§E). From + * `computed` it covers the verifier being unreachable until the outbox gives up — which + * is an outage like any other, and must not be confused with the next paragraph. * * `verification_failed` is reachable only from `computed`: it means the TypeScript * engine and the Go verifier disagreed, which stops signing for that ruleset rather - * than silently preferring one implementation (§F). + * than silently preferring one implementation (§F). A verifier that never answered has + * disagreed with nothing, so it forfeits instead; marking it failed would trip a + * ruleset-wide circuit breaker over a service being down. */ export const ALLOWED_TRANSITIONS: Readonly> = { [BattleState.accepted]: [BattleState.committed, BattleState.rejected, BattleState.expired], [BattleState.committed]: [BattleState.seeded, BattleState.forfeited], [BattleState.seeded]: [BattleState.computed, BattleState.forfeited], - [BattleState.computed]: [BattleState.verified, BattleState.verification_failed], + [BattleState.computed]: [ + BattleState.verified, + BattleState.verification_failed, + BattleState.forfeited, + ], [BattleState.verified]: [BattleState.signed, BattleState.signing_failed], [BattleState.signed]: [BattleState.published], [BattleState.published]: [BattleState.batched], @@ -82,6 +90,11 @@ export function classifyTransition(from: BattleState, to: BattleState): Transiti return ALLOWED_TRANSITIONS[from].includes(to) ? 'advance' : 'illegal'; } +/** Whether `forfeited` is a legal move from here; see `abandonBattle`. */ +export function canForfeitFrom(state: BattleState): boolean { + return (ALLOWED_TRANSITIONS[state] ?? []).includes(BattleState.forfeited); +} + /** Whether a battle has reached a state it never leaves. */ export function isTerminal(state: BattleState): boolean { return TERMINAL_STATES.includes(state); diff --git a/backend/src/features/battle-ledger/transitions.ts b/backend/src/features/battle/ledger/transitions.ts similarity index 82% rename from backend/src/features/battle-ledger/transitions.ts rename to backend/src/features/battle/ledger/transitions.ts index 53b46589..8780620b 100644 --- a/backend/src/features/battle-ledger/transitions.ts +++ b/backend/src/features/battle/ledger/transitions.ts @@ -1,10 +1,15 @@ -import type { BattleState } from '@generated/prisma/enums'; +import { BattleState } from '@generated/prisma/enums'; import type { Prisma } from '@generated/prisma/client'; import { prisma } from '@config/prisma'; import { enqueueOutbox, type OutboxMessage } from './outbox'; -import { classifyTransition, IllegalTransitionError, shouldReleaseLocks } from './state'; +import { + canForfeitFrom, + classifyTransition, + IllegalTransitionError, + shouldReleaseLocks, +} from './state'; /** * Transactional state transitions for the battle ledger (§J). @@ -220,3 +225,47 @@ export async function failBattle( ): Promise { return applyTransition({ battleId, from, to, patch: { failureReason: reason } }); } + +/** + * Ends a battle whose pipeline can no longer make progress, freeing both pets. + * + * Called when an outbox message dead-letters. Until this existed, a battle whose step ran + * out of retries stayed in a non-terminal state forever, and because locks are released by + * reaching a terminal state, both its pets were unable to battle again — permanently, with + * nothing surfacing why. That is how one unreachable verifier took two pets out of the + * game for days. + * + * `forfeited` and deliberately not `verification_failed`: that state means the two engines + * disagreed and is a ruleset-wide circuit breaker (§F). A verifier that never answered has + * disagreed with nothing, and marking it failed would stop signing for every battle on + * that ruleset because one service was down. + * + * States with no legal move to `forfeited` — `verified` awaiting a signature, say — are + * left exactly as they are. Freeing the pets is not worth inventing a transition the state + * machine does not allow, and the dead letter is still listed for a human either way. + */ +export async function abandonBattle( + battleId: string, + reason: string, +): Promise<{ abandoned: boolean; state: BattleState | null }> { + const current = await prisma.battleLedger.findUnique({ + where: { battleId }, + select: { state: true }, + }); + if (!current) { + return { abandoned: false, state: null }; + } + + const from = current.state as BattleState; + if (!canForfeitFrom(from)) { + return { abandoned: false, state: from }; + } + + const result = await applyTransition({ + battleId, + from, + to: BattleState.forfeited, + patch: { failureReason: reason }, + }); + return { abandoned: result.applied, state: result.state }; +} diff --git a/backend/src/features/battle-randomness/drand.client.ts b/backend/src/features/battle/randomness/drand.client.ts similarity index 100% rename from backend/src/features/battle-randomness/drand.client.ts rename to backend/src/features/battle/randomness/drand.client.ts diff --git a/backend/src/features/battle-randomness/index.ts b/backend/src/features/battle/randomness/index.ts similarity index 100% rename from backend/src/features/battle-randomness/index.ts rename to backend/src/features/battle/randomness/index.ts diff --git a/backend/src/features/battle-rewards/entitlements.ts b/backend/src/features/battle/rewards/entitlements.ts similarity index 100% rename from backend/src/features/battle-rewards/entitlements.ts rename to backend/src/features/battle/rewards/entitlements.ts diff --git a/backend/src/features/battle-rewards/index.ts b/backend/src/features/battle/rewards/index.ts similarity index 100% rename from backend/src/features/battle-rewards/index.ts rename to backend/src/features/battle/rewards/index.ts diff --git a/backend/src/features/battle-rewards/season.controller.ts b/backend/src/features/battle/rewards/season.controller.ts similarity index 100% rename from backend/src/features/battle-rewards/season.controller.ts rename to backend/src/features/battle/rewards/season.controller.ts diff --git a/backend/src/features/battle-rewards/season.open.ts b/backend/src/features/battle/rewards/season.open.ts similarity index 100% rename from backend/src/features/battle-rewards/season.open.ts rename to backend/src/features/battle/rewards/season.open.ts diff --git a/backend/src/features/battle-rewards/season.service.ts b/backend/src/features/battle/rewards/season.service.ts similarity index 100% rename from backend/src/features/battle-rewards/season.service.ts rename to backend/src/features/battle/rewards/season.service.ts diff --git a/backend/src/features/battle-room/battle-room.controller.ts b/backend/src/features/battle/room/battle-room.controller.ts similarity index 100% rename from backend/src/features/battle-room/battle-room.controller.ts rename to backend/src/features/battle/room/battle-room.controller.ts diff --git a/backend/src/features/battle-room/battle-room.schema.ts b/backend/src/features/battle/room/battle-room.schema.ts similarity index 100% rename from backend/src/features/battle-room/battle-room.schema.ts rename to backend/src/features/battle/room/battle-room.schema.ts diff --git a/backend/src/features/battle-room/battle-room.service.ts b/backend/src/features/battle/room/battle-room.service.ts similarity index 100% rename from backend/src/features/battle-room/battle-room.service.ts rename to backend/src/features/battle/room/battle-room.service.ts diff --git a/backend/src/features/battle-room/index.ts b/backend/src/features/battle/room/index.ts similarity index 78% rename from backend/src/features/battle-room/index.ts rename to backend/src/features/battle/room/index.ts index 5ffcb4a2..41c661b6 100644 --- a/backend/src/features/battle-room/index.ts +++ b/backend/src/features/battle/room/index.ts @@ -1,6 +1,6 @@ /** * Public surface of the battle-room feature. External code imports from - * `@features/battle-room` so the internal layout can change without touching + * `@features/battle/room` so the internal layout can change without touching * call sites. */ export { createBattleRoom } from './battle-room.controller'; diff --git a/backend/src/features/battle-signer/index.ts b/backend/src/features/battle/signer/index.ts similarity index 100% rename from backend/src/features/battle-signer/index.ts rename to backend/src/features/battle/signer/index.ts diff --git a/backend/src/features/battle-signer/signer.kms.ts b/backend/src/features/battle/signer/signer.kms.ts similarity index 95% rename from backend/src/features/battle-signer/signer.kms.ts rename to backend/src/features/battle/signer/signer.kms.ts index 64d1dace..ceaecf74 100644 --- a/backend/src/features/battle-signer/signer.kms.ts +++ b/backend/src/features/battle/signer/signer.kms.ts @@ -20,7 +20,7 @@ import type { SignerBackend } from './signer.types'; export function createKmsSigner(provider: string): SignerBackend { throw new Error( `KMS signer provider "${provider}" is not implemented. Production signing must use a managed ` + - 'KMS/HSM key restricted to commitment and receipt digests (docs/plan-backend-battle-architecture.md §G). ' + + 'KMS/HSM key restricted to commitment and receipt digests (docs/battle-protocol.md §G). ' + 'Implement an adapter here rather than setting BATTLE_SIGNER_PRIVATE_KEY in production.', ); } diff --git a/backend/src/features/battle-signer/signer.local.ts b/backend/src/features/battle/signer/signer.local.ts similarity index 100% rename from backend/src/features/battle-signer/signer.local.ts rename to backend/src/features/battle/signer/signer.local.ts diff --git a/backend/src/features/battle-signer/signer.registry.ts b/backend/src/features/battle/signer/signer.registry.ts similarity index 98% rename from backend/src/features/battle-signer/signer.registry.ts rename to backend/src/features/battle/signer/signer.registry.ts index c724cc68..4b7e5498 100644 --- a/backend/src/features/battle-signer/signer.registry.ts +++ b/backend/src/features/battle/signer/signer.registry.ts @@ -27,7 +27,7 @@ import type { SigningKeyDescriptor } from './signer.types'; * later call passes a milder status. Downgrading that flag would quietly turn "this key may * have signed things we did not authorise" back into an ordinary rotation, and the whole * point of the distinction is that the two demand different responses - * (docs/runbook-signing-key-compromise.md). + * (docs/battle-protocol.md Appendix C). */ export async function persistSigningKey(key: SigningKeyDescriptor): Promise { const compromised = key.status === 'compromised'; diff --git a/backend/src/features/battle-signer/signer.service.ts b/backend/src/features/battle/signer/signer.service.ts similarity index 100% rename from backend/src/features/battle-signer/signer.service.ts rename to backend/src/features/battle/signer/signer.service.ts diff --git a/backend/src/features/battle-signer/signer.types.ts b/backend/src/features/battle/signer/signer.types.ts similarity index 100% rename from backend/src/features/battle-signer/signer.types.ts rename to backend/src/features/battle/signer/signer.types.ts diff --git a/backend/src/features/battle-worker/beacon.worker.ts b/backend/src/features/battle/worker/beacon.worker.ts similarity index 98% rename from backend/src/features/battle-worker/beacon.worker.ts rename to backend/src/features/battle/worker/beacon.worker.ts index 91c974bc..42827724 100644 --- a/backend/src/features/battle-worker/beacon.worker.ts +++ b/backend/src/features/battle/worker/beacon.worker.ts @@ -10,8 +10,8 @@ import { completeOutbox, OUTBOX_TOPICS, rescheduleOutbox, -} from '@features/battle-ledger'; -import { fetchVerifiedRound, roundPublishTime } from '@features/battle-randomness'; +} from '@features/battle/ledger'; +import { fetchVerifiedRound, roundPublishTime } from '@features/battle/randomness'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; /** diff --git a/backend/src/features/battle-worker/compute.worker.ts b/backend/src/features/battle/worker/compute.worker.ts similarity index 99% rename from backend/src/features/battle-worker/compute.worker.ts rename to backend/src/features/battle/worker/compute.worker.ts index ff6b8f13..dbc74be5 100644 --- a/backend/src/features/battle-worker/compute.worker.ts +++ b/backend/src/features/battle/worker/compute.worker.ts @@ -10,7 +10,7 @@ import { BattleState } from '@generated/prisma/enums'; import type { Prisma } from '@generated/prisma/client'; import { prisma } from '@config/prisma'; -import { applyTransition, type ClaimedMessage, completeOutbox, OUTBOX_TOPICS } from '@features/battle-ledger'; +import { applyTransition, type ClaimedMessage, completeOutbox, OUTBOX_TOPICS } from '@features/battle/ledger'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; /** diff --git a/backend/src/features/battle-worker/index.ts b/backend/src/features/battle/worker/index.ts similarity index 100% rename from backend/src/features/battle-worker/index.ts rename to backend/src/features/battle/worker/index.ts diff --git a/backend/src/features/battle-worker/publish.worker.ts b/backend/src/features/battle/worker/publish.worker.ts similarity index 99% rename from backend/src/features/battle-worker/publish.worker.ts rename to backend/src/features/battle/worker/publish.worker.ts index 6eeff6e6..ad5a7a0c 100644 --- a/backend/src/features/battle-worker/publish.worker.ts +++ b/backend/src/features/battle/worker/publish.worker.ts @@ -2,7 +2,7 @@ import { assertBattleReceipt, hashBattleReceipt, receiptFromWire, type WireBattl import { BattleState } from '@generated/prisma/enums'; import { prisma } from '@config/prisma'; -import { applyTransition, type ClaimedMessage, completeOutbox } from '@features/battle-ledger'; +import { applyTransition, type ClaimedMessage, completeOutbox } from '@features/battle/ledger'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; /** diff --git a/backend/src/features/battle-worker/runner.ts b/backend/src/features/battle/worker/runner.ts similarity index 67% rename from backend/src/features/battle-worker/runner.ts rename to backend/src/features/battle/worker/runner.ts index 7383a700..c0609031 100644 --- a/backend/src/features/battle-worker/runner.ts +++ b/backend/src/features/battle/worker/runner.ts @@ -1,5 +1,11 @@ import { env } from '@config/env'; -import { type ClaimedMessage, claimOutbox, failOutbox, OUTBOX_TOPICS } from '@features/battle-ledger'; +import { + abandonBattle, + type ClaimedMessage, + claimOutbox, + failOutbox, + OUTBOX_TOPICS, +} from '@features/battle/ledger'; import { processAwaitBeaconMessage } from './beacon.worker'; import { processComputeMessage } from './compute.worker'; @@ -37,19 +43,42 @@ export async function runBattleWorkerOnce(workerId: string, now: Date = new Date // Claimed a topic this process does not know how to run. That is a deployment or // routing bug, not a transient failure, but dead-lettering it immediately is safer // than leaving it claimed forever with nothing to process it. - await failOutbox(message, `no handler for topic ${message.topic}`, now); + await giveUp(message, `no handler for topic ${message.topic}`, now); continue; } try { await handler(message, nowSeconds); } catch (error) { - await failOutbox(message, (error as Error).message, now); + await giveUp(message, (error as Error).message, now); } } return { processed: messages.length }; } +/** + * Records the failure, and ends the battle if that was the last attempt. + * + * A dead letter used to stop at the message: the battle stayed in whatever non-terminal + * state it was in, and since locks are freed by reaching a terminal state, both its pets + * were left unable to battle again with nothing saying why. `abandonBattle` closes that, + * and declines where the state machine has no legal way to forfeit — the dead letter is + * still listed for a human in either case. + */ +async function giveUp(message: ClaimedMessage, error: string, now: Date): Promise { + const { deadLettered } = await failOutbox(message, error, now); + if (!deadLettered) return; + + const { abandoned, state } = await abandonBattle( + message.battleId, + `${message.topic} gave up after ${message.attempts} attempts: ${error}`, + ); + console.error( + `[battle-worker] ${message.topic} dead-lettered for ${message.battleId}` + + (abandoned ? ' — battle forfeited, pets released' : ` — left in ${state ?? 'unknown'}`), + ); +} + export interface BattleWorkerHandle { stop(): void; } diff --git a/backend/src/features/battle-worker/sign.worker.ts b/backend/src/features/battle/worker/sign.worker.ts similarity index 99% rename from backend/src/features/battle-worker/sign.worker.ts rename to backend/src/features/battle/worker/sign.worker.ts index 3e783c6a..13c4b6ca 100644 --- a/backend/src/features/battle-worker/sign.worker.ts +++ b/backend/src/features/battle/worker/sign.worker.ts @@ -11,8 +11,8 @@ import type { Prisma } from '@generated/prisma/client'; import { env } from '@config/env'; import { prisma } from '@config/prisma'; -import { applyTransition, type ClaimedMessage, completeOutbox, OUTBOX_TOPICS } from '@features/battle-ledger'; -import { activeSigningKey, type EngineAttestation, sign, SignerRefusedError } from '@features/battle-signer'; +import { applyTransition, type ClaimedMessage, completeOutbox, OUTBOX_TOPICS } from '@features/battle/ledger'; +import { activeSigningKey, type EngineAttestation, sign, SignerRefusedError } from '@features/battle/signer'; import { recordBattleFromReceipt } from '@repositories/history.repository'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; diff --git a/backend/src/features/battle-worker/verify.worker.ts b/backend/src/features/battle/worker/verify.worker.ts similarity index 99% rename from backend/src/features/battle-worker/verify.worker.ts rename to backend/src/features/battle/worker/verify.worker.ts index 0e2a4608..e311efa2 100644 --- a/backend/src/features/battle-worker/verify.worker.ts +++ b/backend/src/features/battle/worker/verify.worker.ts @@ -11,7 +11,7 @@ import { BattleState } from '@generated/prisma/enums'; import type { Prisma } from '@generated/prisma/client'; import { prisma } from '@config/prisma'; -import { applyTransition, type ClaimedMessage, completeOutbox, OUTBOX_TOPICS } from '@features/battle-ledger'; +import { applyTransition, type ClaimedMessage, completeOutbox, OUTBOX_TOPICS } from '@features/battle/ledger'; import { callVerifyBattle, type VerifyBattleWire, type VerifyPetProgressionWire } from '@grpc-client/verifyBattle'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; diff --git a/backend/src/features/chat/chat.controller.ts b/backend/src/features/chat/chat.controller.ts new file mode 100644 index 00000000..9a247152 --- /dev/null +++ b/backend/src/features/chat/chat.controller.ts @@ -0,0 +1,211 @@ +import type { Request, Response } from 'express'; + +import type { AuthenticatedRequest } from '@middleware/auth'; +import { notifyChatThread } from '@ws/chatSocket'; +import { + authorizeThread, + listThreads, + markRead, + reactToMessage, + readMessages, + sendMessage, + type ChatDenial, +} from './chat.service'; +import { + ListMessagesSchema, + MarkReadSchema, + ReactSchema, + SendMessageSchema, +} from './chat.schema'; + +/** + * HTTP surface for private chat (roadmap §2 v1). Every route is JWT-gated at the + * router; the caller is always the session wallet and never a request field. + */ + +/** GET /api/chat/threads — the caller's currently-usable threads. */ +export async function getThreads(req: Request, res: Response): Promise { + const caller = callerOf(req); + if (!caller) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + try { + res.json({ threads: await listThreads(caller) }); + } catch (err) { + console.error('[chat] failed to list threads:', err); + res.status(500).json({ error: 'Failed to list chat threads' }); + } +} + +/** GET /api/chat/threads/:id/messages — a page, oldest first, newest page by default. */ +export async function getMessages(req: Request, res: Response): Promise { + const caller = callerOf(req); + if (!caller) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + const query = ListMessagesSchema.safeParse(req.query); + if (!query.success) { + res.status(400).json({ error: 'Invalid pagination' }); + return; + } + + const threadId = req.params.id ?? ''; + const denial = await authorizeThread(threadId, caller); + if (denial) { + respondToDenial(res, denial); + return; + } + + try { + res.json(await readMessages(threadId, caller, query.data.limit, query.data.before)); + } catch (err) { + console.error('[chat] failed to read messages:', err); + res.status(500).json({ error: 'Failed to read messages' }); + } +} + +/** POST /api/chat/threads/:id/read — move the caller's read watermark. */ +export async function postRead(req: Request, res: Response): Promise { + const caller = callerOf(req); + if (!caller) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + const body = MarkReadSchema.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: 'Invalid message id' }); + return; + } + + const threadId = req.params.id ?? ''; + const denial = await authorizeThread(threadId, caller); + if (denial) { + respondToDenial(res, denial); + return; + } + + try { + await markRead(threadId, caller, body.data.messageId); + // The sender is watching for their tick to fill in. Contentless like every other + // frame on this channel: it says the thread changed, and the client re-reads. + notifyChatThread(threadId, { + type: 'thread-read', + threadId, + messageId: body.data.messageId, + }); + res.status(204).end(); + } catch (err) { + console.error('[chat] failed to mark read:', err); + res.status(500).json({ error: 'Failed to mark read' }); + } +} + +/** + * POST /api/chat/threads/:id/messages/:messageId/reaction — tap a reaction. + * + * One call for all three outcomes: adding, replacing and removing. The client sends the + * emoji it tapped and the server works out which of those it meant, because the answer + * depends on what is already stored and only the server knows that without a race. + */ +export async function postReaction(req: Request, res: Response): Promise { + const caller = callerOf(req); + if (!caller) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + const body = ReactSchema.safeParse(req.body); + const messageId = Number(req.params.messageId); + if (!body.success || !Number.isInteger(messageId) || messageId <= 0) { + res.status(400).json({ error: 'Invalid reaction' }); + return; + } + + const threadId = req.params.id ?? ''; + const denial = await authorizeThread(threadId, caller); + if (denial) { + respondToDenial(res, denial); + return; + } + + try { + const result = await reactToMessage(threadId, caller, messageId, body.data.emoji); + if (result === 'not-found') { + res.status(404).json({ error: 'Message not found' }); + return; + } + // Contentless like every other frame here: it says the thread changed and the + // clients re-read. Its own type, because the message id it names is one every + // client already holds — the echo check would drop it as its own send. + notifyChatThread(threadId, { type: 'thread-reacted', threadId, messageId }); + res.json(result); + } catch (err) { + console.error('[chat] failed to react:', err); + res.status(500).json({ error: 'Failed to react' }); + } +} + +/** POST /api/chat/threads/:id/messages — append one message. */ +export async function postMessage(req: Request, res: Response): Promise { + const caller = callerOf(req); + if (!caller) { + res.status(401).json({ error: 'No token provided' }); + return; + } + + const body = SendMessageSchema.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: 'Message must be between 1 and 2000 characters' }); + return; + } + + const threadId = req.params.id ?? ''; + const denial = await authorizeThread(threadId, caller); + if (denial) { + respondToDenial(res, denial); + return; + } + + try { + const message = await sendMessage(threadId, caller, body.data.text); + // After the write, never instead of it: the message is already durable, so a + // failure to notify costs liveness, not the message. Carries no text — see + // `chatSocket`. + notifyChatThread(threadId, { + type: 'thread-updated', + threadId, + messageId: message.id, + }); + res.status(201).json({ message }); + } catch (err) { + console.error('[chat] failed to send message:', err); + res.status(500).json({ error: 'Failed to send message' }); + } +} + +function callerOf(req: Request): string | undefined { + return (req as AuthenticatedRequest).user?.address; +} + +/** + * A non-participant gets 404, not 403. + * + * 403 would confirm that a thread with that id exists, which is exactly what someone + * probing ids wants to learn. "Not yours" and "not there" are the same answer to + * anyone who is not a participant. An ended marriage does get its own status, because + * that caller is a participant and has already been told the thread exists. + */ +function respondToDenial(res: Response, denial: ChatDenial): void { + if (denial === 'not-married') { + res.status(403).json({ + error: 'This conversation is open only while your pets are married', + }); + return; + } + res.status(404).json({ error: 'Thread not found' }); +} diff --git a/backend/src/features/chat/chat.schema.ts b/backend/src/features/chat/chat.schema.ts new file mode 100644 index 00000000..68d66f4b --- /dev/null +++ b/backend/src/features/chat/chat.schema.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import { CHAT_REACTIONS } from '@shared/core/node'; + +/** + * Longest message accepted. + * + * A cap is the one content rule v1 has, and it exists for storage and render sanity + * rather than moderation — see the feature's README note. 2000 characters is far above + * anything a chat message needs and far below anything worth storing as a document. + */ +export const MAX_MESSAGE_LENGTH = 2000; + +/** Default and maximum messages returned per page. */ +export const DEFAULT_MESSAGE_PAGE = 50; +export const MAX_MESSAGE_PAGE = 100; + +/** + * Body of POST /api/chat/threads/:id/messages. + * + * Trimmed before the length check, so a message of nothing but whitespace is rejected + * rather than stored as a blank line. + */ +export const SendMessageSchema = z.object({ + text: z.string().transform((value) => value.trim()).pipe(z.string().min(1).max(MAX_MESSAGE_LENGTH)), +}); + +/** Body of POST /api/chat/threads/:id/read. */ +export const MarkReadSchema = z.object({ + /** Newest message the caller has seen. */ + messageId: z.coerce.number().int().positive(), +}); + +/** + * Body of POST /api/chat/threads/:id/messages/:messageId/reaction. + * + * The emoji must be one the client is allowed to offer. Sharing the list with the + * frontend is what stops a picker from showing something the API refuses, and it means + * this endpoint accepts no arbitrary user-authored string at all. + */ +export const ReactSchema = z.object({ + emoji: z.enum(CHAT_REACTIONS), +}); + +/** Query of GET /api/chat/threads/:id/messages. */ +export const ListMessagesSchema = z.object({ + /** Exclusive message id to page backwards from; omit for the newest page. */ + before: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().max(MAX_MESSAGE_PAGE).default(DEFAULT_MESSAGE_PAGE), +}); diff --git a/backend/src/features/chat/chat.service.ts b/backend/src/features/chat/chat.service.ts new file mode 100644 index 00000000..0c587ea9 --- /dev/null +++ b/backend/src/features/chat/chat.service.ts @@ -0,0 +1,239 @@ +import { normalizeAccount } from '@cryptopets/protocol'; + +import { + findMarriedCounterparts, + findCounterpartReadId, + findMessages, + findReactionsForMessages, + findThreadById, + insertMessage, + isMarriedTo, + markThreadRead, + messageBelongsToThread, + openThread, + setReaction, + type ChatMessageRow, +} from '@repositories/chat.repository'; + +/** + * Private player-to-player chat, v1 (roadmap §2). + * + * Access is the whole feature. A thread is readable and writable only while the two + * wallets have a married pet pair, checked against `pet_roster.spouse_id` on **every** + * request rather than recorded on the thread. That is the difference between "these two + * are married" and "these two were married once": a divorce closes the thread the moment + * the indexer sees it, with no revocation step to forget. + * + * The scope is deliberately the narrowest thing that is still a chat feature. There is + * no discovery surface, no way to name a counterpart, and no way to start a thread with + * anyone the game has not already connected you to — so the v2 question (open DMs) + * arrives with moderation as a prerequisite rather than as a retrofit. + * + * What v1 does NOT have, all of it flagged in the roadmap as a product call rather than + * an oversight: no block/report, no profanity filtering, no read receipts, no presence, + * no edit or delete, and no retention policy. Messages are kept until someone decides + * what the policy is. Rate limiting and a length cap are the only abuse controls, and + * they are volume controls, not content ones. + */ + +const MARRIAGE_SCOPE = 'marriage'; + +/** A thread as the caller sees it, with the marriage that justifies it. */ +export interface ChatThreadView { + threadId: string; + counterpart: string; + /** The married pair behind this thread, for a UI that wants to say why it exists. */ + pets: { + petId: string; + petName: string; + petDna: string; + spousePetId: string; + spouseName: string; + spouseDna: string; + }[]; + chain: string; +} + +/** + * Every thread the caller may currently use, derived from live marriage state. + * + * Threads are created here rather than by an explicit "open chat" call: a married pair + * always ends up with exactly one thread, so making the client ask for it first would + * add a round trip and a null state that only ever resolves one way. The insert is + * idempotent. + * + * A pair with two married pet couples collapses to one thread carrying both pairs, + * because the conversation is between the owners, not the pets. + */ +export async function listThreads(rawCaller: string): Promise { + // Normalized once, here, because the caller is also the thread's participant key: an + // unnormalized spelling of the same wallet would open a second thread beside the + // first and split the conversation. Production callers arrive normalized from the + // JWT; this makes that an invariant of the feature rather than of its callers. + const caller = normalizeAccount(rawCaller); + const marriages = await findMarriedCounterparts(caller); + if (marriages.length === 0) { + return []; + } + + // Group first, then open every thread at once. Opening inside the loop made this one + // sequential round trip per counterpart on a screen that loads them all together. + const byCounterpart = new Map>(); + for (const marriage of marriages) { + const counterpart = normalizeAccount(marriage.counterpart); + const pets = { + petId: marriage.petId, + petName: marriage.petName, + petDna: marriage.petDna, + spousePetId: marriage.spousePetId, + spouseName: marriage.spouseName, + spouseDna: marriage.spouseDna, + }; + + const seen = byCounterpart.get(counterpart); + if (seen) { + seen.pets.push(pets); + } else { + byCounterpart.set(counterpart, { counterpart, pets: [pets], chain: marriage.chain }); + } + } + + const views = [...byCounterpart.values()]; + const threadIds = await Promise.all( + views.map((view) => openThread(caller, view.counterpart, MARRIAGE_SCOPE)) + ); + return views.map((view, index) => ({ ...view, threadId: threadIds[index] as string })); +} + +/** Why a thread request was refused. `null` means it was not. */ +export type ChatDenial = 'not-found' | 'not-a-participant' | 'not-married'; + +/** + * Authorizes one request against one thread. + * + * Both checks are needed and neither implies the other. Participation says the thread is + * yours; the marriage says it is still live. A thread whose marriage has ended stays in + * the database — deleting it would destroy the history — but stops answering, which is + * why this returns a reason a caller can distinguish rather than a bare boolean. + */ +export async function authorizeThread(threadId: string, rawCaller: string): Promise { + const caller = normalizeAccount(rawCaller); + const thread = await findThreadById(threadId); + if (!thread) { + return 'not-found'; + } + + const isParticipant = thread.participantA === caller || thread.participantB === caller; + if (!isParticipant) { + return 'not-a-participant'; + } + + const counterpart = thread.participantA === caller ? thread.participantB : thread.participantA; + return (await isMarriedTo(caller, counterpart)) ? null : 'not-married'; +} + +/** One emoji on one message, as the reader needs it. */ +export interface ChatReactionView { + emoji: string; + /** How many people reacted with it. */ + count: number; + /** Whether the reader is one of them, which is the one the UI lights up. */ + mine: boolean; +} + +/** A message with its reactions attached. */ +export type ChatMessageView = ChatMessageRow & { reactions: ChatReactionView[] }; + +/** A page of messages, and how far the other participant has read. */ +export interface ChatPage { + messages: ChatMessageView[]; + /** + * Newest message id the counterpart has read; 0 if none. The caller's own messages up + * to here have been seen. Sent with every page rather than per message: it is one + * number for the whole thread, and a client compares it against the ids it already + * has. + */ + readUpTo: number; +} + +/** A page of messages. Authorization is the caller's job — see `authorizeThread`. */ +export async function readMessages( + threadId: string, + caller: string, + limit: number, + before?: number +): Promise { + const [rows, readUpTo] = await Promise.all([ + findMessages(threadId, limit, before), + findCounterpartReadId(threadId, caller), + ]); + // One query for the whole page's reactions, not one per message. + const reactions = await findReactionsForMessages(rows.map((row) => row.id)); + return { messages: attachReactions(rows, reactions, caller), readUpTo }; +} + +/** + * Groups raw reaction rows onto their messages. + * + * Emoji keep the order they were first used on each message, so a reaction bar does not + * reshuffle under the reader when someone else joins one that is already there. + */ +function attachReactions( + rows: ChatMessageRow[], + reactions: { messageId: number; participant: string; emoji: string }[], + caller: string +): ChatMessageView[] { + const me = normalizeAccount(caller); + const byMessage = new Map>(); + for (const reaction of reactions) { + let group = byMessage.get(reaction.messageId); + if (!group) { + group = new Map(); + byMessage.set(reaction.messageId, group); + } + const entry = group.get(reaction.emoji) ?? { emoji: reaction.emoji, count: 0, mine: false }; + entry.count += 1; + entry.mine ||= reaction.participant === me; + group.set(reaction.emoji, entry); + } + return rows.map((row) => ({ ...row, reactions: [...(byMessage.get(row.id)?.values() ?? [])] })); +} + +/** + * Applies a reaction tap and reports what the caller now holds, or null if it was removed. + * + * Authorization is the caller's job, and it is thread-level: a participant may react to + * either side's messages, which is the whole point. The message is checked to belong to + * the thread so a thread id the caller *can* read cannot be used to reach a message in one + * they cannot. + */ +export async function reactToMessage( + threadId: string, + caller: string, + messageId: number, + emoji: string +): Promise<{ emoji: string | null } | 'not-found'> { + const belongs = await messageBelongsToThread(messageId, threadId); + if (!belongs) return 'not-found'; + return { emoji: await setReaction(messageId, caller, emoji) }; +} + +/** + * Records that the caller has read up to `messageId`. Authorization is the caller's job. + * + * Marking a message the caller sent themselves is harmless and not worth a round trip to + * prevent: the watermark is only ever read to answer "has the *other* side seen this", + * and their own messages are excluded from that question by construction. + */ +export function markRead(threadId: string, caller: string, messageId: number): Promise { + return markThreadRead(threadId, caller, messageId); +} + +/** Appends the caller's message. Authorization is the caller's job. */ +export function sendMessage( + threadId: string, + sender: string, + text: string +): Promise { + return insertMessage(threadId, sender, text); +} diff --git a/backend/src/features/chat/index.ts b/backend/src/features/chat/index.ts new file mode 100644 index 00000000..927832a0 --- /dev/null +++ b/backend/src/features/chat/index.ts @@ -0,0 +1,12 @@ +/** + * Public surface of the chat feature. External code imports from `@features/chat` so + * the internal layout can change without touching call sites. + */ +export { + getMessages, + getThreads, + postMessage, + postReaction, + postRead, +} from './chat.controller'; +export { authorizeThread, listThreads, type ChatDenial, type ChatThreadView } from './chat.service'; diff --git a/backend/src/features/settle-keeper/index.ts b/backend/src/features/settle-keeper/index.ts index f2240ed9..20003aea 100644 --- a/backend/src/features/settle-keeper/index.ts +++ b/backend/src/features/settle-keeper/index.ts @@ -4,9 +4,8 @@ import { startKeeper, type SettleKeeperHandle } from './keeper'; /** * Settles GameLogic breed/mint requests from a backend-held wallet the moment * Pyth Entropy reveals, so the player never has to send the second (settle) - * transaction themselves. See docs/plan-realtime-battle-ux.md and - * docs/plan-realtime-battle-impl.md Phase 2 for the original design; battles no - * longer take this path at all (§L Phase 6), breed and mint still do. + * transaction themselves. Battles no longer take this path at all + * (docs/battle-protocol.md §L Phase 6), breed and mint still do. * * Off unless KEEPER_ENABLED=true: the feature simply doesn't start rather than failing, * so local dev / CI without a configured keeper wallet is unaffected. diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 87ea771c..1f5bace3 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -1,5 +1,10 @@ import { findReadyOpponents, getAllPets, getPetById, searchPets, type RosterPet } from '@repositories/roster.repository'; import { findBattleProgress, withBattleProgress } from '@repositories/battleProgress.overlay'; +import { + findPetLeaderboard, + findPlayerLeaderboard, + findPlayerRank, +} from '@repositories/leaderboard.repository'; import { tryGrpcEstimateWin } from '@grpc-client/estimateWin'; import { isSupportedChain, SUPPORTED_CHAINS } from '@typings/chain'; @@ -17,6 +22,14 @@ interface OpponentsArgs { pageSize?: number | null; } +interface LeaderboardArgs { + chain: string; + page?: number | null; + pageSize?: number | null; + /** Substring filter; ranks stay absolute, so it narrows the board without renumbering it. */ + search?: string | null; +} + interface BattleProgressArgs { chain: string; petIds: string[]; @@ -94,6 +107,58 @@ export const rootValue = { }; }, + leaderboard: async (args: LeaderboardArgs) => { + if (!isSupportedChain(args.chain)) { + throw new Error(`chain must be one of: ${SUPPORTED_CHAINS.join(', ')}`); + } + + const page = Math.max(0, args.page ?? 0); + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, args.pageSize ?? DEFAULT_PAGE_SIZE)); + + // No overlay here, for the same reason as `opponents`: the ranking is the merge, + // so `findPetLeaderboard` does it in the query. + const { entries, total } = await findPetLeaderboard({ + chain: args.chain, + page, + pageSize, + search: args.search ?? undefined, + }); + + return { + entries: entries.map(({ petId: id, ...rest }) => ({ id, ...rest })), + total, + page, + pageSize, + }; + }, + + playerLeaderboard: async (args: LeaderboardArgs) => { + if (!isSupportedChain(args.chain)) { + throw new Error(`chain must be one of: ${SUPPORTED_CHAINS.join(', ')}`); + } + + const page = Math.max(0, args.page ?? 0); + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, args.pageSize ?? DEFAULT_PAGE_SIZE)); + const { entries, total } = await findPlayerLeaderboard({ + chain: args.chain, + page, + pageSize, + search: args.search ?? undefined, + }); + + return { entries, total, page, pageSize }; + }, + + playerRank: async (args: { chain: string }, context: GraphQLContext) => { + if (!isSupportedChain(args.chain)) { + throw new Error(`chain must be one of: ${SUPPORTED_CHAINS.join(', ')}`); + } + + // The session address, already normalized the way the board groups owners. An + // unauthenticated caller has no standing to report rather than an error. + return findPlayerRank(args.chain, context.caller); + }, + searchPets: async (args: SearchPetsArgs) => { if (!isSupportedChain(args.chain)) { throw new Error(`chain must be one of: ${SUPPORTED_CHAINS.join(', ')}`); diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 74a48a7b..c408c050 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -54,6 +54,57 @@ export const schema = buildSchema(` readyAt: Float! } + """ + One ranked pet on the leaderboard. + + The battle record is the merged one: pet_battle_progress where a pet has fought a + backend battle, the frozen pet_roster counters otherwise — the same rule every + other pet read here applies. + """ + type LeaderboardEntry { + "1-based position in the full ranking, not within the page." + rank: Int! + "Pet id as a decimal string." + id: String! + chain: String! + owner: String! + name: String! + "On-chain DNA serialized as a decimal string." + dna: String! + level: Int! + rarity: Int! + winCount: Int! + lossCount: Int! + "Metaplex Core asset pubkey (Solana only); empty string on EVM." + asset: String! + } + + type LeaderboardPage { + entries: [LeaderboardEntry!]! + total: Int! + page: Int! + pageSize: Int! + } + + "One ranked owner, over the same merged battle record as LeaderboardEntry." + type PlayerLeaderboardEntry { + "1-based position in the full ranking, not within the page." + rank: Int! + "Wallet address / pubkey. EVM addresses are lowercased; Solana pubkeys are not." + owner: String! + winCount: Int! + lossCount: Int! + "How many of this owner's pets have a battle record." + petCount: Int! + } + + type PlayerLeaderboardPage { + entries: [PlayerLeaderboardEntry!]! + total: Int! + page: Int! + pageSize: Int! + } + type OpponentsPage { opponents: [OpponentPet!]! total: Int! @@ -77,6 +128,42 @@ export const schema = buildSchema(` pageSize: Int ): OpponentsPage! + """ + Pets ranked by battle record: wins descending, then losses ascending (the + win-rate tiebreak), then level, then pet id. Pets that have never fought are + omitted. Ranks are absolute, so page 2 continues where page 1 stopped. + """ + leaderboard( + chain: String! + page: Int + pageSize: Int + "Case-insensitive substring of the pet's name. Ranks stay absolute: a match keeps its position on the full board rather than being renumbered within the results." + search: String + ): LeaderboardPage! + + """ + Owners ranked by their pets' combined battle record, ordered the same way as + the pet board. Only pets that have fought are summed, so petCount is "pets with + a record" and an owner whose pets have never fought does not appear. + """ + playerLeaderboard( + chain: String! + page: Int + pageSize: Int + "Case-insensitive substring of the owner's address. Ranks stay absolute, as on the pet board." + search: String + ): PlayerLeaderboardPage! + + """ + The authenticated caller's own standing on the player board. + + Returns null when the caller holds no pet that has fought — "unranked" is a real + answer, and a zeroed row could not be told apart from a player ranked last. The + owner is taken from the session, never from an argument, so this cannot be used + to enumerate other wallets' positions. + """ + playerRank(chain: String!): PlayerLeaderboardEntry + """ Search pets by name prefix or exact numeric ID across the whole roster. Returns up to 'limit' results (default 10, max 20). diff --git a/backend/src/middleware/rateLimit.ts b/backend/src/middleware/rateLimit.ts index 4e04ea2b..24f1d8d6 100644 --- a/backend/src/middleware/rateLimit.ts +++ b/backend/src/middleware/rateLimit.ts @@ -41,6 +41,34 @@ export const dialogueRateLimit = rateLimit({ message: { error: 'Too many dialogue requests, try again shortly' }, }); +/** + * Chat sending is the only endpoint in the API that produces content another player + * receives, so its budget is a moderation control as much as a cost one: 20/min is + * conversational speed and well short of flooding someone's thread. It is also the + * *only* such control in v1 — there is no block, report, or filter yet (roadmap §2). + */ +export const chatSendRateLimit = rateLimit({ + windowMs: 60_000, + limit: 20, + standardHeaders: 'draft-8', + legacyHeaders: false, + keyGenerator: walletKey, + message: { error: 'Sending too fast, try again shortly' }, +}); + +/** + * Reading is indexed queries against the caller's own threads, so it only needs to be + * loose enough for a client that polls between socket updates and re-reads on focus. + */ +export const chatReadRateLimit = rateLimit({ + windowMs: 60_000, + limit: 120, + standardHeaders: 'draft-8', + legacyHeaders: false, + keyGenerator: walletKey, + message: { error: 'Too many chat requests, try again shortly' }, +}); + /** * Room creation is a single cheap insert (no LLM), so it gets a much looser * budget than dialogue — just enough to stop room-spam from repeated diff --git a/backend/src/repositories/battleProgress.overlay.ts b/backend/src/repositories/battleProgress.overlay.ts index 9c1f2318..0d7270fa 100644 --- a/backend/src/repositories/battleProgress.overlay.ts +++ b/backend/src/repositories/battleProgress.overlay.ts @@ -1,7 +1,7 @@ import { chainFamily, type ChainId } from '@cryptopets/protocol'; import { prisma } from '@config/prisma'; -import { servedChainIds, servedDeploymentId } from '@features/battle-ledger/domain'; +import { servedChainIds, servedDeploymentId } from '@features/battle/ledger/domain'; import type { RosterPet } from './roster.repository'; import type { Chain } from '@typings/chain'; diff --git a/backend/src/repositories/chat.repository.ts b/backend/src/repositories/chat.repository.ts new file mode 100644 index 00000000..e5204722 --- /dev/null +++ b/backend/src/repositories/chat.repository.ts @@ -0,0 +1,318 @@ +import { normalizeAccount } from '@cryptopets/protocol'; +import { Prisma } from '@generated/prisma/client'; + +import { prisma } from '@config/prisma'; +import { ownerKey } from './owner.sql'; +import { chainOfAccount, type Chain } from '@typings/chain'; + +/** + * Read/write access for private chat (roadmap §2 v1), plus the marriage lookup that + * gates it. + * + * The gate lives here rather than in `roster.repository.ts` because it is a chat + * question asked of roster data, and `roster.repository.ts` is deliberately kept as the + * unmerged projection two battle callers depend on. + */ + +/** One current marriage involving the caller, from the caller's side. */ +export interface MarriedCounterpart { + chain: Chain; + /** The caller's married pet. `dna` is what a client derives its art and emoji from. */ + petId: string; + petName: string; + petDna: string; + /** The spouse pet, and the wallet that owns it. */ + spousePetId: string; + spouseName: string; + spouseDna: string; + counterpart: string; +} + +/** + * The roster-side marriage predicate for one caller: their pets, joined to each spouse + * pet, excluding pairs they own both sides of. + * + * A pet married to another of the caller's own pets is a marriage but not a + * conversation — there is no second person to talk to. + */ +function marriedPairs(caller: string): { chain: Chain; where: Prisma.Sql } { + const chain = chainOfAccount(caller); + return { + chain, + where: Prisma.sql` + FROM pet_roster p + JOIN pet_roster s + ON s.chain = p.chain + AND s.pet_id = p.spouse_id + WHERE p.chain = ${chain} + AND ${ownerKey(chain, 'p')} = ${normalizeAccount(caller)} + AND p.spouse_id <> '0' + AND s.owner <> p.owner + `, + }; +} + +/** + * Every wallet the caller is currently married to, one row per married pet pair. + * + * The caller is normalized and the chain derived from its shape, so the comparison + * matches how indexer-go wrote the roster regardless of the case it used. An owner pair + * can appear more than once (two of their pets married to each other's); the caller + * collapses that into one thread per pair. + */ +export async function findMarriedCounterparts(caller: string): Promise { + if (!caller) { + return []; + } + const { where } = marriedPairs(caller); + + return prisma.$queryRaw` + SELECT p.chain, + p.pet_id AS "petId", + p.name AS "petName", + p.dna AS "petDna", + s.pet_id AS "spousePetId", + s.name AS "spouseName", + s.dna AS "spouseDna", + s.owner AS counterpart + ${where} + ORDER BY p.pet_id ASC + `; +} + +/** + * Whether these two wallets currently have a married pet pair. + * + * The binary form of `findMarriedCounterparts`, for the authorization check that runs on + * every message read and send: that path only needs a yes or no, and fetching every + * counterpart to filter one out in JavaScript reads more rows the busier a player is. + */ +export async function isMarriedTo(caller: string, counterpart: string): Promise { + if (!caller || !counterpart) { + return false; + } + const { chain, where } = marriedPairs(caller); + + const rows = await prisma.$queryRaw<{ ok: number }[]>` + SELECT 1 AS ok + ${where} + AND ${ownerKey(chain, 's')} = ${normalizeAccount(counterpart)} + LIMIT 1 + `; + return rows.length > 0; +} + +/** + * The thread id for a pair, creating it if this is their first. + * + * Participants are stored in lexicographic order, which is what makes the unique + * constraint mean "one thread per pair" instead of "one per direction" — without it A→B + * and B→A would be two threads holding half a conversation each. + * + * An upsert rather than find-then-create: it is one round trip whether or not the thread + * exists, and two callers opening the same thread at once resolve to the same row instead + * of racing to a unique-constraint violation. + */ +export async function openThread( + walletX: string, + walletY: string, + scope: string +): Promise { + const [participantA, participantB] = walletX < walletY ? [walletX, walletY] : [walletY, walletX]; + + const thread = await prisma.chatThread.upsert({ + where: { chat_thread_pair: { participantA, participantB } }, + create: { participantA, participantB, scope }, + update: {}, + select: { id: true }, + }); + return thread.id; +} + +/** The thread by id, or null. Returns participants so the caller can authorize. */ +export function findThreadById(id: string) { + return prisma.chatThread.findUnique({ + where: { id }, + select: { id: true, participantA: true, participantB: true }, + }); +} + +export interface ChatMessageRow { + id: number; + sender: string; + text: string; + createdAt: Date; +} + +/** + * A page of messages, oldest first within the page. + * + * Paged backwards from `before` (an exclusive message id) because a chat is read from its + * end: the first page is the newest messages, and older ones load as the reader scrolls + * up. + */ +export async function findMessages( + threadId: string, + limit: number, + before?: number +): Promise { + const rows = await prisma.chatMessage.findMany({ + where: { threadId, ...(before != null ? { id: { lt: before } } : {}) }, + orderBy: { id: 'desc' }, + take: limit, + select: { id: true, sender: true, text: true, createdAt: true }, + }); + return rows.reverse(); +} + +/** Appends a message. */ +export function insertMessage( + threadId: string, + sender: string, + text: string +): Promise { + return prisma.chatMessage.create({ + data: { threadId, sender, text }, + select: { id: true, sender: true, text: true, createdAt: true }, + }); +} + +/** + * Records that `participant` has read up to `messageId`. + * + * `GREATEST` rather than a plain assignment: the watermark only ever moves forward. A + * client that scrolls up and re-marks, or whose two tabs report different positions, + * would otherwise walk it backwards and un-read messages the sender has already been + * shown as read. + */ +export async function markThreadRead( + threadId: string, + participant: string, + messageId: number +): Promise { + await prisma.$executeRaw` + INSERT INTO chat_read (thread_id, participant, last_read_id, updated_at) + VALUES (${threadId}, ${normalizeAccount(participant)}, ${messageId}, now()) + ON CONFLICT (thread_id, participant) DO UPDATE + SET last_read_id = GREATEST(chat_read.last_read_id, EXCLUDED.last_read_id), + updated_at = now() + `; +} + +/** + * The newest message id anyone other than `caller` has read in this thread. + * + * 0 when nobody has, which reads naturally at the call site: every message id is + * positive, so nothing is marked seen. Excluding the caller by address rather than + * looking their counterpart up keeps this to one query and stays correct if a thread + * ever holds more than two participants. + */ +export async function findCounterpartReadId(threadId: string, caller: string): Promise { + const rows = await prisma.$queryRaw<{ lastReadId: number | null }[]>` + SELECT MAX(last_read_id) AS "lastReadId" + FROM chat_read + WHERE thread_id = ${threadId} + AND participant <> ${normalizeAccount(caller)} + `; + return rows[0]?.lastReadId ?? 0; +} + +/** One emoji on one message, with who reacted. */ +export interface ChatReactionRow { + messageId: number; + participant: string; + emoji: string; +} + +/** + * Applies a reaction tap: sets it, replaces the previous one, or removes it. + * + * Returns the reaction now in place, or null if the tap removed one. Toggling is decided + * here rather than in the service because the decision needs the current row, and a read + * then a write would let two taps interleave into the wrong final state. + * + * One statement, because Postgres runs every data-modifying CTE and the insert's + * `NOT EXISTS (SELECT 1 FROM removed)` makes it conditional on the delete having matched: + * tapping the emoji already stored deletes it and inserts nothing, anything else replaces. + */ +export async function setReaction( + messageId: number, + participant: string, + emoji: string +): Promise { + const who = normalizeAccount(participant); + const rows = await prisma.$queryRaw<{ emoji: string }[]>` + WITH removed AS ( + DELETE FROM chat_reaction + WHERE message_id = ${messageId} AND participant = ${who} AND emoji = ${emoji} + RETURNING emoji + ), applied AS ( + INSERT INTO chat_reaction (message_id, participant, emoji, created_at) + SELECT ${messageId}, ${who}, ${emoji}, now() + WHERE NOT EXISTS (SELECT 1 FROM removed) + ON CONFLICT (message_id, participant) DO UPDATE + SET emoji = EXCLUDED.emoji, created_at = now() + RETURNING emoji + ) + SELECT emoji FROM applied + `; + return rows[0]?.emoji ?? null; +} + +/** Removes a participant's reaction to a message, if any. */ +export async function clearReaction(messageId: number, participant: string): Promise { + await prisma.chatReaction.deleteMany({ + where: { messageId, participant: normalizeAccount(participant) }, + }); +} + +/** The participant's current reaction to a message, or null. */ +export async function findReaction( + messageId: number, + participant: string +): Promise { + const row = await prisma.chatReaction.findUnique({ + where: { + messageId_participant: { messageId, participant: normalizeAccount(participant) }, + }, + select: { emoji: true }, + }); + return row?.emoji ?? null; +} + +/** + * Every reaction on a page of messages, in one query. + * + * Per-message queries would be one round trip per row rendered; a page is fifty. Returns + * the raw rows and lets the service group them, because who reacted matters to the caller + * (their own reaction is the one the UI highlights) and a count alone would lose it. + */ +export async function findReactionsForMessages( + messageIds: number[] +): Promise { + if (messageIds.length === 0) return []; + return prisma.chatReaction.findMany({ + where: { messageId: { in: messageIds } }, + select: { messageId: true, participant: true, emoji: true }, + orderBy: [{ messageId: 'asc' }, { createdAt: 'asc' }], + }); +} + +/** + * Whether a message belongs to a thread. + * + * Reaction requests name both, and authorization is thread-level. Without this check a + * caller could authorize against a thread they are in and then name any message id in the + * database, reacting to a conversation they cannot read — and reactions are visible to the + * people in that thread, so it would be writing into it. + */ +export async function messageBelongsToThread( + messageId: number, + threadId: string +): Promise { + const row = await prisma.chatMessage.findFirst({ + where: { id: messageId, threadId }, + select: { id: true }, + }); + return row !== null; +} diff --git a/backend/src/repositories/leaderboard.repository.ts b/backend/src/repositories/leaderboard.repository.ts new file mode 100644 index 00000000..8021966f --- /dev/null +++ b/backend/src/repositories/leaderboard.repository.ts @@ -0,0 +1,291 @@ +import { Prisma } from '@generated/prisma/client'; + +import { prisma } from '@config/prisma'; +import { servedChainIdForFamily } from './battleProgress.overlay'; +import { ownerKey } from './owner.sql'; +import { servedDeploymentId } from '@features/battle/ledger/domain'; +import type { Chain } from '@typings/chain'; + +/** + * Read layer for the leaderboards: pets ranked individually, and owners ranked by their + * pets' combined record. + * + * Ranks on the *merged* battle record, not on `pet_roster` alone. Since battles stopped + * settling on chain (§L Phase 6) the roster's `win_count`/`loss_count` are frozen at + * whatever the retired path left behind, while the live record accumulates in + * `pet_battle_progress` — so ranking on the roster would rank pets by a number that + * stopped moving, and ranking on progress alone would drop every pet whose whole record + * predates the backend path. + * + * Like `findReadyOpponents`, the merge happens in the query rather than afterwards: the + * ordering *is* the merge here, and a post-sort can only reorder rows a page already + * holds. Same consequence, too — no gRPC fast path, because indexer-go's cache holds + * chain state and has no view of `pet_battle_progress`. + * + * Every query below is built from the fragments beneath: the merge, the "has fought" + * filter and the ordering each exist once. That matters beyond tidiness — `findPlayerRank` + * must order identically to `findPlayerLeaderboard` or a player's stated rank stops + * matching where they appear on the board, and nothing but shared text enforces that. + */ + +/** One ranked owner: their pets' battle records, summed. */ +export interface PlayerLeaderboardEntry { + /** 1-based position in the full ranking, not within the page. */ + rank: number; + /** Wallet address / pubkey, as grouped — see `ownerKey`. */ + owner: string; + winCount: number; + lossCount: number; + /** How many of this owner's pets have a battle record. */ + petCount: number; +} + +/** One ranked pet. Carries what a leaderboard row displays, not the full roster shape. */ +export interface LeaderboardEntry { + /** 1-based position in the full ranking, not within the page. */ + rank: number; + chain: Chain; + petId: string; + owner: string; + name: string; + level: number; + rarity: number; + dna: string; + winCount: number; + lossCount: number; + /** Metaplex Core asset pubkey (Solana only); "" on EVM. Needed to address pet art. */ + asset: string; +} + +export interface FindLeaderboardParams { + chain: Chain; + page: number; + pageSize: number; + /** Filters the board without renumbering it; see `contains`. */ + search?: string | undefined; +} + +/** The projected columns, before the rank is attached. */ +type RankedRow = Omit & { chain: string }; +type PlayerRow = Omit; + +/** + * The join onto backend progression. + * + * A null `chainId` — this deployment serves no chain of the family — is not a special + * case needing its own query. `p.chain_id = NULL` is never true, so the LEFT JOIN matches + * nothing, every `COALESCE` below falls through to the roster column, and the result is + * exactly the chain-state-only ranking. Verified against live data on both chains. + */ +const progressJoin = (chainId: string | null): Prisma.Sql => Prisma.sql` + LEFT JOIN pet_battle_progress p + ON p.pet_id = r.pet_id + AND p.chain_id = ${chainId} + AND p.deployment_id = ${servedDeploymentId()} +`; + +// COALESCE for win/loss, GREATEST for level, matching `overlayRosterPet` and +// `findReadyOpponents`: a progress row supplies the battle record wholesale (nothing on +// chain writes it any more), while level keeps two live writers — backend battles raise +// the row, paid on-chain train()/levelUp() raise the roster. +const WINS = Prisma.sql`COALESCE(p.win_count, r.win_count)`; +const LOSSES = Prisma.sql`COALESCE(p.loss_count, r.loss_count)`; +const LEVEL = Prisma.sql`GREATEST(r.level, COALESCE(p.level, 0))`; + +/** + * Pets and owners with no record at all are excluded. A board of never-fought pets would + * be a roster dump with a rank column, and on this ordering they would all tie last. + */ +const HAS_FOUGHT = Prisma.sql`${WINS} + ${LOSSES} > 0`; + +/** + * Wins descending, then losses ascending, then a stable tiebreak. + * + * The losses key *is* the win-rate tiebreak: among rows on equal wins, fewer losses is a + * strictly higher rate, so no division is needed and nothing is ranked on a ratio drawn + * from a handful of fights. + */ +const petOrder = Prisma.sql`${WINS} DESC, ${LOSSES} ASC, ${LEVEL} DESC, r.pet_id ASC`; + +/** + * A search term as a case-insensitive contains-match against any of several columns. + * + * Several, because one box has to answer both questions people bring here — "how is my + * pet doing" and "how is that player doing" — and nobody should have to know which board + * indexes which. A wallet address typed into the pet board finds that wallet's pets. + * + * `TRUE` for a blank term rather than a second query: an unfiltered board is the same + * shape as a filtered one, and branching would double every query below. + * + * `%` and `_` are escaped because they are wildcards to `ILIKE` — a player searching for + * a pet actually named `100%` should not match every pet on the board. + */ +function matchesAny(columns: Prisma.Sql[], term: string | undefined): Prisma.Sql { + const needle = term?.trim(); + if (!needle) return Prisma.sql`TRUE`; + const escaped = needle.replace(/[\\%_]/g, (char) => `\\${char}`); + const pattern = `%${escaped}%`; + return Prisma.sql`(${Prisma.join( + columns.map((column) => Prisma.sql`${column} ILIKE ${pattern} ESCAPE '\\'`), + ' OR ' + )})`; +} + +const playerOrder = (owner: Prisma.Sql) => + Prisma.sql`SUM(${WINS}) DESC, SUM(${LOSSES}) ASC, ${owner} ASC`; + +/** A page of the pet leaderboard. */ +export async function findPetLeaderboard( + params: FindLeaderboardParams +): Promise<{ entries: LeaderboardEntry[]; total: number }> { + const join = progressJoin(servedChainIdForFamily(params.chain)); + const skip = params.page * params.pageSize; + // Name or owner: the pet board is where someone pastes an address to see a rival's + // pets, and where they type a name to find their own. + const match = matchesAny( + [Prisma.sql`ranked.name`, Prisma.sql`ranked.owner`], + params.search + ); + + /** + * Ranked first, filtered second. + * + * The rank is a position on the whole board, so it has to be assigned before any + * search narrows the rows — otherwise a pet's rank would change depending on what + * someone typed, and "where does Yasu sit" is the question a search here is asked. + * That is why it comes from `ROW_NUMBER` rather than from the offset, which only ever + * described an unfiltered page. + */ + const ranked = Prisma.sql` + SELECT r.chain, r.pet_id AS "petId", r.owner, r.name, r.rarity, r.dna, r.asset, + ${LEVEL} AS level, ${WINS} AS "winCount", ${LOSSES} AS "lossCount", + ROW_NUMBER() OVER (ORDER BY ${petOrder})::int AS rank + FROM pet_roster r + ${join} + WHERE r.chain = ${params.chain} AND ${HAS_FOUGHT} + `; + + const [rows, counted] = await Promise.all([ + prisma.$queryRaw<(RankedRow & { rank: number })[]>` + SELECT * FROM (${ranked}) ranked + WHERE ${match} + ORDER BY rank + LIMIT ${params.pageSize} OFFSET ${skip} + `, + // Counted after the filter, since it drives the pager over the matches rather + // than over the board. + prisma.$queryRaw<{ total: bigint }[]>` + SELECT COUNT(*) AS total FROM (${ranked}) ranked WHERE ${match} + `, + ]); + + return { + entries: rows.map((row) => ({ ...row, chain: row.chain as Chain })), + total: Number(counted[0]?.total ?? 0), + }; +} + +/** + * A page of the player leaderboard: owners ranked by their pets' combined record. + * + * Only pets that have fought are summed, so `petCount` reads as "pets with a record" + * rather than "pets owned", and an owner with no battled pets does not appear at all. + */ +export async function findPlayerLeaderboard( + params: FindLeaderboardParams +): Promise<{ entries: PlayerLeaderboardEntry[]; total: number }> { + const join = progressJoin(servedChainIdForFamily(params.chain)); + const owner = ownerKey(params.chain, 'r'); + const skip = params.page * params.pageSize; + // Address or any of the owner's pet names, so looking up the player behind a pet + // works without knowing whose it is. The names are aggregated in the subquery below + // because the filter runs outside it, after the grouping. + const match = matchesAny( + [Prisma.sql`ranked.owner`, Prisma.sql`ranked."petNames"`], + params.search + ); + + // The SUMs are cast because Postgres widens them to bigint, which Prisma would hand + // back as a BigInt the GraphQL Int serializer cannot take. A player's battle count has + // no way to approach the int range. + // + // Ranked before filtered, for the same reason as the pet board: a rank describes a + // position on the whole board, not within someone's search. + const ranked = Prisma.sql` + SELECT ${owner} AS owner, + SUM(${WINS})::int AS "winCount", + SUM(${LOSSES})::int AS "lossCount", + COUNT(*)::int AS "petCount", + STRING_AGG(r.name, ' ') AS "petNames", + ROW_NUMBER() OVER (ORDER BY ${playerOrder(owner)})::int AS rank + FROM pet_roster r + ${join} + WHERE r.chain = ${params.chain} AND ${HAS_FOUGHT} + GROUP BY ${owner} + `; + + const [rows, counted] = await Promise.all([ + prisma.$queryRaw<(PlayerRow & { rank: number; petNames: string })[]>` + SELECT * FROM (${ranked}) ranked + WHERE ${match} + ORDER BY rank + LIMIT ${params.pageSize} OFFSET ${skip} + `, + // Counts grouped owners, not joined pet rows: COUNT(*) over the ungrouped join + // would count pets and page the client past the last owner. + prisma.$queryRaw<{ total: bigint }[]>` + SELECT COUNT(*) AS total FROM (${ranked}) ranked WHERE ${match} + `, + ]); + + return { + // `petNames` exists to be searched, not shown: dropped here so it never reaches a + // client that would have to know to ignore it. + entries: rows.map(({ petNames: _petNames, ...entry }) => entry), + total: Number(counted[0]?.total ?? 0), + }; +} + +/** + * One owner's own standing, or null when they hold no pet that has fought. + * + * Exists so a player can be told their rank without the client paging the board looking + * for itself, which costs one request per page and gets worse as the game grows. + * + * `ROW_NUMBER` over `playerOrder` — the same ordering the paged query uses, from the same + * fragment — so this number is the one that row carries on its page. The page arithmetic + * (`skip + index + 1`) is the same function, and ties cannot diverge because the owner key + * makes the ordering strict. + * + * `owner` must already be normalized the way the JWT normalizes it, which is how + * `ownerKey` groups. An unnormalized EVM address matches nothing and reads as "unranked", + * which is why callers pass the authenticated address rather than anything user-supplied. + */ +export async function findPlayerRank( + chain: Chain, + owner: string +): Promise { + if (!owner) { + return null; + } + + const join = progressJoin(servedChainIdForFamily(chain)); + const key = ownerKey(chain, 'r'); + + const rows = await prisma.$queryRaw` + SELECT owner, "winCount", "lossCount", "petCount", rank FROM ( + SELECT ${key} AS owner, + SUM(${WINS})::int AS "winCount", + SUM(${LOSSES})::int AS "lossCount", + COUNT(*)::int AS "petCount", + ROW_NUMBER() OVER (ORDER BY ${playerOrder(key)})::int AS rank + FROM pet_roster r + ${join} + WHERE r.chain = ${chain} AND ${HAS_FOUGHT} + GROUP BY ${key} + ) ranked + WHERE owner = ${owner} + `; + + return rows[0] ?? null; +} diff --git a/backend/src/repositories/owner.sql.ts b/backend/src/repositories/owner.sql.ts new file mode 100644 index 00000000..81861712 --- /dev/null +++ b/backend/src/repositories/owner.sql.ts @@ -0,0 +1,25 @@ +import { Prisma } from '@generated/prisma/client'; + +import type { Chain } from '@typings/chain'; + +/** + * The SQL expression that identifies a `pet_roster` owner, per chain. + * + * EVM addresses are folded to lowercase and base58 Solana pubkeys are left alone — the + * same split `normalizeAccount` makes. Stated once here because it is security-relevant + * in more than one place and was previously restated at three call sites: matchmaking's + * consent match, the leaderboard's owner grouping, and chat's marriage gate. + * + * Both directions of getting it wrong are real. `owner` is written by indexer-go, which + * is not guaranteed to match the case the JWT normalizes to, so an unfolded EVM + * comparison misses rows that should match — listing one wallet twice on the player + * board, or finding no marriage for someone who has one. Folding base58 does the + * opposite damage: two distinct Solana pubkeys can differ only in case, so it can match + * an account that is not the caller's. + * + * `alias` is the table alias the caller gave `pet_roster` in its own query. + */ +export function ownerKey(chain: Chain, alias: string): Prisma.Sql { + const column = Prisma.raw(`${alias}.owner`); + return chain === 'evm' ? Prisma.sql`LOWER(${column})` : Prisma.sql`${column}`; +} diff --git a/backend/src/repositories/roster.repository.ts b/backend/src/repositories/roster.repository.ts index f42d1ee8..f8d24abf 100644 --- a/backend/src/repositories/roster.repository.ts +++ b/backend/src/repositories/roster.repository.ts @@ -5,7 +5,8 @@ import { prisma } from '@config/prisma'; import { tryGrpcGetPetState } from '@grpc-client/rosterReads'; import { mapRosterRowToRosterPet, type PetRosterRow } from './roster.mapping'; import { servedChainIdForFamily } from './battleProgress.overlay'; -import { servedDeploymentId } from '@features/battle-ledger/domain'; +import { ownerKey } from './owner.sql'; +import { servedDeploymentId } from '@features/battle/ledger/domain'; import type { Chain } from '@typings/chain'; /** @@ -101,14 +102,10 @@ export async function findReadyOpponents( const rulesetHash = hashRuleset(SOURCE_DEFAULT_RULESET); const skip = params.page * params.pageSize; - // `normalizeAccount` lowercases EVM addresses and leaves base58 Solana pubkeys - // alone, so only the EVM side is folded — indexer-go is not guaranteed to write - // the roster in the same case. Folding base58 too could match two distinct - // pubkeys and list a pet whose owner never consented. - const ownerMatch = - params.chain === 'evm' - ? Prisma.sql`LOWER(r.owner) = a.defender_owner` - : Prisma.sql`r.owner = a.defender_owner`; + // Folded for EVM, exact for base58 — see `ownerKey`, which states the rule once for + // the three places that need it (here, the leaderboard's grouping, chat's marriage + // gate). Getting it wrong here would list a pet whose owner never consented. + const ownerMatch = Prisma.sql`${ownerKey(params.chain, 'r')} = a.defender_owner`; // A live grant covering this pet, under the ruleset battles are currently settled // under. Level band and daily cap are not here on purpose — see the header. diff --git a/backend/src/routes/battle-room.ts b/backend/src/routes/battle-room.ts index 3b2b97eb..d98469d6 100644 --- a/backend/src/routes/battle-room.ts +++ b/backend/src/routes/battle-room.ts @@ -1,7 +1,7 @@ import express, { Router } from 'express'; import { verifyToken } from '@middleware/auth'; import { battleRoomRateLimit } from '@middleware/rateLimit'; -import { createBattleRoom } from '@features/battle-room'; +import { createBattleRoom } from '@features/battle/room'; const router: Router = express.Router(); diff --git a/backend/src/routes/battle.ts b/backend/src/routes/battle.ts index 47e23ee5..b88d8f04 100644 --- a/backend/src/routes/battle.ts +++ b/backend/src/routes/battle.ts @@ -15,7 +15,7 @@ import { postDefenseAuthorization, postVerifyReceipt, requireBackendBattleMode, -} from '@features/battle-ledger'; +} from '@features/battle/ledger'; import { verifyToken } from '@middleware/auth'; import { battleRoomRateLimit } from '@middleware/rateLimit'; diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts new file mode 100644 index 00000000..5589e54c --- /dev/null +++ b/backend/src/routes/chat.ts @@ -0,0 +1,26 @@ +import express, { Router } from 'express'; +import { verifyToken } from '@middleware/auth'; +import { chatReadRateLimit, chatSendRateLimit } from '@middleware/rateLimit'; +import { getMessages, getThreads, postMessage, postReaction, postRead } from '@features/chat'; + +const router: Router = express.Router(); + +// Rate limits run after verifyToken so the budget is per wallet, not per IP. Sending is +// held to a much tighter budget than reading: a read is one indexed query, while a send +// is the one endpoint in this feature that creates content someone else has to receive. +router.get('/threads', verifyToken, chatReadRateLimit, getThreads); +router.get('/threads/:id/messages', verifyToken, chatReadRateLimit, getMessages); +router.post('/threads/:id/messages', verifyToken, chatSendRateLimit, postMessage); +// Rate-limited as a read: it is one small write per thread open, not per message, and it +// rides the same polling cadence the read endpoint already allows for. +router.post('/threads/:id/read', verifyToken, chatReadRateLimit, postRead); +// Held to the send budget, not the read one: a reaction writes a row other people see, +// which is the same thing a message does, only smaller. +router.post( + '/threads/:id/messages/:messageId/reaction', + verifyToken, + chatSendRateLimit, + postReaction +); + +export default router; diff --git a/backend/src/routes/receipts.ts b/backend/src/routes/receipts.ts index 918aac37..112f846b 100644 --- a/backend/src/routes/receipts.ts +++ b/backend/src/routes/receipts.ts @@ -1,7 +1,7 @@ import express, { Router } from 'express'; -import { getReceiptInclusionProof } from '@features/battle-batcher'; -import { getReceiptsByPet, getReceiptsBySequence, getReceiptsByWallet } from '@features/battle-ledger'; +import { getReceiptInclusionProof } from '@features/battle/batcher'; +import { getReceiptsByPet, getReceiptsBySequence, getReceiptsByWallet } from '@features/battle/ledger'; /** * The public receipt corpus (§H item 3): paginated export by pet, by wallet, and diff --git a/backend/src/routes/rewards.ts b/backend/src/routes/rewards.ts index 274df354..d12b1892 100644 --- a/backend/src/routes/rewards.ts +++ b/backend/src/routes/rewards.ts @@ -1,6 +1,6 @@ import express, { Router } from 'express'; -import { getSeason, getSeasonClaim } from '@features/battle-rewards'; +import { getSeason, getSeasonClaim } from '@features/battle/rewards'; /** * Reward seasons and claim proofs (§I). diff --git a/backend/src/server.ts b/backend/src/server.ts index fe938c3b..4aff4aa0 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,11 +2,11 @@ import './register-path-aliases'; import { env } from '@config/env'; import { prisma } from '@config/prisma'; import app from './app'; -import { configureSigner, loadPersistedSigningKeys } from '@features/battle-signer'; +import { configureSigner, loadPersistedSigningKeys } from '@features/battle/signer'; import { startSettleKeeper, stopSettleKeeper } from '@features/settle-keeper'; -import { type BattleWorkerHandle, startBattleWorker } from '@features/battle-worker'; -import { startBatchAnchor, stopBatchAnchor } from '@features/battle-anchor'; -import { startBattleRoomSocket, stopBattleRoomSocket } from '@ws/battleRoomSocket'; +import { type BattleWorkerHandle, startBattleWorker } from '@features/battle/worker'; +import { startBatchAnchor, stopBatchAnchor } from '@features/battle/anchor'; +import { startWsChannels, stopWsChannels } from '@ws/channel'; let battleWorker: BattleWorkerHandle | undefined; @@ -20,16 +20,17 @@ const server = app.listen(env.port, '0.0.0.0', () => { console.log(`🛡️ Protected endpoints: http://localhost:${port}/api/protected`); console.log(`⚔️ GraphQL endpoint: http://localhost:${port}/graphql`); - // Notification-only per-room channel for backend-authoritative battles (§J). Always on; - // a client only gets pushed to if it connected with a roomId it already knows about. - startBattleRoomSocket(server); + // Every notification-only channel (battle rooms §J, chat §2) behind one upgrade + // listener. They cannot each attach their own: Node would call all of them per + // upgrade and every connection would be handled twice — see @ws/channel. + startWsChannels(server); // Settles GameLogic battle/breed/mint requests once entropy reveals. No-op unless // KEEPER_ENABLED is set. startSettleKeeper(); - // Backend-authoritative battles (docs/plan-backend-battle-architecture.md §L Phase 3). + // Backend-authoritative battles (docs/battle-protocol.md §L Phase 3). // Selects the signing backend (refuses an in-process key in production; see - // @features/battle-signer) and starts the outbox worker that carries accepted battles + // @features/battle/signer) and starts the outbox worker that carries accepted battles // through to a signed receipt. // // Both are gated on the mode, so a deployment running only the on-chain path needs no @@ -76,7 +77,7 @@ async function shutdown(signal: NodeJS.Signals): Promise { stopSettleKeeper(); battleWorker?.stop(); stopBatchAnchor(); - stopBattleRoomSocket(); + stopWsChannels(); await new Promise((resolve) => server.close(() => resolve())); await prisma.$disconnect(); diff --git a/backend/src/types/chain.ts b/backend/src/types/chain.ts index fa87a420..c6d4a495 100644 --- a/backend/src/types/chain.ts +++ b/backend/src/types/chain.ts @@ -1,3 +1,5 @@ +import { isEvmAddress } from '@utils'; + /** Chains the app indexes and matches pets across. Single source of truth. */ export const SUPPORTED_CHAINS = ['evm', 'solana'] as const; @@ -6,3 +8,14 @@ export type Chain = (typeof SUPPORTED_CHAINS)[number]; export function isSupportedChain(value: string): value is Chain { return (SUPPORTED_CHAINS as readonly string[]).includes(value); } + +/** + * Which chain a wallet address belongs to, by its shape. + * + * A wallet only exists on one chain, so this is derivable rather than something a caller + * should be asked for — asking would let them name the wrong one. Uses the same + * case-insensitive EVM test as auth, so a checksummed address is not misread as Solana. + */ +export function chainOfAccount(address: string): Chain { + return isEvmAddress(address) ? 'evm' : 'solana'; +} diff --git a/backend/src/ws/battleRoomSocket.ts b/backend/src/ws/battleRoomSocket.ts index 8ca40dac..64fb79a3 100644 --- a/backend/src/ws/battleRoomSocket.ts +++ b/backend/src/ws/battleRoomSocket.ts @@ -1,27 +1,23 @@ -import type { Server } from 'node:http'; -import { URL } from 'node:url'; - -// `WebSocket.Server` is only attached to the default export under `ws`'s CJS entry point; -// its ESM entry (`wrapper.mjs`, what Vitest resolves) exports the server class only as the -// named `WebSocketServer`, with no `.Server` static property. The named form resolves -// correctly under both, so it's used here instead. -import WebSocket, { WebSocketServer } from 'ws'; +import { defineChannel } from './channel'; /** * The per-room, notification-only channel for backend-authoritative battles - * (docs/plan-backend-battle-architecture.md §J). + * (docs/battle-protocol.md §J). + * + * It replaced a global-broadcast socket that pushed chain-derived data for the on-chain + * flow, filtered client-side by `(chainId, requestId)`. Broadcasting was acceptable there + * because anyone could read the same data straight off the chain anyway. + * Backend-resolved battles carry full combat logs, which is not chain-derived data — a + * global broadcast would tell every connected client the outcome of every battle as it + * resolves. So this channel scopes delivery to one room, and carries no battle content at + * all: only "battleId X changed to state Y, go re-fetch it" (§J's read APIs, Step 27). A + * client that missed a notification, or was never connected, gets the exact same + * information by polling those same endpoints — this socket makes that faster, never more + * authoritative. * - * It replaced a global-broadcast socket that pushed chain-derived data for the - * on-chain flow, filtered client-side by `(chainId, requestId)`. Broadcasting was - * acceptable there because anyone could read the same data straight off the chain - * anyway. Backend-resolved battles carry full combat logs, which is not chain-derived - * data — a global broadcast would tell every connected client the outcome of every - * battle as it resolves. So this channel scopes delivery to one room, and carries - * no battle content at all: - * only "battleId X changed to state Y, go re-fetch it" (§J's read APIs, Step - * 27). A client that missed a notification, or was never connected, gets the - * exact same information by polling those same endpoints — this socket makes - * that faster, never more authoritative. + * Membership and the HTTP upgrade are handled by `channel.ts`, which owns the single + * upgrade listener the process may have; see the note there for why one per channel is + * not an option. */ export interface BattleRoomNotification { @@ -30,72 +26,17 @@ export interface BattleRoomNotification { state: string; } -let wss: WebSocketServer | null = null; -const roomMembers = new Map>(); - -export function startBattleRoomSocket(server: Server): void { - wss = new WebSocketServer({ server, path: '/ws/battle-room' }); - wss.on('connection', (socket, request) => { - const roomId = roomIdFromUrl(request.url); - if (!roomId) { - socket.close(1008, 'roomId query parameter is required'); - return; - } - joinRoom(roomId, socket); - socket.on('close', () => leaveRoom(roomId, socket)); - }); - console.log('[battle-room-ws] listening on /ws/battle-room'); -} +const channel = defineChannel('/ws/battle-room', 'roomId'); -export function stopBattleRoomSocket(): void { - wss?.close(); - wss = null; - roomMembers.clear(); -} - -/** - * Notifies every client watching `roomId`. A no-op, not an error, when nobody - * is connected — most battles are never watched live at all, and that is an - * entirely normal outcome, not a delivery failure worth surfacing. - */ +/** Notifies every client watching `roomId`. */ export function notifyBattleRoom(roomId: string, message: BattleRoomNotification): void { - const members = roomMembers.get(roomId); - if (!members || members.size === 0) return; - const payload = JSON.stringify(message); - for (const client of members) { - if (client.readyState === WebSocket.OPEN) client.send(payload); - } + channel.notify(roomId, message); } /** Same as `notifyBattleRoom`, but a no-op when there is no room to notify at all. */ -export function notifyBattleRoomIfPresent(roomId: string | null, message: BattleRoomNotification): void { +export function notifyBattleRoomIfPresent( + roomId: string | null, + message: BattleRoomNotification +): void { if (roomId) notifyBattleRoom(roomId, message); } - -function joinRoom(roomId: string, socket: WebSocket): void { - let members = roomMembers.get(roomId); - if (!members) { - members = new Set(); - roomMembers.set(roomId, members); - } - members.add(socket); -} - -function leaveRoom(roomId: string, socket: WebSocket): void { - const members = roomMembers.get(roomId); - if (!members) return; - members.delete(socket); - if (members.size === 0) roomMembers.delete(roomId); -} - -function roomIdFromUrl(url: string | undefined): string | null { - if (!url) return null; - try { - // The base is irrelevant and discarded — only used because WHATWG URL requires - // an absolute URL to parse a relative one against. - const parsed = new URL(url, 'http://internal'); - return parsed.searchParams.get('roomId'); - } catch { - return null; - } -} diff --git a/backend/src/ws/channel.ts b/backend/src/ws/channel.ts new file mode 100644 index 00000000..964789b3 --- /dev/null +++ b/backend/src/ws/channel.ts @@ -0,0 +1,290 @@ +import type { IncomingMessage, Server } from 'node:http'; +import type { Duplex } from 'node:stream'; +import { URL } from 'node:url'; + +// Named import for the same reason the channels use one: `ws`'s ESM entry exposes the +// server class only as `WebSocketServer`, with no `.Server` static. +import WebSocket, { WebSocketServer } from 'ws'; + +/** + * One upgrade listener for every WebSocket channel this process serves. + * + * This exists because the obvious arrangement is broken. Constructing a + * `WebSocketServer({ server, path })` per channel attaches *one upgrade listener per + * instance* to the same HTTP server, and Node calls all of them for every upgrade — so + * with two channels each connection is handled twice, the client receives two HTTP 101 + * responses, and the second one is parsed as a WebSocket frame. The visible symptom is + * `RangeError: Invalid WebSocket frame: RSV1 must be clear`, and it takes down *both* + * channels, not just the one added second. + * + * So the servers are constructed with `noServer: true` and this module owns the single + * listener, dispatching on path. That is what the `ws` documentation recommends for + * exactly this case. + * + * A channel may declare an authorizer. Without one it stays open to anyone who knows a + * topic id, which is acceptable only while its frames carry nothing readable — the + * battle-room channel's position. Chat has one, because presence is a claim about + * *identities* and an anonymous socket has none. + */ + +/** Marker subprotocol; the token rides alongside it as the second offered value. */ +export const AUTH_PROTOCOL = 'cryptopets-auth'; + +/** + * Decides whether a connection may subscribe, and who it belongs to. + * + * Returns the subscriber's identity (a wallet address) to accept, or null to refuse + * before the handshake, so a rejected client never becomes a subscriber at all — not + * even to the fact that the topic changed. + */ +export type ChannelAuthorizer = ( + request: IncomingMessage, + topic: string +) => Promise; + +export interface ChannelOptions { + authorize?: ChannelAuthorizer; + /** Broadcast who is connected to a topic. Requires `authorize`. */ + presence?: boolean; +} + +interface Channel { + /** Path this channel answers on, e.g. `/ws/chat`. */ + path: string; + /** Query parameter naming the topic a client subscribes to, e.g. `threadId`. */ + param: string; + authorize?: ChannelAuthorizer; + presence: boolean; + /** topic → connected sockets. */ + members: Map>; + /** topic → identity → open connection count, so two tabs are one person. */ + present: Map>; + wss: WebSocketServer | null; +} + +const channels: Channel[] = []; + +/** A channel handle: register once at module load, start with the HTTP server later. */ +export interface ChannelHandle { + /** + * Sends a message to everyone subscribed to `topic`. + * + * A no-op when nobody is listening, which is the normal case rather than a failure: + * most notifications concern someone who does not have the app open. Delivery here is + * an optimization over re-reading, never the authority for it. + */ + notify(topic: string, message: unknown): void; +} + +export function defineChannel( + path: string, + param: string, + options: ChannelOptions = {} +): ChannelHandle { + if (options.presence && !options.authorize) { + // A guard rather than a silent degradation: presence over anonymous sockets can + // only say "somebody is here", and one person with two tabs open would read as + // their counterpart being online. + throw new Error(`channel ${path}: presence requires an authorizer`); + } + + const channel: Channel = { + path, + param, + members: new Map(), + present: new Map(), + wss: null, + presence: options.presence ?? false, + ...(options.authorize ? { authorize: options.authorize } : {}), + }; + channels.push(channel); + + return { + notify(topic, message) { + const listeners = channel.members.get(topic); + if (!listeners || listeners.size === 0) return; + const payload = JSON.stringify(message); + for (const client of listeners) { + if (client.readyState === WebSocket.OPEN) client.send(payload); + } + }, + }; +} + +/** The server and listener currently attached, so `stop` can detach precisely. */ +let attached: { server: Server; onUpgrade: UpgradeListener } | null = null; + +type UpgradeListener = (request: IncomingMessage, socket: Duplex, head: Buffer) => void; + +/** Starts every defined channel against one HTTP server. */ +export function startWsChannels(server: Server): void { + // Starting twice would attach a second listener and reintroduce the double-handled + // upgrade this module exists to prevent, so the previous one is always detached first. + stopWsChannels(); + + for (const channel of channels) { + channel.wss = new WebSocketServer({ + noServer: true, + // Browsers cannot set headers on a WebSocket, so a token travels as a + // subprotocol. The marker is echoed back, never the token; a query parameter + // would put the JWT into proxy and access logs. + handleProtocols: (protocols) => (protocols.has(AUTH_PROTOCOL) ? AUTH_PROTOCOL : false), + }); + channel.wss.on( + 'connection', + (socket: WebSocket, _req: IncomingMessage, topic: string, identity: string | null) => { + join(channel, topic, socket, identity); + socket.on('close', () => leave(channel, topic, socket, identity)); + } + ); + } + + const onUpgrade: UpgradeListener = (request, socket, head) => { + const url = parseUrl(request.url); + const channel = url + ? channels.find((candidate) => candidate.path === url.pathname) + : undefined; + if (!url || !channel?.wss) { + // Not ours. Destroying rather than ignoring, because with a single listener + // there is nobody else to answer and a hanging socket would leak. + socket.destroy(); + return; + } + + const topic = url.searchParams.get(channel.param); + if (!topic) { + // Refused before the handshake: a client that names no topic would otherwise + // sit connected receiving nothing, which reads as a silent failure. + socket.destroy(); + return; + } + + const wss = channel.wss; + const authorize = channel.authorize; + if (!authorize) { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request, topic, null); + }); + return; + } + + void (async () => { + let identity: string | null = null; + try { + identity = await authorize(request, topic); + } catch (err) { + console.error(`[ws] authorize threw for ${channel.path}:`, err); + } + if (!identity) { + socket.destroy(); + return; + } + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request, topic, identity); + }); + })(); + }; + + server.on('upgrade', onUpgrade); + attached = { server, onUpgrade }; + + console.log(`[ws] listening on ${channels.map((c) => c.path).join(', ')}`); +} + +export function stopWsChannels(): void { + attached?.server.off('upgrade', attached.onUpgrade); + attached = null; + + for (const channel of channels) { + channel.wss?.close(); + channel.wss = null; + channel.members.clear(); + channel.present.clear(); + } +} + +function join( + channel: Channel, + topic: string, + socket: WebSocket, + identity: string | null +): void { + let listeners = channel.members.get(topic); + if (!listeners) { + listeners = new Set(); + channel.members.set(topic, listeners); + } + listeners.add(socket); + + if (channel.presence && identity) { + const counts = channel.present.get(topic) ?? new Map(); + counts.set(identity, (counts.get(identity) ?? 0) + 1); + channel.present.set(topic, counts); + broadcastPresence(channel, topic); + } else if (channel.presence) { + // Still tell the newcomer who is already here; the broadcast above only fires for + // an identified join, and a client that connected to silence would show everyone + // offline until the next arrival. + sendPresence(channel, topic, socket); + } +} + +function leave( + channel: Channel, + topic: string, + socket: WebSocket, + identity: string | null +): void { + const listeners = channel.members.get(topic); + if (listeners) { + listeners.delete(socket); + if (listeners.size === 0) channel.members.delete(topic); + } + + if (channel.presence && identity) { + const counts = channel.present.get(topic); + if (counts) { + // Counted, not a set: closing one of two tabs must not report someone as gone. + const remaining = (counts.get(identity) ?? 1) - 1; + if (remaining > 0) counts.set(identity, remaining); + else counts.delete(identity); + if (counts.size === 0) channel.present.delete(topic); + } + broadcastPresence(channel, topic); + } +} + +/** The current roster of a topic, as a frame. */ +function presenceFrame(channel: Channel, topic: string): string { + const online = [...(channel.present.get(topic)?.keys() ?? [])]; + return JSON.stringify({ type: 'presence', topic, online }); +} + +/** + * Tells everyone on a topic who is currently connected. + * + * Only participants can be connected at all (presence requires an authorizer), so these + * identities are already known to everyone receiving them. + */ +function broadcastPresence(channel: Channel, topic: string): void { + const listeners = channel.members.get(topic); + if (!listeners || listeners.size === 0) return; + const payload = presenceFrame(channel, topic); + for (const client of listeners) { + if (client.readyState === WebSocket.OPEN) client.send(payload); + } +} + +function sendPresence(channel: Channel, topic: string, socket: WebSocket): void { + if (socket.readyState === WebSocket.OPEN) socket.send(presenceFrame(channel, topic)); +} + +function parseUrl(url: string | undefined): URL | null { + if (!url) return null; + try { + // The base is discarded; WHATWG URL just needs an absolute one to parse against. + return new URL(url, 'http://internal'); + } catch { + return null; + } +} diff --git a/backend/src/ws/chatSocket.ts b/backend/src/ws/chatSocket.ts new file mode 100644 index 00000000..24917ceb --- /dev/null +++ b/backend/src/ws/chatSocket.ts @@ -0,0 +1,99 @@ +import type { IncomingMessage } from 'node:http'; +import jwt from 'jsonwebtoken'; + +import { env } from '@config/env'; +// Imported from the module, not the feature barrel: the barrel also exports the +// controller, which imports this file, and that would be a cycle. +import { authorizeThread } from '@features/chat/chat.service'; +import { AUTH_PROTOCOL, defineChannel } from './channel'; + +/** + * The per-thread channel for private chat (roadmap §2 v1). + * + * **Carries no message content.** The socket only ever says "thread X changed" plus who + * is currently connected; the text comes from `GET /api/chat/threads/:id/messages`, which + * authenticates the caller and rechecks the marriage. A client that missed a notification, + * or never connected, learns the same thing by re-reading. This makes chat feel live; it + * is never the thing that decides who may read it. + * + * Unlike the battle-room channel, this one **authenticates the upgrade**. Presence forced + * it: "is my counterpart online" is a question about identities, and an anonymous socket + * has none — counting connections would report one person with two tabs open as two + * people. Authenticating also closes the timing leak the earlier version accepted, where + * anyone holding a thread id could watch a conversation's activity without being in it. + * + * The token arrives as a WebSocket subprotocol rather than a query parameter, because + * browsers cannot set headers on a WebSocket and a URL-borne JWT ends up in proxy and + * access logs. + * + * Authorization is checked at connect only. A marriage that ends mid-session leaves the + * socket open until it drops, which costs nothing: every frame is contentless, and the + * read endpoint it prompts refuses immediately. + */ + +export interface ChatThreadNotification { + /** + * `thread-updated` is a new message; `thread-read` is someone moving their read + * watermark; `thread-reacted` is a reaction added, changed or removed. + * + * They are distinguished because clients treat them oppositely. A client skips a + * `thread-updated` naming a message it already holds — that frame is the echo of its + * own send. A read receipt names a message it certainly already holds, by + * definition: the whole point is that the *other* side has now seen it. Folding the + * two together would make every receipt look like an echo and no tick would ever + * fill in. + */ + type: 'thread-updated' | 'thread-read' | 'thread-reacted'; + threadId: string; + /** Id of the message that caused it, so a client can skip a re-read it already has. */ + messageId: number; +} + +/** Who is connected to a thread right now. */ +export interface ChatPresenceNotification { + type: 'presence'; + topic: string; + /** Wallet addresses currently connected, normalized as the thread stores them. */ + online: string[]; +} + +/** + * Reads the token from the offered subprotocols. + * + * The client offers `[AUTH_PROTOCOL, ]`; the server echoes only the marker back. + */ +function tokenFromRequest(request: IncomingMessage): string | null { + const offered = request.headers['sec-websocket-protocol']; + if (!offered) return null; + const values = (Array.isArray(offered) ? offered.join(',') : offered) + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const marker = values.indexOf(AUTH_PROTOCOL); + return marker === -1 ? null : (values[marker + 1] ?? null); +} + +const channel = defineChannel('/ws/chat', 'threadId', { + presence: true, + async authorize(request, threadId) { + const token = tokenFromRequest(request); + if (!token) return null; + + let address: string; + try { + ({ address } = jwt.verify(token, env.jwtSecret) as { address: string }); + } catch { + return null; + } + if (!address) return null; + + // The same gate the HTTP routes apply, so a socket can never be subscribed to a + // thread its holder could not read. + return (await authorizeThread(threadId, address)) === null ? address : null; + }, +}); + +/** Tells everyone watching `threadId` that it changed. */ +export function notifyChatThread(threadId: string, message: ChatThreadNotification): void { + channel.notify(threadId, message); +} diff --git a/backend/tests/features/battle-anchor/anchor.service.test.ts b/backend/tests/features/battle/anchor/anchor.service.test.ts similarity index 99% rename from backend/tests/features/battle-anchor/anchor.service.test.ts rename to backend/tests/features/battle/anchor/anchor.service.test.ts index 38ffdcdc..639325fd 100644 --- a/backend/tests/features/battle-anchor/anchor.service.test.ts +++ b/backend/tests/features/battle/anchor/anchor.service.test.ts @@ -5,7 +5,7 @@ vi.mock('@config/prisma', () => ({ })); import { prisma } from '@config/prisma'; -import { anchorNextBatch, ZERO_ROOT, type AnchorContext } from '@features/battle-anchor'; +import { anchorNextBatch, ZERO_ROOT, type AnchorContext } from '@features/battle/anchor'; const ROOT_1 = `0x${'11'.repeat(32)}`; const ROOT_2 = `0x${'22'.repeat(32)}`; diff --git a/backend/tests/features/battle-batcher/batch.builder.test.ts b/backend/tests/features/battle/batcher/batch.builder.test.ts similarity index 99% rename from backend/tests/features/battle-batcher/batch.builder.test.ts rename to backend/tests/features/battle/batcher/batch.builder.test.ts index e775d26a..1aef70bc 100644 --- a/backend/tests/features/battle-batcher/batch.builder.test.ts +++ b/backend/tests/features/battle/batcher/batch.builder.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { merkleLeaf, verifyReceiptInclusion, type Hex } from '@cryptopets/protocol'; -import { buildBatch, hashRulesetSet, type BatchableReceipt } from '@features/battle-batcher'; +import { buildBatch, hashRulesetSet, type BatchableReceipt } from '@features/battle/batcher'; const RULESET_A = `0x${'aa'.repeat(32)}`; const RULESET_B = `0x${'bb'.repeat(32)}`; diff --git a/backend/tests/features/battle-batcher/batcher.service.test.ts b/backend/tests/features/battle/batcher/batcher.service.test.ts similarity index 99% rename from backend/tests/features/battle-batcher/batcher.service.test.ts rename to backend/tests/features/battle/batcher/batcher.service.test.ts index 627877be..0f37e1a6 100644 --- a/backend/tests/features/battle-batcher/batcher.service.test.ts +++ b/backend/tests/features/battle/batcher/batcher.service.test.ts @@ -19,7 +19,7 @@ vi.mock('@config/prisma', () => { }); import { prisma } from '@config/prisma'; -import { buildBatch, buildNextBatch, getInclusionProof } from '@features/battle-batcher'; +import { buildBatch, buildNextBatch, getInclusionProof } from '@features/battle/batcher'; const RULESET = `0x${'aa'.repeat(32)}`; const SCOPE = { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }; diff --git a/backend/tests/features/battle-ledger/accept.service.test.ts b/backend/tests/features/battle/ledger/accept.service.test.ts similarity index 95% rename from backend/tests/features/battle-ledger/accept.service.test.ts rename to backend/tests/features/battle/ledger/accept.service.test.ts index 5cc6d6d7..d0487446 100644 --- a/backend/tests/features/battle-ledger/accept.service.test.ts +++ b/backend/tests/features/battle/ledger/accept.service.test.ts @@ -14,21 +14,21 @@ vi.mock('@config/prisma', () => ({ }, })); -vi.mock('../../../src/features/battle-ledger/snapshot.builder', () => ({ +vi.mock('../../../../src/features/battle/ledger/snapshot.builder', () => ({ buildPetSnapshot: vi.fn(), })); -vi.mock('../../../src/features/battle-ledger/consent.service', () => ({ +vi.mock('../../../../src/features/battle/ledger/consent.service', () => ({ findCoveringAuthorization: vi.fn(), consumeDailyBudget: vi.fn(), })); -vi.mock('../../../src/features/battle-randomness', () => ({ +vi.mock('../../../../src/features/battle/randomness', () => ({ chooseCommitmentRound: vi.fn(), roundPublishTime: vi.fn((round: number) => new Date(roundTime(QUICKNET, round) * 1000)), })); -vi.mock('../../../src/features/battle-signer', () => ({ +vi.mock('../../../../src/features/battle/signer', () => ({ activeSigningKey: vi.fn(), sign: vi.fn(), SignerRefusedError: class SignerRefusedError extends Error { @@ -41,18 +41,18 @@ vi.mock('../../../src/features/battle-signer', () => ({ }, })); -vi.mock('../../../src/features/battle-ledger/transitions', () => ({ +vi.mock('../../../../src/features/battle/ledger/transitions', () => ({ openBattle: vi.fn(), applyTransition: vi.fn(), })); import { prisma } from '@config/prisma'; -import { acceptBattle } from '@features/battle-ledger'; -import { chooseCommitmentRound, roundPublishTime } from '@features/battle-randomness'; -import { activeSigningKey, sign, SignerRefusedError } from '@features/battle-signer'; -import { consumeDailyBudget, findCoveringAuthorization } from '../../../src/features/battle-ledger/consent.service'; -import { buildPetSnapshot } from '../../../src/features/battle-ledger/snapshot.builder'; -import { applyTransition, openBattle } from '../../../src/features/battle-ledger/transitions'; +import { acceptBattle } from '@features/battle/ledger'; +import { chooseCommitmentRound, roundPublishTime } from '@features/battle/randomness'; +import { activeSigningKey, sign, SignerRefusedError } from '@features/battle/signer'; +import { consumeDailyBudget, findCoveringAuthorization } from '../../../../src/features/battle/ledger/consent.service'; +import { buildPetSnapshot } from '../../../../src/features/battle/ledger/snapshot.builder'; +import { applyTransition, openBattle } from '../../../../src/features/battle/ledger/transitions'; const ROUND_1000_TIME = roundTime(QUICKNET, 1000); const NOW = ROUND_1000_TIME + 1; diff --git a/backend/tests/features/battle-ledger/config.service.test.ts b/backend/tests/features/battle/ledger/config.service.test.ts similarity index 96% rename from backend/tests/features/battle-ledger/config.service.test.ts rename to backend/tests/features/battle/ledger/config.service.test.ts index ebe42a2e..eb421886 100644 --- a/backend/tests/features/battle-ledger/config.service.test.ts +++ b/backend/tests/features/battle/ledger/config.service.test.ts @@ -17,9 +17,9 @@ vi.mock('@config/prisma', () => ({ battleRuleset: { findMany: vi.fn(), findUnique: vi.fn() }, }, })); -vi.mock('@features/battle-signer', () => ({ listSigningKeys: vi.fn() })); +vi.mock('@features/battle/signer', () => ({ listSigningKeys: vi.fn() })); -import { getBattleConfig } from '@features/battle-ledger'; +import { getBattleConfig } from '@features/battle/ledger'; beforeEach(() => { battleEnv.enabled = true; diff --git a/backend/tests/features/battle-ledger/consent.service.test.ts b/backend/tests/features/battle/ledger/consent.service.test.ts similarity index 99% rename from backend/tests/features/battle-ledger/consent.service.test.ts rename to backend/tests/features/battle/ledger/consent.service.test.ts index 3c5ce136..93c02c20 100644 --- a/backend/tests/features/battle-ledger/consent.service.test.ts +++ b/backend/tests/features/battle/ledger/consent.service.test.ts @@ -25,7 +25,7 @@ import { revokeDefenseAuthorizations, submitDefenseAuthorization, toProtocolAuthorization, -} from '@features/battle-ledger'; +} from '@features/battle/ledger'; const wallet = new ethers.Wallet('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'); const DEFENDER = wallet.address.toLowerCase(); diff --git a/backend/tests/features/battle-ledger/corpus.controller.test.ts b/backend/tests/features/battle/ledger/corpus.controller.test.ts similarity index 93% rename from backend/tests/features/battle-ledger/corpus.controller.test.ts rename to backend/tests/features/battle/ledger/corpus.controller.test.ts index 0d96b039..c3d91f22 100644 --- a/backend/tests/features/battle-ledger/corpus.controller.test.ts +++ b/backend/tests/features/battle/ledger/corpus.controller.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@features/battle-ledger/corpus.service', () => ({ +vi.mock('@features/battle/ledger/corpus.service', () => ({ listReceiptsByPet: vi.fn(), listReceiptsByWallet: vi.fn(), listReceiptsBySequence: vi.fn(), @@ -10,12 +10,12 @@ import { listReceiptsByPet, listReceiptsBySequence, listReceiptsByWallet, -} from '../../../src/features/battle-ledger/corpus.service'; +} from '../../../../src/features/battle/ledger/corpus.service'; import { getReceiptsByPet, getReceiptsBySequence, getReceiptsByWallet, -} from '../../../src/features/battle-ledger/corpus.controller'; +} from '../../../../src/features/battle/ledger/corpus.controller'; function mockRes() { const res = { status: vi.fn(), json: vi.fn() }; diff --git a/backend/tests/features/battle-ledger/corpus.service.test.ts b/backend/tests/features/battle/ledger/corpus.service.test.ts similarity index 99% rename from backend/tests/features/battle-ledger/corpus.service.test.ts rename to backend/tests/features/battle/ledger/corpus.service.test.ts index 09f45752..9b62d729 100644 --- a/backend/tests/features/battle-ledger/corpus.service.test.ts +++ b/backend/tests/features/battle/ledger/corpus.service.test.ts @@ -5,7 +5,7 @@ vi.mock('@config/prisma', () => ({ })); import { prisma } from '@config/prisma'; -import { listReceiptsByPet, listReceiptsBySequence, listReceiptsByWallet } from '@features/battle-ledger'; +import { listReceiptsByPet, listReceiptsBySequence, listReceiptsByWallet } from '@features/battle/ledger'; function row(overrides: Partial> = {}) { return { diff --git a/backend/tests/features/battle-ledger/drills.test.ts b/backend/tests/features/battle/ledger/drills.test.ts similarity index 96% rename from backend/tests/features/battle-ledger/drills.test.ts rename to backend/tests/features/battle/ledger/drills.test.ts index 311bcad1..ad9bcf83 100644 --- a/backend/tests/features/battle-ledger/drills.test.ts +++ b/backend/tests/features/battle/ledger/drills.test.ts @@ -5,7 +5,7 @@ import { ethers } from 'ethers'; /** * The §L Phase 3 drills, executed rather than described. * - * `docs/runbook-backend-battles.md` documents the procedures; this file runs the mechanical + * `docs/battle-protocol.md` Appendix B documents the procedures; this file runs the mechanical * half of each one on every CI pass, so a drill cannot quietly stop being true between * incidents. What is deliberately not here is the human half — who is paged, who decides — * which is what the runbook prose is for. @@ -25,9 +25,9 @@ vi.mock('@config/prisma', () => ({ import { env } from '@config/env'; import { prisma } from '@config/prisma'; -import { listDeadLetters, requeueDeadLetter } from '@features/battle-ledger'; -import { backendBattleModeEnabled } from '@features/battle-ledger'; -import { listSigningKeys, registerRotatedKey, resetSigner } from '@features/battle-signer'; +import { listDeadLetters, requeueDeadLetter } from '@features/battle/ledger'; +import { backendBattleModeEnabled } from '@features/battle/ledger'; +import { listSigningKeys, registerRotatedKey, resetSigner } from '@features/battle/signer'; const NOW = new Date('2026-07-26T12:00:00.000Z'); diff --git a/backend/tests/features/battle-ledger/intent.service.test.ts b/backend/tests/features/battle/ledger/intent.service.test.ts similarity index 99% rename from backend/tests/features/battle-ledger/intent.service.test.ts rename to backend/tests/features/battle/ledger/intent.service.test.ts index 20484eea..1cc132e1 100644 --- a/backend/tests/features/battle-ledger/intent.service.test.ts +++ b/backend/tests/features/battle/ledger/intent.service.test.ts @@ -19,7 +19,7 @@ vi.mock('@repositories/roster.repository', () => ({ })); import { prisma } from '@config/prisma'; -import { submitBattleIntent, toProtocolIntent, verifyIntentSignature } from '@features/battle-ledger'; +import { submitBattleIntent, toProtocolIntent, verifyIntentSignature } from '@features/battle/ledger'; import { getPetById } from '@repositories/roster.repository'; /** diff --git a/backend/tests/features/battle-ledger/mode.test.ts b/backend/tests/features/battle/ledger/mode.test.ts similarity index 98% rename from backend/tests/features/battle-ledger/mode.test.ts rename to backend/tests/features/battle/ledger/mode.test.ts index 2ed55b9a..86224afc 100644 --- a/backend/tests/features/battle-ledger/mode.test.ts +++ b/backend/tests/features/battle/ledger/mode.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@config/env', () => ({ env: { battle: { enabled: false } } })); import { env } from '@config/env'; -import { backendBattleModeEnabled, requireBackendBattleMode } from '@features/battle-ledger'; +import { backendBattleModeEnabled, requireBackendBattleMode } from '@features/battle/ledger'; function res() { const json = vi.fn(); diff --git a/backend/tests/features/battle-ledger/outbox.reschedule.test.ts b/backend/tests/features/battle/ledger/outbox.reschedule.test.ts similarity index 94% rename from backend/tests/features/battle-ledger/outbox.reschedule.test.ts rename to backend/tests/features/battle/ledger/outbox.reschedule.test.ts index b5fbb6e8..d4918540 100644 --- a/backend/tests/features/battle-ledger/outbox.reschedule.test.ts +++ b/backend/tests/features/battle/ledger/outbox.reschedule.test.ts @@ -5,7 +5,7 @@ vi.mock('@config/prisma', () => ({ })); import { prisma } from '@config/prisma'; -import { rescheduleOutbox } from '@features/battle-ledger'; +import { rescheduleOutbox } from '@features/battle/ledger'; beforeEach(() => { vi.clearAllMocks(); diff --git a/backend/tests/features/battle-ledger/outbox.test.ts b/backend/tests/features/battle/ledger/outbox.test.ts similarity index 99% rename from backend/tests/features/battle-ledger/outbox.test.ts rename to backend/tests/features/battle/ledger/outbox.test.ts index fb2b2a5d..a77ec75c 100644 --- a/backend/tests/features/battle-ledger/outbox.test.ts +++ b/backend/tests/features/battle/ledger/outbox.test.ts @@ -22,7 +22,7 @@ import { MAX_OUTBOX_ATTEMPTS, OUTBOX_TOPICS, retryDelaySeconds, -} from '@features/battle-ledger'; +} from '@features/battle/ledger'; const NOW = new Date('2026-07-26T09:00:00.000Z'); diff --git a/backend/tests/features/battle-ledger/reads.controller.test.ts b/backend/tests/features/battle/ledger/reads.controller.test.ts similarity index 96% rename from backend/tests/features/battle-ledger/reads.controller.test.ts rename to backend/tests/features/battle/ledger/reads.controller.test.ts index caf7616d..bbb59f12 100644 --- a/backend/tests/features/battle-ledger/reads.controller.test.ts +++ b/backend/tests/features/battle/ledger/reads.controller.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@features/battle-ledger/reads.service', () => ({ +vi.mock('@features/battle/ledger/reads.service', () => ({ getBattleStateSummary: vi.fn(), getSignedCommitment: vi.fn(), getSignedReceipt: vi.fn(), @@ -20,7 +20,7 @@ import { listActiveSigningKeys, listRulesets, verifyReceiptSignature, -} from '../../../src/features/battle-ledger/reads.service'; +} from '../../../../src/features/battle/ledger/reads.service'; import { getBattleCombatLog, getBattleCommitment, @@ -30,7 +30,7 @@ import { getRulesets, getSigningKeys, postVerifyReceipt, -} from '../../../src/features/battle-ledger/reads.controller'; +} from '../../../../src/features/battle/ledger/reads.controller'; function mockRes() { const res = { status: vi.fn(), json: vi.fn() }; diff --git a/backend/tests/features/battle-ledger/reads.service.test.ts b/backend/tests/features/battle/ledger/reads.service.test.ts similarity index 98% rename from backend/tests/features/battle-ledger/reads.service.test.ts rename to backend/tests/features/battle/ledger/reads.service.test.ts index b849231e..49ddd235 100644 --- a/backend/tests/features/battle-ledger/reads.service.test.ts +++ b/backend/tests/features/battle/ledger/reads.service.test.ts @@ -11,7 +11,7 @@ vi.mock('@config/prisma', () => ({ }, })); -vi.mock('@features/battle-signer', () => ({ +vi.mock('@features/battle/signer', () => ({ listSigningKeys: vi.fn(), })); @@ -25,8 +25,8 @@ import { listActiveSigningKeys, listRulesets, verifyReceiptSignature, -} from '@features/battle-ledger'; -import { listSigningKeys } from '@features/battle-signer'; +} from '@features/battle/ledger'; +import { listSigningKeys } from '@features/battle/signer'; beforeEach(() => { vi.clearAllMocks(); diff --git a/backend/tests/features/battle-ledger/snapshot.builder.test.ts b/backend/tests/features/battle/ledger/snapshot.builder.test.ts similarity index 99% rename from backend/tests/features/battle-ledger/snapshot.builder.test.ts rename to backend/tests/features/battle/ledger/snapshot.builder.test.ts index cc156496..bbfd3476 100644 --- a/backend/tests/features/battle-ledger/snapshot.builder.test.ts +++ b/backend/tests/features/battle/ledger/snapshot.builder.test.ts @@ -12,7 +12,7 @@ vi.mock('@config/prisma', () => ({ })); import { prisma } from '@config/prisma'; -import { buildPetSnapshot } from '@features/battle-ledger'; +import { buildPetSnapshot } from '@features/battle/ledger'; const ROSTER_ROW = { chain: 'evm', diff --git a/backend/tests/features/battle-ledger/state.test.ts b/backend/tests/features/battle/ledger/state.test.ts similarity index 89% rename from backend/tests/features/battle-ledger/state.test.ts rename to backend/tests/features/battle/ledger/state.test.ts index 666c8b71..757b659b 100644 --- a/backend/tests/features/battle-ledger/state.test.ts +++ b/backend/tests/features/battle/ledger/state.test.ts @@ -10,7 +10,7 @@ import { isTerminal, shouldReleaseLocks, TERMINAL_STATES, -} from '@features/battle-ledger'; +} from '@features/battle/ledger'; describe('happy path', () => { it('is walkable end to end', () => { @@ -53,12 +53,19 @@ describe('no cancellation after commitment', () => { expect(isCommitted(BattleState.seeded)).toBe(true); }); - it('offers forfeit instead, but only where a beacon can actually stall', () => { - // A permanent beacon outage has to end the battle somehow. Forfeit does it with no - // progression change, so manufacturing an outage gains nothing. + it('offers forfeit instead, but only where the pipeline can actually stall', () => { + // A permanent outage has to end the battle somehow. Forfeit does it with no + // progression change, so manufacturing an outage gains nothing. `computed` is here + // because the verifier can be unreachable until the outbox gives up, which strands + // the battle and its pets' locks exactly as a beacon outage would. expect(classifyTransition(BattleState.committed, BattleState.forfeited)).toBe('advance'); expect(classifyTransition(BattleState.seeded, BattleState.forfeited)).toBe('advance'); - expect(classifyTransition(BattleState.computed, BattleState.forfeited)).toBe('illegal'); + expect(classifyTransition(BattleState.computed, BattleState.forfeited)).toBe('advance'); + + // Not from everywhere: past signing there is a signed receipt, and a battle with one + // is resolved rather than abandonable. + expect(classifyTransition(BattleState.verified, BattleState.forfeited)).toBe('illegal'); + expect(classifyTransition(BattleState.signed, BattleState.forfeited)).toBe('illegal'); }); }); diff --git a/backend/tests/features/battle-ledger/transitions.test.ts b/backend/tests/features/battle/ledger/transitions.test.ts similarity index 80% rename from backend/tests/features/battle-ledger/transitions.test.ts rename to backend/tests/features/battle/ledger/transitions.test.ts index 23867c3b..edc5e1c0 100644 --- a/backend/tests/features/battle-ledger/transitions.test.ts +++ b/backend/tests/features/battle/ledger/transitions.test.ts @@ -24,13 +24,14 @@ vi.mock('@config/prisma', () => ({ import { prisma } from '@config/prisma'; import { + abandonBattle, applyTransition, failBattle, IllegalTransitionError, openBattle, OUTBOX_TOPICS, sortPetIds, -} from '@features/battle-ledger'; +} from '@features/battle/ledger'; beforeEach(() => { vi.clearAllMocks(); @@ -236,3 +237,59 @@ describe('sortPetIds', () => { expect(input).toEqual(['10', '9']); }); }); + +describe('abandonBattle', () => { + /** The state `prisma.battleLedger.findUnique` reports for the battle under test. */ + const currently = (state: BattleState | null) => + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue( + (state === null ? null : { state }) as never, + ); + + // The gap this closes: a dead-lettered step left the battle non-terminal, and locks are + // released by reaching a terminal state, so both its pets were stuck for good. + it('forfeits a battle stuck in computed and releases its locks', async () => { + currently(BattleState.computed); + + const result = await abandonBattle('btl_1', 'verify gave up'); + + expect(result).toEqual({ abandoned: true, state: BattleState.forfeited }); + expect(tx.battleLedger.updateMany).toHaveBeenCalledWith({ + where: { battleId: 'btl_1', state: BattleState.computed }, + data: { failureReason: 'verify gave up', state: BattleState.forfeited }, + }); + // Terminal, so the locks go with it — in the same transaction. + expect(tx.petBattleLock.deleteMany).toHaveBeenCalledWith({ where: { battleId: 'btl_1' } }); + }); + + // `verification_failed` means the two engines disagreed and is a ruleset-wide circuit + // breaker (§F). A verifier that never answered has disagreed with nothing. + it('does not mark an unreachable verifier as a verification failure', async () => { + currently(BattleState.computed); + + await abandonBattle('btl_1', 'indexer-go unavailable'); + + const data = tx.battleLedger.updateMany.mock.calls[0]![0].data; + expect(data.state).toBe(BattleState.forfeited); + expect(data.state).not.toBe(BattleState.verification_failed); + }); + + it('leaves a state with no legal forfeit exactly as it is', async () => { + currently(BattleState.verified); + + const result = await abandonBattle('btl_1', 'sign gave up'); + + expect(result).toEqual({ abandoned: false, state: BattleState.verified }); + // Untouched: the battle is still live and may yet be signed, so its pets stay locked. + expect(tx.battleLedger.updateMany).not.toHaveBeenCalled(); + expect(tx.petBattleLock.deleteMany).not.toHaveBeenCalled(); + }); + + it('reports nothing to do for a battle that does not exist', async () => { + currently(null); + + await expect(abandonBattle('btl_missing', 'whatever')).resolves.toEqual({ + abandoned: false, + state: null, + }); + }); +}); diff --git a/backend/tests/features/battle-randomness/drand.client.test.ts b/backend/tests/features/battle/randomness/drand.client.test.ts similarity index 98% rename from backend/tests/features/battle-randomness/drand.client.test.ts rename to backend/tests/features/battle/randomness/drand.client.test.ts index fd00e286..b7bb0645 100644 --- a/backend/tests/features/battle-randomness/drand.client.test.ts +++ b/backend/tests/features/battle/randomness/drand.client.test.ts @@ -25,7 +25,7 @@ import { resetDrandTransport, roundPublishTime, setDrandTransport, -} from '@features/battle-randomness'; +} from '@features/battle/randomness'; /** * Real quicknet beacons, from the same fixtures the protocol tests use. The whole point of @@ -37,7 +37,7 @@ interface Fixture { } const here = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')); const fixture = JSON.parse( - readFileSync(join(here, '../../../../protocol/tests/fixtures/drand.json'), 'utf8'), + readFileSync(join(here, '../../../../../protocol/tests/fixtures/drand.json'), 'utf8'), ) as Fixture; const ROUND_1000 = fixture.quicknet.rounds.find((r) => r.round === 1000)!; diff --git a/backend/tests/features/battle-rewards/entitlements.test.ts b/backend/tests/features/battle/rewards/entitlements.test.ts similarity index 99% rename from backend/tests/features/battle-rewards/entitlements.test.ts rename to backend/tests/features/battle/rewards/entitlements.test.ts index 46a600d1..bfdb38dc 100644 --- a/backend/tests/features/battle-rewards/entitlements.test.ts +++ b/backend/tests/features/battle/rewards/entitlements.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { computeEntitlements, totalEntitled, type BattleContribution, type RewardRates } from '@features/battle-rewards'; +import { computeEntitlements, totalEntitled, type BattleContribution, type RewardRates } from '@features/battle/rewards'; const ALICE = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const BOB = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; diff --git a/backend/tests/features/battle-rewards/season.open.test.ts b/backend/tests/features/battle/rewards/season.open.test.ts similarity index 99% rename from backend/tests/features/battle-rewards/season.open.test.ts rename to backend/tests/features/battle/rewards/season.open.test.ts index bf82873a..05710f23 100644 --- a/backend/tests/features/battle-rewards/season.open.test.ts +++ b/backend/tests/features/battle/rewards/season.open.test.ts @@ -5,7 +5,7 @@ vi.mock('@config/prisma', () => ({ })); import { prisma } from '@config/prisma'; -import { boundsViolations, openSeasonOnChain, type OpenSeasonContext } from '@features/battle-rewards'; +import { boundsViolations, openSeasonOnChain, type OpenSeasonContext } from '@features/battle/rewards'; const ALICE = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const BOB = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; diff --git a/backend/tests/features/battle-rewards/season.service.test.ts b/backend/tests/features/battle/rewards/season.service.test.ts similarity index 99% rename from backend/tests/features/battle-rewards/season.service.test.ts rename to backend/tests/features/battle/rewards/season.service.test.ts index 8dbad228..6001cc36 100644 --- a/backend/tests/features/battle-rewards/season.service.test.ts +++ b/backend/tests/features/battle/rewards/season.service.test.ts @@ -18,7 +18,7 @@ vi.mock('@config/prisma', () => { }); import { prisma } from '@config/prisma'; -import { buildSeason, getClaimProof, type SeasonInputs } from '@features/battle-rewards'; +import { buildSeason, getClaimProof, type SeasonInputs } from '@features/battle/rewards'; const ALICE = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const BOB = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; diff --git a/backend/tests/features/battle-room/battle-room.controller.test.ts b/backend/tests/features/battle/room/battle-room.controller.test.ts similarity index 87% rename from backend/tests/features/battle-room/battle-room.controller.test.ts rename to backend/tests/features/battle/room/battle-room.controller.test.ts index e3700d0e..18ee7987 100644 --- a/backend/tests/features/battle-room/battle-room.controller.test.ts +++ b/backend/tests/features/battle/room/battle-room.controller.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import type { Request, Response } from 'express'; -vi.mock('../../../src/features/battle-room/battle-room.service', () => ({ +vi.mock('../../../../src/features/battle/room/battle-room.service', () => ({ mintRoom: vi.fn(), })); -import { createBattleRoom } from '../../../src/features/battle-room/battle-room.controller'; -import { mintRoom } from '../../../src/features/battle-room/battle-room.service'; +import { createBattleRoom } from '../../../../src/features/battle/room/battle-room.controller'; +import { mintRoom } from '../../../../src/features/battle/room/battle-room.service'; function makeRes() { const res = { diff --git a/backend/tests/features/battle-signer/signer.persistence.test.ts b/backend/tests/features/battle/signer/signer.persistence.test.ts similarity index 99% rename from backend/tests/features/battle-signer/signer.persistence.test.ts rename to backend/tests/features/battle/signer/signer.persistence.test.ts index 36b18f99..908451a8 100644 --- a/backend/tests/features/battle-signer/signer.persistence.test.ts +++ b/backend/tests/features/battle/signer/signer.persistence.test.ts @@ -22,7 +22,7 @@ import { listSigningKeys, loadPersistedSigningKeys, resetSigner, -} from '@features/battle-signer'; +} from '@features/battle/signer'; /** When this deployment first started signing — well before any of the restarts below. */ const FIRST_BOOT = 1_700_000_000; diff --git a/backend/tests/features/battle-signer/signer.registry.test.ts b/backend/tests/features/battle/signer/signer.registry.test.ts similarity index 98% rename from backend/tests/features/battle-signer/signer.registry.test.ts rename to backend/tests/features/battle/signer/signer.registry.test.ts index d6967284..1746fac1 100644 --- a/backend/tests/features/battle-signer/signer.registry.test.ts +++ b/backend/tests/features/battle/signer/signer.registry.test.ts @@ -5,8 +5,8 @@ vi.mock('@config/prisma', () => ({ })); import { prisma } from '@config/prisma'; -import { loadSigningKeys, persistSigningKey } from '@features/battle-signer'; -import type { SigningKeyDescriptor } from '@features/battle-signer'; +import { loadSigningKeys, persistSigningKey } from '@features/battle/signer'; +import type { SigningKeyDescriptor } from '@features/battle/signer'; function key(overrides: Partial = {}): SigningKeyDescriptor { return { diff --git a/backend/tests/features/battle-signer/signer.service.test.ts b/backend/tests/features/battle/signer/signer.service.test.ts similarity index 99% rename from backend/tests/features/battle-signer/signer.service.test.ts rename to backend/tests/features/battle/signer/signer.service.test.ts index e2b0ac87..68d4c6a4 100644 --- a/backend/tests/features/battle-signer/signer.service.test.ts +++ b/backend/tests/features/battle/signer/signer.service.test.ts @@ -46,7 +46,7 @@ import { sign, signerAuditLog, SignerRefusedError, -} from '@features/battle-signer'; +} from '@features/battle/signer'; const NOW = roundTime(QUICKNET, 1000) + 1; const DOMAIN = { chainId: 'eip155:84532' as const, deploymentId: 'base-sepolia-live' }; diff --git a/backend/tests/features/battle-worker/beacon.worker.test.ts b/backend/tests/features/battle/worker/beacon.worker.test.ts similarity index 96% rename from backend/tests/features/battle-worker/beacon.worker.test.ts rename to backend/tests/features/battle/worker/beacon.worker.test.ts index 88051416..765289b3 100644 --- a/backend/tests/features/battle-worker/beacon.worker.test.ts +++ b/backend/tests/features/battle/worker/beacon.worker.test.ts @@ -10,14 +10,14 @@ vi.mock('@config/prisma', () => ({ prisma: { battleLedger: { findUnique: vi.fn() } }, })); -vi.mock('@features/battle-ledger', () => ({ +vi.mock('@features/battle/ledger', () => ({ applyTransition: vi.fn(), completeOutbox: vi.fn(), rescheduleOutbox: vi.fn(), OUTBOX_TOPICS: { compute: 'compute' }, })); -vi.mock('@features/battle-randomness', () => ({ +vi.mock('@features/battle/randomness', () => ({ fetchVerifiedRound: vi.fn(), roundPublishTime: vi.fn((round: number) => new Date(roundTime(QUICKNET, round) * 1000)), })); @@ -27,9 +27,9 @@ vi.mock('@ws/battleRoomSocket', () => ({ })); import { prisma } from '@config/prisma'; -import { applyTransition, completeOutbox, rescheduleOutbox } from '@features/battle-ledger'; -import { fetchVerifiedRound } from '@features/battle-randomness'; -import { processAwaitBeaconMessage } from '@features/battle-worker'; +import { applyTransition, completeOutbox, rescheduleOutbox } from '@features/battle/ledger'; +import { fetchVerifiedRound } from '@features/battle/randomness'; +import { processAwaitBeaconMessage } from '@features/battle/worker'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; const ROUND = 1000; diff --git a/backend/tests/features/battle-worker/compute.worker.test.ts b/backend/tests/features/battle/worker/compute.worker.test.ts similarity index 98% rename from backend/tests/features/battle-worker/compute.worker.test.ts rename to backend/tests/features/battle/worker/compute.worker.test.ts index 4c6a2324..87e22d56 100644 --- a/backend/tests/features/battle-worker/compute.worker.test.ts +++ b/backend/tests/features/battle/worker/compute.worker.test.ts @@ -9,7 +9,7 @@ vi.mock('@config/prisma', () => ({ }, })); -vi.mock('@features/battle-ledger', () => ({ +vi.mock('@features/battle/ledger', () => ({ applyTransition: vi.fn(), completeOutbox: vi.fn(), OUTBOX_TOPICS: { verify: 'verify' }, @@ -20,8 +20,8 @@ vi.mock('@ws/battleRoomSocket', () => ({ })); import { prisma } from '@config/prisma'; -import { applyTransition, completeOutbox } from '@features/battle-ledger'; -import { processComputeMessage } from '@features/battle-worker'; +import { applyTransition, completeOutbox } from '@features/battle/ledger'; +import { processComputeMessage } from '@features/battle/worker'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); diff --git a/backend/tests/features/battle-worker/publish.worker.test.ts b/backend/tests/features/battle/worker/publish.worker.test.ts similarity index 98% rename from backend/tests/features/battle-worker/publish.worker.test.ts rename to backend/tests/features/battle/worker/publish.worker.test.ts index c7bfd7be..bb12db53 100644 --- a/backend/tests/features/battle-worker/publish.worker.test.ts +++ b/backend/tests/features/battle/worker/publish.worker.test.ts @@ -19,7 +19,7 @@ import { vi.mock('@config/prisma', () => ({ prisma: { battleLedger: { findUnique: vi.fn() }, battleReceipt: { findUnique: vi.fn() } }, })); -vi.mock('@features/battle-ledger', () => ({ +vi.mock('@features/battle/ledger', () => ({ applyTransition: vi.fn(), completeOutbox: vi.fn(), claimOutbox: vi.fn(), @@ -38,8 +38,8 @@ vi.mock('@features/battle-ledger', () => ({ vi.mock('@ws/battleRoomSocket', () => ({ notifyBattleRoomIfPresent: vi.fn() })); import { prisma } from '@config/prisma'; -import { applyTransition, completeOutbox } from '@features/battle-ledger'; -import { processPublishMessage } from '@features/battle-worker'; +import { applyTransition, completeOutbox } from '@features/battle/ledger'; +import { processPublishMessage } from '@features/battle/worker'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; const BEACON = { diff --git a/backend/tests/features/battle-worker/runner.test.ts b/backend/tests/features/battle/worker/runner.test.ts similarity index 57% rename from backend/tests/features/battle-worker/runner.test.ts rename to backend/tests/features/battle/worker/runner.test.ts index 379e0e70..99f49427 100644 --- a/backend/tests/features/battle-worker/runner.test.ts +++ b/backend/tests/features/battle/worker/runner.test.ts @@ -4,9 +4,10 @@ vi.mock('@config/env', () => ({ env: { battle: { workerBatchSize: 10, workerPollIntervalMs: 2000 } }, })); -vi.mock('@features/battle-ledger', () => ({ +vi.mock('@features/battle/ledger', () => ({ claimOutbox: vi.fn(), failOutbox: vi.fn(), + abandonBattle: vi.fn(), // Must list every real topic. A missing one is not a smaller map — it registers as an // `undefined` key and the dispatcher claims a topic called "undefined". OUTBOX_TOPICS: { @@ -19,31 +20,36 @@ vi.mock('@features/battle-ledger', () => ({ }, })); -vi.mock('@features/battle-worker/beacon.worker', () => ({ +vi.mock('@features/battle/worker/beacon.worker', () => ({ processAwaitBeaconMessage: vi.fn(), })); -vi.mock('@features/battle-worker/compute.worker', () => ({ +vi.mock('@features/battle/worker/compute.worker', () => ({ processComputeMessage: vi.fn(), })); -vi.mock('@features/battle-worker/verify.worker', () => ({ +vi.mock('@features/battle/worker/verify.worker', () => ({ processVerifyMessage: vi.fn(), })); -vi.mock('@features/battle-worker/sign.worker', () => ({ +vi.mock('@features/battle/worker/sign.worker', () => ({ processSignMessage: vi.fn(), })); -vi.mock('@features/battle-worker/publish.worker', () => ({ +vi.mock('@features/battle/worker/publish.worker', () => ({ processPublishMessage: vi.fn(), })); -import { claimOutbox, failOutbox } from '@features/battle-ledger'; -import { processAwaitBeaconMessage } from '@features/battle-worker/beacon.worker'; -import { processComputeMessage } from '@features/battle-worker/compute.worker'; -import { runBattleWorkerOnce } from '@features/battle-worker/runner'; +import { abandonBattle, claimOutbox, failOutbox } from '@features/battle/ledger'; +import { processAwaitBeaconMessage } from '@features/battle/worker/beacon.worker'; +import { processComputeMessage } from '@features/battle/worker/compute.worker'; +import { processVerifyMessage } from '@features/battle/worker/verify.worker'; +import { runBattleWorkerOnce } from '@features/battle/worker/runner'; const NOW = new Date('2026-07-26T12:00:00.000Z'); beforeEach(() => { vi.clearAllMocks(); + // The default: a failure that still has retries left. Tests wanting the last attempt + // override this. + vi.mocked(failOutbox).mockResolvedValue({ deadLettered: false, retryAt: new Date(0) }); + vi.mocked(abandonBattle).mockResolvedValue({ abandoned: true, state: 'forfeited' as never }); }); describe('dispatch', () => { @@ -113,3 +119,47 @@ describe('dispatch', () => { expect(await runBattleWorkerOnce('worker-a', NOW)).toEqual({ processed: 0 }); }); }); + + +describe('the end of the retry road', () => { + const dying = (topic: string) => { + vi.mocked(claimOutbox).mockResolvedValue([ + { id: 'm1', battleId: 'btl_1', topic, payload: {}, attempts: 8 }, + ]); + vi.mocked(failOutbox).mockResolvedValue({ deadLettered: true, retryAt: null }); + }; + + // Locks are released by reaching a terminal state, so a battle left non-terminal keeps + // both its pets out of the game for good. This is what closes that. + it('ends the battle when a message dead-letters, freeing its pets', async () => { + dying('verify'); + vi.mocked(processVerifyMessage).mockRejectedValue(new Error('indexer-go unavailable')); + + await runBattleWorkerOnce('w1'); + + expect(abandonBattle).toHaveBeenCalledWith( + 'btl_1', + expect.stringContaining('verify gave up after 8 attempts'), + ); + }); + + it('leaves the battle alone while retries remain', async () => { + vi.mocked(claimOutbox).mockResolvedValue([ + { id: 'm1', battleId: 'btl_1', topic: 'verify', payload: {}, attempts: 2 }, + ]); + vi.mocked(processVerifyMessage).mockRejectedValue(new Error('transient')); + + await runBattleWorkerOnce('w1'); + + expect(failOutbox).toHaveBeenCalled(); + expect(abandonBattle).not.toHaveBeenCalled(); + }); + + it('also ends one dead-lettered for having no handler at all', async () => { + dying('nonsense'); + + await runBattleWorkerOnce('w1'); + + expect(abandonBattle).toHaveBeenCalledWith('btl_1', expect.stringContaining('no handler')); + }); +}); diff --git a/backend/tests/features/battle-worker/sign.worker.test.ts b/backend/tests/features/battle/worker/sign.worker.test.ts similarity index 98% rename from backend/tests/features/battle-worker/sign.worker.test.ts rename to backend/tests/features/battle/worker/sign.worker.test.ts index 768d8ea0..0f075f72 100644 --- a/backend/tests/features/battle-worker/sign.worker.test.ts +++ b/backend/tests/features/battle/worker/sign.worker.test.ts @@ -26,14 +26,14 @@ vi.mock('@config/prisma', () => ({ }, })); -vi.mock('@features/battle-ledger', () => ({ +vi.mock('@features/battle/ledger', () => ({ applyTransition: vi.fn(), completeOutbox: vi.fn(), OUTBOX_TOPICS: { publish: 'publish' }, })); -vi.mock('@features/battle-signer', async () => { - const actual = await vi.importActual('@features/battle-signer'); +vi.mock('@features/battle/signer', async () => { + const actual = await vi.importActual('@features/battle/signer'); return { activeSigningKey: vi.fn(), sign: vi.fn(), @@ -46,9 +46,9 @@ vi.mock('@ws/battleRoomSocket', () => ({ })); import { prisma } from '@config/prisma'; -import { applyTransition, completeOutbox } from '@features/battle-ledger'; -import { activeSigningKey, sign, SignerRefusedError } from '@features/battle-signer'; -import { processSignMessage } from '@features/battle-worker'; +import { applyTransition, completeOutbox } from '@features/battle/ledger'; +import { activeSigningKey, sign, SignerRefusedError } from '@features/battle/signer'; +import { processSignMessage } from '@features/battle/worker'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); diff --git a/backend/tests/features/battle-worker/verify.worker.test.ts b/backend/tests/features/battle/worker/verify.worker.test.ts similarity index 98% rename from backend/tests/features/battle-worker/verify.worker.test.ts rename to backend/tests/features/battle/worker/verify.worker.test.ts index 0dee9dad..2877c1ff 100644 --- a/backend/tests/features/battle-worker/verify.worker.test.ts +++ b/backend/tests/features/battle/worker/verify.worker.test.ts @@ -19,7 +19,7 @@ vi.mock('@config/prisma', () => ({ }, })); -vi.mock('@features/battle-ledger', () => ({ +vi.mock('@features/battle/ledger', () => ({ applyTransition: vi.fn(), completeOutbox: vi.fn(), OUTBOX_TOPICS: { sign: 'sign' }, @@ -34,8 +34,8 @@ vi.mock('@ws/battleRoomSocket', () => ({ })); import { prisma } from '@config/prisma'; -import { applyTransition, completeOutbox } from '@features/battle-ledger'; -import { processVerifyMessage } from '@features/battle-worker'; +import { applyTransition, completeOutbox } from '@features/battle/ledger'; +import { processVerifyMessage } from '@features/battle/worker'; import { callVerifyBattle } from '@grpc-client/verifyBattle'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; diff --git a/backend/tests/features/chat.controller.test.ts b/backend/tests/features/chat.controller.test.ts new file mode 100644 index 00000000..961322db --- /dev/null +++ b/backend/tests/features/chat.controller.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; + +vi.mock('../../src/features/chat/chat.service', () => ({ + listThreads: vi.fn(), + authorizeThread: vi.fn(), + readMessages: vi.fn(), + sendMessage: vi.fn(), + markRead: vi.fn(), + reactToMessage: vi.fn(), +})); +vi.mock('@ws/chatSocket', () => ({ notifyChatThread: vi.fn() })); + +import { + getMessages, + getThreads, + postMessage, + postReaction, + postRead, +} from '../../src/features/chat/chat.controller'; +import { + authorizeThread, + listThreads, + markRead, + reactToMessage, + readMessages, + sendMessage, +} from '../../src/features/chat/chat.service'; +import { notifyChatThread } from '@ws/chatSocket'; + +function makeRes() { + const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() }; + return res as unknown as Response; +} + +const ME = '0x1111111111111111111111111111111111111111'; + +/** An authenticated request for thread `t1`. */ +function req(over: Record = {}): Request { + return { + params: { id: 't1' }, + query: {}, + body: {}, + user: { address: ME, userId: 'u1' }, + ...over, + } as unknown as Request; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(authorizeThread).mockResolvedValue(null); +}); + +describe('getThreads', () => { + it('lists threads for the session wallet', async () => { + vi.mocked(listThreads).mockResolvedValue([]); + const res = makeRes(); + + await getThreads(req(), res); + + expect(listThreads).toHaveBeenCalledWith(ME); + expect(res.json).toHaveBeenCalledWith({ threads: [] }); + }); + + it('returns 401 without an authenticated user', async () => { + const res = makeRes(); + await getThreads(req({ user: undefined }), res); + expect(res.status).toHaveBeenCalledWith(401); + expect(listThreads).not.toHaveBeenCalled(); + }); +}); + +describe('thread authorization', () => { + // A non-participant must not be able to tell an existing thread id from a made-up + // one; 403 here would confirm the id for anyone probing. + it('answers 404, not 403, for a wallet that is not a participant', async () => { + vi.mocked(authorizeThread).mockResolvedValue('not-a-participant'); + const res = makeRes(); + + await getMessages(req(), res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(readMessages).not.toHaveBeenCalled(); + }); + + it('answers 404 for a thread that does not exist', async () => { + vi.mocked(authorizeThread).mockResolvedValue('not-found'); + const res = makeRes(); + + await getMessages(req(), res); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + // A participant already knows the thread exists, so an ended marriage can say so. + it('answers 403 with a reason when the marriage has ended', async () => { + vi.mocked(authorizeThread).mockResolvedValue('not-married'); + const res = makeRes(); + + await postMessage(req({ body: { text: 'hi' } }), res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); + +describe('getMessages', () => { + it('passes the page through after authorizing', async () => { + vi.mocked(readMessages).mockResolvedValue({ messages: [], readUpTo: 3 }); + const res = makeRes(); + + await getMessages(req({ query: { limit: '10', before: '99' } }), res); + + expect(readMessages).toHaveBeenCalledWith('t1', ME, 10, 99); + // The watermark rides with the page, so one read answers both questions. + expect(res.json).toHaveBeenCalledWith({ messages: [], readUpTo: 3 }); + }); + + it('rejects a bad page size before touching the database', async () => { + const res = makeRes(); + + await getMessages(req({ query: { limit: '5000' } }), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(readMessages).not.toHaveBeenCalled(); + }); +}); + +describe('postMessage', () => { + it('stores the trimmed text under the session wallet', async () => { + vi.mocked(sendMessage).mockResolvedValue({ + id: 1, + sender: ME, + text: 'hello', + createdAt: new Date(0), + }); + const res = makeRes(); + + await postMessage(req({ body: { text: ' hello ' } }), res); + + expect(sendMessage).toHaveBeenCalledWith('t1', ME, 'hello'); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it('rejects a message that is only whitespace', async () => { + const res = makeRes(); + + await postMessage(req({ body: { text: ' ' } }), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('rejects a message past the length cap', async () => { + const res = makeRes(); + + await postMessage(req({ body: { text: 'x'.repeat(2001) } }), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('notifies the thread after the write, carrying the id and no text', async () => { + vi.mocked(sendMessage).mockResolvedValue({ + id: 42, + sender: ME, + text: 'hello', + createdAt: new Date(0), + }); + + await postMessage(req({ body: { text: 'hello' } }), makeRes()); + + expect(notifyChatThread).toHaveBeenCalledWith('t1', { + type: 'thread-updated', + threadId: 't1', + messageId: 42, + }); + }); + + it('does not notify when the write was refused', async () => { + vi.mocked(authorizeThread).mockResolvedValue('not-married'); + + await postMessage(req({ body: { text: 'hello' } }), makeRes()); + + expect(notifyChatThread).not.toHaveBeenCalled(); + }); + + it('cannot send as another wallet by putting one in the body', async () => { + vi.mocked(sendMessage).mockResolvedValue({ + id: 1, + sender: ME, + text: 'hi', + createdAt: new Date(0), + }); + const res = makeRes(); + + await postMessage(req({ body: { text: 'hi', sender: '0xsomeone-else' } }), res); + + expect(sendMessage).toHaveBeenCalledWith('t1', ME, 'hi'); + }); +}); + +describe('postRead', () => { + it('moves the watermark and tells the thread so the sender ticks', async () => { + vi.mocked(authorizeThread).mockResolvedValue(null); + vi.mocked(markRead).mockResolvedValue(undefined); + const res = makeRes(); + Object.assign(res, { end: vi.fn().mockReturnThis() }); + + await postRead(req({ body: { messageId: 12 } }), res); + + expect(markRead).toHaveBeenCalledWith('t1', ME, 12); + // Its own frame type: a receipt names a message the sender already holds, and a + // client that skips those as echoes of its own send would never fill the tick in. + expect(notifyChatThread).toHaveBeenCalledWith('t1', { + type: 'thread-read', + threadId: 't1', + messageId: 12, + }); + expect(res.status).toHaveBeenCalledWith(204); + }); + + it('rejects a bad message id before touching the database', async () => { + vi.mocked(authorizeThread).mockResolvedValue(null); + const res = makeRes(); + + await postRead(req({ body: { messageId: 'nope' } }), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(markRead).not.toHaveBeenCalled(); + }); + + // The same gate as every other route here: a non-participant learns nothing. + it('refuses a thread the caller is not in', async () => { + vi.mocked(authorizeThread).mockResolvedValue('not-a-participant'); + const res = makeRes(); + + await postRead(req({ body: { messageId: 1 } }), res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(markRead).not.toHaveBeenCalled(); + }); +}); + + +describe('postReaction', () => { + const reactReq = (body: unknown, messageId = '7') => + req({ params: { id: 't1', messageId }, body }); + + it('applies the tap and announces it as its own frame type', async () => { + vi.mocked(authorizeThread).mockResolvedValue(null); + vi.mocked(reactToMessage).mockResolvedValue({ emoji: '👍' }); + const res = makeRes(); + + await postReaction(reactReq({ emoji: '👍' }), res); + + expect(reactToMessage).toHaveBeenCalledWith('t1', ME, 7, '👍'); + // Not `thread-updated`: the message id is one every client already holds, and the + // echo check would drop the frame as its own send. + expect(notifyChatThread).toHaveBeenCalledWith('t1', { + type: 'thread-reacted', + threadId: 't1', + messageId: 7, + }); + expect(res.json).toHaveBeenCalledWith({ emoji: '👍' }); + }); + + // The whitelist is shared with the client, so this is the API refusing anything the + // picker could not have offered — including arbitrary user-authored text. + it('refuses an emoji outside the shared set', async () => { + vi.mocked(authorizeThread).mockResolvedValue(null); + const res = makeRes(); + + await postReaction(reactReq({ emoji: 'not an emoji' }), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(reactToMessage).not.toHaveBeenCalled(); + }); + + it('refuses a message id that is not a positive integer', async () => { + vi.mocked(authorizeThread).mockResolvedValue(null); + const res = makeRes(); + + await postReaction(reactReq({ emoji: '👍' }, 'abc'), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(reactToMessage).not.toHaveBeenCalled(); + }); + + it('404s a message that belongs to another thread', async () => { + vi.mocked(authorizeThread).mockResolvedValue(null); + vi.mocked(reactToMessage).mockResolvedValue('not-found'); + const res = makeRes(); + + await postReaction(reactReq({ emoji: '👍' }), res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(notifyChatThread).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/features/chat.service.test.ts b/backend/tests/features/chat.service.test.ts new file mode 100644 index 00000000..2c72b9c7 --- /dev/null +++ b/backend/tests/features/chat.service.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const findMarriedCounterparts = vi.fn(); +const openThread = vi.fn(); +const findThreadById = vi.fn(); +const isMarriedTo = vi.fn(); +const findMessages = vi.fn(); +const findCounterpartReadId = vi.fn(); +const markThreadRead = vi.fn(); +const findReactionsForMessages = vi.fn(); +const setReaction = vi.fn(); +const messageBelongsToThread = vi.fn(); + +vi.mock('@repositories/chat.repository', () => ({ + findMarriedCounterparts: (caller: string) => findMarriedCounterparts(caller), + openThread: (x: string, y: string, scope: string) => openThread(x, y, scope), + findThreadById: (id: string) => findThreadById(id), + isMarriedTo: (a: string, b: string) => isMarriedTo(a, b), + findMessages: (id: string, limit: number, before?: number) => findMessages(id, limit, before), + findCounterpartReadId: (id: string, caller: string) => findCounterpartReadId(id, caller), + markThreadRead: (id: string, participant: string, messageId: number) => + markThreadRead(id, participant, messageId), + findReactionsForMessages: (ids: number[]) => findReactionsForMessages(ids), + setReaction: (messageId: number, who: string, emoji: string) => + setReaction(messageId, who, emoji), + messageBelongsToThread: (messageId: number, threadId: string) => + messageBelongsToThread(messageId, threadId), + insertMessage: vi.fn(), +})); + +import { + authorizeThread, + listThreads, + markRead, + reactToMessage, + readMessages, +} from '../../src/features/chat/chat.service'; + +const ME = '0x1111111111111111111111111111111111111111'; +const THEM = '0x2222222222222222222222222222222222222222'; + +const marriage = (over: Record = {}) => ({ + chain: 'evm', + petId: '1', + petName: 'Mine', + spousePetId: '2', + spouseName: 'Theirs', + counterpart: THEM, + ...over, +}); + +beforeEach(() => { + vi.clearAllMocks(); + openThread.mockImplementation(async (x: string, y: string) => `thread-${x}-${y}`); + isMarriedTo.mockResolvedValue(true); +}); + +describe('listThreads', () => { + it('opens one thread per married counterpart', async () => { + findMarriedCounterparts.mockResolvedValue([marriage()]); + + const threads = await listThreads(ME); + + expect(threads).toHaveLength(1); + expect(threads[0].counterpart).toBe(THEM); + expect(openThread).toHaveBeenCalledWith(ME, THEM, 'marriage'); + }); + + it('collapses two married pet couples between the same owners into one thread', async () => { + // The conversation is between the owners, not the pets. Two threads here would + // split one relationship across two inboxes. + findMarriedCounterparts.mockResolvedValue([ + marriage(), + marriage({ petId: '3', petName: 'Second', spousePetId: '4', spouseName: 'Fourth' }), + ]); + + const threads = await listThreads(ME); + + expect(threads).toHaveLength(1); + expect(threads[0].pets).toHaveLength(2); + expect(openThread).toHaveBeenCalledTimes(1); + }); + + it('returns nothing, and opens nothing, for an unmarried caller', async () => { + findMarriedCounterparts.mockResolvedValue([]); + + expect(await listThreads(ME)).toEqual([]); + expect(openThread).not.toHaveBeenCalled(); + }); + + it('normalizes the counterpart, so a mixed-case roster owner is one participant', async () => { + // indexer-go is not guaranteed to write the roster in the JWT's case; an + // unnormalized counterpart would open a second thread for the same wallet. + findMarriedCounterparts.mockResolvedValue([marriage({ counterpart: THEM.toUpperCase().replace('0X', '0x') })]); + + const threads = await listThreads(ME); + + expect(threads[0].counterpart).toBe(THEM); + expect(openThread).toHaveBeenCalledWith(ME, THEM, 'marriage'); + }); +}); + +describe('authorizeThread', () => { + const thread = { id: 't1', participantA: ME, participantB: THEM, scope: 'marriage' }; + + it('allows a participant whose marriage is live', async () => { + findThreadById.mockResolvedValue(thread); + findMarriedCounterparts.mockResolvedValue([marriage()]); + + expect(await authorizeThread('t1', ME)).toBeNull(); + expect(isMarriedTo).toHaveBeenCalledWith(ME, THEM); + }); + + it('refuses a wallet that is not a participant', async () => { + findThreadById.mockResolvedValue(thread); + + const result = await authorizeThread('t1', '0x9999999999999999999999999999999999999999'); + + expect(result).toBe('not-a-participant'); + // The marriage is not even consulted: not being in the thread settles it. + expect(isMarriedTo).not.toHaveBeenCalled(); + }); + + // The point of checking live state per request instead of recording it on the + // thread: a divorce closes the conversation with nothing to revoke. + it('refuses a participant whose marriage has ended', async () => { + findThreadById.mockResolvedValue(thread); + isMarriedTo.mockResolvedValue(false); + + expect(await authorizeThread('t1', ME)).toBe('not-married'); + }); + + it('reports a missing thread', async () => { + findThreadById.mockResolvedValue(null); + + expect(await authorizeThread('nope', ME)).toBe('not-found'); + }); +}); + + +describe('readMessages', () => { + it('returns the page with how far the counterpart has read', async () => { + findMessages.mockResolvedValue([{ id: 7 }]); + findCounterpartReadId.mockResolvedValue(5); + findReactionsForMessages.mockResolvedValue([]); + + await expect(readMessages('t1', ME, 50)).resolves.toEqual({ + messages: [{ id: 7, reactions: [] }], + readUpTo: 5, + }); + expect(findCounterpartReadId).toHaveBeenCalledWith('t1', ME); + }); + + it("groups reactions per message, counting them and flagging the caller's own", async () => { + findMessages.mockResolvedValue([{ id: 7 }, { id: 8 }]); + findCounterpartReadId.mockResolvedValue(0); + findReactionsForMessages.mockResolvedValue([ + { messageId: 7, participant: ME, emoji: '👍' }, + { messageId: 7, participant: THEM, emoji: '👍' }, + { messageId: 7, participant: THEM, emoji: '😂' }, + { messageId: 8, participant: THEM, emoji: '🙏' }, + ]); + + const page = await readMessages('t1', ME, 50); + + expect(page.messages[0].reactions).toEqual([ + { emoji: '👍', count: 2, mine: true }, + { emoji: '😂', count: 1, mine: false }, + ]); + expect(page.messages[1].reactions).toEqual([{ emoji: '🙏', count: 1, mine: false }]); + // One query for the page, not one per message. + expect(findReactionsForMessages).toHaveBeenCalledWith([7, 8]); + }); + + // The caller arrives from the JWT already normalized, but the rule lives at the + // service boundary everywhere else in this feature and `mine` depends on it. + it("matches the caller's own reaction regardless of address case", async () => { + findMessages.mockResolvedValue([{ id: 7 }]); + findCounterpartReadId.mockResolvedValue(0); + findReactionsForMessages.mockResolvedValue([ + { messageId: 7, participant: ME, emoji: '👍' }, + ]); + + // Checksummed spelling: `0x` prefix kept, hex body upper-cased, which is what a + // wallet actually hands over. + const page = await readMessages('t1', `0x${ME.slice(2).toUpperCase()}`, 50); + + expect(page.messages[0].reactions[0].mine).toBe(true); + }); +}); + +describe('reactToMessage', () => { + it('applies the tap and reports what the caller now holds', async () => { + messageBelongsToThread.mockResolvedValue(true); + setReaction.mockResolvedValue('👍'); + + await expect(reactToMessage('t1', ME, 7, '👍')).resolves.toEqual({ emoji: '👍' }); + }); + + it('reports null when the tap removed the reaction', async () => { + messageBelongsToThread.mockResolvedValue(true); + setReaction.mockResolvedValue(null); + + await expect(reactToMessage('t1', ME, 7, '👍')).resolves.toEqual({ emoji: null }); + }); + + // Authorization is thread-level, so without this a caller could authorize against + // their own thread and then name a message in one they cannot read. + it('refuses a message that belongs to another thread', async () => { + messageBelongsToThread.mockResolvedValue(false); + + await expect(reactToMessage('t1', ME, 999, '👍')).resolves.toBe('not-found'); + expect(setReaction).not.toHaveBeenCalled(); + }); + + // Nobody has read anything yet is the common case on a new thread, and it has to read + // as "no message is seen" rather than as a missing value at the call site. + it('reports 0 when the counterpart has read nothing', async () => { + findMessages.mockResolvedValue([]); + findCounterpartReadId.mockResolvedValue(0); + + await expect(readMessages('t1', ME, 50)).resolves.toEqual({ messages: [], readUpTo: 0 }); + }); +}); + +describe('markRead', () => { + it("moves the caller's own watermark", async () => { + markThreadRead.mockResolvedValue(undefined); + + await markRead('t1', ME, 9); + + expect(markThreadRead).toHaveBeenCalledWith('t1', ME, 9); + }); +}); diff --git a/backend/tests/graphql/resolvers.test.ts b/backend/tests/graphql/resolvers.test.ts index 6aebdfc0..b7481cc1 100644 --- a/backend/tests/graphql/resolvers.test.ts +++ b/backend/tests/graphql/resolvers.test.ts @@ -7,6 +7,11 @@ vi.mock('@repositories/roster.repository', () => ({ vi.mock('../../src/grpc/estimateWin', () => ({ tryGrpcEstimateWin: vi.fn(), })); +vi.mock('@repositories/leaderboard.repository', () => ({ + findPetLeaderboard: vi.fn(), + findPlayerLeaderboard: vi.fn(), + findPlayerRank: vi.fn(), +})); // The overlay's own merge rule is covered in repositories/battleProgress.overlay.test.ts; // here it is stubbed to a pass-through so these tests stay about resolver shaping. vi.mock('@repositories/battleProgress.overlay', () => ({ @@ -16,6 +21,11 @@ vi.mock('@repositories/battleProgress.overlay', () => ({ import { rootValue } from '../../src/graphql/resolvers'; import { findReadyOpponents, getPetById } from '@repositories/roster.repository'; +import { + findPetLeaderboard, + findPlayerLeaderboard, + findPlayerRank, +} from '@repositories/leaderboard.repository'; import { tryGrpcEstimateWin } from '../../src/grpc/estimateWin'; const ctx = { caller: '0xcaller' }; @@ -59,6 +69,88 @@ describe('opponents resolver', () => { }); }); +describe('leaderboard resolver', () => { + const entry = { + rank: 1, + chain: 'evm', + petId: '42', + owner: '0xowner', + name: 'Rex', + level: 5, + rarity: 1, + dna: '123', + winCount: 3, + lossCount: 1, + asset: '', + }; + + it('renames petId to id and passes the page through', async () => { + vi.mocked(findPetLeaderboard).mockResolvedValue({ entries: [entry], total: 1 }); + + const result = await rootValue.leaderboard({ chain: 'evm', page: 1 }, ctx); + + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.entries[0]).toMatchObject({ id: '42', rank: 1 }); + expect(result.entries[0]).not.toHaveProperty('petId'); + }); + + it('throws for an unsupported chain', async () => { + await expect(rootValue.leaderboard({ chain: 'tron' }, ctx)).rejects.toThrow('chain must be one of'); + }); + + it('clamps pageSize to MAX_PAGE_SIZE=50', async () => { + vi.mocked(findPetLeaderboard).mockResolvedValue({ entries: [], total: 0 }); + await rootValue.leaderboard({ chain: 'evm', pageSize: 999 }, ctx); + expect(vi.mocked(findPetLeaderboard).mock.calls[0][0].pageSize).toBe(50); + }); +}); + +describe('playerLeaderboard resolver', () => { + const entry = { rank: 1, owner: '0xowner', winCount: 20, lossCount: 4, petCount: 3 }; + + it('passes the page through unchanged (no petId to rename here)', async () => { + vi.mocked(findPlayerLeaderboard).mockResolvedValue({ entries: [entry], total: 1 }); + + const result = await rootValue.playerLeaderboard({ chain: 'evm', page: 2 }, ctx); + + expect(result.total).toBe(1); + expect(result.page).toBe(2); + expect(result.entries[0]).toEqual(entry); + }); + + it('throws for an unsupported chain', async () => { + await expect(rootValue.playerLeaderboard({ chain: 'tron' }, ctx)).rejects.toThrow('chain must be one of'); + }); + + it('clamps pageSize to MAX_PAGE_SIZE=50', async () => { + vi.mocked(findPlayerLeaderboard).mockResolvedValue({ entries: [], total: 0 }); + await rootValue.playerLeaderboard({ chain: 'evm', pageSize: 999 }, ctx); + expect(vi.mocked(findPlayerLeaderboard).mock.calls[0][0].pageSize).toBe(50); + }); +}); + +describe('playerRank resolver', () => { + it('ranks the session wallet, not an argument', async () => { + // The owner comes from the JWT context, so this cannot be pointed at someone + // else's wallet to enumerate their standing. + vi.mocked(findPlayerRank).mockResolvedValue(null); + + await rootValue.playerRank({ chain: 'evm' }, { caller: '0xme' }); + + expect(findPlayerRank).toHaveBeenCalledWith('evm', '0xme'); + }); + + it('returns null for an unranked caller rather than a zeroed row', async () => { + vi.mocked(findPlayerRank).mockResolvedValue(null); + expect(await rootValue.playerRank({ chain: 'evm' }, ctx)).toBeNull(); + }); + + it('throws for an unsupported chain', async () => { + await expect(rootValue.playerRank({ chain: 'tron' }, ctx)).rejects.toThrow('chain must be one of'); + }); +}); + describe('pet resolver', () => { it('returns the mapped pet when found', async () => { vi.mocked(getPetById).mockResolvedValue(rosterPet); diff --git a/backend/tests/graphql/schema.test.ts b/backend/tests/graphql/schema.test.ts index c242450c..5230fb7c 100644 --- a/backend/tests/graphql/schema.test.ts +++ b/backend/tests/graphql/schema.test.ts @@ -21,9 +21,10 @@ function fieldsOf(typeName: string): Record { describe('GraphQL schema — Query surface', () => { const query = fieldsOf('Query'); - it('exposes opponents, pet, searchPets, allPets, battleProgress, and winEstimate', () => { + it('exposes the pet reads, both leaderboards, battleProgress, and winEstimate', () => { expect(Object.keys(query).sort()).toEqual([ - 'allPets', 'battleProgress', 'opponents', 'pet', 'searchPets', 'winEstimate', + 'allPets', 'battleProgress', 'leaderboard', 'opponents', 'pet', 'playerLeaderboard', + 'playerRank', 'searchPets', 'winEstimate', ]); }); @@ -44,6 +45,53 @@ describe('GraphQL schema — Query surface', () => { it('returns a nullable WinEstimate (null = odds unavailable)', () => { expect(query.winEstimate?.type).toBe('WinEstimate'); }); + + it('returns a non-null LeaderboardPage from leaderboard', () => { + expect(query.leaderboard?.type).toBe('LeaderboardPage!'); + }); + + it('returns a non-null PlayerLeaderboardPage from playerLeaderboard', () => { + expect(query.playerLeaderboard?.type).toBe('PlayerLeaderboardPage!'); + }); + + it('returns a nullable PlayerLeaderboardEntry from playerRank (null = unranked)', () => { + expect(query.playerRank?.type).toBe('PlayerLeaderboardEntry'); + }); + + it('takes no owner argument on playerRank — the session decides whose rank it is', () => { + const args = (schema.getQueryType()?.getFields().playerRank?.args ?? []).map((a) => a.name); + expect(args).toEqual(['chain']); + }); +}); + +describe('GraphQL schema — LeaderboardEntry', () => { + const entry = fieldsOf('LeaderboardEntry'); + + it('carries the rank plus the fields a row displays', () => { + for (const f of ['rank', 'id', 'chain', 'owner', 'name', 'dna', 'level', 'rarity', 'winCount', 'lossCount', 'asset']) { + expect(entry, `missing field ${f}`).toHaveProperty(f); + } + }); + + it('types rank as Int and carries `asset` so Solana rows can address pet art', () => { + expect(entry.rank?.type).toBe('Int!'); + expect(entry.asset?.type).toBe('String!'); + }); +}); + +describe('GraphQL schema — PlayerLeaderboardEntry', () => { + const entry = fieldsOf('PlayerLeaderboardEntry'); + + it('carries the rank, owner, and the summed record', () => { + for (const f of ['rank', 'owner', 'winCount', 'lossCount', 'petCount']) { + expect(entry, `missing field ${f}`).toHaveProperty(f); + } + }); + + it('has no pet-specific fields — a player row is an aggregate, not a pet', () => { + expect(entry).not.toHaveProperty('id'); + expect(entry).not.toHaveProperty('dna'); + }); }); describe('GraphQL schema — OpponentPet v2 fields', () => { diff --git a/backend/tests/repositories/leaderboard.repository.test.ts b/backend/tests/repositories/leaderboard.repository.test.ts new file mode 100644 index 00000000..46f9e223 --- /dev/null +++ b/backend/tests/repositories/leaderboard.repository.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('@config/prisma', () => ({ + prisma: { + petRoster: { + findMany: vi.fn(), + count: vi.fn(), + }, + $queryRaw: vi.fn(), + }, +})); + +const servedChainIdForFamily = vi.fn(() => 'eip155:31337' as string | null); +vi.mock('../../src/repositories/battleProgress.overlay', () => ({ + servedChainIdForFamily: (chain: string) => servedChainIdForFamily(chain), +})); + +import { + findPetLeaderboard, + findPlayerLeaderboard, + findPlayerRank, +} from '../../src/repositories/leaderboard.repository'; +import { prisma } from '@config/prisma'; + +const rankedRow = { + rank: 1, + chain: 'evm', + petId: '7', + owner: '0xowner', + name: 'Rex', + level: 9, + rarity: 3, + dna: '123', + winCount: 12, + lossCount: 2, + asset: '', +}; + +/** `$queryRaw` is called twice per page: the rows, then their count. */ +function mockJoinQuery(rows: unknown[], total: number) { + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce(rows as never) + .mockResolvedValueOnce([{ total: BigInt(total) }] as never); +} + +function callOf(index: number): [string[], ...unknown[]] { + return vi.mocked(prisma.$queryRaw).mock.calls[index] as unknown as [string[], ...unknown[]]; +} + +/** + * The full SQL of the nth `$queryRaw` call: literal chunks with any interpolated + * `Prisma.Sql` fragment spliced back in. + * + * The queries are assembled from shared fragments (the merge, the filter, the ordering), + * and `$queryRaw` is mocked so nothing ever flattens them — a helper that read only the + * literal chunks would see none of the logic under test. + */ +function sqlOfCall(index: number): string { + const [template, ...values] = callOf(index); + return template + .map((chunk, i) => { + const value = values[i] as { sql?: unknown } | undefined; + return chunk + (typeof value?.sql === 'string' ? value.sql : i < values.length ? ' ? ' : ''); + }) + .join('') + .replace(/\s+/g, ' '); +} + +/** + * Every bound parameter of the nth call, including those carried by interpolated + * fragments — the join's `chain_id` is one of those, so a helper that only looked at the + * outer values would miss it. + */ +function paramsOfCall(index: number): unknown[] { + const [, ...values] = callOf(index); + return values.flatMap((value) => { + const fragment = value as { sql?: unknown; values?: unknown[] }; + return typeof fragment?.sql === 'string' ? (fragment.values ?? []) : [value]; + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + servedChainIdForFamily.mockReturnValue('eip155:31337'); +}); + +describe('findPetLeaderboard', () => { + it('returns the ranked rows and their total', async () => { + mockJoinQuery([rankedRow], 1); + + const result = await findPetLeaderboard({ chain: 'evm', page: 0, pageSize: 20 }); + + expect(result.total).toBe(1); + expect(result.entries[0].petId).toBe('7'); + expect(result.entries[0].rank).toBe(1); + }); + + // The rank moved into the query when search arrived: it has to be assigned over the + // whole board *before* a filter narrows it, which an offset cannot do. These two say + // the repository reports what the query ranked and never recomputes it. + it('reports the rank the query assigned', async () => { + mockJoinQuery([{ ...rankedRow, rank: 41 }, { ...rankedRow, petId: '8', rank: 42 }], 42); + + const result = await findPetLeaderboard({ chain: 'evm', page: 2, pageSize: 20 }); + + expect(result.entries.map((entry) => entry.rank)).toEqual([41, 42]); + }); + + it('ranks over the whole board before any search narrows it', async () => { + mockJoinQuery([], 0); + + await findPetLeaderboard({ chain: 'evm', page: 0, pageSize: 20, search: 'Rex' }); + + const sql = sqlOfCall(0); + // The window sits inside the subquery, the filter outside it. The other way round + // would renumber the matches from one and a pet's rank would depend on what + // somebody typed. + const ranking = sql.indexOf('ROW_NUMBER() OVER'); + const filtering = sql.indexOf('ILIKE'); + expect(ranking).toBeGreaterThan(-1); + expect(filtering).toBeGreaterThan(ranking); + }); + + it('counts the matches, not the board, so the pager fits the search', async () => { + mockJoinQuery([], 0); + + await findPetLeaderboard({ chain: 'evm', page: 0, pageSize: 20, search: 'Rex' }); + + expect(sqlOfCall(1)).toContain('ILIKE'); + }); + + // One box, both questions: a name finds a pet, an address finds that wallet's pets. + it('matches a term against the pet name or its owner', async () => { + mockJoinQuery([], 0); + + await findPetLeaderboard({ chain: 'evm', page: 0, pageSize: 20, search: '0xf00d' }); + + const sql = sqlOfCall(0); + expect(sql).toContain('ranked.name ILIKE'); + expect(sql).toContain('ranked.owner ILIKE'); + }); + + it('leaves the board unfiltered when nothing is searched for', async () => { + mockJoinQuery([], 0); + + await findPetLeaderboard({ chain: 'evm', page: 0, pageSize: 20, search: ' ' }); + + // A blank term is `TRUE`, not a second query shape. + expect(sqlOfCall(0)).not.toContain('ILIKE'); + }); + + it('ranks on the merged battle record, not the frozen roster counters', async () => { + // The roster's win/loss stopped moving when battles left the chain, so ordering on + // r.win_count alone would freeze the leaderboard at the retired path's last state. + mockJoinQuery([], 0); + + await findPetLeaderboard({ chain: 'evm', page: 0, pageSize: 20 }); + + const sql = sqlOfCall(0); + expect(sql).toContain('LEFT JOIN pet_battle_progress p'); + expect(sql).toContain('ORDER BY COALESCE(p.win_count, r.win_count) DESC'); + expect(sql).toContain('COALESCE(p.loss_count, r.loss_count) ASC'); + expect(sql).toContain('GREATEST(r.level, COALESCE(p.level, 0)) DESC'); + expect(sql).toContain('r.pet_id ASC'); + }); + + it('excludes pets that have never fought, in the query and in the count', async () => { + mockJoinQuery([], 0); + + await findPetLeaderboard({ chain: 'evm', page: 0, pageSize: 20 }); + + const battled = 'COALESCE(p.win_count, r.win_count) + COALESCE(p.loss_count, r.loss_count) > 0'; + expect(sqlOfCall(0)).toContain(battled); + // The count must apply the same filter, or `total` would page past the last row. + expect(sqlOfCall(1)).toContain(battled); + }); + + // A chain family this deployment does not serve needs no second query: `chain_id = NULL` + // never matches, so the join contributes nothing and every COALESCE falls back to the + // roster column — which is the chain-state-only ranking, exactly. + it('joins on a null chain id for an unserved chain family, falling back to roster values', async () => { + servedChainIdForFamily.mockReturnValue(null); + mockJoinQuery([rankedRow], 1); + + const result = await findPetLeaderboard({ chain: 'solana', page: 0, pageSize: 20 }); + + expect(result.entries[0].rank).toBe(1); + expect(sqlOfCall(0)).toContain('LEFT JOIN pet_battle_progress p'); + expect(paramsOfCall(0)).toContain(null); + // No separate roster-only code path exists any more. + expect(prisma.petRoster.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('findPlayerLeaderboard', () => { + const playerRow = { rank: 1, owner: '0xowner', winCount: 20, lossCount: 4, petCount: 3 }; + + it('returns the ranked owners and their total', async () => { + mockJoinQuery([playerRow], 1); + + const result = await findPlayerLeaderboard({ chain: 'evm', page: 0, pageSize: 20 }); + + expect(result.total).toBe(1); + expect(result.entries[0]).toMatchObject({ owner: '0xowner', rank: 1, petCount: 3 }); + }); + + it('reports the rank the query assigned', async () => { + mockJoinQuery([{ ...playerRow, rank: 21 }, { ...playerRow, owner: '0xother', rank: 22 }], 30); + + const result = await findPlayerLeaderboard({ chain: 'evm', page: 1, pageSize: 20 }); + + expect(result.entries.map((entry) => entry.rank)).toEqual([21, 22]); + }); + + it('ranks over the whole board before a search narrows it', async () => { + mockJoinQuery([], 0); + + await findPlayerLeaderboard({ chain: 'evm', page: 0, pageSize: 20, search: '0xowner' }); + + const sql = sqlOfCall(0); + expect(sql.indexOf('ILIKE')).toBeGreaterThan(sql.indexOf('ROW_NUMBER() OVER')); + }); + + // Aggregated in the subquery because the filter runs outside it, after the grouping: + // looking up the player behind a pet should not require knowing whose it is. + it("matches a term against the address or the owner's pet names", async () => { + mockJoinQuery([], 0); + + await findPlayerLeaderboard({ chain: 'evm', page: 0, pageSize: 20, search: 'Rex' }); + + const sql = sqlOfCall(0); + expect(sql).toContain('STRING_AGG(r.name'); + expect(sql).toContain('ranked."petNames" ILIKE'); + }); + + it('keeps the searchable pet names out of what it returns', async () => { + mockJoinQuery([{ ...playerRow, petNames: 'Rex Bramble' }], 1); + + const result = await findPlayerLeaderboard({ chain: 'evm', page: 0, pageSize: 20 }); + + expect(result.entries[0]).not.toHaveProperty('petNames'); + }); + + it('folds EVM owners to one group so a wallet is not listed twice', async () => { + // indexer-go is not guaranteed to write the roster in one case, and an unfolded + // group would split a single wallet's record across two rows. + mockJoinQuery([], 0); + + await findPlayerLeaderboard({ chain: 'evm', page: 0, pageSize: 20 }); + + expect(sqlOfCall(0)).toContain('LOWER(r.owner)'); + }); + + it('leaves Solana pubkeys unfolded, since base58 is case-significant', async () => { + // Folding here would merge two distinct pubkeys into one player. + mockJoinQuery([], 0); + + await findPlayerLeaderboard({ chain: 'solana', page: 0, pageSize: 20 }); + + const sql = sqlOfCall(0); + expect(sql).toContain('r.owner'); + expect(sql).not.toContain('LOWER('); + }); + + it('sums the merged record and counts owners, not pets', async () => { + mockJoinQuery([], 0); + + await findPlayerLeaderboard({ chain: 'evm', page: 0, pageSize: 20 }); + + const sql = sqlOfCall(0); + expect(sql).toContain('SUM(COALESCE(p.win_count, r.win_count))::int'); + expect(sql).toContain('SUM(COALESCE(p.loss_count, r.loss_count))::int'); + expect(sql).toContain('ORDER BY SUM(COALESCE(p.win_count, r.win_count)) DESC'); + // The total counts grouped owners: COUNT(*) over the ungrouped join would count + // pets, and page the client past the last owner. + expect(sqlOfCall(1)).toContain('SELECT COUNT(*) AS total FROM ('); + expect(sqlOfCall(1)).toContain('GROUP BY'); + }); +}); + +describe('findPlayerRank', () => { + const rankedRowForOwner = { rank: 4, owner: '0xowner', winCount: 8, lossCount: 4, petCount: 3 }; + + it('returns the caller row with its rank', async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([rankedRowForOwner] as never); + + expect(await findPlayerRank('evm', '0xowner')).toEqual(rankedRowForOwner); + }); + + it('returns null for a player holding no pet that has fought', async () => { + // Unranked is a real answer. A zeroed row could not be told apart from a player + // who has fought and lost everything. + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([] as never); + + expect(await findPlayerRank('evm', '0xowner')).toBeNull(); + }); + + it('does not query at all for an unauthenticated caller', async () => { + expect(await findPlayerRank('evm', '')).toBeNull(); + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + }); + + // The board and the rank are ordered by one shared fragment. If they ever diverged, a + // player's stated rank would stop matching where they actually appear. + it('ranks with ROW_NUMBER over the same ordering the paged board uses', async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([] as never); + await findPlayerRank('evm', '0xowner'); + const rankSql = sqlOfCall(0); + + vi.mocked(prisma.$queryRaw).mockClear(); + mockJoinQuery([], 0); + await findPlayerLeaderboard({ chain: 'evm', page: 0, pageSize: 20 }); + const boardSql = sqlOfCall(0); + + const ordering = 'SUM(COALESCE(p.win_count, r.win_count)) DESC, SUM(COALESCE(p.loss_count, r.loss_count)) ASC, LOWER(r.owner) ASC'; + expect(rankSql).toContain(`ROW_NUMBER() OVER (ORDER BY ${ordering})`); + expect(boardSql).toContain(`ORDER BY ${ordering}`); + }); + + it('ranks on frozen counters for a chain family this deployment does not serve', async () => { + servedChainIdForFamily.mockReturnValue(null); + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([] as never); + + await findPlayerRank('solana', 'SoLpubkey'); + + const sql = sqlOfCall(0); + expect(sql).toContain('ROW_NUMBER() OVER ('); + expect(paramsOfCall(0)).toContain(null); + // base58 stays unfolded here too, for the reason the board folds only EVM. + expect(sql).not.toContain('LOWER('); + }); +}); diff --git a/backend/tests/ws/battleRoomSocket.test.ts b/backend/tests/ws/battleRoomSocket.test.ts index c5f85024..1f0b7680 100644 --- a/backend/tests/ws/battleRoomSocket.test.ts +++ b/backend/tests/ws/battleRoomSocket.test.ts @@ -2,12 +2,9 @@ import { createServer, type Server } from 'node:http'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import WebSocket from 'ws'; -import { - notifyBattleRoom, - notifyBattleRoomIfPresent, - startBattleRoomSocket, - stopBattleRoomSocket, -} from '@ws/battleRoomSocket'; +import '@ws/chatSocket'; // registers the other channel, as the real server does +import { notifyBattleRoom, notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; +import { startWsChannels, stopWsChannels } from '@ws/channel'; /** * Real HTTP server + real `ws` clients, not mocks: the property under test is @@ -21,7 +18,7 @@ let baseUrl: string; beforeEach(async () => { server = createServer(); - startBattleRoomSocket(server); + startWsChannels(server); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); if (address === null || typeof address === 'string') { @@ -31,7 +28,7 @@ beforeEach(async () => { }); afterEach(async () => { - stopBattleRoomSocket(); + stopWsChannels(); await new Promise((resolve) => server.close(() => resolve())); }); @@ -113,10 +110,15 @@ describe('room scoping', () => { }); describe('connection requirements', () => { - it('closes the connection when roomId is missing', async () => { + it('refuses the connection when roomId is missing', async () => { + // Refused at the upgrade now, so this is a transport error rather than a close + // code — see the equivalent note in chatSocket.test.ts. const socket = new WebSocket(baseUrl); - const closed = nextClose(socket); - const { code } = await closed; - expect(code).toBe(1008); + const outcome = await new Promise((resolve) => { + socket.once('open', () => resolve('open')); + socket.once('error', () => resolve('refused')); + socket.once('close', () => resolve('refused')); + }); + expect(outcome).toBe('refused'); }); }); diff --git a/backend/tests/ws/channel.test.ts b/backend/tests/ws/channel.test.ts new file mode 100644 index 00000000..14dd782b --- /dev/null +++ b/backend/tests/ws/channel.test.ts @@ -0,0 +1,118 @@ +import { createServer, type Server } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import jwt from 'jsonwebtoken'; +import WebSocket from 'ws'; + +// Chat authorizes its upgrades now; the battle room does not. Stubbing the marriage gate +// keeps this file about channel routing rather than about chat's rules. +vi.mock('@features/chat/chat.service', () => ({ + authorizeThread: vi.fn(async () => null), +})); + +import { notifyBattleRoom } from '@ws/battleRoomSocket'; +import { notifyChatThread } from '@ws/chatSocket'; +import { startWsChannels, stopWsChannels } from '@ws/channel'; + +/** + * The case the per-channel tests cannot see: both channels on **one** HTTP server, which + * is what the real process runs. + * + * Each channel used to construct its own `WebSocketServer({ server, path })`, which + * attaches one upgrade listener per instance. Node calls every listener for every + * upgrade, so with two channels each connection was handled twice, the client received + * two HTTP 101 responses, and the second was parsed as a frame — + * `RangeError: Invalid WebSocket frame: RSV1 must be clear`. It broke *both* channels, + * and every existing test passed because each gave its socket a private server. + */ + +let server: Server; +let base: string; + +beforeEach(async () => { + server = createServer(); + startWsChannels(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('expected a bound TCP address'); + } + base = `ws://127.0.0.1:${address.port}`; +}); + +afterEach(async () => { + stopWsChannels(); + await new Promise((resolve) => server.close(() => resolve())); +}); + +const AUTH_PROTOCOL = 'cryptopets-auth'; +const token = () => + jwt.sign( + { address: '0x1111111111111111111111111111111111111111', userId: 'u' }, + process.env.JWT_SECRET as string, + { expiresIn: '5m' }, + ); + +function open(url: string): Promise { + return new Promise((resolve, reject) => { + // The token is ignored by the unauthenticated channel and required by chat, so + // both connections are opened the same way. + const socket = new WebSocket(url, [AUTH_PROTOCOL, token()]); + socket.once('open', () => resolve(socket)); + socket.once('error', reject); + }); +} + +/** Skips presence frames, which chat emits unprompted on join. */ +function nextMessage(socket: WebSocket): Promise> { + return new Promise((resolve, reject) => { + const onMessage = (data: Buffer) => { + const message = JSON.parse(data.toString()) as Record; + if (message.type === 'presence') return; + socket.off('message', onMessage); + resolve(message); + }; + socket.on('message', onMessage); + socket.once('error', reject); + socket.once('close', (code) => reject(new Error(`closed before a message: ${code}`))); + }); +} + +describe('two channels on one server', () => { + it('accepts a connection on each path', async () => { + const room = await open(`${base}/ws/battle-room?roomId=r1`); + const chat = await open(`${base}/ws/chat?threadId=t1`); + + expect(room.readyState).toBe(WebSocket.OPEN); + expect(chat.readyState).toBe(WebSocket.OPEN); + + room.close(); + chat.close(); + }); + + it('delivers each channel its own traffic and never the other channel traffic', async () => { + const room = await open(`${base}/ws/battle-room?roomId=r1`); + const chat = await open(`${base}/ws/chat?threadId=t1`); + const roomMessage = nextMessage(room); + const chatMessage = nextMessage(chat); + + notifyChatThread('t1', { type: 'thread-updated', threadId: 't1', messageId: 1 }); + notifyBattleRoom('r1', { type: 'battle-updated', battleId: 'b1', state: 'signed' }); + + // Each listener's first frame must be its own channel's: if the chat notification + // reached the room listener, this resolves to the wrong shape rather than timing + // out, so the assertion catches cross-talk directly. + await expect(chatMessage).resolves.toMatchObject({ type: 'thread-updated' }); + await expect(roomMessage).resolves.toMatchObject({ type: 'battle-updated' }); + + room.close(); + chat.close(); + }); + + it('refuses an unknown path without disturbing the known ones', async () => { + await expect(open(`${base}/ws/nope?roomId=r1`)).rejects.toThrow(); + + const chat = await open(`${base}/ws/chat?threadId=t1`); + expect(chat.readyState).toBe(WebSocket.OPEN); + chat.close(); + }); +}); diff --git a/backend/tests/ws/chatSocket.test.ts b/backend/tests/ws/chatSocket.test.ts new file mode 100644 index 00000000..cc9f02db --- /dev/null +++ b/backend/tests/ws/chatSocket.test.ts @@ -0,0 +1,239 @@ +import { createServer, type Server } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import jwt from 'jsonwebtoken'; +import WebSocket from 'ws'; + +// The channel authorizes against the real service, so the marriage gate is stubbed while +// the JWT stays genuine — the token path is part of what these tests cover. +const authorizeThread = vi.fn(); +vi.mock('@features/chat/chat.service', () => ({ + authorizeThread: (threadId: string, caller: string) => authorizeThread(threadId, caller), +})); + +import '@ws/battleRoomSocket'; // registers the other channel, as the real server does +import { notifyChatThread } from '@ws/chatSocket'; +import { startWsChannels, stopWsChannels } from '@ws/channel'; + +/** + * Real server and real clients, because the property under test is delivery over the + * wire: a listener on thread A must never receive thread B's notification, what arrives + * must contain no message text, and an unauthorized client must not connect at all. + */ + +const ME = '0x1111111111111111111111111111111111111111'; +const THEM = '0x2222222222222222222222222222222222222222'; +const AUTH_PROTOCOL = 'cryptopets-auth'; + +let server: Server; +let baseUrl: string; + +const tokenFor = (address: string) => + jwt.sign({ address, userId: address }, process.env.JWT_SECRET as string, { expiresIn: '5m' }); + +beforeEach(async () => { + vi.clearAllMocks(); + authorizeThread.mockResolvedValue(null); // a null denial means allowed + server = createServer(); + startWsChannels(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('expected a bound TCP address'); + } + baseUrl = `ws://127.0.0.1:${address.port}/ws/chat`; +}); + +afterEach(async () => { + stopWsChannels(); + await new Promise((resolve) => server.close(() => resolve())); +}); + +function connect(threadId: string | null, address = ME): Promise { + return new Promise((resolve, reject) => { + const url = threadId === null ? baseUrl : `${baseUrl}?threadId=${threadId}`; + const socket = new WebSocket(url, [AUTH_PROTOCOL, tokenFor(address)]); + socket.once('open', () => resolve(socket)); + socket.once('error', reject); + }); +} + +/** The next frame that is not presence — presence arrives unprompted on join and leave. */ +function nextUpdate(socket: WebSocket): Promise> { + return new Promise((resolve, reject) => { + const onMessage = (data: Buffer) => { + const message = JSON.parse(data.toString()) as Record; + if (message.type === 'presence') return; + socket.off('message', onMessage); + resolve(message); + }; + socket.on('message', onMessage); + socket.once('close', (code) => reject(new Error(`closed before a message: ${code}`))); + }); +} + +function nextClose(socket: WebSocket): Promise { + return new Promise((resolve) => socket.once('close', (code) => resolve(code))); +} + +/** Refusals happen at the upgrade, so they surface as an error rather than a close code. */ +function outcomeOf(socket: WebSocket): Promise { + return new Promise((resolve) => { + socket.once('open', () => resolve('open')); + socket.once('error', () => resolve('refused')); + socket.once('close', () => resolve('refused')); + }); +} + +const update = { type: 'thread-updated', threadId: 'thread_1', messageId: 7 } as const; + +describe('thread scoping', () => { + it('delivers only to listeners on that thread', async () => { + const onThread = await connect('thread_1'); + const onOther = await connect('thread_2'); + const otherMessage = nextUpdate(onOther); + + const received = nextUpdate(onThread); + notifyChatThread('thread_1', update); + await expect(received).resolves.toEqual(update); + + // Prove the negative without a timeout: race the other listener against a + // notification for its own thread, which must be the first thing it sees. + const ownUpdate = { type: 'thread-updated', threadId: 'thread_2', messageId: 9 } as const; + notifyChatThread('thread_2', ownUpdate); + await expect(otherMessage).resolves.toEqual(ownUpdate); + + onThread.close(); + onOther.close(); + }); + + it('delivers to both participants watching the same thread', async () => { + const first = await connect('thread_1', ME); + const second = await connect('thread_1', THEM); + const firstMessage = nextUpdate(first); + const secondMessage = nextUpdate(second); + + notifyChatThread('thread_1', update); + + await expect(firstMessage).resolves.toEqual(update); + await expect(secondMessage).resolves.toEqual(update); + + first.close(); + second.close(); + }); + + // The safety argument for the channel: what it pushes is not readable content, so a + // listener still has to pass the authenticated read. + it('carries no message text', async () => { + const socket = await connect('thread_1'); + const received = nextUpdate(socket); + + notifyChatThread('thread_1', update); + + expect(Object.keys(await received).sort()).toEqual(['messageId', 'threadId', 'type']); + socket.close(); + }); + + it('does not throw when nobody is listening', () => { + // The normal case: most messages arrive while the recipient has the app closed. + expect(() => notifyChatThread('quiet', update)).not.toThrow(); + }); + + it('stops delivering to a client after it disconnects', async () => { + const socket = await connect('thread_1'); + socket.close(); + await nextClose(socket); + + expect(() => notifyChatThread('thread_1', update)).not.toThrow(); + }); +}); + +describe('presence', () => { + /** Records presence rosters as they arrive. */ + function presenceLog(socket: WebSocket): string[][] { + const seen: string[][] = []; + socket.on('message', (data: Buffer) => { + const message = JSON.parse(data.toString()) as { type: string; online?: string[] }; + if (message.type === 'presence' && message.online) seen.push(message.online); + }); + return seen; + } + + const settle = () => new Promise((resolve) => setTimeout(resolve, 80)); + + it('announces an arrival to whoever is already there', async () => { + const first = await connect('thread_1', ME); + const seen = presenceLog(first); + await settle(); + + const second = await connect('thread_1', THEM); + await settle(); + + // This is what turns the dot green without a reload. + expect(seen.at(-1)).toEqual(expect.arrayContaining([ME, THEM])); + + first.close(); + second.close(); + }); + + it('drops someone from the roster when they disconnect', async () => { + const first = await connect('thread_1', ME); + const second = await connect('thread_1', THEM); + const seen = presenceLog(first); + await settle(); + + second.close(); + await settle(); + + expect(seen.at(-1)).toEqual([ME]); + first.close(); + }); + + // Why presence counts identities rather than sockets: one person with two tabs open + // must not look like two people, and closing one tab must not report them as gone. + it('treats two connections from one wallet as one person', async () => { + const first = await connect('thread_1', ME); + const seen = presenceLog(first); + const secondTab = await connect('thread_1', ME); + await settle(); + + expect(seen.at(-1)).toEqual([ME]); + + secondTab.close(); + await settle(); + expect(seen.at(-1)).toEqual([ME]); + + first.close(); + }); +}); + +describe('connection requirements', () => { + it('refuses a connection that names no thread', async () => { + expect(await outcomeOf(new WebSocket(baseUrl, [AUTH_PROTOCOL, tokenFor(ME)]))).toBe( + 'refused' + ); + }); + + it('refuses a connection with no token', async () => { + expect(await outcomeOf(new WebSocket(`${baseUrl}?threadId=thread_1`))).toBe('refused'); + expect(authorizeThread).not.toHaveBeenCalled(); + }); + + it('refuses a connection with a forged token', async () => { + const forged = jwt.sign({ address: ME, userId: ME }, 'not-the-secret'); + const socket = new WebSocket(`${baseUrl}?threadId=thread_1`, [AUTH_PROTOCOL, forged]); + + expect(await outcomeOf(socket)).toBe('refused'); + // Rejected on the signature, before the marriage gate is even consulted. + expect(authorizeThread).not.toHaveBeenCalled(); + }); + + // Authenticated but not a participant: the same gate the HTTP routes apply, so a + // socket can never subscribe to a thread its holder could not read. + it('refuses a valid token for a thread the caller is not in', async () => { + authorizeThread.mockResolvedValue('not-a-participant'); + const socket = new WebSocket(`${baseUrl}?threadId=thread_1`, [AUTH_PROTOCOL, tokenFor(ME)]); + + expect(await outcomeOf(socket)).toBe('refused'); + expect(authorizeThread).toHaveBeenCalledWith('thread_1', ME); + }); +}); diff --git a/bash.exe.stackdump b/bash.exe.stackdump new file mode 100644 index 00000000..e15c61ae --- /dev/null +++ b/bash.exe.stackdump @@ -0,0 +1,9 @@ +Stack trace: +Frame Function Args +000FFFFA3C0 00210062B0E (00210297158, 00210275E3E, 000FFFFA3C0, 000FFFF92C0) +000FFFFA3C0 0021004846A (00000000000, 00000000000, 00000000000, 00000000004) +000FFFFA3C0 002100484A2 (00210297209, 000FFFFA278, 000FFFFA3C0, 00000000000) +000FFFFA3C0 002100D2FFE (00000000000, 00000000000, 00000000000, 00000000000) +000FFFFA3C0 002100D3125 (000FFFFA3D0, 00000000000, 00000000000, 00000000000) +001004F84B7 002100D46E5 (000FFFFA3D0, 00000000000, 00000000000, 00000000000) +End of stack trace diff --git a/contracts/ethereum/src/BattleBatchRegistry.sol b/contracts/ethereum/src/BattleBatchRegistry.sol index 33faf86d..69057096 100644 --- a/contracts/ethereum/src/BattleBatchRegistry.sol +++ b/contracts/ethereum/src/BattleBatchRegistry.sol @@ -7,7 +7,7 @@ import "@openzeppelin/contracts/utils/Pausable.sol"; /** * @title BattleBatchRegistry * @notice Immutable publication record for batches of backend-resolved battle receipts. - * @dev docs/plan-backend-battle-architecture.md §I. Deliberately minimal: this contract + * @dev docs/battle-protocol.md §I. Deliberately minimal: this contract * stores roots and nothing else. It does not verify proofs, hold funds, or know what * a reward is — the claim path is a separate contract, so the thing every player's * history is anchored against stays small enough to audit in one sitting. diff --git a/contracts/ethereum/src/CryptoPetsToken.sol b/contracts/ethereum/src/CryptoPetsToken.sol index 8f91691f..3f4cf276 100644 --- a/contracts/ethereum/src/CryptoPetsToken.sol +++ b/contracts/ethereum/src/CryptoPetsToken.sol @@ -6,7 +6,7 @@ import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; /** * @title CryptoPetsToken * @notice The CPET reward token. Fixed supply, minted once at deployment. - * @dev Funds `SeasonRewardDistributor` (docs/plan-backend-battle-architecture.md §I). + * @dev Funds `SeasonRewardDistributor` (docs/battle-protocol.md §I). * * **There is no mint function, and no owner.** §I's whole argument is that rewards are * bounded: the distributor caps what one wallet and one season may pay, and those caps diff --git a/contracts/ethereum/src/SeasonRewardDistributor.sol b/contracts/ethereum/src/SeasonRewardDistributor.sol index 0bdbae47..efad1b09 100644 --- a/contracts/ethereum/src/SeasonRewardDistributor.sol +++ b/contracts/ethereum/src/SeasonRewardDistributor.sol @@ -9,7 +9,7 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; /** * @title SeasonRewardDistributor * @notice Capped, one-time reward claims against a per-season Merkle root. - * @dev docs/plan-backend-battle-architecture.md §I. Separate from BattleBatchRegistry on + * @dev docs/battle-protocol.md §I. Separate from BattleBatchRegistry on * purpose: that contract is the immutable record of what happened and must stay * minimal, while this one holds funds. Keeping the ledger away from the money means a * bug here cannot corrupt the history, and a pause here cannot stop battles. diff --git a/contracts/ethereum/subgraph/src/mapping.ts b/contracts/ethereum/subgraph/src/mapping.ts index 1aa060ac..ec0c4efb 100644 --- a/contracts/ethereum/subgraph/src/mapping.ts +++ b/contracts/ethereum/subgraph/src/mapping.ts @@ -4,7 +4,7 @@ // recover the parents that BreedSettled omits. // // No battle handlers: battles are resolved by the backend and published as signed -// receipts (docs/plan-backend-battle-architecture.md), never as chain events. +// receipts (docs/battle-protocol.md), never as chain events. import { NewPet, diff --git a/contracts/ethereum/test/BattleBatchRegistry.test.ts b/contracts/ethereum/test/BattleBatchRegistry.test.ts index b843ca67..77a0e89e 100644 --- a/contracts/ethereum/test/BattleBatchRegistry.test.ts +++ b/contracts/ethereum/test/BattleBatchRegistry.test.ts @@ -30,7 +30,7 @@ async function rejectsWithError(promise: Promise, signature: string): P } /** - * BattleBatchRegistry (docs/plan-backend-battle-architecture.md §I). + * BattleBatchRegistry (docs/battle-protocol.md §I). * * The contract's only real guarantee is ordering: batches are append-only, linked, and * sequence-contiguous. Most of what follows is aimed at that, because a registry that diff --git a/contracts/ethereum/test/SeasonRewardDistributor.test.ts b/contracts/ethereum/test/SeasonRewardDistributor.test.ts index 096f18e5..f68ec003 100644 --- a/contracts/ethereum/test/SeasonRewardDistributor.test.ts +++ b/contracts/ethereum/test/SeasonRewardDistributor.test.ts @@ -6,7 +6,7 @@ import { network } from "hardhat"; import { toFunctionSelector } from "viem"; /** - * SeasonRewardDistributor (docs/plan-backend-battle-architecture.md §I). + * SeasonRewardDistributor (docs/battle-protocol.md §I). * * Two things are being tested. First, that the Solidity leaf encoding is byte-identical to * the protocol's `rewardMerkleLeaf` — if it is not, no proof the backend ever builds will diff --git a/contracts/test-vectors/protocol-commitment.json b/contracts/test-vectors/protocol-commitment.json index 402c9e15..9f2e043c 100644 --- a/contracts/test-vectors/protocol-commitment.json +++ b/contracts/test-vectors/protocol-commitment.json @@ -1,5 +1,5 @@ { - "description": "BattleCommitment canonical-hash vectors (docs/plan-backend-battle-architecture.md §E). Generated by protocol/scripts/gen-vectors.ts from protocol/src/commitment. The snapshot enters the hash as snapshotHash, while the payload carries the full snapshot. A failure means the implementation drifted. Never edit an expectation to match new output.", + "description": "BattleCommitment canonical-hash vectors (docs/battle-protocol.md §E). Generated by protocol/scripts/gen-vectors.ts from protocol/src/commitment. The snapshot enters the hash as snapshotHash, while the payload carries the full snapshot. A failure means the implementation drifted. Never edit an expectation to match new output.", "cases": [ { "name": "baseline", diff --git a/contracts/test-vectors/protocol-consent.json b/contracts/test-vectors/protocol-consent.json index c0cf7651..9ee10640 100644 --- a/contracts/test-vectors/protocol-consent.json +++ b/contracts/test-vectors/protocol-consent.json @@ -1,5 +1,5 @@ { - "description": "DefenseAuthorization canonical-hash and Solana sign-message vectors (docs/plan-backend-battle-architecture.md §D). Generated by protocol/scripts/gen-vectors.ts from protocol/src/consent. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.", + "description": "DefenseAuthorization canonical-hash and Solana sign-message vectors (docs/battle-protocol.md §D). Generated by protocol/scripts/gen-vectors.ts from protocol/src/consent. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.", "cases": [ { "name": "evm-all-pets", diff --git a/contracts/test-vectors/protocol-intent.json b/contracts/test-vectors/protocol-intent.json index f6103d6b..b82384ed 100644 --- a/contracts/test-vectors/protocol-intent.json +++ b/contracts/test-vectors/protocol-intent.json @@ -1,5 +1,5 @@ { - "description": "BattleIntent canonical-hash and Solana sign-message vectors (docs/plan-backend-battle-architecture.md §D). Generated by protocol/scripts/gen-vectors.ts from protocol/src/intent. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.", + "description": "BattleIntent canonical-hash and Solana sign-message vectors (docs/battle-protocol.md §D). Generated by protocol/scripts/gen-vectors.ts from protocol/src/intent. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.", "cases": [ { "name": "evm-direct-challenge", diff --git a/contracts/test-vectors/protocol-merkle.json b/contracts/test-vectors/protocol-merkle.json index 2dd5d1aa..6f7255db 100644 --- a/contracts/test-vectors/protocol-merkle.json +++ b/contracts/test-vectors/protocol-merkle.json @@ -1,5 +1,5 @@ { - "description": "Merkle leaf, root, and proof vectors (docs/plan-backend-battle-architecture.md §I). Generated by protocol/scripts/gen-vectors.ts from protocol/src/merkle. Layout notes for a Solidity implementation: leaf = keccak256(LEAF_DOMAIN || uint16 schemaVersion || receiptHash); node = keccak256(NODE_DOMAIN || min(a,b) || max(a,b)); all elements are fixed 32 bytes so abi.encodePacked matches; pairs are sorted so proofs carry no direction flags; an odd node is promoted unchanged rather than paired with itself. A failure means the implementation drifted. Never edit an expectation to match new output.", + "description": "Merkle leaf, root, and proof vectors (docs/battle-protocol.md §I). Generated by protocol/scripts/gen-vectors.ts from protocol/src/merkle. Layout notes for a Solidity implementation: leaf = keccak256(LEAF_DOMAIN || uint16 schemaVersion || receiptHash); node = keccak256(NODE_DOMAIN || min(a,b) || max(a,b)); all elements are fixed 32 bytes so abi.encodePacked matches; pairs are sorted so proofs carry no direction flags; an odd node is promoted unchanged rather than paired with itself. A failure means the implementation drifted. Never edit an expectation to match new output.", "domains": { "leaf": "0xea935f3622687fcaec0f16c38a011768b07df58a102da1ee85881dc236e9b935", "node": "0xfc3a24400652b1e1b0cbd8aa53656a5a5af5f5ab615d7faeb2c37dd511e09ca1", diff --git a/contracts/test-vectors/protocol-progression.json b/contracts/test-vectors/protocol-progression.json index b249802f..84a39fb8 100644 --- a/contracts/test-vectors/protocol-progression.json +++ b/contracts/test-vectors/protocol-progression.json @@ -1,5 +1,5 @@ { - "description": "Progression-delta vectors in the frozen-snapshot shape (docs/plan-backend-battle-architecture.md §F). Generated by protocol/scripts/gen-vectors.ts from protocol/src/progression. The XP formula and decay themselves are pinned cross-language by contracts/test-vectors/xp.json; these cases pin the composition (which base applies to whom, which decay shift, and the level-threshold interaction). A failure means the implementation drifted. Never edit an expectation to match new output.", + "description": "Progression-delta vectors in the frozen-snapshot shape (docs/battle-protocol.md §F). Generated by protocol/scripts/gen-vectors.ts from protocol/src/progression. The XP formula and decay themselves are pinned cross-language by contracts/test-vectors/xp.json; these cases pin the composition (which base applies to whom, which decay shift, and the level-threshold interaction). A failure means the implementation drifted. Never edit an expectation to match new output.", "cases": [ { "name": "attacker-wins-fresh", diff --git a/contracts/test-vectors/protocol-receipt.json b/contracts/test-vectors/protocol-receipt.json index e263b4c9..217ee3dc 100644 --- a/contracts/test-vectors/protocol-receipt.json +++ b/contracts/test-vectors/protocol-receipt.json @@ -1,5 +1,5 @@ { - "description": "BattleReceipt canonical-hash vectors (docs/plan-backend-battle-architecture.md §G). Generated by protocol/scripts/gen-vectors.ts from protocol/src/receipt. Each case is a coherent receipt: real quicknet beacons, a seed derived from the receipt own inputs, a combat-log hash from an actual simulation, and a recomputed progression delta. Derived fields are recorded so a reader can see what the encoding covered. A failure means the implementation drifted. Never edit an expectation to match new output.", + "description": "BattleReceipt canonical-hash vectors (docs/battle-protocol.md §G). Generated by protocol/scripts/gen-vectors.ts from protocol/src/receipt. Each case is a coherent receipt: real quicknet beacons, a seed derived from the receipt own inputs, a combat-log hash from an actual simulation, and a recomputed progression delta. Derived fields are recorded so a reader can see what the encoding covered. A failure means the implementation drifted. Never edit an expectation to match new output.", "cases": [ { "name": "first-receipt-under-key", diff --git a/contracts/test-vectors/protocol-ruleset.json b/contracts/test-vectors/protocol-ruleset.json index 43b7b2ef..86393475 100644 --- a/contracts/test-vectors/protocol-ruleset.json +++ b/contracts/test-vectors/protocol-ruleset.json @@ -1,5 +1,5 @@ { - "description": "Ruleset canonical-hash vectors (docs/plan-backend-battle-architecture.md §F, §H). Generated by protocol/scripts/gen-vectors.ts from protocol/src/ruleset. A ruleset hash is chain-agnostic on purpose: the same rules can run on either chain. A failure means the implementation drifted. Never edit an expectation to match new output.", + "description": "Ruleset canonical-hash vectors (docs/battle-protocol.md §F, §H). Generated by protocol/scripts/gen-vectors.ts from protocol/src/ruleset. A ruleset hash is chain-agnostic on purpose: the same rules can run on either chain. A failure means the implementation drifted. Never edit an expectation to match new output.", "cases": [ { "name": "source-defaults", diff --git a/contracts/test-vectors/protocol-seed.json b/contracts/test-vectors/protocol-seed.json index 2681ff68..b265d309 100644 --- a/contracts/test-vectors/protocol-seed.json +++ b/contracts/test-vectors/protocol-seed.json @@ -1,5 +1,5 @@ { - "description": "Battle seed derivation vectors (docs/plan-backend-battle-architecture.md §E). Generated by protocol/scripts/gen-vectors.ts from protocol/src/randomness. The layout is length-prefixed via the canonical encoder rather than the bare concatenation §E sketches; field order matches §E exactly. A failure means the implementation drifted, and every historical battle depends on this layout. Never edit an expectation to match new output.", + "description": "Battle seed derivation vectors (docs/battle-protocol.md §E). Generated by protocol/scripts/gen-vectors.ts from protocol/src/randomness. The layout is length-prefixed via the canonical encoder rather than the bare concatenation §E sketches; field order matches §E exactly. A failure means the implementation drifted, and every historical battle depends on this layout. Never edit an expectation to match new output.", "cases": [ { "name": "baseline", diff --git a/contracts/test-vectors/protocol-snapshot.json b/contracts/test-vectors/protocol-snapshot.json index 1f82e437..d1b248b9 100644 --- a/contracts/test-vectors/protocol-snapshot.json +++ b/contracts/test-vectors/protocol-snapshot.json @@ -1,5 +1,5 @@ { - "description": "BattleSnapshot canonical-hash vectors (docs/plan-backend-battle-architecture.md §C, §F). Generated by protocol/scripts/gen-vectors.ts from protocol/src/snapshot. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.", + "description": "BattleSnapshot canonical-hash vectors (docs/battle-protocol.md §C, §F). Generated by protocol/scripts/gen-vectors.ts from protocol/src/snapshot. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.", "cases": [ { "name": "evm-baseline", diff --git a/docs/README.md b/docs/README.md index b72bf89d..248abfa3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,14 @@ package-specific docs live next to their code and are linked below. - [Testing strategy](./testing.md) — how tests and coverage are organized - [Development guide](../DEVELOPMENT.md) — setup, commands, env vars, local chains +## Cross-cutting docs + +| Doc | What it is | +| --- | --- | +| [Battle protocol](./battle-protocol.md) | The shipped backend-authoritative battle system. Part 1 plain words, Part 2 spec (§A–§M), Appendix A threat model, B operations runbook, C key-compromise runbook. | +| [Future features roadmap](./plan-future-features-roadmap.md) | Brainstorm for eleven unbuilt features. Not a build spec. | +| [Testing](./testing.md) | Per-package suite table and conventions. | + ## Package docs | Area | Doc | diff --git a/docs/plan-backend-battle-architecture.md b/docs/battle-protocol.md similarity index 51% rename from docs/plan-backend-battle-architecture.md rename to docs/battle-protocol.md index ff151954..6ffbbd13 100644 --- a/docs/plan-backend-battle-architecture.md +++ b/docs/battle-protocol.md @@ -1,21 +1,27 @@ -# Backend-authoritative battle architecture +# The CryptoPets battle protocol -Status: proposed architecture, not an implementation spec. +Status: implemented and live. This is the specification of the shipped system, not a proposal. +Where the text describes work as upcoming (§K, §L), read it as the record of how the migration +was sequenced. -This document has two halves. +This document has two halves and three appendices. - **Part 1** explains the whole design in plain words, with no jargon. Read it even if you plan to read Part 2. It takes about ten minutes. - **Part 2** is the precise specification: field lists, state machines, hash definitions, phases. Every section there starts with a one-line plain-words summary, so you can skim. +- **Appendix A** is the threat model, **B** the operations runbook, **C** the signing-key + compromise procedure. | If you want | Go to | |---|---| -| To understand what we are building and why | Part 1 | -| To decide whether to approve it | Part 1, then §A trust model | -| To build it | Part 2 | -| What to build first | §L implementation order | -| Unfamiliar word | Glossary at the end | +| To understand what we built and why | Part 1 | +| To evaluate the trust assumptions | Part 1, then §A trust model | +| To implement against it or verify a receipt | Part 2 | +| To attack it, or to review how we defend it | Appendix A | +| To operate it, or run a drill | Appendix B | +| To respond to a suspected key compromise | Appendix C | +| Unfamiliar word | Glossary at the end of Part 2 | --- @@ -732,7 +738,7 @@ The four-port combat rule is a `MUST` in `AGENTS.md`, restated in `CLAUDE.md`. I roadmap guidance, so accepting this document does not relax it. Amend both files **at Phase 6**, when the legacy on-chain path actually retires, not at acceptance. -**Done (Step 40).** The amendment split the four ports rather than loosening the rule: `CombatSim.sol` +**Done.** The amendment split the four ports rather than loosening the rule: `CombatSim.sol` and `combat.rs` are now `MUST NOT` change — frozen, because the battles they settled are permanent records that must stay replayable — while `protocol/src/combat/` and `services/indexer-go/internal/combat/` are `MUST` change together, since §F's circuit breaker depends on the two being independent. All four @@ -861,3 +867,666 @@ risk well beyond the battle problem. Not worth introducing solely to reduce comb | **Merkle proof** | The short evidence that your receipt was in that pile. | | **Nullifier** | A one-time marker preventing a reward from being claimed twice. | | **Equivocation** | Signing two conflicting statements about the same battle. Provable cheating. | + +--- + +# Appendix A: threat model + +Scope: the backend battle path specified in Part 2 above. The existing on-chain path +(`GameLogic.sol`, `settle_battle.rs`) keeps its own properties and is not re-analysed here. + +This appendix exists because moving battle resolution off-chain moves it inside our trust +boundary. §A states the trust model in one table; this is the expanded version: who attacks +what, what stops them, how we notice, and what is left over. + +## 1. Assets + +| Asset | Why it matters | Authority | +|---|---|---| +| Pet and item ownership | Transferable value | Chain | +| Reward custody and claims | Transferable value | Chain | +| Battle signing key | Signs commitments and receipts; forgery source | KMS | +| Root publisher key | Anchors batches on-chain | KMS + multisig | +| Off-chain progression (XP, rating, streak) | Determines rewards and matchmaking | Backend | +| Signed receipt corpus | The evidence players hold against us | Backend, published | +| Commitment sequence | Proves which round each battle was bound to | Backend, delivered to players | + +## 2. Actors + +| Actor | Assumed capability | +|---|---| +| Player | Can sign with their own wallet, replay traffic, script requests, disconnect at will | +| External attacker | Network position, can hit any public endpoint, no keys | +| Insider with database access | Read and write Postgres, no KMS signing scope | +| Insider with signer access | Can request signatures over well-formed commitments and receipts | +| Dishonest operator | Controls all backend processes, both combat implementations, and the database | +| drand network | Assumed honest and live; a threshold of nodes would have to collude to bias a round | + +The dishonest-operator row is the uncomfortable one and it is deliberate. Most controls below do not +stop that actor. They make the actor's lies detectable by anyone holding a commitment or a receipt. + +## 3. Threats + +Each row: what the attacker does, what stops or bounds it, how we notice, what is left. + +### T1: randomness reroll after seeing the value + +- **Attack.** Operator watches drand, computes the result, dislikes it, and claims the battle was + always bound to a later round. Recompute, publish, everything self-consistent. +- **Control.** Commit before reveal (§E). The round is chosen mechanically as + `currentVerifiedRound + 2`, and the signed `BattleCommitment` is returned synchronously in the + accept response, before the round exists. +- **Detection.** A reroll needs a second signature over the same `battleId`. Either player's stored + commitment plus the published receipt is a provable equivocation. +- **Residual.** Only detectable if players keep their commitment. The client persists it to local + storage and the endpoint serves it, but a player who never fetched it holds no evidence. +- **Non-control.** Persisting the chosen round in Postgres proves nothing. It is our database. + Merkle anchoring does not help either, because anchoring happens after computation. + +### T2: lying about the result + +- **Attack.** Publish a receipt whose winner does not follow from its own inputs. +- **Control.** None preventive. The receipt carries every input, so the result is recomputable. +- **Detection.** Public replay (§H). Anyone runs the verifier and the check fails. +- **Residual.** Only caught if someone runs the verifier. That is why it ships in Phase 3 while + nothing of value is at stake (Steps 30 to 32), and why we run it in CI against a corpus fixture + and monitor it ourselves. + +### T3: hiding a battle + +- **Attack.** Resolve a battle, dislike the outcome, never publish the receipt. +- **Control.** None preventive. Receipts are sequenced and hash-chained globally and per pet (§G). +- **Detection.** A gap breaks the chain. The per-pet chain also means a player who holds their own + commitment can show a committed battle with no corresponding receipt. +- **Residual.** Visible, not impossible. If omission risk becomes unacceptable, §I's delayed + direct-receipt claim fallback or an optimistic challenge protocol is the next step. + +### T4: signing-key compromise + +- **Attack.** Stolen key signs arbitrary commitments and receipts. +- **Control.** Key in KMS, never in API or worker environments. Signer accepts only the exact + commitment and receipt schemas, never a generic state-mutation payload. No asset custody, no + withdrawal authority. Separate keys per reward domain. Reward caps bound the economic damage + (§I). +- **Detection.** KMS request logging of every digest and key version. Signer throughput outside + expected range. Receipts that exist in the corpus but not in the ledger. Hash-chain forks. +- **Residual.** Real. Bounded, not eliminated. See + Appendix C. + +### T5: outcome grinding by submit-and-abandon + +- **Attack.** Submit many battles, abandon the ones that seed badly, keep the good ones. +- **Control.** No player-initiated cancellation after `committed` (§E). Disconnection, tab close, + and app kill do not affect resolution. Per-wallet and per-pet rate limits, daily battle caps. +- **Detection.** Abandonment rate per wallet. Win distribution per wallet against the expected + distribution for their matchups. +- **Residual.** A player can still choose which opponents to fight. That is matchmaking design, not + a randomness leak. + +### T6: manufactured beacon outage + +- **Attack.** Player degrades their own connectivity, or an attacker degrades ours, to escape a + battle already seeded against them. +- **Control.** The committed round is retried indefinitely and never substituted. On a genuine + permanent outage past the timeout, the battle ends `forfeited` with no progression change and both + pets stay locked for several rounds, so escaping costs more than losing (§E). +- **Detection.** drand fetch delay metric. Forfeit rate per wallet. +- **Residual.** A wallet that repeatedly forfeits is a rate-limit and abuse-policy matter. + +### T7: intent replay + +- **Attack.** Resubmit a captured signed intent to force extra battles. +- **Control.** `clientNonce` with a unique database constraint, `expiresAt`, and nonce consumption at + acceptance (§D). +- **Detection.** Repeated-nonce alert. +- **Residual.** None material. + +### T8: cross-chain or cross-deployment replay + +- **Attack.** Take a signature from staging and use it on production, or across chains. +- **Control.** Every signed object binds `chainId` and `deploymentId` (§D). Intents, + consents, commitments, and receipts all carry both, inside the hashed payload. +- **Detection.** Domain mismatch is a hard rejection, logged. +- **Residual.** None material, provided `deploymentId` is genuinely unique per environment. + +### T9: forged defender consent + +- **Attack.** Battle an unwilling defender, applying cooldown and rating changes to them. +- **Control.** `DefenseAuthorization` signed by the defender's wallet, bound to `rulesetHash`, with + level band, daily cap, validity window, and `revocationNonce`. Every receipt embeds the hash of + the authorization it relied on (§D). +- **Detection.** Receipts referencing an unknown or revoked authorization hash fail public replay. +- **Residual.** Consent is to a ruleset version, so a rules change invalidates outstanding + authorizations by design. Expect a re-consent prompt after every balance patch. + +### T10: stale ownership after an NFT transfer + +- **Attack.** Battle with a pet already sold, or snapshot a pet mid-transfer. +- **Control.** Ownership checked at the finalized source version, snapshot records + `sourceChainVersions` (§G). Reconciliation job between finalized chain ownership, snapshots, + receipts, and claims (§J). +- **Detection.** Reconciliation mismatch. +- **Residual.** Reorg depth on the source chain sets the finality wait, which is a latency cost, not + a correctness gap. + +### T11: concurrent battles with the same pet + +- **Attack.** Race two battles for one pet so one snapshot is stale or a cooldown is skipped. +- **Control.** Both pets locked in deterministic id order inside a serializable transaction. + Snapshot persisted before randomness exists. +- **Detection.** Serialization-failure rate, duplicate-battle-id alert. +- **Residual.** None material. This is a correctness test target, not a monitoring target. + +### T12: duplicate workers + +- **Attack.** Two workers process the same transition, double-crediting progression or forking a + hash chain. +- **Control.** Every transition idempotent, at-least-once processing assumed, each transition and its + outbox message committed atomically. A duplicate battle id with a different payload is a security + alert and never an upsert (§J). +- **Detection.** Hash-chain discontinuity alert. Duplicate-payload alert. +- **Residual.** None material. + +### T13: forged snapshot inputs + +- **Attack.** Inflate a pet's stats, level, or equipment inside the snapshot. +- **Control.** Snapshot fields derive from indexed chain state at a recorded source version. + Progression fields (`xp`, `streak`, `lastOpponentId`) are off-chain, so they are only checkable by + replaying that pet's prior receipts, which the per-pet hash chain makes tractable (§G). +- **Detection.** Public replay walking a pet's chain catches a snapshot that does not follow from the + previous receipt's `progressionDelta`. +- **Residual.** Equipment ownership must be verifiable from chain or from a signed inventory record + before equipment affects combat. Until then, keep equipment out of combat inputs. + +### T14: combat log leaks outcomes to spectators + +- **Attack.** Read the outcome of every resolving battle by connecting to the WebSocket. +- **Control.** `liveBattleSocket.ts` currently broadcasts every message to every client. That is + acceptable for chain-derived data and not acceptable for full combat logs. Subscriptions scope to + the existing `BattleRoom` and the socket becomes notification-only (§J). +- **Detection.** Route-level test asserting no cross-room delivery. +- **Residual.** Room ids are shareable by design, so a room link is a spectator link. + +### T15: commitment accepted but never delivered + +- **Attack.** Or, more likely, a bug. Accept succeeds, the player never receives the signed + commitment, and the only record of the chosen round is ours. T1 is then undetectable for that + battle. +- **Control.** Commitment signed and returned synchronously in the accept response, and also served + from a public endpoint so it is re-fetchable (Steps 23, 27). +- **Detection.** Dedicated alert on accept-succeeded-without-commitment-delivery (§J). +- **Residual.** A player who never fetches it still holds no evidence. The public endpoint bounds + this to non-malicious loss. + +### T16: fraudulent or omitted reward batch + +- **Attack.** Anchor a root covering receipts that were never signed, or omit signed receipts from + every batch. +- **Control.** Per-battle, per-wallet, per-batch, and per-season reward caps. One-time claims with + nullifiers. Emergency pause. Root publishers behind multisig and timelock. Published + receipt-to-root inclusion proofs (§I). +- **Detection.** Receipt-omission alert past the inclusion SLO. Root-anchor delay alert. Verifier + Merkle-inclusion check. +- **Residual.** An unanchored signed receipt is evidence of operator failure, not an on-chain claim. + +### T17: denial of service against popular opponents + +- **Attack.** Flood a specific defender to exhaust their daily cap or keep their pets locked. +- **Control.** Per-wallet and per-pet rate limits, defender daily battle cap set by the defender + themselves in their authorization (§D). +- **Detection.** Per-defender request-rate anomaly. +- **Residual.** A popular defender's cap is consumed by whoever gets there first. Matchmaking policy, + not a cryptographic problem. + +### T18: verifier collusion + +- **Attack.** The Go verifier does not constrain a dishonest operator, because the same operator runs + both processes and both ports descend from `CombatSim.sol`. +- **Control.** None, and none is claimed. The Go verifier's role is release safety: it catches + implementation drift, bad deploys, and transcription bugs, and it hard-stops receipt signing on any + mismatch (§F). +- **Detection.** Engine/verifier mismatch alert with both outputs and all inputs retained. +- **Residual.** Dishonest computation is caught by public replay (T2), not by this. + +### T19: database rollback or restore + +- **Attack.** Restore Postgres to an earlier point and lose or rewrite receipts, including via an + honest recovery. +- **Control.** Receipts are append-only and hash-chained, and the corpus is published, so an + external copy exists outside the database. Append-only audit events for every transition. +- **Detection.** Chain discontinuity between the restored database and the published corpus. +- **Residual.** Recovery procedure must reconcile against the published corpus, not just restore. + Point-in-time recovery drills have to include that reconciliation. + +### T20: a season nobody can fully claim + +- **Attack.** Not an attack so much as a self-inflicted one, which is why it is easy to miss. The + reward caps in `SeasonRewardDistributor` are enforced *per claim*, first come first served. Open a + season whose total exceeds its season cap, or whose distributor is underfunded, and early claimants + are paid in full while the last ones get a revert they did nothing to earn. An entitlement above + the per-wallet cap is worse: that wallet can never claim at all, and finds out only by trying. +- **Control.** `boundsViolations` refuses to open a season unless every entitlement fits the + per-wallet cap, the total fits the season cap, and the distributor already holds the full amount. + All three are checked before the root is posted, where the answer is still "do not open + this season" rather than "some people lost". The check is pure, so candidate caps can be tested + before any of them are committed to. +- **Detection.** Refusal at open time, with every failing reason reported at once. After opening, + a claim reverting with `ExceedsSeasonCap` means this check was bypassed. +- **Residual.** The caps still protect against a *bad* root, which is their real job; this control + only stops a *correct* season from being opened in an unpayable state. A distributor drained by + some other means after opening reintroduces the same race, so the balance is a precondition rather + than a guarantee. + +## 4. Invariants + +These are the properties tests and alerts exist to defend. Any one of them breaking is an incident, +not a bug report. + +1. A `BattleCommitment` is signed and returned to the player before its committed drand round + publishes. +2. The committed round is never substituted. Retry the same round or forfeit. +3. A battle that reaches `committed` always resolves. `rejected` exists only before `committed`. +4. The snapshot is persisted before any randomness for that battle exists. +5. The seed is derived only from the committed beacon value under the §E derivation. Never from + timestamps, uuids, or backend secrets. +6. A receipt is signed only when the TypeScript engine and the Go verifier agree exactly. +7. Every receipt links its predecessor in the global chain and in both per-pet chains. +8. One `battleId` has at most one signed commitment and at most one signed receipt. A conflicting + payload is an alert, never an upsert. +9. The signer accepts only commitment and receipt schemas, and holds no asset authority. +10. Off-chain XP is never represented as NFT state unless a successful aggregate claim applied it + on-chain. + +## 5. Accepted residual risk + +Stated plainly, because §9 of Part 1 commits to stating it plainly. + +- **A stolen signing key can sign lies.** Bounded by key isolation, schema restriction, reward caps, + and the runbook. Not eliminated. +- **We can refuse to publish.** The chains make it visible, not impossible. +- **The receipt proves what we published, not that we were honest.** Public replay is the control, + and it only works if replay actually happens. +- **The Go verifier does not constrain us**, only our deploys. + +## 6. Escalation threshold + +This design is proportionate while battle outcomes drive progression and capped, aggregate season +rewards. It stops being proportionate when a single battle's outcome carries significant transferable +value, or when reward caps have to be raised beyond what we would accept losing to a key compromise. + +At that point the next steps are §M's deferred options: per-battle backend signature verified +on-chain (Phase 3.5), optimistic settlement with bonded challenges, or proof-based settlement. That +threshold should be crossed deliberately, with a review, not drifted past by raising caps one +increment at a time. + +--- + +# Appendix B: operations runbook + +Operating the backend battle mode specified above. Covers the drills §L Phase 3 requires +before the mode carries anything of value: recovery, replay, key rotation, and incident +response. Key compromise has its own procedure in Appendix C; this one is for everything +short of that. + +## The drills are tests, not a checklist + +Each drill below is executed by `backend/tests/features/battle/ledger/drills.test.ts`, so it +runs on every CI pass rather than being performed once and slowly becoming untrue. A drill +that only ever lived in this file would describe a system nobody had checked in months. + +What the tests cannot cover is the human half — who gets paged, who decides, how long it +takes. That is what the procedures here are for. + +## Turning the mode on and off + +`BATTLE_BACKEND_MODE_ENABLED=true` enables it. Off by default. + +| Off | On | +| --- | --- | +| `POST /api/battle/intents`, `/accept`, `/authorizations` return **503** | accepted | +| `DELETE /api/battle/authorizations` still works | works | +| every read route and `/api/receipts/*` still works | works | +| the outbox worker does not start | runs | +| no signing key required | required | + +**Switching the mode off does not retract anything.** Receipts already issued stay served, +and the public corpus stays public. That asymmetry is the point: §H's claim is that anyone +can check what we did, and a feature flag that could un-publish past evidence would turn +every issued receipt into an assertion. Turning the mode off stops new battles only. + +Revocation is ungated for the same class of reason — refusing battles is never the +dangerous direction, so a defender must be able to withdraw consent even after the mode is +off. + +### Kill switch + +Set `BATTLE_BACKEND_MODE_ENABLED=false` and restart. Battles already in flight stop +advancing (the worker is gone) and stay in whatever state they reached; they resume when +the mode is turned back on, because state lives in the ledger rather than in the worker. +Pets stay locked in the meantime — see *Stuck battles* below if that window is long. + +## Drill 1: recovery + +**Scenario.** A dependency failed long enough that outbox messages exhausted their retries +and dead-lettered. Battles are parked mid-pipeline. + +Dead-lettering is deliberately not automatic-retry exhaustion to be undone by a cron. It +parks the battle for a person, because a message that failed eight times with exponential +backoff is usually failing for a reason that retrying will not fix. + +**Procedure.** + +1. List what is parked: `listDeadLetters()`. Each entry names the `battleId`, the `topic` + it died on, and `lastError`. +2. Group by `lastError`. One shared cause (drand unreachable, indexer-go down, signer + unconfigured) is the common case and means one fix. +3. Fix the cause. Confirm it is actually fixed before requeuing — a requeue against a still + broken dependency just burns the retry budget again. +4. Requeue each message: `requeueDeadLetter(id, new Date())`. Attempts reset so backoff + starts fresh. `lastError` is deliberately left in place; a requeue is not evidence the + cause is gone. +5. Watch the battles advance. Anything that dead-letters a second time on the same error is + not a transient failure and needs the incident procedure below. + +**What is safe about this.** Requeuing cannot double-apply anything. Every worker is +idempotent on its own transition — each checks the battle's current state and completes the +message as a no-op if another worker already moved it — so a message that actually +succeeded before dying is harmless to run again. + +## Drill 2: replay + +**Scenario.** Confirming that receipts this deployment issued verify independently. Run +routinely, not only during an incident: the value of a receipt is that someone outside can +check it, and a claim nobody has ever tested is not worth much. + +**Procedure.** + +1. Export a corpus: `GET /api/receipts?signingKeyId=` and page through `nextAfter`. +2. Fetch the published keys: `GET /api/battle/signing-keys`. +3. Run the standalone verifier over them, from a checkout with no access to this backend: + ```bash + pnpm --filter @cryptopets/verifier cli -- ./corpus.json --keys ./keys.json + ``` +4. Every check must pass and the exit code must be `0`. + +The verifier holds its own pinned ruleset bundles, so this works with the backend entirely +unreachable — which is the situation the drill is really rehearsing. + +**If it fails.** A failing check is not automatically our fault: confirm the corpus and key +list were fetched completely and that the ruleset the receipts name is one the verifier +holds (`ruleset-unavailable` means it is not). A genuine `combat-replay`, +`beacon-signature`, or `operator-signature` failure is an incident — go to Drill 4. + +## Drill 3: key rotation + +**Scenario.** Routine rotation, or a key approaching the end of its validity window. For a +*compromised* key, stop and use Appendix C instead. + +**Procedure.** + +1. Provision the new key in the KMS. It never leaves the KMS; the backend holds a reference. +2. Register the outgoing key as rotated: `registerRotatedKey(descriptor)` with `notAfter` + set. It stays published from `GET /api/battle/signing-keys` permanently. +3. Point the signer at the new key and restart. +4. Confirm `GET /api/battle/signing-keys` lists **both**, and that a receipt signed under + the old key still verifies. + +**The rule that matters.** A retired key is never removed from the published list. Receipts +signed under it must keep verifying forever, and delisting a key silently invalidates every +receipt it ever signed — a retroactive erasure of evidence, which is exactly what §G's +validity windows exist to make unnecessary. + +**Durability.** The registry is persisted in `battle_signing_key` and reloaded at startup, so +a rotated key keeps being published across restarts and deploys. Two properties are worth +knowing during an incident: + +- Any key that is not the one currently signing is reported as **rotated**, whatever the row + says. Swapping keys without calling `registerRotatedKey` still leaves the old key + published — the safe direction to fail in. +- **`compromised` is sticky.** Once a key is marked compromised it stays marked, and is never + reported active again, even if configuration points back at it. "This key may have signed + things we did not authorise" is a fact about history that a restart must not quietly + downgrade to a routine rotation. + +If `registerRotatedKey` logs a persistence failure, the key is published by the running +process but will not survive a restart. Re-run it once the database is reachable; it is +idempotent. + +## Drill 4: incident + +### Engine mismatch (`verification_failed`) + +The TypeScript engine and the Go verifier disagreed, so the battle was never signed. This is +the circuit breaker doing its job. + +1. **Do not sign it.** There is no override, deliberately: signing something two engines + disagree about is the one action that cannot be walked back. +2. Read `verificationDetail` on the ledger row. It holds both outputs and the field-level + mismatches. +3. Reproduce offline from the receipt's inputs — the snapshot, seed, and ruleset are all in + the row. +4. Whichever port is wrong, fix that port and rerun the golden vectors. **Never edit the + vectors** (`AGENTS.md`). +5. Affected battles stay `verification_failed`. They are not retried into existence; the + honest outcome is that the fight did not resolve. + +### Shadow mismatch + +Shadow mode (§L Phase 2) says the backend engine disagreed with the chain. Same substance as +above, with a stronger signal: the chain is the reference implementation. Blocks the Phase 3 +gate until resolved. `shadowSummary()` is the durable record. + +### Stuck battles + +A battle not advancing is one of: a dead letter (Drill 1), a committed drand round that has +not published (waits, then forfeits — by design), or a worker that is not running (check the +mode flag). + +Both pets stay locked until the battle reaches `signed` or a terminal state. If a battle is +genuinely unresolvable, moving it to a terminal state is what releases them; leaving it +pending indefinitely is worse for the player than a forfeit. + +### Drand outage + +Committed rounds are never substituted — §E allows only "keep waiting" or "give up". An +outage past `BATTLE_FORFEIT_AFTER_SECONDS` forfeits affected battles, with no progression +change. If an outage is ongoing, turn the mode off rather than let battles accumulate +toward mass forfeiture. + +## Drill 5: opening a reward season + +**Scenario.** A season's battles are anchored and it is time to pay out. This is the only +procedure here that moves real value, so it is the one worth rehearsing on a testnet first. + +**Procedure.** + +1. Confirm the receipts are **anchored**, not merely signed. `buildSeason` only counts + anchored receipts, but check the batch backlog is drained rather than discovering a + short season afterwards. +2. Build the season: sequence range, distributor address, token, and rates. The season is + written with its rates and range so anyone can recompute the root from the public corpus. +3. Fund the distributor with at least the season total. +4. Choose caps and dry-run them with `boundsViolations` before committing to any. It is + pure, so this costs nothing and answers "would this season open" directly. +5. `openSeasonOnChain`. It refuses unless every entitlement fits the per-wallet cap, the + total fits the season cap, and the distributor already holds the full amount — and + reports every failing reason at once rather than one transaction at a time. +6. Spot-check a claim proof against the on-chain root before announcing anything. + +**The rule that matters.** Caps are enforced per claim, first come first served. A season +opened over its cap, or underfunded, pays whoever claims first and reverts on whoever claims +last (threat T20). That is why the bound is checked *before* the root is posted: afterwards, +the season is immutable and the only remedy is a second season making people whole. + +**Sweeping.** `sweepUnclaimed` only works after the claim window closes, so it cannot be +used to pull funds out from under people still entitled to them. + +## Drill 6: a bad season root + +**Scenario.** A season was opened with wrong entitlements. + +1. **Pause the distributor.** This stops claims without touching battles — the registry and + the battle path are separate contracts precisely so one can be halted without the other. +2. Work out who was overpaid before the pause. Claims are events; the nullifier mapping says + who has claimed. +3. **The season cannot be corrected in place.** `openSeason` refuses to reopen a season, and + that refusal is deliberate: a rewritable root would let entitlements change after people + had read them. The remedy is a new season that makes the difference up. +4. Unpause once the replacement is ready, or leave paused and sweep after the window if the + season is being abandoned entirely. + +**Note on the owner key.** The distributor owner can open seasons, pause, and sweep after +close. It cannot rewrite an open season, mint, or take funds mid-window. That is the blast +radius to assume if the key is compromised — and it is why the owner should be a multisig +behind a timelock (§I) rather than a hot wallet. + +## What this mode deliberately does not do + +- **No reward inside a receipt.** Receipts carry no `rewardDelta` at any setting, and they + never will — rewards are computed *from* anchored receipts into a separate season tree, so + a receipt stays a statement about a fight rather than a promise of payment. Nothing pays + out until a season is deliberately built, funded, bounded, and opened (Drill 5). +- **No rating.** There is no rating or matchmaking-score system in this repo yet. §L Phase 3 + lists "off-chain XP, rating, and cooldown"; XP and cooldown exist in `pet_battle_progress`, + stored separately from NFT state. Rating is a game-design decision — what it measures, how + it decays, whether it is public — and is not something to invent as a side effect of + shipping this mode. +- **No NFT mutation.** Backend battles never write pet state on chain. Off-chain progression + lives in `pet_battle_progress`, keyed separately from `pet_roster`, so the two can never be + confused for each other. + +--- + +# Appendix C: signing key compromise runbook + +Applies to the KMS keys that sign `BattleCommitment` and `BattleReceipt` objects, and to the +root publisher key that anchors Merkle batches. See Appendix A, T4 and T16. + +Assume compromise means an attacker can produce signatures that verify against a published key. It +does not mean they can move assets: the battle signing key has no custody and no withdrawal +authority, and the root publisher sits behind multisig and timelock. That is what buys time here. + +**Bias towards pausing.** A false alarm costs players a few hours of battles. A missed compromise +costs the integrity of every receipt signed in the window. + +## Triggers + +Any one of these starts this runbook. Do not wait for confirmation of intent. + +- KMS audit log shows a signing request the pipeline cannot account for (no matching ledger row, no + matching digest). +- Signer throughput outside expected range, or signing requests from an unexpected principal, + network path, or region. +- A receipt or commitment exists in the public corpus with no corresponding ledger row. +- Hash-chain fork: two signed receipts claiming the same `previousReceiptHash`, or two commitments for + one `battleId`. +- A player produces a signed commitment or receipt we did not issue. +- Credential exposure: KMS principal credentials in a log, repo, image, or CI artifact. +- Cloud provider or KMS vendor notifies us of key or account compromise. + +## Roles + +| Role | Owns | +|---|---| +| Incident lead | Declares the incident, owns the timeline, makes the pause call | +| Signer owner | KMS policy changes, key disable, rotation | +| Chain owner | Root registry pause, multisig coordination | +| Verifier owner | Corpus re-verification, fork analysis | +| Comms owner | Player-facing status, disclosure | + +One person may hold several roles. The pause call is never blocked on availability: if the incident +lead is unreachable, the signer owner pauses. + +## Phase 1: contain (target: 15 minutes) + +Order matters. Stop the bleeding on-chain first, because that is the only irreversible surface. + +1. **Pause the on-chain surfaces.** Emergency pause on the root registry and the claim contract. No + new roots accepted, no claims processed. This is the only step that prevents economic loss. +2. **Disable the suspect key in KMS.** Deny all signing operations on that key version. Do not delete + the key and do not delete its public record, which is needed for later verification. +3. **Stop receipt signing.** Trip the signer circuit breaker. Battles already `committed` stay in + `verified` and are not lost. Battle acceptance also stops, since acceptance requires a signed + commitment and an unsigned acceptance would break invariant 1. +4. **Snapshot evidence.** KMS audit logs, signer access logs, ledger tables, published corpus, and + the current chain tips of all three receipt chains. Copy to write-once storage before anything is + rotated or restored. +5. **Freeze deploys.** No code or infrastructure changes to the signer path until Phase 4. +6. **Declare the incident** and record the suspected compromise window opening time. When unknown, + use the earliest plausible time, not the most convenient one. + +## Phase 2: assess (target: 4 hours) + +Establish the compromise window and what was signed inside it. + +1. **Reconcile KMS to ledger.** For every signing request in the window, match the digest to a ledger + row. Unmatched digests are forged-signature candidates and define the real window. +2. **Reconcile corpus to ledger.** Every published receipt and commitment must have a ledger row with + the same payload. Extra corpus entries mean forged artifacts were served. +3. **Run the verifier over the window.** `verifier` over the affected sequence range. Failures split + into: signature invalid, beacon invalid, replay mismatch, chain discontinuity. Replay mismatch on + an otherwise valid signature is the strongest evidence of forgery, because our pipeline cannot + produce it. +4. **Walk the chains.** Global chain and the per-pet chains for every pet touched in the window. Note + every fork point and both branches. A fork with two valid signatures is provable equivocation and + must be preserved exactly as found. +5. **Check batches.** Which anchored roots include window receipts. Which of those had claims against + them. Compute worst-case economic exposure against the caps. +6. **Classify.** Confirmed compromise, suspected, or false alarm. A false alarm exits at Phase 4 with + the pause lifted and a post-incident note. Do not skip Phase 4. + +## Phase 3: rotate and recover + +Only after the window is bounded. + +1. **Generate a new key** in a fresh KMS key with a new `signingKeyId`. New credentials, new + principal, minimal network path. Never reuse the old principal. +2. **Publish the key registry update.** New key with its `notBefore`. Old key marked compromised with + its validity end set to the window opening time, and **retained**, because historical receipts + still verify against it. Never remove a rotated-out key from the registry. +3. **Publish the compromise window** as a first-class record: `signingKeyId`, window start and end, + affected sequence ranges, and the list of receipts we attest to as pipeline-produced. Players and + third-party verifiers need this to interpret their own copies. +4. **Do not re-sign history under the new key.** Re-signing changes nothing about what happened and + destroys the evidence trail. Instead publish an attestation list: the receipt hashes we confirm + our pipeline produced, signed with the new key. Verifiers then treat an in-window receipt as valid + only if it appears in the attestation list. +5. **Do not renumber sequences.** Gaps and forks stay visible. Continue the chain from the last + attested receipt, recording the discontinuity explicitly. +6. **Handle in-flight battles.** Battles in `verified` at pause time resolve normally under the new + key. Battles in `committed` whose round has published resolve normally. Battles whose committed + round has passed the beacon timeout become `forfeited` with no progression change. +7. **Reverse or freeze bad claims.** Claims against forged inclusion stay paused. Nullifiers already + consumed cannot be reused, so genuine claimants inside a poisoned batch need a re-issued batch + under a new root rather than a retry. +8. **Lift the pauses** in the reverse of Phase 1: signer, then acceptance, then root registry, then + claims. Claims last, because they are the only irreversible surface. + +## Phase 4: post-incident + +- Timeline with detection latency, containment latency, and every decision point. +- Which detection fired, and which should have fired first. If detection came from a player, that is + the headline finding. +- Whether reward caps bounded the exposure as designed. If not, lower the caps before resuming. +- Whether the escalation threshold in Appendix A §6 has been reached. +- Public disclosure: what was signed, what was attested, what players should check themselves. The + design's entire premise is that we publish our homework, so a compromise is disclosed with the same + detail we would want if we were the player. + +## Never do these + +- Delete or unpublish an old public key. Historical verification depends on it. +- Delete a forged receipt from the corpus without recording it. The fork is the evidence. +- Re-sign or rewrite historical receipts under the new key. +- Renumber sequences or repair a chain by regenerating links. +- Substitute a different drand round for an unresolved battle, even to clear the queue. That breaks + invariant 2 and is exactly the behaviour T1 is designed to make impossible. +- Restore Postgres to a point before the published corpus without reconciling against it (T19). + +## Drill + +Run this as a live drill in Phase 3, before anything of value is at stake. The drill must +cover: pause, key disable, evidence snapshot, corpus reconciliation, +verifier run over a range, rotation with registry publication, attestation-list publication, and +resumption. Record the wall-clock time of each phase and correct the targets above to what the drill +actually achieves. diff --git a/docs/plan-backend-battle-steps.md b/docs/plan-backend-battle-steps.md deleted file mode 100644 index 3e402e77..00000000 --- a/docs/plan-backend-battle-steps.md +++ /dev/null @@ -1,404 +0,0 @@ -# Backend-authoritative battle: implementation steps - -Companion to [plan-backend-battle-architecture.md](./plan-backend-battle-architecture.md). That -document says what to build and why. This one splits it into commit-sized steps. - -Section references (§A, §E, §L) point at the architecture document. - -## How this runs - -- **One step, one commit.** Every step below is scoped so it can land on its own without breaking - the repo. If a step turns out to need two commits, split it and keep the numbering. -- **Every step has a verification command.** "Done" means that command passes, not that the code - looks right. -- **Branch per step group** (`feat/protocol-package`, `feat/battle-ledger`, ...), not per step. -- Steps 1 to 14 have no dependency on backend or chain work, so they can proceed while the - Phase 1 decisions in §L are still being settled. - -## Settled decisions - -Recorded here so later steps stop re-litigating them. - -| Decision | Choice | Where it came from | -|---|---|---| -| Canonical protocol code home | New MIT top-level `protocol/` package | §K licensing constraint | -| Public verifier home | New MIT top-level `verifier/` package (TypeScript CLI) | §H, §K | -| TS combat engine | Moves from `shared/src/utils/combat/` into `protocol/`, re-exported from `shared` | Verifier must replay combat and must be MIT | -| Randomness beacon | drand quicknet, 3s rounds, fixed offset of 2 rounds | §E latency budget | -| First reward chain | EVM only; Solana after EVM operations are stable | §I, §L Phase 5 | - -Open, and deliberately not blocking steps 1 to 14: exact reward economics (§I caps), whether -Phase 3.5 becomes the end state, and the KMS provider. - ---- - -## Group A: specification and licensing (§L Phase 1) - -### Step 1: threat model and key-compromise runbook -- Scope: write the threat model as its own document (the §J threat list expanded into attacker, - capability, control, detection, residual risk), plus the runbook for a suspected signing-key - compromise (pause roots, rotate key, republish key registry, re-verify affected receipts). -- Files: `docs/threat-model-backend-battles.md`, `docs/runbook-signing-key-compromise.md`. -- Verify: no command. Review only. -- Commit: `docs: add backend battle threat model and key-compromise runbook` - -### Step 2: reconcile the roadmap with backend combat -- Scope: the §K "update on acceptance" list. Team battles become backend orchestration over a - versioned ruleset, drop the "inherently low risk" claim, restate equipment guidance, mark the - settle keeper legacy for battle execution, correct stale dual-indexer text. -- Files: `docs/plan-future-features-roadmap.md`. -- Verify: no command. Review only. -- Commit: `docs: align future-features roadmap with backend-authoritative battles` - ---- - -## Group B: the `protocol/` package (§F, §G, build order 1 and 5) - -### Step 3: scaffold the MIT protocol package -- Scope: `@cryptopets/protocol` workspace package, MIT `LICENSE`, README stating the package is - intentionally MIT because outsiders run the verifier against it. tsconfig, vitest, eslint config - mirroring `shared`. Consumed as raw TypeScript, same as `shared` (no build step). -- Files: `protocol/{package.json,tsconfig.json,vitest.config.ts,eslint.config.js,LICENSE,README.md}`, - `protocol/src/index.ts`, `pnpm-workspace.yaml`, root `package.json` lint/test aggregates. -- Verify: `pnpm --filter @cryptopets/protocol test && pnpm --filter @cryptopets/protocol lint` -- Commit: `chore(protocol): scaffold MIT protocol package` - -### Step 4: move the combat engine into `protocol/` -- Scope: move `shared/src/utils/combat/*` to `protocol/src/combat/`, and its golden-vector test to - `protocol/tests/combat/`. `shared/src/utils/combat/index.ts` becomes a re-export so no frontend, - mobile, or backend import changes. Relicensing note in the package README. -- Files: `protocol/src/combat/*`, `protocol/tests/combat/goldenVectors.test.ts`, - `shared/src/utils/combat/index.ts`, `shared/package.json` (dependency on `@cryptopets/protocol`). -- Verify: `pnpm --filter @cryptopets/protocol test && pnpm --filter @shared/core test && pnpm --filter frontend build` -- Commit: `refactor(protocol): move TS combat engine out of shared into MIT protocol package` - -### Step 5: canonical encoding primitives -- Scope: the fixed binary encoder every hash in this design depends on. Length-prefixed fields, - explicit integer widths, no JSON. Domain-tag helper, keccak-256 wrapper (legacy Keccak, matching - the existing simulator hashing), hex and bigint rules. This is the step everything downstream - inherits its determinism from, so it gets its own tests. -- Files: `protocol/src/encoding/{writer.ts,hash.ts,domain.ts,index.ts}`, - `protocol/tests/encoding/*.test.ts`. -- Verify: `pnpm --filter @cryptopets/protocol test` -- Commit: `feat(protocol): add canonical binary encoding and keccak hashing primitives` - -### Step 6: deployment and schema-version binding -- Scope: `chainId` plus `deploymentId` binding used by every signed object (§D), and the schema - version registry so a version bump is a code change, not a magic number at a call site. -- Files: `protocol/src/domain/{deployment.ts,schemaVersions.ts}`, tests. -- Verify: `pnpm --filter @cryptopets/protocol test` -- Commit: `feat(protocol): bind signed objects to chainId and deploymentId` - -### Step 7: battle intent -- Scope: `BattleIntent` type, canonical hash, EIP-712 typed data for EVM wallets, domain-separated - sign-message format for Solana. Expiry and nonce fields, no verification logic yet (that is - backend, Step 18). -- Files: `protocol/src/intent/*`, `contracts/test-vectors/protocol-intent.json`, - `protocol/tests/intent/*.test.ts`. -- Verify: `pnpm --filter @cryptopets/protocol test` -- Commit: `feat(protocol): add wallet-signed battle intent schema and hashing` - -### Step 8: standing defence authorization -- Scope: `DefenseAuthorization` type (§D), canonical hash, EIP-712 and Solana formats, - `revocationNonce` semantics. Consent is bound to `rulesetHash`. -- Files: `protocol/src/consent/*`, `contracts/test-vectors/protocol-consent.json`, tests. -- Verify: `pnpm --filter @cryptopets/protocol test` -- Commit: `feat(protocol): add standing defense-authorization schema and hashing` - -### Step 9: pet snapshot -- Scope: the "photo" (§C, §F). Frozen pet fields plus `lastOpponentId` and `streak`, so progression - is a pure function of the receipt's own inputs. `snapshotHash` over both pets. Equipment slots - present but empty until equipment ships. -- Files: `protocol/src/snapshot/*`, `contracts/test-vectors/protocol-snapshot.json`, tests. -- Verify: `pnpm --filter @cryptopets/protocol test` -- Commit: `feat(protocol): add frozen pet snapshot schema and snapshot hashing` - -### Step 10: seed derivation -- Scope: the §E derivation exactly as specified, domain-separated over chainId, deploymentId, drand - randomness, battleId, snapshotHash, rulesetHash. Golden vectors, including one recorded real - quicknet beacon value. -- Files: `protocol/src/randomness/seed.ts`, `contracts/test-vectors/protocol-seed.json`, tests. -- Verify: `pnpm --filter @cryptopets/protocol test` -- Commit: `feat(protocol): derive battle seed from drand randomness with domain separation` - -### Step 11: drand beacon verification -- Scope: pinned quicknet chain hash and public key, BLS12-381 signature verification over - `@noble/curves`, round-to-time and time-to-round helpers, the fixed offset constant. Pure - verification, no network client (that is Step 20). Record the bundle-size cost in the README, as - §E requires. -- Files: `protocol/src/randomness/{drand.ts,beacon.ts}`, fixtures of real quicknet rounds, tests. -- Verify: `pnpm --filter @cryptopets/protocol test` -- Commit: `feat(protocol): verify drand quicknet BLS beacon signatures against a pinned key` - -### Step 12: battle commitment -- Scope: `BattleCommitment` type (§E), canonical hash, `previousCommitmentHash` chain link, and a - chain-continuity checker. No signing here. -- Files: `protocol/src/commitment/*`, `contracts/test-vectors/protocol-commitment.json`, tests. -- Verify: `pnpm --filter @cryptopets/protocol test` -- Commit: `feat(protocol): add battle commitment schema, hashing, and chain link` - -### Step 13: XP and progression port -- Scope: build order step 5, and the §F workstream. Port `services/indexer-go/internal/combat/xp.go` to - `protocol/src/combat/xp.ts`, reading streak state from the snapshot rather than chain state. - Produce a `progressionDelta` (xp, level, streak, rating inputs) as a pure function. -- Files: `protocol/src/combat/xp.ts`, `contracts/test-vectors/protocol-progression.json`, - `protocol/tests/combat/xp.test.ts` (runs the existing `contracts/test-vectors/xp.json` too). -- Verify: `pnpm --filter @cryptopets/protocol test` and `cd services/indexer-go && go test ./internal/combat` -- Commit: `feat(protocol): port XP and progression math to TypeScript with golden vectors` - -### Step 14: ruleset versioning -- Scope: `rulesetVersion` and `rulesetHash` over the combat config and skill/balance configuration, - plus the content-addressed ruleset bundle format §H requires for historical replay. -- Files: `protocol/src/ruleset/*`, `contracts/test-vectors/protocol-ruleset.json`, tests. -- Verify: `pnpm --filter @cryptopets/protocol test` -- Commit: `feat(protocol): add content-addressed ruleset versioning and hashing` - -### Step 15: battle receipt -- Scope: `BattleReceipt` type (§G) with all three hash links, canonical hash, combat-log hash, and - the chain-continuity checkers for the global chain and both per-pet chains. -- Files: `protocol/src/receipt/*`, `contracts/test-vectors/protocol-receipt.json`, tests. -- Verify: `pnpm --filter @cryptopets/protocol test` -- Commit: `feat(protocol): add signed battle receipt schema, hashing, and hash chains` - -### Step 16: Merkle leaves and proofs -- Scope: canonical Merkle leaf encoding for a receipt, tree construction, proof generation and - verification, matching whatever the EVM registry will accept (Step 33). Vectors so Solidity and - TypeScript cannot drift. -- Files: `protocol/src/merkle/*`, `contracts/test-vectors/protocol-merkle.json`, tests. -- Verify: `pnpm --filter @cryptopets/protocol test` -- Commit: `feat(protocol): add canonical Merkle leaf encoding, proofs, and vectors` - ---- - -## Group C: backend ledger and intent (§J, build order 2 and 3) - -### Step 17: Prisma models and migration -- Scope: the §J models. `BattleIntent`, `DefenseAuthorization`, `BattleLedger`, `BattleCommitment`, - `BattleReceipt`, `BattleBatch`, `BattleRuleset`, `PetBattleProgress`, `BattleOutbox`. Unique - constraint on the wallet idempotency nonce. `BattleHistory` is untouched, since the on-chain path - keeps running. -- Files: `backend/prisma/schema.prisma`, `backend/prisma/migrations/*`. -- Verify: `pnpm --filter backend build` and a migration applied against a scratch database. -- Commit: `feat(backend): add battle ledger, commitment, receipt, and progress models` - -### Step 18: ledger state machine -- Scope: the §J transition table as code, with every transition idempotent and each one writing its - outbox message in the same transaction. Deterministic pet lock ordering. No HTTP surface yet. -- Files: `backend/src/features/battle-ledger/{state.ts,transitions.ts,outbox.ts,index.ts}`, tests. -- Verify: `pnpm --filter backend test` -- Commit: `feat(backend): add transactional battle ledger state machine and outbox` - -### Step 19: signed intent submission -- Scope: verify an EIP-712 or Solana-signed `BattleIntent`, check finalized attacker ownership, - consume the nonce, reject expiry and cross-deployment replay, create the ledger row in `accepted`. - A JWT can carry the request but never authorizes another wallet's battle. -- Files: `backend/src/features/battle-ledger/intent.service.ts`, `backend/src/routes/battle.ts`, - tests. -- Verify: `pnpm --filter backend test` -- Commit: `feat(backend): accept wallet-signed battle intents` - -### Step 20: standing defender consent -- Scope: store, verify, and revoke `DefenseAuthorization`. Level band, daily battle cap, immediate - revocation with its timestamp available to receipts. -- Files: `backend/src/features/battle-ledger/consent.service.ts`, routes, tests. -- Verify: `pnpm --filter backend test` -- Commit: `feat(backend): add standing defender consent with immediate revocation` - ---- - -## Group D: randomness, signing, execution (build order 4, 6, 7) - -### Step 21: drand client -- Scope: fetch quicknet rounds, verify with the `protocol/` verifier, cache verified rounds, retry - the same committed round indefinitely (§E), never substitute a known round. Metrics for fetch - delay. -- Files: `backend/src/features/battle-randomness/*`, tests with a stubbed HTTP transport. -- Verify: `pnpm --filter backend test` -- Commit: `feat(backend): add verified drand quicknet round client with same-round retry` - -### Step 22: isolated signer -- Scope: signer interface accepting only the exact commitment and receipt schemas, digest-only - signing, KMS adapter plus a local dev adapter, key registry with validity periods including - rotated-out keys. No generic signing endpoint, ever. -- Files: `backend/src/features/battle-signer/*`, tests. -- Verify: `pnpm --filter backend test` -- Commit: `feat(backend): add schema-restricted KMS signer for commitments and receipts` - -### Step 23: accept flow, snapshot and commitment delivery -- Scope: the one ordering that can never be relaxed. On acceptance: snapshot both pets, pick - `currentRound + 2`, persist, sign the `BattleCommitment`, and return it synchronously in the accept - response. Alert when accept succeeds but commitment delivery fails. -- Files: `backend/src/features/battle-ledger/accept.service.ts`, integration test asserting the - commitment is signed and returned before the committed round exists. -- Verify: `pnpm --filter backend test` -- Commit: `feat(backend): sign and deliver battle commitment before the drand round publishes` - -### Step 24: seeded and computed worker -- Scope: worker driving `committed` to `seeded` to `computed`. Verify the beacon, derive the seed, - run `protocol/` combat plus progression, persist the combat log and its hash. -- Files: `backend/src/features/battle-worker/*`, tests. -- Verify: `pnpm --filter backend test` -- Commit: `feat(backend): compute battles from verified drand seeds in a worker` - -### Step 25: independent Go verification -- Scope: §F release safety. `indexer-go` gains a snapshot-shaped verify entry point over its - existing combat and xp packages, exposed to the backend. Mismatch on winner, rounds, winner HP, - progression delta, or combat-log hash stops signing for that ruleset, alerts, and retains both - outputs. Never silently prefer one implementation. -- Files: `services/indexer-go/internal/combat/verify.go`, `services/indexer-go/internal/grpcsrv/*` (or an HTTP - endpoint), `proto/cryptopets.proto` if gRPC, `backend/src/features/battle-worker/verify.ts`. -- Verify: `cd services/indexer-go && go vet ./... && go test ./internal/combat` and `pnpm --filter backend test` -- Commit: `feat(indexer-go): verify backend battle results independently before signing` - -### Step 26: sign the receipt and append the hash chains -- Scope: `verified` to `signed` to `published`. Append to the global chain and both per-pet chains - inside one transaction, update `PetBattleProgress`. A duplicate battle id with a different payload - raises a security alert and is never an upsert. -- Files: `backend/src/features/battle-ledger/receipt.service.ts`, tests including a concurrency test. -- Verify: `pnpm --filter backend test` -- Commit: `feat(backend): sign battle receipts and append global and per-pet hash chains` - ---- - -## Group E: public surfaces (build order 8, 10) - -### Step 27: read APIs -- Scope: battle state by id, signed commitment, signed receipt, combat log, active signing keys, - active rulesets, verify-receipt. Authoritative and re-fetchable, since the WebSocket stops being - trusted in Step 29. -- Files: `backend/src/routes/battle.ts`, `backend/src/graphql/*`, `backend/API.md`, tests. -- Verify: `pnpm --filter backend test` -- Commit: `feat(backend): expose battle state, commitment, receipt, and key endpoints` - -### Step 28: public receipt corpus -- Scope: paginated export by pet, by wallet, and by sequence range, with no authentication, so - replay needs no special access (§H item 3). -- Files: `backend/src/routes/receipts.ts`, tests. -- Verify: `pnpm --filter backend test` -- Commit: `feat(backend): publish a paginated public receipt corpus` - -### Step 29: scope the WebSocket per room -- Scope: `backend/src/ws/liveBattleSocket.ts` stops broadcasting globally, since the payload now - carries full combat logs. Subscriptions scope to the existing `BattleRoom`. Notification only, - never authoritative. -- Files: `backend/src/ws/liveBattleSocket.ts`, `backend/src/features/battle-room/*`, frontend and - mobile subscribe calls, tests. -- Verify: `pnpm --filter backend test && pnpm --filter frontend test` -- Commit: `refactor(backend): scope live battle socket per room and make it notification-only` - ---- - -## Group F: the standalone verifier (§H, build order 9) - -### Step 30: scaffold the verifier CLI -- Scope: MIT `verifier/` package depending only on `@cryptopets/protocol`. No backend access, no - database. Reads a receipt file or a corpus URL. Checks operator signature and hash-chain - continuity first, since those need nothing else. -- Files: `verifier/{package.json,tsconfig.json,LICENSE,README.md}`, `verifier/src/*`, - `pnpm-workspace.yaml`. -- Verify: `pnpm --filter @cryptopets/verifier test` -- Commit: `feat(verifier): scaffold standalone MIT receipt verifier CLI` - -### Step 31: full verification checks -- Scope: drand BLS verification, seed derivation, combat replay, progression comparison, per-check - pass or fail output, non-zero exit on any failure. -- Files: `verifier/src/checks/*`, fixtures, tests. -- Verify: `pnpm --filter @cryptopets/verifier test` -- Commit: `feat(verifier): verify beacon, seed, combat replay, and progression` - -### Step 32: pinned ruleset artifacts and CI -- Scope: fetch and pin content-addressed ruleset bundles so historical battles reproduce exactly, - plus a CI job running the verifier over a committed corpus fixture on every PR. -- Files: `verifier/src/ruleset.ts`, `verifier/fixtures/*`, `.github/workflows/verifier.yml`. -- Verify: `pnpm --filter @cryptopets/verifier test` and a green workflow run. -- Commit: `feat(verifier): pin ruleset artifacts and run receipt verification in CI` - ---- - -## Group G: client (§J frontend behavior) - -### Step 33: submit intent and persist the commitment -- Scope: wallet signs the intent, client stores battle id and the signed commitment in local - storage so the player's own evidence survives a reload, subscribes to the room, refetches the - authoritative endpoint after reconnect. -- Files: `shared/src/hooks/*` (new backend battle hook), `frontend/src/*`, tests. -- Verify: `pnpm --filter frontend test && pnpm --filter frontend lint:check` -- Commit: `feat(frontend): submit signed battle intents and persist the signed commitment` - -### Step 34: client-side verification and replay -- Scope: verify the receipt signature, the drand BLS signature against the pinned key, and the hash - links, in the browser. Then replay the combat log and animate. Record the bundle-size delta. -- Files: `frontend/src/*`, `shared/src/hooks/*`, tests. -- Verify: `pnpm --filter frontend test && pnpm --filter frontend build` -- Commit: `feat(frontend): verify battle receipts client-side before replaying the fight` - ---- - -## Group H: shadow mode and launch (§L Phase 2 and 3) - -### Step 35: shadow the on-chain path -- Scope: recompute every settled on-chain battle through the `protocol/` engine and the Go verifier, - compare against `BattleResolved`, record mismatches. On-chain battles keep running unchanged. Stop - condition: zero deterministic mismatch over the agreed observation window. -- Files: `backend/src/features/battle-shadow/*`, metrics, tests. -- Verify: `pnpm --filter backend test`, then the observation window itself. -- Commit: `feat(backend): shadow-compute settled on-chain battles against the backend engine` - -### Step 36: rewardless backend battle mode -- Scope: backend battle mode behind a flag, alongside the on-chain mode. Off-chain XP, rating, and - cooldown stored distinctly from NFT state. Signed commitments and receipts, no transferable - reward. Recovery, replay, key-rotation, and incident drills documented and run. -- Files: `backend/src/features/battle-*`, `backend/env.example`, frontend mode switch, - `docs/runbook-backend-battles.md`. -- Verify: `pnpm --filter backend test && pnpm --filter frontend test` -- Commit: `feat: launch rewardless backend battle mode behind a flag` - ---- - -## Group I: anchoring and rewards (§L Phase 4 to 6) - -Deliberately coarse. Scope these into steps once Group H is operating, because the batch cadence, -caps, and claim shape depend on what shadow mode and the rewardless launch actually show. - -### Step 37: EVM root registry and batcher -- Commit: `feat(contracts): add battle batch root registry` and - `feat(backend): aggregate signed receipts into anchored Merkle batches` - -### Step 38: capped claim contract and proof API -- Commit: `feat(contracts): add capped aggregate reward claims with nullifiers` - -### Step 39: security review, drills, bounded rewards -- Commit: `feat: enable bounded aggregate season rewards` - -### Step 40: retire per-battle settlement and amend the four-port rule -- Scope: only here, at §L Phase 6, do `AGENTS.md` and `CLAUDE.md` change. The four-port combat rule - stays a `MUST` until the legacy on-chain path actually retires. Legacy receipts and events stay - replayable. -- Commit: `docs: retire per-battle settlement and amend the four-port combat rule` -- **Done.** The rule was split rather than relaxed: `CombatSim.sol` and `combat.rs` became - `MUST NOT` change (frozen, so the battles they settled stay replayable), while - `protocol/src/combat/` and `services/indexer-go/internal/combat/` stay `MUST` change together, since - §F's circuit breaker only works while those two are independent. All four golden-vector - suites keep running. The settle keepers stay deployable so in-flight requests can drain - rather than being stranded, and breed/mint still settle on chain — only battles retired. - ---- - -## Dependency map - -```mermaid -flowchart LR - A["1-2 docs"] --> B["3-16 protocol/"] - B --> C["17-20 ledger + intent"] - C --> D["21-26 randomness, signer, execution"] - B --> F["30-32 verifier/"] - D --> E["27-29 public APIs + ws"] - E --> F - D --> G["33-34 client"] - F --> H["35-36 shadow + rewardless launch"] - G --> H - H --> I["37-40 anchoring + rewards"] -``` - -Steps 3 to 16 are the critical path. Nothing else can start until the canonical encodings exist, -because every signature in this design is over one of them. diff --git a/docs/plan-future-features-roadmap.md b/docs/plan-future-features-roadmap.md index 89067f27..b8fbe555 100644 --- a/docs/plan-future-features-roadmap.md +++ b/docs/plan-future-features-roadmap.md @@ -7,8 +7,7 @@ NFTs, agentic AI pets, and ERC20 tokenomics — listed here in the recommended d (see below), which is also the order the sections appear in below. Each section sketches where the feature lands in the existing architecture, what new data model it needs, and the open decisions a human should make before implementation starts. Nothing here is committed to; treat -it as a starting point for per-feature implementation plans in the style of -`plan-realtime-battle-impl.md`. +it as a starting point for per-feature implementation plans. Grounded against the repo as of this writing: `backend/prisma/schema.prisma` (existing `PetRoster`, `BattleHistory`, `BattleRoom`, `BattleDialogue`, `BattleConversation` models), @@ -21,6 +20,29 @@ half-built. Two doc comments in `schema.prisma` reference `PVP_BATTLE.md` and `AI_BATTLE_DIALOGUE.md` as design docs; neither file exists in the repo today, so treat those names as historical pointers, not sources to read. +> **The paragraph above has aged out in five specific ways.** It is kept as written, since +> the sections below were reasoned from it, but check each of these before building on it: +> +> - **`CombatSim.sol` is deleted.** It had no on-chain caller left once battles moved to the +> backend. `contracts/ethereum/src/` now holds `PetCore`, `GameLogic`, `GameConfig`, +> `DnaLib`, `TestDeployer`, plus `BattleBatchRegistry`, `SeasonRewardDistributor`, +> `CryptoPetsToken` and `MockERC20`. +> - **`liveBattleSocket.ts` is gone, and it is no longer "the one channel".** There are two: +> `battleRoomSocket.ts` (§J battle notifications) and `chatSocket.ts` (§2 chat), both +> behind one upgrade listener (`ws/channel.ts`) — a `WebSocketServer` per channel on the +> same HTTP server handles every connection twice. The battle room is unauthenticated, +> which is safe only because its frames carry nothing readable; chat is authenticated. +> §2's assumption that an authenticated socket already existed to reuse was wrong, so one +> had to be built. +> - **A chat surface now exists** (§2 v1, built). So does a **token**: `CryptoPetsToken.sol` +> is a deployed-ready fixed-supply ERC20 (CPET) that funds `SeasonRewardDistributor` under +> the battle protocol's §I. That materially narrows §11 — see the note there. +> - **The Node `RosterIndexer` is deleted.** `indexer-go` is the only writer of `pet_roster`, +> so the "mirror path in the backend's Node `RosterIndexer`" in the indexer-extension +> pattern below no longer exists and a new asset type needs one adapter, not two. +> - **Two of the eleven features are built** (§1 leaderboard, §2 chat) and a third is partly +> built (§3), on top of §9 which was always out of order. Each carries its own banner. + ## Cross-cutting architecture, established once so each feature section doesn't repeat it **Licensing.** Any new Solidity or Anchor program is MIT (`contracts/ethereum`, @@ -38,10 +60,10 @@ stay server/backend-computed (leaderboard ranking, quest progress) don't need th give something a TS port if the client actually needs to simulate it before the chain confirms. **Combat authority is moving off-chain, and that reshapes several features below.** -`docs/plan-backend-battle-architecture.md` is the accepted architecture for battle execution: the +`docs/battle-protocol.md` is the accepted architecture for battle execution: the backend resolves fights from a frozen snapshot against a versioned ruleset, seeds them from a -pre-committed drand round, and publishes signed receipts anyone can replay -(`docs/plan-backend-battle-steps.md` sequences the work). Two consequences for this doc. First, a +pre-committed drand round, and publishes signed receipts anyone can replay. Two consequences +for this doc. First, a *new* combat mechanic is built once, in the canonical TypeScript engine (`protocol/src/combat/`, moved out of `shared/src/utils/combat/` into the MIT `protocol` package), with the Go port acting as an independent pre-signing verifier rather than a fourth hand-maintained implementation. @@ -66,6 +88,15 @@ backend's Node `RosterIndexer`. Items, marketplace listings, and token transfers types — each needs a new Prisma table shaped this way and a new case in both indexers, not a bolt-on to `PetRoster`. +> Correction: **there is no second indexer to mirror into.** The Node `RosterIndexer` was +> deleted and `indexer-go` is the sole writer of `pet_roster`, so a new asset type needs one +> adapter, not two. `BattleHistory` is also no longer an indexed type — the backend writes it +> from its own signed receipts — so `PetRoster` is the only live example of this pattern. +> Two things about it are worth copying deliberately rather than by habit: the version guard +> discards a *lower*-versioned write, which is what makes a chain rollback leave a stale row +> in place (see §3), and on Solana the source version is a slot read at the configured +> commitment, which is why that setting is `finalized` rather than `confirmed`. + **`ChainAdapter` stays pet-action-only.** Per `AGENTS.md`, `shared/src/hooks/adapters/types.ts` is a real, narrow interface (`createPet`, `levelUpPet`, `trainPet`, `renamePet`, `transferPet`, `battlePets`, `breedPets`). Marketplace, inventory, and quest actions are new domains — give them @@ -79,9 +110,11 @@ what to build first, section 11 is what to build last. *Tier 1 — ship immediately.* Near-zero new infrastructure, no contract work on either chain, nothing else in this doc needs to exist first. -1. **Leaderboard** — `PetRoster.winCount`/`lossCount` already exist. -2. **Social chat** — reuses the existing WebSocket channel and JWT auth; v1 scope is gated by the - marriage feature that's already shipped. +1. **Leaderboard** — **Built; see §1.** Ranked on the merged battle record, not on + `PetRoster.winCount`/`lossCount` as this line originally assumed: those froze when + battles left the chain. +2. **Social chat** — **Built (v1); see §2.** It did not reuse an authenticated socket, because + there was none to reuse; the channel it added is notification-only for that reason. *Tier 2 — foundation.* Harden what's shipped and build the systems everything else leans on. 3. **Indexing hardening** — everything downstream trusts the indexer more once this lands. @@ -191,7 +224,7 @@ Reading the graph: is itself part of the ruleset hash, and a receipt shape that survives replay. Authorization, snapshots, seed derivation, signer scope, and reward aggregation are all security-sensitive here. Treat this as a feature that inherits the full backend battle threat model - (`docs/threat-model-backend-battles.md`), not as a loop around a proven function. + (`docs/battle-protocol.md` Appendix A), not as a loop around a proven function. - **Inventory is the pivot feature.** It's a parallel asset type to pets (same ownership/indexing pattern), the second thing the marketplace can list besides pets, and the default reward payload for quests. Everything downstream of it moves faster once it exists — see feature 4 @@ -229,6 +262,35 @@ Reading the graph: ## 1. Leaderboard feature +> **Built.** This section is kept as the original proposal; `backend/API.md` +> (Leaderboards) is authoritative for the shipped surface. What shipped diverges +> from the sketch below in three ways, recorded here so they are not mistaken for +> drift: +> +> - **`PetRoster.winCount`/`lossCount` were the wrong source.** The sketch's whole +> claim to cheapness was that those columns already exist. They do, and they have +> been frozen since §L Phase 6 — the live record accumulates in +> `pet_battle_progress`. This was not a theoretical concern: run the roster-only +> ranking against the live Base Sepolia data and it returns **zero rows**, while +> the merged ranking returns four pets and two players. Every leaderboard query +> merges the two the way `findReadyOpponents` does, in the query that orders the +> rows, because the ordering *is* the merge and a post-sort cannot fix it. +> - **No ELO, and no `PetRating` table.** Ranking is wins DESC then losses ASC. That +> second key is the win-rate tiebreak without the division: on equal wins, fewer +> losses is a strictly higher rate, so nothing is ranked on a ratio drawn from a +> handful of fights. The `PetRating` model below was not created — see the +> paragraph after it, which is still the standing decision: if rating ever lands it +> belongs in the versioned ruleset computed from receipts, not at this layer. +> - **A player board and a self-rank came with it.** Owners are ranked by their pets' +> combined record, grouped case-folded on EVM and unfolded on Solana (folding base58 +> would merge two distinct pubkeys into one player). `playerRank` answers "where am +> I" in one query rather than having a client page the board looking for itself, and +> returns null for an unranked player rather than a zeroed row. +> +> Surfaces: `/leaderboard` in the web app, plus the sidebar rank footer, which until +> now hardcoded "RANK #3 GLOBAL / 649 Total Wins" for every visitor. Mobile was +> deliberately left out; the hooks live in `@shared/core` so it can pick them up. + **Goal.** Ranked pets and/or players by battle performance. **Design.** The cheapest feature on this list to ship: `PetRoster.winCount`/`lossCount` already @@ -268,6 +330,38 @@ rank. The ranking computation itself is unaffected. ## 2. Social features: player-to-player chat +> **Built (v1).** The married-pet private thread shipped: `chat_thread` / `chat_message`, +> `/api/chat/threads` and its message routes, `/ws/chat`, and a `/messages` screen. See +> `backend/API.md` (Private chat) for the authoritative surface. Three divergences from the +> sketch below, recorded so they are not mistaken for drift: +> +> - **There was no authenticated WebSocket to reuse.** This section's cost argument rested +> on one. `liveBattleSocket.ts` is gone, and its replacement joins whoever presents a room +> id — safe there only because battle notifications carry nothing a client could not +> re-fetch. `/ws/chat` still carries **no message text**: it says a thread changed, and +> the authenticated read returns the content. Chat still cost less than anything else +> here, just not for the stated reason. +> - **Presence shipped, and it is what made the socket authenticated.** The open decision +> below asked whether v1 should have online presence. It does: a green dot for the +> counterpart. That forced the auth this section assumed existed — presence is a claim +> about identities, and an anonymous socket has none, so counting connections would +> report one person with two tabs open as two people. The JWT travels as a subprotocol +> rather than a query parameter, and the upgrade applies the same participation and +> live-marriage gate as the HTTP routes. Read receipts are still absent. +> - **Access is checked per request, not stored.** As sketched, `ChatThread` does not record +> the marriage — but the check runs on every read and send, not only at open time, so a +> divorce closes the conversation with nothing to revoke. The thread row survives, since +> deleting it would destroy the history; it just stops answering. +> - **Threads are created on listing, not by an explicit open call.** A married pair always +> ends up with exactly one thread, so a separate open step would add a round trip and a +> null state that only ever resolves one way. +> +> The open decisions below are **unresolved and now carry a shipped surface**: there is no +> block, report, filter, read receipt, edit, delete, or retention policy. (Presence is the +> one that got answered — see above.) The only abuse controls are a length cap and a send +> rate limit. v1's lack of any discovery surface is what contains the risk today, and that +> containment ends with v2's open DMs. + **Goal.** Real player-to-player messaging, not another AI-generated conversation. The natural starting point is the marriage feature: two owners whose pets are married already have an established relationship in the game — a private chat thread between them is the smallest, @@ -337,6 +431,43 @@ bare as possible for a first version. ## 3. "Perfect" indexing with realtime blockchain sync +> **Partly built, and partly obsolete.** Two of this section's four items no longer +> describe the repo, and one has shipped: +> +> - **There is no dual-indexer setup left.** The backend's Node `RosterIndexer` was +> deleted; `indexer-go` is the only thing that writes `pet_roster`. So "shadow mode", +> "the Node indexers stay the source of truth until promotion", and the proposed +> reconciliation job that *diffs Node-indexer state against indexer-go state* have +> nothing left to compare against. What survives of that item shipped as a plain +> freshness signal rather than a cross-indexer diff: `indexer_last_poll_unixtime{chain}` +> is stamped on every error-free round trip, and `/readyz` (separate from `/healthz`, +> which stays pure liveness) refuses until every chain has been reached once. `ROSTER_CACHE_ENABLED`'s "only coherent while +> indexer-go is the sole writer" caveat is likewise satisfied by default now. +> - **Reorg handling shipped for Solana.** Every read and the program subscription ran +> at `confirmed`, which is exactly the phantom-row exposure this section names. +> `SOLANA_COMMITMENT` now defaults to `finalized`, with reads and the subscription +> pinned to the same value; see `services/indexer-go/README.md`. This mattered more +> than "display freshness" suggests, because the roster is what battle snapshots are +> frozen from (threat T10). +> - **A periodic EVM reconcile scan shipped.** `RECONCILE_INTERVAL` was documented as a +> full-scan safety net but only the Solana adapter used it, so an EVM row the +> incremental path missed stayed wrong indefinitely: that query asks for +> `updatedAt_gt: watermark`, which cannot see anything the watermark has passed. +> - **EVM confirmation depth is still open**, and is subtler than the Solana case, in a +> way worth stating precisely because it defeats the obvious fix. The subgraph rolls +> back reorged blocks itself, but a rollback *lowers* a pet's `updatedAt`, and two +> independent things then block recovery: the incremental query never re-fetches the +> row (it is below the watermark), and the writer discards a lower version anyway +> (`WHERE last_version <= EXCLUDED.last_version`). So the reconcile sweep above fixes +> the first and not the second — it re-reads the row and the correction is rejected. +> Closing it needs either a confirmation depth on the read or a `Version` that never +> moves backwards. Written up with a recommendation in +> `services/indexer-go/plan-evm-reorg-recovery.md`, which also records the trap in the +> obvious fix: EVM versions are block *timestamps* (~1.79e9 in the live table) while +> block numbers are ~3e7, so switching to a block number rejects every subsequent write +> while the service still looks healthy. +> - **The log-subscription path is still open** and unchanged in motivation. + **Goal.** Tighten the existing dual-indexer setup (Node `RosterIndexer` + optional `indexer-go`) so both chains reflect on-chain state with minimal lag and no missed events. @@ -365,7 +496,7 @@ Both indexers are still live: the Node `RosterIndexer` is the source of truth in `indexer-go` is the promotable path, so "dual-indexer" describes the current state, not a leftover. What changes is that `indexer-go` picks up a second, unrelated job under backend-resolved combat: it becomes the independent pre-signing verifier that recomputes every battle result before a receipt is -signed (`docs/plan-backend-battle-steps.md` Step 25). That is a release-safety role, not an indexing +signed (`docs/battle-protocol.md` §F). That is a release-safety role, not an indexing role, and it does not depend on which indexer owns roster writes. Worth knowing before promotion, because an `indexer-go` outage then blocks receipt signing as well as roster freshness, so the two concerns need separate health signals. @@ -373,7 +504,7 @@ concerns need separate health signals. Snapshot inputs are the other connection. Backend battles freeze pet state at acceptance from indexed chain state at a recorded source version, so indexer lag and reorg handling stop being purely cosmetic: a snapshot taken from an unfinalized write is threat T10 in -`docs/threat-model-backend-battles.md`. The confirmation-depth work above is a prerequisite for +`docs/battle-protocol.md` Appendix A. The confirmation-depth work above is a prerequisite for that, not an optional polish item. This feature has little product-design risk — it's operational hardening of a path that already @@ -423,7 +554,7 @@ checkable. So: - Equipment ownership must be verifiable at snapshot time from chain state at a recorded source version, exactly like pet ownership. Backend-only equip state that no third party can confirm turns every geared receipt into an assertion (threat T13 in - `docs/threat-model-backend-battles.md`). + `docs/battle-protocol.md` Appendix A). - The snapshot carries the resolved modifiers, not a reference to a mutable item row. Unequipping after acceptance must not change a committed fight, the same reason pet stats are frozen. - `ItemDefinition.effect` becomes part of the ruleset hash if it feeds combat. A rebalance is then a @@ -824,7 +955,7 @@ a separate explicit decision if ever pursued. **Memory: build it on the per-pet receipt chain, don't invent a second one.** An agentic pet is only interesting if it has continuity, and continuity needs a bounded, ordered view of one pet's -past. [plan-backend-battle-architecture.md](./plan-backend-battle-architecture.md) §G already +past. [battle-protocol.md](./battle-protocol.md) §G already produces exactly that: every battle receipt links to the previous receipt involving that same pet (`attackerProgressPrevReceiptHash` / `defenderProgressPrevReceiptHash`), with `PetBattleProgress` holding the head pointer. Walking that link is the retrieval query this feature needs, already @@ -918,6 +1049,24 @@ decision. ## 11. ERC20 tokenomics +> **An EVM token already exists, and its design constrains this section.** +> `contracts/ethereum/src/CryptoPetsToken.sol` is CPET: a standard OpenZeppelin ERC20 with +> **fixed supply, no mint function, and no owner**, minted once at deployment to fund +> `SeasonRewardDistributor` under the battle protocol's §I. That is this section's +> "independently minted token per chain" recommendation, already taken for EVM. +> +> What it rules out is the part worth noticing before designing anything here: **there is no +> emission schedule to design.** A token that cannot be minted cannot emit. Rewards come out +> of a supply that already exists, so "running out means running out" — which is deliberate, +> because §I's safety argument is that a bad Merkle root is bounded by real supply rather +> than by whoever holds a minting key. Any tokenomics plan that assumes new issuance is +> either proposing a second token or proposing to give that argument up, and should say +> which. +> +> Still genuinely open, and still a human call: sinks, fee denomination, whether Solana gets +> its own SPL mint, and how much of the fixed supply is committed to seasons versus held +> back. The note below about not planning a sink around the retired battle fee still stands. + **Goal.** A utility/governance token usable for battle fees, training fees, marketplace fees, quest rewards, and breeding stud fees. diff --git a/docs/runbook-backend-battles.md b/docs/runbook-backend-battles.md deleted file mode 100644 index 903b5a1c..00000000 --- a/docs/runbook-backend-battles.md +++ /dev/null @@ -1,238 +0,0 @@ -# Runbook: backend-authoritative battles - -Operating the backend battle mode described in -[plan-backend-battle-architecture.md](./plan-backend-battle-architecture.md). Covers the -four drills §L Phase 3 requires before the mode carries anything of value: recovery, replay, -key rotation, and incident response. - -Key compromise has its own runbook: -[runbook-signing-key-compromise.md](./runbook-signing-key-compromise.md). This one is for -everything short of that. - -## The drills are tests, not a checklist - -Each drill below is executed by `backend/tests/features/battle-ledger/drills.test.ts`, so it -runs on every CI pass rather than being performed once and slowly becoming untrue. A drill -that only ever lived in this file would describe a system nobody had checked in months. - -What the tests cannot cover is the human half — who gets paged, who decides, how long it -takes. That is what the procedures here are for. - -## Turning the mode on and off - -`BATTLE_BACKEND_MODE_ENABLED=true` enables it. Off by default. - -| Off | On | -| --- | --- | -| `POST /api/battle/intents`, `/accept`, `/authorizations` return **503** | accepted | -| `DELETE /api/battle/authorizations` still works | works | -| every read route and `/api/receipts/*` still works | works | -| the outbox worker does not start | runs | -| no signing key required | required | - -**Switching the mode off does not retract anything.** Receipts already issued stay served, -and the public corpus stays public. That asymmetry is the point: §H's claim is that anyone -can check what we did, and a feature flag that could un-publish past evidence would turn -every issued receipt into an assertion. Turning the mode off stops new battles only. - -Revocation is ungated for the same class of reason — refusing battles is never the -dangerous direction, so a defender must be able to withdraw consent even after the mode is -off. - -### Kill switch - -Set `BATTLE_BACKEND_MODE_ENABLED=false` and restart. Battles already in flight stop -advancing (the worker is gone) and stay in whatever state they reached; they resume when -the mode is turned back on, because state lives in the ledger rather than in the worker. -Pets stay locked in the meantime — see *Stuck battles* below if that window is long. - -## Drill 1: recovery - -**Scenario.** A dependency failed long enough that outbox messages exhausted their retries -and dead-lettered. Battles are parked mid-pipeline. - -Dead-lettering is deliberately not automatic-retry exhaustion to be undone by a cron. It -parks the battle for a person, because a message that failed eight times with exponential -backoff is usually failing for a reason that retrying will not fix. - -**Procedure.** - -1. List what is parked: `listDeadLetters()`. Each entry names the `battleId`, the `topic` - it died on, and `lastError`. -2. Group by `lastError`. One shared cause (drand unreachable, indexer-go down, signer - unconfigured) is the common case and means one fix. -3. Fix the cause. Confirm it is actually fixed before requeuing — a requeue against a still - broken dependency just burns the retry budget again. -4. Requeue each message: `requeueDeadLetter(id, new Date())`. Attempts reset so backoff - starts fresh. `lastError` is deliberately left in place; a requeue is not evidence the - cause is gone. -5. Watch the battles advance. Anything that dead-letters a second time on the same error is - not a transient failure and needs the incident procedure below. - -**What is safe about this.** Requeuing cannot double-apply anything. Every worker is -idempotent on its own transition — each checks the battle's current state and completes the -message as a no-op if another worker already moved it — so a message that actually -succeeded before dying is harmless to run again. - -## Drill 2: replay - -**Scenario.** Confirming that receipts this deployment issued verify independently. Run -routinely, not only during an incident: the value of a receipt is that someone outside can -check it, and a claim nobody has ever tested is not worth much. - -**Procedure.** - -1. Export a corpus: `GET /api/receipts?signingKeyId=` and page through `nextAfter`. -2. Fetch the published keys: `GET /api/battle/signing-keys`. -3. Run the standalone verifier over them, from a checkout with no access to this backend: - ```bash - pnpm --filter @cryptopets/verifier cli -- ./corpus.json --keys ./keys.json - ``` -4. Every check must pass and the exit code must be `0`. - -The verifier holds its own pinned ruleset bundles, so this works with the backend entirely -unreachable — which is the situation the drill is really rehearsing. - -**If it fails.** A failing check is not automatically our fault: confirm the corpus and key -list were fetched completely and that the ruleset the receipts name is one the verifier -holds (`ruleset-unavailable` means it is not). A genuine `combat-replay`, -`beacon-signature`, or `operator-signature` failure is an incident — go to Drill 4. - -## Drill 3: key rotation - -**Scenario.** Routine rotation, or a key approaching the end of its validity window. For a -*compromised* key, stop and use -[runbook-signing-key-compromise.md](./runbook-signing-key-compromise.md) instead. - -**Procedure.** - -1. Provision the new key in the KMS. It never leaves the KMS; the backend holds a reference. -2. Register the outgoing key as rotated: `registerRotatedKey(descriptor)` with `notAfter` - set. It stays published from `GET /api/battle/signing-keys` permanently. -3. Point the signer at the new key and restart. -4. Confirm `GET /api/battle/signing-keys` lists **both**, and that a receipt signed under - the old key still verifies. - -**The rule that matters.** A retired key is never removed from the published list. Receipts -signed under it must keep verifying forever, and delisting a key silently invalidates every -receipt it ever signed — a retroactive erasure of evidence, which is exactly what §G's -validity windows exist to make unnecessary. - -**Durability.** The registry is persisted in `battle_signing_key` and reloaded at startup, so -a rotated key keeps being published across restarts and deploys. Two properties are worth -knowing during an incident: - -- Any key that is not the one currently signing is reported as **rotated**, whatever the row - says. Swapping keys without calling `registerRotatedKey` still leaves the old key - published — the safe direction to fail in. -- **`compromised` is sticky.** Once a key is marked compromised it stays marked, and is never - reported active again, even if configuration points back at it. "This key may have signed - things we did not authorise" is a fact about history that a restart must not quietly - downgrade to a routine rotation. - -If `registerRotatedKey` logs a persistence failure, the key is published by the running -process but will not survive a restart. Re-run it once the database is reachable; it is -idempotent. - -## Drill 4: incident - -### Engine mismatch (`verification_failed`) - -The TypeScript engine and the Go verifier disagreed, so the battle was never signed. This is -the circuit breaker doing its job. - -1. **Do not sign it.** There is no override, deliberately: signing something two engines - disagree about is the one action that cannot be walked back. -2. Read `verificationDetail` on the ledger row. It holds both outputs and the field-level - mismatches. -3. Reproduce offline from the receipt's inputs — the snapshot, seed, and ruleset are all in - the row. -4. Whichever port is wrong, fix that port and rerun the golden vectors. **Never edit the - vectors** (`AGENTS.md`). -5. Affected battles stay `verification_failed`. They are not retried into existence; the - honest outcome is that the fight did not resolve. - -### Shadow mismatch - -Shadow mode (§L Phase 2) says the backend engine disagreed with the chain. Same substance as -above, with a stronger signal: the chain is the reference implementation. Blocks the Phase 3 -gate until resolved. `shadowSummary()` is the durable record. - -### Stuck battles - -A battle not advancing is one of: a dead letter (Drill 1), a committed drand round that has -not published (waits, then forfeits — by design), or a worker that is not running (check the -mode flag). - -Both pets stay locked until the battle reaches `signed` or a terminal state. If a battle is -genuinely unresolvable, moving it to a terminal state is what releases them; leaving it -pending indefinitely is worse for the player than a forfeit. - -### Drand outage - -Committed rounds are never substituted — §E allows only "keep waiting" or "give up". An -outage past `BATTLE_FORFEIT_AFTER_SECONDS` forfeits affected battles, with no progression -change. If an outage is ongoing, turn the mode off rather than let battles accumulate -toward mass forfeiture. - -## Drill 5: opening a reward season - -**Scenario.** A season's battles are anchored and it is time to pay out. This is the only -procedure here that moves real value, so it is the one worth rehearsing on a testnet first. - -**Procedure.** - -1. Confirm the receipts are **anchored**, not merely signed. `buildSeason` only counts - anchored receipts, but check the batch backlog is drained rather than discovering a - short season afterwards. -2. Build the season: sequence range, distributor address, token, and rates. The season is - written with its rates and range so anyone can recompute the root from the public corpus. -3. Fund the distributor with at least the season total. -4. Choose caps and dry-run them with `boundsViolations` before committing to any. It is - pure, so this costs nothing and answers "would this season open" directly. -5. `openSeasonOnChain`. It refuses unless every entitlement fits the per-wallet cap, the - total fits the season cap, and the distributor already holds the full amount — and - reports every failing reason at once rather than one transaction at a time. -6. Spot-check a claim proof against the on-chain root before announcing anything. - -**The rule that matters.** Caps are enforced per claim, first come first served. A season -opened over its cap, or underfunded, pays whoever claims first and reverts on whoever claims -last (threat T20). That is why the bound is checked *before* the root is posted: afterwards, -the season is immutable and the only remedy is a second season making people whole. - -**Sweeping.** `sweepUnclaimed` only works after the claim window closes, so it cannot be -used to pull funds out from under people still entitled to them. - -## Drill 6: a bad season root - -**Scenario.** A season was opened with wrong entitlements. - -1. **Pause the distributor.** This stops claims without touching battles — the registry and - the battle path are separate contracts precisely so one can be halted without the other. -2. Work out who was overpaid before the pause. Claims are events; the nullifier mapping says - who has claimed. -3. **The season cannot be corrected in place.** `openSeason` refuses to reopen a season, and - that refusal is deliberate: a rewritable root would let entitlements change after people - had read them. The remedy is a new season that makes the difference up. -4. Unpause once the replacement is ready, or leave paused and sweep after the window if the - season is being abandoned entirely. - -**Note on the owner key.** The distributor owner can open seasons, pause, and sweep after -close. It cannot rewrite an open season, mint, or take funds mid-window. That is the blast -radius to assume if the key is compromised — and it is why the owner should be a multisig -behind a timelock (§I) rather than a hot wallet. - -## What this mode deliberately does not do - -- **No reward inside a receipt.** Receipts carry no `rewardDelta` at any setting, and they - never will — rewards are computed *from* anchored receipts into a separate season tree, so - a receipt stays a statement about a fight rather than a promise of payment. Nothing pays - out until a season is deliberately built, funded, bounded, and opened (Drill 5). -- **No rating.** There is no rating or matchmaking-score system in this repo yet. §L Phase 3 - lists "off-chain XP, rating, and cooldown"; XP and cooldown exist in `pet_battle_progress`, - stored separately from NFT state. Rating is a game-design decision — what it measures, how - it decays, whether it is public — and is not something to invent as a side effect of - shipping this mode. -- **No NFT mutation.** Backend battles never write pet state on chain. Off-chain progression - lives in `pet_battle_progress`, keyed separately from `pet_roster`, so the two can never be - confused for each other. diff --git a/docs/runbook-signing-key-compromise.md b/docs/runbook-signing-key-compromise.md deleted file mode 100644 index 5205a15c..00000000 --- a/docs/runbook-signing-key-compromise.md +++ /dev/null @@ -1,134 +0,0 @@ -# Runbook: battle signing key compromise - -Applies to the KMS keys that sign `BattleCommitment` and `BattleReceipt` objects, and to the root -publisher key that anchors Merkle batches. See -[threat-model-backend-battles.md](./threat-model-backend-battles.md) T4 and T16. - -Assume compromise means an attacker can produce signatures that verify against a published key. It -does not mean they can move assets: the battle signing key has no custody and no withdrawal -authority, and the root publisher sits behind multisig and timelock. That is what buys time here. - -**Bias towards pausing.** A false alarm costs players a few hours of battles. A missed compromise -costs the integrity of every receipt signed in the window. - -## Triggers - -Any one of these starts this runbook. Do not wait for confirmation of intent. - -- KMS audit log shows a signing request the pipeline cannot account for (no matching ledger row, no - matching digest). -- Signer throughput outside expected range, or signing requests from an unexpected principal, - network path, or region. -- A receipt or commitment exists in the public corpus with no corresponding ledger row. -- Hash-chain fork: two signed receipts claiming the same `previousReceiptHash`, or two commitments for - one `battleId`. -- A player produces a signed commitment or receipt we did not issue. -- Credential exposure: KMS principal credentials in a log, repo, image, or CI artifact. -- Cloud provider or KMS vendor notifies us of key or account compromise. - -## Roles - -| Role | Owns | -|---|---| -| Incident lead | Declares the incident, owns the timeline, makes the pause call | -| Signer owner | KMS policy changes, key disable, rotation | -| Chain owner | Root registry pause, multisig coordination | -| Verifier owner | Corpus re-verification, fork analysis | -| Comms owner | Player-facing status, disclosure | - -One person may hold several roles. The pause call is never blocked on availability: if the incident -lead is unreachable, the signer owner pauses. - -## Phase 1: contain (target: 15 minutes) - -Order matters. Stop the bleeding on-chain first, because that is the only irreversible surface. - -1. **Pause the on-chain surfaces.** Emergency pause on the root registry and the claim contract. No - new roots accepted, no claims processed. This is the only step that prevents economic loss. -2. **Disable the suspect key in KMS.** Deny all signing operations on that key version. Do not delete - the key and do not delete its public record, which is needed for later verification. -3. **Stop receipt signing.** Trip the signer circuit breaker. Battles already `committed` stay in - `verified` and are not lost. Battle acceptance also stops, since acceptance requires a signed - commitment and an unsigned acceptance would break invariant 1. -4. **Snapshot evidence.** KMS audit logs, signer access logs, ledger tables, published corpus, and - the current chain tips of all three receipt chains. Copy to write-once storage before anything is - rotated or restored. -5. **Freeze deploys.** No code or infrastructure changes to the signer path until Phase 4. -6. **Declare the incident** and record the suspected compromise window opening time. When unknown, - use the earliest plausible time, not the most convenient one. - -## Phase 2: assess (target: 4 hours) - -Establish the compromise window and what was signed inside it. - -1. **Reconcile KMS to ledger.** For every signing request in the window, match the digest to a ledger - row. Unmatched digests are forged-signature candidates and define the real window. -2. **Reconcile corpus to ledger.** Every published receipt and commitment must have a ledger row with - the same payload. Extra corpus entries mean forged artifacts were served. -3. **Run the verifier over the window.** `verifier` over the affected sequence range. Failures split - into: signature invalid, beacon invalid, replay mismatch, chain discontinuity. Replay mismatch on - an otherwise valid signature is the strongest evidence of forgery, because our pipeline cannot - produce it. -4. **Walk the chains.** Global chain and the per-pet chains for every pet touched in the window. Note - every fork point and both branches. A fork with two valid signatures is provable equivocation and - must be preserved exactly as found. -5. **Check batches.** Which anchored roots include window receipts. Which of those had claims against - them. Compute worst-case economic exposure against the caps. -6. **Classify.** Confirmed compromise, suspected, or false alarm. A false alarm exits at Phase 4 with - the pause lifted and a post-incident note. Do not skip Phase 4. - -## Phase 3: rotate and recover - -Only after the window is bounded. - -1. **Generate a new key** in a fresh KMS key with a new `signingKeyId`. New credentials, new - principal, minimal network path. Never reuse the old principal. -2. **Publish the key registry update.** New key with its `notBefore`. Old key marked compromised with - its validity end set to the window opening time, and **retained**, because historical receipts - still verify against it. Never remove a rotated-out key from the registry. -3. **Publish the compromise window** as a first-class record: `signingKeyId`, window start and end, - affected sequence ranges, and the list of receipts we attest to as pipeline-produced. Players and - third-party verifiers need this to interpret their own copies. -4. **Do not re-sign history under the new key.** Re-signing changes nothing about what happened and - destroys the evidence trail. Instead publish an attestation list: the receipt hashes we confirm - our pipeline produced, signed with the new key. Verifiers then treat an in-window receipt as valid - only if it appears in the attestation list. -5. **Do not renumber sequences.** Gaps and forks stay visible. Continue the chain from the last - attested receipt, recording the discontinuity explicitly. -6. **Handle in-flight battles.** Battles in `verified` at pause time resolve normally under the new - key. Battles in `committed` whose round has published resolve normally. Battles whose committed - round has passed the beacon timeout become `forfeited` with no progression change. -7. **Reverse or freeze bad claims.** Claims against forged inclusion stay paused. Nullifiers already - consumed cannot be reused, so genuine claimants inside a poisoned batch need a re-issued batch - under a new root rather than a retry. -8. **Lift the pauses** in the reverse of Phase 1: signer, then acceptance, then root registry, then - claims. Claims last, because they are the only irreversible surface. - -## Phase 4: post-incident - -- Timeline with detection latency, containment latency, and every decision point. -- Which detection fired, and which should have fired first. If detection came from a player, that is - the headline finding. -- Whether reward caps bounded the exposure as designed. If not, lower the caps before resuming. -- Whether the escalation threshold in the threat model §6 has been reached. -- Public disclosure: what was signed, what was attested, what players should check themselves. The - design's entire premise is that we publish our homework, so a compromise is disclosed with the same - detail we would want if we were the player. - -## Never do these - -- Delete or unpublish an old public key. Historical verification depends on it. -- Delete a forged receipt from the corpus without recording it. The fork is the evidence. -- Re-sign or rewrite historical receipts under the new key. -- Renumber sequences or repair a chain by regenerating links. -- Substitute a different drand round for an unresolved battle, even to clear the queue. That breaks - invariant 2 and is exactly the behaviour T1 is designed to make impossible. -- Restore Postgres to a point before the published corpus without reconciling against it (T19). - -## Drill - -Run this as a live drill in Phase 3 of the implementation plan, before anything of value is at stake -(Step 36). The drill must cover: pause, key disable, evidence snapshot, corpus reconciliation, -verifier run over a range, rotation with registry publication, attestation-list publication, and -resumption. Record the wall-clock time of each phase and correct the targets above to what the drill -actually achieves. diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 00000000..33e67389 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/docs/threat-model-backend-battles.md b/docs/threat-model-backend-battles.md deleted file mode 100644 index 10a3f86c..00000000 --- a/docs/threat-model-backend-battles.md +++ /dev/null @@ -1,291 +0,0 @@ -# Threat model: backend-authoritative battles - -Scope: the design in [plan-backend-battle-architecture.md](./plan-backend-battle-architecture.md). -Step references point at [plan-backend-battle-steps.md](./plan-backend-battle-steps.md). - -This document exists because moving battle resolution off-chain moves it inside our trust boundary. -Section §A of the architecture document states the trust model in one table. This is the expanded -version: who attacks what, what stops them, how we notice, and what is left over. - -It covers the backend battle path only. The existing on-chain path (`GameLogic.sol`, -`settle_battle.rs`) keeps its own properties and is not re-analysed here. - -## 1. Assets - -| Asset | Why it matters | Authority | -|---|---|---| -| Pet and item ownership | Transferable value | Chain | -| Reward custody and claims | Transferable value | Chain | -| Battle signing key | Signs commitments and receipts; forgery source | KMS | -| Root publisher key | Anchors batches on-chain | KMS + multisig | -| Off-chain progression (XP, rating, streak) | Determines rewards and matchmaking | Backend | -| Signed receipt corpus | The evidence players hold against us | Backend, published | -| Commitment sequence | Proves which round each battle was bound to | Backend, delivered to players | - -## 2. Actors - -| Actor | Assumed capability | -|---|---| -| Player | Can sign with their own wallet, replay traffic, script requests, disconnect at will | -| External attacker | Network position, can hit any public endpoint, no keys | -| Insider with database access | Read and write Postgres, no KMS signing scope | -| Insider with signer access | Can request signatures over well-formed commitments and receipts | -| Dishonest operator | Controls all backend processes, both combat implementations, and the database | -| drand network | Assumed honest and live; a threshold of nodes would have to collude to bias a round | - -The dishonest-operator row is the uncomfortable one and it is deliberate. Most controls below do not -stop that actor. They make the actor's lies detectable by anyone holding a commitment or a receipt. - -## 3. Threats - -Each row: what the attacker does, what stops or bounds it, how we notice, what is left. - -### T1: randomness reroll after seeing the value - -- **Attack.** Operator watches drand, computes the result, dislikes it, and claims the battle was - always bound to a later round. Recompute, publish, everything self-consistent. -- **Control.** Commit before reveal (§E). The round is chosen mechanically as - `currentVerifiedRound + 2`, and the signed `BattleCommitment` is returned synchronously in the - accept response, before the round exists (Step 23). -- **Detection.** A reroll needs a second signature over the same `battleId`. Either player's stored - commitment plus the published receipt is a provable equivocation. -- **Residual.** Only detectable if players keep their commitment. The client persists it to local - storage (Step 33) and the endpoint serves it, but a player who never fetched it holds no evidence. -- **Non-control.** Persisting the chosen round in Postgres proves nothing. It is our database. - Merkle anchoring does not help either, because anchoring happens after computation. - -### T2: lying about the result - -- **Attack.** Publish a receipt whose winner does not follow from its own inputs. -- **Control.** None preventive. The receipt carries every input, so the result is recomputable. -- **Detection.** Public replay (§H). Anyone runs the verifier and the check fails. -- **Residual.** Only caught if someone runs the verifier. That is why it ships in Phase 3 while - nothing of value is at stake (Steps 30 to 32), and why we run it in CI against a corpus fixture - and monitor it ourselves. - -### T3: hiding a battle - -- **Attack.** Resolve a battle, dislike the outcome, never publish the receipt. -- **Control.** None preventive. Receipts are sequenced and hash-chained globally and per pet (§G). -- **Detection.** A gap breaks the chain. The per-pet chain also means a player who holds their own - commitment can show a committed battle with no corresponding receipt. -- **Residual.** Visible, not impossible. If omission risk becomes unacceptable, §I's delayed - direct-receipt claim fallback or an optimistic challenge protocol is the next step. - -### T4: signing-key compromise - -- **Attack.** Stolen key signs arbitrary commitments and receipts. -- **Control.** Key in KMS, never in API or worker environments. Signer accepts only the exact - commitment and receipt schemas, never a generic state-mutation payload. No asset custody, no - withdrawal authority. Separate keys per reward domain. Reward caps bound the economic damage - (Step 22, §I). -- **Detection.** KMS request logging of every digest and key version. Signer throughput outside - expected range. Receipts that exist in the corpus but not in the ledger. Hash-chain forks. -- **Residual.** Real. Bounded, not eliminated. See - [runbook-signing-key-compromise.md](./runbook-signing-key-compromise.md). - -### T5: outcome grinding by submit-and-abandon - -- **Attack.** Submit many battles, abandon the ones that seed badly, keep the good ones. -- **Control.** No player-initiated cancellation after `committed` (§E). Disconnection, tab close, - and app kill do not affect resolution. Per-wallet and per-pet rate limits, daily battle caps. -- **Detection.** Abandonment rate per wallet. Win distribution per wallet against the expected - distribution for their matchups. -- **Residual.** A player can still choose which opponents to fight. That is matchmaking design, not - a randomness leak. - -### T6: manufactured beacon outage - -- **Attack.** Player degrades their own connectivity, or an attacker degrades ours, to escape a - battle already seeded against them. -- **Control.** The committed round is retried indefinitely and never substituted. On a genuine - permanent outage past the timeout, the battle ends `forfeited` with no progression change and both - pets stay locked for several rounds, so escaping costs more than losing (§E). -- **Detection.** drand fetch delay metric. Forfeit rate per wallet. -- **Residual.** A wallet that repeatedly forfeits is a rate-limit and abuse-policy matter. - -### T7: intent replay - -- **Attack.** Resubmit a captured signed intent to force extra battles. -- **Control.** `clientNonce` with a unique database constraint, `expiresAt`, and nonce consumption at - acceptance (§D, Step 19). -- **Detection.** Repeated-nonce alert. -- **Residual.** None material. - -### T8: cross-chain or cross-deployment replay - -- **Attack.** Take a signature from staging and use it on production, or across chains. -- **Control.** Every signed object binds `chainId` and `deploymentId` (§D, Step 6). Intents, - consents, commitments, and receipts all carry both, inside the hashed payload. -- **Detection.** Domain mismatch is a hard rejection, logged. -- **Residual.** None material, provided `deploymentId` is genuinely unique per environment. - -### T9: forged defender consent - -- **Attack.** Battle an unwilling defender, applying cooldown and rating changes to them. -- **Control.** `DefenseAuthorization` signed by the defender's wallet, bound to `rulesetHash`, with - level band, daily cap, validity window, and `revocationNonce`. Every receipt embeds the hash of - the authorization it relied on (§D, Step 20). -- **Detection.** Receipts referencing an unknown or revoked authorization hash fail public replay. -- **Residual.** Consent is to a ruleset version, so a rules change invalidates outstanding - authorizations by design. Expect a re-consent prompt after every balance patch. - -### T10: stale ownership after an NFT transfer - -- **Attack.** Battle with a pet already sold, or snapshot a pet mid-transfer. -- **Control.** Ownership checked at the finalized source version, snapshot records - `sourceChainVersions` (§G). Reconciliation job between finalized chain ownership, snapshots, - receipts, and claims (§J). -- **Detection.** Reconciliation mismatch. -- **Residual.** Reorg depth on the source chain sets the finality wait, which is a latency cost, not - a correctness gap. - -### T11: concurrent battles with the same pet - -- **Attack.** Race two battles for one pet so one snapshot is stale or a cooldown is skipped. -- **Control.** Both pets locked in deterministic id order inside a serializable transaction - (Step 18). Snapshot persisted before randomness exists. -- **Detection.** Serialization-failure rate, duplicate-battle-id alert. -- **Residual.** None material. This is a correctness test target, not a monitoring target. - -### T12: duplicate workers - -- **Attack.** Two workers process the same transition, double-crediting progression or forking a - hash chain. -- **Control.** Every transition idempotent, at-least-once processing assumed, each transition and its - outbox message committed atomically. A duplicate battle id with a different payload is a security - alert and never an upsert (§J, Step 18). -- **Detection.** Hash-chain discontinuity alert. Duplicate-payload alert. -- **Residual.** None material. - -### T13: forged snapshot inputs - -- **Attack.** Inflate a pet's stats, level, or equipment inside the snapshot. -- **Control.** Snapshot fields derive from indexed chain state at a recorded source version. - Progression fields (`xp`, `streak`, `lastOpponentId`) are off-chain, so they are only checkable by - replaying that pet's prior receipts, which the per-pet hash chain makes tractable (§G). -- **Detection.** Public replay walking a pet's chain catches a snapshot that does not follow from the - previous receipt's `progressionDelta`. -- **Residual.** Equipment ownership must be verifiable from chain or from a signed inventory record - before equipment affects combat. Until then, keep equipment out of combat inputs. - -### T14: combat log leaks outcomes to spectators - -- **Attack.** Read the outcome of every resolving battle by connecting to the WebSocket. -- **Control.** `liveBattleSocket.ts` currently broadcasts every message to every client. That is - acceptable for chain-derived data and not acceptable for full combat logs. Subscriptions scope to - the existing `BattleRoom` and the socket becomes notification-only (§J, Step 29). -- **Detection.** Route-level test asserting no cross-room delivery. -- **Residual.** Room ids are shareable by design, so a room link is a spectator link. - -### T15: commitment accepted but never delivered - -- **Attack.** Or, more likely, a bug. Accept succeeds, the player never receives the signed - commitment, and the only record of the chosen round is ours. T1 is then undetectable for that - battle. -- **Control.** Commitment signed and returned synchronously in the accept response, and also served - from a public endpoint so it is re-fetchable (Steps 23, 27). -- **Detection.** Dedicated alert on accept-succeeded-without-commitment-delivery (§J). -- **Residual.** A player who never fetches it still holds no evidence. The public endpoint bounds - this to non-malicious loss. - -### T16: fraudulent or omitted reward batch - -- **Attack.** Anchor a root covering receipts that were never signed, or omit signed receipts from - every batch. -- **Control.** Per-battle, per-wallet, per-batch, and per-season reward caps. One-time claims with - nullifiers. Emergency pause. Root publishers behind multisig and timelock. Published - receipt-to-root inclusion proofs (§I). -- **Detection.** Receipt-omission alert past the inclusion SLO. Root-anchor delay alert. Verifier - Merkle-inclusion check (Step 32). -- **Residual.** An unanchored signed receipt is evidence of operator failure, not an on-chain claim. - -### T17: denial of service against popular opponents - -- **Attack.** Flood a specific defender to exhaust their daily cap or keep their pets locked. -- **Control.** Per-wallet and per-pet rate limits, defender daily battle cap set by the defender - themselves in their authorization (§D). -- **Detection.** Per-defender request-rate anomaly. -- **Residual.** A popular defender's cap is consumed by whoever gets there first. Matchmaking policy, - not a cryptographic problem. - -### T18: verifier collusion - -- **Attack.** The Go verifier does not constrain a dishonest operator, because the same operator runs - both processes and both ports descend from `CombatSim.sol`. -- **Control.** None, and none is claimed. The Go verifier's role is release safety: it catches - implementation drift, bad deploys, and transcription bugs, and it hard-stops receipt signing on any - mismatch (§F, Step 25). -- **Detection.** Engine/verifier mismatch alert with both outputs and all inputs retained. -- **Residual.** Dishonest computation is caught by public replay (T2), not by this. - -### T19: database rollback or restore - -- **Attack.** Restore Postgres to an earlier point and lose or rewrite receipts, including via an - honest recovery. -- **Control.** Receipts are append-only and hash-chained, and the corpus is published, so an - external copy exists outside the database. Append-only audit events for every transition. -- **Detection.** Chain discontinuity between the restored database and the published corpus. -- **Residual.** Recovery procedure must reconcile against the published corpus, not just restore. - Point-in-time recovery drills have to include that reconciliation (Step 36). - -### T20: a season nobody can fully claim - -- **Attack.** Not an attack so much as a self-inflicted one, which is why it is easy to miss. The - reward caps in `SeasonRewardDistributor` are enforced *per claim*, first come first served. Open a - season whose total exceeds its season cap, or whose distributor is underfunded, and early claimants - are paid in full while the last ones get a revert they did nothing to earn. An entitlement above - the per-wallet cap is worse: that wallet can never claim at all, and finds out only by trying. -- **Control.** `boundsViolations` refuses to open a season unless every entitlement fits the - per-wallet cap, the total fits the season cap, and the distributor already holds the full amount - (Step 39). All three are checked before the root is posted, where the answer is still "do not open - this season" rather than "some people lost". The check is pure, so candidate caps can be tested - before any of them are committed to. -- **Detection.** Refusal at open time, with every failing reason reported at once. After opening, - a claim reverting with `ExceedsSeasonCap` means this check was bypassed. -- **Residual.** The caps still protect against a *bad* root, which is their real job; this control - only stops a *correct* season from being opened in an unpayable state. A distributor drained by - some other means after opening reintroduces the same race, so the balance is a precondition rather - than a guarantee. - -## 4. Invariants - -These are the properties tests and alerts exist to defend. Any one of them breaking is an incident, -not a bug report. - -1. A `BattleCommitment` is signed and returned to the player before its committed drand round - publishes. -2. The committed round is never substituted. Retry the same round or forfeit. -3. A battle that reaches `committed` always resolves. `rejected` exists only before `committed`. -4. The snapshot is persisted before any randomness for that battle exists. -5. The seed is derived only from the committed beacon value under the §E derivation. Never from - timestamps, uuids, or backend secrets. -6. A receipt is signed only when the TypeScript engine and the Go verifier agree exactly. -7. Every receipt links its predecessor in the global chain and in both per-pet chains. -8. One `battleId` has at most one signed commitment and at most one signed receipt. A conflicting - payload is an alert, never an upsert. -9. The signer accepts only commitment and receipt schemas, and holds no asset authority. -10. Off-chain XP is never represented as NFT state unless a successful aggregate claim applied it - on-chain. - -## 5. Accepted residual risk - -Stated plainly, because §9 of the architecture document commits to stating it plainly. - -- **A stolen signing key can sign lies.** Bounded by key isolation, schema restriction, reward caps, - and the runbook. Not eliminated. -- **We can refuse to publish.** The chains make it visible, not impossible. -- **The receipt proves what we published, not that we were honest.** Public replay is the control, - and it only works if replay actually happens. -- **The Go verifier does not constrain us**, only our deploys. - -## 6. Escalation threshold - -This design is proportionate while battle outcomes drive progression and capped, aggregate season -rewards. It stops being proportionate when a single battle's outcome carries significant transferable -value, or when reward caps have to be raised beyond what we would accept losing to a key compromise. - -At that point the next steps are §M's deferred options: per-battle backend signature verified -on-chain (Phase 3.5), optimistic settlement with bonded challenges, or proof-based settlement. That -threshold should be crossed deliberately, with a review, not drifted past by raising caps one -increment at a time. diff --git a/frontend/env.example b/frontend/env.example index 2f767196..c7b01a02 100644 --- a/frontend/env.example +++ b/frontend/env.example @@ -27,4 +27,4 @@ VITE_API_URL=http://localhost:3001 # Local dev: `pnpm dev:art` serves the service on :8787, and the value below is # what talks to it. Point at the deployed service in production. VITE_IMAGE_SERVICE_URL=http://localhost:8787 -# VITE_IMAGE_SERVICE_URL=https://art.cryptopets.io +# VITE_IMAGE_SERVICE_URL=https://do-not-stop-image-generator.onrender.com diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 886f4c99..9cd9b6f1 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -35,8 +35,13 @@ + "strong" ends of each token (cp-cyan-strong, cp-violet-strong, + cp-magenta-strong), which is what holds up unbacked on a light tab. + + Those token names are written without their leading dashes on purpose: + an XML comment may not contain a double hyphen anywhere, so spelling + them out as CSS custom properties makes the whole file malformed and + every browser silently falls back to the default icon. --> diff --git a/frontend/src/assets/nav-icons/leaderboard.svg b/frontend/src/assets/nav-icons/leaderboard.svg new file mode 100644 index 00000000..df836137 --- /dev/null +++ b/frontend/src/assets/nav-icons/leaderboard.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/nav-icons/messages.svg b/frontend/src/assets/nav-icons/messages.svg new file mode 100644 index 00000000..a758ef21 --- /dev/null +++ b/frontend/src/assets/nav-icons/messages.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/chat/index.module.css b/frontend/src/components/chat/index.module.css new file mode 100644 index 00000000..d206115f --- /dev/null +++ b/frontend/src/components/chat/index.module.css @@ -0,0 +1,625 @@ +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. + Private chat: thread rail plus one conversation. + + Violet is the page's accent, not the magenta the marriage panels use. Chat borrowed + that wholesale and every surface ran hot at once — bubbles, avatars, chips, the send + button. Violet is the same neon family a step cooler, carried at low alpha so the pet + art is the one saturated thing on screen; the faces are what the page is for. Cyan + stays reserved for signals that must not read as decoration: the seen receipt. */ + +/* The messaging panel is the one screen that should end at the bottom of the window + rather than at the bottom of its content. + + `height: 100%` and not a flex rule: the shell's content slot is a scrollable *block* + (`overflow: auto`, no `display: flex`), so a `flex` declaration on the panel is inert + and it just sizes to its children. The slot does have a definite height — it is a + flex item of the shell's column — so a percentage resolves against it, and it resolves + against the slot's *content box*, which is why the panel lands inside the shell's + padding instead of overflowing it. Everything below here is a flex column already, so + the height flows down to the transcript on its own. */ +.page { + height: 100%; +} + +/* Fills the panel body instead of sizing to its content. Every ancestor from the app + shell down is already a flex column that owns the viewport height, so the conversation + should end at the bottom of the panel's padding rather than at a fixed height with + dead space beneath it. `min-height: 0` is what lets the transcript scroll inside + rather than pushing the composer past the bottom edge. */ +.layout { + display: grid; + grid-template-columns: minmax(0, 240px) minmax(0, 1fr); + gap: 14px; + flex: 1; + min-height: 0; + align-items: stretch; +} + +.threadList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + /* Scrolls on its own so a long thread list cannot stretch the row and push the + conversation off the bottom. */ + min-height: 0; + overflow-y: auto; +} + +.threadButton { + width: 100%; + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgb(148 163 184 / 14%); + background: var(--cp-surface); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.threadButton.isActive { + border-color: rgb(181 140 255 / 45%); + background: rgb(181 140 255 / 7%); +} + +.threadName { + font-size: 0.9rem; + font-variant-numeric: tabular-nums; +} + +.threadSub, +.conversationSub { + font-size: 0.75rem; + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border-radius: 10px; + border: 1px solid rgb(148 163 184 / 14%); + background: var(--cp-surface); +} + +.conversationHeader { + display: flex; + align-items: baseline; + gap: 10px; + padding: 12px 18px; + border-bottom: 1px solid rgb(148 163 184 / 12%); +} + +.conversationTitle { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.95rem; +} + +/* Presence. Grey is the resting state and green is the exception, so a thread that is + simply quiet does not look like an error. Sized in em so it tracks the title. */ +.dot { + width: 0.55em; + height: 0.55em; + flex-shrink: 0; + border-radius: 50%; + background: rgb(148 163 184 / 55%); +} + +.dot.isOnline { + background: rgb(52 211 153); + /* A faint halo so it reads as "lit" at this size rather than as a dark speck. */ + box-shadow: 0 0 0 3px rgb(52 211 153 / 18%); +} + +/* Stated plainly rather than blocking the composer: sending still works while the + notification channel is down, it just will not update on its own. */ +.offline { + margin-left: auto; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.5; +} + +.messages { + list-style: none; + margin: 0; + /* Roomier than the bubbles strictly need. A short message's reactions are wider than + the bubble they hang under, so they reach closer to the panel edge than anything + else does, and at the old inset they read as spilling out of it. */ + padding: 14px 18px; + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.message { + max-width: 78%; + align-self: flex-start; + display: flex; + /* A column: the pet-and-bubble row, then any reactions beneath it. */ + flex-direction: column; + align-items: flex-start; + gap: 3px; +} + +.message.isMine { + align-self: flex-end; + align-items: flex-end; +} + +.row { + display: flex; + /* Bottom-aligned: the face sits beside the last line of a run, where the run ends. */ + align-items: flex-end; + gap: 8px; + min-width: 0; + max-width: 100%; +} + +/* Reversed rather than a separate rule per child: the sender's own messages put the + face on the same side as the bubble they belong to. */ +.message.isMine .row { + flex-direction: row-reverse; +} + +.bubble { + min-width: 0; + display: flex; + /* Text, then time and receipt beneath it. A row put the time on the first line's + baseline, so a message that wrapped left it stranded at the top of the bubble with + the text running on below it. */ + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 8px 14px; + border-radius: 12px; + background: rgb(148 163 184 / 12%); + /* `#root` in App.css centres the whole app, so anything that wants ordinary prose + has to say so — see `.threadButton`, which already had to. A centred second line + under a longer first one does not read as a message. The meta below is placed by + `align-self`, which this does not touch. */ + text-align: left; +} + +/* The bubble's bottom-right corner, wherever the text ends. */ +.messageMeta { + align-self: flex-end; + display: flex; + align-items: center; + gap: 5px; +} + +.message.isMine .bubble { + background: rgb(181 140 255 / 13%); +} + +/* The pet behind each side of the thread. Round, because that is what reads as a + messenger avatar; square frames belong to the pet panels. PetArt draws at 1em, so + font-size is what sizes the image (see PetArt). */ +.messageFace, +.messageFaceGap { + flex-shrink: 0; + width: 28px; +} + +.messageFace { + display: flex; + align-items: center; + justify-content: center; + height: 28px; + font-size: 1.5rem; + line-height: 1; + border-radius: 50%; + overflow: hidden; + background: radial-gradient(circle at 50% 45%, rgb(181 140 255 / 14%), transparent 68%); +} + +.messageFace img { + border-radius: 50%; +} + +.messageText { + /* User-authored text: wrap anywhere rather than let one long token stretch the row, + and keep the newlines the composer now allows. `pre-wrap`, not `pre`: the author's + line breaks are theirs, but the wrapping is still ours. */ + min-width: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; + font-size: 0.9rem; +} + +.messageTime { + flex-shrink: 0; + font-size: 0.7rem; + opacity: 0.5; + font-variant-numeric: tabular-nums; +} + +/* Pinned to the bottom of the card by the region above it filling, not by absolute + positioning. An absolutely positioned bar sits on top of the transcript unless the + scroll area reserves exactly its height, and that reservation has to be re-tuned by + hand every time the composer's padding, font or error line changes — it silently + covers the newest message when it drifts. `flex-shrink: 0` is the part that matters: + it keeps the bar at its natural height instead of being squeezed by a long + transcript. */ +.composer { + display: flex; + gap: 8px; + padding: 12px 18px; + border-top: 1px solid rgb(148 163 184 / 12%); + flex-shrink: 0; + /* The box grows upward from the bottom edge, so the Send button stays on the last + line rather than floating beside the middle of a long draft. */ + align-items: flex-end; +} + +.composer textarea { + flex: 1; + min-width: 0; + /* Height is assigned from scrollHeight, which already counts the padding. Without + this the padding lands twice and an empty box stands 16px taller than the single + line it holds. */ + box-sizing: border-box; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid rgb(148 163 184 / 22%); + background: transparent; + color: inherit; + font: inherit; + font-size: 0.9rem; + + /* Height is set inline as the draft grows; this caps it at roughly six lines, + after which the draft scrolls instead of eating the transcript. */ + max-height: 9.5rem; + resize: none; + overflow-y: auto; + line-height: 1.4; + scrollbar-width: thin; + scrollbar-color: rgb(181 140 255 / 28%) transparent; +} + +.composer button { + padding: 8px 16px; + border-radius: 8px; + border: 1px solid rgb(181 140 255 / 38%); + background: rgb(181 140 255 / 10%); + color: inherit; + font: inherit; + font-size: 0.85rem; + cursor: pointer; +} + +.composer button:disabled { + opacity: 0.4; + cursor: default; +} + +/* The transcript's stand-in states. They take the same space the message list would, so + the composer stays on the bottom edge whether the thread is loading, empty, or broken + — previously these hugged their one line of text and the composer rode up with them. */ +.placeholder { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + +.empty, +.error { + margin: 20px 14px; + font-size: 0.9rem; +} + +.empty { + opacity: 0.7; +} + +.error { + color: rgb(251 113 133); +} + +@media (width <= 720px) { + /* The rail becomes a strip above the conversation; two columns leave neither usable. + Rows are declared so the conversation still takes the remaining height rather than + splitting it evenly with the strip. */ + .layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + } + + .threadList { + flex-direction: row; + overflow-x: auto; + overflow-y: visible; + } + + .threadButton { + width: auto; + flex-shrink: 0; + } +} + +/* Sent / seen receipt, on your own messages only. + One tick once the server has the message — every message in the transcript came back + from the server, so a rendered message is a stored one — and two once the counterpart's + read watermark has passed it. The pair is tightened rather than drawn as one glyph so + it reads as two ticks at 0.7rem. + + Rides in `.messageMeta` beside the time, in the bubble's bottom-right corner. */ +.receipt { + flex-shrink: 0; + font-size: 0.7rem; + letter-spacing: -0.22em; + padding-inline-end: 0.22em; + opacity: 0.5; +} + +.receipt.isSeen { + color: rgb(125 214 255); + opacity: 1; +} + +/* ── Reactions ───────────────────────────────────────────────────────────────── + Under the row, indented past the pet so the chips line up with the bubble rather than + with the avatar beside it. 36px is the face plus the row's gap. */ +.reactions { + padding-inline-start: 36px; +} + +.message.isMine .reactions { + padding-inline-start: 0; + padding-inline-end: 36px; +} + +/* Only rendered when a message actually has reactions, so no rule is needed to hide it. */ +.reactions { + display: flex; + align-items: center; + gap: 4px; +} + +/* A circle, not a pill: a lone emoji in a stadium-shaped chip reads as a squashed + button. Square dimensions with no horizontal padding, so the border sits an even + distance from the glyph on every side. */ +.reactionChip { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + box-sizing: border-box; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid rgb(148 163 184 / 22%); + border-radius: 999px; + background: rgb(5 13 30 / 85%); + color: inherit; + font: inherit; + font-size: 0.95rem; + line-height: 1; + cursor: pointer; +} + +/* Two things side by side cannot be a circle, so this one becomes a stadium and takes + the width it needs. `width` gives way to `min-width` for the same reason. */ +.reactionChip.hasCount { + width: auto; + min-width: 30px; + padding: 0 9px; +} + +/* Your own reaction reads as pressed, because tapping it again is what takes it off. */ +.reactionChip.isMineReaction { + border-color: rgb(181 140 255 / 45%); + background: rgb(181 140 255 / 13%); +} + +.reactionCount { + font-size: 0.7rem; + opacity: 0.7; + font-variant-numeric: tabular-nums; +} + +/* Beside the bubble, centred on it, on the side away from the pet — the row is reversed + for your own messages, so it lands there without a side-specific rule. The anchor for + the picker, which is why it is positioned. */ +.reactionTrigger { + position: relative; + flex-shrink: 0; + align-self: center; +} + +/* Hidden until the reader is on the message, so an untouched transcript carries no column + of empty controls. Kept visible while its own picker is open, or choosing an emoji + would dismiss the thing being chosen from. A CSS rule rather than hover state in React: + a re-render per pointer move to draw one button is not worth it. */ +.reactionAdd { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid transparent; + border-radius: 50%; + background: transparent; + color: rgb(195 210 255 / 55%); + font: inherit; + font-size: 1.15rem; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.message:hover .reactionAdd, +.message:focus-within .reactionAdd, +.reactionAdd[aria-expanded='true'] { + opacity: 1; +} + +.reactionAdd:hover, +.reactionAdd:focus-visible { + color: #f6f3ff; + border-color: rgb(148 163 184 / 35%); + background: rgb(148 163 184 / 12%); +} + +/* Absolute rather than in flow: in flow it would widen the row and shove the bubble + sideways the moment it opened. Not portalled either — the transcript scrolls, and a + popup pinned to a rect measured once would drift off its message on the next scroll. + + Opens upward and inward, over the message rather than the margin, because the list + clips anything running past its edge sideways. + + Opens as a row of the quick six with a chevron; expanding turns it into the grid. */ +.reactionPicker { + position: absolute; + bottom: calc(100% + 6px); + z-index: 2; + display: flex; + align-items: center; + gap: 2px; + padding: 5px; + border: 1px solid rgb(148 163 184 / 25%); + border-radius: 999px; + background: rgb(4 10 26 / 98%); + box-shadow: 0 8px 24px rgb(0 0 0 / 45%); +} + +/* Six columns keep the full set roughly square, and the quick six stay on the first row + so expanding moves nothing the reader was already looking at. */ +.reactionPicker.isExpanded { + display: grid; + grid-template-columns: repeat(6, 1fr); + padding: 6px; + border-radius: 12px; + /* Scrolls rather than growing past the transcript it opens over. */ + max-height: 216px; + overflow-y: auto; + /* Explicit, because a non-visible overflow on one axis forces the other to `auto` + too, and the grid then draws a horizontal scrollbar it has no use for. */ + overflow-x: hidden; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: rgb(148 163 184 / 35%) transparent; +} + +/* Flipped downward when the component measures too little room above; see PICKER_MAX_HEIGHT. */ +.reactionPicker.isBelow { + bottom: auto; + top: calc(100% + 6px); +} + +/* Their message puts the control on the right, so the picker extends left, and the + reverse for yours. Either way it opens across the bubble, never off the panel. */ +.reactionPicker { + inset-inline-end: 0; +} + +.message.isMine .reactionPicker { + inset-inline-end: auto; + inset-inline-start: 0; +} + +.reactionOption { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + font-size: 1.2rem; + line-height: 1; + cursor: pointer; +} + +.reactionOption:hover, +.reactionOption:focus-visible { + background: rgb(148 163 184 / 18%); +} + +/* Opens the rest of the set. Drawn rather than a glyph so it sits at the same weight as + the emoji beside it instead of whatever a font decides a chevron character is. */ +.reactionMore { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 34px; + padding: 0; + border: none; + border-inline-start: 1px solid rgb(148 163 184 / 20%); + background: transparent; + cursor: pointer; +} + +.reactionMoreChevron { + width: 7px; + height: 7px; + border-right: 2px solid rgb(125 214 255 / 70%); + border-bottom: 2px solid rgb(125 214 255 / 70%); + transform: translateY(-2px) rotate(45deg); +} + +.reactionMore:hover .reactionMoreChevron, +.reactionMore:focus-visible .reactionMoreChevron { + border-color: #f6f3ff; +} + +/* ── History and day marks ───────────────────────────────────────────────────── + The date a run of messages belongs to. A rule through the middle rather than a bare + line of text, so it reads as a division of the transcript and not as someone's + message. */ +.dayMark { + display: flex; + align-items: center; + gap: 10px; + margin: 6px 0 2px; + color: rgb(195 210 255 / 45%); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; +} + +.dayMark::before, +.dayMark::after { + content: ''; + flex: 1; + height: 1px; + background: rgb(148 163 184 / 14%); +} + +/* Holds the top of the list while a page is on its way. Present but empty when there is + more to load and nothing in flight, so arriving history does not shift the view by the + height of a notice appearing. */ +.historyNotice { + flex-shrink: 0; + min-height: 18px; + color: rgb(195 210 255 / 45%); + font-size: 0.75rem; + font-style: italic; + text-align: center; +} diff --git a/frontend/src/components/chat/index.tsx b/frontend/src/components/chat/index.tsx new file mode 100644 index 00000000..eed88f9f --- /dev/null +++ b/frontend/src/components/chat/index.tsx @@ -0,0 +1,655 @@ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; +import { useNavigate } from 'react-router-dom'; +import { + CHAT_REACTIONS, + useChainCapabilities, + useChatMessages, + useChatThreads, + type ChatMessage, + type ChatThread, + type Pet, + type PetChain, +} from '@shared/core'; + +import DashboardPanel from '@components/common/dashboard-panel'; +import PetArt from '@components/pet/pet-art'; +import SessionGate from '@components/common/session-gate'; +import Icon, { MarriageIcon } from '@components/ui/icon'; +import { CHAT_WS_URL } from '../../config'; +import { sameAccount, shortAddress } from '@utils/address'; +import { DASHBOARD_HOME } from '@constants/interactionRoutes'; +import { Tones } from '@constants/tones'; +import styles from './index.module.css'; + +/** The married pets behind a thread, as a one-line reason it exists. */ +function marriageLine(thread: ChatThread): string { + return thread.pets.map((pair) => `${pair.petName} ♥ ${pair.spouseName}`).join(', '); +} + +/** + * The pet standing in for each side of a thread. + * + * A thread belongs to two owners, but what the game connected is their pets, and a + * truncated wallet address identifies nobody. Where a pair has several married couples + * the first is used: the rows are ordered by pet id, so the choice is stable between + * loads rather than shuffling with the query plan. + */ +function facesOf(thread: ChatThread): { mine: Pet | null; theirs: Pet | null } { + const pair = thread.pets[0]; + if (!pair) return { mine: null, theirs: null }; + const chain = thread.chain as PetChain; + // `dna` is newer than the rest of the payload, and the client deploys separately from + // the backend that serves it. No dna means no art and no emoji, so the row falls back + // to a plain bubble rather than throwing on `BigInt(undefined)` and taking the page + // down over an avatar. + const face = (id: string, name: string, dna?: string) => + dna ? (({ id, name, chain, dna: BigInt(dna) }) as Pet) : null; + return { + mine: face(pair.petId, pair.petName, pair.petDna), + theirs: face(pair.spousePetId, pair.spouseName, pair.spouseDna), + }; +} + +/** + * The day a message belongs to, as a label to sit above it. + * + * Relative for the two days anyone is likely to be reading — a date says less than + * "Today" when today is what you mean — and an absolute date beyond that. The weekday is + * included within the past week because that is how people refer to recent conversations. + */ +function dayLabel(createdAt: string, now: Date): string | null { + const at = new Date(createdAt); + if (Number.isNaN(at.getTime())) return null; + + const days = Math.round( + (startOfDay(now).getTime() - startOfDay(at).getTime()) / 86_400_000, + ); + if (days === 0) return 'Today'; + if (days === 1) return 'Yesterday'; + if (days < 7) return at.toLocaleDateString([], { weekday: 'long' }); + return at.toLocaleDateString([], { + day: 'numeric', + month: 'long', + // Only when it is not the year everyone is already in. + ...(at.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }), + }); +} + +/** Local midnight, so "same day" means the reader's day rather than UTC's. */ +function startOfDay(at: Date): Date { + return new Date(at.getFullYear(), at.getMonth(), at.getDate()); +} + +function timeOf(createdAt: string): string { + const at = new Date(createdAt); + return Number.isNaN(at.getTime()) + ? '' + : at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +/** + * How many of the set the picker offers before it is expanded. + * + * The first six, which is what everyone reaches for; the rest are one press away. A wall + * of forty as the first thing you see is a decision where a reaction should be a reflex. + */ +const QUICK_REACTIONS = 6; + +/** How close to the top the reader gets before the next page of history is fetched. */ +const OLDER_TRIGGER_PX = 120; + +/** Room the collapsed row and the full grid each need above the trigger to open upward. */ +const QUICK_HEIGHT = 52; +const PICKER_MAX_HEIGHT = 224; + +/** + * The chips under a message: one per emoji used, with how many used it. + * + * Tapping a chip you are already in removes your reaction, which is the same gesture as + * adding it and is what both messengers do. + */ +const ReactionChips: React.FC<{ + message: ChatMessage; + onReact: (messageId: number, emoji: string) => void; +}> = ({ message, onReact }) => { + const reactions = message.reactions ?? []; + if (reactions.length === 0) return null; + + return ( +
+ {reactions.map((reaction) => ( + + ))} +
+ ); +}; + +/** + * The control that adds a reaction, beside the bubble on the side away from the pet. + * + * The picker is a fixed six — the list the server accepts — so there is no search, no + * skin-tone menu and nothing to load. It opens inward, over the message rather than the + * margin, because the transcript scrolls vertically and clips anything that runs past its + * edge sideways. + */ +const ReactionAdd: React.FC<{ + message: ChatMessage; + onReact: (messageId: number, emoji: string) => void; +}> = ({ message, onReact }) => { + const [picking, setPicking] = useState(false); + const [expanded, setExpanded] = useState(false); + const [below, setBelow] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + + const close = useCallback(() => { + setPicking(false); + // Reset, so reopening starts at the quick row rather than wherever it was left. + setExpanded(false); + }, []); + + /** Distance from the trigger to the top of the transcript, which is what clips it. */ + const roomAbove = () => { + const trigger = triggerRef.current?.getBoundingClientRect(); + const list = triggerRef.current?.closest('ol')?.getBoundingClientRect(); + return trigger && list ? trigger.top - list.top : Number.POSITIVE_INFINITY; + }; + + /** + * Opens upward, or downward when there is not room above. + * + * Measured per state rather than fixed: the transcript clips the picker, and the + * collapsed row needs a fraction of the height the full grid does — deciding once + * with either number gets the other case wrong. + */ + const toggle = () => { + if (picking) { + close(); + return; + } + setBelow(roomAbove() < QUICK_HEIGHT); + setPicking(true); + }; + + const expand = () => { + setBelow(roomAbove() < PICKER_MAX_HEIGHT); + setExpanded(true); + }; + + // A click anywhere else closes it, which is what every menu does and what a reader + // expects when they have changed their mind. Escape too: the picker takes focus, so + // leaving the keyboard without a way out would trap it. + useEffect(() => { + if (!picking) return; + + const onPointerDown = (event: MouseEvent) => { + if (rootRef.current?.contains(event.target as Node)) return; + close(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') close(); + }; + + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [picking, close]); + + const offered = expanded ? CHAT_REACTIONS : CHAT_REACTIONS.slice(0, QUICK_REACTIONS); + + return ( +
+ + + {picking && ( +
+ {offered.map((emoji) => ( + + ))} + + {!expanded && ( + + )} +
+ )} +
+ ); +}; + +const ThreadList: React.FC<{ + threads: ChatThread[]; + selectedId: string | null; + onSelect: (threadId: string) => void; +}> = ({ threads, selectedId, onSelect }) => ( +
    + {threads.map((thread) => ( +
  • + +
  • + ))} +
+); + +const Conversation: React.FC<{ thread: ChatThread; me: string }> = ({ thread, me }) => { + const { + messages, + readUpTo, + markRead, + react, + isLoading, + error, + isLive, + online, + send, + isSending, + sendError, + hasOlder, + isLoadingOlder, + loadOlder, + } = useChatMessages({ + threadId: thread.threadId, + socketUrl: CHAT_WS_URL, + }); + + // Only a live channel can say anyone is present. While it is down every dot would + // otherwise read grey, which is indistinguishable from "they left" — so the header + // says the channel is offline instead of asserting anything about the other person. + const counterpartOnline = + isLive && online.some((address) => sameAccount(address, thread.counterpart)); + const faces = useMemo(() => facesOf(thread), [thread]); + // What "Today" is measured against. Read per render rather than memoized, so a tab + // left open across midnight relabels on the next render instead of insisting + // yesterday is still today. Nothing memoizes on its identity. + const now = new Date(); + const listRef = useRef(null); + // Height before an older page arrives, so the view can be pinned to what the reader + // was looking at. A ref, not state: it is read during layout, never rendered. + const heightBeforeLoad = useRef(0); + const [draft, setDraft] = useState(''); + const endRef = useRef(null); + const boxRef = useRef(null); + + // Grows with the draft up to the max height the stylesheet sets, then scrolls. + // + // Reset to `auto` first: scrollHeight never shrinks below the height already set, so + // measuring without clearing it makes the box one-way. The border is added back + // because scrollHeight covers content and padding but not the border, and the box is + // border-box — without it the field is short by its own border and scrolls a line + // that fits. + useEffect(() => { + const box = boxRef.current; + if (!box) return; + box.style.height = 'auto'; + const border = box.offsetHeight - box.clientHeight; + box.style.height = `${box.scrollHeight + border}px`; + }, [draft]); + + /** + * Fetches the page before the top when the reader gets near it. + * + * A scroll handler rather than an IntersectionObserver on a sentinel: one threshold + * on one element, and it needs no observer to exist in a test environment. + */ + const onScroll = () => { + const list = listRef.current; + if (!list || !hasOlder || isLoadingOlder || list.scrollTop > OLDER_TRIGGER_PX) return; + heightBeforeLoad.current = list.scrollHeight; + loadOlder(); + }; + + /** + * Holds the reader's place when older messages are prepended. + * + * Content added above shifts everything down by its height; without this the view + * jumps to a different part of the conversation the moment a page lands. Layout + * effect, not a plain one, so the correction happens before the browser paints and + * the jump is never visible. + */ + useLayoutEffect(() => { + const list = listRef.current; + if (!list || heightBeforeLoad.current === 0) return; + list.scrollTop += list.scrollHeight - heightBeforeLoad.current; + heightBeforeLoad.current = 0; + }, [messages]); + + // Chats are read from the bottom. Keyed on the newest id rather than length so a + // re-read that changes nothing does not yank the view while someone scrolls up. + const newest = messages[messages.length - 1]; + const newestId = newest?.id; + useEffect(() => { + endRef.current?.scrollIntoView({ block: 'end' }); + }, [newestId]); + + // Open thread means read. Marking on arrival rather than on visibility is the v1 + // rule: this panel shows one conversation at a time and scrolls to the end, so a + // message that lands here is on screen. Own messages are skipped — the watermark + // exists to answer what the *other* side has seen. + useEffect(() => { + if (newest && !sameAccount(newest.sender, me)) markRead(newest.id); + }, [newest, me, markRead]); + + /** + * Enter sends; Shift+Enter and Ctrl+Enter break the line. + * + * The messenger convention, and the reason the box is a textarea at all — a form's + * lone text input submits on Enter for free, but it can only ever hold one line. + * + * `isComposing` guards the Enter that closes an IME candidate window: for anyone + * typing Japanese, Korean or Chinese that keystroke picks a character and would + * otherwise fire the message off mid-word. + */ + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey) return; + if (event.nativeEvent.isComposing) return; + event.preventDefault(); + void submit(event); + }; + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + const text = draft.trim(); + if (!text || isSending) return; + try { + // Cleared only after the server accepts it: a send refused because the + // marriage ended must leave the text where the player can see it. + await send(text); + setDraft(''); + } catch { + // Already surfaced through `sendError`. Caught rather than left to reject, + // which in an onSubmit handler becomes an unhandled rejection nobody sees. + } + }; + + return ( +
+
+ + + {shortAddress(thread.counterpart)} + + {marriageLine(thread)} + {!isLive && ( + + reconnecting + + )} +
+ + {error ? ( +

{error.message}

+ ) : isLoading ? ( +
+
+
+ ) : messages.length === 0 ? ( +

+ No messages yet. Say hello. +

+ ) : ( +
    + {hasOlder && ( +
  1. + {isLoadingOlder ? 'Loading earlier messages…' : ''} +
  2. + )} + {messages.map((message, index) => { + const isMine = sameAccount(message.sender, me); + const next = messages[index + 1]; + // One face per run of consecutive messages, on the last of the + // run. Repeating it on every line in a two-person thread is the + // noise messengers avoid; dropping it entirely loses the thing + // worth showing, which is whose pet is talking. + const endsRun = !next || sameAccount(next.sender, me) !== isMine; + const face = isMine ? faces.mine : faces.theirs; + // A heading only where the day changes, which for the first + // message on screen is always. + const previous = messages[index - 1]; + const day = dayLabel(message.createdAt, now); + const startsDay = + day !== null && + (!previous || dayLabel(previous.createdAt, now) !== day); + return ( + + {startsDay && ( +
  3. + {day} +
  4. + )} +
  5. + {/* The pet rides beside the bubble; reactions hang under + the pair. Nesting the bubble and its reactions together + instead put the pet level with the reaction chips, + since the row bottom-aligns whatever it holds. */} +
    + {endsRun && face ? ( + + + + ) : ( + // Holds the column so bubbles in a run stay aligned + // with the one that carries the face. + + )} +
    + {message.text} + {/* Time and receipt travel together in the bubble's + bottom-right corner. Own messages carry the + receipt: yours is the only side whose reading is + news to anyone. */} + + + {timeOf(message.createdAt)} + + {isMine && ( + + {message.id <= readUpTo ? '✓✓' : '✓'} + + )} + +
    + +
    + +
  6. +
    + ); + })} +
    +
+ )} + +
+