Skip to content

Harden the battle and inventory seam, consent, and receipt signing - #75

Merged
cdsaidev merged 36 commits into
mainfrom
feat/battle-hardening
Aug 11, 2026
Merged

Harden the battle and inventory seam, consent, and receipt signing#75
cdsaidev merged 36 commits into
mainfrom
feat/battle-hardening

Conversation

@heyradcode

Copy link
Copy Markdown
Collaborator

Description

What this is

Started as a review of the roadmap §4 inventory work against the backend-authoritative
battle path (docs/plan-battle-inventory-hardening.md), and grew to cover the signing and
consent machinery that path depends on. 36 commits across 8 packages.

The review found one blocker and a cluster of correctness bugs; running the result against a
live Base Sepolia deployment found several more that only appear with real data. Both sets
are here, with the plan doc updated in place to record what was wrong and what was decided.

Deploy notes, read before merging

Four things change behaviour on an existing deployment.

1. go-verifier is now a required attester by default.
BATTLE_SIGNER_REQUIRED_ATTESTERS defaults to typescript-engine,go-verifier. §F is a
precondition, not a fallback: the backend will not sign a receipt the independent Go port has
not confirmed. A deployment without INDEXER_GRPC_ADDR set and indexer-go running will
have every battle stall at computed and then forfeit after eight attempts. This is the
intended posture, but it is a live change. Boot now says which state you are in.

2. Serving more than one chain family now requires per-domain signing keys.
§G separates keys per reward domain, so BATTLE_SIGNER_EVM_KEY_ID and
BATTLE_SIGNER_SOLANA_KEY_ID must both be set when BATTLE_CHAIN_IDS spans both. Note the
default BATTLE_CHAIN_IDS is eip155:31337,solana:localnet, which spans both, so a local
dev environment relying on defaults is affected. A single-family deployment keeps inheriting
the shared BATTLE_SIGNER_KEY_ID. The signer refuses to start rather than quietly putting
one key across both domains, which is threat T4.

3. Two migrations, applied with prisma migrate deploy.

  • 20260810120000_battle_ruleset_version_not_unique drops the unique constraint on
    battle_ruleset.version. Two catalog states can share a ruleset version and differ only by
    hash, and the constraint made publishing the second one fail.
  • 20260810140000_add_session_delegation adds session_delegation, with
    ENABLE ROW LEVEL SECURITY and no FORCE, per the repo rule.

4. One wire code renamed.
POST /api/battle/intents returned expired for a timed-out request, while the accept path
returns expired from CoverageFailure meaning the defender's authorization lapsed. One
code carried two meanings and the client maps a code to text with no idea which endpoint
answered, so one of the two was always wrong. The intent side is now intent-expired,
matching what accept already called it.

What changed, by theme

Inventory seam (the original review)

  • Blocker: the sign worker rebuilt every snapshot at schema version 1 and dropped
    equipment, so nothing settled. Proved with two differing hashes, fixed with one shared
    codec, and the fix is pinned by reintroducing the bug in a test.
  • Strict and lenient catalog reads split: a bag hides a row it cannot read, combat refuses
    it. An unreadable equipment row moves rulesetHash and invalidates every outstanding
    defence authorization; an uncatalogued equipped item produces a receipt saying the pet
    fought bare while chain state disagrees.
  • TS and Go bonus clamps brought back into step, with the case added to equipment.json.
  • A self-battle no longer swallows one of its own drops; an unconfirmed mint no longer pays
    an entitlement twice; spending a consumable refreshes the pet's own numbers.

Consent and matchmaking

  • Matchmaking compared against hashRuleset(SOURCE_DEFAULT_RULESET) while defenders sign
    against the served ruleset. The two are equal only while the item catalog is empty, so on a
    seeded deployment the filter matched no authorization ever written and the opponent list
    came back empty. Silent, because empty is also the correct answer when nobody has
    consented. servedRulesetHash() now derives the hash once beside the ruleset it belongs
    to, and hashRuleset no longer appears anywhere in backend/src.
  • diagnoseEmpty() names which of six situations produced a blank picker.
  • Defenders can read their own consent state, and the UI distinguishes never-granted from
    stale.

Signing (§G)

  • AWS KMS backend: DER decoding, EIP-2 low-s normalisation, recovery-id derivation checked
    against the published address. BATTLE_SIGNER_PRIVATE_KEY is refused in production.
  • Separate keys per reward domain, selected by the signed object's own domain.
  • Rotated keys get a validity end, dated from the last receipt they signed. Retired keys stay
    published forever, since a receipt signed under a rotated key must still verify.

Session delegation (§D)

Battles no longer prompt the wallet per fight. The owner signs one SessionDelegation and a
client-held ephemeral key signs intents under it. Scope is battle-intent only and explicitly
excludes defence consent; the key lives in sessionStorage under a 24h cap and is never
sent anywhere, so the operator still cannot produce an intent. A key the server rejects is
discarded locally and the intent re-signed once with the wallet, which is the pre-session
path.

Frontend

  • Battle setup rebuilt around its empty state, which is the state players land on. Two tall
    dashed voids became bays that draw an empty arena and show the shape of what will fill
    them; the floating VS and detached win-rate box became one odds rail spanning both bays.
  • View switchers on battle, leaderboard and inventory were three copies of the same markup
    and stylesheet differing only in an accent colour. Now one TabSwitch primitive in
    components/ui, styled as a segmented control.

Operator visibility

Three failures in this branch were hard to diagnose for the same reason: the cause was known
in-process and never reported. configureSigner recorded why it refused and returned, so a
deployment that could not sign anything booted clean. Boot now reports the signer and the
verifier, both lookups carry the configuration error into failureReason, and
backend/scripts/ gains three read-only diagnostics (diagnose-signer, diagnose-battles,
diagnose-stuck). None prints key material.

indexer-go

Both EVM loops polled a hosted subgraph on a fixed ticker with no backoff, so an HTTP 429 was
retried at full rate indefinitely and the limit never cleared. Adds a pacer with doubling
backoff capped at 60s, cleared on the first success, preferring the server's own
Retry-After. The loops also ticked in phase and arrived as simultaneous pairs, which is
why both error lines carried the same millisecond; inventory now starts half a period out
with its own pacer.

Boundaries

  • contracts/test-vectors/{battle,xp,equipment}.json are unchanged. One case was added
    to equipment.json for the clamp fix, none edited.
  • Solana's frozen combat port is untouched.
  • MIT packages changed: protocol, verifier, services/indexer-go. protocol gains
    src/session/ and takes no new dependencies, so the no-PolyForm-import test still holds.
  • sessionDelegation is a new versioned object at version 1. No existing schema version
    moved, so no receipt is re-encoded.

Review guidance

The commits are themed and mostly stack in order. If this is too large as one unit, it splits
cleanly at: inventory seam, consent and matchmaking, signing and §G, session delegation,
frontend. The two migrations and the attester default are the parts worth the most scrutiny.

Verification

Package Result
backend 1031 tests, 92 files
protocol 619 tests, 36 files
shared 578 tests, 81 files
frontend 373 tests, 48 files
verifier 86 tests, 13 files
indexer-go go build, go vet, go test ./... clean across 9 packages

pnpm lint clean. Golden vectors pass in both live ports and Anchor's frozen suite.

@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
do-not-stop-frontend Ready Ready Preview Aug 11, 2026 2:35pm
do-not-stop-website Ready Ready Preview Aug 11, 2026 2:35pm

@github-actions

Copy link
Copy Markdown

🧪 Coverage

Package Statements Branches Functions Lines Overall
backend 83.60% (2677/3202) 77.44% (1380/1782) 82.38% (505/613) 83.96% (2540/3025) 82.37% (7102/8622)
frontend 70.17% (1654/2357) 71.37% (1441/2019) 69.10% (434/628) 71.01% (1509/2125) 70.67% (5038/7129)
shared 58.68% (1700/2897) 59.89% (1105/1845) 58.49% (358/612) 60.08% (1540/2563) 59.40% (4703/7917)

@cdsaidev cdsaidev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! 👍

@cdsaidev
cdsaidev merged commit 1513fa1 into main Aug 11, 2026
9 checks passed
@cdsaidev
cdsaidev deleted the feat/battle-hardening branch August 11, 2026 14:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants