diff --git a/CLAUDE.md b/CLAUDE.md index 06a8fcbe..b73c0ffa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,7 +211,7 @@ indexer-go writes**, under the same monotonic `last_version` guard `pet_roster` `item_entitlement` is backend-owned and holds earned-but-unminted drops. Reads join a projection to the catalog in TypeScript rather than SQL, so the two owners stay visible. -Three things are easy to get wrong here: +Four things are easy to get wrong here: - **Equipping escrows the token into `ItemCore`, and only the player can send it.** `equip` requires `msg.sender` to be the pet's owner, so the backend physically cannot do @@ -225,20 +225,39 @@ Three things are easy to get wrong here: emptied slot writes `item_type "0"`, because indexer-go resumes from an `updatedAt` watermark and a deleted row is one it never learns about. Zero is a value, not an absence. - **Battle drops derive from the battle's own drand seed**, committed before the fight - resolves, so nobody including the operator can grind one and anyone holding the receipt - can recompute it. They are written in the *same transaction* as the receipt, the rule - `battle_history` already follows. The honest limit: the drop is not inside the signed - payload in v1, so an outsider can recompute what was owed and notice a discrepancy but - cannot prove one from the receipt alone. + resolves, so nobody including the operator can grind one. They are written in the *same + transaction* as the receipt, the rule `battle_history` already follows. The honest limit + is larger than it used to say here: an outsider holding the receipt **cannot** recompute + the drop at all. The rates and the drop pool are backend constants (`drops.ts`, + `catalog.data.ts`), neither reaches the ruleset, and only the seed and battle id are + signed, so the payout is not pinned by the receipt either. Publishing them would put + non-equipment items into `rulesetHash`, which §4 rules out because adding a collectible + would then re-consent every defender. Tracked as D2 in + `docs/plan-battle-inventory-hardening.md`. + +- **The catalog has a lenient read and a strict one, and combat must use the strict one.** + `getCatalog`/`getPetEquipment` hide a row they cannot read, which is right for a bag: one + unnamed tile beats a bag that will not open. `getCombatCatalog`/`getPetEquipmentForCombat` + throw instead, and `servedRuleset` and `snapshot.builder` use those. The difference is not + fussiness: an unreadable equipment row dropped from `itemCatalog` moves `rulesetHash` and + invalidates every outstanding defence authorization, and an uncatalogued equipped item + dropped from a snapshot produces a receipt saying the pet fought bare while + `ItemCore.equipmentOf` at `sourceVersion` says otherwise. Acceptance turns either into an + `item-catalog-stale` rejection rather than fighting under rules it cannot state. Equipment reaching combat is what made this expensive, and it is why `snapshot` and `ruleset` both went to schema v2 (see the combat-simulator section above). The snapshot freezes **resolved modifiers plus the item type**: the modifiers so unequipping after -acceptance cannot change a committed fight, the item type so `@cryptopets/verifier`'s -`equipment` check can confirm those modifiers were the ones the catalog declares. Replay -alone cannot do that — a receipt granting +50 ATK from a 4-ATK dagger replays perfectly. -What remains unproven is *ownership* of the item, which is a claim about chain state at -`sourceVersion` that the verifier deliberately cannot read. +acceptance cannot change a committed fight, the item type so the `equipment` check can +confirm those modifiers were the ones the catalog declares. Replay alone cannot do that — a +receipt granting +50 ATK from a 4-ATK dagger replays perfectly. That check is +`findEquipmentMismatches` in `@cryptopets/protocol`, and it has **two** callers on purpose: +`@cryptopets/verifier` runs it on a finished receipt, and `accept.service` runs it before a +battle starts, so a fight guaranteed to fail verification is refused rather than held. One +implementation because two would drift into a battle that accepts and then cannot be +verified, with the comparison itself the last thing anyone would suspect. What remains +unproven is *ownership* of the item, which is a claim about chain state at `sourceVersion` +that the verifier deliberately cannot read. `servedRuleset()` joins the live catalog onto `SOURCE_DEFAULT_RULESET` and caches for the process's life, so a catalog edit needs a restart. That is deliberate: it moves diff --git a/backend/API.md b/backend/API.md index 79cc0f8b..91e51de3 100644 --- a/backend/API.md +++ b/backend/API.md @@ -419,12 +419,18 @@ to check independently. The four write routes are gated on `BATTLE_BACKEND_MODE_ENABLED` and return **503 `backend-battle-mode-disabled`** when it is off. Every read below stays served regardless: switching the mode off stops new battles, it does not retract receipts already issued. -`DELETE /authorizations` is ungated too, since withdrawing consent must keep working. +`DELETE /authorizations` is ungated too, since withdrawing consent must keep working, and +`GET /authorizations` for the same reason: a defender needs to see that their consent went +stale precisely when something is off, and a mode flag should not be what hides it. +`DELETE /sessions` is ungated too, so withdrawing a delegated key never depends on a flag. | POST | `/api/battle/intents` | JWT | Submit a signed battle intent (§D). | | POST | `/api/battle/intents/:intentHash/accept` | JWT | Freeze the snapshot, commit to a future drand round, sign the commitment, and return it synchronously (§E). | | POST | `/api/battle/authorizations` | JWT | Submit a signed standing defence authorization (§D). | | DELETE | `/api/battle/authorizations?chainId=` | JWT | Revoke every live authorization for the caller on one chain. No wallet signature required — refusing battles is never the dangerous direction. | +| POST | `/api/battle/sessions` | JWT | Approve a client-held key to sign battle intents for the caller (§D). The key is generated in the browser and never sent here, so the operator still cannot forge an intent — only the number of wallet prompts changes. Scope is `battle-intent` alone and the window is capped at 24h. | +| DELETE | `/api/battle/sessions?chainId=` | JWT | Revoke every session key for the caller on one chain. Unsigned, like consent revocation: the failure mode is more prompts, never fewer. | +| GET | `/api/battle/authorizations?chainId=` | JWT | The caller's own live authorizations, plus the `rulesetHash` now being served. Each carries `isStale`, true when it was signed under a different ruleset and therefore covers no battle. Always scoped to the authenticated wallet, never to a queried address: one wallet's consent state says which of their pets can be challenged and until when. | | GET | `/api/battle/config` | none | The `deploymentId`, served `chainIds`, and active ruleset a client needs *before* it can build a signable intent. None of it is derivable client-side, and guessing it fails only after the wallet prompt: a wrong deployment is refused as `wrong-deployment`, a wrong ruleset produces an authorization no battle matches. | | GET | `/api/battle/:battleId` | none | Battle state summary: state, failure reason, both pets, ruleset hash. | | GET | `/api/battle/:battleId/commitment` | none | The signed commitment, exactly as delivered at accept time — the re-fetch path if a client's local copy was lost. | diff --git a/backend/env.example b/backend/env.example index dd873877..78431400 100644 --- a/backend/env.example +++ b/backend/env.example @@ -151,12 +151,48 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # BATTLE_SIGNER_KEY_ID=battle-signer-2026-07 # Dev/test only. Any secp256k1 key; never a wallet holding funds. # BATTLE_SIGNER_PRIVATE_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d -# Required in production once an adapter exists, e.g. aws-kms. Unimplemented providers fail -# loudly rather than degrading to the in-process key. +# Required in production. Only aws-kms has an adapter; anything else fails loudly rather than +# degrading to the in-process key. # BATTLE_SIGNER_KMS_PROVIDER=aws-kms +# +# The KMS's own identifier: an ARN, a key id, or alias/battle-signer. Kept separate from +# BATTLE_SIGNER_KEY_ID because that one is stamped into every receipt permanently, and an ARN +# carries the account id and changes if the key is re-imported or moved. Defaults to +# BATTLE_SIGNER_KEY_ID when a deployment genuinely uses one name for both. +# BATTLE_SIGNER_KMS_KEY_ID=arn:aws:kms:us-east-1:111122223333:key/1234abcd-... +# +# Omit when the runtime already supplies a region (ECS task role, Lambda, EC2). +# BATTLE_SIGNER_KMS_REGION=us-east-1 +# +# Separate keys per reward domain (§G). One key signing both EVM and Solana means a +# compromise of either is a compromise of both (threat T4), so a deployment serving both +# families must name a key for each and the signer refuses to start otherwise. A deployment +# serving one family needs none of these: with a single domain there is nothing to separate, +# and the shared values above are used. +# BATTLE_SIGNER_EVM_KEY_ID=battle-signer-evm-2026-07 +# BATTLE_SIGNER_EVM_KMS_KEY_ID=arn:aws:kms:us-east-1:111122223333:key/... +# BATTLE_SIGNER_EVM_PRIVATE_KEY=0x... # dev/test only +# BATTLE_SIGNER_SOLANA_KEY_ID=battle-signer-solana-2026-07 +# BATTLE_SIGNER_SOLANA_KMS_KEY_ID=arn:aws:kms:us-east-1:111122223333:key/... +# BATTLE_SIGNER_SOLANA_PRIVATE_KEY=0x... # dev/test only +# +# The AWS key must be created with key spec ECC_SECG_P256K1 and usage SIGN_VERIFY. P-256 is +# accepted by the API and produces signatures that recover to nothing here, so it is checked +# at startup rather than discovered on the first battle. Credentials come from the default +# provider chain (instance/task role preferred) — a key reachable only with long-lived secrets +# held by this process is a key whose isolation is partial. The IAM policy should allow +# kms:Sign and kms:GetPublicKey and nothing else. # Implementations that must attest to a receipt hash before it can be signed. This is §F's -# circuit breaker as a precondition: with no agreement there is no signature to be had. Add -# go-verifier once the independent Go verifier is wired up. Default: typescript-engine +# circuit breaker as a precondition: with no agreement there is no signature to be had. +# +# Default: typescript-engine,go-verifier — the independent Go recomputation is required, so a +# receipt cannot exist without both engines having agreed on that exact hash. This costs +# nothing on the happy path (a battle only reaches the signer via `verified`, which is set in +# the same transition that records the Go result), and it means the check lives at the one +# place a receipt is actually produced rather than only earlier in the pipeline. +# +# Narrowing this to typescript-engine alone disables §F's breaker at the signer. Only do that +# knowingly, e.g. to drain a queue during an indexer-go outage, and put it back. # BATTLE_SIGNER_REQUIRED_ATTESTERS=typescript-engine,go-verifier # How long a battle waits on its committed drand round before forfeiting (§E). Measured from diff --git a/backend/package.json b/backend/package.json index 124eedeb..16177a8e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,10 +8,10 @@ "node": ">=20" }, "scripts": { - "build": "prisma generate && tsc && node scripts/copy-proto.cjs && node scripts/bundle-shared-node.cjs && node scripts/bundle-protocol.cjs", + "build": "pnpm clean && prisma generate && tsc && node scripts/copy-proto.cjs && node scripts/bundle-shared-node.cjs && node scripts/bundle-protocol.cjs", "start": "node dist/src/server.js", "dev": "nodemon", - "clean": "rm -rf dist", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", "postinstall": "prisma generate", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate deploy", @@ -23,17 +23,19 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "lint": "eslint .", + "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json", + "lint": "eslint . && pnpm typecheck:scripts", "lint:fix": "eslint . --fix" }, "dependencies": { "@ai-sdk/openai": "^3.0.68", + "@aws-sdk/client-kms": "^3.1107.0", "@coral-xyz/anchor": "^0.32.0", "@cryptopets/protocol": "workspace:*", "@grpc/grpc-js": "^1.14.4", + "@grpc/proto-loader": "^0.8.1", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0", - "@grpc/proto-loader": "^0.8.1", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@shared/core": "workspace:*", diff --git a/backend/prisma/migrations/20260810120000_battle_ruleset_version_not_unique/migration.sql b/backend/prisma/migrations/20260810120000_battle_ruleset_version_not_unique/migration.sql new file mode 100644 index 00000000..df640181 --- /dev/null +++ b/backend/prisma/migrations/20260810120000_battle_ruleset_version_not_unique/migration.sql @@ -0,0 +1,15 @@ +-- Drop the unique constraint on battle_ruleset.version. +-- +-- `ruleset_hash` is the primary key and the identity: it is what a receipt names and the +-- only thing a bundle is ever looked up by. `version` is descriptive. +-- +-- The unique held only while the ruleset was a pure constant. Roadmap §4 folded the item +-- catalog into it, so its *content* now varies while `version` stays 1, and the constraint +-- meant a second bundle could never be inserted. The failure was worse than a hard error: +-- it surfaced as a P2002 that `ensureRulesetPublished` read as "a concurrent accept already +-- published this", so accept reported success having written nothing, and every battle +-- naming the new hash dead-lettered in `compute` with "no published ruleset bundle". +-- +-- No RLS statement here: this alters an existing table rather than creating one, and +-- battle_ruleset already has row level security enabled. +DROP INDEX IF EXISTS "battle_ruleset_version_key"; diff --git a/backend/prisma/migrations/20260810140000_add_session_delegation/migration.sql b/backend/prisma/migrations/20260810140000_add_session_delegation/migration.sql new file mode 100644 index 00000000..a21fc344 --- /dev/null +++ b/backend/prisma/migrations/20260810140000_add_session_delegation/migration.sql @@ -0,0 +1,44 @@ +-- Delegated battle-intent signing (§D). +-- +-- §D requires the wallet, not a JWT, to authorize a battle, because a JWT is a bearer token +-- this server issues to itself. That rule stands: the delegated key is generated and held by +-- the client, so the operator still cannot forge an intent. The delegation only removes the +-- per-battle wallet prompt. +-- +-- Not referenced by any receipt. Public replay never checks intent signatures, so this is an +-- authorization gate rather than evidence. +CREATE TABLE "session_delegation" ( + "delegation_hash" TEXT NOT NULL, + "chain_id" TEXT NOT NULL, + "deployment_id" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "session_key" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "not_before" BIGINT NOT NULL, + "expires_at" BIGINT NOT NULL, + "revocation_nonce" INTEGER NOT NULL, + "signature" TEXT NOT NULL, + "signature_format" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked_at" TIMESTAMP(3), + + CONSTRAINT "session_delegation_pkey" PRIMARY KEY ("delegation_hash") +); + +-- CreateIndex +CREATE INDEX "session_delegation_chain_id_deployment_id_owner_idx" ON "session_delegation"("chain_id", "deployment_id", "owner"); + +-- Looked up by the recovered signer on every intent, so this is the hot path. +CREATE INDEX "session_delegation_chain_id_deployment_id_session_key_idx" ON "session_delegation"("chain_id", "deployment_id", "session_key"); + +-- EnableRowLevelSecurity +-- +-- Required on every new table (see CLAUDE.md): Supabase's ALTER DEFAULT PRIVILEGES grants +-- each newly created table in `public` to `anon` and `authenticated` with ALL privileges, so +-- a table shipped without this is readable and deletable by anyone holding the project's +-- public anon key. Here that would mean reading which key may act for which wallet, and +-- deleting revocations. +-- +-- Enabled with no policies, matching every other table: that denies the PostgREST roles +-- everything while the backend connects as the owner and bypasses RLS. Do NOT add FORCE. +ALTER TABLE "session_delegation" ENABLE ROW LEVEL SECURITY; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 34641fbf..1986f068 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -303,6 +303,42 @@ model BattleIntent { } /// A defender's standing, revocable permission to be challenged while offline (§D). +/// A wallet's short-lived permission for a client-held key to sign battle intents (§D). +/// +/// §D requires the *wallet* to authorize a battle, because a JWT is a bearer token this +/// server issues to itself and so proves nothing about the owner's intent. That stays true: +/// the delegated key is generated and held by the client, never by us, so an operator still +/// cannot forge an intent. What the delegation removes is the per-battle wallet prompt. +/// +/// Deliberately absent from every receipt. Public replay never checks intent signatures, so +/// this is an authorization gate rather than evidence, and keeping it out of the signed +/// record means the mechanism can be revised without invalidating a single receipt. +model SessionDelegation { + /// `hashSessionDelegation` from @cryptopets/protocol. + delegationHash String @id @map("delegation_hash") + chainId String @map("chain_id") + deploymentId String @map("deployment_id") + owner String + /// The client-held key this authorizes. Compared against the recovered intent signer. + sessionKey String @map("session_key") + /// What the key may sign. One value today ('battle-intent'); widening needs a new + /// schema version and a fresh prompt, never a silent grant. + scope String + notBefore BigInt @map("not_before") // unix seconds + expiresAt BigInt @map("expires_at") + revocationNonce Int @map("revocation_nonce") + signature String + signatureFormat String @map("signature_format") + createdAt DateTime @default(now()) @map("created_at") + /// Revocation is immediate. Kept rather than deleted so an audit can still see which key + /// was allowed to act, and when that stopped. + revokedAt DateTime? @map("revoked_at") + + @@index([chainId, deploymentId, owner]) + @@index([chainId, deploymentId, sessionKey]) + @@map("session_delegation") +} + model DefenseAuthorization { /// `hashDefenseAuthorization` from @cryptopets/protocol. authorizationHash String @id @map("authorization_hash") @@ -547,7 +583,13 @@ model BattleBatch { /// can replay. model BattleRuleset { rulesetHash String @id @map("ruleset_hash") - version Int @unique + /// Descriptive, not an identity: `rulesetHash` is what a receipt names and what makes a + /// bundle findable. This was `@unique`, which held only while the ruleset was a pure + /// constant. Roadmap §4 made the item catalog part of it, so content now varies while + /// `version` stays 1, and the unique meant the second bundle could never be written. + /// It failed as a `version` conflict that read like a concurrent publish, so accept + /// reported success, published nothing, and the battle died in `compute`. + version Int engineId String @map("engine_id") engineVersion Int @map("engine_version") /// The full bundle exactly as published. diff --git a/backend/scripts/diagnose-battles.ts b/backend/scripts/diagnose-battles.ts new file mode 100644 index 00000000..4bae2de4 --- /dev/null +++ b/backend/scripts/diagnose-battles.ts @@ -0,0 +1,60 @@ +import 'dotenv/config'; + +import { prisma } from '@config/prisma'; +import { env } from '@config/env'; + +/** + * Recent battles and what stalled them. Read-only: SELECT only, no writes of any kind. + * + * Exists because `failureReason` is the only record of why a battle stopped, and it is not + * served anywhere an operator can read it without a database client. + */ + +async function main(): Promise { + const battles = await prisma.battleLedger.findMany({ + orderBy: { createdAt: 'desc' }, + take: 12, + select: { + battleId: true, + chainId: true, + deploymentId: true, + state: true, + failureReason: true, + rulesetHash: true, + createdAt: true, + }, + }); + + console.log(`served chain ids: ${env.battle.chainIds.join(', ')}\n`); + if (battles.length === 0) { + console.log('no battles on record'); + return; + } + + for (const battle of battles) { + // The comparison that matters: the signer is keyed by chain *family*, so a chainId + // that is not a CAIP-2 `eip155:` string resolves to the Solana domain regardless of + // what it meant to say. + const family = battle.chainId.startsWith('eip155:') ? 'evm' : 'solana'; + const served = env.battle.chainIds.includes(battle.chainId); + console.log( + `${battle.createdAt.toISOString()} ${battle.state.padEnd(20)} ` + + `chainId=${JSON.stringify(battle.chainId)} family=${family} served=${served}`, + ); + console.log(` ${battle.battleId} deployment=${battle.deploymentId}`); + if (battle.failureReason) console.log(` failure: ${battle.failureReason}`); + } + + const chains = await prisma.battleLedger.groupBy({ by: ['chainId'], _count: { chainId: true } }); + console.log('\ndistinct chain ids on record:'); + for (const row of chains) { + console.log(` ${JSON.stringify(row.chainId)} x${row._count.chainId}`); + } +} + +void main() + .catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => void prisma.$disconnect()); diff --git a/backend/scripts/diagnose-opponents.ts b/backend/scripts/diagnose-opponents.ts new file mode 100644 index 00000000..0ae375e0 --- /dev/null +++ b/backend/scripts/diagnose-opponents.ts @@ -0,0 +1,99 @@ +/** + * Read-only diagnostic for "no eligible opponents". + * + * `findReadyOpponents` applies four filters, and an empty list looks identical whichever + * one emptied it. This reports the survivor count after each in turn, so the answer is the + * first line that drops to zero rather than a guess. + * + * Usage (from backend/): + * pnpm tsx scripts/diagnose-opponents.ts [--chain evm] [--owner 0xyou] + * + * Writes nothing, ever. Safe against production. + */ +import 'dotenv/config'; + +import { hashRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; + +import { prisma } from '../src/config/prisma'; +import { servedRuleset } from '../src/features/battle/ledger/ruleset.builder'; +import { servedDeploymentId } from '../src/features/battle/ledger/domain'; +import { servedChainIdForFamily } from '../src/repositories/battleProgress.overlay'; + +function arg(name: string, fallback: string): string { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback; +} + +async function main(): Promise { + const chain = arg('chain', 'evm'); + const owner = arg('owner', '').toLowerCase(); + const now = BigInt(Math.floor(Date.now() / 1000)); + + const chainId = servedChainIdForFamily(chain as never); + const deploymentId = servedDeploymentId(); + const served = await servedRuleset(); + const servedHash = hashRuleset(served); + const sourceHash = hashRuleset(SOURCE_DEFAULT_RULESET); + + console.log(`chain=${chain} chainId=${chainId ?? '(unserved)'} deployment=${deploymentId}`); + console.log(`served rulesetHash = ${servedHash}`); + console.log(` item catalog entries: ${served.itemCatalog?.length ?? 0}`); + if (servedHash !== sourceHash) { + // Not a fault. Worth printing because a mismatch here is what used to make + // matchmaking compare against a hash no defender had ever signed. + console.log(` (differs from SOURCE_DEFAULT_RULESET ${sourceHash} — expected once items are seeded)`); + } + + const total = await prisma.petRoster.count({ where: { chain } }); + console.log(`\n1. pets in roster ${total}`); + if (total === 0) { + console.log(' -> nothing indexed. Is indexer-go running with DATABASE_URL set?'); + } + + const notMine = owner + ? await prisma.petRoster.count({ where: { chain, owner: { not: owner } } }) + : total; + console.log(`2. not owned by --owner ${notMine}${owner ? '' : ' (pass --owner to apply)'}`); + + // Cooldown and level use the merged value, so this counts the same way the query does. + const ready = await prisma.$queryRaw<{ n: bigint }[]>` + SELECT COUNT(*) AS n FROM pet_roster r + LEFT JOIN pet_battle_progress p + ON p.pet_id = r.pet_id AND p.chain_id = ${chainId} AND p.deployment_id = ${deploymentId} + WHERE r.chain = ${chain} + AND GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) <= ${now} + `; + console.log(`3. off cooldown ${Number(ready[0]?.n ?? 0)}`); + + const grants = await prisma.defenseAuthorization.findMany({ + where: { ...(chainId ? { chainId } : {}), deploymentId, revokedAt: null }, + select: { defenderOwner: true, rulesetHash: true, allPets: true, expiresAt: true }, + }); + const live = grants.filter((g) => g.expiresAt > now); + const matching = live.filter((g) => g.rulesetHash.toLowerCase() === servedHash.toLowerCase()); + console.log(`4. live defence grants ${live.length} (of ${grants.length} unrevoked)`); + console.log(` matching the served ruleset ${matching.length}`); + + if (live.length === 0) { + console.log('\n=> Nobody has allowed challenges. This is the design, not a bug: a pet with no'); + console.log(' standing DefenseAuthorization cannot be challenged at all (§D). Grant consent'); + console.log(' from another wallet in the Allow Challenges panel, then re-run this.'); + } else if (matching.length === 0) { + console.log('\n=> Grants exist but every one was signed under a different ruleset, so they cover'); + console.log(' nothing. Those defenders must allow challenges again. Their own panel now says'); + console.log(' so; before that they had no way to find out.'); + for (const g of live) { + console.log(` ${g.defenderOwner} signed under ${g.rulesetHash}`); + } + } else { + console.log('\n=> Consent looks healthy. If the list is still empty, check lines 1-3 above:'); + console.log(' an empty roster, everything on cooldown, or every pet owned by you.'); + } +} + +main() + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/diagnose-signer.ts b/backend/scripts/diagnose-signer.ts new file mode 100644 index 00000000..d991cb9e --- /dev/null +++ b/backend/scripts/diagnose-signer.ts @@ -0,0 +1,79 @@ +import 'dotenv/config'; + +import { env } from '@config/env'; +import { + activeSigningKey, + configureSigner, + listSigningKeys, + signerBackendError, +} from '@features/battle/signer'; + +/** + * Why this deployment cannot sign, printed rather than inferred. + * + * `configureSigner` records its failure and returns, so a deployment that cannot sign + * anything still boots clean and only says so when a player has already fought a battle. + * This runs the same configuration against the same environment and prints what it decided, + * including the reason the running process kept to itself. + * + * Read-only: no database, no KMS writes, no state. Safe to run against any environment. + * Never prints key material — only whether a value is present and how long it is. + */ + +function shape(value: string | undefined): string { + if (value === undefined) return 'unset'; + if (value.length === 0) return 'empty'; + return `set (${value.length} chars)`; +} + +async function main(): Promise { + const now = Math.floor(Date.now() / 1000); + + console.log('--- environment ---'); + console.log(`NODE_ENV ${process.env.NODE_ENV ?? 'unset (so not production)'}`); + console.log(`BATTLE_BACKEND_MODE_ENABLED ${env.battle.enabled}`); + console.log(`BATTLE_CHAIN_IDS ${env.battle.chainIds.join(', ') || '(none)'}`); + console.log(`BATTLE_SIGNER_KEY_ID ${env.battleSigner.keyId || '(none)'}`); + console.log(`BATTLE_SIGNER_PRIVATE_KEY ${shape(env.battleSigner.privateKey)}`); + console.log(`BATTLE_SIGNER_KMS_PROVIDER ${env.battleSigner.kmsProvider || '(none)'}`); + + // The families this deployment serves, which is what the key set has to cover. Two + // families means each needs its own key id (§G), and that is the configuration most + // likely to be missing after an upgrade. + const families = [...new Set(env.battle.chainIds.map((id) => (id.startsWith('eip155:') ? 'evm' : 'solana')))]; + console.log(`\ndomains to sign for ${families.join(', ') || '(none)'}`); + for (const domain of ['evm', 'solana'] as const) { + const specific = env.battleSigner.domains[domain]; + console.log( + ` ${domain.padEnd(7)} keyId=${specific.keyId || '(inherits shared)'} ` + + `privateKey=${shape(specific.privateKey)} kmsKeyId=${specific.kmsKeyId || '(none)'}`, + ); + } + + console.log('\n--- configuring ---'); + await configureSigner(now); + + const failure = signerBackendError(); + if (failure) { + console.log(`REFUSED: ${failure}`); + } else { + console.log('configured without error'); + } + + console.log('\n--- what each served chain resolves to ---'); + for (const chainId of env.battle.chainIds) { + const key = activeSigningKey(chainId); + console.log(` ${chainId.padEnd(24)} ${key ? `${key.keyId} (${key.address})` : 'NO ACTIVE KEY'}`); + } + + const published = listSigningKeys(); + console.log(`\npublished keys ${published.length}`); + for (const key of published) { + console.log(` ${key.keyId} ${key.address} notBefore=${key.notBefore} notAfter=${key.notAfter ?? 'open'}`); + } +} + +void main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/backend/scripts/diagnose-stuck.ts b/backend/scripts/diagnose-stuck.ts new file mode 100644 index 00000000..62727174 --- /dev/null +++ b/backend/scripts/diagnose-stuck.ts @@ -0,0 +1,63 @@ +import 'dotenv/config'; + +import { prisma } from '@config/prisma'; +/** + * Whatever is in flight, and what its outbox message is doing about it. Read-only. + * + * A battle sitting in a non-terminal state is either being retried or waiting on nothing at + * all, and those look identical from the UI. `attempts`, `availableAt` and `lastError` are + * what tell them apart, and none of them is served anywhere. + */ + +async function main(): Promise { + console.log(`INDEXER_GRPC_ADDR = ${process.env.INDEXER_GRPC_ADDR ?? '(unset)'}\n`); + + const TERMINAL = ['batched', 'rejected', 'expired', 'forfeited', 'verification_failed', 'signing_failed']; + const live = await prisma.battleLedger.findMany({ + where: { state: { notIn: TERMINAL as never } }, + orderBy: { createdAt: 'desc' }, + take: 10, + select: { battleId: true, state: true, chainId: true, createdAt: true, updatedAt: true }, + }); + + if (live.length === 0) { + console.log('nothing in flight'); + } + + for (const battle of live) { + console.log(`${battle.state.padEnd(12)} ${battle.battleId}`); + console.log(` created ${battle.createdAt.toISOString()} updated ${battle.updatedAt.toISOString()}`); + + const messages = await prisma.battleOutbox.findMany({ + where: { battleId: battle.battleId }, + orderBy: { createdAt: 'asc' }, + }); + if (messages.length === 0) { + // The state machine advances on these, so a non-terminal battle with no message + // is waiting on something that will never arrive. + console.log(' NO OUTBOX MESSAGE — nothing will move this battle'); + } + for (const message of messages) { + const status = message.processedAt + ? 'processed' + : message.deadLetteredAt + ? 'DEAD-LETTERED' + : message.lockedBy + ? `locked by ${message.lockedBy}` + : 'pending'; + console.log( + ` [${message.topic}] attempts=${message.attempts} ` + + `availableAt=${message.availableAt.toISOString()} ${status}`, + ); + if (message.lastError) console.log(` lastError: ${message.lastError}`); + } + console.log(''); + } +} + +void main() + .catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => void prisma.$disconnect()); diff --git a/backend/scripts/diagnose-verifier.ts b/backend/scripts/diagnose-verifier.ts new file mode 100644 index 00000000..c7f67acb --- /dev/null +++ b/backend/scripts/diagnose-verifier.ts @@ -0,0 +1,70 @@ +import 'dotenv/config'; + +import { env } from '@config/env'; +import { callVerifyBattle } from '../src/grpc/verifyBattle'; + +/** + * Whether the independent verifier (§F) can actually be reached, asked directly. + * + * A battle stalls at `computed` until indexer-go agrees with the engine, and the three ways + * that fails — not configured, breaker open, transport error — are indistinguishable from + * the UI, which says only that it is waiting. This runs the same call the verify worker + * runs, against the same configuration, and prints which one it is. + * + * Read-only: it verifies a throwaway matchup and stores nothing. + */ + +const PET = { + petId: '1', + level: 10, + xp: 0, + attack: 100, + defense: 80, + intelligence: 90, + life: 100, + speed: 70, + element: 1, + skill: 0, + rarity: 3, + equipment: [], +}; + +async function main(): Promise { + console.log(`INDEXER_GRPC_ADDR ${env.indexerGrpc.addr ?? '(unset — verification cannot run)'}`); + console.log(`INDEXER_PROTO_PATH ${env.indexerGrpc.protoPath ?? '(auto-resolved)'}\n`); + + const started = Date.now(); + const result = await callVerifyBattle({ + attacker: PET as never, + defender: { ...PET, petId: '2' } as never, + seed: `0x${'11'.repeat(32)}`, + skillConfig: { + berserkerAtkBonusPct: 20, + tankDefBonusPct: 20, + assassinCritBonusPct: 15, + mageIntBonusPct: 20, + healerHealPct: 10, + } as never, + maxLevel: 100, + }); + const ms = Date.now() - started; + + if (result.ok) { + console.log(`REACHABLE (${ms}ms) — the verifier answered, so §F can complete.`); + console.log(` winner=${JSON.stringify((result.response as { winner?: unknown }).winner)}`); + return; + } + + console.log(`UNREACHABLE (${ms}ms)`); + console.log(` reason: ${result.reason}`); + console.log(` detail: ${result.detail}`); + console.log( + '\nEvery battle stalls at `computed` and then forfeits while this is failing: the backend ' + + 'will not sign a receipt the independent port has not confirmed (§F).', + ); +} + +void main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/backend/scripts/grant-defense-authorization.ts b/backend/scripts/grant-defense-authorization.ts index 823596f4..87da673f 100644 --- a/backend/scripts/grant-defense-authorization.ts +++ b/backend/scripts/grant-defense-authorization.ts @@ -22,8 +22,8 @@ * --max-level 100 highest attacker level accepted, default 100 * --api http://... backend base URL, default http://localhost:3001 */ -import { defenseAuthorizationTypedData } from '@cryptopets/protocol'; -import { Wallet } from 'ethers'; +import { assertChainId, defenseAuthorizationTypedData } from '@cryptopets/protocol'; +import { type TypedDataField, Wallet } from 'ethers'; interface Options { petIds: string[]; @@ -94,8 +94,13 @@ async function main(): Promise { ruleset: { hash: string }; }>(configRes, 'GET /api/battle/config'); - const chainId = config.chainIds.find((id) => id.startsWith('eip155:')); - if (!chainId) throw new Error(`no EVM chain in served config: ${config.chainIds.join(', ')}`); + const evmChain = config.chainIds.find((id) => id.startsWith('eip155:')); + if (!evmChain) throw new Error(`no EVM chain in served config: ${config.chainIds.join(', ')}`); + // Validated rather than asserted. The prefix test above narrows nothing on its own, and + // the value came off the wire, so `assertChainId` is what turns a served string into a + // `ChainId` the protocol will accept — and rejects a malformed one here rather than + // inside the signature. + const chainId = assertChainId(evmChain); const now = Math.floor(Date.now() / 1000); const authorization = { @@ -127,7 +132,14 @@ async function main(): Promise { expiresAt: authorization.expiresAt, revocationNonce: 0, }); - const signature = await wallet.signTypedData(typed.domain, typed.types, typed.message); + // `typed.types` is a readonly tuple, because the protocol builds the EIP-712 type list + // as a literal and a mutable one could be reordered by a caller — which would change + // the digest. ethers wants a mutable `TypedDataField[]`, so the array is copied rather + // than cast: a cast would hand ethers the protocol's own object to do as it likes with. + const types: Record = Object.fromEntries( + Object.entries(typed.types).map(([name, fields]) => [name, fields.map((field) => ({ ...field }))]), + ); + const signature = await wallet.signTypedData(typed.domain, types, typed.message); const token = await authenticate(opts.api, wallet); const res = await fetch(`${opts.api}/api/battle/authorizations`, { diff --git a/backend/scripts/seed-item-catalog.ts b/backend/scripts/seed-item-catalog.ts index e36d6209..d4c7bee6 100644 --- a/backend/scripts/seed-item-catalog.ts +++ b/backend/scripts/seed-item-catalog.ts @@ -26,6 +26,7 @@ */ import 'dotenv/config'; +import { Prisma } from '../src/generated/prisma/client'; import { prisma } from '../src/config/prisma'; import { assertCatalog, SLOT } from '../src/features/inventory/catalog'; import { ITEM_CATALOG } from '../src/features/inventory/catalog.data'; @@ -71,7 +72,12 @@ async function seedDatabase(dryRun: boolean): Promise { category: item.category, slot: item.slot === undefined ? null : SLOT[item.slot], rarity: item.rarity, - effect: item.effect ?? null, + // `Prisma.DbNull`, not `null`. For a nullable Json column Prisma makes the + // distinction explicit: `DbNull` writes SQL NULL, `JsonNull` writes the JSON + // value `null`, and a bare `null` is rejected because it cannot say which was + // meant. SQL NULL is what the reader expects — `asItemEffect` treats it as + // "no effect", and the column is what an inert collectible leaves empty. + effect: item.effect === undefined ? Prisma.DbNull : (item.effect as unknown as Prisma.InputJsonValue), name: item.name, description: item.description, }; diff --git a/backend/scripts/verify-inventory-setup.ts b/backend/scripts/verify-inventory-setup.ts index d4a84739..f7909c36 100644 --- a/backend/scripts/verify-inventory-setup.ts +++ b/backend/scripts/verify-inventory-setup.ts @@ -20,6 +20,7 @@ import 'dotenv/config'; import { prisma } from '../src/config/prisma'; import { assertCatalog, SLOT } from '../src/features/inventory/catalog'; import { ITEM_CATALOG } from '../src/features/inventory/catalog.data'; +import { getCombatCatalog } from '../src/features/inventory/inventory.service'; const ITEM_CORE_ABI = [ { @@ -120,6 +121,19 @@ async function checkCatalog(): Promise { drifted.length === 0, drifted.length === 0 ? 'no drift' : `drifted: ${drifted.map((i) => i.key).join(', ')}`, ); + + // The stored rows have to be readable as *rules*, not merely present. Since roadmap §4 + // an equipment row whose modifier will not parse is refused rather than skipped, so one + // bad `effect` column stops every battle this deployment accepts with + // `item-catalog-stale`. The seeder cannot produce that state (`assertCatalog` rejects it + // at authoring), which is exactly why it is worth checking here: it means someone edited + // the table by hand, and that is invisible to every other check above. + try { + const priced = await getCombatCatalog(); + record('catalog can price a fight', true, `${priced.length} definitions readable`); + } catch (error) { + record('catalog can price a fight', false, `${(error as Error).message} — every accept will 503`); + } } async function checkChain(): Promise { diff --git a/backend/src/app.ts b/backend/src/app.ts index 80c52758..f665c850 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,4 +1,4 @@ -import express, { Request, Response } from 'express'; +import express, { NextFunction, Request, Response } from 'express'; import cors from 'cors'; import { env } from '@config/env'; @@ -59,4 +59,26 @@ app.get('/', (_req: Request, res: Response) => { }); }); +/** + * Last resort for a route that rejected. + * + * Express 4 does not await route handlers, so a rejected promise from an `async` one is an + * unhandled rejection, and Node 24 exits the process on those by default. Every async + * handler in this app is therefore one throw away from taking the whole server down for + * every user: a single failed battle accept did exactly that. + * + * Registered after the routers, since Express picks error middleware by arity and by + * position. `next` is unused but must be declared, or Express treats this as an ordinary + * middleware and never calls it with an error. + */ +app.use((error: Error, req: Request, res: Response, _next: NextFunction) => { + console.error(`[api] unhandled error in ${req.method} ${req.originalUrl}:`, error); + if (res.headersSent) { + return; + } + // Deliberately opaque: an internal failure's message can name tables, hashes and + // wallets, none of which belongs in a client response. + res.status(500).json({ error: 'Internal error' }); +}); + export default app; diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index a13aed8d..20256bf9 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -138,12 +138,6 @@ export const env = { : undefined) as `0x${string}` | undefined, chainId: process.env.ITEM_CORE_CHAIN_ID ? Number(process.env.ITEM_CORE_CHAIN_ID) : undefined, address: process.env.ITEM_CORE_ADDRESS?.trim() as `0x${string}` | undefined, - /** - * Wallets allowed to grant items, comma-separated. Empty by default, so the admin - * route is closed until someone is named rather than open until someone is - * excluded. Normalized here so a checksummed address in the env still matches the - * lowercased one the JWT carries. - */ /** * Whether a settled battle pays item drops. * @@ -153,6 +147,12 @@ export const env = { * added for something else. */ dropsEnabled: process.env.ITEM_DROPS_ENABLED?.trim().toLowerCase() === 'true', + /** + * Wallets allowed to grant items, comma-separated. Empty by default, so the admin + * route is closed until someone is named rather than open until someone is + * excluded. Normalized here so a checksummed address in the env still matches the + * lowercased one the JWT carries. + */ adminWallets: new Set( (process.env.ITEM_ADMIN_WALLETS ?? '') .split(',') @@ -274,19 +274,63 @@ export const env = { * production, so a deployment cannot quietly fall back to an in-process key. * * `requiredAttesters` is what makes §F's circuit breaker unbypassable: a receipt cannot be - * signed unless every listed implementation has attested to that exact receipt hash. Add - * `go-verifier` once the independent verifier is wired up; until then the single-attester - * default means only the TypeScript engine's agreement is enforced. + * signed unless every listed implementation has attested to that exact receipt hash. + * + * `go-verifier` is in the default, so the independent Go recomputation is a *precondition* + * for a signature rather than a step that happened earlier in the pipeline. Those are not + * the same guarantee: the pipeline already refuses to advance a battle whose verification + * did not match, but that is one code path away from being edited, while this refuses at + * the signer — the one place a receipt can actually be produced. + * + * It costs nothing on the happy path. `verify.worker` writes `verificationDetail` in the + * same transition that moves a battle to `verified`, and `sign.worker` only runs from + * `verified`, so every battle reaching the signer already carries the attestation. A + * deployment running without indexer-go never reaches `verified` at all, and would have + * stalled before signing with or without this. */ battleSigner: { keyId: process.env.BATTLE_SIGNER_KEY_ID?.trim() || 'battle-signer-dev', /** Dev and test only. Ignored (and refused) in production. */ privateKey: process.env.BATTLE_SIGNER_PRIVATE_KEY?.trim() || undefined, - /** e.g. `aws-kms` or `gcp-kms`. Unset locally; required in production. */ + /** `aws-kms` today. Unset locally; required in production. */ kmsProvider: process.env.BATTLE_SIGNER_KMS_PROVIDER?.trim() || undefined, - requiredAttesters: (process.env.BATTLE_SIGNER_REQUIRED_ATTESTERS?.trim() || 'typescript-engine') + /** + * The provider's identifier for the key: an ARN or `alias/...` on AWS. + * + * Separate from `keyId` on purpose. `keyId` is stamped into every receipt and + * published in the registry, so it has to stay stable; an ARN carries the account + * id and changes if the key is re-imported or moved. Defaults to `keyId` for a + * deployment that genuinely uses one name for both. + */ + kmsKeyId: process.env.BATTLE_SIGNER_KMS_KEY_ID?.trim() || undefined, + /** Omitted when the runtime already supplies one (ECS task role, Lambda, EC2). */ + kmsRegion: process.env.BATTLE_SIGNER_KMS_REGION?.trim() || undefined, + requiredAttesters: (process.env.BATTLE_SIGNER_REQUIRED_ATTESTERS?.trim() || 'typescript-engine,go-verifier') .split(',') .map((name) => name.trim()) .filter((name) => name.length > 0), + /** + * Per-domain key overrides (§G: "separate keys for EVM and Solana reward domains"). + * + * One key signing both chains means compromising it compromises both (threat T4), so + * a deployment serving two families needs two keys. The signer refuses to start + * rather than share one, and these are how each is named. + * + * Left unset by a single-chain deployment, which is every deployment today: with only + * one domain there is nothing to separate, so the shared values above are used and + * the sharing is not a compromise of anything. + */ + domains: { + evm: { + keyId: process.env.BATTLE_SIGNER_EVM_KEY_ID?.trim() || undefined, + privateKey: process.env.BATTLE_SIGNER_EVM_PRIVATE_KEY?.trim() || undefined, + kmsKeyId: process.env.BATTLE_SIGNER_EVM_KMS_KEY_ID?.trim() || undefined, + }, + solana: { + keyId: process.env.BATTLE_SIGNER_SOLANA_KEY_ID?.trim() || undefined, + privateKey: process.env.BATTLE_SIGNER_SOLANA_PRIVATE_KEY?.trim() || undefined, + kmsKeyId: process.env.BATTLE_SIGNER_SOLANA_KMS_KEY_ID?.trim() || undefined, + }, + }, }, } as const; diff --git a/backend/src/features/battle/ledger/accept.controller.ts b/backend/src/features/battle/ledger/accept.controller.ts index 99f4106f..081ad048 100644 --- a/backend/src/features/battle/ledger/accept.controller.ts +++ b/backend/src/features/battle/ledger/accept.controller.ts @@ -6,11 +6,15 @@ import { acceptBattle, type AcceptRejection } from './accept.service'; /** * 409 for "someone already acted on this", 404/403/422 for the client's own fault, and 503 for - * the two dependencies this flow cannot proceed without (drand, the signer). A 503 is the - * honest answer for those: retrying shortly is the correct client behaviour, and nothing about - * the request itself was wrong. + * the dependencies this flow cannot proceed without (drand, the signer, a catalog that can + * price the gear in play). A 503 is the honest answer for those: retrying shortly is the + * correct client behaviour, and nothing about the request itself was wrong. + * + * A stale catalog is the odd one of the three, since retrying will not help until someone runs + * the seeder. It is still a 503 rather than a 500: the deployment is misconfigured, not broken, + * and a client that backs off and retries is behaving correctly either way. */ -const STATUS_BY_REASON: Record = { +export const STATUS_BY_REASON: Record = { 'intent-not-found': 404, 'intent-already-consumed': 409, 'intent-expired': 422, @@ -30,6 +34,8 @@ const STATUS_BY_REASON: Record = { 'pet-locked': 409, 'drand-unavailable': 503, 'signer-unavailable': 503, + 'item-catalog-stale': 503, + 'equipment-catalog-mismatch': 503, }; interface AcceptBody { diff --git a/backend/src/features/battle/ledger/accept.service.ts b/backend/src/features/battle/ledger/accept.service.ts index dd59c323..bd3b6d25 100644 --- a/backend/src/features/battle/ledger/accept.service.ts +++ b/backend/src/features/battle/ledger/accept.service.ts @@ -4,28 +4,31 @@ import { type BattleCommitment, type BattleSnapshot, type ChainId, + findEquipmentMismatches, hashBattleSnapshot, - hashRuleset, isBattleReady, type Hex, + type PetSnapshot, publishRuleset, QUICKNET, + type Ruleset, SNAPSHOT_SCHEMA_VERSION, } from '@cryptopets/protocol'; import type { Prisma } from '@generated/prisma/client'; import { BattleState } from '@generated/prisma/enums'; import { prisma } from '@config/prisma'; +import { ItemCatalogError } from '@features/inventory'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; -import { activeSigningKey, sign, SignerRefusedError } from '../signer'; +import { activeSigningKey, sign, signerBackendError, SignerRefusedError } from '../signer'; import { chooseCommitmentRound, roundPublishTime } from '../randomness'; import { type ConsentFailure, consumeDailyBudget, findCoveringAuthorization } from './consent.service'; import { servedDeploymentId } from './domain'; import { OUTBOX_TOPICS } from './outbox'; import { buildPetSnapshot } from './snapshot.builder'; -import { servedRuleset } from './ruleset.builder'; +import { servedRuleset, servedRulesetHash } from './ruleset.builder'; import { applyTransition, openBattle } from './transitions'; /** @@ -76,7 +79,26 @@ export type AcceptRejection = | ConsentFailure | 'pet-locked' | 'drand-unavailable' - | 'signer-unavailable'; + | 'signer-unavailable' + /** + * The item catalog cannot price something this battle needs priced: a pet wears an + * item with no catalog row, or an equipment row's modifier will not parse (roadmap §4). + * + * Its own reason rather than a 500, because it is an operational fault with an obvious + * remedy (run the seeder) and no fault of the player's. Refusing is the conservative + * end: the alternative is a fight under rules this deployment cannot state, recorded in + * a signed receipt that contradicts chain state. + */ + | 'item-catalog-stale' + /** + * The frozen gear disagrees with what the ruleset this battle names prices it at + * (roadmap §4, threat T13). Reachable when the catalog changes between resolving the + * snapshot and building the ruleset, and otherwise a bug. + * + * Refused rather than fought, because the verifier makes the same comparison on the + * finished receipt: accepting would produce a battle guaranteed to fail verification. + */ + | 'equipment-catalog-mismatch'; export interface AcceptedBattle { battleId: string; @@ -93,42 +115,23 @@ export type AcceptBattleResult = const MAX_COMMITMENT_CHAIN_RETRIES = 5; export async function acceptBattle(request: AcceptBattleRequest): Promise { - const intent = await prisma.battleIntent.findUnique({ where: { intentHash: request.intentHash } }); - if (!intent) { - return reject('intent-not-found', `no intent ${request.intentHash}`); - } - if (intent.consumedAt) { - return reject('intent-already-consumed', 'this intent already produced a battle'); - } - if (BigInt(request.nowSeconds) >= intent.expiresAt) { - return reject('intent-expired', `intent expired at ${intent.expiresAt}`); + const intent = await loadAcceptableIntent(request); + if (!intent.ok) { + return intent.refusal; } + const chainId = intent.value.chainId as ChainId; - const chainId = intent.chainId as ChainId; - const [attacker, defender] = await Promise.all([ - buildPetSnapshot(chainId, intent.attackerPetId), - buildPetSnapshot(chainId, intent.defenderPetId), - ]); - if (!attacker) { - return reject('attacker-pet-missing', `pet ${intent.attackerPetId} is not in the roster`); - } - if (!defender) { - return reject('defender-pet-missing', `pet ${intent.defenderPetId} is not in the roster`); - } - // Both pets must be off cooldown, mirroring GameLogic.sol's requirement that neither side - // of an on-chain battle is mid-recovery. - if (!isBattleReady(attacker, request.nowSeconds)) { - return reject('attacker-not-ready', `attacker ready at ${attacker.readyAt}`); - } - if (!isBattleReady(defender, request.nowSeconds)) { - return reject('defender-not-ready', `defender ready at ${defender.readyAt}`); + const fighters = await freezeFighters(chainId, intent.value, request.nowSeconds); + if (!fighters.ok) { + return fighters.refusal; } + const { attacker, defender } = fighters.value; - // Built from the live item catalog rather than taken from the constant: gear changes - // fights, so the rules a battle names have to include what gear does (roadmap §4). - const ruleset = await servedRuleset(); - const rulesetHash = hashRuleset(ruleset); - await ensureRulesetPublished(rulesetHash); + const priced = await priceUnderServedRuleset(attacker, defender); + if (!priced.ok) { + return priced.refusal; + } + const { ruleset, rulesetHash } = priced.value; const coverage = await findCoveringAuthorization({ chainId, @@ -171,39 +174,20 @@ export async function acceptBattle(request: AcceptBattleRequest): Promise { for (let attempt = 0; attempt < MAX_COMMITMENT_CHAIN_RETRIES; attempt++) { - const key = activeSigningKey(); + // The commitment's own domain picks the key (§G separates them per reward domain), + // so a commitment can never be signed under another chain's key. + const key = activeSigningKey(seed.domain.chainId); if (!key) { - throw new SignerRefusedError('signer-not-configured', 'no active signing key'); + // Carries the configuration failure rather than restating the symptom — see the + // same lookup in `sign.worker`. This one reaches the player as a refused battle, + // so the detail is what tells an operator it was their config and not the chain. + const why = signerBackendError(); + throw new SignerRefusedError( + 'signer-not-configured', + `no active signing key for the ${seed.domain.chainId} domain${why ? `: ${why}` : ''}`, + ); } const previous = await prisma.battleCommitment.findFirst({ @@ -348,13 +341,33 @@ async function unwindToRejected(battleId: string, reason: string): Promise * replayed by anyone (§H), so this runs before the hash is ever referenced rather than as a * background job that might lag behind it. */ -async function ensureRulesetPublished(expectedHash: Hex): Promise { +async function ensureRulesetPublished(ruleset: Ruleset, expectedHash: Hex): Promise { const existing = await prisma.battleRuleset.findUnique({ where: { rulesetHash: expectedHash } }); if (existing) { return; } - const ruleset = await servedRuleset(); + + // The *same* ruleset object the caller hashed, passed in rather than re-read. + // + // This used to call `servedRuleset()` again and publish under whatever hash that + // produced, while the battle went on referencing the caller's. Nothing checked the + // two agreed, so any drift between the two reads published a bundle nobody would ever + // look up and left the battle naming one that did not exist. It surfaced as far away + // as it possibly could: the battle accepted cleanly, the player signed, and it died + // nine retries later in `compute` with "no published ruleset bundle for 0x…". + // + // Taking the object removes the window rather than narrowing it: there is now only one + // read, so there is nothing to drift. const { hash, json } = publishRuleset(ruleset); + if (hash.toLowerCase() !== expectedHash.toLowerCase()) { + // Unreachable while the caller hashes what it passes, which is the point of + // asserting it: if that ever stops being true, it fails here, before a battle + // exists, instead of stranding one that has already been signed for. + throw new Error( + `ruleset bundle hashes to ${hash} but this battle names ${expectedHash}; refusing to publish a bundle no battle references`, + ); + } + try { await prisma.battleRuleset.create({ data: { @@ -369,7 +382,20 @@ async function ensureRulesetPublished(expectedHash: Hex): Promise { if ((error as { code?: string }).code !== 'P2002') { throw error; } - // Another concurrent accept call published it first; that is fine, the row exists now. + // A unique violation is only benign when it means *this* bundle is already there, + // which is the concurrent-accept race. Any other unique conflict leaves no row for + // this hash, and swallowing it publishes nothing while reporting success. + // + // That is not hypothetical: `version` is unique and every served ruleset carries + // version 1, so the first catalog change made this collide on `version` rather + // than on the hash. The battle went on naming a bundle that had never been + // written, and died in `compute` nine retries later. + const published = await prisma.battleRuleset.findUnique({ where: { rulesetHash: expectedHash } }); + if (!published) { + throw new Error( + `could not publish the ruleset bundle for ${expectedHash}: ${(error as Error).message}`, + ); + } } } @@ -381,3 +407,190 @@ function serializeBigints(value: T): Prisma.InputJsonValue { function reject(reason: AcceptRejection, detail: string): AcceptBattleResult { return { ok: false, reason, detail }; } + +/** + * Turns a stale item catalog into a named rejection, and rethrows anything else. + * + * Both catalog-dependent reads on this path (what the pets are wearing, and the ruleset + * the fight is priced under) run before the first write, so refusing here strands nothing: + * no ledger row, no consumed intent, no spent daily budget. + * + * Rethrows rather than swallowing, because "the catalog is behind the contract" is a + * recoverable operational state with a clear remedy, while any other failure here is a bug + * and should keep reaching the error handler as one. + */ +function catalogRejection(error: unknown): AcceptBattleResult { + if (error instanceof ItemCatalogError) { + return reject('item-catalog-stale', error.message); + } + throw error; +} + +/** + * One step of accept that is allowed to refuse. + * + * `acceptBattle` is a sequence of checks that each end the request on failure, and inlining + * all of them made one function responsible for validating an intent, freezing two pets, + * pricing them under a ruleset, spending a budget, and unwinding a half-built battle. The + * steps below are the seams that were already documented in its own comments; this type is + * only what lets them hand a refusal back rather than each inventing a way to say no. + */ +type Step = { ok: true; value: T } | { ok: false; refusal: AcceptBattleResult }; + +const proceed = (value: T): Step => ({ ok: true, value }); +const refuse = (reason: AcceptRejection, detail: string): Step => ({ + ok: false, + refusal: reject(reason, detail), +}); + +type BattleIntentRow = NonNullable>>; + +/** The intent this battle claims to answer, if it is still answerable. */ +async function loadAcceptableIntent(request: AcceptBattleRequest): Promise> { + const intent = await prisma.battleIntent.findUnique({ where: { intentHash: request.intentHash } }); + if (!intent) { + return refuse('intent-not-found', `no intent ${request.intentHash}`); + } + if (intent.consumedAt) { + return refuse('intent-already-consumed', 'this intent already produced a battle'); + } + if (BigInt(request.nowSeconds) >= intent.expiresAt) { + return refuse('intent-expired', `intent expired at ${intent.expiresAt}`); + } + return proceed(intent); +} + +/** + * The frozen photo of both pets (§C), and the readiness checks that depend on it. + * + * Kept together because the second is meaningless without the first: `readyAt` is a + * snapshot field, so cooldown can only be judged once the snapshot exists. + */ +async function freezeFighters( + chainId: ChainId, + intent: BattleIntentRow, + nowSeconds: number, +): Promise> { + let attacker: Awaited>; + let defender: Awaited>; + try { + [attacker, defender] = await Promise.all([ + buildPetSnapshot(chainId, intent.attackerPetId), + buildPetSnapshot(chainId, intent.defenderPetId), + ]); + } catch (error) { + return { ok: false, refusal: catalogRejection(error) }; + } + + if (!attacker) { + return refuse('attacker-pet-missing', `pet ${intent.attackerPetId} is not in the roster`); + } + if (!defender) { + return refuse('defender-pet-missing', `pet ${intent.defenderPetId} is not in the roster`); + } + // Both pets must be off cooldown, mirroring GameLogic.sol's requirement that neither side + // of an on-chain battle is mid-recovery. + if (!isBattleReady(attacker, nowSeconds)) { + return refuse('attacker-not-ready', `attacker ready at ${attacker.readyAt}`); + } + if (!isBattleReady(defender, nowSeconds)) { + return refuse('defender-not-ready', `defender ready at ${defender.readyAt}`); + } + return proceed({ attacker, defender }); +} + +/** + * The rules this battle will be fought and judged under, published so it can be replayed. + * + * Built from the live item catalog rather than the constant: gear changes fights, so the + * rules a battle names have to include what gear does (roadmap §4). + */ +async function priceUnderServedRuleset( + attacker: PetSnapshot, + defender: PetSnapshot, +): Promise> { + let ruleset: Ruleset; + try { + ruleset = await servedRuleset(); + } catch (error) { + return { ok: false, refusal: catalogRejection(error) }; + } + + // The gear the snapshots froze has to be the gear this ruleset prices (roadmap §4, + // threat T13). The verifier makes the same comparison on the finished receipt, using the + // same function; making it here as well turns "this battle will fail to verify" into + // "this battle was never accepted". + // + // Not merely redundant. The two inputs are read from the item catalog at different + // points — `buildPetSnapshot` resolves the modifiers, `servedRuleset` publishes them — + // so a seeder run landing between the two would price the fight from one catalog and the + // rules from another. Narrow, but it produces a receipt that cannot be verified and no + // other check would notice. + const mismatches = findEquipmentMismatches( + [ + { role: 'attacker', equipment: attacker.equipment }, + { role: 'defender', equipment: defender.equipment }, + ], + ruleset, + ); + if (mismatches.length > 0) { + return refuse('equipment-catalog-mismatch', mismatches.join('; ')); + } + + const rulesetHash = await servedRulesetHash(); + await ensureRulesetPublished(ruleset, rulesetHash); + return proceed({ ruleset, rulesetHash }); +} + +/** + * Stage A: the durable record, written before any randomness for this battle exists. + * + * One transaction (inside `openBattle`) consumes the intent, locks both pets, and persists + * the frozen snapshot. Splitting it out keeps `acceptBattle` readable as the sequence it is, + * rather than a sequence with one twenty-line row literal in the middle of it. + */ +async function openAcceptedBattle(args: { + battleId: string; + domain: { chainId: ChainId; deploymentId: string }; + intentHash: string; + authorizationHash: string; + snapshot: BattleSnapshot; + rulesetHash: Hex; + rulesetVersion: number; + roomId: string | null; +}): Promise> { + const { attacker, defender } = args.snapshot; + const opened = await openBattle({ + consumeIntentHash: args.intentHash, + petIds: [attacker.petId.toString(), defender.petId.toString()], + ledger: { + battleId: args.battleId, + chainId: args.domain.chainId, + deploymentId: args.domain.deploymentId, + state: BattleState.accepted, + intentHash: args.intentHash, + authorizationHash: args.authorizationHash, + attackerPetId: attacker.petId.toString(), + attackerOwner: attacker.owner, + defenderPetId: defender.petId.toString(), + defenderOwner: defender.owner, + snapshot: serializeBigints(args.snapshot), + snapshotHash: hashBattleSnapshot(args.snapshot), + rulesetHash: args.rulesetHash, + rulesetVersion: args.rulesetVersion, + // Filled in Stage C once the round is committed and signed; zero is not a legal + // committed round, so a row stuck here is unambiguously still `accepted`. + drandChainHash: '', + drandRound: 0n, + acceptedAt: 0n, + roomId: args.roomId, + }, + }); + + if (opened.ok) { + return proceed(null); + } + return opened.reason === 'pet-locked' + ? refuse('pet-locked', `pet ${opened.petId} already has an open battle`) + : refuse('intent-already-consumed', 'this intent already produced a battle'); +} diff --git a/backend/src/features/battle/ledger/consent.controller.ts b/backend/src/features/battle/ledger/consent.controller.ts index dd39180c..37256e69 100644 --- a/backend/src/features/battle/ledger/consent.controller.ts +++ b/backend/src/features/battle/ledger/consent.controller.ts @@ -5,10 +5,12 @@ import type { AuthenticatedRequest } from '@middleware/auth'; import { type AuthorizationRejection, type DefenseAuthorizationWire, + listDefenseAuthorizations, revokeDefenseAuthorizations, submitDefenseAuthorization, } from './consent.service'; import type { SignatureFormat } from './intent.service'; +import { servedRulesetHash } from './ruleset.builder'; const STATUS_BY_REASON: Record = { 'malformed-authorization': 422, @@ -76,3 +78,34 @@ export async function deleteDefenseAuthorizations(req: AuthenticatedRequest, res const { revoked } = await revokeDefenseAuthorizations(chainId, wallet, new Date()); res.status(200).json({ revoked }); } + +/** + * The caller's own live authorizations, each flagged with whether it still applies. + * + * Always the authenticated wallet, never an argument. One wallet's consent state says + * which of their pets can be challenged and until when, which is theirs to see and nobody + * else's to enumerate. + * + * `isStale` is the field this exists for. A rules change invalidates every outstanding + * grant by design, and a defender is the one who has to re-sign but the last to find out: + * being challenged is passive, so their pets just stop being challengeable and only the + * attacker sees an error. + */ +export async function getDefenseAuthorizations(req: AuthenticatedRequest, res: Response): Promise { + const wallet = req.user?.address; + if (!wallet) { + res.status(401).json({ error: 'authentication required' }); + return; + } + const chainId = typeof req.query.chainId === 'string' ? req.query.chainId : undefined; + if (!chainId) { + res.status(422).json({ error: 'chainId is required' }); + return; + } + + // The hash battles are actually being accepted under, from the same builder `accept` + // uses, so "stale" here means exactly what it means there rather than approximately. + const rulesetHash = await servedRulesetHash(); + const authorizations = await listDefenseAuthorizations(chainId, wallet, rulesetHash); + res.status(200).json({ rulesetHash, authorizations }); +} diff --git a/backend/src/features/battle/ledger/consent.service.ts b/backend/src/features/battle/ledger/consent.service.ts index 1e317a1a..2c3dc83e 100644 --- a/backend/src/features/battle/ledger/consent.service.ts +++ b/backend/src/features/battle/ledger/consent.service.ts @@ -187,6 +187,73 @@ export async function revokeDefenseAuthorizations( return { revoked: count }; } +/** One of the caller's own authorizations, as the read surface presents it. */ +export interface DefenseAuthorizationSummary { + authorizationHash: string; + allPets: boolean; + petIds: string[]; + minLevel: number; + maxLevel: number; + maxBattlesPerDay: number; + /** Unix seconds. */ + notBefore: number; + expiresAt: number; + rulesetHash: string; + /** + * Whether this authorization still covers battles under the rules now being served. + * + * Computed rather than stored, because it is a comparison against a value that moves: + * a grant is signed over one `rulesetHash`, and a rules change makes it cover nothing + * without touching the row. + */ + isStale: boolean; + createdAt: string; +} + +/** + * Every live authorization the caller has granted, and whether each still applies. + * + * The missing third of the consent API. Granting and revoking both existed; reading did + * not, so a defender had no way to learn they had consented, let alone that their consent + * had gone stale. That matters because a rules change invalidates every outstanding grant + * by design, and being challenged is *passive*: the defender never finds out by trying + * something and failing. Their pets simply stop being challengeable, silently, and the + * only person who sees an error is the attacker, who cannot fix it. + * + * Revoked rows are excluded rather than returned with a flag. A revocation is the owner + * deciding this grant no longer exists, and the row survives only so a verifier can still + * read what a historical receipt was authorized under, which is not this caller's question. + */ +export async function listDefenseAuthorizations( + chainId: string, + defenderOwner: string, + servedRulesetHash: string, +): Promise { + const rows = await prisma.defenseAuthorization.findMany({ + where: { + chainId, + deploymentId: servedDeploymentId(), + defenderOwner: normalizeAccount(defenderOwner), + revokedAt: null, + }, + orderBy: { createdAt: 'desc' }, + }); + + return rows.map((row) => ({ + authorizationHash: row.authorizationHash, + allPets: row.allPets, + petIds: Array.isArray(row.petIds) ? (row.petIds as string[]) : [], + minLevel: row.minLevel, + maxLevel: row.maxLevel, + maxBattlesPerDay: row.maxBattlesPerDay, + notBefore: Number(row.notBefore), + expiresAt: Number(row.expiresAt), + rulesetHash: row.rulesetHash, + isStale: row.rulesetHash.toLowerCase() !== servedRulesetHash.toLowerCase(), + createdAt: row.createdAt.toISOString(), + })); +} + /** What a battle needs an authorization to permit. */ export interface CoverageRequest { chainId: string; diff --git a/backend/src/features/battle/ledger/index.ts b/backend/src/features/battle/ledger/index.ts index 5dc27798..e3fc9e23 100644 --- a/backend/src/features/battle/ledger/index.ts +++ b/backend/src/features/battle/ledger/index.ts @@ -15,7 +15,11 @@ export { type SequencePage, } from './corpus.service'; export { getReceiptsByPet, getReceiptsBySequence, getReceiptsByWallet } from './corpus.controller'; -export { deleteDefenseAuthorizations, postDefenseAuthorization } from './consent.controller'; +export { + deleteDefenseAuthorizations, + getDefenseAuthorizations, + postDefenseAuthorization, +} from './consent.controller'; export { getBattleCombatLog, getBattleCommitment, @@ -51,9 +55,11 @@ export { type ConsentResult, consumeDailyBudget, type CoverageRequest, + type DefenseAuthorizationSummary, type DefenseAuthorizationWire, epochDay, findCoveringAuthorization, + listDefenseAuthorizations, revokeDefenseAuthorizations, type SubmitAuthorizationRequest, type SubmitAuthorizationResult, @@ -62,6 +68,18 @@ export { verifyAuthorizationSignature, } from './consent.service'; export { assertServedDomain, servedChainIds, servedDeploymentId, servedDomain } from './domain'; +export { deleteSessionDelegations, postSessionDelegation } from './session.controller'; +export { + findSessionDelegation, + revokeSessionDelegations, + type SessionDelegationWire, + type SessionRejection, + submitSessionDelegation, + type SubmitSessionRequest, + type SubmitSessionResult, + toProtocolDelegation, + verifyDelegationSignature, +} from './session.service'; export { backendBattleModeEnabled, requireBackendBattleMode } from './mode'; export { postBattleIntent } from './intent.controller'; export { @@ -103,6 +121,7 @@ export { } from './state'; export { abandonBattle, + expireOrphanedAccepts, applyTransition, type BattleLedgerPatch, failBattle, @@ -115,3 +134,10 @@ export { type TransitionResult, } from './transitions'; export { buildPetSnapshot } from './snapshot.builder'; +export { + decodeStoredPet, + decodeStoredSnapshot, + type StoredBattleSnapshot, + type StoredEquipEntry, + type StoredPetSnapshot, +} from './snapshot.codec'; diff --git a/backend/src/features/battle/ledger/intent.controller.ts b/backend/src/features/battle/ledger/intent.controller.ts index 1b7658e5..159c0c24 100644 --- a/backend/src/features/battle/ledger/intent.controller.ts +++ b/backend/src/features/battle/ledger/intent.controller.ts @@ -17,13 +17,18 @@ import { * distinction is worth making: a client should retry none of these, but a wallet-mismatch is * a bug in the client while a used nonce usually means a duplicate submit. */ -const STATUS_BY_REASON: Record = { +export const STATUS_BY_REASON: Record = { 'malformed-intent': 422, 'wrong-deployment': 422, - expired: 422, + 'intent-expired': 422, 'wallet-mismatch': 403, 'wrong-signature-format': 422, 'bad-signature': 401, + // 401, like a bad signature: the signature was real but the key is not authorized to + // act for this wallet. Its own reason rather than folded into `bad-signature`, because + // it is the one a player can fix — the session lapsed or was revoked, and re-approving + // is a single prompt. A client should re-delegate and retry rather than give up. + 'session-not-authorized': 401, 'unknown-pet': 404, 'not-pet-owner': 403, 'self-battle': 422, @@ -35,6 +40,8 @@ interface SubmitIntentBody { intent?: BattleIntentWire; signature?: string; signatureFormat?: SignatureFormat; + /** The delegated key that signed, when one did (§D). Absent means the wallet signed. */ + sessionKey?: string; } export async function postBattleIntent(req: AuthenticatedRequest, res: Response): Promise { @@ -54,6 +61,7 @@ export async function postBattleIntent(req: AuthenticatedRequest, res: Response) intent: body.intent, signature: body.signature, signatureFormat: body.signatureFormat, + ...(typeof body.sessionKey === 'string' ? { sessionKey: body.sessionKey } : {}), authenticatedWallet: wallet, // The clock enters here and nowhere deeper, so every layer below is testable // without faking time. diff --git a/backend/src/features/battle/ledger/intent.service.ts b/backend/src/features/battle/ledger/intent.service.ts index f65092a2..a187f4ca 100644 --- a/backend/src/features/battle/ledger/intent.service.ts +++ b/backend/src/features/battle/ledger/intent.service.ts @@ -16,6 +16,7 @@ import { verifySolanaSignature } from '@features/auth/solana'; import { getPetById } from '@repositories/roster.repository'; import { assertServedDomain } from './domain'; +import { findSessionDelegation } from './session.service'; /** * Battle intent submission (§D). @@ -51,6 +52,13 @@ export interface SubmitIntentRequest { intent: BattleIntentWire; signature: string; signatureFormat: SignatureFormat; + /** + * The delegated key that signed, when one did (§D). + * + * Absent means the wallet signed directly, which is the original path and stays + * supported: a client with no session, or one whose session lapsed, simply prompts. + */ + sessionKey?: string; /** Wallet from the verified JWT. Must be the attacker. */ authenticatedWallet: string; /** Unix seconds. Injected so expiry is testable and never read from a global clock. */ @@ -61,10 +69,20 @@ export interface SubmitIntentRequest { export type IntentRejection = | 'malformed-intent' | 'wrong-deployment' - | 'expired' + /** + * The intent's own `expiresAt` has passed. Named for the intent rather than a bare + * `expired` because the accept path returns a `CoverageFailure` also spelled `expired`, + * meaning the *defender's authorization* lapsed. One wire code cannot carry both: the + * client maps a code to player-facing text with no idea which endpoint produced it, and + * told players their request had timed out when the opponent's consent was the thing + * that had run out. + */ + | 'intent-expired' | 'wallet-mismatch' | 'wrong-signature-format' | 'bad-signature' + /** A real signature from a key this wallet has not delegated to, or no longer has. */ + | 'session-not-authorized' | 'unknown-pet' | 'not-pet-owner' | 'self-battle' @@ -99,7 +117,7 @@ export async function submitBattleIntent(request: SubmitIntentRequest): Promise< } if (isExpired(intent, request.nowSeconds)) { - return reject('expired', `intent expired at ${intent.expiresAt}, now ${request.nowSeconds}`); + return reject('intent-expired', `intent expired at ${intent.expiresAt}, now ${request.nowSeconds}`); } if (normalizeAccount(request.authenticatedWallet) !== intent.attackerOwner) { @@ -122,7 +140,30 @@ export async function submitBattleIntent(request: SubmitIntentRequest): Promise< ); } - if (!verifyIntentSignature(intent, request.signature, expectedFormat)) { + // Signed by the wallet, or by a key the wallet delegated to (§D). + // + // The delegated branch is checked against `sessionKey` rather than by recovering and + // seeing who turns up, because recovery is an EVM affordance: Solana verifies against a + // named pubkey. Having the client say which key it used keeps one code path for both + // families, and costs nothing, since a lie fails the signature check immediately. + if (request.sessionKey) { + const signer = normalizeAccount(request.sessionKey); + if (!verifyIntentSignature(intent, request.signature, expectedFormat, signer)) { + return reject('bad-signature', 'signature does not verify against the named session key'); + } + const delegation = await findSessionDelegation( + intent.domain.chainId, + intent.attackerOwner, + signer, + request.nowSeconds, + ); + if (!delegation.ok) { + // Its own reason, because it is the one a player can act on: their session + // lapsed or was revoked, and re-approving takes one prompt. Collapsing it into + // `bad-signature` would send them looking at their wallet instead. + return reject('session-not-authorized', `${signer} may not sign for ${intent.attackerOwner}: ${delegation.reason}`); + } + } else if (!verifyIntentSignature(intent, request.signature, expectedFormat)) { return reject('bad-signature', 'signature does not recover to the attacker owner'); } @@ -184,8 +225,22 @@ export async function submitBattleIntent(request: SubmitIntentRequest): Promise< * digest supplied by the client. A client that sends a signature over different fields * fails here, because the message being checked is derived from the fields it claims. */ -export function verifyIntentSignature(intent: BattleIntent, signature: string, format: SignatureFormat): boolean { +/** + * Whether `signature` over `intent` was produced by `expectedSigner`. + * + * Defaults to the attacker owner, which is the wallet-signed path. A delegated session key + * is passed explicitly, and the caller is responsible for having checked that the key is + * actually allowed to act for that owner — this function only answers "who signed this", + * never "may they". + */ +export function verifyIntentSignature( + intent: BattleIntent, + signature: string, + format: SignatureFormat, + expectedSigner: string = intent.attackerOwner, +): boolean { try { + const signer = normalizeAccount(expectedSigner); if (format === 'eip712') { const typed = battleIntentTypedData(intent); // The protocol declares its type list `as const` so field order cannot drift; @@ -193,9 +248,9 @@ export function verifyIntentSignature(intent: BattleIntent, signature: string, f // rebuilt copy that could silently reorder fields. const types = typed.types as unknown as Record; const recovered = ethers.verifyTypedData(typed.domain, types, typed.message, signature); - return normalizeAccount(recovered) === intent.attackerOwner; + return normalizeAccount(recovered) === signer; } - return verifySolanaSignature(intent.attackerOwner, signature, battleIntentSolanaMessage(intent)); + return verifySolanaSignature(signer, signature, battleIntentSolanaMessage(intent)); } catch { // A malformed signature is a refusal, not an exception for the route to handle. return false; diff --git a/backend/src/features/battle/ledger/outbox.ts b/backend/src/features/battle/ledger/outbox.ts index eb3af5df..d5969194 100644 --- a/backend/src/features/battle/ledger/outbox.ts +++ b/backend/src/features/battle/ledger/outbox.ts @@ -26,10 +26,18 @@ export const OUTBOX_TOPICS = { sign: 'sign', /** Publish the receipt to the public corpus. */ publish: 'publish', - /** Include the receipt in the next Merkle batch. */ - batch: 'batch', } as const; +/** + * Publishing is the last per-battle step. Batching and anchoring (§I) deliberately have no + * topic here: they aggregate across every publishable receipt on their own timer + * (`startBatchAnchor`), so there is no per-battle message to send. A `batch` topic was + * declared here for a while and never enqueued or handled, which was worse than absent — + * `claimOutbox` only claims topics `HANDLERS` lists, so anyone who took the declaration at + * face value would have enqueued a message no worker could ever claim, stranding the battle + * in a non-terminal state with both pets locked and nothing to dead-letter it. + */ + export type OutboxTopic = (typeof OUTBOX_TOPICS)[keyof typeof OUTBOX_TOPICS]; /** Retries before a message is dead-lettered. */ diff --git a/backend/src/features/battle/ledger/reads.service.ts b/backend/src/features/battle/ledger/reads.service.ts index 58bd7c24..0bfcd22c 100644 --- a/backend/src/features/battle/ledger/reads.service.ts +++ b/backend/src/features/battle/ledger/reads.service.ts @@ -1,6 +1,6 @@ -import { hashRuleset, type Hex } from '@cryptopets/protocol'; +import { type Hex } from '@cryptopets/protocol'; -import { servedRuleset } from './ruleset.builder'; +import { servedRuleset, servedRulesetHash } from './ruleset.builder'; import { ethers } from 'ethers'; import { prisma } from '@config/prisma'; @@ -65,7 +65,7 @@ export async function getBattleConfig(): Promise { enabled: backendBattleModeEnabled(), deploymentId: servedDeploymentId(), chainIds: servedChainIds(), - ruleset: { hash: hashRuleset(ruleset), version: ruleset.version }, + ruleset: { hash: await servedRulesetHash(), version: ruleset.version }, }; } diff --git a/backend/src/features/battle/ledger/ruleset.builder.ts b/backend/src/features/battle/ledger/ruleset.builder.ts index aac12d26..308f8b6d 100644 --- a/backend/src/features/battle/ledger/ruleset.builder.ts +++ b/backend/src/features/battle/ledger/ruleset.builder.ts @@ -1,6 +1,6 @@ -import { type ItemModifier, type Ruleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; +import { hashRuleset, type Hex, type ItemModifier, type Ruleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; -import { getCatalog } from '@features/inventory'; +import { getCombatCatalog, itemCatalogGeneration } from '@features/inventory'; /** * Builds the ruleset this deployment fights under (roadmap §4). @@ -26,15 +26,26 @@ import { getCatalog } from '@features/inventory'; * path to answer the same question. A catalog edit therefore needs a restart to take * effect, which is the right shape for something that invalidates outstanding consent: * it should be a deliberate rollout, not a row edit that quietly re-prices live battles. + * + * Stamped with the catalog generation it was built from, so dropping the catalog drops + * this too. Two independent process-life caches over the same rows was a trap: whichever + * one a caller knew to reset, the other kept answering from data that no longer existed. */ -let cached: Ruleset | null = null; +let cached: { ruleset: Ruleset; hash: Hex; generation: number } | null = null; export async function servedRuleset(): Promise { - if (cached) { - return cached; + // Read before the await, not after: a reset landing mid-build then stamps this result + // with the older generation, so the next call rebuilds. The other order would stamp a + // half-stale ruleset as current. + const generation = itemCatalogGeneration(); + if (cached && cached.generation === generation) { + return cached.ruleset; } - const catalog = await getCatalog(); + // The strict read: an equipment row whose modifier will not parse throws here rather + // than dropping out of the list. Dropping it would move `rulesetHash` and invalidate + // every outstanding defence authorization on the strength of one bad column. + const catalog = await getCombatCatalog(); const itemCatalog: ItemModifier[] = []; for (const item of catalog) { if (item.effect?.kind !== 'stat_bonus' || item.slot === null) { @@ -56,11 +67,41 @@ export async function servedRuleset(): Promise { // type surfaces rather than being tidied away. itemCatalog.sort((a, b) => (a.itemType < b.itemType ? -1 : a.itemType > b.itemType ? 1 : 0)); - cached = { ...SOURCE_DEFAULT_RULESET, itemCatalog }; - return cached; + const ruleset: Ruleset = { ...SOURCE_DEFAULT_RULESET, itemCatalog }; + cached = { ruleset, hash: hashRuleset(ruleset), generation }; + return cached.ruleset; } -/** Test seam: drops the memoized ruleset so a changed catalog is picked up. */ +/** + * The hash of the served ruleset, derived once per catalog generation. + * + * Every caller that needs it used to run `hashRuleset(await servedRuleset())` itself, and + * four sites doing that is four chances to hash something else. It was not hypothetical: + * matchmaking hashed `SOURCE_DEFAULT_RULESET` while defenders signed against the served + * one, so the consent filter matched no authorization ever written and the opponent list + * came back empty on a deployment full of consenting pets. Nothing detected it, because an + * empty list is also the correct answer when nobody has consented. + * + * Deriving it beside the ruleset it belongs to makes that class of divergence impossible + * rather than merely fixed: there is one place the value comes from, and a caller cannot + * reach the ruleset without the matching hash being right there. + * + * It also takes a keccak over the whole ruleset — item catalog included — off the + * matchmaking query path, which ran it per request. + */ +export async function servedRulesetHash(): Promise { + await servedRuleset(); + // Non-null: `servedRuleset` either returns from the cache or fills it. + return cached!.hash; +} + +/** + * Test seam: drops the memoized ruleset directly. + * + * Rarely the one to reach for now. `resetItemCatalog()` invalidates this as well, which is + * what a caller changing catalog rows actually wants; this is for a test that stubs the + * catalog module itself and so never bumps a generation. + */ export function resetServedRuleset(): void { cached = null; } diff --git a/backend/src/features/battle/ledger/session.controller.ts b/backend/src/features/battle/ledger/session.controller.ts new file mode 100644 index 00000000..61f6deda --- /dev/null +++ b/backend/src/features/battle/ledger/session.controller.ts @@ -0,0 +1,86 @@ +import type { Response } from 'express'; + +import type { AuthenticatedRequest } from '@middleware/auth'; + +import type { SignatureFormat } from './intent.service'; +import { + revokeSessionDelegations, + type SessionDelegationWire, + type SessionRejection, + submitSessionDelegation, +} from './session.service'; + +/** + * Delegated battle-intent signing (§D). + * + * The owner approves a client-held key once; that key then signs intents, so the wallet + * prompt stops being per battle. What it does not change is who authorizes a battle: the + * key is generated and held by the client, so the operator still cannot produce an intent, + * which is the property that ruled out authorizing from a JWT in the first place. + */ + +const STATUS_BY_REASON: Record = { + 'malformed-delegation': 422, + 'wrong-deployment': 422, + 'wallet-mismatch': 403, + 'wrong-signature-format': 422, + 'bad-signature': 401, + 'already-expired': 422, + 'stale-revocation-nonce': 409, +}; + +interface SubmitBody { + delegation?: SessionDelegationWire; + signature?: string; + signatureFormat?: SignatureFormat; +} + +export async function postSessionDelegation(req: AuthenticatedRequest, res: Response): Promise { + const wallet = req.user?.address; + if (!wallet) { + res.status(401).json({ error: 'authentication required' }); + return; + } + const body = req.body as SubmitBody; + if (!body?.delegation || typeof body.signature !== 'string' || !body.signatureFormat) { + res.status(422).json({ error: 'delegation, signature, and signatureFormat are required' }); + return; + } + + const result = await submitSessionDelegation({ + delegation: body.delegation, + signature: body.signature, + signatureFormat: body.signatureFormat, + authenticatedWallet: wallet, + nowSeconds: Math.floor(Date.now() / 1000), + }); + + if (!result.ok) { + res.status(STATUS_BY_REASON[result.reason]).json({ error: result.reason, detail: result.detail }); + return; + } + res.status(201).json({ delegationHash: result.delegationHash, expiresAt: result.expiresAt }); +} + +/** + * Revokes every session key this wallet has approved on one chain. + * + * Unsigned, like consent revocation and for the same reason: the failure mode of an + * unauthorized revocation is more wallet prompts, never fewer. Demanding a signature would + * strand exactly the person who most needs this — someone whose key was stolen. + */ +export async function deleteSessionDelegations(req: AuthenticatedRequest, res: Response): Promise { + const wallet = req.user?.address; + if (!wallet) { + res.status(401).json({ error: 'authentication required' }); + return; + } + const chainId = typeof req.query.chainId === 'string' ? req.query.chainId : undefined; + if (!chainId) { + res.status(422).json({ error: 'chainId is required' }); + return; + } + + const { revoked } = await revokeSessionDelegations(chainId, wallet, new Date()); + res.status(200).json({ revoked }); +} diff --git a/backend/src/features/battle/ledger/session.service.ts b/backend/src/features/battle/ledger/session.service.ts new file mode 100644 index 00000000..cb1e07a4 --- /dev/null +++ b/backend/src/features/battle/ledger/session.service.ts @@ -0,0 +1,260 @@ +import { + type ChainId, + chainFamily, + hashSessionDelegation, + normalizeAccount, + type SessionDelegation, + sessionCovers, + sessionDelegationSolanaMessage, + sessionDelegationTypedData, +} from '@cryptopets/protocol'; +import { ethers } from 'ethers'; + +import { prisma } from '@config/prisma'; +import { verifySolanaSignature } from '@features/auth/solana'; + +import { assertServedDomain, servedDeploymentId } from './domain'; +import type { SignatureFormat } from './intent.service'; + +/** + * Delegated battle-intent signing (§D). + * + * §D's rule is that a wallet authorizes a battle and a JWT does not, because a JWT is a + * bearer token this server issues to itself: accepting one would mean the operator could + * start battles as any player, burn their cooldowns, and move their standing, with nothing + * in the record able to tell that apart from the player having done it. + * + * A delegation keeps that property and removes the prompt. The owner signs once, naming a + * key the *client* generated and holds. The operator never sees the private key, so it + * still cannot forge an intent. Only the number of times a human is asked changes. + * + * Three bounds make the delegated key visibly weaker than the wallet, and all three are + * enforced here rather than trusted to the client: scope (battle intents alone), a window + * the protocol caps at 24 hours, and revocation. + */ + +export interface SessionDelegationWire { + chainId: string; + deploymentId: string; + owner: string; + sessionKey: string; + scope: string; + notBefore: number; + expiresAt: number; + revocationNonce: number; +} + +export type SessionRejection = + | 'malformed-delegation' + | 'wrong-deployment' + | 'wallet-mismatch' + | 'wrong-signature-format' + | 'bad-signature' + | 'already-expired' + | 'stale-revocation-nonce'; + +export type SubmitSessionResult = + | { ok: true; delegationHash: string; expiresAt: number } + | { ok: false; reason: SessionRejection; detail: string }; + +export interface SubmitSessionRequest { + delegation: SessionDelegationWire; + signature: string; + signatureFormat: SignatureFormat; + /** Wallet from the verified JWT. Must be the delegating owner. */ + authenticatedWallet: string; + nowSeconds: number; +} + +/** Records a wallet-signed delegation after checking it says what it claims. */ +export async function submitSessionDelegation(request: SubmitSessionRequest): Promise { + let delegation: SessionDelegation; + try { + delegation = toProtocolDelegation(request.delegation); + assertServedDomain(delegation.domain); + } catch (error) { + const message = (error as Error).message; + return message.includes('deployment') || message.includes('chain') + ? reject('wrong-deployment', message) + : reject('malformed-delegation', message); + } + + // The JWT says who is calling; this says they are delegating their own authority and + // not somebody else's. Both are required, and neither substitutes for the signature. + if (normalizeAccount(request.authenticatedWallet) !== delegation.owner) { + return reject( + 'wallet-mismatch', + `authenticated wallet ${request.authenticatedWallet} is not the delegating owner ${delegation.owner}`, + ); + } + + if (delegation.expiresAt <= request.nowSeconds) { + return reject('already-expired', `delegation expired at ${delegation.expiresAt}`); + } + + const expectedFormat: SignatureFormat = chainFamily(delegation.domain.chainId) === 'evm' ? 'eip712' : 'solana-message'; + if (request.signatureFormat !== expectedFormat) { + return reject( + 'wrong-signature-format', + `${delegation.domain.chainId} delegations are signed as ${expectedFormat}, got ${request.signatureFormat}`, + ); + } + if (!verifyDelegationSignature(delegation, request.signature, expectedFormat)) { + return reject('bad-signature', 'signature does not recover to the delegating owner'); + } + + // Monotonic, matching `DefenseAuthorization`: bumping the nonce is how an owner + // cancels everything signed at a lower value, so accepting a lower one would let a + // replayed older delegation resurrect authority that was deliberately withdrawn. + const newest = await prisma.sessionDelegation.findFirst({ + where: { chainId: delegation.domain.chainId, deploymentId: delegation.domain.deploymentId, owner: delegation.owner }, + orderBy: { revocationNonce: 'desc' }, + select: { revocationNonce: true }, + }); + if (newest && delegation.revocationNonce < newest.revocationNonce) { + return reject( + 'stale-revocation-nonce', + `revocationNonce ${delegation.revocationNonce} is below the current ${newest.revocationNonce}`, + ); + } + + const delegationHash = hashSessionDelegation(delegation); + await prisma.sessionDelegation.upsert({ + where: { delegationHash }, + // Re-submitting the same delegation is idempotent rather than an error: a client + // that lost its response and retried should get the same answer. + update: {}, + create: { + delegationHash, + chainId: delegation.domain.chainId, + deploymentId: delegation.domain.deploymentId, + owner: delegation.owner, + sessionKey: delegation.sessionKey, + scope: delegation.scope, + notBefore: BigInt(delegation.notBefore), + expiresAt: BigInt(delegation.expiresAt), + revocationNonce: delegation.revocationNonce, + signature: request.signature, + signatureFormat: request.signatureFormat, + }, + }); + + return { ok: true, delegationHash, expiresAt: delegation.expiresAt }; +} + +/** + * Whether `sessionKey` may sign battle intents for `owner` right now. + * + * The stored row is rebuilt into the protocol object and run through `sessionCovers`, so + * the rule that decides this is the published one rather than a second copy of it in SQL. + * Revocation is the part a pure function cannot know, and is the only check done here. + */ +export async function findSessionDelegation( + chainId: ChainId, + owner: string, + sessionKey: string, + nowSeconds: number, +): Promise<{ ok: true; delegationHash: string } | { ok: false; reason: string }> { + const deploymentId = servedDeploymentId(); + const normalizedOwner = normalizeAccount(owner); + const normalizedKey = normalizeAccount(sessionKey); + + const rows = await prisma.sessionDelegation.findMany({ + where: { + chainId, + deploymentId, + owner: normalizedOwner, + sessionKey: normalizedKey, + revokedAt: null, + }, + orderBy: { revocationNonce: 'desc' }, + }); + if (rows.length === 0) { + return { ok: false, reason: 'no-delegation' }; + } + + for (const row of rows) { + const coverage = sessionCovers( + { + domain: { chainId: row.chainId as ChainId, deploymentId: row.deploymentId }, + owner: row.owner, + sessionKey: row.sessionKey, + scope: row.scope as SessionDelegation['scope'], + notBefore: Number(row.notBefore), + expiresAt: Number(row.expiresAt), + revocationNonce: row.revocationNonce, + }, + { + domain: { chainId, deploymentId }, + owner: normalizedOwner, + sessionKey: normalizedKey, + scope: 'battle-intent', + nowSeconds, + }, + ); + if (coverage.covered) { + return { ok: true, delegationHash: row.delegationHash }; + } + } + // Rows exist but none apply, which is almost always a key whose window has closed. + return { ok: false, reason: 'delegation-not-valid' }; +} + +/** + * Revokes every delegation this wallet holds on one chain. + * + * Unsigned, exactly like `revokeDefenseAuthorizations` and for the same reason: the failure + * mode of an unauthorized revocation is more wallet prompts, never fewer, and demanding a + * signature would strand someone whose key was stolen from doing the one thing that helps. + */ +export async function revokeSessionDelegations( + chainId: string, + owner: string, + revokedAt: Date, +): Promise<{ revoked: number }> { + const { count } = await prisma.sessionDelegation.updateMany({ + where: { + chainId, + deploymentId: servedDeploymentId(), + owner: normalizeAccount(owner), + revokedAt: null, + }, + data: { revokedAt }, + }); + return { revoked: count }; +} + +export function verifyDelegationSignature( + delegation: SessionDelegation, + signature: string, + format: SignatureFormat, +): boolean { + try { + if (format === 'eip712') { + const typed = sessionDelegationTypedData(delegation); + const types = typed.types as unknown as Record; + const recovered = ethers.verifyTypedData(typed.domain, types, typed.message, signature); + return normalizeAccount(recovered) === delegation.owner; + } + return verifySolanaSignature(delegation.owner, signature, sessionDelegationSolanaMessage(delegation)); + } catch { + return false; + } +} + +/** Maps the wire shape onto the protocol type, leaving validation to the protocol. */ +export function toProtocolDelegation(wire: SessionDelegationWire): SessionDelegation { + return { + domain: { chainId: wire.chainId as ChainId, deploymentId: wire.deploymentId }, + owner: wire.owner, + sessionKey: wire.sessionKey, + scope: wire.scope as SessionDelegation['scope'], + notBefore: wire.notBefore, + expiresAt: wire.expiresAt, + revocationNonce: wire.revocationNonce, + }; +} + +function reject(reason: SessionRejection, detail: string): SubmitSessionResult { + return { ok: false, reason, detail }; +} diff --git a/backend/src/features/battle/ledger/snapshot.builder.ts b/backend/src/features/battle/ledger/snapshot.builder.ts index e7c0826e..450f741d 100644 --- a/backend/src/features/battle/ledger/snapshot.builder.ts +++ b/backend/src/features/battle/ledger/snapshot.builder.ts @@ -1,7 +1,7 @@ import { chainFamily, type ChainId, type EquipEntry, type PetSnapshot } from '@cryptopets/protocol'; import { prisma } from '@config/prisma'; -import { getPetEquipment } from '@features/inventory'; +import { getPetEquipmentForCombat } from '@features/inventory'; import { servedDeploymentId } from './domain'; @@ -79,28 +79,23 @@ export async function buildPetSnapshot(chainId: ChainId, petId: string): Promise * so what is frozen is what the chain said at a version the snapshot records. An outsider * can therefore check the gear as well as the numbers. * - * An equipped item with no catalog effect contributes nothing and is left out entirely. - * Including it with zeroes would put an entry in the receipt claiming an item was worn and - * did nothing, which reads as a bug rather than as a fact. + * `getPetEquipmentForCombat` throws rather than skipping an item this process cannot + * price, so there is no filtering left to do here. Skipping was the tempting version and + * the wrong one: it produced a receipt claiming the pet fought bare while chain state at + * `sourceVersion` said it was wearing something. */ async function resolveEquipment(family: string, petId: string): Promise { - const equipped = await getPetEquipment(family, petId); - const entries: EquipEntry[] = []; + const equipped = await getPetEquipmentForCombat(family, petId); - for (const { slot, item } of equipped) { - if (item.effect?.kind !== 'stat_bonus') { - continue; - } - entries.push({ - slot, - itemType: BigInt(item.itemType), - hp: item.effect.hp, - atk: item.effect.atk, - def: item.effect.def, - int: item.effect.int, - mdef: item.effect.mdef, - }); - } + const entries = equipped.map(({ slot, itemType, bonus }) => ({ + slot, + itemType: BigInt(itemType), + hp: bonus.hp, + atk: bonus.atk, + def: bonus.def, + int: bonus.int, + mdef: bonus.mdef, + })); // Ascending by slot, which the protocol requires: the order is part of the snapshot // digest, and `assertPetSnapshot` refuses to sort silently so an upstream bug that diff --git a/backend/src/features/battle/ledger/snapshot.codec.ts b/backend/src/features/battle/ledger/snapshot.codec.ts new file mode 100644 index 00000000..2e9720cd --- /dev/null +++ b/backend/src/features/battle/ledger/snapshot.codec.ts @@ -0,0 +1,102 @@ +import type { BattleSnapshot, EquipEntry, PetSnapshot } from '@cryptopets/protocol'; + +/** + * Reads a frozen snapshot back out of the ledger row it was stored in. + * + * The snapshot is persisted with `JSON.stringify`, which has no bigint, so `petId`, `dna`, + * `lastOpponentId`, `sourceVersion` and an equipped item's `itemType` all come back as + * decimal strings. The protocol types require real bigints, so nothing may hash, validate + * or simulate a stored snapshot without going through here first. + * + * One decoder, used by every worker that reads the column. There were three, written + * separately, and that is precisely how one of them came to be missing `schemaVersion` and + * `equipment` after roadmap §4 added them: the signing worker rebuilt every snapshot at + * layout version 1, its hash stopped matching the one acceptance committed, and the seed + * check inside `assertBattleReceipt` refused every receipt. Adding a field to `PetSnapshot` + * must be a change in one place, or the next field lands the same way. + * + * `schemaVersion` is carried verbatim and never defaulted. A row written before that field + * existed genuinely is a version 1 snapshot with a receipt already signed over it, so + * leaving it absent is what lets `assertBattleSnapshot` read it as 1; substituting this + * build's current version would re-encode it under a layout it was never hashed under. + */ + +/** One equipped item as stored: JSON, so the item type arrives as a decimal string. */ +export interface StoredEquipEntry { + slot: number; + itemType: string | bigint; + hp: number; + atk: number; + def: number; + int: number; + mdef: number; +} + +/** One pet as stored, with every bigint field widened to accept its decimal-string form. */ +export interface StoredPetSnapshot { + petId: string | bigint; + owner: string; + dna: string | bigint; + rarity: number; + level: number; + skill: number; + xp: number; + lastOpponentId: string | bigint; + streak: number; + readyAt: number; + sourceVersion: string | bigint; + equipment?: StoredEquipEntry[]; +} + +/** A battle snapshot as stored in `battle_ledger.snapshot`. */ +export interface StoredBattleSnapshot { + domain: BattleSnapshot['domain']; + attacker: StoredPetSnapshot; + defender: StoredPetSnapshot; + takenAt: number; + schemaVersion?: number; +} + +/** Decodes one stored pet. */ +export function decodeStoredPet(pet: StoredPetSnapshot): PetSnapshot { + return { + petId: BigInt(pet.petId), + owner: pet.owner, + dna: BigInt(pet.dna), + rarity: pet.rarity, + level: pet.level, + skill: pet.skill, + xp: pet.xp, + lastOpponentId: BigInt(pet.lastOpponentId), + streak: pet.streak, + readyAt: pet.readyAt, + sourceVersion: BigInt(pet.sourceVersion), + // Omitted rather than empty when the pet wore nothing, matching what + // `assertPetSnapshot` normalizes to and what `snapshot.builder` wrote. + ...(pet.equipment && pet.equipment.length > 0 && { equipment: pet.equipment.map(decodeStoredEquipEntry) }), + }; +} + +/** Decodes a whole stored snapshot, ready to hash, validate or simulate. */ +export function decodeStoredSnapshot(stored: unknown): BattleSnapshot { + const snapshot = stored as StoredBattleSnapshot; + return { + domain: snapshot.domain, + attacker: decodeStoredPet(snapshot.attacker), + defender: decodeStoredPet(snapshot.defender), + takenAt: snapshot.takenAt, + ...(snapshot.schemaVersion !== undefined && { schemaVersion: snapshot.schemaVersion }), + }; +} + +function decodeStoredEquipEntry(entry: StoredEquipEntry): EquipEntry { + return { + slot: entry.slot, + itemType: BigInt(entry.itemType), + hp: entry.hp, + atk: entry.atk, + def: entry.def, + int: entry.int, + mdef: entry.mdef, + }; +} diff --git a/backend/src/features/battle/ledger/transitions.ts b/backend/src/features/battle/ledger/transitions.ts index 8780620b..18ddb832 100644 --- a/backend/src/features/battle/ledger/transitions.ts +++ b/backend/src/features/battle/ledger/transitions.ts @@ -269,3 +269,55 @@ export async function abandonBattle( }); return { abandoned: result.applied, state: result.state }; } + +/** + * How long a battle may sit in `accepted` before it is treated as orphaned. + * + * `accepted` is meant to be transient: `acceptBattle` opens the ledger row and moves it to + * `committed` a few statements later, in the same call. A battle still here minutes later + * did not get slower, it stopped — the process died between the two, or the accept threw + * after the row was written. + * + * Generous anyway, because the cost of being wrong is asymmetric: expiring a live battle + * would strand a player who has already signed, while expiring a dead one late only means + * their pets wait a few more minutes. + */ +const ACCEPTED_ORPHAN_SECONDS = 10 * 60; + +/** + * Expires battles orphaned in `accepted`, releasing the pets they hold. + * + * Without this a pet locked by an orphan is locked *forever*. Locks are freed by reaching a + * terminal state, and `accepted` has no route to one that anything travels: the outbox + * dead-letter path calls `abandonBattle`, which declines because `accepted` cannot forfeit, + * and nothing else ever wrote `expired`. So the state existed in `ALLOWED_TRANSITIONS`, + * described exactly this situation, and had no code behind it — a crash between accept and + * commit permanently bricked both pets, with the only symptom a unique-constraint error on + * `pet_battle_lock` the next time either one tried to fight. + * + * Runs on the worker tick rather than as a scheduled job, because it needs no coordination: + * `applyTransition` guards on the `from` state, so two workers racing on one battle produce + * one expiry and one no-op. + */ +export async function expireOrphanedAccepts(nowSeconds: number): Promise<{ expired: number }> { + const cutoff = new Date((nowSeconds - ACCEPTED_ORPHAN_SECONDS) * 1000); + const orphans = await prisma.battleLedger.findMany({ + where: { state: BattleState.accepted, createdAt: { lt: cutoff } }, + select: { battleId: true }, + }); + + let expired = 0; + for (const { battleId } of orphans) { + const result = await applyTransition({ + battleId, + from: BattleState.accepted, + to: BattleState.expired, + patch: { failureReason: `orphaned in accepted for over ${ACCEPTED_ORPHAN_SECONDS}s` }, + }); + if (result.applied) { + expired += 1; + console.warn(`[battle-worker] expired orphaned battle ${battleId}; its pets are released`); + } + } + return { expired }; +} diff --git a/backend/src/features/battle/signer/index.ts b/backend/src/features/battle/signer/index.ts index a11f6af2..9e2727aa 100644 --- a/backend/src/features/battle/signer/index.ts +++ b/backend/src/features/battle/signer/index.ts @@ -1,8 +1,9 @@ -export { createKmsSigner } from './signer.kms'; +export { createKmsSigner, createKmsSignerFromPort, type KmsKeyPort } from './signer.kms'; export { createLocalSigner } from './signer.local'; export { activeSigningKey, configureSigner, + signerBackendError, listSigningKeys, loadPersistedSigningKeys, registerRotatedKey, @@ -10,7 +11,7 @@ export { sign, signerAuditLog, } from './signer.service'; -export { loadSigningKeys, persistSigningKey } from './signer.registry'; +export { loadSigningKeys, persistSigningKey, retireInactiveKeys } from './signer.registry'; export { type EngineAttestation, type SignableKind, diff --git a/backend/src/features/battle/signer/signer.kms.aws.ts b/backend/src/features/battle/signer/signer.kms.aws.ts new file mode 100644 index 00000000..d36f96bb --- /dev/null +++ b/backend/src/features/battle/signer/signer.kms.aws.ts @@ -0,0 +1,75 @@ +import { GetPublicKeyCommand, KMSClient, SignCommand } from '@aws-sdk/client-kms'; + +import type { KmsKeyPort } from './signer.kms'; + +/** + * AWS KMS adapter for the battle signer (§G). + * + * Small on purpose: everything fiddly about turning a KMS response into an Ethereum + * signature lives in `signer.kms.crypto.ts`, so this is only "fetch bytes, sign bytes". + * + * Two AWS specifics are load-bearing: + * + * - **`MessageType: 'DIGEST'`.** The default is `RAW`, which makes KMS hash the input + * itself — so passing an already-hashed receipt would sign `SHA-256(receiptHash)` and + * produce a signature that verifies against nothing anyone can recompute. The algorithm + * is still named `ECDSA_SHA_256` in that mode; it describes the digest being supplied, + * not a second hashing step. + * - **`ECC_SECG_P256K1` key spec.** AWS also offers P-256, whose signatures are the same + * shape and useless here. The curve is not visible in the signature, so a key created on + * the wrong one fails at `toEthereumSignature` with "does not recover to the published + * key" rather than anything mentioning curves — worth knowing before debugging that. + * + * The IAM policy for this key should permit `kms:Sign` and `kms:GetPublicKey` and nothing + * else, and the key should have no other grants. §G's requirement is not "the key is in + * AWS" but "the key can only sign these digests and holds no asset authority". + */ + +export interface AwsKmsOptions { + /** Key id, alias (`alias/battle-signer`), or full ARN. */ + keyId: string; + /** Omitted when the environment already supplies one, e.g. on ECS or Lambda. */ + region?: string | undefined; +} + +export function createAwsKmsPort(options: AwsKmsOptions): KmsKeyPort { + // Credentials come from the default provider chain — instance role, task role, or the + // environment — never from configuration here. A key that requires this process to hold + // long-lived secrets to reach it is a key whose isolation is only partial. + const client = new KMSClient(options.region ? { region: options.region } : {}); + + return { + provider: 'aws-kms', + + async getPublicKeyDer(): Promise { + const response = await client.send(new GetPublicKeyCommand({ KeyId: options.keyId })); + if (!response.PublicKey) { + throw new Error(`AWS KMS returned no public key for ${options.keyId}`); + } + if (response.KeySpec && response.KeySpec !== 'ECC_SECG_P256K1') { + // Caught here rather than left to surface as an unrecoverable signature, + // because the message there names neither the key nor the curve. + throw new Error( + `AWS KMS key ${options.keyId} has spec ${response.KeySpec}; battle signing requires ECC_SECG_P256K1`, + ); + } + return response.PublicKey; + }, + + async signDigest(digest: Uint8Array): Promise { + const response = await client.send( + new SignCommand({ + KeyId: options.keyId, + Message: digest, + // See above: without this AWS hashes the digest again. + MessageType: 'DIGEST', + SigningAlgorithm: 'ECDSA_SHA_256', + }), + ); + if (!response.Signature) { + throw new Error(`AWS KMS returned no signature for ${options.keyId}`); + } + return response.Signature; + }, + }; +} diff --git a/backend/src/features/battle/signer/signer.kms.crypto.ts b/backend/src/features/battle/signer/signer.kms.crypto.ts new file mode 100644 index 00000000..5695ee65 --- /dev/null +++ b/backend/src/features/battle/signer/signer.kms.crypto.ts @@ -0,0 +1,144 @@ +import type { Hex } from '@cryptopets/protocol'; +import { ethers } from 'ethers'; + +/** + * Turning what a KMS returns into what an EVM verifier expects (§G). + * + * Every managed KMS that supports secp256k1 hands back an ECDSA signature in the form the + * X.509 world uses, and Ethereum wants a different one. Three gaps, none of them optional: + * + * 1. **Encoding.** AWS and GCP return a DER `SEQUENCE { INTEGER r, INTEGER s }`. Ethereum + * wants fixed 32-byte `r` and `s`. DER integers are signed and minimally encoded, so + * they carry a leading zero when the high bit is set and drop leading zeros otherwise: + * a naive slice produces a signature that verifies to the wrong address roughly half + * the time, which is exactly the kind of bug that looks like "KMS is flaky". + * 2. **Malleability.** For any valid `(r, s)`, `(r, n - s)` is equally valid. Ethereum + * rejects the high half (EIP-2), and a KMS has no reason to care, so `s` has to be + * normalized here or perhaps half of all signatures are refused on chain. + * 3. **Recovery id.** Ethereum's `v` says which of two candidate public keys signed. It is + * not part of an ECDSA signature and no KMS returns it, so it is recovered by trying + * both and keeping the one that yields the known address. + * + * All of this is provider-independent, which is why it lives apart from any SDK: the + * adapter's whole job becomes "fetch bytes, sign bytes", and the fiddly part is tested + * without a cloud account. + */ + +/** secp256k1 group order. `s` above half of this is the malleable form EIP-2 forbids. */ +const SECP256K1_N = BigInt('0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'); +const HALF_N = SECP256K1_N / 2n; + +/** + * Reads a DER `SEQUENCE { INTEGER r, INTEGER s }`. + * + * Hand-parsed rather than pulled from a library because the shape is fixed and tiny, and a + * general ASN.1 decoder would be a dependency whose failure modes are broader than the one + * structure we ever see here. + */ +export function parseDerSignature(der: Uint8Array): { r: bigint; s: bigint } { + let offset = 0; + const readByte = (): number => { + if (offset >= der.length) { + throw new Error('DER signature ended early'); + } + return der[offset++]!; + }; + + if (readByte() !== 0x30) { + throw new Error('DER signature does not start with a SEQUENCE tag'); + } + // Length byte. Short form only: an ECDSA signature is far below the 128-byte threshold + // that would make this multi-byte, so a long form here means the input is not one. + const seqLength = readByte(); + if (seqLength & 0x80) { + throw new Error('DER signature uses long-form length, which an ECDSA signature never needs'); + } + if (seqLength !== der.length - offset) { + throw new Error(`DER signature length ${seqLength} does not match its ${der.length - offset} remaining bytes`); + } + + const readInteger = (label: string): bigint => { + if (readByte() !== 0x02) { + throw new Error(`DER signature ${label} is not an INTEGER`); + } + const length = readByte(); + if (length === 0 || length > 33) { + throw new Error(`DER signature ${label} has an implausible length ${length}`); + } + let value = 0n; + for (let i = 0; i < length; i++) { + value = (value << 8n) | BigInt(readByte()); + } + return value; + }; + + const r = readInteger('r'); + const s = readInteger('s'); + if (offset !== der.length) { + throw new Error('DER signature has trailing bytes'); + } + return { r, s }; +} + +/** + * Extracts the uncompressed public key point from a DER SubjectPublicKeyInfo. + * + * KMS providers publish the key as SPKI, which wraps the point in an AlgorithmIdentifier + * and a BIT STRING. The point itself is the trailing 65 bytes beginning with `0x04`, and + * that is what is taken: reading it positionally rather than fully decoding SPKI keeps this + * honest about only understanding one shape, and it is checked rather than assumed. + */ +export function extractUncompressedPublicKey(spkiDer: Uint8Array): Hex { + if (spkiDer.length < 65) { + throw new Error(`public key DER is too short to contain a point (${spkiDer.length} bytes)`); + } + const point = spkiDer.subarray(spkiDer.length - 65); + if (point[0] !== 0x04) { + throw new Error('public key DER does not end in an uncompressed secp256k1 point'); + } + return ethers.hexlify(point) as Hex; +} + +/** + * Assembles an Ethereum signature from a KMS response. + * + * `expectedAddress` is what decides `v`, and passing it is deliberate: it means this + * function cannot return a signature that recovers to somebody else. If neither candidate + * matches, that is a genuine mismatch between the key the KMS signed with and the key this + * process published, and it throws rather than returning a plausible-looking signature that + * would be rejected later with no explanation. + */ +export function toEthereumSignature( + derSignature: Uint8Array, + digest: Uint8Array, + expectedAddress: string, +): Hex { + if (digest.length !== 32) { + throw new Error(`expected a 32-byte digest, got ${digest.length}`); + } + const { r, s: rawS } = parseDerSignature(derSignature); + + // EIP-2: only the low half of the range is canonical. Flipping `s` produces an equally + // valid signature over the same digest, so this changes nothing except acceptability. + const s = rawS > HALF_N ? SECP256K1_N - rawS : rawS; + + const target = expectedAddress.toLowerCase(); + for (const v of [27, 28]) { + const signature = ethers.Signature.from({ + r: ethers.toBeHex(r, 32), + s: ethers.toBeHex(s, 32), + v, + }); + try { + if (ethers.recoverAddress(digest, signature).toLowerCase() === target) { + return signature.serialized as Hex; + } + } catch { + // A candidate that does not recover at all is simply the wrong one. + } + } + + throw new Error( + `KMS signature does not recover to the published key ${expectedAddress}; the key material and the registry disagree`, + ); +} diff --git a/backend/src/features/battle/signer/signer.kms.ts b/backend/src/features/battle/signer/signer.kms.ts index ceaecf74..811ab027 100644 --- a/backend/src/features/battle/signer/signer.kms.ts +++ b/backend/src/features/battle/signer/signer.kms.ts @@ -1,26 +1,135 @@ -import type { SignerBackend } from './signer.types'; +import type { Hex } from '@cryptopets/protocol'; +import { ethers } from 'ethers'; + +import { extractUncompressedPublicKey, toEthereumSignature } from './signer.kms.crypto'; +import type { SignerBackend, SigningKeyDescriptor } from './signer.types'; /** - * KMS-backed signer: not implemented yet, on purpose. + * KMS-backed signing (§G). + * + * §G requires the production key to live in a managed KMS or HSM, restricted to signing + * these digests and holding no asset or withdrawal authority. What that buys is specific: + * compromising an API host stops being the same event as compromising the key, because the + * host can ask for signatures but can never read the material or take it elsewhere. * - * §G requires the production key to live in a managed KMS or HSM, restricted to signing these - * digests and holding no asset or withdrawal authority. Which provider that is has not been - * decided (it is an open item in the step plan), and the wrong move here would be a stub that - * looks like a KMS and quietly holds a key in process memory: a deployment could then run on - * it believing the key was isolated. + * Everything here is provider-independent. A provider is reduced to `KmsKeyPort` — fetch a + * public key, sign a digest — so the parts that are easy to get wrong (DER decoding, EIP-2 + * normalization, recovering `v`) are written and tested once, and an adapter is small + * enough to read in one sitting. * - * So this throws with instructions instead. `createSignerBackend` refuses to start in - * production without a real backend, which means the missing piece blocks a production launch - * rather than silently degrading one. + * The public key is fetched from the KMS rather than configured. A configured address is a + * second copy of the truth, and if it drifted the signer would publish one key while + * signing with another, making every receipt it produced unverifiable against the registry. + * Asking the KMS makes that impossible by construction. + */ + +/** + * The whole surface a provider has to implement. * - * Implementing it needs, per §G: a key that can only sign, an audit log of every request with - * its digest and key version, separate keys per reward domain, and published validity periods - * with rotated-out keys retained. + * Deliberately two methods and no lifecycle. Anything wider — "sign this message", "create + * a key", "list versions" — would put decisions in the adapter that belong in the signer, + * and the signer's value is that it is narrow (see `signer.types.ts`). */ -export function createKmsSigner(provider: string): SignerBackend { +export interface KmsKeyPort { + /** Human-readable provider name, for errors and the audit log. */ + readonly provider: string; + /** SubjectPublicKeyInfo DER for the signing key. */ + getPublicKeyDer(): Promise; + /** ECDSA signature over `digest`, DER-encoded, as AWS and GCP both return. */ + signDigest(digest: Uint8Array): Promise; +} + +/** + * Builds a signer over a provider port. + * + * The public key is read once at construction: it cannot change for a given key id, a + * request per signature would add a round trip to the hot path, and reading it up front + * means a misconfigured key fails at startup rather than on the first battle to reach + * signing. + */ +export async function createKmsSignerFromPort(options: { + port: KmsKeyPort; + keyId: string; + notBefore: number; +}): Promise { + const { port, keyId, notBefore } = options; + + const publicKey = extractUncompressedPublicKey(await port.getPublicKeyDer()); + const address = ethers.computeAddress(publicKey).toLowerCase() as Hex; + + const key: SigningKeyDescriptor = { + keyId, + algorithm: 'secp256k1', + publicKey, + address, + notBefore, + notAfter: null, + status: 'active', + }; + + return { + key, + async sign(digest: Uint8Array): Promise { + if (digest.length !== 32) { + // Same guard the local backend carries: a backend that signs + // arbitrary-length input is a general-purpose oracle. + throw new Error(`expected a 32-byte digest, got ${digest.length}`); + } + const der = await port.signDigest(digest); + // Checked against the published address, so a signature from a key other than + // the one in the registry is refused here rather than discovered by a verifier. + return toEthereumSignature(der, digest, address); + }, + }; +} + +/** + * Selects a provider adapter by name. + * + * Unknown providers throw rather than falling back. A fallback here would be an in-process + * key wearing a KMS's name, and a deployment could then run believing the material was + * isolated when it was sitting in the environment — which is the single thing §G's KMS + * requirement exists to prevent. + * + * Async because a real backend has to ask the KMS for its public key before it can describe + * the key it signs with. That is what makes `configureSigner` async too, and it is the right + * trade: the alternative is configuring the address by hand, which is a second copy of the + * truth that can drift from the key actually doing the signing. + */ +export async function createKmsSigner(options: { + provider: string; + /** + * The key id receipts are stamped with, and the registry publishes. Ours, and stable. + */ + keyId: string; + /** + * The provider's own identifier — an ARN or alias for AWS. + * + * Deliberately not the same value as `keyId`. A receipt records which key signed it and + * that record is permanent, so putting an ARN there would write the account id into + * every receipt forever and break the moment the key was re-imported or moved. + */ + kmsKeyId: string; + region?: string | undefined; + notBefore: number; +}): Promise { + const { provider, keyId, kmsKeyId, region, notBefore } = options; + + if (provider === 'aws-kms') { + // Imported here rather than at module scope so the SDK is only loaded by a + // deployment that actually uses it: local development and every test run resolve + // this module without pulling in an AWS client they will never call. + const { createAwsKmsPort } = await import('./signer.kms.aws'); + return createKmsSignerFromPort({ + port: createAwsKmsPort({ keyId: kmsKeyId, region }), + keyId, + notBefore, + }); + } + 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/battle-protocol.md §G). ' + - 'Implement an adapter here rather than setting BATTLE_SIGNER_PRIVATE_KEY in production.', + `KMS signer provider "${provider}" has no adapter. Supported: aws-kms. Production signing ` + + 'must use a managed KMS/HSM key restricted to commitment and receipt digests ' + + '(docs/battle-protocol.md §G) — do not set BATTLE_SIGNER_PRIVATE_KEY in production instead.', ); } diff --git a/backend/src/features/battle/signer/signer.registry.ts b/backend/src/features/battle/signer/signer.registry.ts index 4b7e5498..1991ba5f 100644 --- a/backend/src/features/battle/signer/signer.registry.ts +++ b/backend/src/features/battle/signer/signer.registry.ts @@ -55,15 +55,19 @@ export async function persistSigningKey(key: SigningKeyDescriptor): Promise { +export async function loadSigningKeys(activeKeyIds: ReadonlySet): Promise { const rows = await prisma.battleSigningKey.findMany({ orderBy: { notBefore: 'asc' } }); return rows.map((row) => ({ keyId: row.keyId, @@ -72,6 +76,61 @@ export async function loadSigningKeys(activeKeyId: string | null): Promise): Promise<{ retired: number }> { + const stale = await prisma.battleSigningKey.findMany({ + where: { notAfter: null, keyId: { notIn: [...activeKeyIds] } }, + select: { keyId: true, notBefore: true }, + }); + + let retired = 0; + for (const key of stale) { + const last = await prisma.battleReceipt.findFirst({ + where: { signingKeyId: key.keyId }, + orderBy: { createdAt: 'desc' }, + select: { createdAt: true }, + }); + const notAfter = last?.createdAt ?? key.notBefore; + + // Guarded on `notAfter` still being null, so two processes booting at once produce + // one stamp rather than the later one overwriting the earlier. + const { count } = await prisma.battleSigningKey.updateMany({ + where: { keyId: key.keyId, notAfter: null }, + data: { notAfter }, + }); + if (count > 0) { + retired += 1; + console.warn( + `[battle-signer] key ${key.keyId} is no longer signing; published validity now ends at ${notAfter} ` + + `(${last ? 'its last receipt' : 'it never signed'})`, + ); + } + } + return { retired }; +} diff --git a/backend/src/features/battle/signer/signer.service.ts b/backend/src/features/battle/signer/signer.service.ts index ae181d49..938eab1c 100644 --- a/backend/src/features/battle/signer/signer.service.ts +++ b/backend/src/features/battle/signer/signer.service.ts @@ -1,6 +1,8 @@ import { assertBattleCommitment, assertBattleReceipt, + type ChainId, + chainFamily, hashBattleCommitment, hashBattleReceipt, type Hex, @@ -11,7 +13,7 @@ import { env } from '@config/env'; import { createKmsSigner } from './signer.kms'; import { createLocalSigner } from './signer.local'; -import { loadSigningKeys, persistSigningKey } from './signer.registry'; +import { loadSigningKeys, persistSigningKey, retireInactiveKeys } from './signer.registry'; import { type EngineAttestation, type SignerAuditEntry, @@ -31,7 +33,17 @@ import { * rest of the system can then check. It cannot obtain a signature over anything else at all. */ -let backend: SignerBackend | null = null; +/** + * One backend per reward domain (§G: "separate keys for EVM and Solana reward domains"). + * + * Keyed by chain family rather than by chain id: the domain §G means is the settlement + * environment, so every EVM chain a deployment serves shares one key and Solana has its + * own. Threat T4 is what this bounds — a stolen key that signs both families compromises + * both, and the receipts of one are no evidence about the other. + */ +type SignerDomain = 'evm' | 'solana'; + +const backends = new Map(); let backendError: string | null = null; const rotatedKeys: SigningKeyDescriptor[] = []; const auditLog: SignerAuditEntry[] = []; @@ -45,33 +57,105 @@ const MAX_AUDIT_ENTRIES = 1000; * failure rather than a warning is the difference between a blocked deploy and a quiet * downgrade nobody notices until the incident. */ -export function configureSigner(nowSeconds: number): void { - backend = null; +export async function configureSigner(nowSeconds: number): Promise { + backends.clear(); backendError = null; - const { keyId, privateKey, kmsProvider } = env.battleSigner; + const domains = servedDomains(); + if (domains.length === 0) { + backendError = 'no chains configured, so there is no domain to sign for'; + return; + } - if (kmsProvider) { + // Only one domain to sign for means there is nothing to keep separate, so the shared + // configuration is used as-is. With two, each must be named explicitly — see + // `keyConfigFor`, which refuses rather than letting them collapse onto one key. + const shared = domains.length === 1; + + for (const domain of domains) { try { - backend = createKmsSigner(kmsProvider); + backends.set(domain, await createDomainSigner(domain, shared, nowSeconds)); } catch (error) { - backendError = (error as Error).message; + // One domain failing leaves the others unconfigured too: a partially-signing + // deployment would accept battles on one chain and silently stall them on the + // other, which is harder to notice than not starting. + backends.clear(); + backendError = `${domain}: ${(error as Error).message}`; + return; } - return; } +} - if (!privateKey) { - backendError = 'no signing backend configured (set BATTLE_SIGNER_KMS_PROVIDER, or a dev key locally)'; - return; +/** The chain families this deployment serves, deduplicated. */ +function servedDomains(): SignerDomain[] { + const families = new Set(); + for (const chainId of env.battle.chainIds) { + families.add(chainFamily(chainId as ChainId)); } + return [...families]; +} - if (env.isProduction) { - backendError = - 'refusing to use BATTLE_SIGNER_PRIVATE_KEY in production; the signing key must live in a KMS (§G)'; - return; +/** + * Resolves one domain's key configuration. + * + * Refuses to fall back to the shared values when more than one domain is served, which is + * the whole point of §G's separation: a fallback there would silently hand both chains the + * same key, and the deployment would look correctly configured while having exactly the + * blast radius T4 describes. + */ +function keyConfigFor( + domain: SignerDomain, + shared: boolean, +): { keyId: string; privateKey: string | undefined; kmsKeyId: string | undefined } { + const specific = env.battleSigner.domains[domain]; + if (shared) { + return { + keyId: specific.keyId ?? env.battleSigner.keyId, + privateKey: specific.privateKey ?? env.battleSigner.privateKey, + kmsKeyId: specific.kmsKeyId ?? env.battleSigner.kmsKeyId, + }; } - backend = createLocalSigner({ keyId, privateKey, notBefore: nowSeconds }); + if (!specific.keyId) { + throw new Error( + `this deployment serves more than one chain family, so ${domain} needs its own signing key ` + + `(set BATTLE_SIGNER_${domain.toUpperCase()}_KEY_ID). §G requires separate keys per reward ` + + 'domain: one key across both means a compromise of either is a compromise of both.', + ); + } + return { keyId: specific.keyId, privateKey: specific.privateKey, kmsKeyId: specific.kmsKeyId }; +} + +async function createDomainSigner( + domain: SignerDomain, + shared: boolean, + nowSeconds: number, +): Promise { + const { kmsProvider, kmsRegion } = env.battleSigner; + const { keyId, privateKey, kmsKeyId } = keyConfigFor(domain, shared); + + if (kmsProvider) { + return createKmsSigner({ + provider: kmsProvider, + keyId, + // The KMS's own identifier, kept separate from the `keyId` receipts carry: that + // one is ours and stays stable across a re-import or a move between accounts, + // while this is an ARN that does not. + kmsKeyId: kmsKeyId ?? keyId, + region: kmsRegion, + notBefore: nowSeconds, + }); + } + + if (!privateKey) { + throw new Error('no signing backend configured (set BATTLE_SIGNER_KMS_PROVIDER, or a dev key locally)'); + } + if (env.isProduction) { + throw new Error( + 'refusing to use BATTLE_SIGNER_PRIVATE_KEY in production; the signing key must live in a KMS (§G)', + ); + } + return createLocalSigner({ keyId, privateKey, notBefore: nowSeconds }); } /** @@ -106,17 +190,25 @@ export function registerRotatedKey(key: SigningKeyDescriptor): void { * receipts unverifiable rather than invalid, which is the failure §H exists to prevent. */ export async function loadPersistedSigningKeys(): Promise { - const active = activeSigningKey(); - if (active) { - // Recorded on every boot, so the key currently signing is in the registry even if it - // is never explicitly rotated out later. - await persistSigningKey(active); + const active = [...backends.values()].map((entry) => entry.key); + for (const key of active) { + // Recorded on every boot, so every key currently signing is in the registry even if + // it is never explicitly rotated out later. + await persistSigningKey(key); } - const stored = await loadSigningKeys(active?.keyId ?? null); + const activeIds = new Set(active.map((key) => key.keyId)); + // Before reading them back, close the window on anything that has stopped signing (§G). + // A rotation is only observable here — the process that stops using a key is the one + // that never mentions it again — so a boot with a new key configured is exactly when + // the old one's validity should end. Left to itself it would stay published as "valid + // indefinitely" and keep vouching for receipts dated long after it was retired. + await retireInactiveKeys(activeIds); + + const stored = await loadSigningKeys(activeIds); rotatedKeys.length = 0; for (const key of stored) { - if (key.keyId !== active?.keyId) { + if (!activeIds.has(key.keyId)) { rotatedKeys.push(key); continue; } @@ -128,15 +220,23 @@ export async function loadPersistedSigningKeys(): Promise { // forward on each boot, and every receipt signed before that restart would fail the // operator-signature check for anyone verifying against the published list: not // invalid, unverifiable, which is exactly what §H exists to prevent. - if (backend && key.notBefore < active.notBefore) { - backend = { ...backend, key: { ...backend.key, notBefore: key.notBefore } }; + for (const [domain, entry] of backends) { + if (entry.key.keyId === key.keyId && key.notBefore < entry.key.notBefore) { + backends.set(domain, { ...entry, key: { ...entry.key, notBefore: key.notBefore } }); + } } } } -/** The key currently signing, or null when the signer is unconfigured. */ -export function activeSigningKey(): SigningKeyDescriptor | null { - return backend?.key ?? null; +/** + * The key currently signing for one domain, or null when that domain is unconfigured. + * + * Takes a chain id rather than defaulting to "the" key, because there is no longer one: + * asking without saying which domain would have to guess, and a receipt signed under the + * wrong domain's key is one no verifier can attribute correctly. + */ +export function activeSigningKey(chainId: string): SigningKeyDescriptor | null { + return backends.get(chainFamily(chainId as ChainId))?.key ?? null; } /** @@ -147,8 +247,21 @@ export function activeSigningKey(): SigningKeyDescriptor | null { * different and worse thing (§H item 4). */ export function listSigningKeys(): SigningKeyDescriptor[] { - const active = activeSigningKey(); - return active ? [active, ...rotatedKeys] : [...rotatedKeys]; + // Every domain's active key, then everything retired. A verifier is handed the whole + // set and matches on `signingKeyId`, so it never needs to know how they are partitioned. + return [...[...backends.values()].map((entry) => entry.key), ...rotatedKeys]; +} + +/** + * Why this deployment cannot sign, or null when it can. + * + * `configureSigner` records its failure and returns rather than throwing, so the process + * still boots and keeps serving reads. The cost is that the reason lived only in here: a + * caller that finds no active key could say "no active signing key" and nothing about why, + * which is a misconfiguration reported as a mystery. + */ +export function signerBackendError(): string | null { + return backendError; } /** The signer's own audit trail, newest last. Reconciled against the KMS log during an incident. */ @@ -158,7 +271,7 @@ export function signerAuditLog(): SignerAuditEntry[] { /** Clears state. Tests only. */ export function resetSigner(): void { - backend = null; + backends.clear(); backendError = null; rotatedKeys.length = 0; auditLog.length = 0; @@ -173,22 +286,40 @@ export function resetSigner(): void { * so a mismatch cannot be signed past by mistake. */ export async function sign(request: SignRequest, nowSeconds: number): Promise { - if (!backend) { - return refuse('signer-not-configured', backendError ?? 'signer is not configured', nowSeconds); - } - let digest: Hex; + // Validated before the domain is read, so a malformed object is refused as malformed + // rather than as an unknown domain — the first is the true description, and it is the + // one an operator can act on. + let chainId: string; try { - digest = - request.kind === 'commitment' - ? hashBattleCommitment(assertBattleCommitment(request.commitment)) - : hashBattleReceipt(assertBattleReceipt(request.receipt)); + if (request.kind === 'commitment') { + const commitment = assertBattleCommitment(request.commitment); + digest = hashBattleCommitment(commitment); + chainId = commitment.domain.chainId; + } else { + const receipt = assertBattleReceipt(request.receipt); + digest = hashBattleReceipt(receipt); + chainId = receipt.domain.chainId; + } } catch (error) { // An object that does not validate never reaches the key. The signer is the last place // that can still refuse a malformed receipt, and after it there is only history. return refuse('invalid-payload', (error as Error).message, nowSeconds); } + // The key is chosen by the object's *own* domain, never by a caller argument. A caller + // that could name the key would be able to sign an EVM receipt with the Solana key, + // which is precisely the domain separation §G asks for, undone from the inside. + const domain = chainFamily(chainId as ChainId); + const backend = backends.get(domain); + if (!backend) { + return refuse( + 'signer-not-configured', + backendError ?? `no signing key configured for the ${domain} domain`, + nowSeconds, + ); + } + if (request.kind === 'receipt') { const problem = checkAttestations(request.attestations, digest, nowSeconds); if (problem) { diff --git a/backend/src/features/battle/worker/compute.worker.ts b/backend/src/features/battle/worker/compute.worker.ts index d65cbe06..6c97f5c4 100644 --- a/backend/src/features/battle/worker/compute.worker.ts +++ b/backend/src/features/battle/worker/compute.worker.ts @@ -1,5 +1,4 @@ import { - type BattleSnapshot, computeProgression, bonusFromEquipment, hashCombatLog, @@ -11,7 +10,13 @@ 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, + decodeStoredSnapshot, + OUTBOX_TOPICS, +} from '@features/battle/ledger'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; /** @@ -49,9 +54,8 @@ export async function processComputeMessage(message: ClaimedMessage, nowSeconds: } const ruleset = loadRulesetBundle(JSON.stringify(rulesetRow.bundle), battle.rulesetHash as Hex); - const snapshot = battle.snapshot as unknown as BattleSnapshot; - const attacker = deserializePet(snapshot.attacker); - const defender = deserializePet(snapshot.defender); + const snapshot = decodeStoredSnapshot(battle.snapshot); + const { attacker, defender } = snapshot; // Equipment totals come from the frozen snapshot, not from the catalog: the fight has // to use the modifiers that were written down at acceptance, so unequipping since then @@ -71,11 +75,7 @@ export async function processComputeMessage(message: ClaimedMessage, nowSeconds: bonusFromEquipment(defender.equipment), ); - const progression = computeProgression( - { ...snapshot, attacker, defender }, - outcome.result.firstWins, - { maxLevel: ruleset.maxLevel }, - ); + const progression = computeProgression(snapshot, outcome.result.firstWins, { maxLevel: ruleset.maxLevel }); const combatLogHash = hashCombatLog(outcome); const patch: Prisma.BattleLedgerUncheckedUpdateInput = { @@ -98,52 +98,6 @@ export async function processComputeMessage(message: ClaimedMessage, nowSeconds: await completeOutbox(message.id, new Date(nowSeconds * 1000)); } -/** As stored: JSON, so the item type arrives as a decimal string. */ -export type SnapshotEquipment = { - slot: number; - itemType: string | bigint; - hp: number; - atk: number; - def: number; - int: number; - mdef: number; -}[]; - -/** The snapshot is stored as JSON, where bigint fields round-trip as decimal strings. */ -function deserializePet(pet: { - petId: string | bigint; - owner: string; - dna: string | bigint; - rarity: number; - level: number; - skill: number; - xp: number; - lastOpponentId: string | bigint; - streak: number; - readyAt: number; - sourceVersion: string | bigint; - equipment?: SnapshotEquipment; -}) { - return { - petId: BigInt(pet.petId), - owner: pet.owner, - dna: BigInt(pet.dna), - rarity: pet.rarity, - level: pet.level, - skill: pet.skill, - xp: pet.xp, - lastOpponentId: BigInt(pet.lastOpponentId), - streak: pet.streak, - readyAt: pet.readyAt, - sourceVersion: BigInt(pet.sourceVersion), - // Widened back to bigint: JSON storage round-trips the item type as a decimal - // string, and the protocol's validator wants the number it was written as. - ...(pet.equipment && { - equipment: pet.equipment.map((entry) => ({ ...entry, itemType: BigInt(entry.itemType) })), - }), - }; -} - function serializeBigints(value: T): Prisma.InputJsonValue { return JSON.parse(JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? v.toString() : v))); } diff --git a/backend/src/features/battle/worker/runner.ts b/backend/src/features/battle/worker/runner.ts index c0609031..fdd377ef 100644 --- a/backend/src/features/battle/worker/runner.ts +++ b/backend/src/features/battle/worker/runner.ts @@ -3,6 +3,7 @@ import { abandonBattle, type ClaimedMessage, claimOutbox, + expireOrphanedAccepts, failOutbox, OUTBOX_TOPICS, } from '@features/battle/ledger'; @@ -37,6 +38,16 @@ export async function runBattleWorkerOnce(workerId: string, now: Date = new Date const messages = await claimOutbox(topics, workerId, env.battle.workerBatchSize, now); const nowSeconds = Math.floor(now.getTime() / 1000); + // Frees pets held by a battle that never left `accepted`. Nothing else can: the + // dead-letter path declines because `accepted` cannot forfeit, so without this the lock + // is permanent and the pet simply stops being able to battle, with no state anywhere + // saying why. Failures here must not stop the dispatch below, which is the loop's job. + try { + await expireOrphanedAccepts(nowSeconds); + } catch (error) { + console.error('[battle-worker] could not expire orphaned accepts:', error); + } + for (const message of messages) { const handler = HANDLERS[message.topic]; if (!handler) { diff --git a/backend/src/features/battle/worker/sign.worker.ts b/backend/src/features/battle/worker/sign.worker.ts index ea0adf9c..465e773c 100644 --- a/backend/src/features/battle/worker/sign.worker.ts +++ b/backend/src/features/battle/worker/sign.worker.ts @@ -11,8 +11,20 @@ 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, + decodeStoredSnapshot, + OUTBOX_TOPICS, +} from '@features/battle/ledger'; +import { + activeSigningKey, + type EngineAttestation, + sign, + signerBackendError, + SignerRefusedError, +} from '@features/battle/signer'; import { recordBattleDrops } from '@features/inventory'; import { recordBattleFromReceipt } from '@repositories/history.repository'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; @@ -57,21 +69,12 @@ export async function processSignMessage(message: ClaimedMessage, nowSeconds: nu throw new Error(`battle ${battle.battleId} is verified but is missing a field sign needs`); } - // Stored as JSON, where bigint fields (petId, dna, lastOpponentId, sourceVersion) - // round-trip as decimal strings — the protocol types require real bigints, so - // this must be deserialized before anything here hashes or validates it. - const storedSnapshot = battle.snapshot as unknown as { - domain: BattleSnapshot['domain']; - attacker: StoredPet; - defender: StoredPet; - takenAt: number; - }; - const snapshot: BattleSnapshot = { - domain: storedSnapshot.domain, - attacker: deserializePet(storedSnapshot.attacker), - defender: deserializePet(storedSnapshot.defender), - takenAt: storedSnapshot.takenAt, - }; + // Decoded through the shared codec, which is what carries `schemaVersion` and the + // equipment list back out of storage. Rebuilding the snapshot field by field here is + // what previously dropped both: the receipt then encoded at layout version 1, its + // snapshot hash stopped matching the one acceptance committed, and the seed check + // inside `assertBattleReceipt` refused the receipt for every battle, geared or not. + const snapshot: BattleSnapshot = decodeStoredSnapshot(battle.snapshot); // Same deserialization need: PetProgression.petId/lastOpponentId are bigint in // the protocol type but decimal strings in storage. const storedProgression = battle.progression as unknown as { @@ -84,9 +87,20 @@ export async function processSignMessage(message: ClaimedMessage, nowSeconds: nu }; for (let attempt = 0; attempt < MAX_RECEIPT_CHAIN_RETRIES; attempt++) { - const key = activeSigningKey(); + // Keyed by this battle's own chain, since §G gives each reward domain its own key. + const key = activeSigningKey(battle.chainId); if (!key) { - await failSigning(battle.battleId, battle.roomId, 'no active signing key'); + // `signerBackendError` holds why configuration was refused. Without it this said + // only that there was no key, which is the symptom — and the reason (a missing + // env var, a KMS that would not answer, a per-domain key id this deployment now + // needs) was sitting in memory unreported. It ends up in `failureReason`, so it + // survives to whoever reads the row afterwards. + const why = signerBackendError(); + await failSigning( + battle.battleId, + battle.roomId, + `no active signing key for ${battle.chainId}${why ? `: ${why}` : ''}`, + ); await completeOutbox(message.id, new Date(nowSeconds * 1000)); return; } @@ -358,37 +372,6 @@ function serializeBigints(value: T): Prisma.InputJsonValue { return JSON.parse(JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? v.toString() : v))); } -interface StoredPet { - petId: string | bigint; - owner: string; - dna: string | bigint; - rarity: number; - level: number; - skill: number; - xp: number; - lastOpponentId: string | bigint; - streak: number; - readyAt: number; - sourceVersion: string | bigint; -} - -/** Reverses `serializeBigints` for one pet's snapshot fields. */ -function deserializePet(pet: StoredPet): BattleSnapshot['attacker'] { - return { - petId: BigInt(pet.petId), - owner: pet.owner, - dna: BigInt(pet.dna), - rarity: pet.rarity, - level: pet.level, - skill: pet.skill, - xp: pet.xp, - lastOpponentId: BigInt(pet.lastOpponentId), - streak: pet.streak, - readyAt: pet.readyAt, - sourceVersion: BigInt(pet.sourceVersion), - }; -} - interface StoredProgression { petId: string | bigint; won: boolean; diff --git a/backend/src/features/battle/worker/verify.worker.ts b/backend/src/features/battle/worker/verify.worker.ts index e733126a..329ce517 100644 --- a/backend/src/features/battle/worker/verify.worker.ts +++ b/backend/src/features/battle/worker/verify.worker.ts @@ -1,10 +1,10 @@ import { - type BattleSnapshot, bonusFromEquipment, type Hex, hashCombatLog, loadRulesetBundle, type PetProgression, + type PetSnapshot, type ProgressionDelta, type SimOutcome, } from '@cryptopets/protocol'; @@ -12,10 +12,15 @@ 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, + decodeStoredSnapshot, + OUTBOX_TOPICS, +} from '@features/battle/ledger'; import { callVerifyBattle, type VerifyBattleWire, type VerifyPetProgressionWire } from '@grpc-client/verifyBattle'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; -import { type SnapshotEquipment } from './compute.worker'; /** * Handles `verify` messages: `computed` -> `verified` (§F). @@ -57,9 +62,7 @@ export async function processVerifyMessage(message: ClaimedMessage, nowSeconds: } const ruleset = loadRulesetBundle(JSON.stringify(rulesetRow.bundle), battle.rulesetHash as Hex); - const snapshot = battle.snapshot as unknown as BattleSnapshot; - const attacker = snapshot.attacker as unknown as Record; - const defender = snapshot.defender as unknown as Record; + const { attacker, defender } = decodeStoredSnapshot(battle.snapshot); const outcome = await callVerifyBattle({ attacker: toWirePet(attacker), @@ -109,21 +112,21 @@ export async function processVerifyMessage(message: ClaimedMessage, nowSeconds: await completeOutbox(message.id, new Date(nowSeconds * 1000)); } -function toWirePet(pet: Record) { +function toWirePet(pet: PetSnapshot) { // The resolved equipment total, so the independent recomputation runs on the same // inputs the canonical engine used (roadmap §4). Sending the frozen modifiers rather // than item ids is what lets the verifier hold no item catalog at all: what §F checks // is that the fight follows from the numbers the receipt publishes. - const bonus = bonusFromEquipment(pet.equipment as SnapshotEquipment | undefined); + const bonus = bonusFromEquipment(pet.equipment); return { petId: String(pet.petId), dna: String(pet.dna), - rarity: Number(pet.rarity), - level: Number(pet.level), - skill: Number(pet.skill), - xp: Number(pet.xp), + rarity: pet.rarity, + level: pet.level, + skill: pet.skill, + xp: pet.xp, lastOpponentId: String(pet.lastOpponentId), - streak: Number(pet.streak), + streak: pet.streak, bonusHp: bonus.hp, bonusAtk: bonus.atk, bonusDef: bonus.def, diff --git a/backend/src/features/inventory/catalog.ts b/backend/src/features/inventory/catalog.ts index 363f6bba..05b3f0dc 100644 --- a/backend/src/features/inventory/catalog.ts +++ b/backend/src/features/inventory/catalog.ts @@ -55,6 +55,19 @@ export interface ItemDefinitionSeed { * * A pet's extracted attributes land in the low hundreds, so a bonus in the thousands is * a typo rather than a tuning choice. Anything inside these is the designer's call. + * + * `MAX_STAT_BONUS` carries one consequence worth knowing before raising it. A defender's + * `DefenseAuthorization` is bound to `rulesetHash`, which covers the item catalog, so + * consenting to a ruleset is consenting to the strongest loadout that catalog can express. + * Nothing else bounds an attacker's gear: the authorization's level band does not. Raising + * this therefore widens what every *future* consent implies, silently, in a constant no + * defender ever sees. Existing authorizations are safe, since editing the catalog moves + * `rulesetHash` and invalidates them, which is the intended cost of a rules change. + * + * For scale: 500 a stat across three slots is 1500, against base attributes in the low + * hundreds. The shipped catalog's largest single bonus is 45. The gap between those two + * numbers is headroom nobody has argued for, so treat a change here as a balance decision + * rather than a limit being nudged. */ const MAX_STAT_BONUS = 500; const MAX_XP_GRANT = 100_000; diff --git a/backend/src/features/inventory/drops.ts b/backend/src/features/inventory/drops.ts index 3744361e..8fc2de89 100644 --- a/backend/src/features/inventory/drops.ts +++ b/backend/src/features/inventory/drops.ts @@ -8,15 +8,36 @@ import type { ItemDefinitionSeed } from './catalog'; * Battle-reward drops (roadmap §4). * * Seeded from the battle's own drand seed rather than from a new randomness source. That - * seed is committed to a future drand round before the fight resolves, so nobody — - * including this server — can grind a drop by re-rolling: changing the outcome would mean - * changing a value that was published in advance. It also means a third party holding the - * receipt can recompute exactly what should have dropped. + * seed is committed to a future drand round before the fight resolves, so nobody including + * this server can grind a drop by re-rolling: changing the outcome would mean changing a + * value that was published in advance. That property is real and it is the reason this + * derives from the seed at all. * - * Be precise about how far that goes. The drop is **not** part of the signed receipt in - * v1, so an outsider can recompute what we owed and notice if we paid something else, but - * cannot prove it from the receipt alone. Putting drops inside the signed payload means a - * receipt schema version and a place in the ruleset hash, which is §4 phase 4 work. + * Be precise about how far it goes, because it is easy to overstate and this comment used + * to. A third party holding the receipt **cannot** recompute what should have dropped. + * Two of the three inputs are unpublished: `DropRates` and `DROP_POOL` are constants in + * this file and in `catalog.data.ts`, and neither reaches the ruleset, so neither is + * covered by `rulesetHash` or by anything the receipt names. Only the seed and the battle + * id are signed. Someone reading this source can reproduce a drop; someone holding only a + * receipt and the published bundle cannot. + * + * Nor is the payout pinned by the receipt: `rates` is an argument, so the same seed and + * battle id yield different answers under different odds, and no row records which applied. + * + * Be equally precise about that, because it is easy to overstate in turn. The only + * production caller (`sign.worker`) passes no rates at all, so the odds in force are + * `DEFAULT_DROP_RATES` below, a constant that changes only by code change and deploy. Git + * history and the deployment record are a real audit trail, just not one a receipt holder + * can check. The parameter is a test seam today; it becomes the gap this paragraph + * describes only if something ever starts passing per-battle rates, which is worth a second + * look if anyone proposes it. + * + * Closing that means publishing the rates and the drop pool, which puts non-equipment + * items into the ruleset. §4 deliberately keeps them out: a `rulesetHash` that moved every + * time a collectible was added would re-prompt every defender for consent and train + * players to click through the one prompt that matters. So this is a standing design + * tension, not a missing field, and it is tracked as D2 in + * `docs/plan-battle-inventory-hardening.md` rather than quietly fixed here. * * The pool is read from the shipped catalog constant rather than from `item_definition`, * deliberately. A replay has to reproduce what a battle dropped, and a table that content @@ -153,9 +174,18 @@ function readUint32(bytes: Uint8Array, offset: number): number { * * Idempotent under the retry that transaction can take. The entitlement's unique key is * (sourceRef, owner, itemType), and sourceRef is the battle id, so a replay of the same - * battle collides with its own earlier row instead of paying twice. Two drops of the same - * item to the same wallet from one battle would collide too, which is why each side rolls - * at most one item. + * battle collides with its own earlier row instead of paying twice. + * + * That same key is why the two sides are merged before writing rather than inserted as + * they come. Nothing stops a player fighting two pets they both own, and then the winner + * and the loser are one wallet; when both rolls land on the same item the two drops share + * a key, and `skipDuplicates` silently keeps one. Measured on the shipped pool that is + * about one in six of the self-battles that pay twice, each one quietly costing the player + * an item they earned. Merging turns that into a single row of quantity 2, which is what + * was owed. + * + * Returns what was written, not what was rolled, so a caller sees the same thing the table + * does. */ export async function recordBattleDrops( tx: Prisma.TransactionClient, @@ -168,15 +198,16 @@ export async function recordBattleDrops( rates?: DropRates; }, ): Promise { - const drops = rollDrops(args.seed, args.battleId, args.winnerOwner, args.loserOwner, args.rates); - if (drops.length === 0) { - return drops; + const rolled = rollDrops(args.seed, args.battleId, args.winnerOwner, args.loserOwner, args.rates); + if (rolled.length === 0) { + return rolled; } + const drops = mergeDrops(rolled); await tx.itemEntitlement.createMany({ data: drops.map((drop) => ({ chain: args.chain, - owner: normalizeAccount(drop.owner), + owner: drop.owner, itemType: drop.itemType, quantity: drop.quantity, source: 'battle_drop', @@ -187,3 +218,25 @@ export async function recordBattleDrops( return drops; } + +/** + * Totals drops that would share an entitlement key, normalizing the owner first. + * + * The normalize has to happen here rather than at the insert, because it is part of the + * key: two spellings of one address are one wallet to the unique index and would be two + * groups to anything grouping on the raw value. + */ +function mergeDrops(drops: readonly Drop[]): Drop[] { + const byKey = new Map(); + for (const drop of drops) { + const owner = normalizeAccount(drop.owner); + const key = `${owner}:${drop.itemType}`; + const existing = byKey.get(key); + if (existing) { + existing.quantity += drop.quantity; + } else { + byKey.set(key, { owner, itemType: drop.itemType, quantity: drop.quantity }); + } + } + return [...byKey.values()]; +} diff --git a/backend/src/features/inventory/index.ts b/backend/src/features/inventory/index.ts index e5122aba..e501de80 100644 --- a/backend/src/features/inventory/index.ts +++ b/backend/src/features/inventory/index.ts @@ -4,11 +4,16 @@ */ export { getCatalog, + getCombatCatalog, getInventory, getPendingItems, getPetEquipment, + getPetEquipmentForCombat, getPetEquipmentForPets, + ItemCatalogError, + itemCatalogGeneration, resetItemCatalog, + type CombatEquippedItem, type EquippedItem, type InventoryEntry, type PendingItem, @@ -38,7 +43,7 @@ export { type UseItemResult, type WriteFailure, } from './inventory.write'; -export { getItemCoreClient, type ItemCoreClient } from './inventory.chain'; +export { getItemCoreClient, type ItemCoreClient, UnconfirmedTxError } from './inventory.chain'; export { DEFAULT_DROP_RATES, recordBattleDrops, diff --git a/backend/src/features/inventory/inventory.chain.ts b/backend/src/features/inventory/inventory.chain.ts index 7e0fc32a..078254f2 100644 --- a/backend/src/features/inventory/inventory.chain.ts +++ b/backend/src/features/inventory/inventory.chain.ts @@ -105,6 +105,29 @@ function buildClient(): ItemCoreClient | null { }; } +/** + * A transaction that was broadcast but whose outcome is unknown. + * + * Distinct from every other failure here, and the distinction is what stops a double mint. + * A caller undoing its own bookkeeping after a failed write is only safe when the write + * definitely did not happen. "Simulate reverted" and "the receipt says reverted" both mean + * that. "The RPC stopped answering while waiting for the receipt" does not: the transaction + * may well be mined, and treating it as a failure is how a claim gets released and paid a + * second time. + */ +export class UnconfirmedTxError extends Error { + constructor( + readonly hash: `0x${string}`, + message: string, + // `override` because Error already declares `cause`. Narrowed to a parameter + // property so a caller can read it without the optional-chaining dance. + override readonly cause?: unknown, + ) { + super(message); + this.name = 'UnconfirmedTxError'; + } +} + /** * Simulates, sends, and waits for the receipt. * @@ -113,6 +136,11 @@ function buildClient(): ItemCoreClient | null { * both callers change state that depends on the transaction having landed: a burn that is * still pending is an item the player could spend again. * + * Failures are sorted into two kinds, because the callers have to treat them differently. + * Anything before the broadcast, and an on-chain revert, mean nothing moved. Anything after + * the broadcast that leaves the outcome unknown raises `UnconfirmedTxError` carrying the + * hash, so a caller can record it and refuse to undo state that may already be real. + * * One at a time, like the settle keeper's submitter. Item writes are rare relative to * block times, so a single in-flight transaction avoids nonce management entirely. */ @@ -134,8 +162,21 @@ async function send( args, }); const hash = await walletClient.writeContract(request as Parameters[0]); - const receipt = await publicClient.waitForTransactionReceipt({ hash }); + + let receipt: Awaited>; + try { + receipt = await publicClient.waitForTransactionReceipt({ hash }); + } catch (error) { + // Broadcast, outcome unknown. A timeout here is the ordinary case: the RPC went + // away, or the transaction is simply slow, and the chain will very likely mine it. + throw new UnconfirmedTxError( + hash, + `ItemCore.${functionName} was broadcast as ${hash} but its receipt could not be read; treat it as possibly mined`, + error, + ); + } if (receipt.status !== 'success') { + // A confirmed revert, which is a definite no. Safe for a caller to undo. throw new Error(`ItemCore.${functionName} reverted on chain (${hash})`); } return hash; diff --git a/backend/src/features/inventory/inventory.service.ts b/backend/src/features/inventory/inventory.service.ts index 6334d892..a0eb34fb 100644 --- a/backend/src/features/inventory/inventory.service.ts +++ b/backend/src/features/inventory/inventory.service.ts @@ -9,7 +9,7 @@ import { type ItemDefinitionRow, } from '@repositories/inventory.repository'; -import { asItemEffect, type ItemEffect } from './catalog'; +import { asItemEffect, type ItemEffect, type StatBonus } from './catalog'; /** * Inventory reads (roadmap §4). @@ -97,8 +97,14 @@ export async function getPendingItems(chain: string, owner: string): Promise { - return [...(await catalogByType()).values()]; + return [...(await loadCatalog()).byType.values()]; } /** @@ -189,6 +195,36 @@ export async function getPetEquipmentForPets( return [...byPet].map(([petId, equipped]) => ({ petId, equipped })); } +/** + * What one equipped item contributes to a fight, already narrowed to the modifier. + * + * Distinct from `EquippedItem` because a combat caller has no use for a name or a + * description and every use for a bonus it does not have to re-narrow. The narrowing is + * the point: reaching this type at all means the item is catalogued equipment with a + * readable modifier, so `snapshot.builder` has nothing left to check. + */ +export interface CombatEquippedItem { + slot: number; + itemType: string; + key: string; + bonus: StatBonus; +} + +/** + * Raised when the catalog cannot answer a question combat needs answered. + * + * Its own type so acceptance can turn it into a named rejection rather than a 500. Every + * case it covers is an operational fault: the seeder is behind the contract, or a row was + * edited into a shape the reader does not recognise. Both mean this deployment cannot + * state the rules it is about to fight under. + */ +export class ItemCatalogError extends Error { + constructor(detail: string) { + super(detail); + this.name = 'ItemCatalogError'; + } +} + /** * The catalog, read once per process. * @@ -201,46 +237,142 @@ export async function getPetEquipmentForPets( * `servedRuleset()` already caches catalog-derived data and documents that a catalog edit * needs a restart. With one half frozen and the other live, a mid-process seeder run produced * a ruleset that did not price an item the bag was already showing. + * + * `unreadable` is kept beside the views because `ItemView.effect` is null for two very + * different rows: a collectible that legitimately does nothing, and an equipment row whose + * modifier would not parse. A display path may treat those alike; a combat path must not, + * and the null alone cannot tell them apart. */ -let cached: Map | null = null; +interface CachedCatalog { + byType: Map; + /** Types whose stored `effect` column was present but unreadable. */ + unreadable: Set; +} -async function catalogByType(): Promise> { +let cached: CachedCatalog | null = null; + +async function loadCatalog(): Promise { if (!cached) { - cached = new Map((await findAllDefinitions()).map((row) => [row.itemType, toItemView(row)])); + const byType = new Map(); + const unreadable = new Set(); + for (const row of await findAllDefinitions()) { + const view = toItemView(row); + byType.set(row.itemType, view); + if (row.effect !== null && view.effect === null) { + unreadable.add(row.itemType); + // Loud because the only writer is the seeder, so this means the stored + // shape and the code that reads it have diverged. + console.warn(`[inventory] item ${row.itemType} (${row.key}) has an unreadable effect payload`); + } + } + cached = { byType, unreadable }; } return cached; } -/** Drops the cache, for the seeder and for tests. Mirrors `resetServedRuleset`. */ +/** + * How many times the catalog has been dropped. + * + * Read by anything holding its own cache of catalog-derived data, so dropping the catalog + * invalidates that too. `servedRuleset` is the one such holder, and it cannot simply be + * called from `resetItemCatalog`: `ruleset.builder` imports this module, so the call would + * close a cycle. A number it can compare against costs nothing and points the dependency + * the way it already runs. + */ +let generation = 0; + +export function itemCatalogGeneration(): number { + return generation; +} + +/** Drops the cache, for the seeder and for tests. Also invalidates anything derived from it. */ export function resetItemCatalog(): void { cached = null; + generation += 1; +} + +/** + * The catalog as the ruleset must read it. + * + * Strict where `getCatalog` is lenient, and the split is the rule `catalog.ts` states for + * itself: an unreadable effect costs an item its label on a read path, but once effects + * feed combat, dropping one silently changes a fight rather than a tooltip. An equipment + * row whose modifier will not parse simply vanishes from `itemCatalog`, which moves + * `rulesetHash` and invalidates every outstanding defence authorization, from one bad + * column and a console warning. + */ +export async function getCombatCatalog(): Promise { + const catalog = await loadCatalog(); + for (const view of catalog.byType.values()) { + if (view.category !== 'equipment') { + continue; + } + if (catalog.unreadable.has(view.itemType) || view.effect?.kind !== 'stat_bonus') { + throw new ItemCatalogError( + `item ${view.itemType} (${view.key}) is equipment with no readable stat_bonus; this deployment cannot state its own ruleset`, + ); + } + } + return [...catalog.byType.values()]; +} + +/** + * What a pet has equipped, resolved for combat. + * + * Refuses the two states `getPetEquipment` hides. An item with no catalog row is the + * seeder running behind the contract; an item whose modifier will not parse is a corrupt + * row. Either way the pet is wearing something on chain that this process cannot price, + * and the lenient read would have it fight as though the slot were empty. + * + * That is worse than it sounds, because it is not merely a weaker pet. The receipt would + * publish an ungeared snapshot while `ItemCore.equipmentOf(petId)` at the recorded + * `sourceVersion` says otherwise, and that discrepancy is indistinguishable from the + * operator having quietly removed the gear. §4 put `itemType` in the snapshot precisely so + * an outsider could make that comparison; failing here keeps the answer honest. + */ +export async function getPetEquipmentForCombat(chain: string, petId: string): Promise { + const slots = await findEquipment(chain, petId); + if (slots.length === 0) { + return []; + } + + const catalog = await loadCatalog(); + return slots.map(({ slot, itemType }) => { + const item = catalog.byType.get(itemType); + if (!item) { + throw new ItemCatalogError( + `pet ${petId} has uncatalogued item type ${itemType} equipped in slot ${slot}; the item catalog is behind the contract`, + ); + } + if (catalog.unreadable.has(itemType) || item.effect?.kind !== 'stat_bonus') { + throw new ItemCatalogError( + `pet ${petId} has item ${itemType} (${item.key}) equipped in slot ${slot}, which carries no readable stat_bonus`, + ); + } + return { slot, itemType, key: item.key, bonus: item.effect }; + }); } async function definitionsByType(itemTypes: string[]): Promise> { - const catalog = await catalogByType(); + const catalog = await loadCatalog(); const wanted = new Map(); for (const itemType of new Set(itemTypes)) { - const definition = catalog.get(itemType); + const definition = catalog.byType.get(itemType); if (definition) wanted.set(itemType, definition); } return wanted; } function toItemView(row: ItemDefinitionRow): ItemView { - const effect = asItemEffect(row.effect); - if (row.effect !== null && effect === null) { - // Readable but unrecognised: the item still renders, without whatever it does. - // Loud because the only writer is the seeder, so this means the stored shape and - // the code that reads it have diverged. - console.warn(`[inventory] item ${row.itemType} (${row.key}) has an unreadable effect payload`); - } return { itemType: row.itemType, key: row.key, category: row.category, slot: row.slot, rarity: row.rarity, - effect, + // Readable but unrecognised leaves the item rendering without whatever it does. + // `loadCatalog` records which rows those were, since this null cannot say. + effect: asItemEffect(row.effect), name: row.name, description: row.description, }; diff --git a/backend/src/features/inventory/inventory.write.ts b/backend/src/features/inventory/inventory.write.ts index 56b7dcc4..38e13456 100644 --- a/backend/src/features/inventory/inventory.write.ts +++ b/backend/src/features/inventory/inventory.write.ts @@ -7,7 +7,7 @@ import { findBalance, findDefinitionByType } from '@repositories/inventory.repos import { servedDeploymentId } from '@features/battle/ledger'; import { asItemEffect } from './catalog'; -import { getItemCoreClient } from './inventory.chain'; +import { getItemCoreClient, UnconfirmedTxError } from './inventory.chain'; /** * Inventory writes (roadmap §4): spend a consumable, claim an earned item, grant one. @@ -208,9 +208,23 @@ export async function claimEntitlement(caller: string, entitlementId: string): P await prisma.itemEntitlement.update({ where: { id: entitlementId }, data: { txHash: mintTxHash } }); return { mintTxHash, itemType: entitlement.itemType, quantity: entitlement.quantity }; } catch (error) { + if (error instanceof UnconfirmedTxError) { + // Broadcast, outcome unknown, so the claim stays claimed. Releasing here would + // be the double-mint: the transaction is very likely mined, and a retry would + // send a second one. The hash is recorded so the row names the transaction to + // reconcile against, and so the `txHash: null` guard below keeps meaning what + // it says. Costs at most one item stuck pending until someone looks. + await prisma.itemEntitlement.update({ where: { id: entitlementId }, data: { txHash: error.hash } }); + console.error( + `[inventory] entitlement ${entitlementId} broadcast mint ${error.hash} but could not confirm it; left claimed to avoid a double mint, reconcile by hand`, + error.cause, + ); + throw error; + } // Released, so a failed mint is retryable rather than a permanently burned claim. - // Safe because the mint did not land: the client waits for a receipt and treats a - // reverted one as a throw. + // Safe only for the failures that definitely moved nothing: a simulate revert, a + // send that never left, or a receipt that came back reverted. `UnconfirmedTxError` + // is the one that does not qualify, and it returned above. await prisma.itemEntitlement.updateMany({ where: { id: entitlementId, txHash: null }, data: { claimedAt: null }, diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 301a0f2f..c542be76 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -120,7 +120,7 @@ export const rootValue = { const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, args.pageSize ?? DEFAULT_PAGE_SIZE)); const minLevel = Math.max(0, args.minLevel ?? 0); - const { rows, total } = await findReadyOpponents({ + const { rows, total, emptyReason } = await findReadyOpponents({ chain: args.chain, excludeOwner: context.caller, minLevel, @@ -135,6 +135,10 @@ export const rootValue = { total, page, pageSize, + // Null whenever there is anything to show. Present only to let the client say + // which of four very different situations produced an empty picker, since they + // are indistinguishable to a player and only some are theirs to fix. + emptyReason: emptyReason ?? null, }; }, diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 680968fc..8f319265 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -110,6 +110,16 @@ export const schema = buildSchema(` total: Int! page: Int! pageSize: Int! + """ + Why the list is empty, or null when it is not. + + Several situations render as the same blank picker and only some are the player's + to fix: 'roster-empty' (nothing indexed yet — an operator problem), 'all-yours', + 'all-on-cooldown', 'below-min-level', 'no-consent' (nobody has allowed challenges), + and 'consent-stale' (someone did, but under rules that have since moved, so it + needs granting again). + """ + emptyReason: String } "Pre-fight win odds from indexer-go's combat sim over the warm roster cache." diff --git a/backend/src/middleware/asyncRoute.ts b/backend/src/middleware/asyncRoute.ts new file mode 100644 index 00000000..f39e1d89 --- /dev/null +++ b/backend/src/middleware/asyncRoute.ts @@ -0,0 +1,24 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; + +/** + * Routes a rejected async handler into Express's error middleware. + * + * Express 4 does not await handlers. It calls one, ignores the promise it returns, and + * moves on, so a rejection from an `async` handler is an unhandled rejection rather than a + * 500 — and Node 24 exits the process on those by default. The practical effect is that + * every async route is one throw away from taking the server down for every user, which a + * single failed battle accept demonstrated. + * + * The error middleware in `app.ts` is the other half: this puts the error on the chain, + * that turns it into a response. Neither works without the other. + * + * Express 5 awaits handlers natively and makes this unnecessary. Until that upgrade, wrap + * anything async. + */ +export function asyncRoute( + handler: (req: Request, res: Response, next: NextFunction) => Promise, +): RequestHandler { + return (req, res, next) => { + handler(req, res, next).catch(next); + }; +} diff --git a/backend/src/repositories/roster.repository.ts b/backend/src/repositories/roster.repository.ts index f8d24abf..3137eaa5 100644 --- a/backend/src/repositories/roster.repository.ts +++ b/backend/src/repositories/roster.repository.ts @@ -1,4 +1,3 @@ -import { hashRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; import { Prisma } from '@generated/prisma/client'; import { prisma } from '@config/prisma'; @@ -7,6 +6,7 @@ import { mapRosterRowToRosterPet, type PetRosterRow } from './roster.mapping'; import { servedChainIdForFamily } from './battleProgress.overlay'; import { ownerKey } from './owner.sql'; import { servedDeploymentId } from '@features/battle/ledger/domain'; +import { servedRulesetHash } from '@features/battle/ledger/ruleset.builder'; import type { Chain } from '@typings/chain'; /** @@ -91,7 +91,7 @@ export interface FindOpponentsParams { */ export async function findReadyOpponents( params: FindOpponentsParams -): Promise<{ rows: RosterPet[]; total: number }> { +): Promise<{ rows: RosterPet[]; total: number; emptyReason?: OpponentsEmptyReason }> { const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); const chainId = servedChainIdForFamily(params.chain); if (!chainId) { @@ -99,7 +99,19 @@ export async function findReadyOpponents( } const deploymentId = servedDeploymentId(); - const rulesetHash = hashRuleset(SOURCE_DEFAULT_RULESET); + // The *served* ruleset, item catalog included — not `SOURCE_DEFAULT_RULESET`. + // + // This filter compares against the hash defenders actually signed, and they sign what + // `GET /api/battle/config` hands them, which is `servedRuleset()`. The two are equal + // only while the item catalog is empty. Seed one equipment item and they diverge, so + // this predicate matched no authorization ever written and matchmaking returned an + // empty list on a deployment full of consenting, off-cooldown pets. + // + // Silent by construction: "no eligible opponents" is also what a correct query returns + // when nobody has consented, so the failure looked exactly like the ordinary empty + // case. Anything comparing a `ruleset_hash` has to obtain it the same way `accept` + // does, or it is answering about rules no battle is fought under. + const rulesetHash = await servedRulesetHash(); const skip = params.page * params.pageSize; // Folded for EVM, exact for base58 — see `ownerKey`, which states the rule once for @@ -169,12 +181,103 @@ export async function findReadyOpponents( `, ]); + const total = Number(counted[0]?.total ?? 0); + if (total > 0) { + return { rows: rows.map(mapRosterRowToRosterPet), total }; + } + + // Only on the empty path, so the normal case pays nothing for this. return { - rows: rows.map(mapRosterRowToRosterPet), - total: Number(counted[0]?.total ?? 0), + rows: [], + total, + emptyReason: await diagnoseEmpty(params, nowSeconds, chainId, deploymentId, rulesetHash), }; } +/** + * Which filter emptied the list. + * + * Four very different situations render as the same blank picker: nothing indexed yet, + * every pet is the caller's own, everyone is mid-cooldown, and nobody has consented to + * being challenged. Only the last is the player's problem to solve, and only the first is + * ours, so collapsing them into "No opponents available" tells the one person who could + * act the one thing that does not help. + * + * Counted in a single pass with conditional aggregates, peeling the filters off in the + * order the main query applies them. Consent is deduced rather than counted: it is the + * only predicate left, so surviving every other filter and still not appearing means the + * owner never granted it (or granted it under older rules). + */ +export type OpponentsEmptyReason = + | 'roster-empty' + | 'all-yours' + | 'all-on-cooldown' + | 'below-min-level' + | 'no-consent' + | 'consent-stale'; + +async function diagnoseEmpty( + params: FindOpponentsParams, + nowSeconds: bigint, + chainId: string, + deploymentId: string, + servedRulesetHash: string, +): Promise { + const ready = Prisma.sql`GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) <= ${nowSeconds}`; + const level = Prisma.sql`GREATEST(r.level, COALESCE(p.level, 0)) >= ${params.minLevel}`; + const notMine = Prisma.sql`r.owner <> ${params.excludeOwner}`; + + const [counts] = await prisma.$queryRaw< + { indexed: bigint; notMine: bigint; offCooldown: bigint; inBand: bigint }[] + >` + SELECT COUNT(*) AS indexed, + COUNT(*) FILTER (WHERE ${notMine}) AS "notMine", + COUNT(*) FILTER (WHERE ${notMine} AND ${ready}) AS "offCooldown", + COUNT(*) FILTER (WHERE ${notMine} AND ${ready} AND ${level}) AS "inBand" + FROM pet_roster r + LEFT JOIN pet_battle_progress p + ON p.pet_id = r.pet_id + AND p.chain_id = ${chainId} + AND p.deployment_id = ${deploymentId} + WHERE r.chain = ${params.chain} + `; + + if (Number(counts?.indexed ?? 0) === 0) return 'roster-empty'; + if (Number(counts?.notMine ?? 0) === 0) return 'all-yours'; + if (Number(counts?.offCooldown ?? 0) === 0) return 'all-on-cooldown'; + if (Number(counts?.inBand ?? 0) === 0) return 'below-min-level'; + + // Consent is the only predicate left, but "never granted" and "granted under rules + // that have since moved" send the player somewhere different: the first needs someone + // to turn it on, the second needs someone who already did to do it again. Worth one + // more count to tell them apart, since this only runs on an already-empty list. + const live = await prisma.defenseAuthorization.count({ + where: { + chainId, + deploymentId, + revokedAt: null, + notBefore: { lte: nowSeconds }, + expiresAt: { gt: nowSeconds }, + }, + }); + if (live === 0) return 'no-consent'; + + const current = await prisma.defenseAuthorization.count({ + where: { + chainId, + deploymentId, + revokedAt: null, + notBefore: { lte: nowSeconds }, + expiresAt: { gt: nowSeconds }, + rulesetHash: servedRulesetHash, + }, + }); + // Grants exist and none match: either the rules moved under them, or they cover only + // pets that some earlier filter already removed. Both read as "ask them to re-grant", + // which is the useful instruction either way. + return current === 0 ? 'consent-stale' : 'no-consent'; +} + /** The same query without progression, for a chain family this deployment does not serve. */ async function findReadyOpponentsFromChainState( params: FindOpponentsParams, diff --git a/backend/src/routes/battle.ts b/backend/src/routes/battle.ts index b88d8f04..90f57af2 100644 --- a/backend/src/routes/battle.ts +++ b/backend/src/routes/battle.ts @@ -2,6 +2,7 @@ import express, { Router } from 'express'; import { deleteDefenseAuthorizations, + getDefenseAuthorizations, getBattleCombatLog, getBattleCommitment, getBattleConfigHandler, @@ -12,10 +13,13 @@ import { getSigningKeys, postAcceptBattle, postBattleIntent, + deleteSessionDelegations, postDefenseAuthorization, + postSessionDelegation, postVerifyReceipt, requireBackendBattleMode, } from '@features/battle/ledger'; +import { asyncRoute } from '@middleware/asyncRoute'; import { verifyToken } from '@middleware/auth'; import { battleRoomRateLimit } from '@middleware/rateLimit'; @@ -27,21 +31,32 @@ const router: Router = express.Router(); // Every write below is gated on backend battle mode (§L Phase 3). The reads further down // deliberately are not: receipts already issued stay checkable after the mode is switched // off, or turning the feature off would retract evidence §H promises stays public. -router.post('/intents', requireBackendBattleMode, verifyToken, battleRoomRateLimit, postBattleIntent); +router.post('/intents', requireBackendBattleMode, verifyToken, battleRoomRateLimit, asyncRoute(postBattleIntent)); // The commit-before-reveal moment (§E): the round is chosen and the commitment signed here, // synchronously, and handed back in this same response. -router.post('/intents/:intentHash/accept', requireBackendBattleMode, verifyToken, battleRoomRateLimit, (req, res) => { +router.post('/intents/:intentHash/accept', requireBackendBattleMode, verifyToken, battleRoomRateLimit, asyncRoute(async (req, res) => { req.body = { ...req.body, intentHash: req.params.intentHash }; return postAcceptBattle(req, res); -}); +})); // Standing defence consent. Submission is signed by the defender's wallet; revocation needs // only the JWT, because refusing battles is never the dangerous direction. -router.post('/authorizations', requireBackendBattleMode, verifyToken, battleRoomRateLimit, postDefenseAuthorization); +router.post('/authorizations', requireBackendBattleMode, verifyToken, battleRoomRateLimit, asyncRoute(postDefenseAuthorization)); // Revocation is not gated: withdrawing consent must keep working even after the mode is // switched off, since refusing battles is never the dangerous direction. -router.delete('/authorizations', verifyToken, deleteDefenseAuthorizations); +router.delete('/authorizations', verifyToken, asyncRoute(deleteDefenseAuthorizations)); +// Reading is ungated for the same reason. A defender needs to see that their consent went +// stale precisely when something is off, and a mode flag should not be what hides it. +// Scoped to the authenticated wallet in the controller, never to a queried address. +router.get('/authorizations', verifyToken, asyncRoute(getDefenseAuthorizations)); + +// Delegated battle-intent signing (§D). The owner approves a client-held key once and that +// key signs intents, so the wallet prompt stops being per battle. Gated on backend mode +// like the other writes; revocation is not, for the same reason consent revocation is not: +// withdrawing authority must keep working whatever else is switched off. +router.post('/sessions', requireBackendBattleMode, verifyToken, battleRoomRateLimit, asyncRoute(postSessionDelegation)); +router.delete('/sessions', verifyToken, asyncRoute(deleteSessionDelegations)); // Authoritative, re-fetchable reads (§J). No auth: every value here is either already // public on chain or is itself a signed artifact anyone is meant to check, so gating diff --git a/backend/src/server.ts b/backend/src/server.ts index 4aff4aa0..5a1cb3cd 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,7 +2,12 @@ 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, + listSigningKeys, + loadPersistedSigningKeys, + signerBackendError, +} 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'; @@ -12,7 +17,10 @@ let battleWorker: BattleWorkerHandle | undefined; // Bind 0.0.0.0 so Render's internal health check can reach the process // (listen(port) alone is not always reachable on their network scan). -const server = app.listen(env.port, '0.0.0.0', () => { +// The callback is async because `configureSigner` now is: a KMS backend fetches its public +// key before it can describe the key it signs with. Express ignores the returned promise, +// so anything that must not be silently swallowed is handled inside. +const server = app.listen(env.port, '0.0.0.0', async () => { const { port } = env; console.log(`🚀 Backend server running on 0.0.0.0:${port}`); console.log(`📊 Health check: http://localhost:${port}/api/health`); @@ -37,13 +45,48 @@ const server = app.listen(env.port, '0.0.0.0', () => { // signing key at all. The read routes and the public corpus stay served either way — // receipts already issued must remain checkable after the mode is switched off. if (env.battle.enabled) { - configureSigner(Math.floor(Date.now() / 1000)); + // Awaited: a KMS backend has to fetch its public key before it can say which key it + // signs with, so a misconfigured key fails at boot rather than on the first battle. + await configureSigner(Math.floor(Date.now() / 1000)); + // Said out loud, because `configureSigner` records its failure and returns rather + // than throwing: reads must keep being served either way. The cost of that choice is + // that a deployment which cannot sign anything used to boot completely silently, and + // only admit it once a player had fought a battle and lost the receipt at the last + // step. The reason was in memory the whole time and nothing ever printed it. + const signerFailure = signerBackendError(); + if (signerFailure) { + console.error( + `[battle-signer] NOT CONFIGURED: ${signerFailure} +` + + '[battle-signer] battles will be accepted and then fail at signing. ' + + 'Run `pnpm --filter backend exec tsx scripts/diagnose-signer.ts` to see what resolved.', + ); + } else { + const keys = listSigningKeys() + .map((key) => `${key.keyId} (${key.address})`) + .join(', '); + console.log(`[battle-signer] ready for ${env.battle.chainIds.join(', ')}: ${keys}`); + } // Republishes every key this deployment has ever signed under. Without it the // registry is only as old as the process, and a rotated key vanishes on the next // deploy — making its receipts unverifiable rather than invalid (§H item 4). void loadPersistedSigningKeys().catch((error: unknown) => console.error(`[battle-signer] could not load persisted signing keys: ${(error as Error).message}`), ); + // §F is a hard precondition, not a nice-to-have: the backend will not sign a receipt + // the independent Go port has not confirmed, so an unset address does not degrade + // verification, it stalls every battle at `computed` until it forfeits. Silence here + // cost two rounds of diagnosis, because a stalled battle and an unreachable verifier + // look identical from the client, which only says it is waiting. + if (env.indexerGrpc.addr) { + console.log(`[battle-verify] independent verifier at ${env.indexerGrpc.addr}`); + } else { + console.error( + '[battle-verify] INDEXER_GRPC_ADDR is not set. Independent verification cannot run, ' + + 'so every battle will stall after `computed` and then forfeit. ' + + 'Check it with `pnpm --filter backend exec tsx scripts/diagnose-verifier.ts`.', + ); + } battleWorker = startBattleWorker(`backend-${process.pid}`); // Aggregates published receipts into Merkle batches and anchors the roots (§I). // No-ops unless BATTLE_ANCHOR_* is configured; batches are still built either way. diff --git a/backend/tests/config/battleSignerEnv.test.ts b/backend/tests/config/battleSignerEnv.test.ts new file mode 100644 index 00000000..dc57c2db --- /dev/null +++ b/backend/tests/config/battleSignerEnv.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Neutralized, or this file tests whoever's `.env` happens to be on disk. + * + * `@config/env` imports `dotenv/config` for its side effect, and `vi.resetModules()` makes + * that side effect run again on every re-import — so deleting a variable here would be + * silently undone by the developer's own `.env` before the assertion ran. That is not a + * hypothetical: it made the first version of this file pass against a *reverted* default. + */ +vi.mock('dotenv/config', () => ({})); + +/** + * The battle signer's attestation defaults (§F, §G). + * + * Worth its own test because every other signer test mocks `env`, so the default that a + * real deployment actually runs under is exercised nowhere else. A default is configuration + * only in the sense that it can be overridden; until someone does, it *is* the behaviour. + */ + +const ORIGINAL = process.env; + +beforeEach(() => { + vi.resetModules(); + process.env = { ...ORIGINAL }; +}); + +afterEach(() => { + process.env = ORIGINAL; +}); + +async function loadEnv() { + return (await import('@config/env')).env; +} + +describe('BATTLE_SIGNER_REQUIRED_ATTESTERS', () => { + /** + * Both engines by default, which is what makes §F's circuit breaker a precondition for a + * signature rather than a step earlier in the pipeline. The pipeline already refuses to + * advance a battle whose independent verification disagreed, but that refusal is one + * edit away from being removed; this one lives at the only place a receipt is produced. + */ + it('requires the independent Go verifier by default', async () => { + delete process.env.BATTLE_SIGNER_REQUIRED_ATTESTERS; + + expect((await loadEnv()).battleSigner.requiredAttesters).toEqual([ + 'typescript-engine', + 'go-verifier', + ]); + }); + + it('is overridable, for draining a queue during an indexer-go outage', async () => { + process.env.BATTLE_SIGNER_REQUIRED_ATTESTERS = 'typescript-engine'; + + expect((await loadEnv()).battleSigner.requiredAttesters).toEqual(['typescript-engine']); + }); + + it('ignores blanks and stray whitespace, so a trailing comma is not an empty attester', async () => { + // An empty string would be an attester name nothing ever matches, which would make + // every receipt unsignable for a reason that reads as a mystery. + process.env.BATTLE_SIGNER_REQUIRED_ATTESTERS = ' typescript-engine , go-verifier , '; + + expect((await loadEnv()).battleSigner.requiredAttesters).toEqual([ + 'typescript-engine', + 'go-verifier', + ]); + }); +}); diff --git a/backend/tests/features/battle/ledger/accept.service.test.ts b/backend/tests/features/battle/ledger/accept.service.test.ts index a22dd324..b3c1200c 100644 --- a/backend/tests/features/battle/ledger/accept.service.test.ts +++ b/backend/tests/features/battle/ledger/accept.service.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { QUICKNET, roundTime } from '@cryptopets/protocol'; +import { hashBattleSnapshot, QUICKNET, roundTime, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; vi.mock('@config/env', () => ({ env: { battle: { deploymentId: 'base-sepolia-live', chainIds: ['eip155:84532'] } }, @@ -20,7 +20,12 @@ vi.mock('../../../../src/features/battle/ledger/ruleset.builder', async () => { const { SOURCE_DEFAULT_RULESET } = await vi.importActual( '@cryptopets/protocol', ); - return { servedRuleset: vi.fn(async () => SOURCE_DEFAULT_RULESET) }; + const { hashRuleset } = await vi.importActual('@cryptopets/protocol'); + return { + servedRuleset: vi.fn(async () => SOURCE_DEFAULT_RULESET), + // Same object both times: accept must publish the bundle under the hash it records. + servedRulesetHash: vi.fn(async () => hashRuleset(SOURCE_DEFAULT_RULESET)), + }; }); vi.mock('../../../../src/features/battle/ledger/snapshot.builder', () => ({ @@ -56,10 +61,12 @@ vi.mock('../../../../src/features/battle/ledger/transitions', () => ({ })); import { prisma } from '@config/prisma'; -import { acceptBattle } from '@features/battle/ledger'; +import { acceptBattle, decodeStoredSnapshot } 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 { ItemCatalogError } from '@features/inventory'; +import { servedRuleset } from '../../../../src/features/battle/ledger/ruleset.builder'; import { buildPetSnapshot } from '../../../../src/features/battle/ledger/snapshot.builder'; import { applyTransition, openBattle } from '../../../../src/features/battle/ledger/transitions'; @@ -111,6 +118,9 @@ const SIGNING_KEY = { function baseline() { vi.mocked(prisma.battleIntent.findUnique).mockResolvedValue(INTENT as never); vi.mocked(prisma.battleRuleset.findUnique).mockResolvedValue({} as never); + // Re-established per test: `clearAllMocks` drops recorded calls but not implementations, + // so a case that makes this reject would otherwise poison every case after it. + vi.mocked(prisma.battleRuleset.create).mockResolvedValue({} as never); vi.mocked(prisma.battleCommitment.findFirst).mockResolvedValue(null); vi.mocked(buildPetSnapshot).mockImplementation((async (_chainId: string, petId: string) => petId === '1' ? ATTACKER : DEFENDER) as never); @@ -202,6 +212,72 @@ describe('the happy path', () => { await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); expect(prisma.battleRuleset.create).not.toHaveBeenCalled(); }); + + /** + * The bundle is published under the hash the battle actually names. + * + * These were computed from two separate reads of `servedRuleset()` with nothing + * checking they agreed, so any drift published a bundle nobody would look up and left + * the battle naming one that did not exist. It surfaced as far downstream as possible: + * accept succeeded, the player signed, and the battle died nine retries later in + * `compute` with "no published ruleset bundle for 0x…". + */ + it('publishes under the same hash the ledger row records', async () => { + vi.mocked(prisma.battleRuleset.findUnique).mockResolvedValue(null); + + await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + + const published = vi.mocked(prisma.battleRuleset.create).mock.calls[0]![0].data as { + rulesetHash: string; + }; + const ledger = vi.mocked(openBattle).mock.calls[0]![0].ledger as unknown as { rulesetHash: string }; + expect(published.rulesetHash).toBe(ledger.rulesetHash); + }); + + /** + * A unique violation is benign only when it means *this* bundle already exists. + * + * The catch treated every P2002 as the concurrent-accept race. `version` was also + * unique and every served ruleset carries version 1, so the first catalog change + * collided there instead: accept reported success having written nothing, and the + * battle died in `compute` naming a bundle that had never existed. + */ + it('accepts a duplicate-hash conflict, because the bundle is there either way', async () => { + vi.mocked(prisma.battleRuleset.findUnique) + .mockResolvedValueOnce(null) // not published when we looked + .mockResolvedValueOnce({} as never); // but present after the conflict + vi.mocked(prisma.battleRuleset.create).mockRejectedValue( + Object.assign(new Error('unique'), { code: 'P2002' }), + ); + + expect(await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).toMatchObject({ ok: true }); + }); + + it('refuses when a conflict left no bundle for this hash', async () => { + vi.mocked(prisma.battleRuleset.findUnique).mockResolvedValue(null); + vi.mocked(prisma.battleRuleset.create).mockRejectedValue( + Object.assign(new Error('Unique constraint failed on the fields: (`version`)'), { code: 'P2002' }), + ); + + // Loudly, and before a battle exists. Swallowing this is what produced a signed-for + // battle that could never be computed. + await expect(acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).rejects.toThrow( + /could not publish the ruleset bundle/, + ); + expect(openBattle).not.toHaveBeenCalled(); + }); + + it('looks the bundle up under the hash it is about to record', async () => { + vi.mocked(prisma.battleRuleset.findUnique).mockResolvedValue(null); + + await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + + const looked = vi.mocked(prisma.battleRuleset.findUnique).mock.calls[0]![0] as { + where: { rulesetHash: string }; + }; + const ledger = vi.mocked(openBattle).mock.calls[0]![0].ledger as unknown as { rulesetHash: string }; + expect(looked.where.rulesetHash).toBe(ledger.rulesetHash); + }); }); describe('intent checks', () => { @@ -312,6 +388,158 @@ describe('opening the ledger', () => { }); }); +describe('a catalog that cannot price the battle', () => { + /** + * Refused, not fought (roadmap §4). Both reads that consult the catalog run before the + * first write, so this rejects with nothing stranded: no ledger row, no consumed + * intent, no spent daily budget. + */ + it('rejects when a pet wears something the catalog cannot price', async () => { + vi.mocked(buildPetSnapshot).mockRejectedValue( + new ItemCatalogError('pet 1 has uncatalogued item type 999 equipped in slot 0'), + ); + + expect(await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).toMatchObject({ + ok: false, + reason: 'item-catalog-stale', + }); + expect(openBattle).not.toHaveBeenCalled(); + expect(sign).not.toHaveBeenCalled(); + }); + + it('rejects when the ruleset itself cannot be built', async () => { + vi.mocked(servedRuleset).mockRejectedValueOnce( + new ItemCatalogError('item 2 (bent_fang) is equipment with no readable stat_bonus'), + ); + + expect(await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).toMatchObject({ + ok: false, + reason: 'item-catalog-stale', + }); + expect(openBattle).not.toHaveBeenCalled(); + }); + + it('lets any other failure through as a real error', async () => { + // A stale catalog is a recoverable operational state with an obvious remedy. A bug + // is not, and collapsing the two would turn every defect on this path into a + // routine 503 nobody investigates. + vi.mocked(servedRuleset).mockRejectedValueOnce(new Error('connection reset')); + + await expect(acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).rejects.toThrow('connection reset'); + }); +}); + +describe('gear the ruleset does not price', () => { + /** + * The same comparison the verifier runs on the finished receipt, made at acceptance so + * a battle guaranteed to fail verification is never accepted (roadmap §4, threat T13). + * + * The reachable cause is narrow: `buildPetSnapshot` resolves the modifiers and + * `servedRuleset` publishes them, and those are two reads of the item catalog at + * different points, so a seeder run landing between them prices the fight from one + * catalog and the rules from another. + */ + const WORN = { slot: 0, itemType: 3n, hp: 0, atk: 22, def: 0, int: 0, mdef: 0 }; + + function wearing(entry: typeof WORN) { + vi.mocked(buildPetSnapshot).mockImplementation((async (_chainId: string, petId: string) => + petId === '1' ? { ...ATTACKER, equipment: [entry] } : DEFENDER) as never); + } + + function pricing(item: { itemType: bigint; slot: number; hp: number; atk: number; def: number; int: number; mdef: number }) { + vi.mocked(servedRuleset).mockResolvedValueOnce({ + ...SOURCE_DEFAULT_RULESET, + itemCatalog: [item], + } as never); + } + + it('accepts when the worn modifiers are what the catalog declares', async () => { + wearing(WORN); + pricing({ itemType: 3n, slot: 0, hp: 0, atk: 22, def: 0, int: 0, mdef: 0 }); + + expect(await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).toMatchObject({ ok: true }); + }); + + it('rejects an inflated modifier', async () => { + // The attack the check exists for: a fight given +50 ATK from a 22-ATK sword + // replays perfectly, because the inflated number is the thing being replayed. + wearing({ ...WORN, atk: 50 }); + pricing({ itemType: 3n, slot: 0, hp: 0, atk: 22, def: 0, int: 0, mdef: 0 }); + + expect(await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).toMatchObject({ + ok: false, + reason: 'equipment-catalog-mismatch', + detail: expect.stringContaining('atk applied 50, catalog declares 22'), + }); + expect(openBattle).not.toHaveBeenCalled(); + }); + + it('rejects an item the ruleset never priced', async () => { + wearing(WORN); + pricing({ itemType: 999n, slot: 0, hp: 0, atk: 1, def: 0, int: 0, mdef: 0 }); + + expect(await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).toMatchObject({ + ok: false, + reason: 'equipment-catalog-mismatch', + }); + }); + + it('refuses before consuming the defender daily budget', async () => { + // Ordering matters as much as the refusal: a rejected battle must not spend a use + // of someone's cap, and this check sits ahead of every write for that reason. + wearing({ ...WORN, atk: 50 }); + pricing({ itemType: 3n, slot: 0, hp: 0, atk: 22, def: 0, int: 0, mdef: 0 }); + + await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + + expect(consumeDailyBudget).not.toHaveBeenCalled(); + expect(sign).not.toHaveBeenCalled(); + }); +}); + +describe('the stored snapshot survives a storage round trip', () => { + /** + * The property every worker downstream depends on: what acceptance persisted, read back + * through `decodeStoredSnapshot`, still hashes to the `snapshotHash` acceptance + * committed. The seed is derived from that hash and `assertBattleReceipt` re-derives it + * from the receipt's own snapshot, so a decoder that loses any field stops every battle + * at signing. + * + * Written as a property rather than as an assertion about `schemaVersion` and + * `equipment` specifically, because those are only the two fields that have been lost + * so far. Any field added to `PetSnapshot` is covered here on the day it is added. + */ + async function storedLedger() { + await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + return vi.mocked(openBattle).mock.calls[0]![0].ledger as unknown as { + snapshot: unknown; + snapshotHash: string; + }; + } + + it('rehashes to the committed snapshotHash', async () => { + const ledger = await storedLedger(); + expect(hashBattleSnapshot(decodeStoredSnapshot(ledger.snapshot))).toBe(ledger.snapshotHash); + }); + + it('rehashes to the committed snapshotHash with equipment', async () => { + vi.mocked(buildPetSnapshot).mockImplementation((async (_chainId: string, petId: string) => + petId === '1' + ? { ...ATTACKER, equipment: [{ slot: 0, itemType: 3n, hp: 0, atk: 22, def: 0, int: 0, mdef: 0 }] } + : DEFENDER) as never); + // The ruleset has to price the sword, or acceptance now refuses the battle before + // it ever reaches `openBattle` — which is the catalog cross-check above doing its + // job, not a problem with this case. + vi.mocked(servedRuleset).mockResolvedValueOnce({ + ...SOURCE_DEFAULT_RULESET, + itemCatalog: [{ itemType: 3n, slot: 0, hp: 0, atk: 22, def: 0, int: 0, mdef: 0 }], + } as never); + + const ledger = await storedLedger(); + expect(hashBattleSnapshot(decodeStoredSnapshot(ledger.snapshot))).toBe(ledger.snapshotHash); + }); +}); + describe('signer failure unwinds the accepted row', () => { it('moves the ledger to rejected and reports signer-unavailable', async () => { vi.mocked(sign).mockRejectedValue(new SignerRefusedError('signer-not-configured', 'no key')); diff --git a/backend/tests/features/battle/ledger/config.service.test.ts b/backend/tests/features/battle/ledger/config.service.test.ts index daeaa177..2e31dab8 100644 --- a/backend/tests/features/battle/ledger/config.service.test.ts +++ b/backend/tests/features/battle/ledger/config.service.test.ts @@ -13,7 +13,13 @@ vi.mock('../../../../src/features/battle/ledger/ruleset.builder', async () => { const { SOURCE_DEFAULT_RULESET } = await vi.importActual( '@cryptopets/protocol', ); - return { servedRuleset: vi.fn(async () => SOURCE_DEFAULT_RULESET) }; + const { hashRuleset } = await vi.importActual('@cryptopets/protocol'); + return { + servedRuleset: vi.fn(async () => SOURCE_DEFAULT_RULESET), + // Derived from the same object, so config cannot serve a hash for a ruleset it is + // not also serving — which is what clients sign their consent against. + servedRulesetHash: vi.fn(async () => hashRuleset(SOURCE_DEFAULT_RULESET)), + }; }); vi.mock('@config/env', () => ({ env: { battle: battleEnv } })); diff --git a/backend/tests/features/battle/ledger/consent.service.test.ts b/backend/tests/features/battle/ledger/consent.service.test.ts index 93c02c20..6993d758 100644 --- a/backend/tests/features/battle/ledger/consent.service.test.ts +++ b/backend/tests/features/battle/ledger/consent.service.test.ts @@ -22,6 +22,7 @@ import { consumeDailyBudget, epochDay, findCoveringAuthorization, + listDefenseAuthorizations, revokeDefenseAuthorizations, submitDefenseAuthorization, toProtocolAuthorization, @@ -350,3 +351,70 @@ describe('consumeDailyBudget', () => { await expect(consumeDailyBudget('0xabc', 20, NOW)).rejects.toThrow(/connection reset/); }); }); + +/** + * The read half of the consent API (§D). Granting and revoking both existed; reading did + * not, so a defender could not see that they had consented, nor that a rules change had + * quietly made their consent cover nothing. + */ +describe('listDefenseAuthorizations', () => { + const SERVED = `0x${'11'.repeat(32)}`; + const OLD = `0x${'22'.repeat(32)}`; + + const row = (overrides: Record = {}) => ({ + authorizationHash: `0x${'ab'.repeat(32)}`, + allPets: true, + petIds: [], + minLevel: 1, + maxLevel: 100, + maxBattlesPerDay: 50, + notBefore: 1000n, + expiresAt: 2000n, + rulesetHash: SERVED, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + ...overrides, + }); + + it('flags an authorization signed under the rules now being served as current', async () => { + vi.mocked(prisma.defenseAuthorization.findMany).mockResolvedValue([row()] as never); + + const [entry] = await listDefenseAuthorizations('eip155:84532', '0xABC', SERVED); + + expect(entry!.isStale).toBe(false); + expect(entry!.notBefore).toBe(1000); + expect(entry!.expiresAt).toBe(2000); + }); + + // The field this read exists for. A rules change invalidates every outstanding grant by + // design, and the defender is the one who has to re-sign but the last to notice: being + // challenged is passive, so their pets just stop being challengeable. + it('flags an authorization signed under older rules as stale', async () => { + vi.mocked(prisma.defenseAuthorization.findMany).mockResolvedValue([row({ rulesetHash: OLD })] as never); + + const [entry] = await listDefenseAuthorizations('eip155:84532', '0xABC', SERVED); + + expect(entry!.isStale).toBe(true); + }); + + it('compares the ruleset hash case-insensitively', async () => { + vi.mocked(prisma.defenseAuthorization.findMany).mockResolvedValue([ + row({ rulesetHash: SERVED.toUpperCase().replace('0X', '0x') }), + ] as never); + + expect((await listDefenseAuthorizations('eip155:84532', '0xABC', SERVED))[0]!.isStale).toBe(false); + }); + + it('normalizes the caller and excludes revoked grants', async () => { + vi.mocked(prisma.defenseAuthorization.findMany).mockResolvedValue([] as never); + + await listDefenseAuthorizations('eip155:84532', '0xABCDEF0123456789ABCDEF0123456789ABCDEF01', SERVED); + + const { where } = vi.mocked(prisma.defenseAuthorization.findMany).mock.calls.at(-1)![0]!; + expect(where).toMatchObject({ + defenderOwner: '0xabcdef0123456789abcdef0123456789abcdef01', + // Revoked rows survive so a verifier can read what a historical receipt was + // authorized under, which is not this caller's question. + revokedAt: null, + }); + }); +}); diff --git a/backend/tests/features/battle/ledger/intent.service.test.ts b/backend/tests/features/battle/ledger/intent.service.test.ts index 1cc132e1..e2dfb40b 100644 --- a/backend/tests/features/battle/ledger/intent.service.test.ts +++ b/backend/tests/features/battle/ledger/intent.service.test.ts @@ -5,7 +5,10 @@ import { ethers } from 'ethers'; import { battleIntentSolanaMessage, battleIntentTypedData, hashBattleIntent } from '@cryptopets/protocol'; vi.mock('@config/prisma', () => ({ - prisma: { battleIntent: { create: vi.fn() } }, + prisma: { + battleIntent: { create: vi.fn() }, + sessionDelegation: { findMany: vi.fn() }, + }, })); vi.mock('@config/env', () => ({ @@ -57,6 +60,9 @@ async function submit(overrides: Partial = {}, extras: Partial { describe('expiry', () => { it('rejects an expired intent', async () => { const result = await submit({}, { nowSeconds: wire.expiresAt }); - expect(result).toMatchObject({ ok: false, reason: 'expired' }); + expect(result).toMatchObject({ ok: false, reason: 'intent-expired' }); }); it('accepts one that expires a second from now', async () => { @@ -260,3 +266,108 @@ describe('nonce consumption', () => { await expect(submit()).rejects.toThrow(/connection reset/); }); }); + + +/** + * Delegated battle-intent signing (§D). + * + * The wallet approves a client-held key once; that key then signs intents, so the prompt + * stops being per battle. The property §D exists to protect is unchanged, and these cases + * are what hold it: the key is only ever accepted for the owner who delegated to it, and + * only while that delegation is live. A JWT still authorizes nothing. + */ +describe('delegated session signing', () => { + const session = new ethers.Wallet('0x' + '11'.repeat(32)); + const SESSION_KEY = session.address.toLowerCase(); + + async function signAsSession(overrides: Partial = {}): Promise { + const typed = battleIntentTypedData(toProtocolIntent({ ...wire, ...overrides })); + return session.signTypedData(typed.domain, typed.types as never, typed.message); + } + + /** A stored delegation row, as `findSessionDelegation` reads it. */ + function delegationRow(overrides: Record = {}) { + return { + delegationHash: `0x${'cd'.repeat(32)}`, + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + owner: ATTACKER, + sessionKey: SESSION_KEY, + scope: 'battle-intent', + notBefore: BigInt(NOW - 10), + expiresAt: BigInt(NOW + 3600), + revocationNonce: 0, + ...overrides, + }; + } + + it('accepts an intent signed by a delegated key', async () => { + vi.mocked(prisma.sessionDelegation.findMany).mockResolvedValue([delegationRow()] as never); + + const result = await submit({}, { signature: await signAsSession(), sessionKey: SESSION_KEY } as never); + + expect(result).toEqual({ ok: true, intentHash: hashBattleIntent(toProtocolIntent(wire)) }); + }); + + it('refuses a key with no delegation at all', async () => { + vi.mocked(prisma.sessionDelegation.findMany).mockResolvedValue([] as never); + + expect(await submit({}, { signature: await signAsSession(), sessionKey: SESSION_KEY } as never)).toMatchObject({ + ok: false, + reason: 'session-not-authorized', + }); + }); + + // The check that stops one player's session key acting for another wallet: the + // delegation names its owner, and it is compared against the intent's attacker. + it('refuses a delegation belonging to a different owner', async () => { + vi.mocked(prisma.sessionDelegation.findMany).mockResolvedValue([ + delegationRow({ owner: DEFENDER }), + ] as never); + + expect(await submit({}, { signature: await signAsSession(), sessionKey: SESSION_KEY } as never)).toMatchObject({ + ok: false, + reason: 'session-not-authorized', + }); + }); + + it('refuses an expired delegation', async () => { + vi.mocked(prisma.sessionDelegation.findMany).mockResolvedValue([ + delegationRow({ expiresAt: BigInt(NOW - 1) }), + ] as never); + + expect(await submit({}, { signature: await signAsSession(), sessionKey: SESSION_KEY } as never)).toMatchObject({ + ok: false, + reason: 'session-not-authorized', + }); + }); + + // Naming a key you did not sign with buys nothing: the signature is verified against + // the named key before the delegation is even looked up. + it('refuses a real delegation when the wallet signed instead of the key', async () => { + vi.mocked(prisma.sessionDelegation.findMany).mockResolvedValue([delegationRow()] as never); + + expect(await submit({}, { sessionKey: SESSION_KEY } as never)).toMatchObject({ + ok: false, + reason: 'bad-signature', + }); + }); + + // Revoked rows are excluded in the query, so revocation takes effect immediately. + it('only considers unrevoked delegations', async () => { + vi.mocked(prisma.sessionDelegation.findMany).mockResolvedValue([] as never); + + await submit({}, { signature: await signAsSession(), sessionKey: SESSION_KEY } as never); + + expect(vi.mocked(prisma.sessionDelegation.findMany).mock.calls[0]![0]!.where).toMatchObject({ + owner: ATTACKER, + sessionKey: SESSION_KEY, + revokedAt: null, + }); + }); + + it('still accepts a wallet-signed intent, so a client with no session just prompts', async () => { + expect(await submit()).toMatchObject({ ok: true }); + expect(prisma.sessionDelegation.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/features/battle/ledger/rejectionMessages.test.ts b/backend/tests/features/battle/ledger/rejectionMessages.test.ts new file mode 100644 index 00000000..80e24f53 --- /dev/null +++ b/backend/tests/features/battle/ledger/rejectionMessages.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { MESSAGES } from '@shared/core'; + +import { STATUS_BY_REASON as ACCEPT_STATUS } from '@features/battle/ledger/accept.controller'; +import { STATUS_BY_REASON as INTENT_STATUS } from '@features/battle/ledger/intent.controller'; + +/** + * Every refusal these two controllers can return has player-facing text. + * + * The status maps are `Record` and `Record`, so TypeScript already forces them to list every reason — which is why neither + * has ever had a gap. Their keys are therefore the authoritative runtime list, and this + * binds the message map to it. + * + * `MESSAGES` is a `Record` and cannot be checked the same way: the reasons + * are backend types and `@shared/core` must not import from `backend`. That looseness is + * exactly what let nine reasons ship without text, falling back to `Battle refused: + * item-catalog-stale` on screen. + * + * The fallback in `toBattleRejection` means a missing entry degrades rather than + * disappears, so this is a quality check, not a crash guard. It is worth having anyway: + * the degraded text is the internal slug, and a player who reads one cannot act on it. + */ +describe('every battle rejection has something to show the player', () => { + const reasons = [...Object.keys(INTENT_STATUS), ...Object.keys(ACCEPT_STATUS)]; + + it.each(reasons)('%s', (reason) => { + expect(MESSAGES[reason]).toBeTruthy(); + }); + + // The collision that motivated this. `expired` means two different things depending on + // which endpoint answered, and the client maps a code to text with no idea which one + // did. The intent side was renamed to `intent-expired` so a single map can be right: + // if this ever fails, the two meanings have been merged back onto one code and one of + // the two messages is now a lie. + it('keeps the request expiring and the defender consent expiring apart', () => { + expect(INTENT_STATUS).toHaveProperty('intent-expired'); + expect(INTENT_STATUS).not.toHaveProperty('expired'); + + // `expired` survives only as the CoverageFailure it comes from in `@cryptopets/protocol`. + expect(ACCEPT_STATUS).toHaveProperty('expired'); + expect(ACCEPT_STATUS).toHaveProperty('intent-expired'); + + expect(MESSAGES['expired']).toContain('opponent'); + expect(MESSAGES['intent-expired']).toContain('battle request'); + }); +}); diff --git a/backend/tests/features/battle/ledger/ruleset.builder.test.ts b/backend/tests/features/battle/ledger/ruleset.builder.test.ts index 9f806af4..b946ec5b 100644 --- a/backend/tests/features/battle/ledger/ruleset.builder.test.ts +++ b/backend/tests/features/battle/ledger/ruleset.builder.test.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const catalog = vi.fn(); -vi.mock('@features/inventory', () => ({ getCatalog: () => catalog() })); +// The catalog module is stubbed wholesale here, so no generation is ever bumped and +// `resetServedRuleset` is the seam these cases use. A fixed generation keeps the memo +// behaving as it does in production between seeder runs. +vi.mock('@features/inventory', () => ({ getCombatCatalog: () => catalog(), itemCatalogGeneration: () => 0 })); import { hashRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; -import { resetServedRuleset, servedRuleset } from '@features/battle/ledger/ruleset.builder'; +import { resetServedRuleset, servedRuleset, servedRulesetHash } from '@features/battle/ledger/ruleset.builder'; /** * The ruleset a deployment fights under (roadmap §4). @@ -114,3 +117,40 @@ describe('servedRuleset', () => { expect(catalog).toHaveBeenCalledTimes(1); }); }); + +/** + * The hash and the ruleset come from one place (§D, and the bug that motivated it). + * + * Four call sites used to run `hashRuleset(await servedRuleset())` themselves, and one of + * them hashed `SOURCE_DEFAULT_RULESET` instead. Defenders sign against the served ruleset, + * so matchmaking's consent filter matched no authorization ever written and the opponent + * list came back empty on a deployment full of consenting pets — silently, because an empty + * list is also the correct answer when nobody has consented. + */ +describe('servedRulesetHash', () => { + beforeEach(() => { + catalog.mockResolvedValue([BLADE]); + }); + + it('is the hash of the ruleset actually served', async () => { + + expect(await servedRulesetHash()).toBe(hashRuleset(await servedRuleset())); + }); + + it('follows the catalog rather than the source constant', async () => { + // The distinction the bug turned on: with items seeded, the served hash and the + // constant's hash are different values, and consent is bound to the former. + const served = await servedRulesetHash(); + expect(served).not.toBe(hashRuleset(SOURCE_DEFAULT_RULESET)); + }); + + it('derives once per catalog generation rather than per caller', async () => { + const first = await servedRulesetHash(); + const second = await servedRulesetHash(); + + expect(second).toBe(first); + // One build, however many callers ask. This also keeps a keccak over the whole + // ruleset off the matchmaking query path, which ran it per request. + expect(catalog).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/tests/features/battle/ledger/snapshot.builder.test.ts b/backend/tests/features/battle/ledger/snapshot.builder.test.ts index 2f2055c3..b78d5859 100644 --- a/backend/tests/features/battle/ledger/snapshot.builder.test.ts +++ b/backend/tests/features/battle/ledger/snapshot.builder.test.ts @@ -7,7 +7,10 @@ vi.mock('@config/env', () => ({ // Equipment resolution has its own coverage; stubbed to ungeared so these stay about // merging the roster with progression. vi.mock('@features/inventory', () => ({ - getPetEquipment: vi.fn(async () => []), + getPetEquipmentForCombat: vi.fn(async () => []), + // Real, because the builder is expected to let it through untouched and a stub class + // would make `rejects.toThrow(ItemCatalogError)` pass against any error at all. + ItemCatalogError: class ItemCatalogError extends Error {}, })); vi.mock('@config/prisma', () => ({ @@ -19,7 +22,7 @@ vi.mock('@config/prisma', () => ({ import { prisma } from '@config/prisma'; import { buildPetSnapshot } from '@features/battle/ledger'; -import { getPetEquipment } from '@features/inventory'; +import { getPetEquipmentForCombat, ItemCatalogError } from '@features/inventory'; const ROSTER_ROW = { chain: 'evm', @@ -239,26 +242,26 @@ describe('freezing equipment (roadmap §4)', () => { } as never); }); + // Already narrowed to the modifier by `getPetEquipmentForCombat`, which is also where + // an uncatalogued or unreadable item is refused outright. That refusal is covered in + // `inventory.service.test.ts`; by the time the builder sees a row it is priceable. const BLADE = { slot: 0, - item: { - itemType: '1', key: 'iron_fang', category: 'equipment', slot: 0, rarity: 1, - effect: { kind: 'stat_bonus' as const, hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }, - name: 'Iron Fang', description: '', - }, + itemType: '1', + key: 'iron_fang', + bonus: { kind: 'stat_bonus' as const, hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }, }; const PLATE = { slot: 1, - item: { - ...BLADE.item, itemType: '11', key: 'scale_mail', slot: 1, - effect: { kind: 'stat_bonus' as const, hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }, - }, + itemType: '11', + key: 'scale_mail', + bonus: { kind: 'stat_bonus' as const, hp: 30, atk: 0, def: 10, int: 0, mdef: 0 }, }; // Resolved, not referenced: unequipping after acceptance must not change a committed // fight, exactly as a level-up between acceptance and settlement must not. it('freezes the resolved modifiers alongside the item type', async () => { - vi.mocked(getPetEquipment).mockResolvedValue([BLADE] as never); + vi.mocked(getPetEquipmentForCombat).mockResolvedValue([BLADE] as never); const snapshot = await buildPetSnapshot('eip155:84532', '1'); @@ -270,26 +273,26 @@ describe('freezing equipment (roadmap §4)', () => { // Slot order is part of the snapshot digest, and assertPetSnapshot refuses to sort // silently, so the builder has to hand it over already ordered. it('orders slots ascending whatever order the rows arrive in', async () => { - vi.mocked(getPetEquipment).mockResolvedValue([PLATE, BLADE] as never); + vi.mocked(getPetEquipmentForCombat).mockResolvedValue([PLATE, BLADE] as never); const snapshot = await buildPetSnapshot('eip155:84532', '1'); expect(snapshot!.equipment?.map((e) => e.slot)).toEqual([0, 1]); }); - // An entry claiming an item was worn and did nothing reads as a bug rather than a fact. - it('leaves out an equipped item with no combat effect', async () => { - vi.mocked(getPetEquipment).mockResolvedValue([ - { slot: 0, item: { ...BLADE.item, effect: null } }, - ] as never); + // Propagated, not caught: acceptance turns this into an `item-catalog-stale` rejection, + // and a snapshot builder that swallowed it would hand back a pet fighting bare while + // chain state says otherwise. + it('lets a catalog failure reach the caller', async () => { + vi.mocked(getPetEquipmentForCombat).mockRejectedValue(new ItemCatalogError('uncatalogued item type 99')); - expect((await buildPetSnapshot('eip155:84532', '1'))!.equipment).toBeUndefined(); + await expect(buildPetSnapshot('eip155:84532', '1')).rejects.toThrow(ItemCatalogError); }); // Omitted rather than empty, so an ungeared snapshot's stored JSON is identical to what // it was before equipment existed. it('omits the field entirely for an ungeared pet', async () => { - vi.mocked(getPetEquipment).mockResolvedValue([] as never); + vi.mocked(getPetEquipmentForCombat).mockResolvedValue([] as never); expect((await buildPetSnapshot('eip155:84532', '1'))!.equipment).toBeUndefined(); }); diff --git a/backend/tests/features/battle/ledger/transitions.test.ts b/backend/tests/features/battle/ledger/transitions.test.ts index edc5e1c0..76164888 100644 --- a/backend/tests/features/battle/ledger/transitions.test.ts +++ b/backend/tests/features/battle/ledger/transitions.test.ts @@ -17,7 +17,7 @@ const tx = { vi.mock('@config/prisma', () => ({ prisma: { $transaction: vi.fn(), - battleLedger: { findUnique: vi.fn() }, + battleLedger: { findUnique: vi.fn(), findMany: vi.fn() }, }, })); @@ -26,9 +26,11 @@ import { prisma } from '@config/prisma'; import { abandonBattle, applyTransition, + expireOrphanedAccepts, failBattle, IllegalTransitionError, openBattle, + shouldReleaseLocks, OUTBOX_TOPICS, sortPetIds, } from '@features/battle/ledger'; @@ -293,3 +295,53 @@ describe('abandonBattle', () => { }); }); }); + + +/** + * Pets held by a battle that never left `accepted` are released. + * + * Nothing else can release them. Locks are freed by reaching a terminal state, the + * dead-letter path calls `abandonBattle` which declines because `accepted` cannot forfeit, + * and until this existed nothing ever wrote `expired` at all. So a crash between accept and + * commit locked both pets permanently, and the only symptom was a unique-constraint error + * on `pet_battle_lock` the next time either tried to fight. + */ +describe('expireOrphanedAccepts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('expires an accepted battle older than the cutoff, which frees its locks', async () => { + vi.mocked(prisma.battleLedger.findMany).mockResolvedValue([{ battleId: 'btl_old' }] as never); + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ state: 'accepted' } as never); + vi.mocked(prisma.$transaction).mockImplementation((async (fn: (tx: unknown) => Promise) => + fn({ + battleLedger: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, + petBattleLock: { deleteMany: vi.fn().mockResolvedValue({ count: 2 }) }, + battleOutbox: { createMany: vi.fn() }, + })) as never); + + expect(await expireOrphanedAccepts(10_000)).toEqual({ expired: 1 }); + + // `expired` is terminal, which is what makes `shouldReleaseLocks` drop the rows. + expect(shouldReleaseLocks('expired' as never)).toBe(true); + }); + + it('only looks at battles still in accepted', async () => { + vi.mocked(prisma.battleLedger.findMany).mockResolvedValue([] as never); + + await expireOrphanedAccepts(10_000); + + const { where } = vi.mocked(prisma.battleLedger.findMany).mock.calls[0]![0]!; + expect(where).toMatchObject({ state: 'accepted' }); + // A cutoff in the past, never "everything": expiring a battle mid-accept would + // strand a player who has already signed. + expect((where as { createdAt: { lt: Date } }).createdAt.lt.getTime()).toBeLessThan(10_000 * 1000); + }); + + it('does nothing when there are no orphans', async () => { + vi.mocked(prisma.battleLedger.findMany).mockResolvedValue([] as never); + expect(await expireOrphanedAccepts(10_000)).toEqual({ expired: 0 }); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/features/battle/signer/signer.kms.test.ts b/backend/tests/features/battle/signer/signer.kms.test.ts new file mode 100644 index 00000000..0c751a8d --- /dev/null +++ b/backend/tests/features/battle/signer/signer.kms.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; + +import { ethers } from 'ethers'; + +import { createKmsSigner, createKmsSignerFromPort, type KmsKeyPort } from '@features/battle/signer/signer.kms'; +import { + extractUncompressedPublicKey, + parseDerSignature, + toEthereumSignature, +} from '@features/battle/signer/signer.kms.crypto'; + +/** + * The provider-independent half of KMS signing (§G). + * + * Exercised against a real secp256k1 key rather than fixtures, with the DER encoding a KMS + * would return built here. Fixtures would pass while proving nothing about the two things + * that actually break: DER integers are variable-length, and `v` is not in the signature at + * all. + */ + +const wallet = new ethers.Wallet('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'); +const signingKey = new ethers.SigningKey(wallet.privateKey); +const ADDRESS = wallet.address.toLowerCase(); + +/** Encodes one DER INTEGER, minimally, with the sign byte ECDSA integers need. */ +function derInteger(value: bigint): number[] { + let hex = value.toString(16); + if (hex.length % 2) hex = `0${hex}`; + const bytes = [...Buffer.from(hex, 'hex')]; + // DER integers are signed: a leading byte >= 0x80 would read as negative, so a zero is + // prepended. This is exactly the case a fixed-width slice gets wrong. + if ((bytes[0] ?? 0) >= 0x80) bytes.unshift(0); + return [0x02, bytes.length, ...bytes]; +} + +/** A KMS-shaped DER signature over `digest`, as AWS or GCP would return it. */ +function derSignatureFor(digest: Uint8Array, options: { highS?: boolean } = {}): Uint8Array { + const sig = signingKey.sign(digest); + const n = BigInt('0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'); + const s = options.highS ? n - BigInt(sig.s) : BigInt(sig.s); + const body = [...derInteger(BigInt(sig.r)), ...derInteger(s)]; + return Uint8Array.from([0x30, body.length, ...body]); +} + +const digestOf = (text: string): Uint8Array => ethers.getBytes(ethers.id(text)); + +describe('parseDerSignature', () => { + it('reads r and s back from a real signature', () => { + const digest = digestOf('battle'); + const expected = signingKey.sign(digest); + + const { r, s } = parseDerSignature(derSignatureFor(digest)); + + expect(ethers.toBeHex(r, 32)).toBe(expected.r); + expect(ethers.toBeHex(s, 32)).toBe(expected.s); + }); + + it('refuses input that is not a DER sequence', () => { + expect(() => parseDerSignature(Uint8Array.from([0x02, 0x01, 0x00]))).toThrow(/SEQUENCE/); + }); + + it('refuses trailing bytes rather than ignoring them', () => { + const good = derSignatureFor(digestOf('battle')); + const padded = Uint8Array.from([...good, 0x00]); + // The length check catches it first; either way it must not parse. + expect(() => parseDerSignature(padded)).toThrow(); + }); +}); + +describe('toEthereumSignature', () => { + /** + * The recovery id is the whole reason this function needs the address: `v` is not part + * of an ECDSA signature and no KMS returns it, so it is found by trying both. + */ + it('produces a signature that recovers to the signing key', () => { + const digest = digestOf('receipt'); + + const signature = toEthereumSignature(derSignatureFor(digest), digest, ADDRESS); + + expect(ethers.recoverAddress(digest, signature).toLowerCase()).toBe(ADDRESS); + }); + + it('matches what an in-process signer would have produced', () => { + // The KMS path and the local path must be indistinguishable to a verifier, or a + // rotation between backends would change what receipts look like. + const digest = digestOf('same-digest'); + + expect(toEthereumSignature(derSignatureFor(digest), digest, ADDRESS)).toBe( + signingKey.sign(digest).serialized, + ); + }); + + // EIP-2 rejects the high half, and a KMS has no reason to avoid it, so roughly half of + // all signatures would be refused on chain without this. + it('normalizes a high-s signature into the canonical low-s form', () => { + const digest = digestOf('malleable'); + + const normalized = toEthereumSignature(derSignatureFor(digest, { highS: true }), digest, ADDRESS); + + expect(ethers.recoverAddress(digest, normalized).toLowerCase()).toBe(ADDRESS); + expect(normalized).toBe(signingKey.sign(digest).serialized); + }); + + it('refuses a signature from a key other than the published one', () => { + // A mismatch means the KMS signed with different material than the registry + // publishes, which would make every receipt unverifiable. Better to fail loudly. + const digest = digestOf('wrong-key'); + const other = ethers.Wallet.createRandom().address; + + expect(() => toEthereumSignature(derSignatureFor(digest), digest, other)).toThrow( + /does not recover to the published key/, + ); + }); + + it('refuses a digest that is not 32 bytes', () => { + expect(() => toEthereumSignature(derSignatureFor(digestOf('x')), new Uint8Array(31), ADDRESS)).toThrow( + /32-byte digest/, + ); + }); +}); + +describe('extractUncompressedPublicKey', () => { + it('finds the point at the end of an SPKI wrapper', () => { + // The real prefix is an AlgorithmIdentifier; its content does not matter here, only + // that the point is read from the end and validated. + const spki = Uint8Array.from([ + ...Buffer.from('3056301006072a8648ce3d020106052b8104000a034200', 'hex'), + ...ethers.getBytes(signingKey.publicKey), + ]); + + expect(extractUncompressedPublicKey(spki)).toBe(signingKey.publicKey); + }); + + it('refuses DER that does not end in an uncompressed point', () => { + expect(() => extractUncompressedPublicKey(new Uint8Array(80))).toThrow(/uncompressed/); + }); +}); + +describe('createKmsSignerFromPort', () => { + const port: KmsKeyPort = { + provider: 'test-kms', + getPublicKeyDer: async () => + Uint8Array.from([ + ...Buffer.from('3056301006072a8648ce3d020106052b8104000a034200', 'hex'), + ...ethers.getBytes(signingKey.publicKey), + ]), + signDigest: async (digest) => derSignatureFor(digest), + }; + + /** + * The address is derived from what the KMS publishes, never configured. A configured + * one is a second copy of the truth, and if it drifted the signer would publish one key + * while signing with another. + */ + it('derives its published key from the KMS rather than configuration', async () => { + const backend = await createKmsSignerFromPort({ port, keyId: 'kms-1', notBefore: 1000 }); + + expect(backend.key.address).toBe(ADDRESS); + expect(backend.key.publicKey).toBe(signingKey.publicKey); + expect(backend.key.algorithm).toBe('secp256k1'); + }); + + it('signs a digest into a signature that recovers to that key', async () => { + const backend = await createKmsSignerFromPort({ port, keyId: 'kms-1', notBefore: 1000 }); + const digest = digestOf('end-to-end'); + + const signature = await backend.sign(digest); + + expect(ethers.recoverAddress(digest, signature).toLowerCase()).toBe(backend.key.address); + }); + + it('refuses to sign anything that is not a 32-byte digest', async () => { + const backend = await createKmsSignerFromPort({ port, keyId: 'kms-1', notBefore: 1000 }); + + await expect(backend.sign(new Uint8Array(20))).rejects.toThrow(/32-byte digest/); + }); +}); + +describe('createKmsSigner provider dispatch', () => { + /** + * An unknown provider must never fall back. + * + * A fallback here would be an in-process key wearing a KMS's name, and a deployment + * could then run believing the material was isolated while it sat in the environment — + * the single thing §G's KMS requirement exists to prevent. `configureSigner` records + * the failure and refuses to sign, which blocks a launch rather than degrading one. + */ + it('refuses a provider it has no adapter for', async () => { + await expect( + createKmsSigner({ provider: 'gcp-kms', keyId: 'k', kmsKeyId: 'k', notBefore: 0 }), + ).rejects.toThrow(/has no adapter/); + }); + + it('names the providers it does support, so the error is actionable', async () => { + await expect( + createKmsSigner({ provider: 'nonsense', keyId: 'k', kmsKeyId: 'k', notBefore: 0 }), + ).rejects.toThrow(/aws-kms/); + }); + + // The KMS identifier is an ARN carrying an account id, and a receipt records which key + // signed it permanently. Conflating the two would write infrastructure into every + // receipt and break the moment the key was re-imported. + it('keeps the receipt key id separate from the provider key id', async () => { + const backend = await createKmsSignerFromPort({ + port: { + provider: 'test-kms', + getPublicKeyDer: async () => + Uint8Array.from([ + ...Buffer.from('3056301006072a8648ce3d020106052b8104000a034200', 'hex'), + ...ethers.getBytes(signingKey.publicKey), + ]), + signDigest: async (digest) => derSignatureFor(digest), + }, + keyId: 'battle-signer-2026', + notBefore: 0, + }); + + expect(backend.key.keyId).toBe('battle-signer-2026'); + }); +}); diff --git a/backend/tests/features/battle/signer/signer.persistence.test.ts b/backend/tests/features/battle/signer/signer.persistence.test.ts index 908451a8..67dc516f 100644 --- a/backend/tests/features/battle/signer/signer.persistence.test.ts +++ b/backend/tests/features/battle/signer/signer.persistence.test.ts @@ -2,17 +2,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const envMock = vi.hoisted(() => ({ isProduction: false, + // The signer builds one backend per served chain family (§G), so the chain list is now + // part of what configures it. One EVM chain here: a single-domain deployment, which is + // what every deployment is today. + battle: { chainIds: ['eip155:84532'] as string[] }, battleSigner: { keyId: 'battle-signer-test', privateKey: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as string | undefined, kmsProvider: undefined as string | undefined, + kmsKeyId: undefined as string | undefined, + kmsRegion: undefined as string | undefined, requiredAttesters: ['typescript-engine'] as string[], + domains: { + evm: {} as { keyId?: string; privateKey?: string; kmsKeyId?: string }, + solana: {} as { keyId?: string; privateKey?: string; kmsKeyId?: string }, + }, }, })); vi.mock('@config/env', () => ({ env: envMock })); vi.mock('@config/prisma', () => ({ - prisma: { battleSigningKey: { upsert: vi.fn(), findMany: vi.fn() } }, + prisma: { + battleSigningKey: { upsert: vi.fn(), findMany: vi.fn(), updateMany: vi.fn() }, + // Read by `retireInactiveKeys`, which dates a rotated key from its last receipt. + battleReceipt: { findFirst: vi.fn() }, + }, })); import { prisma } from '@config/prisma'; @@ -41,30 +55,47 @@ function storedRow(overrides: Record = {}) { }; } +/** + * `battleSigningKey.findMany` now serves two different queries. + * + * `retireInactiveKeys` asks for keys that have stopped signing; `loadSigningKeys` asks for + * all of them. An argument-blind mock would answer both with the same rows and hand the + * retirement pass the *active* key, which it would then close the window on — a failure + * invented entirely by the mock. Dispatching on the query keeps each answer honest. + */ +function mockStoredKeys(rows: unknown[]): void { + vi.mocked(prisma.battleSigningKey.findMany).mockImplementation((async (args: { + where?: { notAfter?: unknown }; + }) => (args?.where?.notAfter === null ? [] : rows)) as never); +} + beforeEach(() => { vi.clearAllMocks(); resetSigner(); vi.mocked(prisma.battleSigningKey.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.battleSigningKey.updateMany).mockResolvedValue({ count: 0 } as never); + vi.mocked(prisma.battleReceipt.findFirst).mockResolvedValue(null as never); + mockStoredKeys([]); }); describe('a restart must not move the active key validity window forward', () => { it('adopts the persisted notBefore instead of this process start time', async () => { // Second boot: configureSigner stamps "now", but the key really became valid at // FIRST_BOOT and every receipt signed since then was signed under it. - configureSigner(MUCH_LATER); - expect(activeSigningKey()?.notBefore).toBe(MUCH_LATER); + await configureSigner(MUCH_LATER); + expect(activeSigningKey('eip155:84532')?.notBefore).toBe(MUCH_LATER); - vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([storedRow()] as never); + mockStoredKeys([storedRow()]); await loadPersistedSigningKeys(); - expect(activeSigningKey()?.notBefore).toBe(FIRST_BOOT); + expect(activeSigningKey('eip155:84532')?.notBefore).toBe(FIRST_BOOT); }); it('keeps a receipt signed before the restart inside the published window', async () => { const signedAt = FIRST_BOOT + 500; // long before this boot - configureSigner(MUCH_LATER); - vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([storedRow()] as never); + await configureSigner(MUCH_LATER); + mockStoredKeys([storedRow()]); await loadPersistedSigningKeys(); const published = listSigningKeys().find((k) => k.keyId === 'battle-signer-test')!; @@ -74,28 +105,26 @@ describe('a restart must not move the active key validity window forward', () => it('leaves a genuinely new key at its own start time', async () => { // Nothing stored yet, so "now" is the truth rather than an artefact of restarting. - configureSigner(MUCH_LATER); - vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([] as never); + await configureSigner(MUCH_LATER); + mockStoredKeys([]); await loadPersistedSigningKeys(); - expect(activeSigningKey()?.notBefore).toBe(MUCH_LATER); + expect(activeSigningKey('eip155:84532')?.notBefore).toBe(MUCH_LATER); }); it('never moves the window earlier than the stored row claims', async () => { // A stored row from *after* this boot would be nonsense; prefer the earlier value // rather than trusting whichever number happens to be larger. - configureSigner(FIRST_BOOT); - vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue( - [storedRow({ notBefore: BigInt(MUCH_LATER) })] as never, - ); + await configureSigner(FIRST_BOOT); + mockStoredKeys([storedRow({ notBefore: BigInt(MUCH_LATER) })]); await loadPersistedSigningKeys(); - expect(activeSigningKey()?.notBefore).toBe(FIRST_BOOT); + expect(activeSigningKey('eip155:84532')?.notBefore).toBe(FIRST_BOOT); }); it('still records the active key on boot, so it is never missing from the registry', async () => { - configureSigner(MUCH_LATER); - vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([storedRow()] as never); + await configureSigner(MUCH_LATER); + mockStoredKeys([storedRow()]); await loadPersistedSigningKeys(); expect(vi.mocked(prisma.battleSigningKey.upsert)).toHaveBeenCalledTimes(1); diff --git a/backend/tests/features/battle/signer/signer.registry.test.ts b/backend/tests/features/battle/signer/signer.registry.test.ts index 1746fac1..72f045f1 100644 --- a/backend/tests/features/battle/signer/signer.registry.test.ts +++ b/backend/tests/features/battle/signer/signer.registry.test.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@config/prisma', () => ({ - prisma: { battleSigningKey: { upsert: vi.fn(), findMany: vi.fn() } }, + prisma: { + battleSigningKey: { upsert: vi.fn(), findMany: vi.fn(), updateMany: vi.fn() }, + battleReceipt: { findFirst: vi.fn() }, + }, })); import { prisma } from '@config/prisma'; -import { loadSigningKeys, persistSigningKey } from '@features/battle/signer'; +import { loadSigningKeys, persistSigningKey, retireInactiveKeys } from '@features/battle/signer'; import type { SigningKeyDescriptor } from '@features/battle/signer'; function key(overrides: Partial = {}): SigningKeyDescriptor { @@ -100,7 +103,7 @@ describe('the compromised flag is sticky', () => { describe('loading the registry', () => { it('reports the currently signing key as active', async () => { vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([row()] as never); - const keys = await loadSigningKeys('battle-signer-2026-07'); + const keys = await loadSigningKeys(new Set(['battle-signer-2026-07'])); expect(keys[0]?.status).toBe('active'); }); @@ -112,7 +115,7 @@ describe('loading the registry', () => { row({ keyId: 'current' }), ] as never); - const keys = await loadSigningKeys('current'); + const keys = await loadSigningKeys(new Set(['current'])); expect(keys.find((k) => k.keyId === 'old')?.status).toBe('rotated'); expect(keys.find((k) => k.keyId === 'current')?.status).toBe('active'); @@ -124,7 +127,7 @@ describe('loading the registry', () => { row({ keyId: 'burned', compromised: true }), ] as never); - const keys = await loadSigningKeys('burned'); + const keys = await loadSigningKeys(new Set(['burned'])); expect(keys[0]?.status).toBe('compromised'); }); @@ -135,7 +138,7 @@ describe('loading the registry', () => { row({ keyId: 'current' }), ] as never); - const keys = await loadSigningKeys('current'); + const keys = await loadSigningKeys(new Set(['current'])); expect(keys).toHaveLength(2); expect(keys.find((k) => k.keyId === 'old')?.notAfter).toBe(1_760_000_000); @@ -143,13 +146,92 @@ describe('loading the registry', () => { it('returns nothing when no key was ever recorded', async () => { vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([] as never); - await expect(loadSigningKeys(null)).resolves.toEqual([]); + await expect(loadSigningKeys(new Set())).resolves.toEqual([]); }); it('orders by when each key became valid', async () => { vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([] as never); - await loadSigningKeys(null); + await loadSigningKeys(new Set()); const call = vi.mocked(prisma.battleSigningKey.findMany).mock.calls[0]![0] as { orderBy: unknown }; expect(call.orderBy).toEqual({ notBefore: 'asc' }); }); }); + + +/** + * Publishing a validity period for a key that has stopped signing (§G). + * + * §G asks for published validity periods and the verifier already enforces them, refusing a + * receipt created outside `[notBefore, notAfter]`. Nothing ever set `notAfter`, so a rotated + * key stayed published as "valid indefinitely" and would happily vouch for a receipt dated + * long after it was retired — the exact window that check exists to close. + */ +describe('retireInactiveKeys', () => { + beforeEach(() => { + vi.mocked(prisma.battleSigningKey.updateMany).mockResolvedValue({ count: 1 } as never); + }); + + /** + * Dated from evidence, not from the clock. The last receipt the key signed is the + * strongest claim the data supports, and it is safe in the direction that matters: + * every receipt the key legitimately produced is at or before it, so stamping can never + * retroactively invalidate one. + */ + it('ends the window at the last receipt the key signed', async () => { + vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([ + { keyId: 'old', notBefore: 1000n }, + ] as never); + vi.mocked(prisma.battleReceipt.findFirst).mockResolvedValue({ createdAt: 4242n } as never); + + expect(await retireInactiveKeys(new Set(['current']))).toEqual({ retired: 1 }); + expect(vi.mocked(prisma.battleSigningKey.updateMany).mock.calls[0]![0]).toMatchObject({ + where: { keyId: 'old', notAfter: null }, + data: { notAfter: 4242n }, + }); + }); + + // A zero-length window is the honest description of a key that was configured and never + // used, and it is what stops such a key vouching for anything at all. + it('gives a key that never signed a zero-length window', async () => { + vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([ + { keyId: 'never-used', notBefore: 1000n }, + ] as never); + vi.mocked(prisma.battleReceipt.findFirst).mockResolvedValue(null as never); + + await retireInactiveKeys(new Set(['current'])); + + expect(vi.mocked(prisma.battleSigningKey.updateMany).mock.calls[0]![0]).toMatchObject({ + data: { notAfter: 1000n }, + }); + }); + + it('never considers a key that is still signing', async () => { + vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([] as never); + + await retireInactiveKeys(new Set(['current', 'current-solana'])); + + const { where } = vi.mocked(prisma.battleSigningKey.findMany).mock.calls.at(-1)![0]!; + expect(where).toMatchObject({ notAfter: null }); + expect((where as { keyId: { notIn: string[] } }).keyId.notIn.sort()).toEqual([ + 'current', + 'current-solana', + ]); + }); + + /** + * Guarded on the window still being open, so two processes booting together produce one + * stamp. It also protects a window an operator set deliberately during a compromise, + * where the recorded time is a decision rather than an observation and must not be + * overwritten by a later boot's guess. + */ + it('only writes where no end has been recorded yet', async () => { + vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([ + { keyId: 'old', notBefore: 1000n }, + ] as never); + vi.mocked(prisma.battleReceipt.findFirst).mockResolvedValue({ createdAt: 4242n } as never); + vi.mocked(prisma.battleSigningKey.updateMany).mockResolvedValue({ count: 0 } as never); + + // Lost the race, so it is not counted as retired by this process. + expect(await retireInactiveKeys(new Set(['current']))).toEqual({ retired: 0 }); + }); +}); diff --git a/backend/tests/features/battle/signer/signer.service.test.ts b/backend/tests/features/battle/signer/signer.service.test.ts index 68d4c6a4..10d4f8b3 100644 --- a/backend/tests/features/battle/signer/signer.service.test.ts +++ b/backend/tests/features/battle/signer/signer.service.test.ts @@ -27,11 +27,21 @@ const DEV_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b786 // to exercise production refusal and the attester list. const envMock = vi.hoisted(() => ({ isProduction: false, + // The signer builds one backend per served chain family (§G), so the chain list is now + // part of what configures it. One EVM chain here: a single-domain deployment, which is + // what every deployment is today. + battle: { chainIds: ['eip155:84532'] as string[] }, battleSigner: { keyId: 'battle-signer-test', privateKey: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as string | undefined, kmsProvider: undefined as string | undefined, + kmsKeyId: undefined as string | undefined, + kmsRegion: undefined as string | undefined, requiredAttesters: ['typescript-engine'] as string[], + domains: { + evm: {} as { keyId?: string; privateKey?: string; kmsKeyId?: string }, + solana: {} as { keyId?: string; privateKey?: string; kmsKeyId?: string }, + }, }, })); @@ -179,7 +189,7 @@ describe('signing a commitment', () => { // Real ECDSA, so the published key is checked to be the one that actually signs. const result = await sign({ kind: 'commitment', commitment: COMMITMENT }, NOW); const recovered = ethers.recoverAddress(result.digest, result.signature); - expect(recovered.toLowerCase()).toBe(activeSigningKey()!.address); + expect(recovered.toLowerCase()).toBe(activeSigningKey('eip155:84532')!.address); }); it('signs the digest with no message prefix', async () => { @@ -288,7 +298,7 @@ describe('backend selection', () => { envMock.isProduction = true; configureSigner(NOW); - expect(activeSigningKey()).toBeNull(); + expect(activeSigningKey('eip155:84532')).toBeNull(); await expect(sign({ kind: 'commitment', commitment: COMMITMENT }, NOW)).rejects.toMatchObject({ reason: 'signer-not-configured', }); @@ -298,7 +308,7 @@ describe('backend selection', () => { envMock.battleSigner.kmsProvider = 'aws-kms'; configureSigner(NOW); - expect(activeSigningKey()).toBeNull(); + expect(activeSigningKey('eip155:84532')).toBeNull(); await expect(sign({ kind: 'commitment', commitment: COMMITMENT }, NOW)).rejects.toBeInstanceOf( SignerRefusedError, ); @@ -315,7 +325,7 @@ describe('backend selection', () => { describe('key registry', () => { it('publishes the active key', () => { - const key = activeSigningKey()!; + const key = activeSigningKey('eip155:84532')!; expect(key.algorithm).toBe('secp256k1'); expect(key.status).toBe('active'); expect(key.notAfter).toBeNull(); @@ -361,3 +371,78 @@ describe('audit log', () => { expect(entries.at(-1)!.detail).toContain('typescript-engine'); }); }); + +/** + * Separate keys per reward domain (§G, threat T4). + * + * §G: "Separate keys for EVM and Solana reward domains." The point is blast radius — one + * key signing both means a compromise of either is a compromise of both, and the receipts + * of one chain stop being evidence about anything. + */ +describe('per-domain signing keys', () => { + const EVM_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; + const SOLANA_KEY = `0x${'22'.repeat(32)}`; + + beforeEach(() => { + resetSigner(); + envMock.battle.chainIds = ['eip155:84532']; + envMock.battleSigner.domains = { evm: {}, solana: {} }; + envMock.battleSigner.privateKey = EVM_KEY; + }); + + // A single-domain deployment has nothing to separate, so sharing the top-level config + // is not a compromise of anything. Every deployment today is this one. + it('uses the shared key when only one domain is served', async () => { + await configureSigner(1000); + + expect(activeSigningKey('eip155:84532')).not.toBeNull(); + expect(listSigningKeys()).toHaveLength(1); + }); + + /** + * The refusal that makes the separation real. A fallback here would silently hand both + * chains one key while the deployment looked correctly configured — exactly the blast + * radius T4 describes, with nothing to notice it by. + */ + it('refuses to start when two domains are served and one has no key of its own', async () => { + envMock.battle.chainIds = ['eip155:84532', 'solana:devnet']; + + await configureSigner(1000); + + expect(activeSigningKey('eip155:84532')).toBeNull(); + expect(activeSigningKey('solana:devnet')).toBeNull(); + }); + + it('configures both domains when each names its own key', async () => { + envMock.battle.chainIds = ['eip155:84532', 'solana:devnet']; + envMock.battleSigner.domains = { + evm: { keyId: 'battle-signer-evm', privateKey: EVM_KEY }, + solana: { keyId: 'battle-signer-solana', privateKey: SOLANA_KEY }, + }; + + await configureSigner(1000); + + const evm = activeSigningKey('eip155:84532'); + const solana = activeSigningKey('solana:devnet'); + expect(evm?.keyId).toBe('battle-signer-evm'); + expect(solana?.keyId).toBe('battle-signer-solana'); + // Different keys, which is the entire property: same material would satisfy every + // other assertion here while providing none of the isolation. + expect(evm?.address).not.toBe(solana?.address); + }); + + it('publishes every domain active key, so a verifier can attribute either', async () => { + envMock.battle.chainIds = ['eip155:84532', 'solana:devnet']; + envMock.battleSigner.domains = { + evm: { keyId: 'battle-signer-evm', privateKey: EVM_KEY }, + solana: { keyId: 'battle-signer-solana', privateKey: SOLANA_KEY }, + }; + + await configureSigner(1000); + + expect(listSigningKeys().map((key) => key.keyId).sort()).toEqual([ + 'battle-signer-evm', + 'battle-signer-solana', + ]); + }); +}); diff --git a/backend/tests/features/battle/worker/compute.worker.test.ts b/backend/tests/features/battle/worker/compute.worker.test.ts index 87e22d56..4417e43c 100644 --- a/backend/tests/features/battle/worker/compute.worker.test.ts +++ b/backend/tests/features/battle/worker/compute.worker.test.ts @@ -9,10 +9,16 @@ vi.mock('@config/prisma', () => ({ }, })); -vi.mock('@features/battle/ledger', () => ({ +// The snapshot codec is pure and stays real. Stubbing it would let these tests pass +// against a decoder production does not use, which is exactly how the signing worker's +// schemaVersion bug survived a green suite. +vi.mock('@features/battle/ledger', async () => ({ applyTransition: vi.fn(), completeOutbox: vi.fn(), OUTBOX_TOPICS: { verify: 'verify' }, + ...(await vi.importActual( + '@features/battle/ledger/snapshot.codec', + )), })); vi.mock('@ws/battleRoomSocket', () => ({ diff --git a/backend/tests/features/battle/worker/publish.worker.test.ts b/backend/tests/features/battle/worker/publish.worker.test.ts index bb12db53..e6788caa 100644 --- a/backend/tests/features/battle/worker/publish.worker.test.ts +++ b/backend/tests/features/battle/worker/publish.worker.test.ts @@ -32,7 +32,6 @@ vi.mock('@features/battle/ledger', () => ({ verify: 'verify', sign: 'sign', publish: 'publish', - batch: 'batch', }, })); vi.mock('@ws/battleRoomSocket', () => ({ notifyBattleRoomIfPresent: vi.fn() })); diff --git a/backend/tests/features/battle/worker/runner.test.ts b/backend/tests/features/battle/worker/runner.test.ts index 99f49427..af6cd263 100644 --- a/backend/tests/features/battle/worker/runner.test.ts +++ b/backend/tests/features/battle/worker/runner.test.ts @@ -16,7 +16,6 @@ vi.mock('@features/battle/ledger', () => ({ verify: 'verify', sign: 'sign', publish: 'publish', - batch: 'batch', }, })); @@ -99,10 +98,12 @@ describe('dispatch', () => { }); it('dead-letters a message whose topic has no handler, rather than leaving it claimed forever', async () => { - // `batch` is a declared topic with no handler: batching aggregates across many - // receipts on its own schedule rather than per battle, so nothing enqueues it. + // Unreachable while the claim list is derived from `HANDLERS` — the dispatcher can + // only be handed a topic it registered. This pins the fallback for the day those two + // stop being the same object (a worker claiming a topic set from config, say), since + // the alternative is a message claimed and then silently left claimed forever. vi.mocked(claimOutbox).mockResolvedValue([ - { id: 'm1', battleId: 'btl_1', topic: 'batch', payload: {}, attempts: 1 }, + { id: 'm1', battleId: 'btl_1', topic: 'reticulate-splines', payload: {}, attempts: 1 }, ]); await runBattleWorkerOnce('worker-a', NOW); diff --git a/backend/tests/features/battle/worker/sign.worker.test.ts b/backend/tests/features/battle/worker/sign.worker.test.ts index c45d7e4d..84b2cd60 100644 --- a/backend/tests/features/battle/worker/sign.worker.test.ts +++ b/backend/tests/features/battle/worker/sign.worker.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + bonusFromEquipment, computeProgression, deriveBattleSeed, hashBattleReceipt, @@ -10,6 +11,7 @@ import { QUICKNET, roundTime, simulate, + SNAPSHOT_SCHEMA_VERSION, SOURCE_DEFAULT_RULESET, } from '@cryptopets/protocol'; @@ -31,10 +33,16 @@ vi.mock('@config/prisma', () => ({ }, })); -vi.mock('@features/battle/ledger', () => ({ +// The snapshot codec is pure and stays real. Stubbing it would let these tests pass +// against a decoder production does not use, which is exactly how the schemaVersion bug +// this file now covers survived a green suite. +vi.mock('@features/battle/ledger', async () => ({ applyTransition: vi.fn(), completeOutbox: vi.fn(), OUTBOX_TOPICS: { publish: 'publish' }, + ...(await vi.importActual( + '@features/battle/ledger/snapshot.codec', + )), })); vi.mock('@features/battle/signer', async () => { @@ -42,6 +50,7 @@ vi.mock('@features/battle/signer', async () => { return { activeSigningKey: vi.fn(), sign: vi.fn(), + signerBackendError: vi.fn(() => null), SignerRefusedError: actual.SignerRefusedError, }; }); @@ -60,7 +69,12 @@ vi.mock('@features/inventory', () => ({ import { prisma } from '@config/prisma'; import { applyTransition, completeOutbox } from '@features/battle/ledger'; import { recordBattleDrops } from '@features/inventory'; -import { activeSigningKey, sign, SignerRefusedError } from '@features/battle/signer'; +import { + activeSigningKey, + sign, + signerBackendError, + SignerRefusedError, +} from '@features/battle/signer'; import { processSignMessage } from '@features/battle/worker'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; @@ -92,79 +106,117 @@ const DEFENDER = { lastOpponentId: '1', streak: 2, }; -const SNAPSHOT = { domain: DOMAIN, attacker: ATTACKER, defender: DEFENDER, takenAt: NOW - 10 }; - -// The real hash of the snapshot as production code will deserialize and hash it -// (real bigints, not the decimal strings JSON storage carries) — the seed check -// inside assertBattleReceipt recomputes this independently, so the fixture has to -// agree with it or every "happy path" case fails on the seed check alone. -const snapshotHash = hashBattleSnapshot({ - domain: DOMAIN as never, - attacker: { ...ATTACKER, petId: 1n, dna: BigInt(ATTACKER.dna), lastOpponentId: 0n, sourceVersion: 1000n } as never, - defender: { ...DEFENDER, petId: 2n, dna: BigInt(DEFENDER.dna), lastOpponentId: 1n, sourceVersion: 1000n } as never, - takenAt: SNAPSHOT.takenAt, -}); +/** One equipped item, in the decimal-string form JSON storage carries. */ +type StoredGear = { slot: number; itemType: string; hp: number; atk: number; def: number; int: number; mdef: number }; const beaconRandomness = '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd'; -const seed = deriveBattleSeed({ - domain: DOMAIN as never, - drandRandomness: beaconRandomness, - battleId: 'btl_1', - snapshotHash, - rulesetHash: RULESET_HASH, -}); -const outcome = simulate( - BigInt(ATTACKER.dna), - ATTACKER.rarity, - ATTACKER.level, - ATTACKER.skill, - BigInt(DEFENDER.dna), - DEFENDER.rarity, - DEFENDER.level, - DEFENDER.skill, - seed.value, - SOURCE_DEFAULT_RULESET.skillConfig, -); -const combatLogHash = hashCombatLog(outcome); -const progression = computeProgression( - { + +/** + * A verified battle row, exactly as acceptance and the compute worker would have left it. + * + * Everything downstream is derived rather than pinned: the snapshot hash feeds the seed, + * the seed feeds the fight, and the fight feeds the progression, so a fixture that + * disagrees with production anywhere in that chain fails the seed check inside + * `assertBattleReceipt` rather than passing quietly. + * + * `schemaVersion` is declared, because acceptance declares it. Leaving it off made every + * fixture here a version 1 snapshot on both sides of the comparison, which is what let the + * signing worker hash real battles at a layout acceptance never used and still pass. + * + * The decoded form is spelled out rather than obtained from `decodeStoredSnapshot`, also + * deliberately: this is the value the codec is checked against, so deriving it from the + * codec would let a decoder that drops a field agree with itself. + */ +function buildFixture(gear?: { attacker?: StoredGear[]; defender?: StoredGear[] }) { + const attackerStored = { ...ATTACKER, ...(gear?.attacker && { equipment: gear.attacker }) }; + const defenderStored = { ...DEFENDER, ...(gear?.defender && { equipment: gear.defender }) }; + const stored = { + domain: DOMAIN, + attacker: attackerStored, + defender: defenderStored, + takenAt: NOW - 10, + schemaVersion: SNAPSHOT_SCHEMA_VERSION, + }; + + const decodeGear = (equipment?: StoredGear[]) => + equipment?.map((entry) => ({ ...entry, itemType: BigInt(entry.itemType) })); + const decoded = { domain: DOMAIN as never, - attacker: { ...ATTACKER, petId: 1n, dna: BigInt(ATTACKER.dna), lastOpponentId: 0n, sourceVersion: 1000n } as never, - defender: { ...DEFENDER, petId: 2n, dna: BigInt(DEFENDER.dna), lastOpponentId: 1n, sourceVersion: 1000n } as never, - takenAt: SNAPSHOT.takenAt, - }, - outcome.result.firstWins, -); -const serializedProgression = JSON.parse( - JSON.stringify(progression, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)), -); - -const BATTLE = { - battleId: 'btl_1', - chainId: 'eip155:84532', - deploymentId: 'base-sepolia-live', - state: 'verified', - intentHash: `0x${'aa'.repeat(32)}`, - authorizationHash: `0x${'bb'.repeat(32)}`, - attackerPetId: '1', - defenderPetId: '2', - snapshot: SNAPSHOT, - seed: seed.hex, - rulesetHash: RULESET_HASH, - rulesetVersion: SOURCE_DEFAULT_RULESET.version, - drandChainHash: QUICKNET.chainHash, - drandRound: BigInt(1000), - beaconSignature: - '0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39', - beaconRandomness, - attackerWon: outcome.result.firstWins, - rounds: outcome.result.rounds, - winnerHpRemaining: outcome.result.winnerHpRemaining, - combatLogHash, - progression: serializedProgression, - verificationDetail: { mismatches: [] }, - roomId: 'room_1', -}; + attacker: { + ...attackerStored, + petId: BigInt(ATTACKER.petId), + dna: BigInt(ATTACKER.dna), + lastOpponentId: BigInt(ATTACKER.lastOpponentId), + sourceVersion: BigInt(ATTACKER.sourceVersion), + ...(gear?.attacker && { equipment: decodeGear(gear.attacker) }), + } as never, + defender: { + ...defenderStored, + petId: BigInt(DEFENDER.petId), + dna: BigInt(DEFENDER.dna), + lastOpponentId: BigInt(DEFENDER.lastOpponentId), + sourceVersion: BigInt(DEFENDER.sourceVersion), + ...(gear?.defender && { equipment: decodeGear(gear.defender) }), + } as never, + takenAt: stored.takenAt, + schemaVersion: SNAPSHOT_SCHEMA_VERSION, + }; + + const snapshotHash = hashBattleSnapshot(decoded); + const seed = deriveBattleSeed({ + domain: DOMAIN as never, + drandRandomness: beaconRandomness, + battleId: 'btl_1', + snapshotHash, + rulesetHash: RULESET_HASH, + }); + const outcome = simulate( + BigInt(ATTACKER.dna), + ATTACKER.rarity, + ATTACKER.level, + ATTACKER.skill, + BigInt(DEFENDER.dna), + DEFENDER.rarity, + DEFENDER.level, + DEFENDER.skill, + seed.value, + SOURCE_DEFAULT_RULESET.skillConfig, + bonusFromEquipment(decodeGear(gear?.attacker)), + bonusFromEquipment(decodeGear(gear?.defender)), + ); + const progression = computeProgression(decoded, outcome.result.firstWins); + + return { + battleId: 'btl_1', + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + state: 'verified', + intentHash: `0x${'aa'.repeat(32)}`, + authorizationHash: `0x${'bb'.repeat(32)}`, + attackerPetId: '1', + defenderPetId: '2', + snapshot: stored, + seed: seed.hex, + rulesetHash: RULESET_HASH, + rulesetVersion: SOURCE_DEFAULT_RULESET.version, + drandChainHash: QUICKNET.chainHash, + drandRound: BigInt(1000), + beaconSignature: + '0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39', + beaconRandomness, + attackerWon: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + combatLogHash: hashCombatLog(outcome), + progression: JSON.parse(JSON.stringify(progression, (_k, v) => (typeof v === 'bigint' ? v.toString() : v))), + verificationDetail: { mismatches: [] }, + roomId: 'room_1', + }; +} + +const BATTLE = buildFixture(); +/** Who won, which several assertions branch on. Read off the fixture rather than recomputed. */ +const outcome = { result: { firstWins: BATTLE.attackerWon } }; const MESSAGE = { id: 'msg_1', battleId: 'btl_1', topic: 'sign', payload: {}, attempts: 1 }; @@ -335,6 +387,57 @@ describe('the happy path', () => { }); }); +describe('equipment survives into the receipt (roadmap §4)', () => { + // A steel sword and reinforced plate from the shipped catalog, on the attacker only, so + // an assertion about the defender's absent list is meaningful rather than symmetric. + const GEAR = [ + { slot: 0, itemType: '3', hp: 0, atk: 22, def: 0, int: 0, mdef: 0 }, + { slot: 1, itemType: '12', hp: 45, atk: 0, def: 16, int: 0, mdef: 6 }, + ]; + const GEARED = buildFixture({ attacker: GEAR }); + + beforeEach(() => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(GEARED as never); + }); + + it('signs a geared battle, whose seed only derives from a version 2 snapshot', async () => { + // The whole failure mode in one assertion: `hashBattleReceipt` re-derives the seed + // from the snapshot the receipt carries, so a worker that dropped the gear or the + // layout version would throw here rather than sign. + await processSignMessage(MESSAGE, NOW); + expect(sign).toHaveBeenCalledTimes(1); + }); + + it('carries the resolved modifiers and the item type into the persisted receipt', async () => { + const tx = fakeTx(); + vi.mocked(applyTransition).mockImplementationOnce((async (req: { onApplied?: (tx: unknown) => Promise }) => { + if (req.onApplied) await req.onApplied(tx); + return { applied: true, state: 'signed' }; + }) as never); + + await processSignMessage(MESSAGE, NOW); + + const { payload } = tx.battleReceipt.create.mock.calls[0]![0].data; + // Item type as a decimal string, since the payload is stored as JSON. The modifiers + // ride along with it: they are what a replay uses, and the type is what lets a + // third party check them against the published catalog. + expect(payload.snapshot.attacker.equipment).toEqual([ + { slot: 0, itemType: '3', hp: 0, atk: 22, def: 0, int: 0, mdef: 0 }, + { slot: 1, itemType: '12', hp: 45, atk: 0, def: 16, int: 0, mdef: 6 }, + ]); + // Absent, not empty: an ungeared pet encodes a zero-length list either way, and + // omitting it keeps the stored row identical to what it was before gear existed. + expect(payload.snapshot.defender.equipment).toBeUndefined(); + expect(payload.snapshot.schemaVersion).toBe(SNAPSHOT_SCHEMA_VERSION); + }); + + it('fights the geared battle differently from the ungeared one', async () => { + // Guards the fixture itself. If this gear made no difference to the outcome, the + // two tests above would pass against an engine that ignored equipment entirely. + expect(GEARED.seed).not.toBe(BATTLE.seed); + }); +}); + describe('chain-position retry', () => { it('retries with a fresh chain head when another battle under this key wins the position first', async () => { vi.mocked(prisma.battleReceipt.findFirst) @@ -388,6 +491,40 @@ describe('signing failure', () => { }); }); + /** + * The reason, not just the symptom. `configureSigner` records why it refused and returns, + * so the process boots and keeps serving reads — which means "no active signing key" is + * the *consequence* of a configuration failure whose cause is sitting in memory. Writing + * only the consequence into `failureReason` is what made this land as a mystery: the row + * said the key was missing and nothing anywhere said why. + */ + it('records why the signer refused, not just that no key was found', async () => { + vi.mocked(activeSigningKey).mockReturnValue(null); + vi.mocked(signerBackendError).mockReturnValue( + 'evm: this deployment serves more than one chain family, so evm needs its own signing key', + ); + + await processSignMessage(MESSAGE, NOW); + + expect(applyTransition).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'signing_failed', + // Nested under `patch`, which is what actually reaches the row. + patch: expect.objectContaining({ + failureReason: expect.stringContaining('needs its own signing key'), + }), + }), + ); + // Still names the chain, so a multi-chain deployment says which one stalled. + expect(applyTransition).toHaveBeenCalledWith( + expect.objectContaining({ + patch: expect.objectContaining({ + failureReason: expect.stringContaining('eip155:'), + }), + }), + ); + }); + it('propagates an unexpected signer error rather than treating it as signing_failed', async () => { vi.mocked(sign).mockRejectedValue(new Error('kms unreachable')); await expect(processSignMessage(MESSAGE, NOW)).rejects.toThrow(/kms unreachable/); diff --git a/backend/tests/features/battle/worker/verify.worker.test.ts b/backend/tests/features/battle/worker/verify.worker.test.ts index 2877c1ff..8c4381c5 100644 --- a/backend/tests/features/battle/worker/verify.worker.test.ts +++ b/backend/tests/features/battle/worker/verify.worker.test.ts @@ -19,10 +19,16 @@ vi.mock('@config/prisma', () => ({ }, })); -vi.mock('@features/battle/ledger', () => ({ +// The snapshot codec is pure and stays real. Stubbing it would let these tests pass +// against a decoder production does not use, which is exactly how the signing worker's +// schemaVersion bug survived a green suite. +vi.mock('@features/battle/ledger', async () => ({ applyTransition: vi.fn(), completeOutbox: vi.fn(), OUTBOX_TOPICS: { sign: 'sign' }, + ...(await vi.importActual( + '@features/battle/ledger/snapshot.codec', + )), })); vi.mock('@grpc-client/verifyBattle', () => ({ diff --git a/backend/tests/features/inventory/drops.test.ts b/backend/tests/features/inventory/drops.test.ts index b37f4e56..75754d3a 100644 --- a/backend/tests/features/inventory/drops.test.ts +++ b/backend/tests/features/inventory/drops.test.ts @@ -147,4 +147,72 @@ describe('recordBattleDrops', () => { expect(drops).toEqual([]); expect(tx.itemEntitlement.createMany).not.toHaveBeenCalled(); }); + + /** + * A player fighting two pets they both own is the case the unique key does not survive + * naively. Winner and loser are then one wallet, and when both rolls land on the same + * item the two entitlements share (sourceRef, owner, itemType), so `skipDuplicates` + * keeps one and the player silently loses an item they earned. + * + * Nothing forbids the battle: `assertBattleSnapshot` refuses a pet fighting *itself*, + * and the defender's own wallet can sign the authorization. + */ + describe('when the winner and the loser are the same wallet', () => { + /** A battle id where both sides roll the same item, found by scanning the pool. */ + const COLLIDING = (() => { + for (let i = 0; i < 500; i++) { + const drops = rollDrops(SEED, `btl_${i}`, WINNER, WINNER, ALWAYS); + if (drops.length === 2 && drops[0]!.itemType === drops[1]!.itemType) return `btl_${i}`; + } + throw new Error('no colliding battle id in the first 500; the drop pool changed'); + })(); + + it('merges the two drops into one entitlement of quantity 2', async () => { + const tx = fakeTx(); + + const drops = await recordBattleDrops(tx as never, { + chain: 'evm', battleId: COLLIDING, seed: SEED, + winnerOwner: WINNER, loserOwner: WINNER, rates: ALWAYS, + }); + + const { data } = tx.itemEntitlement.createMany.mock.calls[0]![0]; + expect(data).toHaveLength(1); + expect(data[0]).toMatchObject({ owner: WINNER, quantity: 2 }); + // Returned as written, so a caller sees what the table holds. + expect(drops).toEqual([{ owner: WINNER, itemType: data[0].itemType, quantity: 2 }]); + }); + + it('still writes two rows when the same wallet wins two different items', async () => { + const battleId = (() => { + for (let i = 0; i < 500; i++) { + const drops = rollDrops(SEED, `btl_${i}`, WINNER, WINNER, ALWAYS); + if (drops.length === 2 && drops[0]!.itemType !== drops[1]!.itemType) return `btl_${i}`; + } + throw new Error('no two-item battle id in the first 500'); + })(); + const tx = fakeTx(); + + await recordBattleDrops(tx as never, { + chain: 'evm', battleId, seed: SEED, + winnerOwner: WINNER, loserOwner: WINNER, rates: ALWAYS, + }); + + expect(tx.itemEntitlement.createMany.mock.calls[0]![0].data).toHaveLength(2); + }); + + it('merges on the normalized owner, since that is what the unique key stores', async () => { + // Two spellings of one address are one wallet to the index and would otherwise + // be two groups here, which puts the collision straight back. + const tx = fakeTx(); + + await recordBattleDrops(tx as never, { + chain: 'evm', battleId: COLLIDING, seed: SEED, + winnerOwner: WINNER.toUpperCase().replace('0X', '0x'), loserOwner: WINNER, rates: ALWAYS, + }); + + const { data } = tx.itemEntitlement.createMany.mock.calls[0]![0]; + expect(data).toHaveLength(1); + expect(data[0]).toMatchObject({ owner: WINNER, quantity: 2 }); + }); + }); }); diff --git a/backend/tests/features/inventory/inventory.service.test.ts b/backend/tests/features/inventory/inventory.service.test.ts index a5e56bc9..7f67b198 100644 --- a/backend/tests/features/inventory/inventory.service.test.ts +++ b/backend/tests/features/inventory/inventory.service.test.ts @@ -14,7 +14,17 @@ vi.mock('@repositories/inventory.repository', () => ({ findUnclaimedEntitlements: (chain: string, owner: string) => repo.findUnclaimedEntitlements(chain, owner), })); -import { getInventory, getPendingItems, getPetEquipment, resetItemCatalog } from '@features/inventory'; +import { + getCatalog, + getCombatCatalog, + getInventory, + getPendingItems, + getPetEquipment, + getPetEquipmentForCombat, + ItemCatalogError, + itemCatalogGeneration, + resetItemCatalog, +} from '@features/inventory'; const POTION = { itemType: '100', @@ -134,6 +144,110 @@ describe('getPetEquipment', () => { }); }); +/** + * The strict counterparts (roadmap §4). + * + * `getPetEquipment` and `getCatalog` hide a row they cannot read, which is right for a bag + * and wrong for a fight: dropping an item silently changes a battle rather than a label, + * and the resulting receipt claims a pet fought bare while `ItemCore.equipmentOf` at the + * recorded `sourceVersion` says it was wearing something. + * + * An unreadable effect and an absent one are the same `null` on `ItemView`, so each case + * below is checked against a lenient read as well, to show the two paths genuinely differ + * rather than the fixture simply being malformed everywhere. + */ +describe('the combat reads refuse what the display reads hide', () => { + /** Equipment whose stored effect will not parse: `atk` is a string, not an integer. */ + const CORRUPT_BLADE = { ...BLADE, itemType: '2', key: 'bent_fang', effect: { kind: 'stat_bonus', hp: 0, atk: '4', def: 0, int: 0, mdef: 0 } }; + + describe('getCombatCatalog', () => { + it('returns the catalog when every equipment row is readable', async () => { + repo.findAllDefinitions.mockResolvedValue([BLADE, POTION]); + + expect((await getCombatCatalog()).map((item) => item.key)).toEqual(['iron_fang', 'xp_potion_i']); + }); + + it('refuses an equipment row whose modifier will not parse', async () => { + repo.findAllDefinitions.mockResolvedValue([BLADE, CORRUPT_BLADE]); + + await expect(getCombatCatalog()).rejects.toThrow(ItemCatalogError); + // The lenient read still serves it, effect dropped. That difference is the + // point: a bad row costs a tooltip on the bag screen and costs a battle here. + expect((await getCatalog()).find((item) => item.key === 'bent_fang')?.effect).toBeNull(); + }); + + it('ignores an unreadable effect on something that cannot reach a fight', async () => { + // A consumable's effect is applied by `useItem`, never by the engine, so it has + // no business invalidating the ruleset every battle is priced under. + repo.findAllDefinitions.mockResolvedValue([BLADE, { ...POTION, effect: { kind: 'grant_xp', amount: 'fifty' } }]); + + await expect(getCombatCatalog()).resolves.toHaveLength(2); + }); + }); + + describe('getPetEquipmentForCombat', () => { + it('narrows a readable item to its modifier', async () => { + repo.findEquipment.mockResolvedValue([{ slot: 0, itemType: '1' }]); + repo.findAllDefinitions.mockResolvedValue([BLADE]); + + expect(await getPetEquipmentForCombat('evm', '7')).toEqual([ + { slot: 0, itemType: '1', key: 'iron_fang', bonus: { kind: 'stat_bonus', hp: 0, atk: 4, def: 0, int: 0, mdef: 0 } }, + ]); + }); + + it('refuses an equipped item with no catalog row', async () => { + // The seeder running behind the contract. Refusing surfaces it in seconds; the + // lenient read hides it behind a console warning and an ungeared fight. + repo.findEquipment.mockResolvedValue([{ slot: 0, itemType: '999' }]); + repo.findAllDefinitions.mockResolvedValue([BLADE]); + + await expect(getPetEquipmentForCombat('evm', '7')).rejects.toThrow(/uncatalogued item type 999/); + expect(await getPetEquipment('evm', '7')).toEqual([]); + }); + + it('refuses an equipped item whose modifier will not parse', async () => { + repo.findEquipment.mockResolvedValue([{ slot: 0, itemType: '2' }]); + repo.findAllDefinitions.mockResolvedValue([CORRUPT_BLADE]); + + await expect(getPetEquipmentForCombat('evm', '7')).rejects.toThrow(/no readable stat_bonus/); + }); + + it('costs nothing for a pet with no gear', async () => { + repo.findEquipment.mockResolvedValue([]); + + expect(await getPetEquipmentForCombat('evm', '7')).toEqual([]); + expect(repo.findAllDefinitions).not.toHaveBeenCalled(); + }); + }); +}); + +describe('resetItemCatalog', () => { + // The contract `servedRuleset` memoizes against. It cannot call this module's reset + // directly (ruleset.builder imports this one, so the call would close a cycle), so it + // compares generations instead, and a reset that did not bump one would leave a ruleset + // built from rows that no longer exist. + it('bumps the generation so catalog-derived caches rebuild', async () => { + repo.findAllDefinitions.mockResolvedValue([BLADE]); + await getCatalog(); + + const before = itemCatalogGeneration(); + resetItemCatalog(); + + expect(itemCatalogGeneration()).not.toBe(before); + }); + + it('re-reads the definitions after a reset', async () => { + repo.findAllDefinitions.mockResolvedValue([BLADE]); + await getCatalog(); + await getCatalog(); + expect(repo.findAllDefinitions).toHaveBeenCalledTimes(1); + + resetItemCatalog(); + await getCatalog(); + expect(repo.findAllDefinitions).toHaveBeenCalledTimes(2); + }); +}); + describe('getPendingItems', () => { const row = { id: 'e1', diff --git a/backend/tests/features/inventory/inventory.write.test.ts b/backend/tests/features/inventory/inventory.write.test.ts index b60eb96c..9319584d 100644 --- a/backend/tests/features/inventory/inventory.write.test.ts +++ b/backend/tests/features/inventory/inventory.write.test.ts @@ -3,7 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const client = { mintTo: vi.fn(), burnFrom: vi.fn() }; const chain = { getItemCoreClient: vi.fn(() => client as { mintTo: unknown; burnFrom: unknown } | null) }; -vi.mock('@features/inventory/inventory.chain', () => ({ +// The error class stays real: `claimEntitlement` branches on `instanceof`, and a stub +// class would let the release path be exercised by an error the production code would +// have treated as unconfirmed. +vi.mock('@features/inventory/inventory.chain', async () => ({ + ...(await vi.importActual( + '@features/inventory/inventory.chain', + )), getItemCoreClient: () => chain.getItemCoreClient(), })); @@ -33,6 +39,7 @@ vi.mock('@config/prisma', () => ({ }, })); +import { UnconfirmedTxError } from '@features/inventory/inventory.chain'; import { claimEntitlement, grantItem, isAdmin, useItem } from '@features/inventory/inventory.write'; import { prisma } from '@config/prisma'; @@ -180,9 +187,10 @@ describe('claimEntitlement', () => { expect(client.mintTo).not.toHaveBeenCalled(); }); - // Released rather than left claimed, so a failed mint is retryable. Safe because the - // client waits for a receipt and treats a reverted one as a throw. - it('releases the claim when the mint fails', async () => { + // Released rather than left claimed, so a failed mint is retryable. Safe only because + // this failure moved nothing: a simulate revert, a send that never left, or a receipt + // that came back reverted. + it('releases the claim when the mint definitely did not land', async () => { vi.mocked(prisma.itemEntitlement.findUnique).mockResolvedValue(ROW as never); vi.mocked(prisma.itemEntitlement.updateMany).mockResolvedValue({ count: 1 } as never); client.mintTo.mockRejectedValue(new Error('rpc down')); @@ -193,6 +201,35 @@ describe('claimEntitlement', () => { }); }); + /** + * The failure the release must not treat like the others: broadcast, outcome unknown. + * + * A receipt read that times out does not mean the mint failed, it means nobody knows. + * The transaction is very likely mined, so releasing the claim would let a retry send a + * second mint and pay the entitlement twice. + */ + it('keeps the claim when the mint was broadcast but could not be confirmed', async () => { + const hash = `0x${'ab'.repeat(32)}` as const; + vi.mocked(prisma.itemEntitlement.findUnique).mockResolvedValue(ROW as never); + vi.mocked(prisma.itemEntitlement.updateMany).mockResolvedValue({ count: 1 } as never); + client.mintTo.mockRejectedValue(new UnconfirmedTxError(hash, 'receipt unreadable')); + + await expect(claimEntitlement(OWNER, 'e1')).rejects.toThrow(UnconfirmedTxError); + + // Never released: the only updateMany is the claim itself, taken before the mint. + const released = vi + .mocked(prisma.itemEntitlement.updateMany) + .mock.calls.filter((call) => (call[0] as { data?: { claimedAt?: unknown } }).data?.claimedAt === null); + expect(released).toHaveLength(0); + + // The hash is recorded, so the row names the transaction to reconcile against and + // the `txHash: null` guard on the release path keeps meaning what it says. + expect(prisma.itemEntitlement.update).toHaveBeenCalledWith({ + where: { id: 'e1' }, + data: { txHash: hash }, + }); + }); + // 404, not 403: someone else's entitlement is indistinguishable from a missing one, so // an id cannot be probed by watching the answer change. it('reports another wallet’s entitlement as missing', async () => { diff --git a/backend/tests/repositories/roster.repository.test.ts b/backend/tests/repositories/roster.repository.test.ts index 95cfbad1..06f938dd 100644 --- a/backend/tests/repositories/roster.repository.test.ts +++ b/backend/tests/repositories/roster.repository.test.ts @@ -8,6 +8,7 @@ vi.mock('@config/prisma', () => ({ findUnique: vi.fn(), }, $queryRaw: vi.fn(), + defenseAuthorization: { count: vi.fn() }, }, })); vi.mock('../../src/grpc/rosterReads', () => ({ @@ -19,7 +20,34 @@ vi.mock('../../src/repositories/battleProgress.overlay', () => ({ servedChainIdForFamily: (chain: string) => servedChainIdForFamily(chain), })); +/** + * A ruleset with a **non-empty** item catalog, which is the whole point of the stub. + * + * `servedRuleset()` joins the live catalog onto `SOURCE_DEFAULT_RULESET`, so the two are + * equal only while no item is seeded. Stubbing it to the bare constant here would make the + * consent filter's hash match by accident and hide the exact bug these cases now pin. + */ +vi.mock('../../src/features/battle/ledger/ruleset.builder', async () => { + const { SOURCE_DEFAULT_RULESET } = await vi.importActual( + '@cryptopets/protocol', + ); + const { hashRuleset } = await vi.importActual('@cryptopets/protocol'); + const served = { + ...SOURCE_DEFAULT_RULESET, + itemCatalog: [{ itemType: 3n, slot: 0, hp: 0, atk: 22, def: 0, int: 0, mdef: 0 }], + }; + return { + servedRuleset: vi.fn(async () => served), + // Derived from the same object the stub serves, so the test cannot pass by having + // the hash and the ruleset drift — which is the bug this whole seam exists to stop. + servedRulesetHash: vi.fn(async () => hashRuleset(served)), + }; +}); + +import { hashRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; + import { findReadyOpponents, getPetById } from '../../src/repositories/roster.repository'; +import { servedRulesetHash } from '../../src/features/battle/ledger/ruleset.builder'; import { prisma } from '@config/prisma'; const rosterRow = { @@ -45,11 +73,19 @@ const rosterRow = { asset: '', }; -/** `$queryRaw` is called twice per lookup: the page, then its count. */ +/** + * `$queryRaw` is called twice per lookup — the page, then its count — and a third time + * only when the count is zero, to work out which filter emptied it. + * + * The third response is queued unconditionally because an unused `mockResolvedValueOnce` + * is harmless, while a missing one throws inside the diagnostic rather than in the case + * under test, which reads as an unrelated failure. + */ function mockJoinQuery(rows: unknown[], total: number) { vi.mocked(prisma.$queryRaw) .mockResolvedValueOnce(rows as never) - .mockResolvedValueOnce([{ total: BigInt(total) }] as never); + .mockResolvedValueOnce([{ total: BigInt(total) }] as never) + .mockResolvedValueOnce([{ indexed: 0n, notMine: 0n, offCooldown: 0n, inBand: 0n }] as never); } /** The SQL text of the nth `$queryRaw` call, whitespace-collapsed for matching. */ @@ -74,8 +110,32 @@ function fragmentsOfCall(index: number): string { .replace(/\s+/g, ' '); } +/** + * Every bound value in the nth call, flattened through nested `Prisma.Sql` fragments. + * + * The consent clause is a fragment interpolated into the outer query, and the ruleset hash + * is bound *inside* it, so it never appears among the outer call's own values. Flattening + * is what makes it assertable at all. + */ +function valuesOfCall(index: number): unknown[] { + const [, ...values] = vi.mocked(prisma.$queryRaw).mock.calls[index] as unknown as [string[], ...unknown[]]; + const flatten = (input: unknown[]): unknown[] => + input.flatMap((value) => { + const fragment = value as { sql?: unknown; values?: unknown[] }; + return typeof fragment?.sql === 'string' && Array.isArray(fragment.values) + ? flatten(fragment.values) + : [value]; + }); + return flatten(values); +} + beforeEach(() => { vi.clearAllMocks(); + // `clearAllMocks` drops recorded calls but not queued `mockResolvedValueOnce` + // implementations. A case that queues three responses and consumes two leaves one + // behind, which the next case then consumes as its *first* answer — so a test fails + // reporting the previous test's data and nothing in either one looks wrong. + vi.mocked(prisma.$queryRaw).mockReset(); servedChainIdForFamily.mockReturnValue('eip155:31337'); }); @@ -154,6 +214,118 @@ describe('findReadyOpponents', () => { expect(consent).toContain('a.all_pets OR a.pet_ids @>'); }); + /** + * Matches on the hash defenders actually signed, which is the *served* ruleset. + * + * This filtered on `hashRuleset(SOURCE_DEFAULT_RULESET)` while clients sign what + * `GET /api/battle/config` serves, which is `servedRuleset()`. Equal only while the item + * catalog is empty; seed one equipment item and the predicate matches no authorization + * ever written, so matchmaking returns nothing on a deployment full of consenting pets. + * + * The stubbed ruleset carries an item, so the two hashes genuinely differ here and the + * assertion fails against the old code instead of passing by coincidence. + */ + it('matches consent on the served ruleset hash, not the source default', async () => { + mockJoinQuery([], 0); + + await findReadyOpponents({ chain: 'evm', excludeOwner: '0x', minLevel: 0, page: 0, pageSize: 10 }); + + const served = await vi.mocked(servedRulesetHash)(); + const values = valuesOfCall(0); + expect(values).toContain(served); + expect(values).not.toContain(hashRuleset(SOURCE_DEFAULT_RULESET)); + }); + + /** + * Which filter emptied the list. + * + * Four situations render as the same blank picker and only some are the player's to + * act on. Working out which one cost several rounds of guessing by hand, which is the + * argument for the server answering it. + */ + describe('when the list comes back empty', () => { + /** page, count, then the diagnostic pass. */ + function mockEmptyWithCounts(counts: Record) { + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([{ total: 0n }] as never) + .mockResolvedValueOnce([ + { + indexed: BigInt(counts.indexed ?? 0), + notMine: BigInt(counts.notMine ?? 0), + offCooldown: BigInt(counts.offCooldown ?? 0), + inBand: BigInt(counts.inBand ?? 0), + }, + ] as never); + } + + const call = () => + findReadyOpponents({ chain: 'evm', excludeOwner: '0xme', minLevel: 0, page: 0, pageSize: 10 }); + + it('blames an unindexed roster, which is a server problem and not the player’s', async () => { + mockEmptyWithCounts({ indexed: 0 }); + expect((await call()).emptyReason).toBe('roster-empty'); + }); + + it('reports that every pet is the caller’s own', async () => { + mockEmptyWithCounts({ indexed: 5, notMine: 0 }); + expect((await call()).emptyReason).toBe('all-yours'); + }); + + it('reports cooldown when others exist but none are ready', async () => { + mockEmptyWithCounts({ indexed: 5, notMine: 3, offCooldown: 0 }); + expect((await call()).emptyReason).toBe('all-on-cooldown'); + }); + + it('reports the level band when it is what excluded everyone', async () => { + mockEmptyWithCounts({ indexed: 5, notMine: 3, offCooldown: 3, inBand: 0 }); + expect((await call()).emptyReason).toBe('below-min-level'); + }); + + // Consent is the only predicate left once the others are survived, and the two + // ways it fails send the player somewhere different. + it('reports no consent when nobody has granted any', async () => { + mockEmptyWithCounts({ indexed: 5, notMine: 3, offCooldown: 3, inBand: 3 }); + vi.mocked(prisma.defenseAuthorization.count).mockResolvedValueOnce(0); + + expect((await call()).emptyReason).toBe('no-consent'); + }); + + it('reports stale consent when grants exist but none match the served ruleset', async () => { + // The distinction that matters: "turn it on" and "turn it on again" are + // different instructions, and only one of them is right for someone who + // already did. + mockEmptyWithCounts({ indexed: 5, notMine: 3, offCooldown: 3, inBand: 3 }); + vi.mocked(prisma.defenseAuthorization.count) + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(0); + + expect((await call()).emptyReason).toBe('consent-stale'); + }); + + it('costs nothing when the list is not empty', async () => { + mockJoinQuery([rosterRow], 1); + + const result = await call(); + + expect(result.emptyReason).toBeUndefined(); + // Two queries, not three: the diagnostic pass never runs on the happy path. + expect(vi.mocked(prisma.$queryRaw)).toHaveBeenCalledTimes(2); + }); + }); + + it('uses the same ruleset hash for the count as for the page', async () => { + // The count runs its own copy of the predicate, so a hash fixed in one and not the + // other would page correctly and total wrongly. + mockJoinQuery([], 0); + + await findReadyOpponents({ chain: 'evm', excludeOwner: '0x', minLevel: 0, page: 0, pageSize: 10 }); + + const served = await vi.mocked(servedRulesetHash)(); + expect(valuesOfCall(0)).toContain(served); + expect(valuesOfCall(1)).toContain(served); + }); + it('leaves the level band and daily cap to accept time', async () => { // `authorizationCovers` stays the only thing that authorizes a battle. Both of // these depend on the attacker, who is not known when the list is built, so diff --git a/backend/tsconfig.scripts.json b/backend/tsconfig.scripts.json new file mode 100644 index 00000000..9f702c46 --- /dev/null +++ b/backend/tsconfig.scripts.json @@ -0,0 +1,28 @@ +{ + // Typechecks the operator scripts, which `tsconfig.json` deliberately does not. + // + // They cannot simply be added to the main config's `include`: `pnpm build` runs `tsc` + // with it, so they would be emitted into `dist/` and shipped as part of the server. + // These are one-shot tools run with `tsx`, not server code. + // + // But `tsx` strips types rather than checking them, so with no config naming these + // files nothing checked them at all, and four type errors had accumulated across + // `grant-defense-authorization.ts` and `seed-item-catalog.ts`. That matters more here + // than almost anywhere else in the repo: these are the files an operator points at the + // production database. + // + // Run with `pnpm --filter backend typecheck:scripts`. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "src/**/*", + "scripts/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts" + ] +} diff --git a/docs/README.md b/docs/README.md index 248abfa3..04e6e8e7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,8 @@ package-specific docs live next to their code and are linked below. | --- | --- | | [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. | +| [Inventory and item NFTs](./plan-inventory-items.md) | Execution order for roadmap §4, all four phases shipped. Records what was deployed to Base Sepolia and what the seeder still needs. | +| [Battle + inventory hardening](./plan-battle-inventory-hardening.md) | Review of §4 against the battle protocol, and the record of what it found and fixed. Read it before touching the snapshot, ruleset, or drop paths: several of its entries are corrections to claims other docs made. | | [Testing](./testing.md) | Per-package suite table and conventions. | ## Package docs diff --git a/docs/battle-protocol.md b/docs/battle-protocol.md index 6ffbbd13..6b0f52e7 100644 --- a/docs/battle-protocol.md +++ b/docs/battle-protocol.md @@ -288,6 +288,53 @@ Requirements: - Require attacker ownership at the finalized source version. - Never let a JWT user submit a battle for another wallet. +### Delegated signing (session keys) + +The rule above is right and expensive: it puts a wallet prompt on the most repeated action +in the game. The resolution is *not* to accept a JWT after all, because the objection to a +JWT is not that it is inconvenient to check but that we mint it ourselves. + +Instead the owner signs one `SessionDelegation` naming a key **the client generated and +holds**, and that key signs intents: + +```text +schemaVersion +chainId +deploymentId +owner +sessionKey +scope ('battle-intent') +notBefore +expiresAt +revocationNonce +``` + +What survives: the operator never sees the private key, so it still cannot produce an +intent. That is the entire property a JWT lacked. What changes is only how often a human is +asked. + +Bounded on three axes, all enforced by the validator rather than trusted to the client: + +- **Scope.** Battle intents alone. Defence consent is deliberately excluded — it is the one + signature a defender relies on, and a stolen session key must not be able to produce it. + Anything on chain is excluded by construction rather than by rule, since `equip` and every + transfer check `msg.sender` and this key is not an account the chain knows. +- **Time.** `MAX_SESSION_SECONDS` (24h), so a client asking for longer is refused. +- **Revocation.** A nonce the owner bumps, plus `DELETE /api/battle/sessions`, which is + unsigned for the same reason consent revocation is: the failure mode of an unauthorized + revocation is more prompts, never fewer. + +Not carried by any receipt, and that is a scoping decision worth stating. Public replay +never checks intent signatures, so delegation is an authorization gate rather than evidence. +Keeping it out of the signed record means the mechanism can be revised — or withdrawn — +without invalidating a single historical receipt. + +The client stores the key in `sessionStorage`, not `localStorage`: per-tab and cleared on +close bounds a stolen copy to one browsing session, where persistent storage would turn one +XSS into weeks of authority. EVM only for now; a Solana player keeps the per-battle prompt, +because delegation needs the client to hold a key of the right family and the Solana signer +is the wallet adapter rather than a keypair this code owns. + ### Standing defender consent The current EVM contract lets anyone attack anyone's pet. Backend ranked mode should not apply @@ -560,8 +607,21 @@ Sign only the digest: - Private key in a managed KMS/HSM, out of the API and worker environments. - No asset custody, no withdrawal authority. -- Separate keys for EVM and Solana reward domains. -- Publish public keys and validity periods; retain rotated-out keys. +- Separate keys for EVM and Solana reward domains. Implemented as one signer backend per + chain family: the key is chosen by the *object's own* domain, never by a caller argument, + so nothing can sign an EVM receipt with the Solana key. A deployment serving one family + needs one key — there is nothing to separate — but one serving both must name a key for + each, and the signer refuses to start rather than let them collapse onto one. Both keys + are published together, and a verifier matches on `signingKeyId` without needing to know + how they are partitioned. +- Publish public keys and validity periods; retain rotated-out keys. `notAfter` is stamped + automatically at the first boot that no longer configures a key, and is dated from + evidence rather than the clock: the `createdAt` of the last receipt that key actually + signed. That is the strongest claim the data supports and it is safe in the direction that + matters, since every receipt the key legitimately produced is at or before it — stamping + can never retroactively invalidate one. A key that signed nothing gets a zero-length + window, which is the honest description of one configured and never used. An end recorded + deliberately, such as during a compromise, is never overwritten by a later boot's guess. - Log every KMS request, digest, result, and key version. - Signer accepts only the exact commitment and receipt schemas. Never expose a generic state-mutation signing endpoint. @@ -998,6 +1058,11 @@ Each row: what the attacker does, what stops or bounds it, how we notice, what i - **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. + The prompt has to be *sought*, which is easy to miss when writing this down: being challenged + is passive, so a defender whose consent went stale sees no error and no failed action. Their + pets simply stop being challengeable. `GET /api/battle/authorizations` returns each grant with + `isStale` against the served `rulesetHash` for exactly this reason, and the defence panel + states it, or the only party who can repair the situation is the only one never told. ### T10: stale ownership after an NFT transfer @@ -1033,10 +1098,23 @@ Each row: what the attacker does, what stops or bounds it, how we notice, what i - **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). + For equipment specifically (roadmap §4, snapshot schema v2) the snapshot freezes each item's + **resolved modifier alongside its `itemType`**, and the ruleset publishes what every + combat-affecting item does, so the applied effect can be compared against the declared one. - **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. + previous receipt's `progressionDelta`. Replay alone cannot catch an inflated *modifier*, because + the inflated number is the thing being replayed against; `findEquipmentMismatches` is what + compares it to the catalog, run by the verifier on a finished receipt and by `accept` before a + battle starts. +- **Residual.** Item **ownership** is still not provable from the receipt. The modifiers are + checkable and the item type is named, but whether the pet actually held that item at + `sourceVersion` is a claim about chain state, which the verifier deliberately cannot read (it + has no network access). A party wanting that checks `ItemCore.equipmentOf` at the recorded + version themselves; the controls above narrow the remaining trust to exactly that question. + + This entry previously read "keep equipment out of combat inputs", which was the correct advice + until roadmap §4 phase 4 put them in. Kept visible rather than silently rewritten, because a + threat model that quietly changes its own advice is not one anybody can audit. ### T14: combat log leaks outcomes to spectators @@ -1185,6 +1263,7 @@ takes. That is what the procedures here are for. | --- | --- | | `POST /api/battle/intents`, `/accept`, `/authorizations` return **503** | accepted | | `DELETE /api/battle/authorizations` still works | works | +| `GET /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 | @@ -1196,7 +1275,9 @@ every issued receipt into an assertion. Turning the mode off stops new battles o 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. +off. Reading consent is ungated on a related one: a defender needs to see the state of their +own grants precisely when something is wrong, and a mode flag is the last thing that should +decide whether they can. ### Kill switch diff --git a/docs/plan-battle-inventory-hardening.md b/docs/plan-battle-inventory-hardening.md new file mode 100644 index 00000000..de6aaaf7 --- /dev/null +++ b/docs/plan-battle-inventory-hardening.md @@ -0,0 +1,629 @@ +# Plan: harden the battle + inventory seam before it goes live + +Review of the shipped roadmap §4 work (`docs/plan-inventory-items.md`, all four phases marked +complete) against the backend-authoritative battle path (`docs/battle-protocol.md`). This file +is the execution order for what that review found. Each step ends at a command that passes. + +Branch: `fix/battle-inventory-seam`. + +## Status + +Every code item is done. What remains needs a decision or production access, not more code. + +| | Item | State | +|---|---|---| +| B1 | Sign worker rebuilt every snapshot at schema v1, so nothing settled | done | +| C1 | Unreadable catalog effect silently re-priced the ruleset | done | +| C2 | Uncatalogued equipped item fought as nothing | done | +| C3 | TS bonus sum unclamped where Go range-checks | done | +| C4 | Self-battle silently swallowed one of its own drops | done | +| C5 | Unconfirmed mint could pay an entitlement twice | done | +| C6 | Spending a consumable left the pet's own numbers stale | done | +| D1 | Consent and gear. Smaller gap than first stated | done, no schema change | +| D2 | Drops are not verifiable | claim corrected, v1 position taken and disclosed; **revisit at phase 04** | +| C7 | No way to tell a defender their consent had gone stale | done | +| D3 | Phase 4 ships a re-consent event | **needs a deliberate rollout** | +| Q1-Q4 | Cache reset, stranded comment, worker cast, untypechecked scripts | done | +| S1-S3 | Derived hashes single-sourced, dead outbox topic, chained effects | done | +| R1-R3 | Two meanings on one `expired` code, nine reasons with no text, dead session key | done | +| O1-O3 | Migration, seeder, end-to-end | **operator calls** | + +Two of these were corrections to this document rather than to the code. D1 and D2 were both +written up as bigger than they are, and in D1's case that nearly bought a permanent protocol +schema version. Where that happened it is recorded in place, because the correction is the +more useful artifact. + +## Verdict + +The feature is well built. Ownership boundaries are stated and held (indexer writes the +projections, the seeder writes the catalog, the player signs the equip), the two live combat +ports move together, the golden vectors cover the modifier ordering at a one-point margin, and +the doc comments record reasoning rather than restating code. + +Every suite was green **as found**, which is the point worth keeping: + +| Suite | As found | After | +|---|---|---| +| `pnpm --filter backend test` | 914 passed / 89 files | 943 | +| `pnpm --filter @cryptopets/protocol test` | 595 passed / 34 files | 604 | +| `pnpm --filter @cryptopets/verifier test` | 86 passed / 13 files | 86 | +| `pnpm --filter frontend test` | 370 passed / 48 files | 370 | +| `pnpm --filter @shared/core test` | 567 passed / 80 files | 574 | +| `go test ./internal/{combat,evm,store}` | ok | ok | + +A green suite is not the same as working software, and B1 is the clean demonstration: no +battle on any deployment running that code could produce a receipt, and 914 tests passed +anyway, because the fixture and the bug shared an assumption. Every fix below is paired with a +test verified to fail without it, which is the only way that assertion means anything. + +`contracts/test-vectors/` is unchanged throughout, confirmed by diff against `main`. + +Severity ordering below is by consequence, not by size of fix. The C-numbers are in the order +found, not in severity order: C4 through C6 came out of reviewing the drop and claim paths +after the snapshot work was finished. + +--- + +## B1 (blocker): the sign worker rebuilds every snapshot at schema version 1 + +**No battle can produce a receipt on any deployment where this code runs.** Not only geared +battles. Every battle. + +`accept.service.ts:167-174` writes the snapshot with `schemaVersion: SNAPSHOT_SCHEMA_VERSION` +(currently 2) and stores `snapshotHash` computed at that version. `beacon.worker.ts:65` derives +the battle seed from that stored hash. + +`sign.worker.ts:63-74` reads the row back through a `storedSnapshot` type that declares only +`domain`, `attacker`, `defender`, `takenAt`, and a `deserializePet` (line 376) whose `StoredPet` +interface stops at `sourceVersion`. Both `schemaVersion` and `equipment` are dropped. The +reconstructed object therefore encodes at version 1, because `assertBattleSnapshot` reads an +absent version as 1 by design. + +`writeHeader` writes the version as a `u16` inside the hashed bytes, so a v1 encoding and a v2 +encoding of the same ungeared pet differ. Confirmed by running the two hashes side by side on +an identical ungeared pair: + +``` +accept (schemaVersion 2): 0xf7ccca0ba2c0971b7f3b3a18b9bc200aa5616c84751efa3256ab93524ed7a054 +sign (version dropped): 0xac2c61bb061506d0a88b00b3c31203daba05e0a82d48239c8fd99cc54b60a159 +``` + +`hashBattleReceipt` calls `assertBattleReceipt`, which re-derives the seed from +`hashBattleSnapshot(receipt.snapshot)` (`protocol/src/receipt/types.ts:152-161`) and throws when +it disagrees with `receipt.seed`. That throw is not a `SignerRefusedError`, so it escapes +`processSignMessage` into the dispatcher's backoff. Battles pile up in `verified` and +dead-letter. + +For a geared battle the same bug has a second effect: had it not thrown first, the receipt +would publish a snapshot with the gear removed, and `checks/combatReplay.ts` would replay an +ungeared fight against a geared result. + +Why the suite is green: `backend/tests/features/battle/worker/sign.worker.test.ts:95` defines +`SNAPSHOT` with no `schemaVersion` and computes its fixture `snapshotHash` from the same +version-less object (line 101). The fixture is a v1 snapshot on both sides, so it agrees with +itself. Production writes v2 on one side only. + +### Fix + +- [x] **B1.1 One deserializer, not three.** Extract the stored-snapshot reader into + `backend/src/features/battle/ledger/snapshot.codec.ts` next to `snapshot.builder.ts`, + round-tripping `schemaVersion` and `equipment` alongside the bigint fields. Delete + `compute.worker.ts:113-145`'s copy and `sign.worker.ts:361-390`'s copy, and have + `verify.worker.ts:60-62` use it instead of casting to `Record`. Three + readers of one stored shape is what let one of them fall behind, and a fix that patches + only the third leaves the same trap set. + Verify: `pnpm --filter backend test`. +- [x] **B1.2 Make the fixture representative.** Set `schemaVersion: SNAPSHOT_SCHEMA_VERSION` on + the sign-worker test's stored snapshot and derive its `snapshotHash` from the same object, + so the test fails without B1.1. Add a second case with a geared attacker asserting the + persisted `payload.snapshot.attacker.equipment` survives into the receipt. + Verify: `pnpm --filter backend exec vitest run tests/features/battle/worker/sign.worker.test.ts`. +- [x] **B1.3 Close the class of bug, not the instance.** Add a ledger-level test that runs + accept, then re-reads the stored row and asserts `hashBattleSnapshot(decoded) === + row.snapshotHash`. That assertion holds for any future field added to the snapshot, + which the two tests above do not. + Verify: `pnpm --filter backend test`. + +Nothing outside `backend/src/features/battle` changes. The protocol encoder, both combat ports +and the vectors are correct as they stand. + +--- + +## C1: an unreadable catalog effect silently re-prices the ruleset + +`catalog.ts:158-162` states the rule: "once an effect feeds combat, an unreadable one has to be +a hard error, because silently dropping it would change a fight rather than a label." Phase 4 +made effects feed combat and the code did not follow. + +`toItemView` (`inventory.service.ts:229-247`) calls `asItemEffect`, which returns `null` on any +shape it does not recognise, logs a warning, and continues. Two consumers then read the result +as authoritative: + +- `ruleset.builder.ts:40` skips the item, so it leaves `itemCatalog`. That moves `rulesetHash`, + which invalidates every outstanding `DefenseAuthorization`. +- `snapshot.builder.ts:91` skips it too, so a pet wearing that item fights ungeared, and its + receipt says it was ungeared. + +Both happen from one malformed JSON column, with a `console.warn` as the only signal. + +- [x] **C1.1** Split the read. Keep `asItemEffect`'s leniency on the display path (a bag with one + unnamed tile beats a bag that will not open) and make the combat path strict: a + `stat_bonus` row that fails to parse throws from `servedRuleset()` and from + `resolveEquipment`. A deployment that cannot state its own rules should refuse to accept + battles rather than quietly fight under different ones. + Verify: `pnpm --filter backend test`. + +## C2: an equipped item missing from the catalog fights as nothing + +Same shape, different cause. `resolveEquipment` (`snapshot.builder.ts:86-109`) drops any equipped +item with no `stat_bonus`, including one with no catalog row at all. `getPetEquipment` warns and +drops it first. + +The receipt then says ungeared while `ItemCore.equipmentOf(petId)` at the recorded +`sourceVersion` says otherwise. That is exactly the cross-check §4 added `itemType` to the +snapshot to enable, reporting a discrepancy an outsider cannot distinguish from operator +misbehaviour. + +- [x] **C2.1** Reject the acceptance instead. An uncatalogued equipped item means the seeder is + behind the contract, which is an operational fault; failing the accept with a named reason + surfaces it in seconds, where a silent ungeared fight surfaces as an unexplained verifier + failure weeks later. Reuse the existing reject path in `accept.service.ts`. + Verify: `pnpm --filter backend test`. + +## C3: the TypeScript bonus sum is unclamped where Go range-checks + +`protocol/src/combat/equipment.ts:56-66`'s `sumBonuses` totals in plain JS numbers with no +ceiling; only `applyBonus` clamps, and it clamps after adding to the attributes. Go's +`SumBonuses` saturates at each step. The two agree on the final attribute value, because both +ceilings are 65535 and attributes are non-negative, so this is not a live divergence. + +It does change one thing. `verify.worker.ts:117` sends the unclamped total over gRPC, and +`grpcsrv/verify.go:111-125` rejects any bonus field above 65535 rather than truncating. A total +past the ceiling becomes an RPC error, which `processVerifyMessage` correctly treats as "could +not check" rather than "disagreed", so the battle retries and dead-letters. + +Unreachable with shipped content: `MAX_STAT_BONUS` is 500, three slots cap a pet at 1500, and +the shipped catalog's largest single bonus is 45 HP. This is a guardrail, not a bug. + +- [x] **C3.1** Clamp in `sumBonuses` to match the Go port, and add the case to + `contracts/test-vectors/equipment.json` so the two stay pinned. Both live ports change in + the same commit, per `AGENTS.md`. + Verify: `pnpm --filter @cryptopets/protocol test && go test ./internal/combat` from + `services/indexer-go`. + + **Done without the vector case, deliberately.** The vector format hands `simulate` one + already-summed `bonus1`/`bonus2` per pet, so no case in `equipment.json` reaches + `sumBonuses` at all; pinning it there would have meant extending the vector schema to + carry item lists. Both ports already pin this function with a unit test beside their + golden tests (order-independence), so the clamp went there too: + `equipmentVectors.test.ts`'s `saturates at 65535` and `equipment_golden_test.go`'s + `TestSumBonusesSaturates` assert the same thing on both sides. `equipment.json` is + untouched. + +--- + +## D1: consent already bounds gear. The gap was smaller than stated + +**The framing above this line was wrong, and the correction is the useful part.** Written out +because it was believed long enough to nearly justify a permanent ruleset schema version. + +The original claim was that `DefenseAuthorization` bounds the attacker's level but not their +gear, leaving a defender exposed to whatever the attacker equips after consenting. Two things +already in the code say otherwise: + +- **`itemCatalog` is inside `rulesetHash`** (ruleset schema v2), and consent is bound to that + hash. So a defender has consented to the exact set of items and their exact effects, + including the strongest loadout that set can express. Shipping a stronger sword moves the + hash and re-consents everyone. That is the mechanism §4 designed, working. +- **`verifier/src/checks/equipment.ts` already enforces it**, comparing every resolved + modifier in the snapshot against what the ruleset declares, per item and per slot. + +So gear is bounded, the bound is signed, and the modifiers are checked against it. What +actually remained was narrower: + +1. The ceiling is *derivable* (compute best-in-slot across the catalog) rather than legible as + a single number a defender could read. +2. The catalog comparison happened only at verification, so a disagreeing snapshot became a + failed receipt rather than a refused battle. + +A `Ruleset.maxEquipmentBonus` field would have bought mostly (1), at the price of a permanent +entry in `SUPPORTED_VERSIONS` and a second re-consent event. Not proportionate. + +- [x] **D1.1 Make the comparison at acceptance, with no schema change.** `findEquipmentMismatches` + moved into `@cryptopets/protocol` (`ruleset/equipmentCheck.ts`) and now has two callers: + the verifier, reporting on a finished receipt, and `accept.service.ts`, refusing a battle + that would be guaranteed to fail that report. One implementation, because two would drift + into a battle that accepts and then fails to verify, with the comparison itself the last + thing anyone would suspect. New rejection: `equipment-catalog-mismatch` (503). + + This is not merely redundant with the verifier. `buildPetSnapshot` resolves the modifiers + and `servedRuleset` publishes them, and those are two reads of the item catalog at + different points in one accept, so a seeder run landing between them prices the fight + from one catalog and the rules from another. Narrow, unreachable by an attacker, and + invisible to every other check. + + Verify: `pnpm --filter @cryptopets/protocol test && pnpm --filter @cryptopets/verifier test + && pnpm --filter backend test`. + +Left open deliberately: `MAX_STAT_BONUS` is still 500 a stat against attributes in the low +hundreds, where the largest shipped bonus is 45. Lowering it is a balance call, and raising it +later widens the ceiling every outstanding authorization implies. Worth a line in `catalog.ts` +saying so. + +## C4: a self-battle silently swallowed one of its own drops + +Found after the C1 to C3 work, reviewing the drop path rather than the snapshot path. + +`item_entitlement`'s unique key is `(sourceRef, owner, itemType)` and `sourceRef` is the +battle id, which is what makes a retried receipt transaction idempotent. `recordBattleDrops` +inserted the winner's and the loser's drop as separate rows under `skipDuplicates: true`, and +its comment argued the two could never collide because "each side rolls at most one item". + +That holds only while the two sides are different wallets. Nothing forbids a player fighting +two pets they both own: `assertBattleSnapshot` refuses a pet fighting *itself*, and the +defender's own wallet can sign the authorization. Then winner and loser are one wallet, and +when both rolls land on the same item the two entitlements share a key, so `skipDuplicates` +keeps one and the player loses an item they earned. + +Measured on the shipped pool, scanning 500 battle ids with both rates forced to certainty: +**82 collided**, about one in six. At the real rates (25% winner, 5% loser) both sides pay in +roughly 1.25% of battles, so this reaches about one self-battle in 500. Small, silent, and +wrong in the player's disfavour. + +- [x] **C4.1** Merge drops by `(normalized owner, itemType)` before writing, so the case + becomes one row of quantity 2 rather than two rows one of which vanishes. Normalizing + inside the merge rather than at the insert, because the owner is part of the key: two + spellings of one address are one wallet to the index and would be two groups to anything + grouping on the raw value. `recordBattleDrops` now returns what it wrote rather than what + it rolled. + Verify: `pnpm --filter backend exec vitest run tests/features/inventory/drops.test.ts`. + +`rollDrops` is unchanged, deliberately. It is the pure derivation of what a battle owed each +side; reconciling two owed drops with one storage key is the writer's job, not the +derivation's. + +## C5: an unconfirmed mint could pay an entitlement twice + +`claimEntitlement` marks the row claimed, mints, and on failure releases the claim so the +player can retry. Its comment justified the release as "safe because the mint did not land: +the client waits for a receipt and treats a reverted one as a throw." + +That covers two of the three ways the mint can fail and misses the third. A revert is a +definite no, and so is a send that never left. But `waitForTransactionReceipt` throwing means +the outcome is *unknown*, not failed: the transaction is broadcast and very likely mined. The +release then hands the player a retry that mints a second time, and `txHash` was never +written, so the `txHash: null` guard on the release did not stop it either. + +Narrow (it needs the RPC to drop between broadcast and receipt) but it pays out real items, +and RPC flakiness on this deployment is documented: `plan-inventory-items.md` records drpc +returning intermittent 500s on `eth_getTransactionCount` during the Base Sepolia deploy, which +is why the backend points at `sepolia.base.org` instead. + +- [x] **C5.1** Sort the failures by what is actually known. `send` now raises + `UnconfirmedTxError` carrying the hash when the broadcast succeeded but the receipt + could not be read; everything else keeps throwing plainly. `claimEntitlement` records + the hash and leaves the row claimed for that case only, so the worst outcome is one + entitlement stuck pending until someone reconciles it, rather than one item minted + twice. + Verify: `pnpm --filter backend exec vitest run tests/features/inventory/inventory.write.test.ts`. + +`useItem`'s burn takes the same client and is deliberately left as it was. Its ordering is +already the conservative one its doc comment describes: an unconfirmed burn costs the player +an item and gives nothing, which is a bad afternoon, where the reverse is a repeatable +exploit. + +## C6: using a consumable left the pet's own numbers stale + +`useSpendItem` invalidated the bag and documented the rest as someone else's job: "the pet's +battle progression moved too, so the caller refreshes that itself." Neither call site in +`components/inventory/index.tsx` did. + +Every effect the route accepts writes `pet_battle_progress`. `grant_xp` moves level and xp; +`clear_battle_cooldown` moves `readyAt`. So the item vanished from the bag and the pet went on +showing its old level, or, for the cooldown tonic, went on showing as resting. That last one is +the worst reading available: the player spends an item specifically to battle again, and the +UI says they still cannot. + +- [x] **C6.1** Invalidate progression in the hook rather than asking callers to remember. + `useBattleProgress` gained `battleProgressQueryKey` / `battleProgressQueryPrefix`, and + `useSpendItem` invalidates the prefix, following `petEquipmentForPetsQueryPrefix` + exactly and for the same reason: progression is cached per *list* of pets a screen + asked about, and a mutation cannot know which lists exist. + Verify: `pnpm --filter @shared/core exec vitest run tests/hooks/useInventory.test.tsx`. + +The general point is worth keeping. A comment asking every future caller to pair a mutation +with an invalidation is a bug waiting for its second caller, and this one did not survive its +first. A mutation that knows what it changed should invalidate it. + +## Reviewed and found clean + +Recorded so a later pass does not repeat the work. Neither of these produced a change: + +- **`indexer-go`'s inventory ingest.** Two watermarks, genuinely separate, so a busy balance + stream cannot drag the equipment cursor past unread rows. Coalescing keeps the highest + version per key across all three streams identically, which is required rather than merely + efficient: two rows sharing a key in one `ON CONFLICT` statement is a Postgres error, not a + silent overwrite. Watermarks are in-memory and reprimed by a full scan on restart, and the + periodic reconcile scan covers the one real gap in `updatedAt_gt` polling, which is two + blocks sharing a timestamp. +- **The subgraph half.** Balances are re-read through `balanceOf` rather than accumulated + from deltas, so a missed event stales a row instead of corrupting it. Escrow-on-equip + writes an `ItemBalance` row owned by the `ItemCore` contract itself; that is storage noise + no player read touches, since `findBalances` filters by owner and by `quantity > 0`. + +## D2 (decision): drops are not verifiable, and the reason is not a missing field + +The description this section carried was wrong, in the same way D1's was, and inherited from +`drops.ts`'s own doc comment. Both said a receipt holder could recompute a drop and merely +lacked the means to *prove* a discrepancy. Neither is accurate. + +`rollDrops` reads three inputs. The seed and the battle id are in the signed receipt. The +third, `DropRates`, is a constant in `drops.ts`, and the pool it draws from is +`ITEM_CATALOG` filtered to non-equipment in `catalog.data.ts`. Neither reaches the ruleset, +so neither is covered by `rulesetHash` or by anything else the receipt names. **An outsider +holding a receipt and the published bundle cannot recompute the drop at all.** Someone +reading this source can; that is not the same property. + +The payout is not pinned by the receipt either. `rates` is a parameter, so the same seed and +battle id produce different answers under different odds, and no row records which applied. + +That is worth stating precisely, because the first version of this paragraph overstated it. +The only production caller passes no rates, so the odds in force are `DEFAULT_DROP_RATES`, a +constant that moves by code change and deploy. Git history is a real audit trail, just not +one a receipt holder can check. The anti-grinding property survives everything here and is +worth keeping: the operator cannot re-roll a committed seed. + +**That materially changes which option below is right.** "Operator-attested" is a much +stronger position when the attestation is a versioned constant in a public repository than +when it is a runtime value nobody records, and it is the former. The case for spending a +protocol schema version on this is correspondingly weaker than it looked. + +What makes this a decision rather than a fix: closing it means publishing the rates and the +drop pool, which puts non-equipment items into the ruleset. §4 rules that out on purpose, and +CLAUDE.md states why, that a `rulesetHash` moving every time a collectible is added would +re-consent every defender and train players to click through the one prompt that matters. So +verifiable drops and stable consent are in direct tension, and picking between them is a +product call. + +- [x] **D2.1 Make the claim honest.** Corrected in `drops.ts`, `CLAUDE.md`, and + `plan-inventory-items.md` §5, all three of which asserted recomputability. A false + verifiability claim is worse than a documented gap: it is the kind of thing a later + decision gets built on, and it nearly was here. +- [ ] **D2.2 Decide the tension, or decide to keep it.** Three options, with what each + actually costs now that the inputs are pinned down: + + 1. **Keep it, and say so.** Drops are operator-attested in v1: derived from a committed + seed the operator cannot re-roll, under odds that live in a versioned constant in a + public repository. Cost: a line in the player-facing docs. Buys no cryptographic + property, and forecloses nothing. + 2. **A separate drop-rules digest the receipt names.** Resolves the tension properly: + drop rules get their own hash and version independently of consent, so publishing + them never touches `rulesetHash`. Cost: a receipt schema version, permanently, plus + a second published artifact to serve forever (§H). + 3. **Fold drops into the receipt.** Strongest property, highest price: a receipt schema + version *and* the non-equipment catalog inside `rulesetHash`, which is the + re-consent-on-every-collectible outcome §4 explicitly rejected. + + **Taken: (1).** The gap is real but narrow, and what makes it narrow is that nobody can + grind a drop, which already holds. A permanent schema version is a poor trade for + making a constant checkable when the constant is already public. + + Chosen rather than recommended because the decision was repeatedly deferred back, and + (1) is the option that forecloses nothing: it adds disclosure and no protocol surface, + so (2) or (3) remain open at their original cost. Reverse it by deleting one tooltip. + +- [x] **D2.2a Disclose it where a player meets a drop.** A tooltip on the inventory's + "Waiting to be claimed" heading, saying both halves: the drop was fixed by public + randomness before the battle resolved and nobody can re-roll it, *and* the odds are + not checkable against a single receipt. Saying only the first would be the marketing + version of the same fact. + Verify: `pnpm --filter frontend lint:check && pnpm --filter frontend test`. +- [ ] **D2.2b Revisit at roadmap phase 04, not "eventually".** The condition that changes + this answer is a drop being worth money to someone other than the player who earned + it, and that is already scheduled: `landing.ts` lists a "Pet and item marketplace" in + phase 04, and the FAQ already tells players their items are tradable assets. Once an + item has a market price, "trust the constant in our repo" stops being proportionate + and option (2) is worth its schema version. Worth deciding *before* the marketplace + ships rather than after, since receipts signed in between are the ones that cannot be + upgraded. + +## C7: nobody could tell a defender their consent had gone stale + +Found while working out what D3 needs to ship well, which is the point of doing that before +the rollout rather than during it. + +The consent API had `POST /authorizations` and `DELETE /authorizations` and no `GET`. A +defender could grant consent and revoke it, and could not read it. So there was no way to +answer "have I consented?", and more importantly no way to answer "does my consent still +apply?" + +D3 invalidates every outstanding authorization at once. Without a read, here is what a +defender experiences: nothing. Being challenged is *passive*, so their pets simply stop +being challengeable and no screen they visit says otherwise. The only person who sees an +error is the attacker, who gets a clear message (`battleFailureMessage.ts` renders +`ruleset-mismatch` as "This opponent's consent was signed under older rules") and can do +nothing about it. The one person who can fix it is the one person not told. + +- [x] **C7.1** Add `GET /api/battle/authorizations?chainId=`, returning the caller's live + grants plus the `rulesetHash` now being served, each flagged `isStale` when it was + signed under a different one. Ungated like `DELETE`, and for the same reason: a + defender needs to see this precisely when something is off, so a mode flag should not + be what hides it. Always scoped to the authenticated wallet, never a queried address. + Verify: `pnpm --filter backend exec vitest run tests/features/battle/ledger/consent.service.test.ts`. +- [x] **C7.2 Surface it.** `useDefenseAuthorizations` in `@shared/core` reads the endpoint + and collapses it to one `ConsentStatus`, and `DefensePanel` states it above the + controls, because it changes what they mean: signing again after a rules change is a + repair, not a duplicate, and a player who cannot see the difference reads the same + button two ways. + + Three states, not two. `stale` is deliberately distinct from `none`: they ask the same + action of the player but are not the same message, and "you have not allowed + challenges" shown to someone who did reads as the app having forgotten. `unknown` + covers not-yet-loaded and no-wallet, so a disconnected visitor is never told nobody can + battle their pets, which would be a false statement about their account. `active` wins + whenever any grant is current, since a defender holding one usable authorization and + three superseded ones is covered and should not be told to re-sign. + + Both writes call `refresh()`, or the banner would contradict the success line directly + beneath it. + Verify: `pnpm --filter @shared/core exec vitest run tests/hooks/useDefenseAuthorizations.test.tsx`. + +Extracted while doing it: `chainIdFor` was written out identically in +`useDefenseAuthorization` and `useSubmitBattleIntent`, and this needed a third copy. It now +lives in `hooks/battle/chainIdFor.ts`. Harmless duplication right up until a deployment +serves two chains of one family and only one caller learns how to choose. + +## D3: shipping Phase 4 is a re-consent event + +Already recorded in `plan-inventory-items.md`. `ENGINE_VERSION` 1 to 2 plus the ruleset's item +catalog moves `rulesetHash` for every battle, so every outstanding `DefenseAuthorization` is +invalidated and every defender re-consents once. Intended behaviour, user-visible, ships +deliberately. If D1 lands as option 1, fold it into the same rollout and pay this once. + +--- + +## Code quality + +Small, none of them urgent. + +- [x] **Q1 Two caches, one reset each.** `inventory.service.ts:205` caches the catalog for the + process's life and `ruleset.builder.ts:30` caches a ruleset derived from it. Their reset + seams are separate (`resetItemCatalog`, `resetServedRuleset`), so clearing one leaves the + other holding data built from what was just dropped. Have `resetItemCatalog` clear both. +- [x] **Q2 Orphaned doc comment.** `env.ts:141-146` documents `adminWallets` directly above the + comment for `dropsEnabled`; the field itself is at line 156. Move the comment to its field. +- [x] **Q3 `verify.worker.ts:60-62` casts to `Record`** to read a shape the + codec from B1.1 will type properly. Folded into B1.1 rather than done twice. + +- [x] **Q4 `backend/scripts/` had no compiler watching it.** `backend/tsconfig.json` includes + `src/**/*` only, and `tsx` strips types rather than checking them, so nothing checked the + operator scripts and four type errors had accumulated. All pre-existing and unrelated to + this branch, but these are the files an operator points at the production database, so + they were worth fixing before O1 rather than after. + + Fixed rather than suppressed, and each was hiding something: + - `grant-defense-authorization.ts` narrowed a served `chainId` with `startsWith('eip155:')`, + which narrows nothing to the compiler. Now `assertChainId`, so a malformed value is + rejected at the boundary instead of inside the signature. + - The same file handed ethers the protocol's own readonly EIP-712 type list. Copied now, + rather than cast: the list is readonly because reordering it changes the digest. + - `seed-item-catalog.ts` wrote a bare `null` to a nullable Json column. Prisma rejects + that precisely because it cannot tell SQL NULL from JSON `null`; it wants `Prisma.DbNull`, + which is what the reader expects. + + Kept out of the main config on purpose: `pnpm build` runs `tsc` with it, so including the + scripts there would emit them into `dist/` and ship one-shot tools as server code. They get + `tsconfig.scripts.json` and a `typecheck:scripts` script instead, wired into `backend`'s + `lint`, which root `pnpm lint` already runs and `static-checks.yml` already enforces. No + workflow change needed. + Verify: `pnpm --filter backend lint`. + +## Operational, unblocked by code + +Carried over from `plan-inventory-items.md`'s "still outstanding", still outstanding. All three +are operator calls. + +Prepared ahead of them: the migration SQL was reviewed (RLS on all four tables, no `FORCE`, +matching the posture every other table has), and `verify-inventory-setup.ts` gained a +`catalog can price a fight` check. That one exists because C1 turned an unreadable equipment +row into a hard refusal, so a bad `effect` column now stops every accept with +`item-catalog-stale`. The seeder cannot produce that state, which is why nothing else in the +preflight would have caught it. + +- [ ] **O1 Apply the migration.** `20260807160000_add_inventory` has never run. RLS is correctly + present on all four new tables (`migration.sql:78-81`). `pnpm --filter backend prisma:migrate`, + which is `migrate deploy`, never `dev`. +- [ ] **O2 Run the seeder,** then `scripts/verify-inventory-setup.ts` to confirm the on-chain slot + registrations and `item_definition` agree. The chain half was registered directly during the + Base Sepolia deploy because the table did not exist; the seeder is idempotent and will find + every slot already correct. +- [ ] **O3 End-to-end, once.** The check at the end of `plan-inventory-items.md`. Neither web + screen has been opened against real data and the `ItemCore` write client is stubbed in every + test, so the first real exercise of grant, claim, equip, fight, verify is still ahead. Do it + after B1, or it will fail at signing regardless of anything inventory does. + +--- + +## Order + +B1 first and alone: nothing settles until it lands, so every other check runs against a stalled +pipeline. Then C1 and C2 together (one theme, adjacent code), then C3. D1 turned out to need no +schema change, so it no longer has to be sequenced against D3's re-consent; D3 is still a +one-time cost that Phase 4 forces on its own. O1 to O3 last, because they are the only steps +that touch production. + +All of that has landed. What remains is D2.2 (decide the verifiable-drops tension, or decide +to keep it), D3 (ship the re-consent deliberately), and the three operator steps, which want +running in that order: apply the migration, seed, then exercise it end to end. + +One note for whoever runs O3. It is the first time either web screen will be opened against +real data, and the review that produced C1 to C6 could not substitute for that: it read the +code, not the rendered page. Expect the remaining defects to be presentational, and expect +them to be found by looking rather than by reading. + +## S1-S3: structural, after the bugs were fixed + +Three follow-ups from reviewing what the fixes above had in common. None changes behaviour a +player sees; each closes the route by which one of the bugs arrived. + +- [x] **S1 Derive each protocol hash once.** `hashRuleset` ran at four call sites, each hashing + the result of its own `servedRuleset()` call, and matchmaking hashed `SOURCE_DEFAULT_RULESET` + instead. Defenders sign consent against the served ruleset, so that predicate matched no + authorization ever written and returned an empty opponent list on a deployment full of + consenting pets. `servedRulesetHash()` now derives it beside the ruleset and caches per + catalog generation; `hashRuleset` no longer appears in `backend/src` at all. Every other + protocol hash was already single-site, bar the two deliberate recompute-and-assert pairs in + `publish.worker` and the signer, which are the correct pattern and were left alone. This is + the same shape as B1 (`snapshotHash` computed twice) and as the bundle-publish hash: three + instances, one cause. +- [x] **S2 Delete `OUTBOX_TOPICS.batch`.** Declared, never enqueued, never dispatched. Worse + than untidy: `claimOutbox` builds its topic list from `HANDLERS`, so a `batch` message would + never have been claimed at all, let alone dead-lettered. It would sit pending forever with + its battle in a non-terminal state and both pets locked, which is the failure + `expireOrphanedAccepts` exists to clean up after. Batching aggregates across receipts on + `startBatchAnchor`'s timer, so there is no per-battle message to send. +- [x] **S3 Collapse `useBattlePanel`'s chained effects,** six to four, which is the one change + CLAUDE.md sanctions for that file. The result reveal was two effects chained through state: + one set `showResult`, the second existed only to raise the overlay a render later. The two + `battle.error` effects read the same signal, so their relative order came from where they + sat in the file. Both merged, each into one commit. Added a case for the late-failure guard, + which had no coverage and was held only by that accidental ordering. + +## R1-R3: the client could not explain the refusals the server was sending + +Found by checking, as with S1, whether two things that must agree actually did. Three did not. + +- [x] **R1 One code, two meanings.** `expired` was returned by both battle controllers: from + the intent path meaning the player's own request timed out, and from the accept path as the + `CoverageFailure` meaning the *defender's authorization* had lapsed. The client maps a code + to player-facing text with no idea which endpoint answered, so one of the two was always + wrong — and it was the one that mattered, telling the player to retry a thing that cannot + succeed until the defender re-grants. The intent-side code is now `intent-expired`, matching + what the accept path already called it. `expired` keeps only the protocol meaning. +- [x] **R2 Nine reasons had no text,** including `pet-locked`, `intent-expired` and every + operational one, so a player met `Battle refused: item-catalog-stale`. The status maps are + `Record` and TypeScript forces them complete, which is why neither + ever had a gap; the message map is `Record` and could not be checked the same + way, because the reasons are backend types and `@shared/core` must not import from `backend`. + `tests/features/battle/ledger/rejectionMessages.test.ts` now binds the message map to the two + status maps' keys. It caught a tenth on its first run: `session-not-authorized`, which is the + one refusal on the list a player clears themselves. +- [x] **R3 A dead session key stayed in storage.** On `session-not-authorized` the client + surfaced the error and stopped, leaving the key in `sessionStorage` to fail every subsequent + battle identically. The server has just said that key will never work, so the client now + discards it locally (not via `revoke`, which would ask the server to retract a delegation it + already refuses, and would leave the key behind if that call failed) and re-signs once with + the wallet. That is the pre-session path: it costs one prompt instead of the battle. Retried + once only, and only for that code. +- [x] **R3.1 Match the client's drop-set to matchmaking's filter.** `isConsentFailure` decides + whether to drop an opponent and re-read the list, and listed three of the six conditions + `hasConsent` filters on. A defender whose authorization expired, was not yet valid, or was + signed under an older ruleset therefore stayed in the list, so the player re-picked the one + choice that could not succeed. Now the same six, documented as being the same six. + +## Do not touch + +- `contracts/test-vectors/{battle,xp,equipment}.json`. Nothing here is a vector failure. +- Solana's frozen ports (`game/battle_sim.rs`, `game/xp.rs`). +- The snapshot and ruleset encoders. Both handle their two versions correctly; B1 is a caller + that stopped telling them which version it held. diff --git a/docs/plan-inventory-items.md b/docs/plan-inventory-items.md index ff77e908..70d4132c 100644 --- a/docs/plan-inventory-items.md +++ b/docs/plan-inventory-items.md @@ -39,7 +39,10 @@ protocol objects, and it is sequenced last for that reason. skill modifiers, with the sum clamped to 65535 rather than wrapped. Excluding negative modifiers removes any underflow question against `toUint16`'s wrap semantics. 5. **Drops derive from the battle's existing drand seed** (`keccak(seed, battleId, "DROP")`), - so a drop replays from the receipt like every other outcome. No second randomness system. + so no second randomness system and no drop the operator can grind. Note the second half + of this as written was wrong: a drop does **not** replay from the receipt like other + outcomes, because the rates and the drop pool are unpublished backend constants. See + D2 in [`plan-battle-inventory-hardening.md`](./plan-battle-inventory-hardening.md). ## Environment notes diff --git a/frontend/src/components/inventory/index.module.css b/frontend/src/components/inventory/index.module.css index 1012abff..69e9817f 100644 --- a/frontend/src/components/inventory/index.module.css +++ b/frontend/src/components/inventory/index.module.css @@ -22,41 +22,6 @@ /* Same pill tabs the leaderboard uses, in the page's amber. Outside `.scroll` on purpose: the tabs are chrome and stay put while the panel under them scrolls. */ -.tabs { - display: flex; - gap: 8px; - margin-bottom: 14px; - flex-shrink: 0; -} - -.tab { - padding: 6px 16px; - border-radius: 999px; - border: 1px solid rgb(251 191 36 / 25%); - background: transparent; - color: inherit; - font: inherit; - font-size: 0.9rem; - cursor: pointer; - opacity: 0.7; - transition: opacity 0.15s ease, border-color 0.15s ease, background 0.15s ease; -} - -.tab:hover { - opacity: 1; -} - -.tab:focus-visible { - outline: 2px solid rgb(251 191 36 / 85%); - outline-offset: 2px; -} - -.tab.isActive { - opacity: 1; - border-color: rgb(251 191 36 / 60%); - background: rgb(251 191 36 / 12%); -} - /* The part that scrolls, and the reason the rule above is not enough on its own. `.panel-body` is `overflow: hidden` so each page decides what scrolls rather than letting a long list push the whole shell; a page that declares no scrolling region simply gets @@ -288,6 +253,17 @@ margin-top: 0; } +/* Inline with the heading rather than absolutely placed like `.help`, which anchors to a + tile corner. The heading is uppercase and letter-spaced, so the glyph needs its own + line-height to sit on the text baseline instead of riding above it. */ +.headingHelp { + display: inline-flex; + align-items: center; + margin-left: 6px; + line-height: 0; + vertical-align: middle; +} + .pendingList { display: flex; flex-direction: column; diff --git a/frontend/src/components/inventory/index.tsx b/frontend/src/components/inventory/index.tsx index 5bf16ba3..2551634f 100644 --- a/frontend/src/components/inventory/index.tsx +++ b/frontend/src/components/inventory/index.tsx @@ -23,6 +23,7 @@ import PetSelect from '@components/ui/pet-select'; import Icon, { MuscleIcon, RefreshIcon } from '@components/ui/icon'; import InfoTooltip from '@components/ui/info-tooltip'; import NeonButton from '@components/ui/neon-button'; +import TabSwitch from '@components/ui/tab-switch'; import ItemDetailModal from './item-detail-modal'; import { DASHBOARD_HOME } from '@constants/interactionRoutes'; import { Tones } from '@constants/tones'; @@ -54,6 +55,11 @@ function petName(pets: { id: unknown; name: string }[], petId: string): string { * `ITEM_CATEGORIES` cannot pass backend validation, be stored, be returned by the API, and * then silently fail to render here because this list never heard about it. */ +const INVENTORY_TABS = [ + { id: 'bag', label: 'Bag' }, + { id: 'equipment', label: 'Equipment' }, +] as const; + const CATEGORY_RANK: Record = { consumable: 0, equipment: 1, @@ -333,20 +339,16 @@ const Inventory: React.FC = () => { > {/* One scrolling region for the whole body. `.panel-body` clips, so without this the bag is cut off at the panel's edge with no way to reach the rest. */} -
- {([['bag', 'Bag'], ['equipment', 'Equipment']] as const).map(([id, label]) => ( - - ))} -
+ setTab(next === 'equipment' ? { name: 'equipment', petId: null } : { name: 'bag' })} + label="Inventory" + tone="amber" + />
{tab.name === 'equipment' ? ( @@ -367,6 +369,25 @@ const Inventory: React.FC = () => {

Waiting to be claimed + {/* Where a player actually meets a drop, so it is where the + honest version of how one is decided belongs. Both halves + matter: nobody can re-roll a drop, and the odds are not + checkable against a single battle. Saying only the first + would be the marketing version. */} + + +

+ A battle drop is decided by the same public randomness that + decided the fight, fixed before the battle resolved. Nobody, + including us, can re-roll one. +

+

+ The drop odds live in our open-source code rather than being + published with each battle, so you can read them, but you + cannot check them against one receipt on your own. +

+
+

{/* Its own strip above the bag, because these are not items yet: claiming is what mints them, and until then there is nothing on diff --git a/frontend/src/components/leaderboard/index.module.css b/frontend/src/components/leaderboard/index.module.css index c52f23d3..cbf2caf8 100644 --- a/frontend/src/components/leaderboard/index.module.css +++ b/frontend/src/components/leaderboard/index.module.css @@ -24,37 +24,6 @@ height: 100%; } -.tabs { - display: flex; - gap: 8px; - margin-bottom: 14px; - /* Header-ish: never absorbs the space the list should get. */ - flex-shrink: 0; -} - -.tab { - padding: 6px 16px; - border-radius: 999px; - border: 1px solid rgb(251 191 36 / 25%); - background: transparent; - color: inherit; - font: inherit; - font-size: 0.9rem; - cursor: pointer; - opacity: 0.7; - transition: opacity 0.15s ease, border-color 0.15s ease, background 0.15s ease; -} - -.tab:hover { - opacity: 1; -} - -.tab.isActive { - opacity: 1; - border-color: rgb(251 191 36 / 60%); - background: rgb(251 191 36 / 12%); -} - /* The one part that scrolls. Everything else in the panel keeps its natural height, so the pager stays pinned below the rows instead of being pushed off the bottom. */ /* Podium and ledger scroll together: with a podium on screen the rows below are a diff --git a/frontend/src/components/leaderboard/index.tsx b/frontend/src/components/leaderboard/index.tsx index 2c3a9a39..71159d95 100644 --- a/frontend/src/components/leaderboard/index.tsx +++ b/frontend/src/components/leaderboard/index.tsx @@ -14,6 +14,7 @@ import DashboardPanel from '@components/common/dashboard-panel'; import SessionGate from '@components/common/session-gate'; import PetArt from '@components/pet/pet-art'; import Icon, { TrophyIcon } from '@components/ui/icon'; +import TabSwitch from '@components/ui/tab-switch'; import { DASHBOARD_HOME } from '@constants/interactionRoutes'; import { Tones } from '@constants/tones'; import styles from './index.module.css'; @@ -21,6 +22,11 @@ import styles from './index.module.css'; /** Which ranking is showing. Pets is the default: it is the one with a pet in it. */ type Board = 'pets' | 'players'; +const BOARD_TABS = [ + { id: 'pets', label: 'Pets' }, + { id: 'players', label: 'Players' }, +] as const satisfies readonly { id: Board; label: string }[]; + /** Win rate as a percentage, or null when the row has no battles to divide by. */ function winRate(wins: number, losses: number): number | null { const fought = wins + losses; @@ -327,20 +333,7 @@ const Leaderboard: React.FC = () => { description="Ranked by wins, then by fewest losses" back={goBack} > -
- {(['pets', 'players'] as const).map((tab) => ( - - ))} -
+
` already imposes. */ + Pinned to a corner of the pet's art. Absolute, so the caller's art container has to be a + positioned ancestor — the same requirement `` already imposes. */ .strip, .stripMd { position: absolute; right: 6px; - bottom: 6px; z-index: 2; display: flex; - /* Stacked, not in a row. A column costs one icon's width along the bottom edge instead - of three, which leaves more of the pet visible on a card whose art is the point — and - with `bottom` pinned the stack grows upward, away from the name and stats that sit - under the art. Slot order still runs weapon, armor, trinket, top to bottom. */ + /* Stacked, not in a row. A column costs one icon's width along the edge instead of + three, which leaves more of the pet visible on a card whose art is the point. Slot + order runs weapon, armor, trinket, and the stack grows away from the pinned edge, so + it reads top to bottom either way. */ flex-direction: column; gap: 3px; /* Decoration over a click target: the card underneath owns the interaction, and an icon @@ -21,6 +20,18 @@ pointer-events: none; } +/* Right for a gallery card: nothing is drawn over the art, so the gear keeps clear of the + pet's face. */ +.cornerBottom { + bottom: 6px; +} + +/* Right for a combatant bay, where the name, stat row and HP bar are drawn across the bottom + of the same box the art fills — gear pinned low lands on the readout rather than beside it. */ +.cornerTop { + top: 6px; +} + .badge { display: block; width: 22px; diff --git a/frontend/src/components/pet/equipped-badges.tsx b/frontend/src/components/pet/equipped-badges.tsx index 2175eea0..6c6d257f 100644 --- a/frontend/src/components/pet/equipped-badges.tsx +++ b/frontend/src/components/pet/equipped-badges.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import clsx from 'clsx'; import { getRarityColor, itemArtUrl as buildItemArtUrl, type EquippedItem } from '@shared/core'; import styles from './equipped-badges.module.css'; @@ -14,6 +15,11 @@ import styles from './equipped-badges.module.css'; * container — the same one `` fills. Both current callers already qualify, * because a filling image needs a positioned ancestor for exactly the same reason. * + * Which corner it pins to is the caller's call because it depends on what else is drawn over + * the art. A gallery card has nothing below, so the gear sits bottom-right, out of the way of + * the pet's face. A combatant bay overlays a name, a stat row and an HP bar across the bottom + * of the same box, so gear pinned there lands on top of the readout. + * * Renders nothing for a bare pet rather than an empty strip: most pets have no gear, and a * placeholder on every card would cost more attention than the feature is worth. */ @@ -31,9 +37,20 @@ export type EquippedBadgesProps = { rarity: number; /** Bigger on a combatant card, which is the subject of its screen. */ size?: 'sm' | 'md'; + /** + * Which corner of the art to pin to. Defaults to the bottom, which is right wherever the + * art is the whole card; pass `top-right` when the caller draws a readout across the + * bottom of the same box. + */ + corner?: 'bottom-right' | 'top-right'; }; -const EquippedBadges: React.FC = ({ equipped, rarity, size = 'sm' }) => { +const EquippedBadges: React.FC = ({ + equipped, + rarity, + size = 'sm', + corner = 'bottom-right', +}) => { if (!equipped || equipped.length === 0) return null; // By slot, so a pet's icons do not reshuffle between renders or between cards. Sorted on @@ -43,7 +60,10 @@ const EquippedBadges: React.FC = ({ equipped, rarity, size return (
void; opponentsLoading: boolean; + /** Why the picker is empty, when it is. Null whenever there is anything to show. */ + opponentsEmptyReason: OpponentsEmptyReason | null; onRefreshOpponents: () => void; onBattle: () => void; battleDisabled: boolean; @@ -55,7 +59,15 @@ const STAT_KEYS = [ { label: 'VIT', key: 'life' }, ] as const; -/** One combatant's full stat card (fighter or rival), or an empty prompt. */ +/** + * One combatant bay: the arena slot, its readout, and whoever is standing in it. + * + * The readout renders whether or not a pet does. An empty bay used to be a dashed box + * with one line of centred text, which on a tall panel is mostly void — and void is the + * state a player lands on, since neither side is chosen yet. Drawing the frame either + * way means an empty bay shows the shape of what will fill it, and picking a fighter + * fills that frame in place instead of swapping one layout for another. + */ const CombatantCard: React.FC<{ pet: Pet | OpponentPet | null; side: 'fighter' | 'rival'; @@ -64,68 +76,94 @@ const CombatantCard: React.FC<{ /** Gear this combatant is wearing. It changes the fight, so it is worth seeing first. */ equipped?: readonly EquippedItem[]; }> = ({ pet, side, emptyLabel, owner, equipped }) => { - if (!pet) { - return ( -
- {emptyLabel} -
- ); - } - const props = getPetProperties(pet); - const rarityColor = getRarityColor(pet.rarity); - const hp = getLifePercent(pet); - return ( -
- {/* Art fills the card, and the pet's numbers read over it. The - emoji class goes on the glyph rather than this wrapper: it - carries a drop-shadow and an animated transform, and either - would become the containing block for the filling image and - pin it to the emoji's size instead of the card's. */} -
- - -
- {/* Nothing here is legible over arbitrary generated art without it. */} -
+ const rival = side === 'rival'; + const props = pet ? getPetProperties(pet) : null; + const hp = pet ? getLifePercent(pet) : 0; -
-
{pet.name}
-
- Lv.{pet.level} · {getPetClass(pet.dna)} ·{' '} - - {getRarityName(pet.rarity).toUpperCase()} - -
- {owner ?
{owner}
: null} -
- {STAT_KEYS.map((stat) => ( -
-
{stat.label}
-
{props[stat.key]}
+ return ( +
+
+ {pet ? ( + <> + {/* Art fills the bay and the numbers read over it. The emoji class goes + on the glyph rather than this wrapper: it carries a drop-shadow and + an animated transform, and either would become the containing block + for the filling image and pin it to the emoji's size. */} +
+ +
- ))} -
-
-
- HP - {hp}/100 + {/* Nothing below is legible over arbitrary generated art without it. */} +
+ + ) : ( + /* An empty arena rather than an empty box: floor, plinth, and a sweep + passing over the spot the fighter will stand on. */ +
+
+
+
-
-
+ )} + +
+ {pet ? ( + <> +
{pet.name}
+
+ Lv.{pet.level} · {getPetClass(pet.dna)} ·{' '} + + {getRarityName(pet.rarity).toUpperCase()} + +
+ {owner ?
{owner}
: null} + + ) : ( +
{emptyLabel}
+ )} + +
+ {STAT_KEYS.map((stat) => ( +
+
{stat.label}
+ {props ? ( +
{props[stat.key]}
+ ) : ( +
+ )} +
+ ))} +
+ +
+
+ HP + {pet ? {hp}/100 : null} +
+
+ {pet ? ( +
+ ) : null} +
@@ -148,6 +186,7 @@ const BattleSetup: React.FC = ({ selectedOpponentKey, onSelectOpponent, opponentsLoading, + opponentsEmptyReason, onRefreshOpponents, onBattle, battleDisabled, @@ -175,6 +214,13 @@ const BattleSetup: React.FC = ({ ? `${Math.round(winEstimate.winProbability * 100)}%` : '—'; + // The rail only draws a split it actually has. Without an estimate it shows neutral + // hatching instead of filling to 50%, which would read as a real even match rather + // than as "no estimate yet" — the two mean different things to someone deciding + // whether to take the fight. + const hasOdds = !winEstimate.isLoading && winEstimate.winProbability != null; + const oddsPct = Math.round((winEstimate.winProbability ?? 0.5) * 100); + // The rival list is keyed by owner+id, not by pet id: two players can hold the same // token id on different chains, and the panel reports the composite key back. const opponentOptions = useMemo( @@ -182,10 +228,13 @@ const BattleSetup: React.FC = ({ [sortedOpponents], ); + // Names which of four situations produced the blank picker. They are identical to a + // player and only some are theirs to act on, so "none" alone sends people looking for + // a mistake that may not be theirs. const opponentEmpty = opponentsLoading ? 'Finding challengers…' : sortedOpponents.length === 0 - ? 'No opponents available' + ? describeNoOpponents(opponentsEmptyReason) : 'Select an opponent'; return ( @@ -228,15 +277,22 @@ const BattleSetup: React.FC = ({ />
- {/* VS + win rate */} + {/* The spine. One rail spanning both bays, rather than a VS floating between + two hairlines that joined nothing and a win-rate box off to itself. */}
VS
-
+
+ {hasOdds ? ( + <> +
+
+ + ) : null} +
Win Rate
{winRate}
-
{/* On-chain rival */} diff --git a/frontend/src/components/pet/interactions/panels/defense/index.module.css b/frontend/src/components/pet/interactions/panels/defense/index.module.css index ceea6e47..2d54b727 100644 --- a/frontend/src/components/pet/interactions/panels/defense/index.module.css +++ b/frontend/src/components/pet/interactions/panels/defense/index.module.css @@ -41,3 +41,58 @@ color: rgb(251 113 133); font-size: 0.85rem; } + +/* Current consent state, stated before the controls because it changes what they mean. + Three tones rather than one: a stale grant is a problem to repair, no grant is a choice + not yet made, and an active grant is reassurance. Rendering all three the same way would + make the one that needs acting on look like the two that do not. */ +.status { + margin: 0 0 14px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid; + font-size: 0.85rem; + line-height: 1.5; +} + +/* Amber, not red: nothing is broken and nothing was lost. The grant did exactly what it + promised by expiring with the rules it was signed under. */ +.stale { + composes: status; + border-color: rgb(251 191 36 / 45%); + background: rgb(251 191 36 / 8%); + color: rgb(251 191 36 / 92%); +} + +.active { + composes: status; + border-color: rgb(52 211 153 / 35%); + background: rgb(52 211 153 / 8%); + color: rgb(52 211 153 / 92%); +} + +.inactive { + composes: status; + border-color: rgb(148 163 184 / 30%); + background: rgb(148 163 184 / 8%); + opacity: 0.85; +} + +/* Delegated battle signing, below the consent controls. Visually separated because the two + grants are easy to confuse: consent lets others challenge you, a session lets you start + battles without a prompt each time. */ +.session { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid rgb(148 163 184 / 18%); +} + +.sessionLabel { + font-size: 0.82rem; + line-height: 1.45; + opacity: 0.8; +} diff --git a/frontend/src/components/pet/interactions/panels/defense/index.tsx b/frontend/src/components/pet/interactions/panels/defense/index.tsx index 733c7e3e..f7d89176 100644 --- a/frontend/src/components/pet/interactions/panels/defense/index.tsx +++ b/frontend/src/components/pet/interactions/panels/defense/index.tsx @@ -1,6 +1,12 @@ import React, { useState } from 'react'; import NeonButton from '@components/ui/neon-button'; -import { useChainCapabilities, useDefenseAuthorization, usePetList } from '@shared/core'; +import { + useChainCapabilities, + useDefenseAuthorization, + useBattleSession, + useDefenseAuthorizations, + usePetList, +} from '@shared/core'; import { useNotifyError } from '@hooks/useNotifyError'; import Icon, { CheckIcon } from '@components/ui/icon'; import { Tones } from '@constants/tones'; @@ -23,6 +29,14 @@ const DefensePanel: React.FC = ({ isStandaloneView = true }) const { pets } = usePetList(); const notifyError = useNotifyError(); const { grant, revoke, isPending, error } = useDefenseAuthorization(); + // What the panel could not say before: whether consent exists, and whether it still + // applies. A rules change invalidates every grant by design, and being challenged is + // passive, so without this a defender's pets go quiet and nothing here admits it. + const { status, refresh } = useDefenseAuthorizations(); + // Delegated battle signing (§D). Lives here because this is already the screen about + // what the wallet has authorized, and the two grants are easier to tell apart side by + // side than scattered across the app. + const session = useBattleSession(); const [allPets, setAllPets] = useState(true); const [selected, setSelected] = useState([]); @@ -39,6 +53,10 @@ const DefensePanel: React.FC = ({ isStandaloneView = true }) setSuccess(null); const hash = await grant(allPets ? { allPets: true } : { petIds: selected }); if (hash) { + // Both writes re-read: the status banner is the only thing that says whether the + // grant took, so leaving it on a cached answer would contradict the success line + // directly underneath it. + refresh(); setSuccess( allPets ? 'Every pet you own can now be challenged.' @@ -50,6 +68,7 @@ const DefensePanel: React.FC = ({ isStandaloneView = true }) const handleRevoke = async () => { setSuccess(null); if (await revoke()) { + refresh(); setSuccess('Consent withdrawn. Your pets can no longer be challenged.'); } }; @@ -66,6 +85,58 @@ const DefensePanel: React.FC = ({ isStandaloneView = true }) )} + {/* Above the controls, because it changes what the buttons mean. Signing + again when a grant went stale is a repair, not a duplicate, and a player + who cannot see the difference reads the same button two ways. */} + {status.kind === 'stale' && ( +

+ The battle rules changed since you allowed challenges, so your consent + no longer covers anything and your pets cannot be challenged. Allow + challenges again to restore it. +

+ )} + {status.kind === 'active' && ( +

+ Your pets can be challenged under the current rules. +

+ )} + {status.kind === 'none' && ( +

+ You have not allowed challenges, so nobody can battle your pets. +

+ )} + + {/* Delegated battle signing (§D). Separate from consent above, and the two + are easy to confuse: that one lets *others* challenge you, this one + lets you start battles without a wallet prompt each time. */} + {session.supported && ( +
+ + {session.key + ? 'Battles are signed for this tab, so no wallet prompt each time.' + : 'Approve a battle session to stop confirming every fight in your wallet.'} + + { + setSuccess(null); + if (session.key) { + void session.revoke(); + return; + } + void session.approve().then((key) => { + if (key) setSuccess('Battle session approved for the next 24 hours.'); + }); + }} + > + {session.isPending ? 'Signing…' : session.key ? 'End session' : 'Approve session'} + +
+ )} + {session.error &&

{session.error.message}

} +