Skip to content

Commit 1513fa1

Browse files
authored
Merge pull request #75 from radcrew/feat/battle-hardening
Harden the battle and inventory seam, consent, and receipt signing
2 parents 523f4f5 + d0f54bf commit 1513fa1

138 files changed

Lines changed: 9320 additions & 1590 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ indexer-go writes**, under the same monotonic `last_version` guard `pet_roster`
211211
`item_entitlement` is backend-owned and holds earned-but-unminted drops. Reads join a
212212
projection to the catalog in TypeScript rather than SQL, so the two owners stay visible.
213213

214-
Three things are easy to get wrong here:
214+
Four things are easy to get wrong here:
215215

216216
- **Equipping escrows the token into `ItemCore`, and only the player can send it.**
217217
`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:
225225
emptied slot writes `item_type "0"`, because indexer-go resumes from an `updatedAt`
226226
watermark and a deleted row is one it never learns about. Zero is a value, not an absence.
227227
- **Battle drops derive from the battle's own drand seed**, committed before the fight
228-
resolves, so nobody including the operator can grind one and anyone holding the receipt
229-
can recompute it. They are written in the *same transaction* as the receipt, the rule
230-
`battle_history` already follows. The honest limit: the drop is not inside the signed
231-
payload in v1, so an outsider can recompute what was owed and notice a discrepancy but
232-
cannot prove one from the receipt alone.
228+
resolves, so nobody including the operator can grind one. They are written in the *same
229+
transaction* as the receipt, the rule `battle_history` already follows. The honest limit
230+
is larger than it used to say here: an outsider holding the receipt **cannot** recompute
231+
the drop at all. The rates and the drop pool are backend constants (`drops.ts`,
232+
`catalog.data.ts`), neither reaches the ruleset, and only the seed and battle id are
233+
signed, so the payout is not pinned by the receipt either. Publishing them would put
234+
non-equipment items into `rulesetHash`, which §4 rules out because adding a collectible
235+
would then re-consent every defender. Tracked as D2 in
236+
`docs/plan-battle-inventory-hardening.md`.
237+
238+
- **The catalog has a lenient read and a strict one, and combat must use the strict one.**
239+
`getCatalog`/`getPetEquipment` hide a row they cannot read, which is right for a bag: one
240+
unnamed tile beats a bag that will not open. `getCombatCatalog`/`getPetEquipmentForCombat`
241+
throw instead, and `servedRuleset` and `snapshot.builder` use those. The difference is not
242+
fussiness: an unreadable equipment row dropped from `itemCatalog` moves `rulesetHash` and
243+
invalidates every outstanding defence authorization, and an uncatalogued equipped item
244+
dropped from a snapshot produces a receipt saying the pet fought bare while
245+
`ItemCore.equipmentOf` at `sourceVersion` says otherwise. Acceptance turns either into an
246+
`item-catalog-stale` rejection rather than fighting under rules it cannot state.
233247

234248
Equipment reaching combat is what made this expensive, and it is why `snapshot` and
235249
`ruleset` both went to schema v2 (see the combat-simulator section above). The snapshot
236250
freezes **resolved modifiers plus the item type**: the modifiers so unequipping after
237-
acceptance cannot change a committed fight, the item type so `@cryptopets/verifier`'s
238-
`equipment` check can confirm those modifiers were the ones the catalog declares. Replay
239-
alone cannot do that — a receipt granting +50 ATK from a 4-ATK dagger replays perfectly.
240-
What remains unproven is *ownership* of the item, which is a claim about chain state at
241-
`sourceVersion` that the verifier deliberately cannot read.
251+
acceptance cannot change a committed fight, the item type so the `equipment` check can
252+
confirm those modifiers were the ones the catalog declares. Replay alone cannot do that — a
253+
receipt granting +50 ATK from a 4-ATK dagger replays perfectly. That check is
254+
`findEquipmentMismatches` in `@cryptopets/protocol`, and it has **two** callers on purpose:
255+
`@cryptopets/verifier` runs it on a finished receipt, and `accept.service` runs it before a
256+
battle starts, so a fight guaranteed to fail verification is refused rather than held. One
257+
implementation because two would drift into a battle that accepts and then cannot be
258+
verified, with the comparison itself the last thing anyone would suspect. What remains
259+
unproven is *ownership* of the item, which is a claim about chain state at `sourceVersion`
260+
that the verifier deliberately cannot read.
242261

243262
`servedRuleset()` joins the live catalog onto `SOURCE_DEFAULT_RULESET` and caches for the
244263
process's life, so a catalog edit needs a restart. That is deliberate: it moves

backend/API.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,12 +419,18 @@ to check independently.
419419
The four write routes are gated on `BATTLE_BACKEND_MODE_ENABLED` and return **503
420420
`backend-battle-mode-disabled`** when it is off. Every read below stays served regardless:
421421
switching the mode off stops new battles, it does not retract receipts already issued.
422-
`DELETE /authorizations` is ungated too, since withdrawing consent must keep working.
422+
`DELETE /authorizations` is ungated too, since withdrawing consent must keep working, and
423+
`GET /authorizations` for the same reason: a defender needs to see that their consent went
424+
stale precisely when something is off, and a mode flag should not be what hides it.
425+
`DELETE /sessions` is ungated too, so withdrawing a delegated key never depends on a flag.
423426

424427
| POST | `/api/battle/intents` | JWT | Submit a signed battle intent (§D). |
425428
| POST | `/api/battle/intents/:intentHash/accept` | JWT | Freeze the snapshot, commit to a future drand round, sign the commitment, and return it synchronously (§E). |
426429
| POST | `/api/battle/authorizations` | JWT | Submit a signed standing defence authorization (§D). |
427430
| 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. |
431+
| 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. |
432+
| 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. |
433+
| 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. |
428434
| 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. |
429435
| GET | `/api/battle/:battleId` | none | Battle state summary: state, failure reason, both pets, ruleset hash. |
430436
| 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. |

backend/env.example

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,12 +151,48 @@ DIRECT_URL="postgresql://postgres.<project-ref>:<password>@aws-1-<region>.pooler
151151
# BATTLE_SIGNER_KEY_ID=battle-signer-2026-07
152152
# Dev/test only. Any secp256k1 key; never a wallet holding funds.
153153
# BATTLE_SIGNER_PRIVATE_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
154-
# Required in production once an adapter exists, e.g. aws-kms. Unimplemented providers fail
155-
# loudly rather than degrading to the in-process key.
154+
# Required in production. Only aws-kms has an adapter; anything else fails loudly rather than
155+
# degrading to the in-process key.
156156
# BATTLE_SIGNER_KMS_PROVIDER=aws-kms
157+
#
158+
# The KMS's own identifier: an ARN, a key id, or alias/battle-signer. Kept separate from
159+
# BATTLE_SIGNER_KEY_ID because that one is stamped into every receipt permanently, and an ARN
160+
# carries the account id and changes if the key is re-imported or moved. Defaults to
161+
# BATTLE_SIGNER_KEY_ID when a deployment genuinely uses one name for both.
162+
# BATTLE_SIGNER_KMS_KEY_ID=arn:aws:kms:us-east-1:111122223333:key/1234abcd-...
163+
#
164+
# Omit when the runtime already supplies a region (ECS task role, Lambda, EC2).
165+
# BATTLE_SIGNER_KMS_REGION=us-east-1
166+
#
167+
# Separate keys per reward domain (§G). One key signing both EVM and Solana means a
168+
# compromise of either is a compromise of both (threat T4), so a deployment serving both
169+
# families must name a key for each and the signer refuses to start otherwise. A deployment
170+
# serving one family needs none of these: with a single domain there is nothing to separate,
171+
# and the shared values above are used.
172+
# BATTLE_SIGNER_EVM_KEY_ID=battle-signer-evm-2026-07
173+
# BATTLE_SIGNER_EVM_KMS_KEY_ID=arn:aws:kms:us-east-1:111122223333:key/...
174+
# BATTLE_SIGNER_EVM_PRIVATE_KEY=0x... # dev/test only
175+
# BATTLE_SIGNER_SOLANA_KEY_ID=battle-signer-solana-2026-07
176+
# BATTLE_SIGNER_SOLANA_KMS_KEY_ID=arn:aws:kms:us-east-1:111122223333:key/...
177+
# BATTLE_SIGNER_SOLANA_PRIVATE_KEY=0x... # dev/test only
178+
#
179+
# The AWS key must be created with key spec ECC_SECG_P256K1 and usage SIGN_VERIFY. P-256 is
180+
# accepted by the API and produces signatures that recover to nothing here, so it is checked
181+
# at startup rather than discovered on the first battle. Credentials come from the default
182+
# provider chain (instance/task role preferred) — a key reachable only with long-lived secrets
183+
# held by this process is a key whose isolation is partial. The IAM policy should allow
184+
# kms:Sign and kms:GetPublicKey and nothing else.
157185
# Implementations that must attest to a receipt hash before it can be signed. This is §F's
158-
# circuit breaker as a precondition: with no agreement there is no signature to be had. Add
159-
# go-verifier once the independent Go verifier is wired up. Default: typescript-engine
186+
# circuit breaker as a precondition: with no agreement there is no signature to be had.
187+
#
188+
# Default: typescript-engine,go-verifier — the independent Go recomputation is required, so a
189+
# receipt cannot exist without both engines having agreed on that exact hash. This costs
190+
# nothing on the happy path (a battle only reaches the signer via `verified`, which is set in
191+
# the same transition that records the Go result), and it means the check lives at the one
192+
# place a receipt is actually produced rather than only earlier in the pipeline.
193+
#
194+
# Narrowing this to typescript-engine alone disables §F's breaker at the signer. Only do that
195+
# knowingly, e.g. to drain a queue during an indexer-go outage, and put it back.
160196
# BATTLE_SIGNER_REQUIRED_ATTESTERS=typescript-engine,go-verifier
161197

162198
# How long a battle waits on its committed drand round before forfeiting (§E). Measured from

backend/package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
"node": ">=20"
99
},
1010
"scripts": {
11-
"build": "prisma generate && tsc && node scripts/copy-proto.cjs && node scripts/bundle-shared-node.cjs && node scripts/bundle-protocol.cjs",
11+
"build": "pnpm clean && prisma generate && tsc && node scripts/copy-proto.cjs && node scripts/bundle-shared-node.cjs && node scripts/bundle-protocol.cjs",
1212
"start": "node dist/src/server.js",
1313
"dev": "nodemon",
14-
"clean": "rm -rf dist",
14+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
1515
"postinstall": "prisma generate",
1616
"prisma:generate": "prisma generate",
1717
"prisma:migrate": "prisma migrate deploy",
@@ -23,17 +23,19 @@
2323
"test": "vitest run",
2424
"test:watch": "vitest",
2525
"test:coverage": "vitest run --coverage",
26-
"lint": "eslint .",
26+
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
27+
"lint": "eslint . && pnpm typecheck:scripts",
2728
"lint:fix": "eslint . --fix"
2829
},
2930
"dependencies": {
3031
"@ai-sdk/openai": "^3.0.68",
32+
"@aws-sdk/client-kms": "^3.1107.0",
3133
"@coral-xyz/anchor": "^0.32.0",
3234
"@cryptopets/protocol": "workspace:*",
3335
"@grpc/grpc-js": "^1.14.4",
36+
"@grpc/proto-loader": "^0.8.1",
3437
"@noble/curves": "^1.9.7",
3538
"@noble/hashes": "^1.8.0",
36-
"@grpc/proto-loader": "^0.8.1",
3739
"@prisma/adapter-pg": "^7.8.0",
3840
"@prisma/client": "^7.8.0",
3941
"@shared/core": "workspace:*",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Drop the unique constraint on battle_ruleset.version.
2+
--
3+
-- `ruleset_hash` is the primary key and the identity: it is what a receipt names and the
4+
-- only thing a bundle is ever looked up by. `version` is descriptive.
5+
--
6+
-- The unique held only while the ruleset was a pure constant. Roadmap §4 folded the item
7+
-- catalog into it, so its *content* now varies while `version` stays 1, and the constraint
8+
-- meant a second bundle could never be inserted. The failure was worse than a hard error:
9+
-- it surfaced as a P2002 that `ensureRulesetPublished` read as "a concurrent accept already
10+
-- published this", so accept reported success having written nothing, and every battle
11+
-- naming the new hash dead-lettered in `compute` with "no published ruleset bundle".
12+
--
13+
-- No RLS statement here: this alters an existing table rather than creating one, and
14+
-- battle_ruleset already has row level security enabled.
15+
DROP INDEX IF EXISTS "battle_ruleset_version_key";
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
-- Delegated battle-intent signing (§D).
2+
--
3+
-- §D requires the wallet, not a JWT, to authorize a battle, because a JWT is a bearer token
4+
-- this server issues to itself. That rule stands: the delegated key is generated and held by
5+
-- the client, so the operator still cannot forge an intent. The delegation only removes the
6+
-- per-battle wallet prompt.
7+
--
8+
-- Not referenced by any receipt. Public replay never checks intent signatures, so this is an
9+
-- authorization gate rather than evidence.
10+
CREATE TABLE "session_delegation" (
11+
"delegation_hash" TEXT NOT NULL,
12+
"chain_id" TEXT NOT NULL,
13+
"deployment_id" TEXT NOT NULL,
14+
"owner" TEXT NOT NULL,
15+
"session_key" TEXT NOT NULL,
16+
"scope" TEXT NOT NULL,
17+
"not_before" BIGINT NOT NULL,
18+
"expires_at" BIGINT NOT NULL,
19+
"revocation_nonce" INTEGER NOT NULL,
20+
"signature" TEXT NOT NULL,
21+
"signature_format" TEXT NOT NULL,
22+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
23+
"revoked_at" TIMESTAMP(3),
24+
25+
CONSTRAINT "session_delegation_pkey" PRIMARY KEY ("delegation_hash")
26+
);
27+
28+
-- CreateIndex
29+
CREATE INDEX "session_delegation_chain_id_deployment_id_owner_idx" ON "session_delegation"("chain_id", "deployment_id", "owner");
30+
31+
-- Looked up by the recovered signer on every intent, so this is the hot path.
32+
CREATE INDEX "session_delegation_chain_id_deployment_id_session_key_idx" ON "session_delegation"("chain_id", "deployment_id", "session_key");
33+
34+
-- EnableRowLevelSecurity
35+
--
36+
-- Required on every new table (see CLAUDE.md): Supabase's ALTER DEFAULT PRIVILEGES grants
37+
-- each newly created table in `public` to `anon` and `authenticated` with ALL privileges, so
38+
-- a table shipped without this is readable and deletable by anyone holding the project's
39+
-- public anon key. Here that would mean reading which key may act for which wallet, and
40+
-- deleting revocations.
41+
--
42+
-- Enabled with no policies, matching every other table: that denies the PostgREST roles
43+
-- everything while the backend connects as the owner and bypasses RLS. Do NOT add FORCE.
44+
ALTER TABLE "session_delegation" ENABLE ROW LEVEL SECURITY;

backend/prisma/schema.prisma

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,42 @@ model BattleIntent {
303303
}
304304

305305
/// A defender's standing, revocable permission to be challenged while offline (§D).
306+
/// A wallet's short-lived permission for a client-held key to sign battle intents (§D).
307+
///
308+
/// §D requires the *wallet* to authorize a battle, because a JWT is a bearer token this
309+
/// server issues to itself and so proves nothing about the owner's intent. That stays true:
310+
/// the delegated key is generated and held by the client, never by us, so an operator still
311+
/// cannot forge an intent. What the delegation removes is the per-battle wallet prompt.
312+
///
313+
/// Deliberately absent from every receipt. Public replay never checks intent signatures, so
314+
/// this is an authorization gate rather than evidence, and keeping it out of the signed
315+
/// record means the mechanism can be revised without invalidating a single receipt.
316+
model SessionDelegation {
317+
/// `hashSessionDelegation` from @cryptopets/protocol.
318+
delegationHash String @id @map("delegation_hash")
319+
chainId String @map("chain_id")
320+
deploymentId String @map("deployment_id")
321+
owner String
322+
/// The client-held key this authorizes. Compared against the recovered intent signer.
323+
sessionKey String @map("session_key")
324+
/// What the key may sign. One value today ('battle-intent'); widening needs a new
325+
/// schema version and a fresh prompt, never a silent grant.
326+
scope String
327+
notBefore BigInt @map("not_before") // unix seconds
328+
expiresAt BigInt @map("expires_at")
329+
revocationNonce Int @map("revocation_nonce")
330+
signature String
331+
signatureFormat String @map("signature_format")
332+
createdAt DateTime @default(now()) @map("created_at")
333+
/// Revocation is immediate. Kept rather than deleted so an audit can still see which key
334+
/// was allowed to act, and when that stopped.
335+
revokedAt DateTime? @map("revoked_at")
336+
337+
@@index([chainId, deploymentId, owner])
338+
@@index([chainId, deploymentId, sessionKey])
339+
@@map("session_delegation")
340+
}
341+
306342
model DefenseAuthorization {
307343
/// `hashDefenseAuthorization` from @cryptopets/protocol.
308344
authorizationHash String @id @map("authorization_hash")
@@ -547,7 +583,13 @@ model BattleBatch {
547583
/// can replay.
548584
model BattleRuleset {
549585
rulesetHash String @id @map("ruleset_hash")
550-
version Int @unique
586+
/// Descriptive, not an identity: `rulesetHash` is what a receipt names and what makes a
587+
/// bundle findable. This was `@unique`, which held only while the ruleset was a pure
588+
/// constant. Roadmap §4 made the item catalog part of it, so content now varies while
589+
/// `version` stays 1, and the unique meant the second bundle could never be written.
590+
/// It failed as a `version` conflict that read like a concurrent publish, so accept
591+
/// reported success, published nothing, and the battle died in `compute`.
592+
version Int
551593
engineId String @map("engine_id")
552594
engineVersion Int @map("engine_version")
553595
/// The full bundle exactly as published.

0 commit comments

Comments
 (0)