Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c0b156f
fix(backend): decode the stored battle snapshot through one codec
heyradcode Aug 10, 2026
17e1f92
fix(backend): refuse a battle the item catalog cannot price
heyradcode Aug 10, 2026
d44c879
feat(protocol): refuse gear the ruleset does not price, at acceptance
heyradcode Aug 10, 2026
0a2adf0
fix(backend): typecheck the operator scripts
heyradcode Aug 10, 2026
c72b835
fix(backend): stop a self-battle swallowing one of its own drops
heyradcode Aug 10, 2026
441f50f
docs: correct the claim that battle drops are recomputable
heyradcode Aug 10, 2026
3bafd1e
fix(backend): stop an unconfirmed mint paying an entitlement twice
heyradcode Aug 10, 2026
4fab6dc
fix(shared): refresh pet progression when a consumable is spent
heyradcode Aug 10, 2026
8c66185
docs: record the consent cost of raising MAX_STAT_BONUS
heyradcode Aug 10, 2026
f803d33
feat(frontend): disclose how battle drops are decided
heyradcode Aug 10, 2026
c8aebfd
feat(backend): let a defender read their own consent state
heyradcode Aug 10, 2026
e7ad2d7
feat(frontend): show a defender whether their consent still applies
heyradcode Aug 10, 2026
0b8fe8d
docs: bring the threat model and architecture notes back in step
heyradcode Aug 10, 2026
066df47
fix(backend): match opponent consent on the served ruleset hash
heyradcode Aug 10, 2026
ee08a23
feat(backend): check why the opponent list is empty
heyradcode Aug 10, 2026
fc7d9da
feat(backend): distinguish never-granted consent from stale consent
heyradcode Aug 10, 2026
81b6c20
feat(frontend): show what a battle is actually waiting on
heyradcode Aug 10, 2026
655ee71
fix(backend): publish the ruleset bundle under the hash the battle names
heyradcode Aug 10, 2026
bfb3b04
fix(backend): publish the ruleset bundle even when version collides
heyradcode Aug 10, 2026
df58fec
fix(shared): stop a failing battle poll spinning forever
heyradcode Aug 10, 2026
29e9c8e
fix(backend): release pets held by a battle orphaned in accepted
heyradcode Aug 10, 2026
188ad6a
feat(protocol): delegate battle-intent signing to a client-held sessi…
heyradcode Aug 11, 2026
bbb23b8
feat(backend): implement the provider-independent half of KMS signing
heyradcode Aug 11, 2026
ac1d2d9
feat(backend): sign battle receipts with an AWS KMS key
heyradcode Aug 11, 2026
eb2af85
feat(backend): require the go-verifier attestation by default
heyradcode Aug 11, 2026
889cb79
feat(backend): separate signing keys per reward domain
heyradcode Aug 11, 2026
83b09d3
feat(backend): publish a validity end for keys that stop signing
heyradcode Aug 11, 2026
2134b9c
refactor(backend): split acceptBattle along its documented stages
heyradcode Aug 11, 2026
d4e0e9a
refactor(frontend): collapse useBattlePanel's chained effects
heyradcode Aug 11, 2026
151f96c
fix(battle): stop one rejection code carrying two meanings
heyradcode Aug 11, 2026
a13e670
refactor(frontend): make the view switchers read as one control
heyradcode Aug 11, 2026
1dc07ab
feat(frontend): rebuild battle setup around its empty state
heyradcode Aug 11, 2026
5260421
fix(frontend): move battle gear icons clear of the combatant readout
heyradcode Aug 11, 2026
2caefbb
fix(backend): show why the signer refused instead of only that it did
heyradcode Aug 11, 2026
fede9f6
feat(backend): report the independent verifier's status at boot
heyradcode Aug 11, 2026
d0f54bf
fix(indexer-go): back off when the subgraph rate limits us
heyradcode Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion backend/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
44 changes: 40 additions & 4 deletions backend/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,48 @@ DIRECT_URL="postgresql://postgres.<project-ref>:<password>@aws-1-<region>.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
Expand Down
10 changes: 6 additions & 4 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -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;
44 changes: 43 additions & 1 deletion backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading