From 1f7d56c413469cab55580a8197ccfd43c73cca72 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 05:27:10 -0400 Subject: [PATCH 01/76] docs: split backend-authoritative battle plan into commit-sized steps --- docs/plan-backend-battle-steps.md | 398 ++++++++++++++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 docs/plan-backend-battle-steps.md diff --git a/docs/plan-backend-battle-steps.md b/docs/plan-backend-battle-steps.md new file mode 100644 index 00000000..1de9ac21 --- /dev/null +++ b/docs/plan-backend-battle-steps.md @@ -0,0 +1,398 @@ +# Backend-authoritative battle: implementation steps + +Companion to [plan-backend-battle-architecture.md](./plan-backend-battle-architecture.md). That +document says what to build and why. This one splits it into commit-sized steps. + +Section references (§A, §E, §L) point at the architecture document. + +## How this runs + +- **One step, one commit.** Every step below is scoped so it can land on its own without breaking + the repo. If a step turns out to need two commits, split it and keep the numbering. +- **Every step has a verification command.** "Done" means that command passes, not that the code + looks right. +- **Branch per step group** (`feat/protocol-package`, `feat/battle-ledger`, ...), not per step. +- Steps 1 to 14 have no dependency on backend or chain work, so they can proceed while the + Phase 1 decisions in §L are still being settled. + +## Settled decisions + +Recorded here so later steps stop re-litigating them. + +| Decision | Choice | Where it came from | +|---|---|---| +| Canonical protocol code home | New MIT top-level `protocol/` package | §K licensing constraint | +| Public verifier home | New MIT top-level `verifier/` package (TypeScript CLI) | §H, §K | +| TS combat engine | Moves from `shared/src/utils/combat/` into `protocol/`, re-exported from `shared` | Verifier must replay combat and must be MIT | +| Randomness beacon | drand quicknet, 3s rounds, fixed offset of 2 rounds | §E latency budget | +| First reward chain | EVM only; Solana after EVM operations are stable | §I, §L Phase 5 | + +Open, and deliberately not blocking steps 1 to 14: exact reward economics (§I caps), whether +Phase 3.5 becomes the end state, and the KMS provider. + +--- + +## Group A: specification and licensing (§L Phase 1) + +### Step 1: threat model and key-compromise runbook +- Scope: write the threat model as its own document (the §J threat list expanded into attacker, + capability, control, detection, residual risk), plus the runbook for a suspected signing-key + compromise (pause roots, rotate key, republish key registry, re-verify affected receipts). +- Files: `docs/threat-model-backend-battles.md`, `docs/runbook-signing-key-compromise.md`. +- Verify: no command. Review only. +- Commit: `docs: add backend battle threat model and key-compromise runbook` + +### Step 2: reconcile the roadmap with backend combat +- Scope: the §K "update on acceptance" list. Team battles become backend orchestration over a + versioned ruleset, drop the "inherently low risk" claim, restate equipment guidance, mark the + settle keeper legacy for battle execution, correct stale dual-indexer text. +- Files: `docs/plan-future-features-roadmap.md`. +- Verify: no command. Review only. +- Commit: `docs: align future-features roadmap with backend-authoritative battles` + +--- + +## Group B: the `protocol/` package (§F, §G, build order 1 and 5) + +### Step 3: scaffold the MIT protocol package +- Scope: `@cryptopets/protocol` workspace package, MIT `LICENSE`, README stating the package is + intentionally MIT because outsiders run the verifier against it. tsconfig, vitest, eslint config + mirroring `shared`. Consumed as raw TypeScript, same as `shared` (no build step). +- Files: `protocol/{package.json,tsconfig.json,vitest.config.ts,eslint.config.js,LICENSE,README.md}`, + `protocol/src/index.ts`, `pnpm-workspace.yaml`, root `package.json` lint/test aggregates. +- Verify: `pnpm --filter @cryptopets/protocol test && pnpm --filter @cryptopets/protocol lint` +- Commit: `chore(protocol): scaffold MIT protocol package` + +### Step 4: move the combat engine into `protocol/` +- Scope: move `shared/src/utils/combat/*` to `protocol/src/combat/`, and its golden-vector test to + `protocol/tests/combat/`. `shared/src/utils/combat/index.ts` becomes a re-export so no frontend, + mobile, or backend import changes. Relicensing note in the package README. +- Files: `protocol/src/combat/*`, `protocol/tests/combat/goldenVectors.test.ts`, + `shared/src/utils/combat/index.ts`, `shared/package.json` (dependency on `@cryptopets/protocol`). +- Verify: `pnpm --filter @cryptopets/protocol test && pnpm --filter @shared/core test && pnpm --filter frontend build` +- Commit: `refactor(protocol): move TS combat engine out of shared into MIT protocol package` + +### Step 5: canonical encoding primitives +- Scope: the fixed binary encoder every hash in this design depends on. Length-prefixed fields, + explicit integer widths, no JSON. Domain-tag helper, keccak-256 wrapper (legacy Keccak, matching + the existing simulator hashing), hex and bigint rules. This is the step everything downstream + inherits its determinism from, so it gets its own tests. +- Files: `protocol/src/encoding/{writer.ts,hash.ts,domain.ts,index.ts}`, + `protocol/tests/encoding/*.test.ts`. +- Verify: `pnpm --filter @cryptopets/protocol test` +- Commit: `feat(protocol): add canonical binary encoding and keccak hashing primitives` + +### Step 6: deployment and schema-version binding +- Scope: `chainId` plus `deploymentId` binding used by every signed object (§D), and the schema + version registry so a version bump is a code change, not a magic number at a call site. +- Files: `protocol/src/domain/{deployment.ts,schemaVersions.ts}`, tests. +- Verify: `pnpm --filter @cryptopets/protocol test` +- Commit: `feat(protocol): bind signed objects to chainId and deploymentId` + +### Step 7: battle intent +- Scope: `BattleIntent` type, canonical hash, EIP-712 typed data for EVM wallets, domain-separated + sign-message format for Solana. Expiry and nonce fields, no verification logic yet (that is + backend, Step 18). +- Files: `protocol/src/intent/*`, `contracts/test-vectors/protocol-intent.json`, + `protocol/tests/intent/*.test.ts`. +- Verify: `pnpm --filter @cryptopets/protocol test` +- Commit: `feat(protocol): add wallet-signed battle intent schema and hashing` + +### Step 8: standing defence authorization +- Scope: `DefenseAuthorization` type (§D), canonical hash, EIP-712 and Solana formats, + `revocationNonce` semantics. Consent is bound to `rulesetHash`. +- Files: `protocol/src/consent/*`, `contracts/test-vectors/protocol-consent.json`, tests. +- Verify: `pnpm --filter @cryptopets/protocol test` +- Commit: `feat(protocol): add standing defense-authorization schema and hashing` + +### Step 9: pet snapshot +- Scope: the "photo" (§C, §F). Frozen pet fields plus `lastOpponentId` and `streak`, so progression + is a pure function of the receipt's own inputs. `snapshotHash` over both pets. Equipment slots + present but empty until equipment ships. +- Files: `protocol/src/snapshot/*`, `contracts/test-vectors/protocol-snapshot.json`, tests. +- Verify: `pnpm --filter @cryptopets/protocol test` +- Commit: `feat(protocol): add frozen pet snapshot schema and snapshot hashing` + +### Step 10: seed derivation +- Scope: the §E derivation exactly as specified, domain-separated over chainId, deploymentId, drand + randomness, battleId, snapshotHash, rulesetHash. Golden vectors, including one recorded real + quicknet beacon value. +- Files: `protocol/src/randomness/seed.ts`, `contracts/test-vectors/protocol-seed.json`, tests. +- Verify: `pnpm --filter @cryptopets/protocol test` +- Commit: `feat(protocol): derive battle seed from drand randomness with domain separation` + +### Step 11: drand beacon verification +- Scope: pinned quicknet chain hash and public key, BLS12-381 signature verification over + `@noble/curves`, round-to-time and time-to-round helpers, the fixed offset constant. Pure + verification, no network client (that is Step 20). Record the bundle-size cost in the README, as + §E requires. +- Files: `protocol/src/randomness/{drand.ts,beacon.ts}`, fixtures of real quicknet rounds, tests. +- Verify: `pnpm --filter @cryptopets/protocol test` +- Commit: `feat(protocol): verify drand quicknet BLS beacon signatures against a pinned key` + +### Step 12: battle commitment +- Scope: `BattleCommitment` type (§E), canonical hash, `previousCommitmentHash` chain link, and a + chain-continuity checker. No signing here. +- Files: `protocol/src/commitment/*`, `contracts/test-vectors/protocol-commitment.json`, tests. +- Verify: `pnpm --filter @cryptopets/protocol test` +- Commit: `feat(protocol): add battle commitment schema, hashing, and chain link` + +### Step 13: XP and progression port +- Scope: build order step 5, and the §F workstream. Port `indexer-go/internal/combat/xp.go` to + `protocol/src/combat/xp.ts`, reading streak state from the snapshot rather than chain state. + Produce a `progressionDelta` (xp, level, streak, rating inputs) as a pure function. +- Files: `protocol/src/combat/xp.ts`, `contracts/test-vectors/protocol-progression.json`, + `protocol/tests/combat/xp.test.ts` (runs the existing `contracts/test-vectors/xp.json` too). +- Verify: `pnpm --filter @cryptopets/protocol test` and `cd indexer-go && go test ./internal/combat` +- Commit: `feat(protocol): port XP and progression math to TypeScript with golden vectors` + +### Step 14: ruleset versioning +- Scope: `rulesetVersion` and `rulesetHash` over the combat config and skill/balance configuration, + plus the content-addressed ruleset bundle format §H requires for historical replay. +- Files: `protocol/src/ruleset/*`, `contracts/test-vectors/protocol-ruleset.json`, tests. +- Verify: `pnpm --filter @cryptopets/protocol test` +- Commit: `feat(protocol): add content-addressed ruleset versioning and hashing` + +### Step 15: battle receipt +- Scope: `BattleReceipt` type (§G) with all three hash links, canonical hash, combat-log hash, and + the chain-continuity checkers for the global chain and both per-pet chains. +- Files: `protocol/src/receipt/*`, `contracts/test-vectors/protocol-receipt.json`, tests. +- Verify: `pnpm --filter @cryptopets/protocol test` +- Commit: `feat(protocol): add signed battle receipt schema, hashing, and hash chains` + +### Step 16: Merkle leaves and proofs +- Scope: canonical Merkle leaf encoding for a receipt, tree construction, proof generation and + verification, matching whatever the EVM registry will accept (Step 33). Vectors so Solidity and + TypeScript cannot drift. +- Files: `protocol/src/merkle/*`, `contracts/test-vectors/protocol-merkle.json`, tests. +- Verify: `pnpm --filter @cryptopets/protocol test` +- Commit: `feat(protocol): add canonical Merkle leaf encoding, proofs, and vectors` + +--- + +## Group C: backend ledger and intent (§J, build order 2 and 3) + +### Step 17: Prisma models and migration +- Scope: the §J models. `BattleIntent`, `DefenseAuthorization`, `BattleLedger`, `BattleCommitment`, + `BattleReceipt`, `BattleBatch`, `BattleRuleset`, `PetBattleProgress`, `BattleOutbox`. Unique + constraint on the wallet idempotency nonce. `BattleHistory` is untouched, since the on-chain path + keeps running. +- Files: `backend/prisma/schema.prisma`, `backend/prisma/migrations/*`. +- Verify: `pnpm --filter backend build` and a migration applied against a scratch database. +- Commit: `feat(backend): add battle ledger, commitment, receipt, and progress models` + +### Step 18: ledger state machine +- Scope: the §J transition table as code, with every transition idempotent and each one writing its + outbox message in the same transaction. Deterministic pet lock ordering. No HTTP surface yet. +- Files: `backend/src/features/battle-ledger/{state.ts,transitions.ts,outbox.ts,index.ts}`, tests. +- Verify: `pnpm --filter backend test` +- Commit: `feat(backend): add transactional battle ledger state machine and outbox` + +### Step 19: signed intent submission +- Scope: verify an EIP-712 or Solana-signed `BattleIntent`, check finalized attacker ownership, + consume the nonce, reject expiry and cross-deployment replay, create the ledger row in `accepted`. + A JWT can carry the request but never authorizes another wallet's battle. +- Files: `backend/src/features/battle-ledger/intent.service.ts`, `backend/src/routes/battle.ts`, + tests. +- Verify: `pnpm --filter backend test` +- Commit: `feat(backend): accept wallet-signed battle intents` + +### Step 20: standing defender consent +- Scope: store, verify, and revoke `DefenseAuthorization`. Level band, daily battle cap, immediate + revocation with its timestamp available to receipts. +- Files: `backend/src/features/battle-ledger/consent.service.ts`, routes, tests. +- Verify: `pnpm --filter backend test` +- Commit: `feat(backend): add standing defender consent with immediate revocation` + +--- + +## Group D: randomness, signing, execution (build order 4, 6, 7) + +### Step 21: drand client +- Scope: fetch quicknet rounds, verify with the `protocol/` verifier, cache verified rounds, retry + the same committed round indefinitely (§E), never substitute a known round. Metrics for fetch + delay. +- Files: `backend/src/features/battle-randomness/*`, tests with a stubbed HTTP transport. +- Verify: `pnpm --filter backend test` +- Commit: `feat(backend): add verified drand quicknet round client with same-round retry` + +### Step 22: isolated signer +- Scope: signer interface accepting only the exact commitment and receipt schemas, digest-only + signing, KMS adapter plus a local dev adapter, key registry with validity periods including + rotated-out keys. No generic signing endpoint, ever. +- Files: `backend/src/features/battle-signer/*`, tests. +- Verify: `pnpm --filter backend test` +- Commit: `feat(backend): add schema-restricted KMS signer for commitments and receipts` + +### Step 23: accept flow, snapshot and commitment delivery +- Scope: the one ordering that can never be relaxed. On acceptance: snapshot both pets, pick + `currentRound + 2`, persist, sign the `BattleCommitment`, and return it synchronously in the accept + response. Alert when accept succeeds but commitment delivery fails. +- Files: `backend/src/features/battle-ledger/accept.service.ts`, integration test asserting the + commitment is signed and returned before the committed round exists. +- Verify: `pnpm --filter backend test` +- Commit: `feat(backend): sign and deliver battle commitment before the drand round publishes` + +### Step 24: seeded and computed worker +- Scope: worker driving `committed` to `seeded` to `computed`. Verify the beacon, derive the seed, + run `protocol/` combat plus progression, persist the combat log and its hash. +- Files: `backend/src/features/battle-worker/*`, tests. +- Verify: `pnpm --filter backend test` +- Commit: `feat(backend): compute battles from verified drand seeds in a worker` + +### Step 25: independent Go verification +- Scope: §F release safety. `indexer-go` gains a snapshot-shaped verify entry point over its + existing combat and xp packages, exposed to the backend. Mismatch on winner, rounds, winner HP, + progression delta, or combat-log hash stops signing for that ruleset, alerts, and retains both + outputs. Never silently prefer one implementation. +- Files: `indexer-go/internal/combat/verify.go`, `indexer-go/internal/grpcsrv/*` (or an HTTP + endpoint), `proto/cryptopets.proto` if gRPC, `backend/src/features/battle-worker/verify.ts`. +- Verify: `cd indexer-go && go vet ./... && go test ./internal/combat` and `pnpm --filter backend test` +- Commit: `feat(indexer-go): verify backend battle results independently before signing` + +### Step 26: sign the receipt and append the hash chains +- Scope: `verified` to `signed` to `published`. Append to the global chain and both per-pet chains + inside one transaction, update `PetBattleProgress`. A duplicate battle id with a different payload + raises a security alert and is never an upsert. +- Files: `backend/src/features/battle-ledger/receipt.service.ts`, tests including a concurrency test. +- Verify: `pnpm --filter backend test` +- Commit: `feat(backend): sign battle receipts and append global and per-pet hash chains` + +--- + +## Group E: public surfaces (build order 8, 10) + +### Step 27: read APIs +- Scope: battle state by id, signed commitment, signed receipt, combat log, active signing keys, + active rulesets, verify-receipt. Authoritative and re-fetchable, since the WebSocket stops being + trusted in Step 29. +- Files: `backend/src/routes/battle.ts`, `backend/src/graphql/*`, `backend/API.md`, tests. +- Verify: `pnpm --filter backend test` +- Commit: `feat(backend): expose battle state, commitment, receipt, and key endpoints` + +### Step 28: public receipt corpus +- Scope: paginated export by pet, by wallet, and by sequence range, with no authentication, so + replay needs no special access (§H item 3). +- Files: `backend/src/routes/receipts.ts`, tests. +- Verify: `pnpm --filter backend test` +- Commit: `feat(backend): publish a paginated public receipt corpus` + +### Step 29: scope the WebSocket per room +- Scope: `backend/src/ws/liveBattleSocket.ts` stops broadcasting globally, since the payload now + carries full combat logs. Subscriptions scope to the existing `BattleRoom`. Notification only, + never authoritative. +- Files: `backend/src/ws/liveBattleSocket.ts`, `backend/src/features/battle-room/*`, frontend and + mobile subscribe calls, tests. +- Verify: `pnpm --filter backend test && pnpm --filter frontend test` +- Commit: `refactor(backend): scope live battle socket per room and make it notification-only` + +--- + +## Group F: the standalone verifier (§H, build order 9) + +### Step 30: scaffold the verifier CLI +- Scope: MIT `verifier/` package depending only on `@cryptopets/protocol`. No backend access, no + database. Reads a receipt file or a corpus URL. Checks operator signature and hash-chain + continuity first, since those need nothing else. +- Files: `verifier/{package.json,tsconfig.json,LICENSE,README.md}`, `verifier/src/*`, + `pnpm-workspace.yaml`. +- Verify: `pnpm --filter @cryptopets/verifier test` +- Commit: `feat(verifier): scaffold standalone MIT receipt verifier CLI` + +### Step 31: full verification checks +- Scope: drand BLS verification, seed derivation, combat replay, progression comparison, per-check + pass or fail output, non-zero exit on any failure. +- Files: `verifier/src/checks/*`, fixtures, tests. +- Verify: `pnpm --filter @cryptopets/verifier test` +- Commit: `feat(verifier): verify beacon, seed, combat replay, and progression` + +### Step 32: pinned ruleset artifacts and CI +- Scope: fetch and pin content-addressed ruleset bundles so historical battles reproduce exactly, + plus a CI job running the verifier over a committed corpus fixture on every PR. +- Files: `verifier/src/ruleset.ts`, `verifier/fixtures/*`, `.github/workflows/verifier.yml`. +- Verify: `pnpm --filter @cryptopets/verifier test` and a green workflow run. +- Commit: `feat(verifier): pin ruleset artifacts and run receipt verification in CI` + +--- + +## Group G: client (§J frontend behavior) + +### Step 33: submit intent and persist the commitment +- Scope: wallet signs the intent, client stores battle id and the signed commitment in local + storage so the player's own evidence survives a reload, subscribes to the room, refetches the + authoritative endpoint after reconnect. +- Files: `shared/src/hooks/*` (new backend battle hook), `frontend/src/*`, tests. +- Verify: `pnpm --filter frontend test && pnpm --filter frontend lint:check` +- Commit: `feat(frontend): submit signed battle intents and persist the signed commitment` + +### Step 34: client-side verification and replay +- Scope: verify the receipt signature, the drand BLS signature against the pinned key, and the hash + links, in the browser. Then replay the combat log and animate. Record the bundle-size delta. +- Files: `frontend/src/*`, `shared/src/hooks/*`, tests. +- Verify: `pnpm --filter frontend test && pnpm --filter frontend build` +- Commit: `feat(frontend): verify battle receipts client-side before replaying the fight` + +--- + +## Group H: shadow mode and launch (§L Phase 2 and 3) + +### Step 35: shadow the on-chain path +- Scope: recompute every settled on-chain battle through the `protocol/` engine and the Go verifier, + compare against `BattleResolved`, record mismatches. On-chain battles keep running unchanged. Stop + condition: zero deterministic mismatch over the agreed observation window. +- Files: `backend/src/features/battle-shadow/*`, metrics, tests. +- Verify: `pnpm --filter backend test`, then the observation window itself. +- Commit: `feat(backend): shadow-compute settled on-chain battles against the backend engine` + +### Step 36: rewardless backend battle mode +- Scope: backend battle mode behind a flag, alongside the on-chain mode. Off-chain XP, rating, and + cooldown stored distinctly from NFT state. Signed commitments and receipts, no transferable + reward. Recovery, replay, key-rotation, and incident drills documented and run. +- Files: `backend/src/features/battle-*`, `backend/env.example`, frontend mode switch, + `docs/runbook-backend-battles.md`. +- Verify: `pnpm --filter backend test && pnpm --filter frontend test` +- Commit: `feat: launch rewardless backend battle mode behind a flag` + +--- + +## Group I: anchoring and rewards (§L Phase 4 to 6) + +Deliberately coarse. Scope these into steps once Group H is operating, because the batch cadence, +caps, and claim shape depend on what shadow mode and the rewardless launch actually show. + +### Step 37: EVM root registry and batcher +- Commit: `feat(contracts): add battle batch root registry` and + `feat(backend): aggregate signed receipts into anchored Merkle batches` + +### Step 38: capped claim contract and proof API +- Commit: `feat(contracts): add capped aggregate reward claims with nullifiers` + +### Step 39: security review, drills, bounded rewards +- Commit: `feat: enable bounded aggregate season rewards` + +### Step 40: retire per-battle settlement and amend the four-port rule +- Scope: only here, at §L Phase 6, do `AGENTS.md` and `CLAUDE.md` change. The four-port combat rule + stays a `MUST` until the legacy on-chain path actually retires. Legacy receipts and events stay + replayable. +- Commit: `docs: retire per-battle settlement and amend the four-port combat rule` + +--- + +## Dependency map + +```mermaid +flowchart LR + A["1-2 docs"] --> B["3-16 protocol/"] + B --> C["17-20 ledger + intent"] + C --> D["21-26 randomness, signer, execution"] + B --> F["30-32 verifier/"] + D --> E["27-29 public APIs + ws"] + E --> F + D --> G["33-34 client"] + F --> H["35-36 shadow + rewardless launch"] + G --> H + H --> I["37-40 anchoring + rewards"] +``` + +Steps 3 to 16 are the critical path. Nothing else can start until the canonical encodings exist, +because every signature in this design is over one of them. From bbdb04bc38c452308b64b5f4497048898b9cc330 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 05:31:08 -0400 Subject: [PATCH 02/76] docs: add backend battle threat model and key-compromise runbook --- docs/runbook-signing-key-compromise.md | 134 ++++++++++++ docs/threat-model-backend-battles.md | 272 +++++++++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 docs/runbook-signing-key-compromise.md create mode 100644 docs/threat-model-backend-battles.md diff --git a/docs/runbook-signing-key-compromise.md b/docs/runbook-signing-key-compromise.md new file mode 100644 index 00000000..5205a15c --- /dev/null +++ b/docs/runbook-signing-key-compromise.md @@ -0,0 +1,134 @@ +# Runbook: battle signing key compromise + +Applies to the KMS keys that sign `BattleCommitment` and `BattleReceipt` objects, and to the root +publisher key that anchors Merkle batches. See +[threat-model-backend-battles.md](./threat-model-backend-battles.md) T4 and T16. + +Assume compromise means an attacker can produce signatures that verify against a published key. It +does not mean they can move assets: the battle signing key has no custody and no withdrawal +authority, and the root publisher sits behind multisig and timelock. That is what buys time here. + +**Bias towards pausing.** A false alarm costs players a few hours of battles. A missed compromise +costs the integrity of every receipt signed in the window. + +## Triggers + +Any one of these starts this runbook. Do not wait for confirmation of intent. + +- KMS audit log shows a signing request the pipeline cannot account for (no matching ledger row, no + matching digest). +- Signer throughput outside expected range, or signing requests from an unexpected principal, + network path, or region. +- A receipt or commitment exists in the public corpus with no corresponding ledger row. +- Hash-chain fork: two signed receipts claiming the same `previousReceiptHash`, or two commitments for + one `battleId`. +- A player produces a signed commitment or receipt we did not issue. +- Credential exposure: KMS principal credentials in a log, repo, image, or CI artifact. +- Cloud provider or KMS vendor notifies us of key or account compromise. + +## Roles + +| Role | Owns | +|---|---| +| Incident lead | Declares the incident, owns the timeline, makes the pause call | +| Signer owner | KMS policy changes, key disable, rotation | +| Chain owner | Root registry pause, multisig coordination | +| Verifier owner | Corpus re-verification, fork analysis | +| Comms owner | Player-facing status, disclosure | + +One person may hold several roles. The pause call is never blocked on availability: if the incident +lead is unreachable, the signer owner pauses. + +## Phase 1: contain (target: 15 minutes) + +Order matters. Stop the bleeding on-chain first, because that is the only irreversible surface. + +1. **Pause the on-chain surfaces.** Emergency pause on the root registry and the claim contract. No + new roots accepted, no claims processed. This is the only step that prevents economic loss. +2. **Disable the suspect key in KMS.** Deny all signing operations on that key version. Do not delete + the key and do not delete its public record, which is needed for later verification. +3. **Stop receipt signing.** Trip the signer circuit breaker. Battles already `committed` stay in + `verified` and are not lost. Battle acceptance also stops, since acceptance requires a signed + commitment and an unsigned acceptance would break invariant 1. +4. **Snapshot evidence.** KMS audit logs, signer access logs, ledger tables, published corpus, and + the current chain tips of all three receipt chains. Copy to write-once storage before anything is + rotated or restored. +5. **Freeze deploys.** No code or infrastructure changes to the signer path until Phase 4. +6. **Declare the incident** and record the suspected compromise window opening time. When unknown, + use the earliest plausible time, not the most convenient one. + +## Phase 2: assess (target: 4 hours) + +Establish the compromise window and what was signed inside it. + +1. **Reconcile KMS to ledger.** For every signing request in the window, match the digest to a ledger + row. Unmatched digests are forged-signature candidates and define the real window. +2. **Reconcile corpus to ledger.** Every published receipt and commitment must have a ledger row with + the same payload. Extra corpus entries mean forged artifacts were served. +3. **Run the verifier over the window.** `verifier` over the affected sequence range. Failures split + into: signature invalid, beacon invalid, replay mismatch, chain discontinuity. Replay mismatch on + an otherwise valid signature is the strongest evidence of forgery, because our pipeline cannot + produce it. +4. **Walk the chains.** Global chain and the per-pet chains for every pet touched in the window. Note + every fork point and both branches. A fork with two valid signatures is provable equivocation and + must be preserved exactly as found. +5. **Check batches.** Which anchored roots include window receipts. Which of those had claims against + them. Compute worst-case economic exposure against the caps. +6. **Classify.** Confirmed compromise, suspected, or false alarm. A false alarm exits at Phase 4 with + the pause lifted and a post-incident note. Do not skip Phase 4. + +## Phase 3: rotate and recover + +Only after the window is bounded. + +1. **Generate a new key** in a fresh KMS key with a new `signingKeyId`. New credentials, new + principal, minimal network path. Never reuse the old principal. +2. **Publish the key registry update.** New key with its `notBefore`. Old key marked compromised with + its validity end set to the window opening time, and **retained**, because historical receipts + still verify against it. Never remove a rotated-out key from the registry. +3. **Publish the compromise window** as a first-class record: `signingKeyId`, window start and end, + affected sequence ranges, and the list of receipts we attest to as pipeline-produced. Players and + third-party verifiers need this to interpret their own copies. +4. **Do not re-sign history under the new key.** Re-signing changes nothing about what happened and + destroys the evidence trail. Instead publish an attestation list: the receipt hashes we confirm + our pipeline produced, signed with the new key. Verifiers then treat an in-window receipt as valid + only if it appears in the attestation list. +5. **Do not renumber sequences.** Gaps and forks stay visible. Continue the chain from the last + attested receipt, recording the discontinuity explicitly. +6. **Handle in-flight battles.** Battles in `verified` at pause time resolve normally under the new + key. Battles in `committed` whose round has published resolve normally. Battles whose committed + round has passed the beacon timeout become `forfeited` with no progression change. +7. **Reverse or freeze bad claims.** Claims against forged inclusion stay paused. Nullifiers already + consumed cannot be reused, so genuine claimants inside a poisoned batch need a re-issued batch + under a new root rather than a retry. +8. **Lift the pauses** in the reverse of Phase 1: signer, then acceptance, then root registry, then + claims. Claims last, because they are the only irreversible surface. + +## Phase 4: post-incident + +- Timeline with detection latency, containment latency, and every decision point. +- Which detection fired, and which should have fired first. If detection came from a player, that is + the headline finding. +- Whether reward caps bounded the exposure as designed. If not, lower the caps before resuming. +- Whether the escalation threshold in the threat model §6 has been reached. +- Public disclosure: what was signed, what was attested, what players should check themselves. The + design's entire premise is that we publish our homework, so a compromise is disclosed with the same + detail we would want if we were the player. + +## Never do these + +- Delete or unpublish an old public key. Historical verification depends on it. +- Delete a forged receipt from the corpus without recording it. The fork is the evidence. +- Re-sign or rewrite historical receipts under the new key. +- Renumber sequences or repair a chain by regenerating links. +- Substitute a different drand round for an unresolved battle, even to clear the queue. That breaks + invariant 2 and is exactly the behaviour T1 is designed to make impossible. +- Restore Postgres to a point before the published corpus without reconciling against it (T19). + +## Drill + +Run this as a live drill in Phase 3 of the implementation plan, before anything of value is at stake +(Step 36). The drill must cover: pause, key disable, evidence snapshot, corpus reconciliation, +verifier run over a range, rotation with registry publication, attestation-list publication, and +resumption. Record the wall-clock time of each phase and correct the targets above to what the drill +actually achieves. diff --git a/docs/threat-model-backend-battles.md b/docs/threat-model-backend-battles.md new file mode 100644 index 00000000..b57b6a0f --- /dev/null +++ b/docs/threat-model-backend-battles.md @@ -0,0 +1,272 @@ +# Threat model: backend-authoritative battles + +Scope: the design in [plan-backend-battle-architecture.md](./plan-backend-battle-architecture.md). +Step references point at [plan-backend-battle-steps.md](./plan-backend-battle-steps.md). + +This document exists because moving battle resolution off-chain moves it inside our trust boundary. +Section §A of the architecture document states the trust model in one table. This is the expanded +version: who attacks what, what stops them, how we notice, and what is left over. + +It covers the backend battle path only. The existing on-chain path (`GameLogic.sol`, +`settle_battle.rs`) keeps its own properties and is not re-analysed here. + +## 1. Assets + +| Asset | Why it matters | Authority | +|---|---|---| +| Pet and item ownership | Transferable value | Chain | +| Reward custody and claims | Transferable value | Chain | +| Battle signing key | Signs commitments and receipts; forgery source | KMS | +| Root publisher key | Anchors batches on-chain | KMS + multisig | +| Off-chain progression (XP, rating, streak) | Determines rewards and matchmaking | Backend | +| Signed receipt corpus | The evidence players hold against us | Backend, published | +| Commitment sequence | Proves which round each battle was bound to | Backend, delivered to players | + +## 2. Actors + +| Actor | Assumed capability | +|---|---| +| Player | Can sign with their own wallet, replay traffic, script requests, disconnect at will | +| External attacker | Network position, can hit any public endpoint, no keys | +| Insider with database access | Read and write Postgres, no KMS signing scope | +| Insider with signer access | Can request signatures over well-formed commitments and receipts | +| Dishonest operator | Controls all backend processes, both combat implementations, and the database | +| drand network | Assumed honest and live; a threshold of nodes would have to collude to bias a round | + +The dishonest-operator row is the uncomfortable one and it is deliberate. Most controls below do not +stop that actor. They make the actor's lies detectable by anyone holding a commitment or a receipt. + +## 3. Threats + +Each row: what the attacker does, what stops or bounds it, how we notice, what is left. + +### T1: randomness reroll after seeing the value + +- **Attack.** Operator watches drand, computes the result, dislikes it, and claims the battle was + always bound to a later round. Recompute, publish, everything self-consistent. +- **Control.** Commit before reveal (§E). The round is chosen mechanically as + `currentVerifiedRound + 2`, and the signed `BattleCommitment` is returned synchronously in the + accept response, before the round exists (Step 23). +- **Detection.** A reroll needs a second signature over the same `battleId`. Either player's stored + commitment plus the published receipt is a provable equivocation. +- **Residual.** Only detectable if players keep their commitment. The client persists it to local + storage (Step 33) and the endpoint serves it, but a player who never fetched it holds no evidence. +- **Non-control.** Persisting the chosen round in Postgres proves nothing. It is our database. + Merkle anchoring does not help either, because anchoring happens after computation. + +### T2: lying about the result + +- **Attack.** Publish a receipt whose winner does not follow from its own inputs. +- **Control.** None preventive. The receipt carries every input, so the result is recomputable. +- **Detection.** Public replay (§H). Anyone runs the verifier and the check fails. +- **Residual.** Only caught if someone runs the verifier. That is why it ships in Phase 3 while + nothing of value is at stake (Steps 30 to 32), and why we run it in CI against a corpus fixture + and monitor it ourselves. + +### T3: hiding a battle + +- **Attack.** Resolve a battle, dislike the outcome, never publish the receipt. +- **Control.** None preventive. Receipts are sequenced and hash-chained globally and per pet (§G). +- **Detection.** A gap breaks the chain. The per-pet chain also means a player who holds their own + commitment can show a committed battle with no corresponding receipt. +- **Residual.** Visible, not impossible. If omission risk becomes unacceptable, §I's delayed + direct-receipt claim fallback or an optimistic challenge protocol is the next step. + +### T4: signing-key compromise + +- **Attack.** Stolen key signs arbitrary commitments and receipts. +- **Control.** Key in KMS, never in API or worker environments. Signer accepts only the exact + commitment and receipt schemas, never a generic state-mutation payload. No asset custody, no + withdrawal authority. Separate keys per reward domain. Reward caps bound the economic damage + (Step 22, §I). +- **Detection.** KMS request logging of every digest and key version. Signer throughput outside + expected range. Receipts that exist in the corpus but not in the ledger. Hash-chain forks. +- **Residual.** Real. Bounded, not eliminated. See + [runbook-signing-key-compromise.md](./runbook-signing-key-compromise.md). + +### T5: outcome grinding by submit-and-abandon + +- **Attack.** Submit many battles, abandon the ones that seed badly, keep the good ones. +- **Control.** No player-initiated cancellation after `committed` (§E). Disconnection, tab close, + and app kill do not affect resolution. Per-wallet and per-pet rate limits, daily battle caps. +- **Detection.** Abandonment rate per wallet. Win distribution per wallet against the expected + distribution for their matchups. +- **Residual.** A player can still choose which opponents to fight. That is matchmaking design, not + a randomness leak. + +### T6: manufactured beacon outage + +- **Attack.** Player degrades their own connectivity, or an attacker degrades ours, to escape a + battle already seeded against them. +- **Control.** The committed round is retried indefinitely and never substituted. On a genuine + permanent outage past the timeout, the battle ends `forfeited` with no progression change and both + pets stay locked for several rounds, so escaping costs more than losing (§E). +- **Detection.** drand fetch delay metric. Forfeit rate per wallet. +- **Residual.** A wallet that repeatedly forfeits is a rate-limit and abuse-policy matter. + +### T7: intent replay + +- **Attack.** Resubmit a captured signed intent to force extra battles. +- **Control.** `clientNonce` with a unique database constraint, `expiresAt`, and nonce consumption at + acceptance (§D, Step 19). +- **Detection.** Repeated-nonce alert. +- **Residual.** None material. + +### T8: cross-chain or cross-deployment replay + +- **Attack.** Take a signature from staging and use it on production, or across chains. +- **Control.** Every signed object binds `chainId` and `deploymentId` (§D, Step 6). Intents, + consents, commitments, and receipts all carry both, inside the hashed payload. +- **Detection.** Domain mismatch is a hard rejection, logged. +- **Residual.** None material, provided `deploymentId` is genuinely unique per environment. + +### T9: forged defender consent + +- **Attack.** Battle an unwilling defender, applying cooldown and rating changes to them. +- **Control.** `DefenseAuthorization` signed by the defender's wallet, bound to `rulesetHash`, with + level band, daily cap, validity window, and `revocationNonce`. Every receipt embeds the hash of + the authorization it relied on (§D, Step 20). +- **Detection.** Receipts referencing an unknown or revoked authorization hash fail public replay. +- **Residual.** Consent is to a ruleset version, so a rules change invalidates outstanding + authorizations by design. Expect a re-consent prompt after every balance patch. + +### T10: stale ownership after an NFT transfer + +- **Attack.** Battle with a pet already sold, or snapshot a pet mid-transfer. +- **Control.** Ownership checked at the finalized source version, snapshot records + `sourceChainVersions` (§G). Reconciliation job between finalized chain ownership, snapshots, + receipts, and claims (§J). +- **Detection.** Reconciliation mismatch. +- **Residual.** Reorg depth on the source chain sets the finality wait, which is a latency cost, not + a correctness gap. + +### T11: concurrent battles with the same pet + +- **Attack.** Race two battles for one pet so one snapshot is stale or a cooldown is skipped. +- **Control.** Both pets locked in deterministic id order inside a serializable transaction + (Step 18). Snapshot persisted before randomness exists. +- **Detection.** Serialization-failure rate, duplicate-battle-id alert. +- **Residual.** None material. This is a correctness test target, not a monitoring target. + +### T12: duplicate workers + +- **Attack.** Two workers process the same transition, double-crediting progression or forking a + hash chain. +- **Control.** Every transition idempotent, at-least-once processing assumed, each transition and its + outbox message committed atomically. A duplicate battle id with a different payload is a security + alert and never an upsert (§J, Step 18). +- **Detection.** Hash-chain discontinuity alert. Duplicate-payload alert. +- **Residual.** None material. + +### T13: forged snapshot inputs + +- **Attack.** Inflate a pet's stats, level, or equipment inside the snapshot. +- **Control.** Snapshot fields derive from indexed chain state at a recorded source version. + Progression fields (`xp`, `streak`, `lastOpponentId`) are off-chain, so they are only checkable by + replaying that pet's prior receipts, which the per-pet hash chain makes tractable (§G). +- **Detection.** Public replay walking a pet's chain catches a snapshot that does not follow from the + previous receipt's `progressionDelta`. +- **Residual.** Equipment ownership must be verifiable from chain or from a signed inventory record + before equipment affects combat. Until then, keep equipment out of combat inputs. + +### T14: combat log leaks outcomes to spectators + +- **Attack.** Read the outcome of every resolving battle by connecting to the WebSocket. +- **Control.** `liveBattleSocket.ts` currently broadcasts every message to every client. That is + acceptable for chain-derived data and not acceptable for full combat logs. Subscriptions scope to + the existing `BattleRoom` and the socket becomes notification-only (§J, Step 29). +- **Detection.** Route-level test asserting no cross-room delivery. +- **Residual.** Room ids are shareable by design, so a room link is a spectator link. + +### T15: commitment accepted but never delivered + +- **Attack.** Or, more likely, a bug. Accept succeeds, the player never receives the signed + commitment, and the only record of the chosen round is ours. T1 is then undetectable for that + battle. +- **Control.** Commitment signed and returned synchronously in the accept response, and also served + from a public endpoint so it is re-fetchable (Steps 23, 27). +- **Detection.** Dedicated alert on accept-succeeded-without-commitment-delivery (§J). +- **Residual.** A player who never fetches it still holds no evidence. The public endpoint bounds + this to non-malicious loss. + +### T16: fraudulent or omitted reward batch + +- **Attack.** Anchor a root covering receipts that were never signed, or omit signed receipts from + every batch. +- **Control.** Per-battle, per-wallet, per-batch, and per-season reward caps. One-time claims with + nullifiers. Emergency pause. Root publishers behind multisig and timelock. Published + receipt-to-root inclusion proofs (§I). +- **Detection.** Receipt-omission alert past the inclusion SLO. Root-anchor delay alert. Verifier + Merkle-inclusion check (Step 32). +- **Residual.** An unanchored signed receipt is evidence of operator failure, not an on-chain claim. + +### T17: denial of service against popular opponents + +- **Attack.** Flood a specific defender to exhaust their daily cap or keep their pets locked. +- **Control.** Per-wallet and per-pet rate limits, defender daily battle cap set by the defender + themselves in their authorization (§D). +- **Detection.** Per-defender request-rate anomaly. +- **Residual.** A popular defender's cap is consumed by whoever gets there first. Matchmaking policy, + not a cryptographic problem. + +### T18: verifier collusion + +- **Attack.** The Go verifier does not constrain a dishonest operator, because the same operator runs + both processes and both ports descend from `CombatSim.sol`. +- **Control.** None, and none is claimed. The Go verifier's role is release safety: it catches + implementation drift, bad deploys, and transcription bugs, and it hard-stops receipt signing on any + mismatch (§F, Step 25). +- **Detection.** Engine/verifier mismatch alert with both outputs and all inputs retained. +- **Residual.** Dishonest computation is caught by public replay (T2), not by this. + +### T19: database rollback or restore + +- **Attack.** Restore Postgres to an earlier point and lose or rewrite receipts, including via an + honest recovery. +- **Control.** Receipts are append-only and hash-chained, and the corpus is published, so an + external copy exists outside the database. Append-only audit events for every transition. +- **Detection.** Chain discontinuity between the restored database and the published corpus. +- **Residual.** Recovery procedure must reconcile against the published corpus, not just restore. + Point-in-time recovery drills have to include that reconciliation (Step 36). + +## 4. Invariants + +These are the properties tests and alerts exist to defend. Any one of them breaking is an incident, +not a bug report. + +1. A `BattleCommitment` is signed and returned to the player before its committed drand round + publishes. +2. The committed round is never substituted. Retry the same round or forfeit. +3. A battle that reaches `committed` always resolves. `rejected` exists only before `committed`. +4. The snapshot is persisted before any randomness for that battle exists. +5. The seed is derived only from the committed beacon value under the §E derivation. Never from + timestamps, uuids, or backend secrets. +6. A receipt is signed only when the TypeScript engine and the Go verifier agree exactly. +7. Every receipt links its predecessor in the global chain and in both per-pet chains. +8. One `battleId` has at most one signed commitment and at most one signed receipt. A conflicting + payload is an alert, never an upsert. +9. The signer accepts only commitment and receipt schemas, and holds no asset authority. +10. Off-chain XP is never represented as NFT state unless a successful aggregate claim applied it + on-chain. + +## 5. Accepted residual risk + +Stated plainly, because §9 of the architecture document commits to stating it plainly. + +- **A stolen signing key can sign lies.** Bounded by key isolation, schema restriction, reward caps, + and the runbook. Not eliminated. +- **We can refuse to publish.** The chains make it visible, not impossible. +- **The receipt proves what we published, not that we were honest.** Public replay is the control, + and it only works if replay actually happens. +- **The Go verifier does not constrain us**, only our deploys. + +## 6. Escalation threshold + +This design is proportionate while battle outcomes drive progression and capped, aggregate season +rewards. It stops being proportionate when a single battle's outcome carries significant transferable +value, or when reward caps have to be raised beyond what we would accept losing to a key compromise. + +At that point the next steps are §M's deferred options: per-battle backend signature verified +on-chain (Phase 3.5), optimistic settlement with bonded challenges, or proof-based settlement. That +threshold should be crossed deliberately, with a review, not drifted past by raising caps one +increment at a time. From a81be9372752dda15efb2b3e68321781b8a16aac Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 05:35:33 -0400 Subject: [PATCH 03/76] docs: align future-features roadmap with backend-authoritative battles --- docs/plan-future-features-roadmap.md | 166 +++++++++++++++++++++------ 1 file changed, 129 insertions(+), 37 deletions(-) diff --git a/docs/plan-future-features-roadmap.md b/docs/plan-future-features-roadmap.md index 68f53b45..dab3def0 100644 --- a/docs/plan-future-features-roadmap.md +++ b/docs/plan-future-features-roadmap.md @@ -37,11 +37,26 @@ port, they need their own golden vectors, not a "close enough" reimplementation. stay server/backend-computed (leaderboard ranking, quest progress) don't need this — only give something a TS port if the client actually needs to simulate it before the chain confirms. -**The settle-keeper pattern is reusable.** `backend/src/features/settle-keeper/` (EVM) and -`settle-keeper-solana/` established: async VRF request → provider reveal → permissionless settle -→ a backend hot wallet sends the settle tx so the player isn't stuck with two signatures, with a -player-side fallback timer if the keeper is down. Any new feature with its own commit/reveal/settle -cycle (team battles, item-drop rolls) should reuse this shape rather than inventing a new one. +**Combat authority is moving off-chain, and that reshapes several features below.** +`docs/plan-backend-battle-architecture.md` is the accepted architecture for battle execution: the +backend resolves fights from a frozen snapshot against a versioned ruleset, seeds them from a +pre-committed drand round, and publishes signed receipts anyone can replay +(`docs/plan-backend-battle-steps.md` sequences the work). Two consequences for this doc. First, a +*new* combat mechanic is built once, in the canonical TypeScript engine (moving from +`shared/src/utils/combat/` into an MIT `protocol/` package early in that plan), with the Go port +acting as an independent pre-signing verifier rather than a fourth hand-maintained implementation. +Second, the four-port rule stays a `MUST` in `AGENTS.md` until the legacy on-chain path actually +retires, so any change to *existing* combat math still updates all four ports until then. + +**The settle-keeper pattern is reusable, but it is legacy for battle execution.** +`backend/src/features/settle-keeper/` (EVM) and `settle-keeper-solana/` established: async VRF +request → provider reveal → permissionless settle → a backend hot wallet sends the settle tx so the +player isn't stuck with two signatures, with a player-side fallback timer if the keeper is down. +That shape is still right for any feature whose outcome must land in chain state, such as gacha +crates minting a real item. It is the wrong shape for battles from here on: backend-resolved +battles send no transaction at all, so they need no keeper, no per-battle entropy fee, and no +settle gas. Treat the two keepers as the legacy 1v1 path, maintained until it retires, not as the +template a new battle mode should copy. **Indexer extension pattern.** Every existing on-chain asset type (`PetRoster`, `BattleHistory`) follows the same shape: a `(chain, id)` composite primary key, a monotonic `lastVersion` / @@ -76,7 +91,8 @@ nothing else in this doc needs to exist first. *Tier 3 — depth.* Moderate contract work, each reusing patterns tier 2 or shipped features already established. -6. **Team battles** — reuses the settle-keeper shape and combat math untouched. +6. **Team battles** — backend orchestration over the versioned ruleset, so this now sequences + after the backend battle path exists (see the cross-cutting note above). 7. **Marketplace** — needs inventory to exist so there's more than pets to list. *Tier 4 — differentiation.* The content and AI layer that makes this project distinct from a @@ -134,7 +150,7 @@ generation · parents · spouse"] TOKEN["11. ERC20 tokenomics"] end - BATTLE -->|"reuses CombatSim + settle-keeper shape"| TEAMBATTLE + BATTLE -->|"backend runs the same ruleset per pairing"| TEAMBATTLE ROSTER -->|hardens| INDEXHARDEN BREED -->|"marriage gates v1 chat access"| SOCIAL PETCORE -->|"parallel asset type"| INVENTORY @@ -168,8 +184,14 @@ generation · parents · spouse"] Reading the graph: -- **Team battles** are additive, not risky — they call the existing, golden-vector-validated - single-fight function repeatedly and reuse the settle-keeper shape. No new combat math. +- **Team battles reuse the fight function but are not therefore low risk.** The per-pairing math is + the existing, vector-validated single fight, and that part is genuinely additive. Everything + around it is not: N pairings need N sub-seeds derived from one pre-committed drand round, a + snapshot covering every pet on both teams, consent from every defender, an aggregation rule that + is itself part of the ruleset hash, and a receipt shape that survives replay. Authorization, + snapshots, seed derivation, signer scope, and reward aggregation are all security-sensitive here. + Treat this as a feature that inherits the full backend battle threat model + (`docs/threat-model-backend-battles.md`), not as a loop around a proven function. - **Inventory is the pivot feature.** It's a parallel asset type to pets (same ownership/indexing pattern), the second thing the marketplace can list besides pets, and the default reward payload for quests. Everything downstream of it moves faster once it exists — see feature 4 @@ -189,6 +211,10 @@ Reading the graph: pet's story progress, but none of them read the story to *decide* an outcome. That distinction is exactly why this doesn't trigger the four-port parity rule the way inventory's equip-stats sub-feature does — the arrows out of `STORY` carry narrative content, not deterministic values. + Under backend-resolved combat this hardens into a rule: AI-generated or story-derived content + never enters a battle snapshot, a ruleset, or any other receipt input. A receipt has to be + replayable years later by someone with no access to our model, our prompts, or our content + tables, and a non-deterministic input makes it unreplayable. - **Image generation feeds two other features' visuals** (marketplace listings, inventory cosmetic item art) but depends on nothing itself — it's the most schedule-flexible feature on the list precisely because of that one-way arrow direction. @@ -216,6 +242,12 @@ existing `BattleHistory` stream on each new settled battle — this stays backen needs to touch `CombatSim` or the settle contracts, since rating is a presentation-layer derivative of an already-final on-chain result, not part of the outcome itself. +Once backend battles land, rating stops being a pure presentation derivative: it becomes part of +off-chain progression (`PetBattleProgress`) and, if it ever gates rewards, part of the replayable +`progressionDelta` in each receipt. Build one rating, in the ruleset, computed from receipts. A +second rating invented at the leaderboard layer would disagree with the receipts and neither would be +checkable. + **Data model (if ELO, else skip — win/loss counts already suffice for a naive leaderboard):** ```prisma @@ -329,8 +361,23 @@ updates to the frontend. enable `ROSTER_CACHE_ENABLED` by default — this is explicitly called "promotable later" in the current docs, so this feature is largely finishing that promotion rather than new design. -This feature has the least product-design risk of the eleven — it's operational hardening of a -path that already exists end-to-end. +Both indexers are still live: the Node `RosterIndexer` is the source of truth in local dev and +`indexer-go` is the promotable path, so "dual-indexer" describes the current state, not a leftover. +What changes is that `indexer-go` picks up a second, unrelated job under backend-resolved combat: it +becomes the independent pre-signing verifier that recomputes every battle result before a receipt is +signed (`docs/plan-backend-battle-steps.md` Step 25). That is a release-safety role, not an indexing +role, and it does not depend on which indexer owns roster writes. Worth knowing before promotion, +because an `indexer-go` outage then blocks receipt signing as well as roster freshness, so the two +concerns need separate health signals. + +Snapshot inputs are the other connection. Backend battles freeze pet state at acceptance from +indexed chain state at a recorded source version, so indexer lag and reorg handling stop being +purely cosmetic: a snapshot taken from an unfinalized write is threat T10 in +`docs/threat-model-backend-battles.md`. The confirmation-depth work above is a prerequisite for +that, not an optional polish item. + +This feature has little product-design risk — it's operational hardening of a path that already +exists end-to-end — but it now sits upstream of battle correctness, not just of display freshness. --- @@ -354,7 +401,7 @@ room to grow. | Category | Example | On/off-chain effect | Combat-port risk | |---|---|---|---| | Consumable | XP potion, cooldown reset, fertility charm | Burned on use via a `GameLogic`-style `useItem(petId, itemId)` call; effect applies immediately (grants XP, clears a timer) | None — same shape as `trainPet` | -| Equipment / gear | Weapon, armor, trinket — Dota-style, one per slot | **Persistent**, equip/unequip, grants a stat modifier while equipped | **Yes** — see below | +| Equipment / gear | Weapon, armor, trinket — Dota-style, one per slot | **Persistent**, equip/unequip, grants a stat modifier while equipped | **No new ports** for backend battles, but snapshot verifiability applies — see below | | Cosmetic / skin | Recolor, hat, aura — Dota-style, visual only | Persistent, equip/unequip, no stat effect | None | | Collectible / currency | Crate keys, event tokens, badges — OwoBot-style | Tradeable, stackable, no direct effect; gacha-crate inputs | None | | Crafting material | Combine N materials into an item | Burned on craft, mirrors item minting | None | @@ -364,19 +411,35 @@ Rarity should reuse the game's existing five-tier system verbatim 50/25/15/8/2 pet-rarity split) rather than inventing a separate item-rarity scale — one rarity vocabulary across pets and items keeps the UI (and the player's mental model) consistent. -**The one edge that matters: equipment stats and combat parity.** If gear changes battle -outcomes, its stat modifier becomes an input to `CombatSim.sol` — which means it also has to -become an input to `combat.rs`, `indexer-go/internal/combat`, and `shared/src/utils/combat`, per -`AGENTS.md`'s "update all four ports together" rule, plus new golden vectors covering geared -fights. This is real scope, not a config tweak. Recommendations: +**The one edge that matters: equipment stats and verifiable snapshots.** The cost here changed with +backend-resolved combat. Gear that only affects backend battles needs **no Solidity or Rust +implementation and no fourth port**: the modifier is an input to the versioned TypeScript ruleset, +with the Go verifier recomputing it before signing. That removes most of what made this expensive. + +What replaces it is a verifiability requirement, and it is not weaker. A geared fight is only +replayable by an outsider if the gear is part of the frozen snapshot and the snapshot's inputs are +checkable. So: + +- Equipment ownership must be verifiable at snapshot time from chain state at a recorded source + version, exactly like pet ownership. Backend-only equip state that no third party can confirm + turns every geared receipt into an assertion (threat T13 in + `docs/threat-model-backend-battles.md`). +- The snapshot carries the resolved modifiers, not a reference to a mutable item row. Unequipping + after acceptance must not change a committed fight, the same reason pet stats are frozen. +- `ItemDefinition.effect` becomes part of the ruleset hash if it feeds combat. A rebalance is then a + new `rulesetVersion`, historical receipts keep replaying against the pinned old bundle, and + outstanding defence authorizations bound to the old ruleset are invalidated by design. + +Recommendations: - Ship consumables, cosmetics, and collectibles first — none of them touch combat math, so - they're purely additive to `backend` + the new `ItemCore` contract, no combat-port work at all. + they're purely additive to `backend` + the new `ItemCore` contract. - Scope "equipment affects battle" as its **own separate phase**, gated behind an explicit design review (per CLAUDE.md's rule that game-balance calls aren't for an agent to loop on alone) — - decide slot count, whether stats are additive or multiplicative, and whether equipped gear - needs its own snapshot-at-request-time protection (mirroring the existing pet-stat snapshot - that stops a mid-battle stat change from rerolling a committed fight). + decide slot count, whether stats are additive or multiplicative, and how equip state is proven at + snapshot time. +- If gear must also affect the legacy on-chain 1v1 path, the four-port cost returns in full. Prefer + gating gear to backend battles until that path retires. - Keep the modifier model simple when it lands (flat additive bonuses to existing `Attrs` fields in `DnaLib.extract`-shaped output) — a small, closed modifier space is what keeps four independent ports and one vector file tractable. A multiplicative or conditional (set-bonus, @@ -435,8 +498,9 @@ model PetEquipment { cosmetic slot, arbitrary pending design input); whether `ItemDefinition` content is owner-tunable on-chain (immutable, expensive to rebalance) or backend-managed (cheap to rebalance, matches the existing `GameConfig` pattern of keeping balance knobs off-chain and owner-tunable at that layer); -and — the big one — whether equipment-affects-combat ships at all in v1, given the four-port cost -above. +and — the big one — whether equipment-affects-combat ships at all in v1. That call is now about +snapshot verifiability and ruleset versioning rather than four-port cost, and it is cheaper than it +used to be, but it is still a phase of its own. --- @@ -489,18 +553,40 @@ model PlayerQuestProgress { **Goal.** N-vs-N pet battles (e.g. best-of-3 or best-of-5 pairings) instead of 1v1, aggregating to a single winner. -**Design.** Do not touch `CombatSim.sol` / `combat.rs` — the golden-vector-validated single-fight -function stays exactly as is. Add an orchestration layer above it: a new `TeamBattleManager` -(EVM) / equivalent Anchor instruction (Solana) that takes two arrays of pet IDs, runs the existing -single-fight function once per pairing with a VRF-derived sub-seed per match, and aggregates -match wins into a team result. This keeps the combat math itself untouched and out of scope for -new golden vectors — only the *aggregation/pairing order* logic is new, and only needs its own -golden vectors if it gets a client-side TS replay port for live animation. - -Reuse the settle-keeper flow: `requestTeamBattle` → entropy/Switchboard reveal → permissionless -`settleTeamBattle`, mirroring `requestBattle`/`settleBattle`. - -**Data model** (new table, mirrors `BattleHistory`): +**Design.** This is a backend feature now, not a contract feature. The earlier sketch here was a +`TeamBattleManager` contract on each chain calling the on-chain single-fight function once per +pairing; that multiplies exactly the per-battle gas the backend battle architecture exists to +remove, so it is superseded. Build team battles as orchestration inside the versioned ruleset: one +accepted team battle, one snapshot covering every pet on both teams, one pre-committed drand round, +N pairings resolved by the same single-fight function, aggregated into a team result, one signed +receipt. + +What is genuinely reused is the fight function. What is new, and needs specifying rather than +assuming: + +- **Sub-seed derivation.** One beacon value seeds N fights, so each pairing takes a domain-separated + sub-seed (`battleSeed` plus pairing index) rather than reusing one seed or fetching N rounds. + Deriving this wrong is the whole feature's correctness. +- **Aggregation is part of the ruleset.** Pairing order, early termination, and tie-breaks feed + `rulesetHash`, so a change to them is a ruleset version bump, not a config edit. +- **Consent scales with team size.** Every defending pet's owner must have a valid + `DefenseAuthorization`, and one revoked authorization invalidates the whole match rather than one + pairing. +- **Snapshot size.** Freezing 10 pets instead of 2 makes the snapshot the largest receipt field. + Decide whether the receipt carries full snapshots or a snapshot hash plus a separately published + snapshot blob before the shape is frozen. +- **Golden vectors** for aggregation and sub-seed derivation, alongside the protocol vectors, since + the client replays team battles for animation too. + +No `requestTeamBattle`, no reveal, no settle transaction: a backend team battle sends nothing +on-chain, and rewards flow through the same aggregated claim path as 1v1. + +**Data model.** Not a mirror of `BattleHistory` any more. `BattleHistory` is an ingested projection +of on-chain events, and a backend team battle produces no event to ingest. Team battles extend the +battle ledger models instead (`BattleLedger`, `BattleCommitment`, `BattleReceipt`), most likely as a +battle kind plus a team-composition table, so they inherit the state machine, the commitment chain, +and the receipt chains rather than reimplementing them. The sketch below is kept only as a shape +reference for what a team result holds: ```prisma model TeamBattleHistory { @@ -519,7 +605,10 @@ model TeamBattleHistory { **Open decisions (human call, per CLAUDE.md's guidance on game-balance):** team size, whether pets can be reused across multiple team slots' cooldowns, pairing order (fixed vs. player-chosen -vs. random), and whether a mid-team loss ends the match early or all pairings always resolve. +vs. random), and whether a mid-team loss ends the match early or all pairings always resolve. Note +that pairing order and early termination are no longer purely balance knobs: they are ruleset inputs, +so each answer is baked into a `rulesetVersion` and outstanding defence authorizations are +invalidated when it changes. Once feature 8 (pet stories) exists, a team match can pull a narrative title from the two teams' story progress — framing a repeat matchup as a rivalry chapter, say — as pure display copy with @@ -814,7 +903,10 @@ architecture gives no precedent for, and isn't needed for a single-game utility Utility sinks should replace or supplement the current native-currency fees: `GameConfig.battleFee` and the Solana `GlobalState.battle_fee_lamports` are ETH/SOL today; a token option would need either a dual-payment path or a full migration, which is itself a real design decision, not a -default. +default. Note that both battle fees exist to fund the settle keeper's own gas, so they disappear +along with the legacy path: a backend battle sends no transaction and has no gas to fund. Do not +plan a token sink around a fee that is scheduled to be removed. Breeding, minting, marketplace fees, +and item crafting are the durable sinks. **This is the highest-risk feature on this list from a game-balance standpoint.** Emission rate, reward amounts, and fee levels are exactly the kind of judgment call CLAUDE.md says not to loop on From 313ecc12c7c2f59f7832028441c16c48cb79d6ca Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 05:48:57 -0400 Subject: [PATCH 04/76] feat(protocol): scaffold MIT protocol package --- package.json | 4 +- pnpm-lock.yaml | 668 +++++++++++++++++---------------- pnpm-workspace.yaml | 1 + protocol/LICENSE | 21 ++ protocol/README.md | 56 +++ protocol/eslint.config.js | 54 +++ protocol/package.json | 31 ++ protocol/src/index.ts | 12 + protocol/tests/package.test.ts | 28 ++ protocol/tsconfig.json | 30 ++ protocol/vitest.config.ts | 16 + 11 files changed, 600 insertions(+), 321 deletions(-) create mode 100644 protocol/LICENSE create mode 100644 protocol/README.md create mode 100644 protocol/eslint.config.js create mode 100644 protocol/package.json create mode 100644 protocol/src/index.ts create mode 100644 protocol/tests/package.test.ts create mode 100644 protocol/tsconfig.json create mode 100644 protocol/vitest.config.ts diff --git a/package.json b/package.json index 4a4f80d7..3a4c2ca4 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "test": "pnpm --prefix contracts/ethereum test", "build": "pnpm compile && pnpm --prefix backend build && pnpm --prefix frontend build && pnpm --prefix website build", "build:backend": "pnpm --filter backend build", - "lint": "pnpm --filter frontend lint:check && pnpm --filter @shared/core lint && pnpm --filter website lint && pnpm --filter mobile lint", - "lint:fix": "pnpm --filter frontend lint:fix && pnpm --filter @shared/core lint:fix && pnpm --filter website lint:fix && pnpm --filter mobile lint:fix", + "lint": "pnpm --filter frontend lint:check && pnpm --filter @cryptopets/protocol lint && pnpm --filter @shared/core lint && pnpm --filter website lint && pnpm --filter mobile lint", + "lint:fix": "pnpm --filter frontend lint:fix && pnpm --filter @cryptopets/protocol lint:fix && pnpm --filter @shared/core lint:fix && pnpm --filter website lint:fix && pnpm --filter mobile lint:fix", "eslint": "pnpm lint", "prepare": "husky" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1b12d4c..805839e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,16 +229,16 @@ importers: dependencies: '@dynamic-labs/ethereum': specifier: ^4.37.1 - version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@dynamic-labs/sdk-react-core': specifier: ^4.37.1 version: 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) '@dynamic-labs/solana': specifier: ^4.37.1 - version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@dynamic-labs/wagmi-connector': specifier: ^4.37.1 - version: 4.40.1(x3m74qb4qabheuvcs6rf3ordmy) + version: 4.40.1(lupvgyugmbc5ztyp7prdwbwueq) '@shared/core': specifier: workspace:* version: link:../shared @@ -250,7 +250,7 @@ importers: version: 0.9.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-adapter-wallets': specifier: ^0.19.32 - version: 0.19.37(k66plh6iifxyw5d3zjvlhcznga) + version: 0.19.37(wcwzcvkiean7xoqtynzwkhqyla) '@solana/web3.js': specifier: ^1.95.2 version: 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -280,10 +280,10 @@ importers: version: 7.13.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) viem: specifier: ^2.37.7 - version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) wagmi: specifier: ^2.17.1 - version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -482,6 +482,36 @@ importers: specifier: ^5.8.3 version: 5.8.3 + protocol: + devDependencies: + '@eslint/js': + specifier: ^9.36.0 + version: 9.38.0 + '@types/node': + specifier: ^22.18.6 + version: 22.18.12 + '@vitest/coverage-v8': + specifier: ^4.1.8 + version: 4.1.9(vitest@4.1.9) + eslint: + specifier: ^9.36.0 + version: 9.38.0(jiti@2.7.0) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.7.0))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.38.0(jiti@2.7.0)) + globals: + specifier: ^16.4.0 + version: 16.4.0 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + typescript-eslint: + specifier: ^8.44.0 + version: 8.46.2(eslint@9.38.0(jiti@2.7.0))(typescript@5.8.3) + vitest: + specifier: ^4.1.8 + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.18.12)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) + shared: dependencies: '@coral-xyz/anchor': @@ -507,10 +537,10 @@ importers: version: 6.0.3 viem: specifier: ^2.0.0 - version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) wagmi: specifier: ^2.0.0 - version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -12481,13 +12511,13 @@ snapshots: - utf-8-validate - zod - '@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/hashes': 1.8.0 clsx: 1.2.1 eventemitter3: 5.0.1 preact: 10.27.2 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - typescript @@ -12573,12 +12603,12 @@ snapshots: '@leichtgewicht/ip-codec': 2.0.5 utf8-codec: 1.0.0 - '@dynamic-labs-connectors/base-account-evm@4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': + '@dynamic-labs-connectors/base-account-evm@4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: - '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@4.4.3) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@types/react' - bufferutil @@ -12680,11 +12710,11 @@ snapshots: dependencies: '@dynamic-labs/logger': 4.40.1 - '@dynamic-labs/embedded-wallet-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': + '@dynamic-labs/embedded-wallet-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/embedded-wallet': 4.40.1(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 @@ -12693,9 +12723,9 @@ snapshots: '@dynamic-labs/webauthn': 4.40.1 '@turnkey/api-key-stamper': 0.4.7 '@turnkey/iframe-stamper': 2.5.0 - '@turnkey/viem': 0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + '@turnkey/viem': 0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@turnkey/webauthn-stamper': 0.5.1 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - encoding @@ -12705,7 +12735,7 @@ snapshots: - utf-8-validate - zod - '@dynamic-labs/embedded-wallet-solana@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@dynamic-labs/embedded-wallet-solana@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@dynamic-labs-sdk/client': 0.1.0-alpha.23(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@dynamic-labs/assert-package-version': 4.40.1 @@ -12720,9 +12750,9 @@ snapshots: '@dynamic-labs/webauthn': 4.40.1 '@solana/web3.js': 1.98.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@turnkey/iframe-stamper': 2.5.0 - '@turnkey/solana': 1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/solana': 1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@turnkey/webauthn-stamper': 0.5.1 - viem: 2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - debug @@ -12752,7 +12782,7 @@ snapshots: - react - react-dom - '@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))': + '@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/logger': 4.40.1 @@ -12762,30 +12792,30 @@ snapshots: '@dynamic-labs/utils': 4.40.1 '@dynamic-labs/wallet-book': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - react - react-dom - '@dynamic-labs/ethereum@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': + '@dynamic-labs/ethereum@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: - '@coinbase/wallet-sdk': 4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@dynamic-labs-connectors/base-account-evm': 4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + '@coinbase/wallet-sdk': 4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@dynamic-labs-connectors/base-account-evm': 4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/embedded-wallet-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/embedded-wallet-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/rpc-providers': 4.40.1 '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@dynamic-labs/waas-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@dynamic-labs/wallet-book': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@metamask/sdk': 0.33.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) - '@walletconnect/ethereum-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/ethereum-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) buffer: 6.0.3 eventemitter3: 5.0.1 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -12932,28 +12962,28 @@ snapshots: - typescript - utf-8-validate - '@dynamic-labs/solana@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': + '@dynamic-labs/solana@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/embedded-wallet-solana': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@dynamic-labs/embedded-wallet-solana': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/rpc-providers': 4.40.1 '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/solana-core': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas-svm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/waas-svm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/wallet-book': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@dynamic-labs/wallet-connect': 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@dynamic-labs/wallet-connect': 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@solana/web3.js': 1.98.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@wallet-standard/app': 1.0.1 '@wallet-standard/base': 1.0.1 '@wallet-standard/experimental-features': 0.1.1 '@wallet-standard/features': 1.0.3 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 5.0.0 eventemitter3: 5.0.1 tweetnacl: 1.0.3 @@ -13030,17 +13060,17 @@ snapshots: eventemitter3: 5.0.1 tldts: 6.0.16 - '@dynamic-labs/waas-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@dynamic-labs/waas-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@gql.tada/svelte-support' - '@gql.tada/vue-support' @@ -13054,7 +13084,7 @@ snapshots: - utf-8-validate - zod - '@dynamic-labs/waas-svm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))': + '@dynamic-labs/waas-svm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/logger': 4.40.1 @@ -13063,7 +13093,7 @@ snapshots: '@dynamic-labs/solana-core': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@solana/web3.js': 1.98.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) bs58: 5.0.0 @@ -13081,11 +13111,11 @@ snapshots: - utf-8-validate - viem - '@dynamic-labs/waas@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))': + '@dynamic-labs/waas@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))': dependencies: '@dynamic-labs-wallet/browser-wallet-client': 0.0.187(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/solana-core': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@dynamic-labs/sui-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3) @@ -13104,20 +13134,20 @@ snapshots: - utf-8-validate - viem - '@dynamic-labs/wagmi-connector@4.40.1(x3m74qb4qabheuvcs6rf3ordmy)': + '@dynamic-labs/wagmi-connector@4.40.1(lupvgyugmbc5ztyp7prdwbwueq)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/rpc-providers': 4.40.1 '@dynamic-labs/sdk-react-core': 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) '@dynamic-labs/types': 4.40.1 '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) eventemitter3: 5.0.4 react: 19.1.1 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@dynamic-labs/wallet-book@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: @@ -13131,11 +13161,11 @@ snapshots: util: 0.12.5 zod: 4.0.5 - '@dynamic-labs/wallet-connect@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@dynamic-labs/wallet-connect@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/logger': 4.40.1 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15703,11 +15733,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-common@1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-common@1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: big.js: 6.2.2 dayjs: 1.11.13 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - typescript @@ -15747,13 +15777,13 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-controllers@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15782,13 +15812,13 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15817,13 +15847,13 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15899,12 +15929,12 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) lit: 3.3.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: @@ -15935,12 +15965,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) lit: 3.3.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: @@ -16055,12 +16085,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-scaffold-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.1.0 transitivePeerDependencies: @@ -16092,12 +16122,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -16129,12 +16159,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -16229,10 +16259,10 @@ snapshots: react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) react-native-svg: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@reown/appkit-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.1.0 qrcode: 1.5.3 @@ -16264,10 +16294,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -16299,10 +16329,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -16369,16 +16399,16 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-utils@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.2 '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16407,16 +16437,16 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16445,16 +16475,16 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16584,20 +16614,20 @@ snapshots: - typescript - utf-8-validate - '@reown/appkit@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.2 - '@reown/appkit-scaffold-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) - '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16626,21 +16656,21 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16669,21 +16699,21 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -17015,26 +17045,26 @@ snapshots: - react-native - typescript - '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/stake@0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/stake@0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/system@0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/system@0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))': + '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/accounts@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)': dependencies: @@ -17216,7 +17246,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17229,11 +17259,11 @@ snapshots: '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/rpc-parsed-types': 2.3.0(typescript@5.8.3) '@solana/rpc-spec-types': 2.3.0(typescript@5.8.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/signers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) typescript: 5.8.3 @@ -17323,14 +17353,14 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.8.3) '@solana/functional': 2.3.0(typescript@5.8.3) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.8.3) '@solana/subscribable': 2.3.0(typescript@5.8.3) typescript: 5.8.3 - ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@solana/rpc-subscriptions-spec@2.3.0(typescript@5.8.3)': dependencies: @@ -17340,7 +17370,7 @@ snapshots: '@solana/subscribable': 2.3.0(typescript@5.8.3) typescript: 5.8.3 - '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.8.3) '@solana/fast-stable-stringify': 2.3.0(typescript@5.8.3) @@ -17348,7 +17378,7 @@ snapshots: '@solana/promises': 2.3.0(typescript@5.8.3) '@solana/rpc-spec-types': 2.3.0(typescript@5.8.3) '@solana/rpc-subscriptions-api': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.8.3) '@solana/rpc-transformers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17526,7 +17556,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17534,7 +17564,7 @@ snapshots: '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/promises': 2.3.0(typescript@5.8.3) '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17817,11 +17847,11 @@ snapshots: - typescript - utf-8-validate - '@solana/wallet-adapter-trezor@0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/wallet-adapter-trezor@0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@trezor/connect-web': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/connect-web': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) buffer: 6.0.3 transitivePeerDependencies: - '@solana/sysvars' @@ -17851,11 +17881,11 @@ snapshots: '@solana/wallet-standard-util': 1.1.2 '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-walletconnect@0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@solana/wallet-adapter-walletconnect@0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/solana-adapter': 0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/solana-adapter': 0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -17884,7 +17914,7 @@ snapshots: - utf-8-validate - zod - '@solana/wallet-adapter-wallets@0.19.37(k66plh6iifxyw5d3zjvlhcznga)': + '@solana/wallet-adapter-wallets@0.19.37(wcwzcvkiean7xoqtynzwkhqyla)': dependencies: '@solana/wallet-adapter-alpha': 0.1.14(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-avana': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) @@ -17917,10 +17947,10 @@ snapshots: '@solana/wallet-adapter-tokenary': 0.1.16(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-tokenpocket': 0.4.23(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-torus': 0.11.32(@babel/runtime@7.28.4)(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-trust': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-unsafe-burner': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@solana/wallet-adapter-walletconnect': 0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@solana/wallet-adapter-walletconnect': 0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@solana/wallet-adapter-xdefi': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) transitivePeerDependencies: @@ -18407,13 +18437,13 @@ snapshots: - react-native - utf-8-validate - '@trezor/blockchain-link@2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/blockchain-link@2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: - '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@stellar/stellar-sdk': 13.3.0 '@trezor/blockchain-link-types': 1.4.4(tslib@2.8.1) @@ -18461,9 +18491,9 @@ snapshots: - expo-localization - react-native - '@trezor/connect-web@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/connect-web@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: - '@trezor/connect': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/connect': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@trezor/connect-common': 0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/utils': 9.4.4(tslib@2.8.1) '@trezor/websocket-client': 1.2.4(bufferutil@4.0.9)(tslib@2.8.1)(utf-8-validate@5.0.10) @@ -18482,7 +18512,7 @@ snapshots: - utf-8-validate - ws - '@trezor/connect@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/connect@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@ethereumjs/common': 10.0.0 '@ethereumjs/tx': 10.0.0 @@ -18490,12 +18520,12 @@ snapshots: '@mobily/ts-belt': 3.13.1 '@noble/hashes': 1.8.0 '@scure/bip39': 1.6.0 - '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/system': 0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@trezor/blockchain-link': 2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/system': 0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/blockchain-link': 2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@trezor/blockchain-link-types': 1.4.4(tslib@2.8.1) '@trezor/blockchain-link-utils': 1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10) '@trezor/connect-analytics': 1.3.6(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) @@ -18648,7 +18678,7 @@ snapshots: '@turnkey/api-key-stamper': 0.4.7 '@turnkey/encoding': 0.5.0 - '@turnkey/sdk-browser@5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@turnkey/sdk-browser@5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@turnkey/api-key-stamper': 0.4.7 '@turnkey/crypto': 2.5.0 @@ -18657,7 +18687,7 @@ snapshots: '@turnkey/iframe-stamper': 2.5.0 '@turnkey/indexed-db-stamper': 1.1.1 '@turnkey/sdk-types': 0.3.0 - '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@turnkey/webauthn-stamper': 0.5.1 bs58check: 4.0.0 buffer: 6.0.3 @@ -18670,11 +18700,11 @@ snapshots: - utf-8-validate - zod - '@turnkey/sdk-server@4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@turnkey/sdk-server@4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@turnkey/api-key-stamper': 0.4.7 '@turnkey/http': 3.10.0(encoding@0.1.13) - '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) buffer: 6.0.3 cross-fetch: 3.2.0(encoding@0.1.13) transitivePeerDependencies: @@ -18686,12 +18716,12 @@ snapshots: '@turnkey/sdk-types@0.3.0': {} - '@turnkey/solana@1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@turnkey/solana@1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@turnkey/http': 3.10.0(encoding@0.1.13) - '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - encoding @@ -18699,16 +18729,16 @@ snapshots: - utf-8-validate - zod - '@turnkey/viem@0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': + '@turnkey/viem@0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: '@noble/curves': 1.8.0 '@openzeppelin/contracts': 4.9.6 '@turnkey/api-key-stamper': 0.4.7 '@turnkey/http': 3.10.0(encoding@0.1.13) - '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) cross-fetch: 4.1.0(encoding@0.1.13) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - encoding @@ -18716,12 +18746,12 @@ snapshots: - utf-8-validate - zod - '@turnkey/wallet-stamper@1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@turnkey/wallet-stamper@1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@turnkey/crypto': 2.5.0 '@turnkey/encoding': 0.5.0 optionalDependencies: - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - typescript @@ -19217,7 +19247,7 @@ snapshots: '@vue/shared@3.5.22': {} - '@wagmi/connectors@6.1.0(bgpzjh5q7yrj4ocuf4x4nrqhlm)': + '@wagmi/connectors@6.1.0(2orsghzlwewxwohejkwolumw4e)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) @@ -19226,9 +19256,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(gvhepirkfl6ucqngccm4za6i6m) + porto: 0.2.19(6oek35uj62dxa7liwvqvv47ara) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 @@ -19264,7 +19294,7 @@ snapshots: - wagmi - zod - '@wagmi/connectors@6.1.0(ck72wdzxjpfzfgbpsvtuebcwoi)': + '@wagmi/connectors@6.1.0(bgpzjh5q7yrj4ocuf4x4nrqhlm)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) @@ -19273,9 +19303,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(q4cw5yhvj7zbif42fx5kul3uoi) + porto: 0.2.19(gvhepirkfl6ucqngccm4za6i6m) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 @@ -19311,7 +19341,7 @@ snapshots: - wagmi - zod - '@wagmi/connectors@6.1.0(gs4rcdfjexwcphadxk5xbkqn2i)': + '@wagmi/connectors@6.1.0(g3hyk7kpi5chrxeuitid5ge5f4)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@4.4.3) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@4.4.3) @@ -19320,9 +19350,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(wbh5vdwqtmxnsnoh2rbbugc434) + porto: 0.2.19(dryu7ql2ha2chpe6amo3r4teni) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) optionalDependencies: typescript: 5.8.3 @@ -19437,7 +19467,7 @@ snapshots: dependencies: '@wallet-standard/base': 1.1.0 - '@walletconnect/core@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19451,7 +19481,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 events: 3.3.0 lodash.isequal: 4.5.0 @@ -19481,7 +19511,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19495,7 +19525,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19525,7 +19555,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19539,7 +19569,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19569,7 +19599,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19583,7 +19613,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19657,7 +19687,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19671,7 +19701,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19701,7 +19731,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19715,7 +19745,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19833,7 +19863,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19847,7 +19877,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.39.3 events: 3.3.0 @@ -19881,18 +19911,18 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -19922,18 +19952,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20004,18 +20034,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/ethereum-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20177,16 +20207,16 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/sign-client@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20213,16 +20243,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20249,16 +20279,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20285,16 +20315,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20357,16 +20387,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20393,16 +20423,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20501,16 +20531,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20537,13 +20567,13 @@ snapshots: - utf-8-validate - zod - '@walletconnect/solana-adapter@0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/solana-adapter@0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 6.0.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20867,7 +20897,7 @@ snapshots: - ioredis - uploadthing - '@walletconnect/universal-provider@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -20876,9 +20906,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 lodash: 4.17.21 transitivePeerDependencies: @@ -20907,7 +20937,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -20916,9 +20946,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -20947,7 +20977,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -20956,9 +20986,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -20987,7 +21017,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -20996,9 +21026,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21067,7 +21097,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -21076,9 +21106,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21107,7 +21137,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -21116,9 +21146,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21227,7 +21257,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -21236,9 +21266,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.39.3 events: 3.3.0 transitivePeerDependencies: @@ -21267,7 +21297,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -21285,7 +21315,7 @@ snapshots: elliptic: 6.6.1 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21311,7 +21341,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -21330,7 +21360,7 @@ snapshots: elliptic: 6.6.1 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21356,7 +21386,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -21374,7 +21404,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21400,7 +21430,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -21418,7 +21448,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21488,7 +21518,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -21506,7 +21536,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21532,7 +21562,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -21550,7 +21580,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21664,7 +21694,7 @@ snapshots: - uploadthing - zod - '@walletconnect/utils@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@msgpack/msgpack': 3.1.2 '@noble/ciphers': 1.3.0 @@ -21685,7 +21715,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.1 - viem: 2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -26343,7 +26373,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.7.1(typescript@5.8.3)(zod@3.25.76): + ox@0.7.1(typescript@5.8.3)(zod@4.4.3): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -26351,7 +26381,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.1(typescript@5.8.3)(zod@3.25.76) + abitype: 1.1.1(typescript@5.8.3)(zod@4.4.3) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 @@ -26645,7 +26675,7 @@ snapshots: pony-cause@2.1.11: {} - porto@0.2.19(gvhepirkfl6ucqngccm4za6i6m): + porto@0.2.19(6oek35uj62dxa7liwvqvv47ara): dependencies: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) hono: 4.10.3 @@ -26659,47 +26689,47 @@ snapshots: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) transitivePeerDependencies: - '@types/react' - immer - use-sync-external-store - porto@0.2.19(q4cw5yhvj7zbif42fx5kul3uoi): + porto@0.2.19(dryu7ql2ha2chpe6amo3r4teni): dependencies: - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) hono: 4.10.3 idb-keyval: 6.2.2 mipd: 0.0.7(typescript@5.8.3) ox: 0.9.12(typescript@5.8.3)(zod@4.4.3) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) zod: 4.4.3 zustand: 5.0.8(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(use-sync-external-store@1.4.0(react@19.1.1)) optionalDependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) transitivePeerDependencies: - '@types/react' - immer - use-sync-external-store - porto@0.2.19(wbh5vdwqtmxnsnoh2rbbugc434): + porto@0.2.19(gvhepirkfl6ucqngccm4za6i6m): dependencies: - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) hono: 4.10.3 idb-keyval: 6.2.2 mipd: 0.0.7(typescript@5.8.3) ox: 0.9.12(typescript@5.8.3)(zod@4.4.3) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) zod: 4.4.3 zustand: 5.0.8(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(use-sync-external-store@1.4.0(react@19.1.1)) optionalDependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) transitivePeerDependencies: - '@types/react' - immer @@ -28412,15 +28442,15 @@ snapshots: - utf-8-validate - zod - viem@2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76): + viem@2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3): dependencies: '@noble/curves': 1.8.2 '@noble/hashes': 1.7.2 '@scure/bip32': 1.6.2 '@scure/bip39': 1.5.4 - abitype: 1.0.8(typescript@5.8.3)(zod@3.25.76) + abitype: 1.0.8(typescript@5.8.3)(zod@4.4.3) isows: 1.0.6(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - ox: 0.6.9(typescript@5.8.3)(zod@3.25.76) + ox: 0.6.9(typescript@5.8.3)(zod@4.4.3) ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -28429,15 +28459,15 @@ snapshots: - utf-8-validate - zod - viem@2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76): + viem@2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.8.3)(zod@3.25.76) + abitype: 1.0.8(typescript@5.8.3)(zod@4.4.3) isows: 1.0.7(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - ox: 0.7.1(typescript@5.8.3)(zod@3.25.76) + ox: 0.7.1(typescript@5.8.3)(zod@4.4.3) ws: 8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -28561,14 +28591,14 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): + wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): dependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) - '@wagmi/connectors': 6.1.0(ck72wdzxjpfzfgbpsvtuebcwoi) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@wagmi/connectors': 6.1.0(g3hyk7kpi5chrxeuitid5ge5f4) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) react: 19.1.1 use-sync-external-store: 1.4.0(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -28600,14 +28630,14 @@ snapshots: - utf-8-validate - zod - wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): + wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): dependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) - '@wagmi/connectors': 6.1.0(gs4rcdfjexwcphadxk5xbkqn2i) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/connectors': 6.1.0(2orsghzlwewxwohejkwolumw4e) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) react: 19.1.1 use-sync-external-store: 1.4.0(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 530887a9..d6b46dee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - 'backend' - 'mobile' - 'shared' + - 'protocol' - 'contracts/ethereum' - 'contracts/ethereum/subgraph' - 'contracts/solana/cryptopets' diff --git a/protocol/LICENSE b/protocol/LICENSE new file mode 100644 index 00000000..89dab1bc --- /dev/null +++ b/protocol/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 RadCrew + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/protocol/README.md b/protocol/README.md new file mode 100644 index 00000000..c7e6d01a --- /dev/null +++ b/protocol/README.md @@ -0,0 +1,56 @@ +# @cryptopets/protocol + +Canonical definitions for the backend-authoritative battle protocol: canonical encodings, hashes, +drand seed derivation, the versioned combat ruleset, and the receipt and commitment schemas. + +Design: [docs/plan-backend-battle-architecture.md](../docs/plan-backend-battle-architecture.md). +Sequencing: [docs/plan-backend-battle-steps.md](../docs/plan-backend-battle-steps.md). + +## Why this package exists, and why it is MIT + +The rest of the app layer (`backend`, `frontend`, `mobile`, `shared`, `website`) is PolyForm +Noncommercial. This package is **MIT**, deliberately. + +A backend that decides battle outcomes has to be checkable, and the mechanism that makes it +checkable is public replay: anyone takes a signed receipt, recomputes the fight from its inputs, and +compares. That is only real if outsiders can actually run the code. A verifier licensed +noncommercially is not a verifier, it is a claim. So everything needed to verify a receipt lives +here, under a license that permits running it, and the standalone verifier (`verifier/`) depends only +on this package. + +The combat engine moved here from `shared/src/utils/combat/` for the same reason. The identical +algorithm is already MIT in `contracts/ethereum/src/CombatSim.sol`, +`contracts/solana/cryptopets`, and `indexer-go/internal/combat`, so this changes the license of a +fourth copy of published math, not of anything proprietary. + +**Rule for new files here:** MIT only, and nothing in this package may import from a PolyForm +package. A test enforces the second half (`tests/package.test.ts`). + +## Constraints + +Everything in here must be reproducible by a third party, years later, with no access to our +infrastructure. That rules out more than it sounds like: + +- **No clock reads.** No `Date.now()`, no `new Date()`. Timestamps are inputs. Enforced by eslint. +- **No ambient randomness.** No `Math.random()`. Randomness comes from the committed drand round. + Enforced by eslint. +- **No I/O.** No network, no filesystem, no database, no environment variables. Callers fetch, this + package computes. +- **No React, no hooks, no framework.** Pure functions over plain data. +- **Canonical encoding only.** Hashes are taken over the fixed binary encoding in `src/encoding/`, + never over `JSON.stringify` output. Property order is not a specification. +- **Golden vectors for anything hashed.** Every hash and every combat rule has vectors in + `contracts/test-vectors/`, so a port or a refactor that changes a byte fails loudly. + +## Consumption + +Raw TypeScript, no build step, same as `@shared/core`. Workspace packages depend on it directly; +`shared` re-exports the combat engine so existing imports keep working. + +## Commands + +```bash +pnpm --filter @cryptopets/protocol test # vitest +pnpm --filter @cryptopets/protocol lint # eslint, incl. the determinism rules +pnpm --filter @cryptopets/protocol typecheck # tsc --noEmit +``` diff --git a/protocol/eslint.config.js b/protocol/eslint.config.js new file mode 100644 index 00000000..7f40f987 --- /dev/null +++ b/protocol/eslint.config.js @@ -0,0 +1,54 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import importPlugin from 'eslint-plugin-import'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['node_modules/', 'coverage/'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts'], + languageOptions: { + ecmaVersion: 'latest', + // Runs in browser, React Native, Node, and the standalone verifier. + // No React here: this package is pure computation, never UI. + globals: { ...globals.browser, ...globals.node }, + }, + plugins: { + import: importPlugin, + }, + rules: { + 'import/no-unresolved': 'off', + 'import/no-duplicates': 'error', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-empty-object-type': 'off', + // Determinism rules. Every hash and every fight result in this package + // must be reproducible years later on someone else's machine, so the + // usual sources of drift are errors rather than style opinions. + '@typescript-eslint/no-explicit-any': 'error', + 'no-restricted-properties': [ + 'error', + { object: 'Math', property: 'random', message: 'Randomness comes from the committed drand round, never from Math.random.' }, + { object: 'Date', property: 'now', message: 'Protocol code must not read the clock. Pass timestamps in as inputs.' }, + ], + 'no-restricted-syntax': [ + 'error', + { selector: "NewExpression[callee.name='Date']", message: 'Protocol code must not read the clock. Pass timestamps in as inputs.' }, + ], + 'prefer-const': 'error', + 'semi': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + }, + }, + { + // Tests may read the clock and use loose types for fixtures. + files: ['tests/**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'no-restricted-properties': 'off', + 'no-restricted-syntax': 'off', + }, + }, +); diff --git a/protocol/package.json b/protocol/package.json new file mode 100644 index 00000000..ad9c1147 --- /dev/null +++ b/protocol/package.json @@ -0,0 +1,31 @@ +{ + "name": "@cryptopets/protocol", + "version": "0.0.1", + "description": "Canonical CryptoPets battle protocol: encodings, hashes, drand seed derivation, and the deterministic combat ruleset. MIT so third parties can verify signed battle receipts. Consumed as raw TypeScript source — no build step.", + "license": "MIT", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "pnpm exec eslint .", + "lint:fix": "pnpm exec eslint . --fix", + "typecheck": "pnpm exec tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/node": "^22.18.6", + "@vitest/coverage-v8": "^4.1.8", + "eslint": "^9.36.0", + "eslint-plugin-import": "^2.31.0", + "globals": "^16.4.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.44.0", + "vitest": "^4.1.8" + } +} diff --git a/protocol/src/index.ts b/protocol/src/index.ts new file mode 100644 index 00000000..6728e0d3 --- /dev/null +++ b/protocol/src/index.ts @@ -0,0 +1,12 @@ +/** + * Canonical battle protocol: encodings, hashes, seed derivation, ruleset, and the + * commitment/receipt schemas. MIT, so third parties can replay signed receipts. + * + * See the package README for the constraints every module here must hold to + * (no clock, no ambient randomness, no I/O, canonical encoding only). + * + * Modules land here in the order set by docs/plan-backend-battle-steps.md. + */ + +/** Package identity, exported so a consumer can assert which protocol build it loaded. */ +export const PROTOCOL_PACKAGE = '@cryptopets/protocol'; diff --git a/protocol/tests/package.test.ts b/protocol/tests/package.test.ts new file mode 100644 index 00000000..c109e93c --- /dev/null +++ b/protocol/tests/package.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +import { PROTOCOL_PACKAGE } from '../src/index'; + +/** + * Licensing guard, not a smoke test. §H of the battle architecture only works if + * outsiders can run the verifier, and the verifier depends on this package. Two + * things therefore have to stay true, and both are easy to break by accident: + * this package stays MIT, and it never pulls in a PolyForm-licensed dependency. + */ +describe('package licensing', () => { + const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + + it('is MIT so third parties can run the receipt verifier', () => { + expect(pkg.license).toBe('MIT'); + }); + + it('depends on no PolyForm-licensed workspace package', () => { + const polyform = ['@shared/core', 'backend', 'frontend', 'mobile', 'website']; + const declared = Object.keys({ ...pkg.dependencies, ...pkg.peerDependencies, ...pkg.devDependencies }); + expect(declared.filter((name) => polyform.includes(name))).toEqual([]); + }); + + it('exports its identity', () => { + expect(PROTOCOL_PACKAGE).toBe(pkg.name); + }); +}); diff --git a/protocol/tsconfig.json b/protocol/tsconfig.json new file mode 100644 index 00000000..4972d4c1 --- /dev/null +++ b/protocol/tsconfig.json @@ -0,0 +1,30 @@ +{ + // Typecheck-only config (`pnpm typecheck`). This package is consumed as raw + // TypeScript source, like `shared`, so nothing is emitted. It exists so the + // standalone verifier and any third party can typecheck against the protocol + // without inheriting an app package's config. + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + // `node` is here for the test suites only (golden vectors are read off disk). + // Nothing under src/ may import a node builtin: this package has to run in a + // browser bundle too. + "types": ["vitest/globals", "node"], + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/protocol/vitest.config.ts b/protocol/vitest.config.ts new file mode 100644 index 00000000..d06b9eaf --- /dev/null +++ b/protocol/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, coverageConfigDefaults } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.{test,spec}.ts'], + coverage: { + provider: 'v8', + reportsDirectory: './coverage', + reporter: ['text', 'html', 'lcov', 'json', 'json-summary'], + include: ['src/**/*.ts'], + exclude: [...coverageConfigDefaults.exclude, 'src/**/index.ts'], + }, + }, +}); From 6e754603283edf4eb70f1cfce95d5d33a5390e33 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 06:06:32 -0400 Subject: [PATCH 05/76] refactor(protocol): move TS combat engine out of shared into MIT protocol package --- AGENTS.md | 6 +-- CLAUDE.md | 10 ++-- README.md | 2 +- backend/scripts/bundle-shared-node.cjs | 11 ++++- docs/plan-backend-battle-architecture.md | 7 +-- docs/plan-future-features-roadmap.md | 6 +-- docs/testing.md | 3 +- .../hooks/battle/useLiveBattleAnimation.ts | 2 +- pnpm-lock.yaml | 3 ++ .../src/utils => protocol/src}/combat/dna.ts | 2 +- protocol/src/combat/index.ts | 25 ++++++++++ .../src/utils => protocol/src}/combat/rng.ts | 0 .../src/utils => protocol/src}/combat/sim.ts | 0 .../utils => protocol/src}/combat/skills.ts | 0 .../utils => protocol/src}/combat/strike.ts | 0 .../src/utils => protocol/src}/combat/wire.ts | 0 protocol/src/index.ts | 2 + .../tests}/combat/goldenVectors.test.ts | 7 ++- shared/package.json | 1 + shared/src/utils/combat/index.ts | 46 +++++++++++++------ 20 files changed, 100 insertions(+), 33 deletions(-) rename {shared/src/utils => protocol/src}/combat/dna.ts (97%) create mode 100644 protocol/src/combat/index.ts rename {shared/src/utils => protocol/src}/combat/rng.ts (100%) rename {shared/src/utils => protocol/src}/combat/sim.ts (100%) rename {shared/src/utils => protocol/src}/combat/skills.ts (100%) rename {shared/src/utils => protocol/src}/combat/strike.ts (100%) rename {shared/src/utils => protocol/src}/combat/wire.ts (100%) rename {shared/tests/utils => protocol/tests}/combat/goldenVectors.test.ts (90%) diff --git a/AGENTS.md b/AGENTS.md index 9d5f6ff2..b1042e0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,9 @@ Normative language: `MUST`/`MUST NOT` are mandatory. `SHOULD`/`SHOULD NOT` are e ## Non-Negotiables - `MUST NOT` edit the golden test vectors in `contracts/test-vectors/{battle,xp}.json` to make a failing test pass. If a vector fails, the Go or Rust port has drifted from the Solidity contract; fix the drifted port, never the vector. -- `MUST` update all four combat-simulator ports together (`contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, `indexer-go/internal/combat/`, `shared/src/utils/combat/`) when changing combat logic. Never patch one leg alone. The TS port (`shared/src/utils/combat/`) covers fight math only, not XP — see its package doc. +- `MUST` update all four combat-simulator ports together (`contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, `indexer-go/internal/combat/`, `protocol/src/combat/`) when changing combat logic. Never patch one leg alone. The TS port (`protocol/src/combat/`, re-exported from `shared/src/utils/combat` for existing importers) covers fight math only, not XP — see its package doc. - `MUST NOT` assume the `ChainAdapter` interface (`shared/src/hooks/adapters/`) covers more than pet-action mutations and reads. It is a real, shared interface (`useEvmAdapter`/`useSolanaAdapter` both implement it) and every public pet-action hook consumes it chain-blind, but the low-level chain wiring in `frontend/src/chains/{ethereum,solana}/`, the async battle/breed VRF flows, and the combat simulator remain intentionally separate per chain. See CLAUDE.md's cross-chain interfaces section for the exact boundary. -- `MUST` match the license of the package being edited when adding new files: `contracts/ethereum`, `contracts/solana`, `indexer-go`, and `proto` are MIT; everything else is PolyForm Noncommercial 1.0.0 (root `LICENSE`). See the table in `README.md`. +- `MUST` match the license of the package being edited when adding new files: `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, and `protocol` are MIT; everything else is PolyForm Noncommercial 1.0.0 (root `LICENSE`). See the table in `README.md`. `protocol` is MIT on purpose (third parties have to be able to replay signed battle receipts), so it `MUST NOT` import from a PolyForm package; a test in that package enforces it. - `MUST NOT` treat the v1 contract gaps documented in `contracts/plan-contract-upgrade.md` (no battle authorization, the `changeDna` cheat, client-supplied Solana starter-pet DNA) as bugs to silently patch. They are the known baseline the v2 rewrite is designed around. - `MUST` run the smallest scoped lint/test/build command for the package you touched (see Command Baseline below), not a full monorepo run, unless the change is broad. - `SHOULD NOT` trust `DEVELOPMENT.md`, `contracts/ethereum/README.md`, or the root `eth:deploy` / `eth:vrf:watch` scripts at face value. Several reference commands removed in a past refactor; see CLAUDE.md's Commands section for what is actually current. @@ -45,6 +45,6 @@ Full per-package lint/test/build matrix and single-test syntax: see [CLAUDE.md]( Mechanical checks over prose, where they exist: - ESLint per package (`frontend`, `shared`, `website`, `mobile`), plus a custom CSS-naming check in `frontend` (`lint:css`). -- Golden test vectors (`contracts/test-vectors/{battle,xp}.json`), run by Hardhat, Anchor, `indexer-go`'s `combat_golden_test.go`, and `shared`'s `tests/utils/combat/goldenVectors.test.ts` (Vitest), are the cross-language enforcement for combat-simulator parity. +- Golden test vectors (`contracts/test-vectors/{battle,xp}.json`), run by Hardhat, Anchor, `indexer-go`'s `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts` (Vitest), are the cross-language enforcement for combat-simulator parity. - CI coverage workflow (`.github/workflows/coverage.yml`) runs frontend/backend/shared vitest coverage on every PR and posts a combined comment. - There is no repo-wide `agents:check` or module-boundary lint yet. Rely on the per-package commands above and the golden vectors until one exists. diff --git a/CLAUDE.md b/CLAUDE.md index cd06b17c..84b7eddb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ pnpm build # compile contracts + build backend + frontend + we | `frontend` | `pnpm --filter frontend lint:check` (`eslint . --max-warnings 0` + CSS naming check) | `pnpm --filter frontend test` (vitest) | `pnpm --filter frontend build` (`tsc -b && vite build`) | `pnpm --filter frontend exec vitest run ` or `-t ""` | | `backend` | *(none)* | `pnpm --filter backend test` (vitest) | `pnpm --filter backend build` (`prisma generate && tsc`) | `pnpm --filter backend exec vitest run ` | | `shared` (`@shared/core`) | `pnpm --filter @shared/core lint` | `pnpm --filter @shared/core test` (vitest) | *(none, consumed as raw TS)* | same vitest pattern | +| `protocol` (`@cryptopets/protocol`) | `pnpm --filter @cryptopets/protocol lint` | `pnpm --filter @cryptopets/protocol test` (vitest) | *(none, consumed as raw TS; `typecheck` runs `tsc --noEmit`)* | same vitest pattern | | `mobile` | `pnpm --filter mobile lint` | `pnpm --filter mobile test` (jest) | *(none, RN, use `android`/`ios` scripts)* | `pnpm --filter mobile exec jest ` or `-t ""` | | `website` | `pnpm --filter website lint` (`next lint`) | *(no test script)* | `pnpm --filter website build` | n/a | | `contracts/ethereum` | *(none)* | `pnpm --prefix contracts/ethereum test` (`pnpm hh test`) | `pnpm compile` (`pnpm hh compile --force`) | `pnpm --prefix contracts/ethereum hh test test/.test.ts` | @@ -85,6 +86,7 @@ pnpm build # compile contracts + build backend + frontend + we | `contracts/ethereum` | Solidity, Hardhat | EVM contracts + subgraph | | `contracts/solana/cryptopets` | Rust, Anchor | Solana programs | | `shared` (`@shared/core`) | TypeScript | Common utils/types/hooks, consumed as raw TS (no build step), shared by frontend + mobile | +| `protocol` (`@cryptopets/protocol`) | TypeScript | MIT, dependency-free battle protocol: the TS combat engine plus (in progress) canonical encodings, hashes, and drand seed derivation. Consumed as raw TS by `shared`/`backend` and by the public receipt verifier | | `proto` | Protobuf/Buf | gRPC contract (`GameDataService`) between `indexer-go` and `backend` | ### Data flow @@ -98,7 +100,7 @@ Note: `docs/README.md` and `docs/architecture.md` link to `indexer-go/ARCHITECTU What that adapter does NOT unify: `frontend/src/chains/ethereum/` (wagmi client, in-tree ABI JSONs: `combatSimAbi.json`, `gameConfigAbi.json`, `gameLogicAbi.json`, `petCoreAbi.json`) and `frontend/src/chains/solana/` (Anchor wallet/provider/signer) are still separate, low-level wiring with no shared interface between them, each adapter reaches into its own directly. The async battle/breed VRF flows (`useEvmBattleFlow.ts`, `battleWithSwitchboardVrf.ts`) and the combat simulator itself are also not unified; see the next section. Treat the adapter as a thin, uniform shape over pet-action mutations and reads, not a claim that the underlying chain logic is shared. ### Combat simulator is ported four times: golden vectors keep them in sync -The battle/combat logic is implemented independently in `contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, pure Go in `indexer-go/internal/combat/`, and pure TypeScript in `shared/src/utils/combat/` (the fourth port, added for client-side live battle replay — see `docs/plan-realtime-battle-impl.md` Phase 3). All four are validated against the same golden test vectors at `contracts/test-vectors/{battle,xp}.json`, run by Hardhat, Anchor, `combat_golden_test.go`, and `shared`'s `tests/utils/combat/goldenVectors.test.ts` respectively. Hashing uses **legacy Keccak-256** (`keccak256(abi.encodePacked(...))` byte layout); a SHA3-vs-Keccak mismatch fails every vector. The TS port covers fight math only, not XP (`xp.go`'s equivalent isn't ported): XP depends on on-chain same-opponent streak state the client can't know, and `BattleResolved` already carries `xpWin`/`xpLoss`. +The battle/combat logic is implemented independently in `contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, pure Go in `indexer-go/internal/combat/`, and pure TypeScript in `protocol/src/combat/` (the fourth port, added for client-side live battle replay — see `docs/plan-realtime-battle-impl.md` Phase 3; it lived in `shared/src/utils/combat/` until the backend-battle work moved it into the MIT `protocol` package, which now re-exports through that old path). All four are validated against the same golden test vectors at `contracts/test-vectors/{battle,xp}.json`, run by Hardhat, Anchor, `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts` respectively. Hashing uses **legacy Keccak-256** (`keccak256(abi.encodePacked(...))` byte layout); a SHA3-vs-Keccak mismatch fails every vector. The TS port covers fight math only, not XP (`xp.go`'s equivalent isn't ported): XP depends on on-chain same-opponent streak state the client can't know, and `BattleResolved` already carries `xpWin`/`xpLoss`. **If a golden vector test fails, the Go, Rust, or TS implementation has drifted from the Solidity contract. Fix the drifted port, never edit the vector.** ### Settle keeper: the second EVM battle/breed/mint transaction isn't the player's @@ -108,7 +110,7 @@ The battle/combat logic is implemented independently in `contracts/ethereum/src/ The EVM settle keeper (above) sends `settleBattle` from its own wallet, but until this was added that transaction (~800k gas, `SETTLE_GAS_LIMIT` in `backend/src/features/settle-keeper/abi.ts`) was entirely unfunded — the player's `requestBattle` payment only ever covered the Pyth Entropy fee. `GameConfig.battleFee` (owner-tunable via `setBattleFee`) is now required on top of the entropy fee at `requestBattle` time, escrowed in the pending record, and refunded on `cancelBattle` (no settle tx is ever sent for a cancelled request); on a normal settle it just adds to the contract's withdrawable balance alongside the other protocol fees — there's no automatic reimbursement to the keeper wallet specifically, so it still needs manual top-ups from `withdraw()` proceeds. The frontend surfaces this via `useFees().battleFee` (chain-neutral — see the Solana section below) and shows it in the Start Battle button label. Because `GameConfig` isn't behind a proxy (see its own doc comment), adding this field required a fresh `GameConfig` deployment plus a new `setGameConfig(address)` setter on both `GameLogic` and `PetCore` (added together, deliberately — `PetCore` reads several other config values like `battleCooldown`/`poolSizes`, and pointing only one proxy at a new instance would let the two silently diverge). `scripts/upgrade-game-config.ts` handles the migration: it replays every existing tunable from the old `GameConfig` onto the new one before repointing anything, so live-tuned values (fees, skill balance, cooldowns) aren't reset to source defaults. This has been run against the live Base Sepolia deployment; any client env (`VITE_GAMECONFIG_ADDRESS`, `KEEPER_GAME_CONFIG_ADDRESS`) pointing at the old `GameConfig` address needs updating too, or fee reads fail outright (the old contract has no `battleFee()` at all) — check `frontend/.env`/`.env.local` and `backend/.env` aren't stale before assuming a deployment issue is something else. ### Solana settle keeper and battle-only permissionless settle -Mirrors the EVM keeper, but for Solana's `commit_battle`/`settle_battle` (Switchboard On-Demand VRF) and **battle only** — `settle_breed`/`settle_mint` still require the player's own signature, because their Metaplex Core mint CPI needs a real payer signature (see `docs/plan-realtime-battle-solana.md` Workstream S2 for why this doesn't generalize the way the EVM keeper did). `SettleBattle`'s `attacker_owner` account was changed from `Signer` to `UncheckedAccount` (mirroring this program's own pre-existing `cancel_battle` pattern), and a sibling backend module, `backend/src/features/settle-keeper-solana/`, polls open `BattleRequest`s and submits reveal+settle once Switchboard's oracle is ready. Gated by `KEEPER_SOLANA_ENABLED` (off by default); see `backend/env.example`. The frontend (`shared/src/utils/solana/battleWithSwitchboardVrf.ts`) waits up to 45s for the keeper before falling back to sending reveal+settle from the player's own wallet, same pattern as EVM's `FALLBACK_SETTLE_DELAY_MS`. A second Rust change, `BattleRequest` snapshotting attacker/defender dna/rarity/level/species at commit time (Workstream S1), closes the same train/level-up front-run reroll Phase 1 closed on EVM — `settle_battle.rs` now simulates from that frozen snapshot instead of live pet accounts. Client-side live battle animation (Workstream S3, `useLiveBattleReplaySolana`) reuses the same `shared/src/utils/combat/` TS port (no new simulator code) by independently deriving the seed from an unbroadcast Switchboard reveal instruction; this specific mechanism is unverified against a live gateway (see the hook's header comment) and degrades to no animation, never a broken UI, if the assumption is wrong. +Mirrors the EVM keeper, but for Solana's `commit_battle`/`settle_battle` (Switchboard On-Demand VRF) and **battle only** — `settle_breed`/`settle_mint` still require the player's own signature, because their Metaplex Core mint CPI needs a real payer signature (see `docs/plan-realtime-battle-solana.md` Workstream S2 for why this doesn't generalize the way the EVM keeper did). `SettleBattle`'s `attacker_owner` account was changed from `Signer` to `UncheckedAccount` (mirroring this program's own pre-existing `cancel_battle` pattern), and a sibling backend module, `backend/src/features/settle-keeper-solana/`, polls open `BattleRequest`s and submits reveal+settle once Switchboard's oracle is ready. Gated by `KEEPER_SOLANA_ENABLED` (off by default); see `backend/env.example`. The frontend (`shared/src/utils/solana/battleWithSwitchboardVrf.ts`) waits up to 45s for the keeper before falling back to sending reveal+settle from the player's own wallet, same pattern as EVM's `FALLBACK_SETTLE_DELAY_MS`. A second Rust change, `BattleRequest` snapshotting attacker/defender dna/rarity/level/species at commit time (Workstream S1), closes the same train/level-up front-run reroll Phase 1 closed on EVM — `settle_battle.rs` now simulates from that frozen snapshot instead of live pet accounts. Client-side live battle animation (Workstream S3, `useLiveBattleReplaySolana`) reuses the same `protocol/src/combat/` TS port (no new simulator code) by independently deriving the seed from an unbroadcast Switchboard reveal instruction; this specific mechanism is unverified against a live gateway (see the hook's header comment) and degrades to no animation, never a broken UI, if the assumption is wrong. `commit_battle` also charges `GlobalState.battle_fee_lamports` now, mirroring the EVM battle fee above (funds the settle keeper's own `settle_battle` submission; escrowed in `BattleRequest.battle_fee`, refunded by `cancel_battle`). Owner-tunable via `set_battle_fee_lamports`. The already-deployed devnet `GlobalState` account predates this field — it lives in what was previously reserved padding (`GlobalState::SPACE` is unchanged), so after a program upgrade it reads back as `0` until an admin explicitly calls `set_battle_fee_lamports` once; `initialize` only sets the real default for a genuinely fresh account. `BattleRequest::SPACE` did grow (new `battle_fee` field, no reserved padding on request accounts — mirrors how `BreedRequest.stud_fee`/`other_owner` were added previously), so any already-committed-but-unsettled `BattleRequest` at upgrade time needs to be settled or let expire (`cancel_battle`) before deploying, or it will fail to deserialize. **Rust/Anchor changes here were written without a local toolchain (no `cargo`/`anchor`/`rustc`/`solana` on PATH in this environment) — run `anchor build` / `anchor test` before trusting them.** @@ -151,4 +153,6 @@ See `docs/testing.md` for the full per-package suite table. Test work is expecte ## Licensing -This monorepo has split licensing; see the table in `README.md`. `contracts/ethereum`, `contracts/solana`, `indexer-go`, and `proto` are MIT; everything else (`frontend`, `backend`, `mobile`, `website`, `shared`) is PolyForm Noncommercial 1.0.0 (root `LICENSE`). Match the license of whichever package you're editing when adding new files. +This monorepo has split licensing; see the table in `README.md`. `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, and `protocol` are MIT; everything else (`frontend`, `backend`, `mobile`, `website`, `shared`) is PolyForm Noncommercial 1.0.0 (root `LICENSE`). Match the license of whichever package you're editing when adding new files. + +`protocol` (`@cryptopets/protocol`) is MIT deliberately: the backend-authoritative battle design (`docs/plan-backend-battle-architecture.md` §H) only holds up if outsiders can run the receipt verifier, and the verifier depends on this package. So it must never import from a PolyForm package (`tests/package.test.ts` enforces it), and it must stay free of clock reads, ambient randomness, and I/O (eslint enforces the first two). The TS combat engine lives here now, re-exported from `shared/src/utils/combat` so existing importers are unchanged. diff --git a/README.md b/README.md index 43191204..202aede6 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ This monorepo uses two licenses depending on the package: | Package(s) | License | | --- | --- | -| `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto` | [MIT](./contracts/LICENSE) — fully permissive | +| `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, `protocol` | [MIT](./contracts/LICENSE) — fully permissive | | `frontend`, `backend`, `mobile`, `website`, `shared` (and anything else) | [PolyForm Noncommercial 1.0.0](./LICENSE) — free for any noncommercial purpose; commercial use requires permission | Each package's `package.json` / `go.mod` directory points at the license that diff --git a/backend/scripts/bundle-shared-node.cjs b/backend/scripts/bundle-shared-node.cjs index e35a0a9d..ba8b565b 100644 --- a/backend/scripts/bundle-shared-node.cjs +++ b/backend/scripts/bundle-shared-node.cjs @@ -11,6 +11,14 @@ const esbuild = require('esbuild'); const entry = path.resolve(__dirname, '../../shared/src/node.ts'); const outfile = path.resolve(__dirname, '../dist/shared-node.cjs'); +/** + * `@cryptopets/protocol` is raw TypeScript source, like shared itself, so it must + * be inlined rather than left as a `require()` that production Node cannot load. + * `packages: 'external'` would externalize it on name alone, so point the + * specifier at the source file and let esbuild pull it in. + */ +const protocolEntry = path.resolve(__dirname, '../../protocol/src/index.ts'); + esbuild .build({ entryPoints: [entry], @@ -19,8 +27,9 @@ esbuild platform: 'node', format: 'cjs', target: 'node20', - // Keep heavy native/npm deps external; only inline our shared TS sources. + // Keep heavy native/npm deps external; only inline our own TS sources. packages: 'external', + alias: { '@cryptopets/protocol': protocolEntry }, logLevel: 'info', }) .then(() => { diff --git a/docs/plan-backend-battle-architecture.md b/docs/plan-backend-battle-architecture.md index 06972910..470739b4 100644 --- a/docs/plan-backend-battle-architecture.md +++ b/docs/plan-backend-battle-architecture.md @@ -419,7 +419,8 @@ drand. > **In plain words:** the fight math becomes a versioned, published rulebook, and we run a second > copy to catch our own bugs. -`shared/src/utils/combat/` becomes the canonical computation path for backend and client replay. +`protocol/src/combat/` (moved out of `shared/src/utils/combat/`, which now re-exports it) becomes the +canonical computation path for backend and client replay. Every battle records `rulesetVersion`, `rulesetHash`, the immutable skill/balance configuration, the snapshot hash, the beacon proof, the derived seed, and the result plus combat-log hash. @@ -427,14 +428,14 @@ the snapshot hash, the beacon proof, the derived seed, and the result plus comba This is real work on the critical path, not a checklist item. -`shared/src/utils/combat/` today implements fight math only. There is no `xp.ts`. XP lives solely in +`protocol/src/combat/` today implements fight math only. There is no `xp.ts`. XP lives solely in `indexer-go/internal/combat/xp.go`, and it depends on stateful inputs the current client cannot see: `lastOpponentId` and same-opponent streak decay (`xp.go:32-41`). That is exactly why the TS port stopped at fight math. Under backend authority that state moves into `PetBattleProgress`, so it becomes portable: -1. Port `xp.go` to `shared/src/utils/combat/xp.ts`, reading streak state from the frozen snapshot +1. Port `xp.go` to `protocol/src/combat/xp.ts`, reading streak state from the frozen snapshot rather than chain state. 2. Extend the snapshot to carry `lastOpponentId` and `streak` per pet, so progression is a pure function of the receipt's own inputs and stays independently replayable. diff --git a/docs/plan-future-features-roadmap.md b/docs/plan-future-features-roadmap.md index dab3def0..4c5f0445 100644 --- a/docs/plan-future-features-roadmap.md +++ b/docs/plan-future-features-roadmap.md @@ -29,7 +29,7 @@ names as historical pointers, not sources to read. new files must carry the license header matching whichever package they land in. **The chain-parity discipline extends past combat.** `CombatSim.sol` / `combat.rs` / -`indexer-go/internal/combat` / `shared/src/utils/combat` are kept in sync today via golden +`indexer-go/internal/combat` / `protocol/src/combat` are kept in sync today via golden vectors (`contracts/test-vectors/{battle,xp}.json`). Any new feature that (a) computes a deterministic on-chain outcome and (b) needs a client-side TypeScript port for animation or preview inherits the same obligation: if team battles or story-chapter unlocks get a TS replay @@ -42,8 +42,8 @@ give something a TS port if the client actually needs to simulate it before the backend resolves fights from a frozen snapshot against a versioned ruleset, seeds them from a pre-committed drand round, and publishes signed receipts anyone can replay (`docs/plan-backend-battle-steps.md` sequences the work). Two consequences for this doc. First, a -*new* combat mechanic is built once, in the canonical TypeScript engine (moving from -`shared/src/utils/combat/` into an MIT `protocol/` package early in that plan), with the Go port +*new* combat mechanic is built once, in the canonical TypeScript engine (`protocol/src/combat/`, +moved out of `shared/src/utils/combat/` into the MIT `protocol` package), with the Go port acting as an independent pre-signing verifier rather than a fourth hand-maintained implementation. Second, the four-port rule stays a `MUST` in `AGENTS.md` until the legacy on-chain path actually retires, so any change to *existing* combat math still updates all four ports until then. diff --git a/docs/testing.md b/docs/testing.md index 7ce63244..0d8231ff 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,6 +9,7 @@ own suite with the toolchain native to its stack. | --- | --- | --- | | backend | Vitest | `pnpm --filter backend test` | | shared (`@shared/core`) | Vitest | `pnpm --filter @shared/core test` | +| protocol (`@cryptopets/protocol`) | Vitest | `pnpm --filter @cryptopets/protocol test` | | frontend | Vitest | `pnpm --filter frontend test` | | contracts/ethereum | Hardhat | `pnpm test` (root) | | indexer-go | `go test` | `go test ./...` (in `indexer-go`) | @@ -17,7 +18,7 @@ own suite with the toolchain native to its stack. > `TEST_DATABASE_URL` at a scratch DB only. See > [indexer-go/README.md](../indexer-go/README.md). -> `shared`'s suite includes `tests/utils/combat/goldenVectors.test.ts`, which +> `@cryptopets/protocol`'s suite includes `tests/combat/goldenVectors.test.ts`, which > consumes `contracts/test-vectors/battle.json` directly — the same file > Hardhat, Anchor, and `indexer-go`'s `combat_golden_test.go` consume. It's the > fourth combat-simulator port (TypeScript, for client-side battle replay); a diff --git a/frontend/src/hooks/battle/useLiveBattleAnimation.ts b/frontend/src/hooks/battle/useLiveBattleAnimation.ts index 1d702878..fd647b78 100644 --- a/frontend/src/hooks/battle/useLiveBattleAnimation.ts +++ b/frontend/src/hooks/battle/useLiveBattleAnimation.ts @@ -22,7 +22,7 @@ export interface LiveBattleAnimationState { } /** - * Plays a combat-sim log (shared/src/utils/combat, via useEvmBattleFlow's + * Plays a combat-sim log (@cryptopets/protocol's combat engine, via useEvmBattleFlow's * `liveReplay`) one strike at a time, exposing HP percentages and a flavor * line for the fighting scene. Presentation only — see useBattlePanel.ts for * the gate that keeps the result card off this animation and the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 805839e9..0e6f9a86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,6 +517,9 @@ importers: '@coral-xyz/anchor': specifier: ^0.32.0 version: 0.32.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@cryptopets/protocol': + specifier: workspace:* + version: link:../protocol '@solana/web3.js': specifier: ^1.95.0 version: 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) diff --git a/shared/src/utils/combat/dna.ts b/protocol/src/combat/dna.ts similarity index 97% rename from shared/src/utils/combat/dna.ts rename to protocol/src/combat/dna.ts index bac43c45..ff17be2f 100644 --- a/shared/src/utils/combat/dna.ts +++ b/protocol/src/combat/dna.ts @@ -8,7 +8,7 @@ * Every function here is pure bigint math, bit-identical to the Solidity / * Rust / Go implementations — cross-chain (and now cross-runtime) parity is * enforced by the golden vectors in contracts/test-vectors/battle.json (see - * shared/tests/utils/combat/goldenVectors.test.ts). If a vector fails, this + * protocol/tests/combat/goldenVectors.test.ts). If a vector fails, this * port is wrong; fix the TS, never the vector. */ diff --git a/protocol/src/combat/index.ts b/protocol/src/combat/index.ts new file mode 100644 index 00000000..266435f5 --- /dev/null +++ b/protocol/src/combat/index.ts @@ -0,0 +1,25 @@ +export type { Attrs } from './dna'; +export { digitPair, elementMod, extract, toUint16 } from './dna'; +export { roundSeed, strikeRoll } from './rng'; +export { + DEFAULT_SKILL_CONFIG, + NO_SKILL, + SKILL_BLOODLUST, + SKILL_CUNNING, + SKILL_FURY, + SKILL_REBIRTH, + SKILL_SAGE, + SKILL_SHELL, + SKILL_SWIFT, + SKILL_TANK, + type SkillConfig, +} from './skills'; +export type { StrikeOutcome } from './strike'; +export { addHeal, strike } from './strike'; +export { MAX_ROUNDS, simulate, type SimOutcome, type SimResult, type StrikeLogEntry } from './sim'; +export { + encodeSimOutcome, + decodeSimOutcome, + type SimOutcomeWire, + type StrikeLogEntryWire, +} from './wire'; diff --git a/shared/src/utils/combat/rng.ts b/protocol/src/combat/rng.ts similarity index 100% rename from shared/src/utils/combat/rng.ts rename to protocol/src/combat/rng.ts diff --git a/shared/src/utils/combat/sim.ts b/protocol/src/combat/sim.ts similarity index 100% rename from shared/src/utils/combat/sim.ts rename to protocol/src/combat/sim.ts diff --git a/shared/src/utils/combat/skills.ts b/protocol/src/combat/skills.ts similarity index 100% rename from shared/src/utils/combat/skills.ts rename to protocol/src/combat/skills.ts diff --git a/shared/src/utils/combat/strike.ts b/protocol/src/combat/strike.ts similarity index 100% rename from shared/src/utils/combat/strike.ts rename to protocol/src/combat/strike.ts diff --git a/shared/src/utils/combat/wire.ts b/protocol/src/combat/wire.ts similarity index 100% rename from shared/src/utils/combat/wire.ts rename to protocol/src/combat/wire.ts diff --git a/protocol/src/index.ts b/protocol/src/index.ts index 6728e0d3..84e3ac6e 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -10,3 +10,5 @@ /** Package identity, exported so a consumer can assert which protocol build it loaded. */ export const PROTOCOL_PACKAGE = '@cryptopets/protocol'; + +export * from './combat'; diff --git a/shared/tests/utils/combat/goldenVectors.test.ts b/protocol/tests/combat/goldenVectors.test.ts similarity index 90% rename from shared/tests/utils/combat/goldenVectors.test.ts rename to protocol/tests/combat/goldenVectors.test.ts index ba5baad9..7e277401 100644 --- a/shared/tests/utils/combat/goldenVectors.test.ts +++ b/protocol/tests/combat/goldenVectors.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { simulate, type SimOutcome, type SkillConfig } from '../../../src/utils/combat'; +import { simulate, type SimOutcome, type SkillConfig } from '../../src/combat'; /** * Consumes contracts/test-vectors/battle.json directly — the same file @@ -31,7 +31,7 @@ interface BattleVectorFile { } const here = dirname(fileURLToPath(import.meta.url)); -const vectorsPath = join(here, '../../../../contracts/test-vectors/battle.json'); +const vectorsPath = join(here, '../../../contracts/test-vectors/battle.json'); const vectors: BattleVectorFile = JSON.parse(readFileSync(vectorsPath, 'utf8')); // Internal consistency check, independent of the golden expectations: the log @@ -42,6 +42,9 @@ function assertLogExplainsResult(outcome: SimOutcome): void { const { result, log } = outcome; expect(log.length).toBeGreaterThan(0); const last = log[log.length - 1]; + // Narrowing for this package's `noUncheckedIndexedAccess`; the length + // assertion above is the real check. + if (!last) throw new Error('combat log is empty'); expect(last.round).toBe(result.rounds - 1); const winnerHp = result.firstWins ? last.hp1After : last.hp2After; const cappedWinnerHp = winnerHp > 0xffffn ? 0xffffn : winnerHp; diff --git a/shared/package.json b/shared/package.json index 5229601d..97025088 100644 --- a/shared/package.json +++ b/shared/package.json @@ -50,6 +50,7 @@ "vitest": "^4.1.8" }, "dependencies": { + "@cryptopets/protocol": "workspace:*", "@switchboard-xyz/on-demand": "^3.10.2", "bs58": "^6.0.0", "buffer": "^6.0.3" diff --git a/shared/src/utils/combat/index.ts b/shared/src/utils/combat/index.ts index 266435f5..a2ddabef 100644 --- a/shared/src/utils/combat/index.ts +++ b/shared/src/utils/combat/index.ts @@ -1,9 +1,34 @@ -export type { Attrs } from './dna'; -export { digitPair, elementMod, extract, toUint16 } from './dna'; -export { roundSeed, strikeRoll } from './rng'; +/** + * Compatibility re-export. The combat engine moved to `@cryptopets/protocol` + * (`protocol/src/combat/`) so the standalone receipt verifier can replay fights + * under an MIT license — see that package's README for why. Existing + * `shared/src/utils/combat` imports in frontend, mobile, and backend keep + * working unchanged. + * + * New code should import from `@cryptopets/protocol` directly. + */ +export type { + Attrs, + SimOutcome, + SimOutcomeWire, + SimResult, + SkillConfig, + StrikeLogEntry, + StrikeLogEntryWire, + StrikeOutcome, +} from '@cryptopets/protocol'; export { + addHeal, DEFAULT_SKILL_CONFIG, + decodeSimOutcome, + digitPair, + elementMod, + encodeSimOutcome, + extract, + MAX_ROUNDS, NO_SKILL, + roundSeed, + simulate, SKILL_BLOODLUST, SKILL_CUNNING, SKILL_FURY, @@ -12,14 +37,7 @@ export { SKILL_SHELL, SKILL_SWIFT, SKILL_TANK, - type SkillConfig, -} from './skills'; -export type { StrikeOutcome } from './strike'; -export { addHeal, strike } from './strike'; -export { MAX_ROUNDS, simulate, type SimOutcome, type SimResult, type StrikeLogEntry } from './sim'; -export { - encodeSimOutcome, - decodeSimOutcome, - type SimOutcomeWire, - type StrikeLogEntryWire, -} from './wire'; + strike, + strikeRoll, + toUint16, +} from '@cryptopets/protocol'; From 08e83548f01a29756d8e83ecd9f2aacf72ecece6 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 06:21:20 -0400 Subject: [PATCH 06/76] feat(protocol): add canonical binary encoding and keccak hashing primitives --- backend/package.json | 1 + pnpm-lock.yaml | 99 ++++++++++++++- protocol/package.json | 3 + protocol/src/encoding/bytes.ts | 116 ++++++++++++++++++ protocol/src/encoding/domain.ts | 47 ++++++++ protocol/src/encoding/hash.ts | 24 ++++ protocol/src/encoding/index.ts | 13 ++ protocol/src/encoding/writer.ts | 159 +++++++++++++++++++++++++ protocol/src/index.ts | 1 + protocol/tests/encoding/bytes.test.ts | 113 ++++++++++++++++++ protocol/tests/encoding/domain.test.ts | 23 ++++ protocol/tests/encoding/hash.test.ts | 40 +++++++ protocol/tests/encoding/writer.test.ts | 134 +++++++++++++++++++++ 13 files changed, 770 insertions(+), 3 deletions(-) create mode 100644 protocol/src/encoding/bytes.ts create mode 100644 protocol/src/encoding/domain.ts create mode 100644 protocol/src/encoding/hash.ts create mode 100644 protocol/src/encoding/index.ts create mode 100644 protocol/src/encoding/writer.ts create mode 100644 protocol/tests/encoding/bytes.test.ts create mode 100644 protocol/tests/encoding/domain.test.ts create mode 100644 protocol/tests/encoding/hash.test.ts create mode 100644 protocol/tests/encoding/writer.test.ts diff --git a/backend/package.json b/backend/package.json index d3abc922..0d63387f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "@ai-sdk/openai": "^3.0.68", "@coral-xyz/anchor": "^0.32.0", "@grpc/grpc-js": "^1.14.4", + "@noble/hashes": "^1.8.0", "@grpc/proto-loader": "^0.8.1", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e6f9a86..aee5a57c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@grpc/proto-loader': specifier: ^0.8.1 version: 0.8.1 + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 '@prisma/adapter-pg': specifier: ^7.8.0 version: 7.8.0 @@ -140,7 +143,7 @@ importers: version: 5.8.3 vitest: specifier: ^4.1.8 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.18.12)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.18.12)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) contracts/ethereum: dependencies: @@ -483,6 +486,10 @@ importers: version: 5.8.3 protocol: + dependencies: + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -510,7 +517,7 @@ importers: version: 8.46.2(eslint@9.38.0(jiti@2.7.0))(typescript@5.8.3) vitest: specifier: ^4.1.8 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.18.12)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.18.12)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) shared: dependencies: @@ -13578,6 +13585,11 @@ snapshots: '@evervault/wasm-attestation-bindings@0.3.0': {} + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + optional: true + '@exodus/bytes@1.15.1(@noble/hashes@2.0.1)': optionalDependencies: '@noble/hashes': 2.0.1 @@ -19199,7 +19211,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.18.12)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.18.12)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@vitest/expect@4.1.9': dependencies: @@ -22954,6 +22966,14 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + optional: true + data-urls@7.0.0(@noble/hashes@2.0.1): dependencies: whatwg-mimetype: 5.0.0 @@ -24495,6 +24515,13 @@ snapshots: '@hpke/dhkem-x25519': 1.6.4 '@hpke/dhkem-x448': 1.6.4 + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + optional: true + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): dependencies: '@exodus/bytes': 1.15.1(@noble/hashes@2.0.1) @@ -25320,6 +25347,33 @@ snapshots: jsc-safe-url@0.2.4: {} + jsdom@29.1.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + optional: true + jsdom@29.1.1(@noble/hashes@2.0.1): dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -28554,6 +28608,36 @@ snapshots: tsx: 4.20.6 yaml: 2.9.0 + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.18.12)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 22.18.12 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) + jsdom: 29.1.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - msw + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.18.12)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 @@ -28786,6 +28870,15 @@ snapshots: punycode: 2.3.1 webidl-conversions: 5.0.0 + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + optional: true + whatwg-url@16.0.1(@noble/hashes@2.0.1): dependencies: '@exodus/bytes': 1.15.1(@noble/hashes@2.0.1) diff --git a/protocol/package.json b/protocol/package.json index ad9c1147..029190c9 100644 --- a/protocol/package.json +++ b/protocol/package.json @@ -17,6 +17,9 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage" }, + "dependencies": { + "@noble/hashes": "^1.8.0" + }, "devDependencies": { "@eslint/js": "^9.36.0", "@types/node": "^22.18.6", diff --git a/protocol/src/encoding/bytes.ts b/protocol/src/encoding/bytes.ts new file mode 100644 index 00000000..a323e00e --- /dev/null +++ b/protocol/src/encoding/bytes.ts @@ -0,0 +1,116 @@ +/** + * Byte, hex, and unsigned-integer conversions used by the canonical encoder. + * + * Every function here is strict on purpose. A protocol hash that silently + * accepts a malformed input produces a digest nobody can reproduce, which is + * worse than a thrown error: the error stops a battle, the bad digest ships a + * receipt no verifier can check. + */ + +/** 0x-prefixed hex string. Canonically lowercase whenever this package emits one. */ +export type Hex = `0x${string}`; + +const HEX_PATTERN = /^0x[0-9a-fA-F]*$/; +const EVM_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/; + +/** Lowercase 0x-hex for `data`. */ +export function bytesToHex(data: Uint8Array): Hex { + let out = '0x'; + for (const byte of data) { + out += byte.toString(16).padStart(2, '0'); + } + return out as Hex; +} + +/** Bytes for a 0x-hex string. Rejects a missing prefix, an odd length, or non-hex digits. */ +export function hexToBytes(value: string): Uint8Array { + if (!HEX_PATTERN.test(value)) { + throw new Error(`not a 0x-prefixed hex string: ${JSON.stringify(value)}`); + } + const digits = value.slice(2); + if (digits.length % 2 !== 0) { + throw new Error(`hex string has an odd number of digits: ${value}`); + } + const out = new Uint8Array(digits.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = Number.parseInt(digits.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +/** Accepts either representation of a byte string and returns bytes. */ +export function toBytes(value: Uint8Array | string): Uint8Array { + return typeof value === 'string' ? hexToBytes(value) : value; +} + +/** UTF-8 bytes for `value`. */ +export function utf8ToBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +/** + * Big-endian fixed-width encoding of an unsigned integer. + * + * Fixed width is what makes these fields self-delimiting, so unlike byte + * strings they need no length prefix. Out-of-range values throw rather than + * wrap: a silently truncated pet id or timestamp is a wrong hash. + */ +export function uintToBytes(value: bigint | number, byteLength: number): Uint8Array { + const big = typeof value === 'number' ? numberToBigint(value) : value; + if (big < 0n) { + throw new Error(`unsigned field cannot be negative: ${big}`); + } + const limit = 1n << BigInt(byteLength * 8); + if (big >= limit) { + throw new Error(`value ${big} does not fit in ${byteLength} bytes`); + } + const out = new Uint8Array(byteLength); + let rest = big; + for (let i = byteLength - 1; i >= 0; i--) { + out[i] = Number(rest & 0xffn); + rest >>= 8n; + } + return out; +} + +function numberToBigint(value: number): bigint { + if (!Number.isInteger(value)) { + throw new Error(`expected an integer, got ${value}`); + } + if (!Number.isSafeInteger(value)) { + throw new Error(`${value} exceeds Number.MAX_SAFE_INTEGER; pass a bigint`); + } + return BigInt(value); +} + +/** + * Normalizes a wallet address or pubkey to the form the protocol hashes. + * + * EVM addresses are case-insensitive, so they lowercase: a checksummed and a + * lowercase spelling of one address must not produce two different intent + * hashes. Solana base58 pubkeys are case-*sensitive* and pass through + * untouched. This is the same normalization the backend already applies to the + * JWT `storageKey` and the `users.address` primary key, so an account hashes + * identically to how it is stored. + */ +export function normalizeAccount(value: string): string { + if (value.length === 0) { + throw new Error('account is empty'); + } + return EVM_ADDRESS_PATTERN.test(value) ? value.toLowerCase() : value; +} + +/** Concatenates byte chunks. */ +export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array { + let total = 0; + for (const chunk of chunks) { + total += chunk.length; + } + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} diff --git a/protocol/src/encoding/domain.ts b/protocol/src/encoding/domain.ts new file mode 100644 index 00000000..93be256f --- /dev/null +++ b/protocol/src/encoding/domain.ts @@ -0,0 +1,47 @@ +/** + * Domain tags. Every canonical encoding in this protocol starts with exactly one + * of these, so a digest can only ever be read as the kind of object it was + * written as. + * + * Without a tag, two different objects that happen to encode to the same bytes + * share a hash, and a signature over one becomes a signature over the other. A + * receipt whose fields line up with a commitment's is the cheap version of that + * attack; the tag makes it structurally impossible rather than unlikely. + * + * Rules: + * + * - Tags are frozen. Changing a tag's text changes every digest under it, which + * invalidates historical signatures. Add a new `_V2` tag instead. + * - Tags are unique across the protocol. `tests/encoding/domain.test.ts` + * enforces this, because a duplicate silently reintroduces the confusion the + * tags exist to prevent. + * - A tag is claimed by the object that uses it. Entries for objects that do not + * exist yet are declared here anyway, so the uniqueness check covers the whole + * protocol rather than only the parts already built. + */ +export const DOMAIN_TAGS = { + /** Battle seed derivation (architecture §E). Fixed by that specification. */ + SEED: 'CRYPTOPETS_BATTLE_V1', + /** Wallet-signed battle intent (§D). */ + INTENT: 'CRYPTOPETS_INTENT_V1', + /** Standing defence authorization (§D). */ + DEFENSE_AUTHORIZATION: 'CRYPTOPETS_DEFENSE_AUTH_V1', + /** Frozen pet snapshot pair (§C, §F). */ + SNAPSHOT: 'CRYPTOPETS_SNAPSHOT_V1', + /** Ruleset identity: combat rules plus balance configuration (§F). */ + RULESET: 'CRYPTOPETS_RULESET_V1', + /** Pre-reveal randomness commitment (§E). */ + COMMITMENT: 'CRYPTOPETS_COMMITMENT_V1', + /** Signed battle receipt (§G). */ + RECEIPT: 'CRYPTOPETS_RECEIPT_V1', + /** Per-strike combat log (§G `combatLogHash`). */ + COMBAT_LOG: 'CRYPTOPETS_COMBAT_LOG_V1', + /** Merkle leaf over a receipt (§I). */ + MERKLE_LEAF: 'CRYPTOPETS_MERKLE_LEAF_V1', + /** Merkle internal node (§I). Distinct from the leaf tag, so a leaf digest can + * never be presented as an internal node in a proof. */ + MERKLE_NODE: 'CRYPTOPETS_MERKLE_NODE_V1', +} as const; + +/** One of the protocol's domain tags. */ +export type DomainTag = (typeof DOMAIN_TAGS)[keyof typeof DOMAIN_TAGS]; diff --git a/protocol/src/encoding/hash.ts b/protocol/src/encoding/hash.ts new file mode 100644 index 00000000..f2e397d7 --- /dev/null +++ b/protocol/src/encoding/hash.ts @@ -0,0 +1,24 @@ +import { keccak_256 } from '@noble/hashes/sha3'; + +import { bytesToHex, type Hex } from './bytes'; + +/** Digest length in bytes. Every hash in this protocol is 32 bytes. */ +export const HASH_LENGTH = 32; + +/** + * Legacy Keccak-256, **not** SHA3-256. + * + * This is the same function as Solidity's `keccak256`, so hashes computed here + * match `CombatSim.sol` and anything the contracts sign or verify. The two + * differ only in a padding byte, so a SHA3-256 substitution produces plausible + * looking digests that agree with nothing. `tests/encoding/hash.test.ts` pins + * known Keccak digests specifically to catch that swap. + */ +export function keccak256(data: Uint8Array): Uint8Array { + return keccak_256(data); +} + +/** Legacy Keccak-256 as a 0x-prefixed lowercase hex string. */ +export function keccak256Hex(data: Uint8Array): Hex { + return bytesToHex(keccak_256(data)); +} diff --git a/protocol/src/encoding/index.ts b/protocol/src/encoding/index.ts new file mode 100644 index 00000000..83424482 --- /dev/null +++ b/protocol/src/encoding/index.ts @@ -0,0 +1,13 @@ +export { + bytesToHex, + concatBytes, + type Hex, + hexToBytes, + normalizeAccount, + toBytes, + uintToBytes, + utf8ToBytes, +} from './bytes'; +export { DOMAIN_TAGS, type DomainTag } from './domain'; +export { HASH_LENGTH, keccak256, keccak256Hex } from './hash'; +export { CanonicalWriter } from './writer'; diff --git a/protocol/src/encoding/writer.ts b/protocol/src/encoding/writer.ts new file mode 100644 index 00000000..c1ccb950 --- /dev/null +++ b/protocol/src/encoding/writer.ts @@ -0,0 +1,159 @@ +import { + bytesToHex, + concatBytes, + type Hex, + normalizeAccount, + toBytes, + uintToBytes, + utf8ToBytes, +} from './bytes'; +import type { DomainTag } from './domain'; +import { keccak256 } from './hash'; + +/** + * The canonical encoder. Every protocol hash is Keccak-256 over bytes produced + * here, never over `JSON.stringify` output. + * + * Why not JSON: property order, number formatting, whitespace, and unicode + * escaping are all implementation choices, and two correct JSON serializers can + * disagree on all four. A receipt is meant to be recomputable by a stranger + * years later, so the byte layout has to be the specification rather than a side + * effect of whichever runtime produced it. + * + * Two properties matter: + * + * 1. **Every field is self-delimiting.** Fixed-width integers carry their width + * in the schema; variable-length values (byte strings, text, arrays) carry a + * 4-byte big-endian count. Without that, `("ab", "c")` and `("a", "bc")` + * concatenate to identical bytes, and an attacker gets to shift a boundary + * without changing a digest. + * 2. **A digest is bound to its object kind.** The domain tag is written first, + * so a receipt hash can never be reinterpreted as a commitment hash. + * + * Field order is part of the specification: it is the order the writer calls + * appear in, and it must match the field lists in the architecture document. + * There is deliberately no reader. Verifying means re-encoding a parsed object + * and comparing digests, so a decoder would be a second place for the layout to + * drift. + */ +export class CanonicalWriter { + private readonly parts: Uint8Array[] = []; + + private constructor(domain: DomainTag) { + this.text(domain); + } + + /** Starts an encoding for one kind of object. The tag is written immediately. */ + static withDomain(domain: DomainTag): CanonicalWriter { + return new CanonicalWriter(domain); + } + + /** Unsigned 8-bit field. */ + u8(value: number): this { + return this.push(uintToBytes(value, 1)); + } + + /** Unsigned 16-bit field. */ + u16(value: number): this { + return this.push(uintToBytes(value, 2)); + } + + /** Unsigned 32-bit field. */ + u32(value: number): this { + return this.push(uintToBytes(value, 4)); + } + + /** Unsigned 64-bit field. Timestamps, beacon rounds, and sequence numbers. */ + u64(value: bigint | number): this { + return this.push(uintToBytes(value, 8)); + } + + /** Unsigned 256-bit field. DNA, seeds, and token amounts. */ + u256(value: bigint | number): this { + return this.push(uintToBytes(value, 32)); + } + + /** Boolean as a single 0x00 / 0x01 byte. */ + bool(value: boolean): this { + return this.push(uintToBytes(value ? 1 : 0, 1)); + } + + /** A 32-byte digest, as bytes or 0x-hex. Fixed width, so no length prefix. */ + hash(value: Uint8Array | Hex): this { + const bytes = toBytes(value); + if (bytes.length !== 32) { + throw new Error(`expected a 32-byte hash, got ${bytes.length} bytes`); + } + return this.push(bytes); + } + + /** Variable-length byte string, length-prefixed. */ + bytes(value: Uint8Array | Hex): this { + const bytes = toBytes(value); + return this.push(uintToBytes(bytes.length, 4)).push(bytes); + } + + /** + * Variable-length text, length-prefixed with its UTF-8 **byte** count. + * + * Ids that exceed a JS number (pet ids, dna) travel as decimal strings in + * this codebase; prefer a numeric field for those where the schema allows it, + * so `"07"` and `"7"` cannot be two spellings of one value. + */ + text(value: string): this { + const bytes = utf8ToBytes(value); + return this.push(uintToBytes(bytes.length, 4)).push(bytes); + } + + /** Wallet address or pubkey, normalized first (see `normalizeAccount`). */ + account(value: string): this { + return this.text(normalizeAccount(value)); + } + + /** + * Count-prefixed sequence. Order is significant and never sorted implicitly: + * a caller that needs order-independence sorts before encoding, so the + * ordering rule stays visible at the call site. + */ + array(items: readonly T[], write: (writer: this, item: T) => void): this { + this.push(uintToBytes(items.length, 4)); + for (const item of items) { + write(this, item); + } + return this; + } + + /** + * Optional field: one presence byte, then the value if present. The presence + * byte is what keeps an absent field distinct from an empty one, so a missing + * `defenseAuthorizationHash` cannot collide with a zeroed one. + */ + optional(value: T | null | undefined, write: (writer: this, value: T) => void): this { + if (value === null || value === undefined) { + return this.push(uintToBytes(0, 1)); + } + this.push(uintToBytes(1, 1)); + write(this, value); + return this; + } + + /** The encoded bytes. */ + build(): Uint8Array { + return concatBytes(this.parts); + } + + /** Keccak-256 of the encoded bytes. */ + digest(): Uint8Array { + return keccak256(this.build()); + } + + /** Keccak-256 of the encoded bytes, as 0x-hex. */ + digestHex(): Hex { + return bytesToHex(this.digest()); + } + + private push(bytes: Uint8Array): this { + this.parts.push(bytes); + return this; + } +} diff --git a/protocol/src/index.ts b/protocol/src/index.ts index 84e3ac6e..fc7f8e08 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -12,3 +12,4 @@ export const PROTOCOL_PACKAGE = '@cryptopets/protocol'; export * from './combat'; +export * from './encoding'; diff --git a/protocol/tests/encoding/bytes.test.ts b/protocol/tests/encoding/bytes.test.ts new file mode 100644 index 00000000..45811954 --- /dev/null +++ b/protocol/tests/encoding/bytes.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import { + bytesToHex, + concatBytes, + hexToBytes, + normalizeAccount, + toBytes, + uintToBytes, + utf8ToBytes, +} from '../../src/encoding/bytes'; + +describe('hex conversion', () => { + it('round-trips bytes through lowercase hex', () => { + const bytes = new Uint8Array([0x00, 0x0f, 0xa0, 0xff]); + expect(bytesToHex(bytes)).toBe('0x000fa0ff'); + expect(hexToBytes('0x000fa0ff')).toEqual(bytes); + }); + + it('accepts uppercase hex input and normalizes on the way out', () => { + expect(bytesToHex(hexToBytes('0xDEADBEEF'))).toBe('0xdeadbeef'); + }); + + it('encodes the empty byte string as bare 0x', () => { + expect(bytesToHex(new Uint8Array(0))).toBe('0x'); + expect(hexToBytes('0x')).toEqual(new Uint8Array(0)); + }); + + it.each([ + ['deadbeef', 'missing 0x prefix'], + ['0xabc', 'odd digit count'], + ['0xzz', 'non-hex digits'], + ['', 'empty string'], + ])('rejects %s (%s)', (value) => { + expect(() => hexToBytes(value)).toThrow(); + }); + + it('passes bytes through toBytes untouched', () => { + const bytes = new Uint8Array([1, 2, 3]); + expect(toBytes(bytes)).toBe(bytes); + expect(toBytes('0x010203')).toEqual(bytes); + }); +}); + +describe('uintToBytes', () => { + it('encodes big-endian at the requested width', () => { + expect(bytesToHex(uintToBytes(1, 4))).toBe('0x00000001'); + expect(bytesToHex(uintToBytes(0x0102n, 4))).toBe('0x00000102'); + expect(bytesToHex(uintToBytes(255, 1))).toBe('0xff'); + }); + + it('encodes the maximum value at each width used by the protocol', () => { + expect(bytesToHex(uintToBytes(0xffn, 1))).toBe('0xff'); + expect(bytesToHex(uintToBytes((1n << 64n) - 1n, 8))).toBe('0xffffffffffffffff'); + expect(bytesToHex(uintToBytes((1n << 256n) - 1n, 32))).toBe(`0x${'ff'.repeat(32)}`); + }); + + it('throws rather than truncating an out-of-range value', () => { + expect(() => uintToBytes(256, 1)).toThrow(/does not fit/); + expect(() => uintToBytes(1n << 64n, 8)).toThrow(/does not fit/); + expect(() => uintToBytes(1n << 256n, 32)).toThrow(/does not fit/); + }); + + it('rejects negative values', () => { + expect(() => uintToBytes(-1, 4)).toThrow(/negative/); + expect(() => uintToBytes(-1n, 32)).toThrow(/negative/); + }); + + it('rejects non-integers and unsafe numbers', () => { + expect(() => uintToBytes(1.5, 4)).toThrow(/integer/); + expect(() => uintToBytes(Number.MAX_SAFE_INTEGER + 2, 8)).toThrow(/MAX_SAFE_INTEGER/); + }); +}); + +describe('normalizeAccount', () => { + it('lowercases EVM addresses so a checksummed spelling hashes identically', () => { + expect(normalizeAccount('0xAbC0000000000000000000000000000000000123')).toBe( + '0xabc0000000000000000000000000000000000123', + ); + }); + + it('leaves Solana base58 pubkeys alone, since base58 is case-sensitive', () => { + const pubkey = 'DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL'; + expect(normalizeAccount(pubkey)).toBe(pubkey); + }); + + it('does not touch strings that merely look hex-ish but are the wrong length', () => { + expect(normalizeAccount('0xABCD')).toBe('0xABCD'); + }); + + it('rejects an empty account', () => { + expect(() => normalizeAccount('')).toThrow(/empty/); + }); +}); + +describe('utf8ToBytes', () => { + it('counts bytes, not code points', () => { + expect(utf8ToBytes('abc')).toHaveLength(3); + // Four bytes for one emoji: the length prefix in the writer must be a byte + // count, or a multi-byte name shifts every following field. + expect(utf8ToBytes('🐉')).toHaveLength(4); + }); +}); + +describe('concatBytes', () => { + it('joins chunks in order', () => { + expect(bytesToHex(concatBytes([new Uint8Array([1]), new Uint8Array([2, 3])]))).toBe('0x010203'); + }); + + it('returns an empty array for no chunks', () => { + expect(concatBytes([])).toEqual(new Uint8Array(0)); + }); +}); diff --git a/protocol/tests/encoding/domain.test.ts b/protocol/tests/encoding/domain.test.ts new file mode 100644 index 00000000..be9217c8 --- /dev/null +++ b/protocol/tests/encoding/domain.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { DOMAIN_TAGS } from '../../src/encoding/domain'; + +describe('domain tags', () => { + it('are unique', () => { + // A duplicate tag reintroduces exactly the cross-object confusion the tags + // exist to prevent, and it would do so silently. + const values = Object.values(DOMAIN_TAGS); + expect(new Set(values).size).toBe(values.length); + }); + + it('pin the seed tag the architecture document fixed', () => { + // §E specifies this string. Changing it changes every battle seed. + expect(DOMAIN_TAGS.SEED).toBe('CRYPTOPETS_BATTLE_V1'); + }); + + it('are versioned ASCII identifiers', () => { + for (const tag of Object.values(DOMAIN_TAGS)) { + expect(tag).toMatch(/^CRYPTOPETS_[A-Z0-9_]+_V\d+$/); + } + }); +}); diff --git a/protocol/tests/encoding/hash.test.ts b/protocol/tests/encoding/hash.test.ts new file mode 100644 index 00000000..2bb5bfcc --- /dev/null +++ b/protocol/tests/encoding/hash.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { bytesToHex, utf8ToBytes } from '../../src/encoding/bytes'; +import { HASH_LENGTH, keccak256, keccak256Hex } from '../../src/encoding/hash'; + +/** + * These vectors exist for one reason: to fail loudly if legacy Keccak-256 is ever + * swapped for SHA3-256. The two differ by a padding byte, so a swap keeps + * producing 32-byte digests that look fine and agree with nothing, including + * Solidity's `keccak256` and every signature the contracts have ever produced. + * + * Expected values are the published Keccak-256 digests. The SHA3-256 digest of + * the empty string is a6...0a (different in the first byte), so the empty-input + * case alone catches the substitution. + */ +describe('keccak256', () => { + it('matches the known digest of the empty input', () => { + expect(keccak256Hex(new Uint8Array(0))).toBe( + '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470', + ); + }); + + it('matches the known digest of "abc"', () => { + expect(keccak256Hex(utf8ToBytes('abc'))).toBe( + '0x4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45', + ); + }); + + it('matches the known digest of "testing"', () => { + expect(keccak256Hex(utf8ToBytes('testing'))).toBe( + '0x5f16f4c7f149ac4f9510d9cf8cf384038ad348b3bcdc01915f95de12df9d1b02', + ); + }); + + it('returns 32 bytes', () => { + const digest = keccak256(utf8ToBytes('anything')); + expect(digest).toHaveLength(HASH_LENGTH); + expect(bytesToHex(digest)).toMatch(/^0x[0-9a-f]{64}$/); + }); +}); diff --git a/protocol/tests/encoding/writer.test.ts b/protocol/tests/encoding/writer.test.ts new file mode 100644 index 00000000..f9bc99df --- /dev/null +++ b/protocol/tests/encoding/writer.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; + +import { bytesToHex } from '../../src/encoding/bytes'; +import { DOMAIN_TAGS } from '../../src/encoding/domain'; +import { CanonicalWriter } from '../../src/encoding/writer'; + +const write = () => CanonicalWriter.withDomain(DOMAIN_TAGS.RECEIPT); +const HASH_A = `0x${'11'.repeat(32)}` as const; +const HASH_B = `0x${'22'.repeat(32)}` as const; + +describe('field framing', () => { + it('makes adjacent text fields unambiguous', () => { + // The whole point of length prefixes. Concatenated naively, both of these + // are "abc", and a boundary an attacker can move is a digest they can + // forge a second meaning for. + const first = write().text('ab').text('c').digestHex(); + const second = write().text('a').text('bc').digestHex(); + expect(first).not.toBe(second); + }); + + it('distinguishes an absent optional from an empty one', () => { + const absent = write().optional(null, (w, v: string) => w.text(v)).digestHex(); + const empty = write().optional('', (w, v: string) => w.text(v)).digestHex(); + expect(absent).not.toBe(empty); + }); + + it('distinguishes an empty array from an absent optional array', () => { + const emptyArray = write().array([] as string[], (w, v) => w.text(v)).digestHex(); + const absent = write().optional(null, (w, v: string[]) => w.array(v, (ww, x) => ww.text(x))).digestHex(); + expect(emptyArray).not.toBe(absent); + }); + + it('treats array order as significant', () => { + const ascending = write().array(['1', '2'], (w, v) => w.text(v)).digestHex(); + const descending = write().array(['2', '1'], (w, v) => w.text(v)).digestHex(); + expect(ascending).not.toBe(descending); + }); + + it('separates array elements from a single concatenated element', () => { + const two = write().array(['a', 'b'], (w, v) => w.text(v)).digestHex(); + const one = write().array(['ab'], (w, v) => w.text(v)).digestHex(); + expect(two).not.toBe(one); + }); + + it('does not conflate a numeric field with its decimal text', () => { + expect(write().u32(7).digestHex()).not.toBe(write().text('7').digestHex()); + }); + + it('encodes prefixes as a 4-byte big-endian byte count', () => { + expect(bytesToHex(CanonicalWriter.withDomain(DOMAIN_TAGS.SEED).build())).toBe( + `0x00000014${Buffer.from(DOMAIN_TAGS.SEED, 'utf8').toString('hex')}`, + ); + // Byte count, not character count: one emoji is four bytes. + const emoji = write().text('🐉').build(); + expect(bytesToHex(emoji.slice(-8))).toBe('0x00000004f09f9089'); + }); +}); + +describe('domain separation', () => { + it('gives identical field sequences different digests under different tags', () => { + const asReceipt = CanonicalWriter.withDomain(DOMAIN_TAGS.RECEIPT).u64(1).digestHex(); + const asCommitment = CanonicalWriter.withDomain(DOMAIN_TAGS.COMMITMENT).u64(1).digestHex(); + expect(asReceipt).not.toBe(asCommitment); + }); + + it('writes the tag before any field', () => { + const tagOnly = CanonicalWriter.withDomain(DOMAIN_TAGS.INTENT).build(); + const withField = CanonicalWriter.withDomain(DOMAIN_TAGS.INTENT).u8(9).build(); + expect(bytesToHex(withField).startsWith(bytesToHex(tagOnly))).toBe(true); + }); +}); + +describe('determinism', () => { + it('produces the same digest for the same field sequence', () => { + const build = () => + write() + .u8(1) + .u16(2) + .u32(3) + .u64(4n) + .u256(5n) + .bool(true) + .hash(HASH_A) + .bytes('0xdeadbeef') + .text('pet') + .account('0xABC0000000000000000000000000000000000123') + .array([1, 2], (w, v) => w.u32(v)) + .optional(HASH_B, (w, v) => w.hash(v)) + .digestHex(); + expect(build()).toBe(build()); + }); + + it('is insensitive to EVM address casing but not to pubkey casing', () => { + const lower = write().account('0xabc0000000000000000000000000000000000123').digestHex(); + const checksummed = write().account('0xAbC0000000000000000000000000000000000123').digestHex(); + expect(lower).toBe(checksummed); + + const pubkey = write().account('DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL').digestHex(); + const mangled = write().account('drip2pn2k6fumlkqmt5rzwyhiuz6ak3tzhbd8zuqztql').digestHex(); + expect(pubkey).not.toBe(mangled); + }); + + it('accepts a hash as bytes or hex interchangeably', () => { + const asHex = write().hash(HASH_A).digestHex(); + const asBytes = write().hash(new Uint8Array(32).fill(0x11)).digestHex(); + expect(asHex).toBe(asBytes); + }); +}); + +describe('validation', () => { + it('rejects a hash that is not 32 bytes', () => { + expect(() => write().hash('0x1234')).toThrow(/32-byte/); + expect(() => write().hash(new Uint8Array(31))).toThrow(/32-byte/); + }); + + it('rejects out-of-range integers at each width', () => { + expect(() => write().u8(256)).toThrow(/does not fit/); + expect(() => write().u16(65536)).toThrow(/does not fit/); + expect(() => write().u32(2 ** 32)).toThrow(/does not fit/); + expect(() => write().u64(1n << 64n)).toThrow(/does not fit/); + expect(() => write().u256(1n << 256n)).toThrow(/does not fit/); + }); + + it('rejects malformed hex in a byte field', () => { + expect(() => write().bytes('0xnothex' as `0x${string}`)).toThrow(); + }); +}); + +describe('digest', () => { + it('agrees with the hex form', () => { + const writer = write().u32(42); + expect(bytesToHex(writer.digest())).toBe(writer.digestHex()); + }); +}); From 4fff0f5e8fc197ccd7227e1a362c02db2783a9fe Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 06:58:36 -0400 Subject: [PATCH 07/76] feat(protocol): bind signed objects to chainId and deploymentId --- protocol/src/domain/chainId.ts | 60 +++++++++++ protocol/src/domain/deployment.ts | 79 ++++++++++++++ protocol/src/domain/index.ts | 21 ++++ protocol/src/domain/schemaVersions.ts | 67 ++++++++++++ protocol/src/index.ts | 1 + protocol/tests/domain/chainId.test.ts | 54 ++++++++++ protocol/tests/domain/deployment.test.ts | 102 +++++++++++++++++++ protocol/tests/domain/schemaVersions.test.ts | 56 ++++++++++ 8 files changed, 440 insertions(+) create mode 100644 protocol/src/domain/chainId.ts create mode 100644 protocol/src/domain/deployment.ts create mode 100644 protocol/src/domain/index.ts create mode 100644 protocol/src/domain/schemaVersions.ts create mode 100644 protocol/tests/domain/chainId.test.ts create mode 100644 protocol/tests/domain/deployment.test.ts create mode 100644 protocol/tests/domain/schemaVersions.test.ts diff --git a/protocol/src/domain/chainId.ts b/protocol/src/domain/chainId.ts new file mode 100644 index 00000000..84405485 --- /dev/null +++ b/protocol/src/domain/chainId.ts @@ -0,0 +1,60 @@ +/** + * Chain identity for signed protocol objects. + * + * One string covers both chains, in CAIP-2 shape: `:`. + * A single field means an object can never carry a coherent-looking but + * meaningless pairing such as "Solana, chain id 84532", which a separate + * `chain` plus numeric `chainId` allows. + * + * EVM uses the standard `eip155:` form. Solana uses a cluster name + * rather than CAIP-2's genesis-hash reference, because clusters are what this + * repo actually configures and a genesis-hash lookup would buy nothing here: + * environment separation is `deploymentId`'s job, not the chain id's. + */ + +/** `:`, e.g. `eip155:84532` or `solana:devnet`. */ +export type ChainId = `eip155:${number}` | `solana:${SolanaCluster}`; + +/** Solana clusters this protocol recognizes. */ +export type SolanaCluster = 'mainnet' | 'devnet' | 'testnet' | 'localnet'; + +const SOLANA_CLUSTERS: readonly SolanaCluster[] = ['mainnet', 'devnet', 'testnet', 'localnet']; +const EVM_PATTERN = /^eip155:([1-9][0-9]*)$/; +const SOLANA_PATTERN = /^solana:([a-z]+)$/; + +/** Chain id for an EVM network, e.g. 84532 (Base Sepolia) or 31337 (Hardhat). */ +export function evmChainId(chainId: number): ChainId { + if (!Number.isSafeInteger(chainId) || chainId <= 0) { + throw new Error(`not a valid EVM chain id: ${chainId}`); + } + return `eip155:${chainId}`; +} + +/** Chain id for a Solana cluster. */ +export function solanaChainId(cluster: SolanaCluster): ChainId { + if (!SOLANA_CLUSTERS.includes(cluster)) { + throw new Error(`not a known Solana cluster: ${cluster}`); + } + return `solana:${cluster}`; +} + +/** Narrows an untrusted string, throwing on anything this protocol does not define. */ +export function assertChainId(value: string): ChainId { + const evm = EVM_PATTERN.exec(value); + if (evm) { + return evmChainId(Number(evm[1])); + } + const solana = SOLANA_PATTERN.exec(value); + if (solana) { + return solanaChainId(solana[1] as SolanaCluster); + } + throw new Error(`not a valid chain id: ${JSON.stringify(value)} (expected eip155: or solana:)`); +} + +/** + * Which chain family a chain id belongs to, in this repo's own vocabulary + * (`pet_roster.chain`, the `ChainAdapter` split, the two settle keepers). + */ +export function chainFamily(chainId: ChainId): 'evm' | 'solana' { + return chainId.startsWith('eip155:') ? 'evm' : 'solana'; +} diff --git a/protocol/src/domain/deployment.ts b/protocol/src/domain/deployment.ts new file mode 100644 index 00000000..ec004205 --- /dev/null +++ b/protocol/src/domain/deployment.ts @@ -0,0 +1,79 @@ +import type { CanonicalWriter } from '../encoding/writer'; + +import { assertChainId, type ChainId } from './chainId'; +import { assertSupportedSchemaVersion, currentSchemaVersion, type SchemaKind } from './schemaVersions'; + +/** + * Which chain, and which deployment on it, an object belongs to. + * + * Both halves are needed. `chainId` alone would let a staging signature be + * replayed against production, since both sit on the same testnet: same chain, + * different contracts. `deploymentId` alone would let one environment's + * signature cross chains. Every signed object in this protocol carries the pair + * inside its hashed bytes, so a signature is only ever valid where it was meant. + */ +export interface ProtocolDomain { + chainId: ChainId; + /** + * Identifies one deployed contract set plus its environment, e.g. + * `base-sepolia-live` or `local-dev`. Opaque to the protocol: it only has to + * be stable for a deployment's life and never reused across environments. + */ + deploymentId: string; +} + +const DEPLOYMENT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; + +/** + * Validates an untrusted domain. + * + * `deploymentId` is charset-restricted so it cannot smuggle whitespace, case + * variants, or unicode look-alikes. Two spellings of one environment would + * produce two digests for the same battle, which is the confusion this field + * exists to prevent. + */ +export function assertProtocolDomain(domain: ProtocolDomain): ProtocolDomain { + const chainId = assertChainId(domain.chainId); + if (!DEPLOYMENT_ID_PATTERN.test(domain.deploymentId)) { + throw new Error( + `invalid deploymentId ${JSON.stringify(domain.deploymentId)}: expected 1-64 chars of [a-z0-9._-] starting alphanumeric`, + ); + } + return { chainId, deploymentId: domain.deploymentId }; +} + +/** True when two objects belong to the same chain and deployment. */ +export function sameDomain(a: ProtocolDomain, b: ProtocolDomain): boolean { + return a.chainId === b.chainId && a.deploymentId === b.deploymentId; +} + +/** + * Throws unless `actual` matches `expected`. The message names both sides, + * because the useful question during an incident is which environment a + * signature actually came from. + */ +export function assertSameDomain(expected: ProtocolDomain, actual: ProtocolDomain): void { + if (!sameDomain(expected, actual)) { + throw new Error( + `domain mismatch: expected ${expected.chainId}/${expected.deploymentId}, got ${actual.chainId}/${actual.deploymentId}`, + ); + } +} + +/** + * Writes the header every hashed object starts with: schema version, then chain + * id, then deployment id, immediately after the domain tag. + * + * One helper rather than each object encoder writing its own three fields. The + * header is the part that must be laid out identically everywhere, and a + * copy-pasted prefix is how one object ends up with the fields in a different + * order. Note this normalizes the field order in the architecture document, + * where the receipt lists `battleId` ahead of `chainId`: header first, body + * after, for every object. + */ +export function writeHeader(writer: CanonicalWriter, kind: SchemaKind, domain: ProtocolDomain): void { + const version = currentSchemaVersion(kind); + assertSupportedSchemaVersion(kind, version); + const checked = assertProtocolDomain(domain); + writer.u16(version).text(checked.chainId).text(checked.deploymentId); +} diff --git a/protocol/src/domain/index.ts b/protocol/src/domain/index.ts new file mode 100644 index 00000000..337e2fd8 --- /dev/null +++ b/protocol/src/domain/index.ts @@ -0,0 +1,21 @@ +export { + assertChainId, + chainFamily, + type ChainId, + evmChainId, + solanaChainId, + type SolanaCluster, +} from './chainId'; +export { + assertProtocolDomain, + assertSameDomain, + type ProtocolDomain, + sameDomain, + writeHeader, +} from './deployment'; +export { + assertSupportedSchemaVersion, + currentSchemaVersion, + SCHEMA_VERSIONS, + type SchemaKind, +} from './schemaVersions'; diff --git a/protocol/src/domain/schemaVersions.ts b/protocol/src/domain/schemaVersions.ts new file mode 100644 index 00000000..a1e76607 --- /dev/null +++ b/protocol/src/domain/schemaVersions.ts @@ -0,0 +1,67 @@ +/** + * Schema versions for every signed or hashed protocol object. + * + * Each object carries its own version inside the bytes that get hashed, so a + * digest states which field list produced it. Two rules make that useful: + * + * - **Bump on any encoding change.** Adding, removing, reordering, or retyping a + * field changes what a digest means. Reusing the version would leave two + * incompatible layouts claiming to be the same thing, and no verifier could + * tell which one a historical receipt used. + * - **An unknown version is a hard error.** Never parse an object at a version + * this build does not implement, and never fall back to the nearest known + * layout. Refusing is recoverable (upgrade the verifier); a best-effort read + * produces a confident wrong answer. + * + * Old versions stay listed here as they accumulate, because historical receipts + * must keep verifying. + */ +export const SCHEMA_VERSIONS = { + intent: 1, + defenseAuthorization: 1, + snapshot: 1, + ruleset: 1, + commitment: 1, + receipt: 1, + combatLog: 1, + merkleLeaf: 1, +} as const; + +/** Kinds of object this protocol versions. */ +export type SchemaKind = keyof typeof SCHEMA_VERSIONS; + +/** Versions this build can produce and verify, per kind. */ +const SUPPORTED_VERSIONS: Record = { + intent: [1], + defenseAuthorization: [1], + snapshot: [1], + ruleset: [1], + commitment: [1], + receipt: [1], + combatLog: [1], + merkleLeaf: [1], +}; + +/** The version this build writes for `kind`. */ +export function currentSchemaVersion(kind: SchemaKind): number { + return SCHEMA_VERSIONS[kind]; +} + +/** + * Throws unless this build implements `version` of `kind`. Call before reading an + * object that came from outside this process, including our own older receipts. + */ +export function assertSupportedSchemaVersion(kind: SchemaKind, version: number): void { + const supported = SUPPORTED_VERSIONS[kind]; + if (!supported) { + throw new Error(`unknown protocol object kind: ${kind}`); + } + if (!Number.isSafeInteger(version) || version < 1) { + throw new Error(`${kind} schema version must be a positive integer, got ${version}`); + } + if (!supported.includes(version)) { + throw new Error( + `unsupported ${kind} schema version ${version}; this build implements ${supported.join(', ')}`, + ); + } +} diff --git a/protocol/src/index.ts b/protocol/src/index.ts index fc7f8e08..e186d6c4 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -12,4 +12,5 @@ export const PROTOCOL_PACKAGE = '@cryptopets/protocol'; export * from './combat'; +export * from './domain'; export * from './encoding'; diff --git a/protocol/tests/domain/chainId.test.ts b/protocol/tests/domain/chainId.test.ts new file mode 100644 index 00000000..5d169f07 --- /dev/null +++ b/protocol/tests/domain/chainId.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { assertChainId, chainFamily, evmChainId, solanaChainId } from '../../src/domain/chainId'; + +describe('evmChainId', () => { + it('formats the networks this repo deploys to', () => { + expect(evmChainId(84532)).toBe('eip155:84532'); // Base Sepolia + expect(evmChainId(11155111)).toBe('eip155:11155111'); // Sepolia + expect(evmChainId(31337)).toBe('eip155:31337'); // Hardhat + }); + + it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 2])('rejects %s', (value) => { + expect(() => evmChainId(value)).toThrow(/not a valid EVM chain id/); + }); +}); + +describe('solanaChainId', () => { + it('formats known clusters', () => { + expect(solanaChainId('devnet')).toBe('solana:devnet'); + expect(solanaChainId('localnet')).toBe('solana:localnet'); + }); + + it('rejects an unknown cluster', () => { + expect(() => solanaChainId('staging' as never)).toThrow(/not a known Solana cluster/); + }); +}); + +describe('assertChainId', () => { + it('accepts both forms', () => { + expect(assertChainId('eip155:84532')).toBe('eip155:84532'); + expect(assertChainId('solana:mainnet')).toBe('solana:mainnet'); + }); + + it.each([ + '84532', // bare number, no namespace + 'eip155:', // missing reference + 'eip155:abc', // non-numeric reference + 'eip155:084532', // leading zero would give one chain two spellings + 'EIP155:84532', // namespace case matters + 'solana:Devnet', // cluster case matters + 'solana:genesis-hash', // not a cluster this protocol defines + 'bitcoin:mainnet', + '', + ])('rejects %s', (value) => { + expect(() => assertChainId(value)).toThrow(/not a valid chain id|not a known Solana cluster/); + }); +}); + +describe('chainFamily', () => { + it('maps back to this repo vocabulary', () => { + expect(chainFamily('eip155:84532')).toBe('evm'); + expect(chainFamily('solana:devnet')).toBe('solana'); + }); +}); diff --git a/protocol/tests/domain/deployment.test.ts b/protocol/tests/domain/deployment.test.ts new file mode 100644 index 00000000..c4a7b64a --- /dev/null +++ b/protocol/tests/domain/deployment.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertProtocolDomain, + assertSameDomain, + type ProtocolDomain, + sameDomain, + writeHeader, +} from '../../src/domain/deployment'; +import { bytesToHex } from '../../src/encoding/bytes'; +import { DOMAIN_TAGS } from '../../src/encoding/domain'; +import { CanonicalWriter } from '../../src/encoding/writer'; + +const LIVE: ProtocolDomain = { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }; +const STAGING: ProtocolDomain = { chainId: 'eip155:84532', deploymentId: 'base-sepolia-staging' }; +const SOLANA: ProtocolDomain = { chainId: 'solana:devnet', deploymentId: 'base-sepolia-live' }; + +const digestFor = (domain: ProtocolDomain) => { + const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.INTENT); + writeHeader(writer, 'intent', domain); + return writer.u64(1n).digestHex(); +}; + +describe('replay separation', () => { + it('gives staging and production different digests on the same chain', () => { + // The §D requirement, stated as a test: same chain, same fields, different + // contracts. Without deploymentId these two would be one signature. + expect(digestFor(LIVE)).not.toBe(digestFor(STAGING)); + }); + + it('gives the same deployment label on different chains different digests', () => { + expect(digestFor(LIVE)).not.toBe(digestFor(SOLANA)); + }); + + it('is stable for one domain', () => { + expect(digestFor(LIVE)).toBe(digestFor({ ...LIVE })); + }); +}); + +describe('writeHeader', () => { + it('writes schema version, then chain id, then deployment id', () => { + const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.INTENT); + writeHeader(writer, 'intent', LIVE); + const hex = bytesToHex(writer.build()); + const tagPrefix = bytesToHex(CanonicalWriter.withDomain(DOMAIN_TAGS.INTENT).build()); + const body = hex.slice(tagPrefix.length); + const expected = [ + '0001', // u16 schema version 1 + '0000000c', // 12-byte chain id + Buffer.from('eip155:84532', 'utf8').toString('hex'), + '00000011', // 17-byte deployment id + Buffer.from('base-sepolia-live', 'utf8').toString('hex'), + ].join(''); + expect(body).toBe(expected); + }); + + it('rejects an invalid domain before anything is written', () => { + const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.INTENT); + expect(() => writeHeader(writer, 'intent', { ...LIVE, deploymentId: 'Base Sepolia' })).toThrow( + /invalid deploymentId/, + ); + }); +}); + +describe('assertProtocolDomain', () => { + it('accepts labels this repo would plausibly use', () => { + for (const deploymentId of ['local-dev', 'base-sepolia-live', 'sepolia.v2', 'devnet1']) { + expect(assertProtocolDomain({ ...LIVE, deploymentId }).deploymentId).toBe(deploymentId); + } + }); + + it.each([ + ['', 'empty'], + ['Base-Sepolia', 'uppercase would give one environment two spellings'], + ['base sepolia', 'whitespace'], + ['-leading-dash', 'must start alphanumeric'], + ['base‐sepolia', 'unicode look-alike hyphen'], + ['x'.repeat(65), 'too long'], + ])('rejects %s (%s)', (deploymentId) => { + expect(() => assertProtocolDomain({ ...LIVE, deploymentId })).toThrow(/invalid deploymentId/); + }); + + it('rejects an invalid chain id', () => { + expect(() => assertProtocolDomain({ chainId: 'eip155:0' as never, deploymentId: 'local-dev' })).toThrow( + /not a valid chain id/, + ); + }); +}); + +describe('sameDomain', () => { + it('compares both halves', () => { + expect(sameDomain(LIVE, { ...LIVE })).toBe(true); + expect(sameDomain(LIVE, STAGING)).toBe(false); + expect(sameDomain(LIVE, SOLANA)).toBe(false); + }); + + it('names both sides when it throws', () => { + expect(() => assertSameDomain(LIVE, STAGING)).toThrow( + 'domain mismatch: expected eip155:84532/base-sepolia-live, got eip155:84532/base-sepolia-staging', + ); + }); +}); diff --git a/protocol/tests/domain/schemaVersions.test.ts b/protocol/tests/domain/schemaVersions.test.ts new file mode 100644 index 00000000..efae7c59 --- /dev/null +++ b/protocol/tests/domain/schemaVersions.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertSupportedSchemaVersion, + currentSchemaVersion, + SCHEMA_VERSIONS, + type SchemaKind, +} from '../../src/domain/schemaVersions'; + +const KINDS = Object.keys(SCHEMA_VERSIONS) as SchemaKind[]; + +describe('schema versions', () => { + it('are positive integers', () => { + for (const kind of KINDS) { + expect(Number.isSafeInteger(SCHEMA_VERSIONS[kind])).toBe(true); + expect(SCHEMA_VERSIONS[kind]).toBeGreaterThan(0); + } + }); + + it('cover every object this protocol hashes', () => { + // A kind missing from the registry cannot be version-checked at all, and a + // hashed object with no version is one that can never be migrated. + expect(KINDS).toEqual([ + 'intent', + 'defenseAuthorization', + 'snapshot', + 'ruleset', + 'commitment', + 'receipt', + 'combatLog', + 'merkleLeaf', + ]); + }); + + it('report the version this build writes', () => { + for (const kind of KINDS) { + assertSupportedSchemaVersion(kind, currentSchemaVersion(kind)); + } + }); +}); + +describe('assertSupportedSchemaVersion', () => { + it('rejects a future version rather than guessing at its layout', () => { + expect(() => assertSupportedSchemaVersion('receipt', 2)).toThrow(/unsupported receipt schema version 2/); + }); + + it.each([0, -1, 1.5])('rejects %s as a version', (version) => { + expect(() => assertSupportedSchemaVersion('receipt', version)).toThrow(/must be a positive integer/); + }); + + it('rejects an unknown kind', () => { + expect(() => assertSupportedSchemaVersion('battleRoom' as SchemaKind, 1)).toThrow( + /unknown protocol object kind/, + ); + }); +}); From 679f78867c58deb2e93c0ba13d2569f1119bc520 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 07:10:05 -0400 Subject: [PATCH 08/76] feat(protocol): add wallet-signed battle intent schema and hashing --- contracts/test-vectors/protocol-intent.json | 149 +++++++++++++++++++ pnpm-lock.yaml | 3 + protocol/package.json | 2 + protocol/scripts/gen-vectors.ts | 154 ++++++++++++++++++++ protocol/src/domain/chainId.ts | 12 ++ protocol/src/domain/deployment.ts | 8 +- protocol/src/index.ts | 1 + protocol/src/intent/hash.ts | 44 ++++++ protocol/src/intent/index.ts | 12 ++ protocol/src/intent/signing.ts | 148 +++++++++++++++++++ protocol/src/intent/types.ts | 111 ++++++++++++++ protocol/tests/intent/signing.test.ts | 123 ++++++++++++++++ protocol/tests/intent/types.test.ts | 97 ++++++++++++ protocol/tests/intent/vectors.test.ts | 110 ++++++++++++++ protocol/tsconfig.json | 2 +- 15 files changed, 973 insertions(+), 3 deletions(-) create mode 100644 contracts/test-vectors/protocol-intent.json create mode 100644 protocol/scripts/gen-vectors.ts create mode 100644 protocol/src/intent/hash.ts create mode 100644 protocol/src/intent/index.ts create mode 100644 protocol/src/intent/signing.ts create mode 100644 protocol/src/intent/types.ts create mode 100644 protocol/tests/intent/signing.test.ts create mode 100644 protocol/tests/intent/types.test.ts create mode 100644 protocol/tests/intent/vectors.test.ts diff --git a/contracts/test-vectors/protocol-intent.json b/contracts/test-vectors/protocol-intent.json new file mode 100644 index 00000000..f6103d6b --- /dev/null +++ b/contracts/test-vectors/protocol-intent.json @@ -0,0 +1,149 @@ +{ + "description": "BattleIntent canonical-hash and Solana sign-message vectors (docs/plan-backend-battle-architecture.md §D). Generated by protocol/scripts/gen-vectors.ts from protocol/src/intent. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.", + "cases": [ + { + "name": "evm-direct-challenge", + "note": "Baseline. No matchmaking challenge, so the optional field is absent.", + "intent": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attackerOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "attackerPetId": "1", + "defenderOwner": "0x2222222222222222222222222222222222222222", + "defenderPetId": "2", + "challengeId": null, + "clientNonce": "01hq8z0000000000000000", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "expiresAt": 1893456000 + }, + "expectedIntentHash": "0x928d4a9598e8c0bedd8d40e1598f81eddc36bccc0f3295b9af0fc880496e7c1b", + "expectedSolanaMessage": null + }, + { + "name": "evm-matchmade", + "note": "Baseline plus a challenge id. Must differ from evm-direct-challenge: an absent optional and a present one are distinct.", + "intent": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attackerOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "attackerPetId": "1", + "defenderOwner": "0x2222222222222222222222222222222222222222", + "defenderPetId": "2", + "challengeId": "cm4x9k2p0000abcdefghij", + "clientNonce": "01hq8z0000000000000000", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "expiresAt": 1893456000 + }, + "expectedIntentHash": "0x5a28904df7131b68d2d04b6134dbb9bf76eece7b2e901c918b84d1d99d295e93", + "expectedSolanaMessage": null + }, + { + "name": "evm-staging-deployment", + "note": "Baseline on the same chain with a different deploymentId. Must differ: this is the cross-deployment replay guard (§D).", + "intent": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-staging", + "attackerOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "attackerPetId": "1", + "defenderOwner": "0x2222222222222222222222222222222222222222", + "defenderPetId": "2", + "challengeId": null, + "clientNonce": "01hq8z0000000000000000", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "expiresAt": 1893456000 + }, + "expectedIntentHash": "0x0cfdf23a0b406b56dc2f4b3f8cb0bf4bf9c7f0ae3f0f3edfb1836261518aca22", + "expectedSolanaMessage": null + }, + { + "name": "evm-checksummed-owner", + "note": "Baseline with the attacker address in EIP-55 checksummed spelling. Must hash IDENTICALLY to evm-direct-challenge: EVM addresses are case-insensitive, so two spellings must not be two intents.", + "intent": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attackerOwner": "0xABcDEF0123456789abcDef0123456789aBCDeF01", + "attackerPetId": "1", + "defenderOwner": "0x2222222222222222222222222222222222222222", + "defenderPetId": "2", + "challengeId": null, + "clientNonce": "01hq8z0000000000000000", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "expiresAt": 1893456000 + }, + "expectedIntentHash": "0x928d4a9598e8c0bedd8d40e1598f81eddc36bccc0f3295b9af0fc880496e7c1b", + "expectedSolanaMessage": null + }, + { + "name": "evm-other-nonce", + "note": "Baseline with a different clientNonce. Must differ: the nonce is what makes two otherwise identical battles distinct.", + "intent": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attackerOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "attackerPetId": "1", + "defenderOwner": "0x2222222222222222222222222222222222222222", + "defenderPetId": "2", + "challengeId": null, + "clientNonce": "01hq8z0000000000000001", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "expiresAt": 1893456000 + }, + "expectedIntentHash": "0x0eeb8d4ebcc0f1d7f920894a87d4a0c46598af77faf017d57c8af16a3d544587", + "expectedSolanaMessage": null + }, + { + "name": "evm-field-widths", + "note": "Pet ids at the 256-bit ceiling and just above 2^128, expiry at 2^32-1. Exercises the u256 and u64 widths.", + "intent": { + "chainId": "eip155:11155111", + "deploymentId": "sepolia-live", + "attackerOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "attackerPetId": "115792089237316195423570985008687907853269984665640564039457584007913129639935", + "defenderOwner": "0x2222222222222222222222222222222222222222", + "defenderPetId": "340282366920938463463374607431768211457", + "challengeId": null, + "clientNonce": "01hq8z0000000000000000", + "rulesetHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "expiresAt": 4294967295 + }, + "expectedIntentHash": "0x9e88de177e06fc5afa1f960ccd645c965afaf9f8c18cd69c53ee908ee2989900", + "expectedSolanaMessage": null + }, + { + "name": "solana-devnet", + "note": "Solana baseline. Fields otherwise matching an EVM case must not collide with it.", + "intent": { + "chainId": "solana:devnet", + "deploymentId": "base-sepolia-live", + "attackerOwner": "DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL", + "attackerPetId": "1", + "defenderOwner": "GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp", + "defenderPetId": "2", + "challengeId": null, + "clientNonce": "01hq8z0000000000000000", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "expiresAt": 1893456000 + }, + "expectedIntentHash": "0x099e916bdc1818e3d3709afc72f6c4a11d129db190f48a31b09099f0b7a5d32c", + "expectedSolanaMessage": "CryptoPets Battle Intent v1\nschema: 1\nchain: solana:devnet\ndeployment: base-sepolia-live\nattacker: DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL\nattackerPet: 1\ndefender: GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp\ndefenderPet: 2\nchallenge: (none)\nnonce: 01hq8z0000000000000000\nruleset: 0xabababababababababababababababababababababababababababababababab\nexpires: 1893456000" + }, + { + "name": "solana-matchmade", + "note": "Solana with a challenge present, so the signed text message carries a real challenge line instead of the (none) placeholder.", + "intent": { + "chainId": "solana:mainnet", + "deploymentId": "solana-live", + "attackerOwner": "DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL", + "attackerPetId": "42", + "defenderOwner": "GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp", + "defenderPetId": "99", + "challengeId": "cm4x9k2p0000abcdefghij", + "clientNonce": "01hq8z0000000000000000", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "expiresAt": 1893456000 + }, + "expectedIntentHash": "0xba4901b6e75c83c8124eb44a4ca3defee573ebe6054e9bf864ad5d1642c38e75", + "expectedSolanaMessage": "CryptoPets Battle Intent v1\nschema: 1\nchain: solana:mainnet\ndeployment: solana-live\nattacker: DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL\nattackerPet: 42\ndefender: GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp\ndefenderPet: 99\nchallenge: cm4x9k2p0000abcdefghij\nnonce: 01hq8z0000000000000000\nruleset: 0xabababababababababababababababababababababababababababababababab\nexpires: 1893456000" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aee5a57c..0bfa0753 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -509,6 +509,9 @@ importers: globals: specifier: ^16.4.0 version: 16.4.0 + tsx: + specifier: ^4.20.6 + version: 4.20.6 typescript: specifier: ~5.8.3 version: 5.8.3 diff --git a/protocol/package.json b/protocol/package.json index 029190c9..d761c312 100644 --- a/protocol/package.json +++ b/protocol/package.json @@ -13,6 +13,7 @@ "lint": "pnpm exec eslint .", "lint:fix": "pnpm exec eslint . --fix", "typecheck": "pnpm exec tsc --noEmit", + "vectors": "pnpm exec tsx scripts/gen-vectors.ts", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" @@ -27,6 +28,7 @@ "eslint": "^9.36.0", "eslint-plugin-import": "^2.31.0", "globals": "^16.4.0", + "tsx": "^4.20.6", "typescript": "~5.8.3", "typescript-eslint": "^8.44.0", "vitest": "^4.1.8" diff --git a/protocol/scripts/gen-vectors.ts b/protocol/scripts/gen-vectors.ts new file mode 100644 index 00000000..d33d3c4a --- /dev/null +++ b/protocol/scripts/gen-vectors.ts @@ -0,0 +1,154 @@ +/** + * Writes the protocol golden-vector files under contracts/test-vectors/. + * + * Run with `pnpm --filter @cryptopets/protocol vectors`. + * + * **Regenerating to make a failing test pass is forbidden** (`AGENTS.md`). These + * files exist to lock a byte layout that signatures and historical receipts + * depend on. A failure means the implementation drifted from the frozen layout, + * so fix the implementation. This script is for *adding* cases, and for the + * one-time generation when a new object type lands. + * + * Every case is chosen to pin a property, not to pad a count: cross-deployment + * separation, address-casing equivalence, optional-field presence, field widths. + */ +import { writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { ChainId } from '../src/domain/chainId'; +import type { Hex } from '../src/encoding/bytes'; +import { battleIntentSolanaMessage, type BattleIntent, hashBattleIntent } from '../src/intent'; + +const VECTORS_DIR = join(dirname(fileURLToPath(import.meta.url)), '../../contracts/test-vectors'); + +/** Serializable form of an intent, as it appears in the vector file. */ +interface IntentFixture { + chainId: string; + deploymentId: string; + attackerOwner: string; + attackerPetId: string; + defenderOwner: string; + defenderPetId: string; + challengeId: string | null; + clientNonce: string; + rulesetHash: string; + expiresAt: number; +} + +const RULESET_HASH = `0x${'ab'.repeat(32)}`; +const BASE: IntentFixture = { + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + attackerOwner: '0xabcdef0123456789abcdef0123456789abcdef01', + attackerPetId: '1', + defenderOwner: '0x2222222222222222222222222222222222222222', + defenderPetId: '2', + challengeId: null, + clientNonce: '01hq8z0000000000000000', + rulesetHash: RULESET_HASH, + expiresAt: 1893456000, +}; + +const intentCases: { name: string; note: string; intent: IntentFixture }[] = [ + { + name: 'evm-direct-challenge', + note: 'Baseline. No matchmaking challenge, so the optional field is absent.', + intent: BASE, + }, + { + name: 'evm-matchmade', + note: 'Baseline plus a challenge id. Must differ from evm-direct-challenge: an absent optional and a present one are distinct.', + intent: { ...BASE, challengeId: 'cm4x9k2p0000abcdefghij' }, + }, + { + name: 'evm-staging-deployment', + note: 'Baseline on the same chain with a different deploymentId. Must differ: this is the cross-deployment replay guard (§D).', + intent: { ...BASE, deploymentId: 'base-sepolia-staging' }, + }, + { + name: 'evm-checksummed-owner', + note: 'Baseline with the attacker address in EIP-55 checksummed spelling. Must hash IDENTICALLY to evm-direct-challenge: EVM addresses are case-insensitive, so two spellings must not be two intents.', + intent: { ...BASE, attackerOwner: '0xABcDEF0123456789abcDef0123456789aBCDeF01' }, + }, + { + name: 'evm-other-nonce', + note: 'Baseline with a different clientNonce. Must differ: the nonce is what makes two otherwise identical battles distinct.', + intent: { ...BASE, clientNonce: '01hq8z0000000000000001' }, + }, + { + name: 'evm-field-widths', + note: 'Pet ids at the 256-bit ceiling and just above 2^128, expiry at 2^32-1. Exercises the u256 and u64 widths.', + intent: { + ...BASE, + chainId: 'eip155:11155111', + deploymentId: 'sepolia-live', + attackerPetId: '115792089237316195423570985008687907853269984665640564039457584007913129639935', + defenderPetId: '340282366920938463463374607431768211457', + rulesetHash: `0x${'00'.repeat(31)}01`, + expiresAt: 4294967295, + }, + }, + { + name: 'solana-devnet', + note: 'Solana baseline. Fields otherwise matching an EVM case must not collide with it.', + intent: { + ...BASE, + chainId: 'solana:devnet', + attackerOwner: 'DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL', + defenderOwner: 'GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp', + }, + }, + { + name: 'solana-matchmade', + note: 'Solana with a challenge present, so the signed text message carries a real challenge line instead of the (none) placeholder.', + intent: { + ...BASE, + chainId: 'solana:mainnet', + deploymentId: 'solana-live', + attackerOwner: 'DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL', + attackerPetId: '42', + defenderOwner: 'GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp', + defenderPetId: '99', + challengeId: 'cm4x9k2p0000abcdefghij', + }, + }, +]; + +/** Rebuilds a runtime intent from its serializable fixture. */ +export function intentFromFixture(fixture: IntentFixture): BattleIntent { + return { + domain: { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }, + attackerOwner: fixture.attackerOwner, + attackerPetId: BigInt(fixture.attackerPetId), + defenderOwner: fixture.defenderOwner, + defenderPetId: BigInt(fixture.defenderPetId), + challengeId: fixture.challengeId, + clientNonce: fixture.clientNonce, + rulesetHash: fixture.rulesetHash as Hex, + expiresAt: fixture.expiresAt, + }; +} + +function writeIntentVectors(): void { + const out = { + description: + 'BattleIntent canonical-hash and Solana sign-message vectors (docs/plan-backend-battle-architecture.md §D). Generated by protocol/scripts/gen-vectors.ts from protocol/src/intent. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.', + cases: intentCases.map((c) => { + const intent = intentFromFixture(c.intent); + const solana = c.intent.chainId.startsWith('solana:'); + return { + name: c.name, + note: c.note, + intent: c.intent, + expectedIntentHash: hashBattleIntent(intent), + expectedSolanaMessage: solana ? battleIntentSolanaMessage(intent) : null, + }; + }), + }; + const path = join(VECTORS_DIR, 'protocol-intent.json'); + writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`); + process.stdout.write(`wrote ${out.cases.length} intent cases to ${path}\n`); +} + +writeIntentVectors(); diff --git a/protocol/src/domain/chainId.ts b/protocol/src/domain/chainId.ts index 84405485..9583cb61 100644 --- a/protocol/src/domain/chainId.ts +++ b/protocol/src/domain/chainId.ts @@ -58,3 +58,15 @@ export function assertChainId(value: string): ChainId { export function chainFamily(chainId: ChainId): 'evm' | 'solana' { return chainId.startsWith('eip155:') ? 'evm' : 'solana'; } + +/** + * The numeric chain id an EIP-712 domain needs. Throws for Solana, where there is + * no such number and a caller asking for one has taken a wrong turn. + */ +export function evmChainIdNumber(chainId: ChainId): number { + const match = EVM_PATTERN.exec(chainId); + if (!match) { + throw new Error(`${chainId} is not an EVM chain id, so it has no numeric chain id`); + } + return Number(match[1]); +} diff --git a/protocol/src/domain/deployment.ts b/protocol/src/domain/deployment.ts index ec004205..c2487f8e 100644 --- a/protocol/src/domain/deployment.ts +++ b/protocol/src/domain/deployment.ts @@ -71,9 +71,13 @@ export function assertSameDomain(expected: ProtocolDomain, actual: ProtocolDomai * where the receipt lists `battleId` ahead of `chainId`: header first, body * after, for every object. */ -export function writeHeader(writer: CanonicalWriter, kind: SchemaKind, domain: ProtocolDomain): void { +export function writeHeader( + writer: CanonicalWriter, + kind: SchemaKind, + domain: ProtocolDomain, +): CanonicalWriter { const version = currentSchemaVersion(kind); assertSupportedSchemaVersion(kind, version); const checked = assertProtocolDomain(domain); - writer.u16(version).text(checked.chainId).text(checked.deploymentId); + return writer.u16(version).text(checked.chainId).text(checked.deploymentId); } diff --git a/protocol/src/index.ts b/protocol/src/index.ts index e186d6c4..a0138afb 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -14,3 +14,4 @@ export const PROTOCOL_PACKAGE = '@cryptopets/protocol'; export * from './combat'; export * from './domain'; export * from './encoding'; +export * from './intent'; diff --git a/protocol/src/intent/hash.ts b/protocol/src/intent/hash.ts new file mode 100644 index 00000000..7109bf2f --- /dev/null +++ b/protocol/src/intent/hash.ts @@ -0,0 +1,44 @@ +import { writeHeader } from '../domain/deployment'; +import type { Hex } from '../encoding/bytes'; +import { DOMAIN_TAGS } from '../encoding/domain'; +import { keccak256Hex } from '../encoding/hash'; +import { CanonicalWriter } from '../encoding/writer'; + +import { assertBattleIntent, type BattleIntent } from './types'; + +/** + * Canonical encoding of an intent. Field order is the specification: header + * (schema version, chain id, deployment id) then the §D field list. + * + * Pet ids encode as u256 rather than text, which also canonicalizes them: `"7"` + * and `"07"` parse to one value instead of hashing differently. + * + * The version written is always the one this build produces. Intents expire in + * minutes, so there is no old-version intent to re-verify later; receipts are the + * long-lived object and will need a version parameter when they land. + */ +export function encodeBattleIntent(intent: BattleIntent): Uint8Array { + const checked = assertBattleIntent(intent); + const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.INTENT); + return writeHeader(writer, 'intent', checked.domain) + .account(checked.attackerOwner) + .u256(checked.attackerPetId) + .account(checked.defenderOwner) + .u256(checked.defenderPetId) + .optional(checked.challengeId, (w, v) => w.text(v)) + .text(checked.clientNonce) + .hash(checked.rulesetHash) + .u64(checked.expiresAt) + .build(); +} + +/** + * `intentHash`: the value the ledger stores and every receipt references (§G). + * + * This is not what the wallet signs. The wallet signs the chain-specific payload + * in `./signing`, which shows readable fields rather than an opaque digest. This + * hash is how the backend and any verifier refer to one intent afterwards. + */ +export function hashBattleIntent(intent: BattleIntent): Hex { + return keccak256Hex(encodeBattleIntent(intent)); +} diff --git a/protocol/src/intent/index.ts b/protocol/src/intent/index.ts new file mode 100644 index 00000000..0db444c8 --- /dev/null +++ b/protocol/src/intent/index.ts @@ -0,0 +1,12 @@ +export { encodeBattleIntent, hashBattleIntent } from './hash'; +export { + battleIntentSolanaMessage, + battleIntentSolanaMessageBytes, + type BattleIntentTypedData, + battleIntentTypedData, + EIP712_INTENT_DOMAIN_NAME, + EIP712_INTENT_DOMAIN_VERSION, + EIP712_INTENT_TYPES, + SOLANA_INTENT_MESSAGE_HEADER, +} from './signing'; +export { assertBattleIntent, type BattleIntent, isExpired } from './types'; diff --git a/protocol/src/intent/signing.ts b/protocol/src/intent/signing.ts new file mode 100644 index 00000000..f071725f --- /dev/null +++ b/protocol/src/intent/signing.ts @@ -0,0 +1,148 @@ +import { chainFamily, evmChainIdNumber } from '../domain/chainId'; +import { currentSchemaVersion } from '../domain/schemaVersions'; +import { utf8ToBytes } from '../encoding/bytes'; + +import { assertBattleIntent, type BattleIntent } from './types'; + +/** + * What the wallet actually signs. + * + * Deliberately not the canonical hash. A wallet prompt showing one opaque digest + * is blind signing: the owner cannot tell a battle intent from anything else we + * might ask them to stamp. Both payloads below name every field, so the prompt + * shows which pet is fighting whom, under which ruleset, until when. + * + * EVM gets EIP-712 typed data; Solana gets a labelled text message, which is what + * its wallets can display. Neither is verified here: verification needs + * chain-specific cryptography and belongs in the backend, which uses viem for + * secp256k1 and an ed25519 verifier for Solana. + */ + +/** EIP-712 domain for battle intents. Not tied to a contract: no contract verifies these. */ +export const EIP712_INTENT_DOMAIN_NAME = 'CryptoPets Battle'; +export const EIP712_INTENT_DOMAIN_VERSION = '1'; + +/** EIP-712 type definition, in the shape viem and ethers both accept. */ +export const EIP712_INTENT_TYPES = { + BattleIntent: [ + { name: 'schemaVersion', type: 'uint16' }, + { name: 'chainId', type: 'string' }, + { name: 'deploymentId', type: 'string' }, + { name: 'attackerOwner', type: 'address' }, + { name: 'attackerPetId', type: 'uint256' }, + { name: 'defenderOwner', type: 'address' }, + { name: 'defenderPetId', type: 'uint256' }, + { name: 'challengeId', type: 'string' }, + { name: 'clientNonce', type: 'string' }, + { name: 'rulesetHash', type: 'bytes32' }, + { name: 'expiresAt', type: 'uint64' }, + ], +} as const; + +/** Typed data ready to hand to `signTypedData`. */ +export interface BattleIntentTypedData { + domain: { + name: string; + version: string; + chainId: number; + }; + types: typeof EIP712_INTENT_TYPES; + primaryType: 'BattleIntent'; + message: { + schemaVersion: number; + chainId: string; + deploymentId: string; + attackerOwner: string; + attackerPetId: bigint; + defenderOwner: string; + defenderPetId: bigint; + challengeId: string; + clientNonce: string; + rulesetHash: string; + expiresAt: bigint; + }; +} + +/** + * EIP-712 typed data for an EVM intent. + * + * `chainId` and `deploymentId` appear twice on purpose: the EIP-712 domain carries + * the numeric chain id (so a wallet can warn about a network mismatch), and the + * message carries the protocol's own chain id plus deployment id (so the + * signature cannot be replayed against a different deployment on the same chain, + * which the EIP-712 domain alone would allow). + * + * `challengeId` becomes an empty string when absent, because EIP-712 has no + * optional fields. `assertBattleIntent` rejects an empty-string `challengeId`, so + * absent and present can never both encode to `""`. + */ +export function battleIntentTypedData(intent: BattleIntent): BattleIntentTypedData { + const checked = assertBattleIntent(intent); + if (chainFamily(checked.domain.chainId) !== 'evm') { + throw new Error(`EIP-712 typed data is for EVM intents; got ${checked.domain.chainId}`); + } + return { + domain: { + name: EIP712_INTENT_DOMAIN_NAME, + version: EIP712_INTENT_DOMAIN_VERSION, + chainId: evmChainIdNumber(checked.domain.chainId), + }, + types: EIP712_INTENT_TYPES, + primaryType: 'BattleIntent', + message: { + schemaVersion: currentSchemaVersion('intent'), + chainId: checked.domain.chainId, + deploymentId: checked.domain.deploymentId, + attackerOwner: checked.attackerOwner, + attackerPetId: checked.attackerPetId, + defenderOwner: checked.defenderOwner, + defenderPetId: checked.defenderPetId, + challengeId: checked.challengeId ?? '', + clientNonce: checked.clientNonce, + rulesetHash: checked.rulesetHash, + expiresAt: BigInt(checked.expiresAt), + }, + }; +} + +/** First line of the Solana message. Doubles as its domain separator. */ +export const SOLANA_INTENT_MESSAGE_HEADER = 'CryptoPets Battle Intent v1'; + +/** + * Labelled text message for a Solana intent. + * + * One field per line, every line labelled, header first. The header is the domain + * separator: a signature over this text cannot be replayed as a signature over + * some other CryptoPets message, because no other message starts with this line. + * + * Field values cannot contain newlines (`assertBattleIntent` enforces the + * charset), so no value can forge an extra line and change what the owner + * believes they approved. + */ +export function battleIntentSolanaMessage(intent: BattleIntent): string { + const checked = assertBattleIntent(intent); + if (chainFamily(checked.domain.chainId) !== 'solana') { + throw new Error(`Solana sign-message is for Solana intents; got ${checked.domain.chainId}`); + } + return [ + SOLANA_INTENT_MESSAGE_HEADER, + `schema: ${currentSchemaVersion('intent')}`, + `chain: ${checked.domain.chainId}`, + `deployment: ${checked.domain.deploymentId}`, + `attacker: ${checked.attackerOwner}`, + `attackerPet: ${checked.attackerPetId}`, + `defender: ${checked.defenderOwner}`, + `defenderPet: ${checked.defenderPetId}`, + // `(none)` cannot collide with a real id: parentheses are outside the + // allowed id charset, so absent and present stay distinguishable. + `challenge: ${checked.challengeId ?? '(none)'}`, + `nonce: ${checked.clientNonce}`, + `ruleset: ${checked.rulesetHash}`, + `expires: ${checked.expiresAt}`, + ].join('\n'); +} + +/** The Solana message as the bytes a wallet signs. */ +export function battleIntentSolanaMessageBytes(intent: BattleIntent): Uint8Array { + return utf8ToBytes(battleIntentSolanaMessage(intent)); +} diff --git a/protocol/src/intent/types.ts b/protocol/src/intent/types.ts new file mode 100644 index 00000000..9e71a8ba --- /dev/null +++ b/protocol/src/intent/types.ts @@ -0,0 +1,111 @@ +import { assertProtocolDomain, type ProtocolDomain } from '../domain/deployment'; +import { type Hex, hexToBytes, normalizeAccount } from '../encoding/bytes'; + +/** + * A wallet-signed, expiring request to fight. Permission, not a result. + * + * A JWT is fine for API access but is the wrong thing to authorize a battle: it + * is a bearer token we issued to ourselves, so a compromised API could mint one + * for any wallet. This object is signed by the attacker's own key, which means + * an operator cannot fabricate consent to spend someone else's pet's cooldown. + * + * See architecture §D. The signing payloads live in `./signing`; nothing here + * verifies a signature (that is the backend's job, with a chain-specific + * verifier). + */ +export interface BattleIntent { + /** Which chain and deployment this intent is valid on. */ + domain: ProtocolDomain; + /** Wallet that owns the attacking pet and signs this intent. */ + attackerOwner: string; + /** On-chain pet id. */ + attackerPetId: bigint; + /** Wallet that owns the defending pet. */ + defenderOwner: string; + defenderPetId: bigint; + /** Matchmaking challenge this answers, or null for a direct challenge. */ + challengeId: string | null; + /** Wallet-chosen idempotency nonce. Consumed once, then never again. */ + clientNonce: string; + /** Ruleset the signer is agreeing to fight under. */ + rulesetHash: Hex; + /** Unix seconds after which the intent is dead. */ + expiresAt: number; +} + +/** + * Opaque ids may only contain these characters. + * + * The restriction is not cosmetic. The Solana signing payload is a text message + * with one labelled field per line, so a value containing a newline could forge + * additional lines and change what the wallet owner believes they signed. Every + * free-text field in this object is therefore constrained to characters that + * cannot break that framing. + */ +const SAFE_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; + +/** Validates an untrusted intent, returning a normalized copy. */ +export function assertBattleIntent(intent: BattleIntent): BattleIntent { + const domain = assertProtocolDomain(intent.domain); + + const attackerOwner = assertAccount(intent.attackerOwner, 'attackerOwner'); + const defenderOwner = assertAccount(intent.defenderOwner, 'defenderOwner'); + const attackerPetId = assertPetId(intent.attackerPetId, 'attackerPetId'); + const defenderPetId = assertPetId(intent.defenderPetId, 'defenderPetId'); + + if (intent.challengeId !== null) { + assertId(intent.challengeId, 'challengeId', 1, 64); + } + assertId(intent.clientNonce, 'clientNonce', 8, 128); + + if (hexToBytes(intent.rulesetHash).length !== 32) { + throw new Error('rulesetHash must be a 32-byte hash'); + } + + if (!Number.isSafeInteger(intent.expiresAt) || intent.expiresAt <= 0) { + throw new Error(`expiresAt must be a positive unix-seconds integer, got ${intent.expiresAt}`); + } + + return { + domain, + attackerOwner, + attackerPetId, + defenderOwner, + defenderPetId, + challengeId: intent.challengeId, + clientNonce: intent.clientNonce, + rulesetHash: intent.rulesetHash, + expiresAt: intent.expiresAt, + }; +} + +/** True when `intent` has expired at `nowSeconds`. The clock is always an argument here. */ +export function isExpired(intent: BattleIntent, nowSeconds: number): boolean { + return nowSeconds >= intent.expiresAt; +} + +function assertAccount(value: string, field: string): string { + if (typeof value !== 'string' || !SAFE_ID_PATTERN.test(value)) { + throw new Error(`${field} is not a valid account: ${JSON.stringify(value)}`); + } + return normalizeAccount(value); +} + +function assertPetId(value: bigint, field: string): bigint { + if (typeof value !== 'bigint' || value <= 0n) { + throw new Error(`${field} must be a positive pet id, got ${value}`); + } + if (value >= 1n << 256n) { + throw new Error(`${field} does not fit in 256 bits`); + } + return value; +} + +function assertId(value: string, field: string, min: number, max: number): void { + if (typeof value !== 'string' || value.length < min || value.length > max) { + throw new Error(`${field} must be ${min}-${max} characters, got ${JSON.stringify(value)}`); + } + if (!SAFE_ID_PATTERN.test(value)) { + throw new Error(`${field} contains characters that are not allowed: ${JSON.stringify(value)}`); + } +} diff --git a/protocol/tests/intent/signing.test.ts b/protocol/tests/intent/signing.test.ts new file mode 100644 index 00000000..bf8b78a2 --- /dev/null +++ b/protocol/tests/intent/signing.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { + battleIntentSolanaMessage, + battleIntentSolanaMessageBytes, + type BattleIntent, + battleIntentTypedData, + SOLANA_INTENT_MESSAGE_HEADER, +} from '../../src/intent'; + +const EVM: BattleIntent = { + domain: { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }, + attackerOwner: '0xabcdef0123456789abcdef0123456789abcdef01', + attackerPetId: 1n, + defenderOwner: '0x2222222222222222222222222222222222222222', + defenderPetId: 2n, + challengeId: null, + clientNonce: '01hq8z0000000000000000', + rulesetHash: `0x${'ab'.repeat(32)}`, + expiresAt: 1893456000, +}; + +const SOLANA: BattleIntent = { + ...EVM, + domain: { chainId: 'solana:devnet', deploymentId: 'local-dev' }, + attackerOwner: 'DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL', + defenderOwner: 'GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp', +}; + +describe('battleIntentTypedData', () => { + it('puts the numeric chain id in the EIP-712 domain and the protocol ids in the message', () => { + const typed = battleIntentTypedData(EVM); + expect(typed.domain).toEqual({ name: 'CryptoPets Battle', version: '1', chainId: 84532 }); + // Both are needed: the domain lets a wallet warn about a network mismatch, + // the message fields stop a replay onto another deployment of the same chain. + expect(typed.message.chainId).toBe('eip155:84532'); + expect(typed.message.deploymentId).toBe('base-sepolia-live'); + }); + + it('names every field it asks the owner to approve, rather than one opaque digest', () => { + const typed = battleIntentTypedData(EVM); + const fields = typed.types.BattleIntent.map((f) => f.name); + expect(fields).toEqual([ + 'schemaVersion', + 'chainId', + 'deploymentId', + 'attackerOwner', + 'attackerPetId', + 'defenderOwner', + 'defenderPetId', + 'challengeId', + 'clientNonce', + 'rulesetHash', + 'expiresAt', + ]); + expect(Object.keys(typed.message).sort()).toEqual([...fields].sort()); + }); + + it('normalizes the owner and keeps ids as bigint for the signer', () => { + const typed = battleIntentTypedData({ + ...EVM, + attackerOwner: '0xABCDEF0123456789abcdef0123456789ABCDEF01', + }); + expect(typed.message.attackerOwner).toBe('0xabcdef0123456789abcdef0123456789abcdef01'); + expect(typed.message.attackerPetId).toBe(1n); + expect(typed.message.expiresAt).toBe(1893456000n); + }); + + it('maps an absent challenge to an empty string, which validation forbids as a real value', () => { + expect(battleIntentTypedData(EVM).message.challengeId).toBe(''); + expect(battleIntentTypedData({ ...EVM, challengeId: 'abc' }).message.challengeId).toBe('abc'); + }); + + it('refuses a Solana intent', () => { + expect(() => battleIntentTypedData(SOLANA)).toThrow(/EIP-712 typed data is for EVM intents/); + }); +}); + +describe('battleIntentSolanaMessage', () => { + it('renders labelled lines under a domain-separating header', () => { + expect(battleIntentSolanaMessage(SOLANA)).toBe( + [ + 'CryptoPets Battle Intent v1', + 'schema: 1', + 'chain: solana:devnet', + 'deployment: local-dev', + 'attacker: DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL', + 'attackerPet: 1', + 'defender: GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp', + 'defenderPet: 2', + 'challenge: (none)', + 'nonce: 01hq8z0000000000000000', + `ruleset: 0x${'ab'.repeat(32)}`, + 'expires: 1893456000', + ].join('\n'), + ); + }); + + it('starts with the header, so a signature cannot be reused for another message type', () => { + expect(battleIntentSolanaMessage(SOLANA).startsWith(SOLANA_INTENT_MESSAGE_HEADER)).toBe(true); + }); + + it('cannot confuse an absent challenge with a real id', () => { + // `(none)` is outside the allowed id charset, so no real challenge can + // render the same line as an absent one. + expect(() => battleIntentSolanaMessage({ ...SOLANA, challengeId: '(none)' })).toThrow(/challengeId/); + }); + + it('changes when any field changes', () => { + const base = battleIntentSolanaMessage(SOLANA); + expect(battleIntentSolanaMessage({ ...SOLANA, defenderPetId: 3n })).not.toBe(base); + expect(battleIntentSolanaMessage({ ...SOLANA, expiresAt: SOLANA.expiresAt + 1 })).not.toBe(base); + }); + + it('encodes to UTF-8 bytes for the wallet', () => { + const bytes = battleIntentSolanaMessageBytes(SOLANA); + expect(new TextDecoder().decode(bytes)).toBe(battleIntentSolanaMessage(SOLANA)); + }); + + it('refuses an EVM intent', () => { + expect(() => battleIntentSolanaMessage(EVM)).toThrow(/Solana sign-message is for Solana intents/); + }); +}); diff --git a/protocol/tests/intent/types.test.ts b/protocol/tests/intent/types.test.ts new file mode 100644 index 00000000..f8457256 --- /dev/null +++ b/protocol/tests/intent/types.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { assertBattleIntent, type BattleIntent, isExpired } from '../../src/intent'; + +const VALID: BattleIntent = { + domain: { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }, + attackerOwner: '0xabcdef0123456789abcdef0123456789abcdef01', + attackerPetId: 1n, + defenderOwner: '0x2222222222222222222222222222222222222222', + defenderPetId: 2n, + challengeId: null, + clientNonce: '01hq8z0000000000000000', + rulesetHash: `0x${'ab'.repeat(32)}`, + expiresAt: 1893456000, +}; + +describe('assertBattleIntent', () => { + it('returns a normalized copy', () => { + const checked = assertBattleIntent({ ...VALID, attackerOwner: '0xABCDEF0123456789abcdef0123456789ABCDEF01' }); + expect(checked.attackerOwner).toBe('0xabcdef0123456789abcdef0123456789abcdef01'); + }); + + it('accepts a Solana intent', () => { + expect(() => + assertBattleIntent({ + ...VALID, + domain: { chainId: 'solana:devnet', deploymentId: 'local-dev' }, + attackerOwner: 'DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL', + defenderOwner: 'GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp', + }), + ).not.toThrow(); + }); + + it('rejects an invalid domain', () => { + expect(() => assertBattleIntent({ ...VALID, domain: { ...VALID.domain, deploymentId: 'Live Env' } })).toThrow( + /invalid deploymentId/, + ); + }); + + it.each([0n, -1n, 1n << 256n])('rejects pet id %s', (attackerPetId) => { + expect(() => assertBattleIntent({ ...VALID, attackerPetId })).toThrow(/attackerPetId/); + }); + + it('rejects an empty owner', () => { + expect(() => assertBattleIntent({ ...VALID, defenderOwner: '' })).toThrow(/defenderOwner/); + }); + + it('rejects a rulesetHash that is not 32 bytes', () => { + expect(() => assertBattleIntent({ ...VALID, rulesetHash: '0x1234' })).toThrow(/32-byte/); + }); + + it.each([0, -1, 1.5])('rejects expiresAt %s', (expiresAt) => { + expect(() => assertBattleIntent({ ...VALID, expiresAt })).toThrow(/expiresAt/); + }); + + it('rejects an empty challengeId, so absent and present can never both mean ""', () => { + expect(() => assertBattleIntent({ ...VALID, challengeId: '' })).toThrow(/challengeId/); + }); + + it('rejects a too-short nonce', () => { + expect(() => assertBattleIntent({ ...VALID, clientNonce: 'short' })).toThrow(/clientNonce/); + }); + + describe('message-framing injection', () => { + // The Solana payload is labelled text, one field per line. A value carrying + // a newline could forge extra lines and change what the wallet owner + // believes they approved, so the charset is enforced at validation time + // rather than at rendering time. + it.each([ + 'nonce\nexpires: 9999999999', + 'nonce\r\nchallenge: other', + 'nonce with spaces', + 'nonce\tand-tab', + 'nonce-with-emoji-🐉', + ])('rejects clientNonce %j', (clientNonce) => { + expect(() => assertBattleIntent({ ...VALID, clientNonce })).toThrow(/clientNonce/); + }); + + it('rejects a challengeId with a newline', () => { + expect(() => assertBattleIntent({ ...VALID, challengeId: 'abc\nexpires: 0' })).toThrow(/challengeId/); + }); + + it('rejects an owner with a newline', () => { + expect(() => assertBattleIntent({ ...VALID, attackerOwner: '0xabc\ndefender: x' })).toThrow( + /attackerOwner/, + ); + }); + }); +}); + +describe('isExpired', () => { + it('takes the clock as an argument, never reading it', () => { + expect(isExpired(VALID, VALID.expiresAt - 1)).toBe(false); + expect(isExpired(VALID, VALID.expiresAt)).toBe(true); + expect(isExpired(VALID, VALID.expiresAt + 1)).toBe(true); + }); +}); diff --git a/protocol/tests/intent/vectors.test.ts b/protocol/tests/intent/vectors.test.ts new file mode 100644 index 00000000..3f175e55 --- /dev/null +++ b/protocol/tests/intent/vectors.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import type { ChainId } from '../../src/domain/chainId'; +import type { Hex } from '../../src/encoding/bytes'; +import { battleIntentSolanaMessage, type BattleIntent, hashBattleIntent } from '../../src/intent'; + +/** + * Consumes contracts/test-vectors/protocol-intent.json, the frozen record of what + * an intent hashes to. Generated once by `pnpm --filter @cryptopets/protocol + * vectors`; a failure here means the encoding drifted, and the fix is the code, + * never the vector (`AGENTS.md`). + * + * The cases are not independent samples: several of them exist only to be + * compared against each other, which the relationship tests below do. + */ +interface IntentFixture { + chainId: string; + deploymentId: string; + attackerOwner: string; + attackerPetId: string; + defenderOwner: string; + defenderPetId: string; + challengeId: string | null; + clientNonce: string; + rulesetHash: string; + expiresAt: number; +} + +interface IntentCase { + name: string; + note: string; + intent: IntentFixture; + expectedIntentHash: string; + expectedSolanaMessage: string | null; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const vectorsPath = join(here, '../../../contracts/test-vectors/protocol-intent.json'); +const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')) as { cases: IntentCase[] }; + +function toIntent(fixture: IntentFixture): BattleIntent { + return { + domain: { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }, + attackerOwner: fixture.attackerOwner, + attackerPetId: BigInt(fixture.attackerPetId), + defenderOwner: fixture.defenderOwner, + defenderPetId: BigInt(fixture.defenderPetId), + challengeId: fixture.challengeId, + clientNonce: fixture.clientNonce, + rulesetHash: fixture.rulesetHash as Hex, + expiresAt: fixture.expiresAt, + }; +} + +const byName = new Map(vectors.cases.map((c) => [c.name, c])); +const hashOf = (name: string) => { + const found = byName.get(name); + if (!found) throw new Error(`vector case missing: ${name}`); + return hashBattleIntent(toIntent(found.intent)); +}; + +describe('intent hash golden vectors', () => { + it('covers every case the file declares', () => { + expect(vectors.cases.length).toBeGreaterThanOrEqual(8); + }); + + for (const c of vectors.cases) { + it(`matches the recorded hash for "${c.name}"`, () => { + expect(hashBattleIntent(toIntent(c.intent))).toBe(c.expectedIntentHash); + }); + } + + for (const c of vectors.cases.filter((v) => v.expectedSolanaMessage !== null)) { + it(`matches the recorded Solana message for "${c.name}"`, () => { + expect(battleIntentSolanaMessage(toIntent(c.intent))).toBe(c.expectedSolanaMessage); + }); + } +}); + +describe('relationships the vectors exist to pin', () => { + it('treats an EVM address as case-insensitive', () => { + expect(hashOf('evm-checksummed-owner')).toBe(hashOf('evm-direct-challenge')); + }); + + it('separates staging from production on the same chain', () => { + expect(hashOf('evm-staging-deployment')).not.toBe(hashOf('evm-direct-challenge')); + }); + + it('separates a matchmade battle from a direct one', () => { + expect(hashOf('evm-matchmade')).not.toBe(hashOf('evm-direct-challenge')); + }); + + it('separates two battles that differ only by nonce', () => { + expect(hashOf('evm-other-nonce')).not.toBe(hashOf('evm-direct-challenge')); + }); + + it('separates chains', () => { + expect(hashOf('solana-devnet')).not.toBe(hashOf('evm-direct-challenge')); + }); + + it('produces a distinct hash for every case except the casing pair', () => { + const hashes = vectors.cases + .filter((c) => c.name !== 'evm-checksummed-owner') + .map((c) => c.expectedIntentHash); + expect(new Set(hashes).size).toBe(hashes.length); + }); +}); diff --git a/protocol/tsconfig.json b/protocol/tsconfig.json index 4972d4c1..5ad38756 100644 --- a/protocol/tsconfig.json +++ b/protocol/tsconfig.json @@ -26,5 +26,5 @@ "verbatimModuleSyntax": true, "isolatedModules": true }, - "include": ["src/**/*.ts", "tests/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"] } From 66470a827506605164fb37b807b5b217192ec82a Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 07:16:04 -0400 Subject: [PATCH 09/76] feat(protocol): add standing defense-authorization schema and hashing --- contracts/test-vectors/protocol-consent.json | 217 +++++++++++++++++++ protocol/scripts/gen-vectors.ts | 154 +++++++++++++ protocol/src/consent/hash.ts | 42 ++++ protocol/src/consent/index.ts | 21 ++ protocol/src/consent/signing.ts | 126 +++++++++++ protocol/src/consent/types.ts | 203 +++++++++++++++++ protocol/src/index.ts | 1 + protocol/tests/consent/signing.test.ts | 128 +++++++++++ protocol/tests/consent/types.test.ts | 184 ++++++++++++++++ protocol/tests/consent/vectors.test.ts | 118 ++++++++++ 10 files changed, 1194 insertions(+) create mode 100644 contracts/test-vectors/protocol-consent.json create mode 100644 protocol/src/consent/hash.ts create mode 100644 protocol/src/consent/index.ts create mode 100644 protocol/src/consent/signing.ts create mode 100644 protocol/src/consent/types.ts create mode 100644 protocol/tests/consent/signing.test.ts create mode 100644 protocol/tests/consent/types.test.ts create mode 100644 protocol/tests/consent/vectors.test.ts diff --git a/contracts/test-vectors/protocol-consent.json b/contracts/test-vectors/protocol-consent.json new file mode 100644 index 00000000..c0cf7651 --- /dev/null +++ b/contracts/test-vectors/protocol-consent.json @@ -0,0 +1,217 @@ +{ + "description": "DefenseAuthorization canonical-hash and Solana sign-message vectors (docs/plan-backend-battle-architecture.md §D). Generated by protocol/scripts/gen-vectors.ts from protocol/src/consent. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.", + "cases": [ + { + "name": "evm-all-pets", + "note": "Baseline blanket authorization: every pet the owner holds, now or later.", + "auth": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "defenderOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "allPets": true, + "petIds": [], + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "minLevel": 1, + "maxLevel": 20, + "maxBattlesPerDay": 20, + "notBefore": 1861920000, + "expiresAt": 1893456000, + "revocationNonce": 0 + }, + "expectedAuthorizationHash": "0x1848fd03acbc44f4e2fa15be038060d948d54620c90388cd6949ceda9a7ba36f", + "expectedSolanaMessage": null + }, + { + "name": "evm-specific-pets", + "note": "Explicit two-pet scope. Must differ from evm-all-pets: a blanket authorization is not the same consent as a list.", + "auth": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "defenderOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "allPets": false, + "petIds": [ + "7", + "9" + ], + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "minLevel": 1, + "maxLevel": 20, + "maxBattlesPerDay": 20, + "notBefore": 1861920000, + "expiresAt": 1893456000, + "revocationNonce": 0 + }, + "expectedAuthorizationHash": "0xb8e5951fbb5a69aee6ee1792a9857b111f61d3ebca8ac33b53104ac966d18414", + "expectedSolanaMessage": null + }, + { + "name": "evm-specific-pets-superset", + "note": "Same list plus one pet. Must differ from evm-specific-pets: array framing has to separate a longer list from a shorter one.", + "auth": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "defenderOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "allPets": false, + "petIds": [ + "7", + "9", + "11" + ], + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "minLevel": 1, + "maxLevel": 20, + "maxBattlesPerDay": 20, + "notBefore": 1861920000, + "expiresAt": 1893456000, + "revocationNonce": 0 + }, + "expectedAuthorizationHash": "0x005224b98faf6f5852a35fd7711c914b8b4a67ec912a0cd211001c752dd37c9e", + "expectedSolanaMessage": null + }, + { + "name": "evm-other-ruleset", + "note": "Baseline under a different rulesetHash. Must differ: consent is per ruleset version, which is what stops old consent being reinterpreted under new combat math (§D).", + "auth": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "defenderOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "allPets": true, + "petIds": [], + "rulesetHash": "0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "minLevel": 1, + "maxLevel": 20, + "maxBattlesPerDay": 20, + "notBefore": 1861920000, + "expiresAt": 1893456000, + "revocationNonce": 0 + }, + "expectedAuthorizationHash": "0xcaad5afb05fd07076a5d6ce1929b28583bfd2c642ae84affb7914d0c6dc13ea7", + "expectedSolanaMessage": null + }, + { + "name": "evm-narrow-level-band", + "note": "Baseline with a narrower attacker-level band. Must differ.", + "auth": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "defenderOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "allPets": true, + "petIds": [], + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "minLevel": 8, + "maxLevel": 12, + "maxBattlesPerDay": 20, + "notBefore": 1861920000, + "expiresAt": 1893456000, + "revocationNonce": 0 + }, + "expectedAuthorizationHash": "0xb7c145c4f59f2fb9c31adf569e7f12ba24144d10518ef871f83753c1537676ba", + "expectedSolanaMessage": null + }, + { + "name": "evm-revoked-then-resigned", + "note": "Baseline with revocationNonce bumped. Must differ: this is how a revocation invalidates every authorization signed at a lower value.", + "auth": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "defenderOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "allPets": true, + "petIds": [], + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "minLevel": 1, + "maxLevel": 20, + "maxBattlesPerDay": 20, + "notBefore": 1861920000, + "expiresAt": 1893456000, + "revocationNonce": 1 + }, + "expectedAuthorizationHash": "0x8cbe9d89bd8d57d9bc7c48f146b929a80483f0d7865aa8bab1db9a9f4a2b0626", + "expectedSolanaMessage": null + }, + { + "name": "evm-checksummed-owner", + "note": "Baseline with the owner in checksummed spelling. Must hash IDENTICALLY to evm-all-pets.", + "auth": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "defenderOwner": "0xABcDEF0123456789abcDef0123456789aBCDeF01", + "allPets": true, + "petIds": [], + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "minLevel": 1, + "maxLevel": 20, + "maxBattlesPerDay": 20, + "notBefore": 1861920000, + "expiresAt": 1893456000, + "revocationNonce": 0 + }, + "expectedAuthorizationHash": "0x1848fd03acbc44f4e2fa15be038060d948d54620c90388cd6949ceda9a7ba36f", + "expectedSolanaMessage": null + }, + { + "name": "evm-field-widths", + "note": "Level band, daily cap, revocation nonce, and window at their upper bounds, plus a pet id at the 256-bit ceiling.", + "auth": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "defenderOwner": "0xabcdef0123456789abcdef0123456789abcdef01", + "allPets": false, + "petIds": [ + "115792089237316195423570985008687907853269984665640564039457584007913129639935" + ], + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "minLevel": 65535, + "maxLevel": 65535, + "maxBattlesPerDay": 4294967295, + "notBefore": 1, + "expiresAt": 281474976710655, + "revocationNonce": 4294967295 + }, + "expectedAuthorizationHash": "0x369f62139c866c5fba169d721714f412ec326b55ecaf9902e2b7210f890097fb", + "expectedSolanaMessage": null + }, + { + "name": "solana-all-pets", + "note": "Solana blanket authorization. Must differ from the EVM baseline.", + "auth": { + "chainId": "solana:devnet", + "deploymentId": "base-sepolia-live", + "defenderOwner": "GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp", + "allPets": true, + "petIds": [], + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "minLevel": 1, + "maxLevel": 20, + "maxBattlesPerDay": 20, + "notBefore": 1861920000, + "expiresAt": 1893456000, + "revocationNonce": 0 + }, + "expectedAuthorizationHash": "0x1fa3424a105931db19dcdb912096a669a1d5bfcc0b5b92af4107cbadb6d73fff", + "expectedSolanaMessage": "CryptoPets Defense Authorization v1\nschema: 1\nchain: solana:devnet\ndeployment: base-sepolia-live\ndefender: GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp\npets: (all)\nruleset: 0xabababababababababababababababababababababababababababababababab\nlevels: 1-20\nmaxBattlesPerDay: 20\nnotBefore: 1861920000\nexpires: 1893456000\nrevocationNonce: 0" + }, + { + "name": "solana-specific-pets", + "note": "Solana explicit scope, so the signed text message lists ids instead of the (all) placeholder.", + "auth": { + "chainId": "solana:mainnet", + "deploymentId": "solana-live", + "defenderOwner": "GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp", + "allPets": false, + "petIds": [ + "3", + "4" + ], + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "minLevel": 5, + "maxLevel": 15, + "maxBattlesPerDay": 5, + "notBefore": 1861920000, + "expiresAt": 1893456000, + "revocationNonce": 2 + }, + "expectedAuthorizationHash": "0xeaa9b904804f51b20540c6458904771fba7267bfb43b62d04e40c54265f9f29b", + "expectedSolanaMessage": "CryptoPets Defense Authorization v1\nschema: 1\nchain: solana:mainnet\ndeployment: solana-live\ndefender: GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp\npets: 3,4\nruleset: 0xabababababababababababababababababababababababababababababababab\nlevels: 5-15\nmaxBattlesPerDay: 5\nnotBefore: 1861920000\nexpires: 1893456000\nrevocationNonce: 2" + } + ] +} diff --git a/protocol/scripts/gen-vectors.ts b/protocol/scripts/gen-vectors.ts index d33d3c4a..ab852119 100644 --- a/protocol/scripts/gen-vectors.ts +++ b/protocol/scripts/gen-vectors.ts @@ -16,6 +16,11 @@ import { writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + type DefenseAuthorization, + defenseAuthorizationSolanaMessage, + hashDefenseAuthorization, +} from '../src/consent'; import type { ChainId } from '../src/domain/chainId'; import type { Hex } from '../src/encoding/bytes'; import { battleIntentSolanaMessage, type BattleIntent, hashBattleIntent } from '../src/intent'; @@ -151,4 +156,153 @@ function writeIntentVectors(): void { process.stdout.write(`wrote ${out.cases.length} intent cases to ${path}\n`); } +/** Serializable form of an authorization, as it appears in the vector file. */ +interface ConsentFixture { + chainId: string; + deploymentId: string; + defenderOwner: string; + allPets: boolean; + petIds: string[]; + rulesetHash: string; + minLevel: number; + maxLevel: number; + maxBattlesPerDay: number; + notBefore: number; + expiresAt: number; + revocationNonce: number; +} + +const CONSENT_BASE: ConsentFixture = { + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + defenderOwner: '0xabcdef0123456789abcdef0123456789abcdef01', + allPets: true, + petIds: [], + rulesetHash: RULESET_HASH, + minLevel: 1, + maxLevel: 20, + maxBattlesPerDay: 20, + notBefore: 1861920000, + expiresAt: 1893456000, + revocationNonce: 0, +}; + +const consentCases: { name: string; note: string; auth: ConsentFixture }[] = [ + { + name: 'evm-all-pets', + note: 'Baseline blanket authorization: every pet the owner holds, now or later.', + auth: CONSENT_BASE, + }, + { + name: 'evm-specific-pets', + note: 'Explicit two-pet scope. Must differ from evm-all-pets: a blanket authorization is not the same consent as a list.', + auth: { ...CONSENT_BASE, allPets: false, petIds: ['7', '9'] }, + }, + { + name: 'evm-specific-pets-superset', + note: 'Same list plus one pet. Must differ from evm-specific-pets: array framing has to separate a longer list from a shorter one.', + auth: { ...CONSENT_BASE, allPets: false, petIds: ['7', '9', '11'] }, + }, + { + name: 'evm-other-ruleset', + note: 'Baseline under a different rulesetHash. Must differ: consent is per ruleset version, which is what stops old consent being reinterpreted under new combat math (§D).', + auth: { ...CONSENT_BASE, rulesetHash: `0x${'cd'.repeat(32)}` }, + }, + { + name: 'evm-narrow-level-band', + note: 'Baseline with a narrower attacker-level band. Must differ.', + auth: { ...CONSENT_BASE, minLevel: 8, maxLevel: 12 }, + }, + { + name: 'evm-revoked-then-resigned', + note: 'Baseline with revocationNonce bumped. Must differ: this is how a revocation invalidates every authorization signed at a lower value.', + auth: { ...CONSENT_BASE, revocationNonce: 1 }, + }, + { + name: 'evm-checksummed-owner', + note: 'Baseline with the owner in checksummed spelling. Must hash IDENTICALLY to evm-all-pets.', + auth: { ...CONSENT_BASE, defenderOwner: '0xABcDEF0123456789abcDef0123456789aBCDeF01' }, + }, + { + name: 'evm-field-widths', + note: 'Level band, daily cap, revocation nonce, and window at their upper bounds, plus a pet id at the 256-bit ceiling.', + auth: { + ...CONSENT_BASE, + allPets: false, + petIds: ['115792089237316195423570985008687907853269984665640564039457584007913129639935'], + minLevel: 65535, + maxLevel: 65535, + maxBattlesPerDay: 4294967295, + notBefore: 1, + expiresAt: 281474976710655, + revocationNonce: 4294967295, + }, + }, + { + name: 'solana-all-pets', + note: 'Solana blanket authorization. Must differ from the EVM baseline.', + auth: { + ...CONSENT_BASE, + chainId: 'solana:devnet', + defenderOwner: 'GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp', + }, + }, + { + name: 'solana-specific-pets', + note: 'Solana explicit scope, so the signed text message lists ids instead of the (all) placeholder.', + auth: { + ...CONSENT_BASE, + chainId: 'solana:mainnet', + deploymentId: 'solana-live', + defenderOwner: 'GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp', + allPets: false, + petIds: ['3', '4'], + minLevel: 5, + maxLevel: 15, + maxBattlesPerDay: 5, + revocationNonce: 2, + }, + }, +]; + +/** Rebuilds a runtime authorization from its serializable fixture. */ +export function consentFromFixture(fixture: ConsentFixture): DefenseAuthorization { + return { + domain: { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }, + defenderOwner: fixture.defenderOwner, + scope: fixture.allPets + ? { kind: 'allPets' } + : { kind: 'pets', petIds: fixture.petIds.map((id) => BigInt(id)) }, + rulesetHash: fixture.rulesetHash as Hex, + minLevel: fixture.minLevel, + maxLevel: fixture.maxLevel, + maxBattlesPerDay: fixture.maxBattlesPerDay, + notBefore: fixture.notBefore, + expiresAt: fixture.expiresAt, + revocationNonce: fixture.revocationNonce, + }; +} + +function writeConsentVectors(): void { + const out = { + description: + 'DefenseAuthorization canonical-hash and Solana sign-message vectors (docs/plan-backend-battle-architecture.md §D). Generated by protocol/scripts/gen-vectors.ts from protocol/src/consent. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.', + cases: consentCases.map((c) => { + const auth = consentFromFixture(c.auth); + const solana = c.auth.chainId.startsWith('solana:'); + return { + name: c.name, + note: c.note, + auth: c.auth, + expectedAuthorizationHash: hashDefenseAuthorization(auth), + expectedSolanaMessage: solana ? defenseAuthorizationSolanaMessage(auth) : null, + }; + }), + }; + const path = join(VECTORS_DIR, 'protocol-consent.json'); + writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`); + process.stdout.write(`wrote ${out.cases.length} consent cases to ${path}\n`); +} + writeIntentVectors(); +writeConsentVectors(); diff --git a/protocol/src/consent/hash.ts b/protocol/src/consent/hash.ts new file mode 100644 index 00000000..9d2bff70 --- /dev/null +++ b/protocol/src/consent/hash.ts @@ -0,0 +1,42 @@ +import { writeHeader } from '../domain/deployment'; +import type { Hex } from '../encoding/bytes'; +import { DOMAIN_TAGS } from '../encoding/domain'; +import { keccak256Hex } from '../encoding/hash'; +import { CanonicalWriter } from '../encoding/writer'; + +import { assertDefenseAuthorization, type DefenseAuthorization } from './types'; + +/** + * Canonical encoding of an authorization. Header first, then the §D field list. + * + * The scope encodes as a flag plus a list, and validation guarantees the two + * cannot both be meaningful: a blanket authorization carries an empty list, an + * explicit one carries a non-empty ascending list. So "all pets" and "these + * pets" can never produce the same bytes. + */ +export function encodeDefenseAuthorization(auth: DefenseAuthorization): Uint8Array { + const checked = assertDefenseAuthorization(auth); + const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.DEFENSE_AUTHORIZATION); + const petIds = checked.scope.kind === 'pets' ? checked.scope.petIds : []; + return writeHeader(writer, 'defenseAuthorization', checked.domain) + .account(checked.defenderOwner) + .bool(checked.scope.kind === 'allPets') + .array(petIds, (w, petId) => w.u256(petId)) + .hash(checked.rulesetHash) + .u16(checked.minLevel) + .u16(checked.maxLevel) + .u32(checked.maxBattlesPerDay) + .u64(checked.notBefore) + .u64(checked.expiresAt) + .u32(checked.revocationNonce) + .build(); +} + +/** + * `defenseAuthorizationHash`: embedded in every receipt that relied on this + * authorization (§G), so an outsider can check what the defender had agreed to + * at the time of the battle rather than taking our word for it. + */ +export function hashDefenseAuthorization(auth: DefenseAuthorization): Hex { + return keccak256Hex(encodeDefenseAuthorization(auth)); +} diff --git a/protocol/src/consent/index.ts b/protocol/src/consent/index.ts new file mode 100644 index 00000000..f24e47b8 --- /dev/null +++ b/protocol/src/consent/index.ts @@ -0,0 +1,21 @@ +export { encodeDefenseAuthorization, hashDefenseAuthorization } from './hash'; +export { + defenseAuthorizationSolanaMessage, + defenseAuthorizationSolanaMessageBytes, + type DefenseAuthorizationTypedData, + defenseAuthorizationTypedData, + EIP712_DEFENSE_DOMAIN_NAME, + EIP712_DEFENSE_DOMAIN_VERSION, + EIP712_DEFENSE_TYPES, + SOLANA_DEFENSE_MESSAGE_HEADER, +} from './signing'; +export { + assertDefenseAuthorization, + authorizationCovers, + type CoverageFailure, + type CoverageQuery, + type CoverageResult, + type DefenseAuthorization, + type DefenseScope, + MAX_SCOPE_PET_IDS, +} from './types'; diff --git a/protocol/src/consent/signing.ts b/protocol/src/consent/signing.ts new file mode 100644 index 00000000..64639342 --- /dev/null +++ b/protocol/src/consent/signing.ts @@ -0,0 +1,126 @@ +import { chainFamily, evmChainIdNumber } from '../domain/chainId'; +import { currentSchemaVersion } from '../domain/schemaVersions'; +import { utf8ToBytes } from '../encoding/bytes'; + +import { assertDefenseAuthorization, type DefenseAuthorization } from './types'; + +/** + * Payloads the defender's wallet signs. Same reasoning as the intent's: every + * field is named, because this is the one prompt where the owner decides who may + * challenge them, under which rules, and for how long. A hash would tell them + * none of that. + */ + +export const EIP712_DEFENSE_DOMAIN_NAME = 'CryptoPets Defense'; +export const EIP712_DEFENSE_DOMAIN_VERSION = '1'; + +export const EIP712_DEFENSE_TYPES = { + DefenseAuthorization: [ + { name: 'schemaVersion', type: 'uint16' }, + { name: 'chainId', type: 'string' }, + { name: 'deploymentId', type: 'string' }, + { name: 'defenderOwner', type: 'address' }, + { name: 'allPets', type: 'bool' }, + { name: 'petIds', type: 'uint256[]' }, + { name: 'rulesetHash', type: 'bytes32' }, + { name: 'minLevel', type: 'uint16' }, + { name: 'maxLevel', type: 'uint16' }, + { name: 'maxBattlesPerDay', type: 'uint32' }, + { name: 'notBefore', type: 'uint64' }, + { name: 'expiresAt', type: 'uint64' }, + { name: 'revocationNonce', type: 'uint32' }, + ], +} as const; + +export interface DefenseAuthorizationTypedData { + domain: { + name: string; + version: string; + chainId: number; + }; + types: typeof EIP712_DEFENSE_TYPES; + primaryType: 'DefenseAuthorization'; + message: { + schemaVersion: number; + chainId: string; + deploymentId: string; + defenderOwner: string; + allPets: boolean; + petIds: readonly bigint[]; + rulesetHash: string; + minLevel: number; + maxLevel: number; + maxBattlesPerDay: number; + notBefore: bigint; + expiresAt: bigint; + revocationNonce: number; + }; +} + +/** EIP-712 typed data for an EVM authorization. */ +export function defenseAuthorizationTypedData(auth: DefenseAuthorization): DefenseAuthorizationTypedData { + const checked = assertDefenseAuthorization(auth); + if (chainFamily(checked.domain.chainId) !== 'evm') { + throw new Error(`EIP-712 typed data is for EVM authorizations; got ${checked.domain.chainId}`); + } + return { + domain: { + name: EIP712_DEFENSE_DOMAIN_NAME, + version: EIP712_DEFENSE_DOMAIN_VERSION, + chainId: evmChainIdNumber(checked.domain.chainId), + }, + types: EIP712_DEFENSE_TYPES, + primaryType: 'DefenseAuthorization', + message: { + schemaVersion: currentSchemaVersion('defenseAuthorization'), + chainId: checked.domain.chainId, + deploymentId: checked.domain.deploymentId, + defenderOwner: checked.defenderOwner, + allPets: checked.scope.kind === 'allPets', + petIds: checked.scope.kind === 'pets' ? checked.scope.petIds : [], + rulesetHash: checked.rulesetHash, + minLevel: checked.minLevel, + maxLevel: checked.maxLevel, + maxBattlesPerDay: checked.maxBattlesPerDay, + notBefore: BigInt(checked.notBefore), + expiresAt: BigInt(checked.expiresAt), + revocationNonce: checked.revocationNonce, + }, + }; +} + +/** First line of the Solana message, and its domain separator. */ +export const SOLANA_DEFENSE_MESSAGE_HEADER = 'CryptoPets Defense Authorization v1'; + +/** + * Labelled text message for a Solana authorization. + * + * `pets: (all)` cannot be confused with an explicit list: pet ids render as + * digits, and parentheses cannot appear in one. + */ +export function defenseAuthorizationSolanaMessage(auth: DefenseAuthorization): string { + const checked = assertDefenseAuthorization(auth); + if (chainFamily(checked.domain.chainId) !== 'solana') { + throw new Error(`Solana sign-message is for Solana authorizations; got ${checked.domain.chainId}`); + } + const pets = checked.scope.kind === 'allPets' ? '(all)' : checked.scope.petIds.join(','); + return [ + SOLANA_DEFENSE_MESSAGE_HEADER, + `schema: ${currentSchemaVersion('defenseAuthorization')}`, + `chain: ${checked.domain.chainId}`, + `deployment: ${checked.domain.deploymentId}`, + `defender: ${checked.defenderOwner}`, + `pets: ${pets}`, + `ruleset: ${checked.rulesetHash}`, + `levels: ${checked.minLevel}-${checked.maxLevel}`, + `maxBattlesPerDay: ${checked.maxBattlesPerDay}`, + `notBefore: ${checked.notBefore}`, + `expires: ${checked.expiresAt}`, + `revocationNonce: ${checked.revocationNonce}`, + ].join('\n'); +} + +/** The Solana message as the bytes a wallet signs. */ +export function defenseAuthorizationSolanaMessageBytes(auth: DefenseAuthorization): Uint8Array { + return utf8ToBytes(defenseAuthorizationSolanaMessage(auth)); +} diff --git a/protocol/src/consent/types.ts b/protocol/src/consent/types.ts new file mode 100644 index 00000000..172f5fda --- /dev/null +++ b/protocol/src/consent/types.ts @@ -0,0 +1,203 @@ +import { assertProtocolDomain, type ProtocolDomain } from '../domain/deployment'; +import { type Hex, hexToBytes, normalizeAccount } from '../encoding/bytes'; + +/** + * Which of a defender's pets an authorization covers. + * + * `allPets` is a standing "anyone may challenge me" that keeps covering pets + * minted or bought after signing. An explicit list is the conservative form. + */ +export type DefenseScope = { kind: 'allPets' } | { kind: 'pets'; petIds: readonly bigint[] }; + +/** + * A defender's long-lived, signed permission to be challenged. + * + * The problem this solves: the current EVM contract lets anyone attack anyone's + * pet, which backend ranked mode should not copy, because it would apply cooldown + * and rating changes to an unwilling defender. But demanding a live signature per + * battle means you can only fight players who are online, which is a large + * product regression. So consent is signed once, in advance, and bounded. + * + * Consent is bound to `rulesetHash`: a rules change invalidates outstanding + * authorizations rather than silently re-interpreting old consent under new + * combat math. Expect a re-consent prompt after every balance patch. That is the + * intended cost, and it is what makes "I agreed to the old rules" not a dispute. + * + * See architecture §D. Live PvP is this same object with a short `expiresAt`. + */ +export interface DefenseAuthorization { + domain: ProtocolDomain; + /** Wallet that owns the defending pets and signs this authorization. */ + defenderOwner: string; + scope: DefenseScope; + /** The exact ruleset version being consented to. */ + rulesetHash: Hex; + /** Inclusive attacker-level band the defender accepts. */ + minLevel: number; + maxLevel: number; + /** + * Ceiling on battles per day against this authorization. Enforced by the + * backend's counter, since a pure function cannot know a count; the receipt + * records which authorization it relied on so the count is auditable. + */ + maxBattlesPerDay: number; + /** Unix seconds. Validity window. */ + notBefore: number; + expiresAt: number; + /** + * Bumped by the owner to invalidate every authorization signed at a lower + * value. Revocation is immediate and recorded in the ledger; its timestamp + * goes into any affected receipt (§D). + */ + revocationNonce: number; +} + +const SAFE_ACCOUNT_PATTERN = /^[A-Za-z0-9._:-]+$/; + +/** Upper bound on an explicit pet list, so one signature cannot carry an unbounded set. */ +export const MAX_SCOPE_PET_IDS = 256; + +/** Validates an untrusted authorization, returning a normalized copy. */ +export function assertDefenseAuthorization(auth: DefenseAuthorization): DefenseAuthorization { + const domain = assertProtocolDomain(auth.domain); + + if (typeof auth.defenderOwner !== 'string' || !SAFE_ACCOUNT_PATTERN.test(auth.defenderOwner)) { + throw new Error(`defenderOwner is not a valid account: ${JSON.stringify(auth.defenderOwner)}`); + } + + const scope = assertScope(auth.scope); + + if (hexToBytes(auth.rulesetHash).length !== 32) { + throw new Error('rulesetHash must be a 32-byte hash'); + } + + assertLevel(auth.minLevel, 'minLevel'); + assertLevel(auth.maxLevel, 'maxLevel'); + if (auth.minLevel > auth.maxLevel) { + throw new Error(`minLevel ${auth.minLevel} exceeds maxLevel ${auth.maxLevel}`); + } + + if (!Number.isSafeInteger(auth.maxBattlesPerDay) || auth.maxBattlesPerDay < 1 || auth.maxBattlesPerDay > 0xffffffff) { + // Zero would not be a limit, it would be a refusal, and a refusal is + // expressed by not signing. + throw new Error(`maxBattlesPerDay must be between 1 and 2^32-1, got ${auth.maxBattlesPerDay}`); + } + + assertUnixSeconds(auth.notBefore, 'notBefore'); + assertUnixSeconds(auth.expiresAt, 'expiresAt'); + if (auth.notBefore >= auth.expiresAt) { + throw new Error(`notBefore ${auth.notBefore} must be before expiresAt ${auth.expiresAt}`); + } + + if (!Number.isSafeInteger(auth.revocationNonce) || auth.revocationNonce < 0 || auth.revocationNonce > 0xffffffff) { + throw new Error(`revocationNonce must be between 0 and 2^32-1, got ${auth.revocationNonce}`); + } + + return { + domain, + defenderOwner: normalizeAccount(auth.defenderOwner), + scope, + rulesetHash: auth.rulesetHash, + minLevel: auth.minLevel, + maxLevel: auth.maxLevel, + maxBattlesPerDay: auth.maxBattlesPerDay, + notBefore: auth.notBefore, + expiresAt: auth.expiresAt, + revocationNonce: auth.revocationNonce, + }; +} + +/** Why an authorization does not cover a battle. */ +export type CoverageFailure = + | 'not-yet-valid' + | 'expired' + | 'pet-not-covered' + | 'attacker-level-below-band' + | 'attacker-level-above-band' + | 'ruleset-mismatch'; + +export type CoverageResult = { covered: true } | { covered: false; reason: CoverageFailure }; + +/** What a specific battle needs the authorization to permit. */ +export interface CoverageQuery { + defenderPetId: bigint; + attackerLevel: number; + rulesetHash: Hex; + /** Unix seconds. Always passed in: protocol code never reads the clock. */ + nowSeconds: number; +} + +/** + * Whether this authorization permits one specific battle. + * + * Deliberately returns a reason rather than a bare boolean, because "the + * defender does not accept challenges from level 3" and "the authorization + * expired" are different answers for the player and different alerts for us. + * + * `maxBattlesPerDay` is *not* checked here: it needs a count this function + * cannot see. The backend enforces it. + */ +export function authorizationCovers(auth: DefenseAuthorization, query: CoverageQuery): CoverageResult { + if (query.nowSeconds < auth.notBefore) { + return { covered: false, reason: 'not-yet-valid' }; + } + if (query.nowSeconds >= auth.expiresAt) { + return { covered: false, reason: 'expired' }; + } + if (auth.rulesetHash.toLowerCase() !== query.rulesetHash.toLowerCase()) { + return { covered: false, reason: 'ruleset-mismatch' }; + } + if (auth.scope.kind === 'pets' && !auth.scope.petIds.includes(query.defenderPetId)) { + return { covered: false, reason: 'pet-not-covered' }; + } + if (query.attackerLevel < auth.minLevel) { + return { covered: false, reason: 'attacker-level-below-band' }; + } + if (query.attackerLevel > auth.maxLevel) { + return { covered: false, reason: 'attacker-level-above-band' }; + } + return { covered: true }; +} + +function assertScope(scope: DefenseScope): DefenseScope { + if (scope.kind === 'allPets') { + return { kind: 'allPets' }; + } + if (scope.kind !== 'pets') { + throw new Error(`unknown defense scope: ${JSON.stringify(scope)}`); + } + const { petIds } = scope; + if (!Array.isArray(petIds) || petIds.length === 0) { + throw new Error('an explicit pet scope must list at least one pet; use allPets for a blanket authorization'); + } + if (petIds.length > MAX_SCOPE_PET_IDS) { + throw new Error(`a pet scope may not exceed ${MAX_SCOPE_PET_IDS} pets, got ${petIds.length}`); + } + // Strictly ascending, not merely valid. The owner consents to a *set*, so one + // set must have one hash; sorting silently would make the hash disagree with + // the order the wallet displayed, and accepting any order would give one set + // as many hashes as it has permutations. Duplicates fall out of the same rule. + for (let i = 0; i < petIds.length; i++) { + const petId = petIds[i]; + if (typeof petId !== 'bigint' || petId <= 0n || petId >= 1n << 256n) { + throw new Error(`scope pet id at index ${i} is not a valid pet id: ${petId}`); + } + const previous = petIds[i - 1]; + if (previous !== undefined && petId <= previous) { + throw new Error(`scope pet ids must be strictly ascending; ${petId} follows ${previous}`); + } + } + return { kind: 'pets', petIds: [...petIds] }; +} + +function assertLevel(value: number, field: string): void { + if (!Number.isSafeInteger(value) || value < 1 || value > 0xffff) { + throw new Error(`${field} must be between 1 and 65535, got ${value}`); + } +} + +function assertUnixSeconds(value: number, field: string): void { + if (!Number.isSafeInteger(value) || value <= 0 || value > 0xffffffffffff) { + throw new Error(`${field} must be a positive unix-seconds integer, got ${value}`); + } +} diff --git a/protocol/src/index.ts b/protocol/src/index.ts index a0138afb..8839bc6d 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -12,6 +12,7 @@ export const PROTOCOL_PACKAGE = '@cryptopets/protocol'; export * from './combat'; +export * from './consent'; export * from './domain'; export * from './encoding'; export * from './intent'; diff --git a/protocol/tests/consent/signing.test.ts b/protocol/tests/consent/signing.test.ts new file mode 100644 index 00000000..f4ec21bf --- /dev/null +++ b/protocol/tests/consent/signing.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; + +import { + type DefenseAuthorization, + defenseAuthorizationSolanaMessage, + defenseAuthorizationSolanaMessageBytes, + defenseAuthorizationTypedData, + SOLANA_DEFENSE_MESSAGE_HEADER, +} from '../../src/consent'; +import type { Hex } from '../../src/encoding/bytes'; + +const RULESET = `0x${'ab'.repeat(32)}` as Hex; + +const EVM: DefenseAuthorization = { + domain: { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }, + defenderOwner: '0xabcdef0123456789abcdef0123456789abcdef01', + scope: { kind: 'allPets' }, + rulesetHash: RULESET, + minLevel: 5, + maxLevel: 15, + maxBattlesPerDay: 20, + notBefore: 1861920000, + expiresAt: 1893456000, + revocationNonce: 0, +}; + +const SOLANA: DefenseAuthorization = { + ...EVM, + domain: { chainId: 'solana:devnet', deploymentId: 'local-dev' }, + defenderOwner: 'GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp', +}; + +describe('defenseAuthorizationTypedData', () => { + it('carries the numeric chain id in the domain and the protocol ids in the message', () => { + const typed = defenseAuthorizationTypedData(EVM); + expect(typed.domain).toEqual({ name: 'CryptoPets Defense', version: '1', chainId: 84532 }); + expect(typed.message.chainId).toBe('eip155:84532'); + expect(typed.message.deploymentId).toBe('base-sepolia-live'); + }); + + it('names every field the owner is approving', () => { + const typed = defenseAuthorizationTypedData(EVM); + const fields = typed.types.DefenseAuthorization.map((f) => f.name); + expect(fields).toEqual([ + 'schemaVersion', + 'chainId', + 'deploymentId', + 'defenderOwner', + 'allPets', + 'petIds', + 'rulesetHash', + 'minLevel', + 'maxLevel', + 'maxBattlesPerDay', + 'notBefore', + 'expiresAt', + 'revocationNonce', + ]); + expect(Object.keys(typed.message).sort()).toEqual([...fields].sort()); + }); + + it('renders a blanket scope as allPets with an empty list', () => { + const typed = defenseAuthorizationTypedData(EVM); + expect(typed.message.allPets).toBe(true); + expect(typed.message.petIds).toEqual([]); + }); + + it('renders an explicit scope as the list with allPets false', () => { + const typed = defenseAuthorizationTypedData({ ...EVM, scope: { kind: 'pets', petIds: [7n, 9n] } }); + expect(typed.message.allPets).toBe(false); + expect(typed.message.petIds).toEqual([7n, 9n]); + }); + + it('refuses a Solana authorization', () => { + expect(() => defenseAuthorizationTypedData(SOLANA)).toThrow(/EIP-712 typed data is for EVM authorizations/); + }); +}); + +describe('defenseAuthorizationSolanaMessage', () => { + it('renders labelled lines under a domain-separating header', () => { + expect(defenseAuthorizationSolanaMessage(SOLANA)).toBe( + [ + 'CryptoPets Defense Authorization v1', + 'schema: 1', + 'chain: solana:devnet', + 'deployment: local-dev', + 'defender: GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp', + 'pets: (all)', + `ruleset: ${RULESET}`, + 'levels: 5-15', + 'maxBattlesPerDay: 20', + 'notBefore: 1861920000', + 'expires: 1893456000', + 'revocationNonce: 0', + ].join('\n'), + ); + }); + + it('lists explicit pet ids', () => { + const message = defenseAuthorizationSolanaMessage({ + ...SOLANA, + scope: { kind: 'pets', petIds: [3n, 4n] }, + }); + expect(message).toContain('pets: 3,4'); + expect(message).not.toContain('(all)'); + }); + + it('starts with the header, so the signature cannot be reused for an intent', () => { + expect(defenseAuthorizationSolanaMessage(SOLANA).startsWith(SOLANA_DEFENSE_MESSAGE_HEADER)).toBe(true); + expect(SOLANA_DEFENSE_MESSAGE_HEADER).not.toBe('CryptoPets Battle Intent v1'); + }); + + it('changes when any bound changes', () => { + const base = defenseAuthorizationSolanaMessage(SOLANA); + expect(defenseAuthorizationSolanaMessage({ ...SOLANA, maxLevel: 16 })).not.toBe(base); + expect(defenseAuthorizationSolanaMessage({ ...SOLANA, maxBattlesPerDay: 21 })).not.toBe(base); + expect(defenseAuthorizationSolanaMessage({ ...SOLANA, revocationNonce: 1 })).not.toBe(base); + }); + + it('encodes to UTF-8 bytes for the wallet', () => { + const bytes = defenseAuthorizationSolanaMessageBytes(SOLANA); + expect(new TextDecoder().decode(bytes)).toBe(defenseAuthorizationSolanaMessage(SOLANA)); + }); + + it('refuses an EVM authorization', () => { + expect(() => defenseAuthorizationSolanaMessage(EVM)).toThrow(/Solana sign-message is for Solana/); + }); +}); diff --git a/protocol/tests/consent/types.test.ts b/protocol/tests/consent/types.test.ts new file mode 100644 index 00000000..bd243603 --- /dev/null +++ b/protocol/tests/consent/types.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertDefenseAuthorization, + authorizationCovers, + type DefenseAuthorization, + MAX_SCOPE_PET_IDS, +} from '../../src/consent'; +import type { Hex } from '../../src/encoding/bytes'; + +const RULESET = `0x${'ab'.repeat(32)}` as Hex; +const OTHER_RULESET = `0x${'cd'.repeat(32)}` as Hex; + +const VALID: DefenseAuthorization = { + domain: { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }, + defenderOwner: '0xabcdef0123456789abcdef0123456789abcdef01', + scope: { kind: 'allPets' }, + rulesetHash: RULESET, + minLevel: 5, + maxLevel: 15, + maxBattlesPerDay: 20, + notBefore: 1861920000, + expiresAt: 1893456000, + revocationNonce: 0, +}; + +describe('assertDefenseAuthorization', () => { + it('normalizes the owner', () => { + const checked = assertDefenseAuthorization({ + ...VALID, + defenderOwner: '0xABCDEF0123456789abcdef0123456789ABCDEF01', + }); + expect(checked.defenderOwner).toBe('0xabcdef0123456789abcdef0123456789abcdef01'); + }); + + it('copies the pet list rather than aliasing the caller array', () => { + const petIds = [1n, 2n]; + const checked = assertDefenseAuthorization({ ...VALID, scope: { kind: 'pets', petIds } }); + petIds.push(3n); + expect(checked.scope.kind === 'pets' && checked.scope.petIds).toEqual([1n, 2n]); + }); + + describe('scope', () => { + it('rejects an empty explicit list, which would authorize nothing', () => { + expect(() => assertDefenseAuthorization({ ...VALID, scope: { kind: 'pets', petIds: [] } })).toThrow( + /at least one pet/, + ); + }); + + it('requires strictly ascending ids, so one set has exactly one hash', () => { + expect(() => + assertDefenseAuthorization({ ...VALID, scope: { kind: 'pets', petIds: [9n, 7n] } }), + ).toThrow(/strictly ascending/); + }); + + it('rejects duplicates, which the ascending rule also catches', () => { + expect(() => + assertDefenseAuthorization({ ...VALID, scope: { kind: 'pets', petIds: [7n, 7n] } }), + ).toThrow(/strictly ascending/); + }); + + it('rejects a list longer than the cap', () => { + const petIds = Array.from({ length: MAX_SCOPE_PET_IDS + 1 }, (_, i) => BigInt(i + 1)); + expect(() => assertDefenseAuthorization({ ...VALID, scope: { kind: 'pets', petIds } })).toThrow( + /may not exceed/, + ); + }); + + it('accepts a list exactly at the cap', () => { + const petIds = Array.from({ length: MAX_SCOPE_PET_IDS }, (_, i) => BigInt(i + 1)); + expect(() => assertDefenseAuthorization({ ...VALID, scope: { kind: 'pets', petIds } })).not.toThrow(); + }); + + it.each([0n, -1n])('rejects pet id %s', (petId) => { + expect(() => assertDefenseAuthorization({ ...VALID, scope: { kind: 'pets', petIds: [petId] } })).toThrow( + /not a valid pet id/, + ); + }); + }); + + describe('bounds', () => { + it('rejects an inverted level band', () => { + expect(() => assertDefenseAuthorization({ ...VALID, minLevel: 16, maxLevel: 15 })).toThrow( + /exceeds maxLevel/, + ); + }); + + it('accepts a single-level band', () => { + expect(() => assertDefenseAuthorization({ ...VALID, minLevel: 7, maxLevel: 7 })).not.toThrow(); + }); + + it('rejects a zero daily cap, since refusal is expressed by not signing', () => { + expect(() => assertDefenseAuthorization({ ...VALID, maxBattlesPerDay: 0 })).toThrow( + /maxBattlesPerDay must be between 1/, + ); + }); + + it('rejects an inverted validity window', () => { + expect(() => assertDefenseAuthorization({ ...VALID, notBefore: VALID.expiresAt })).toThrow( + /must be before expiresAt/, + ); + }); + + it('rejects a negative revocation nonce', () => { + expect(() => assertDefenseAuthorization({ ...VALID, revocationNonce: -1 })).toThrow(/revocationNonce/); + }); + + it('rejects a rulesetHash that is not 32 bytes', () => { + expect(() => assertDefenseAuthorization({ ...VALID, rulesetHash: '0x1234' })).toThrow(/32-byte/); + }); + + it('rejects an owner with a newline, which would forge a line in the Solana message', () => { + expect(() => assertDefenseAuthorization({ ...VALID, defenderOwner: '0xabc\npets: (all)' })).toThrow( + /defenderOwner/, + ); + }); + }); +}); + +describe('authorizationCovers', () => { + const query = { + defenderPetId: 7n, + attackerLevel: 10, + rulesetHash: RULESET, + nowSeconds: VALID.notBefore + 1, + }; + + it('covers a battle inside every bound', () => { + expect(authorizationCovers(VALID, query)).toEqual({ covered: true }); + }); + + it('reports why it does not cover, rather than a bare false', () => { + // "the defender does not accept level 3 challengers" and "the authorization + // expired" are different answers for the player and different alerts for us. + expect(authorizationCovers(VALID, { ...query, nowSeconds: VALID.notBefore - 1 })).toEqual({ + covered: false, + reason: 'not-yet-valid', + }); + expect(authorizationCovers(VALID, { ...query, nowSeconds: VALID.expiresAt })).toEqual({ + covered: false, + reason: 'expired', + }); + expect(authorizationCovers(VALID, { ...query, attackerLevel: 4 })).toEqual({ + covered: false, + reason: 'attacker-level-below-band', + }); + expect(authorizationCovers(VALID, { ...query, attackerLevel: 16 })).toEqual({ + covered: false, + reason: 'attacker-level-above-band', + }); + expect(authorizationCovers(VALID, { ...query, rulesetHash: OTHER_RULESET })).toEqual({ + covered: false, + reason: 'ruleset-mismatch', + }); + }); + + it('treats the validity window as half-open: notBefore inclusive, expiresAt exclusive', () => { + expect(authorizationCovers(VALID, { ...query, nowSeconds: VALID.notBefore }).covered).toBe(true); + expect(authorizationCovers(VALID, { ...query, nowSeconds: VALID.expiresAt - 1 }).covered).toBe(true); + expect(authorizationCovers(VALID, { ...query, nowSeconds: VALID.expiresAt }).covered).toBe(false); + }); + + it('includes both band edges', () => { + expect(authorizationCovers(VALID, { ...query, attackerLevel: 5 }).covered).toBe(true); + expect(authorizationCovers(VALID, { ...query, attackerLevel: 15 }).covered).toBe(true); + }); + + it('honours an explicit pet scope', () => { + const scoped: DefenseAuthorization = { ...VALID, scope: { kind: 'pets', petIds: [7n, 9n] } }; + expect(authorizationCovers(scoped, query).covered).toBe(true); + expect(authorizationCovers(scoped, { ...query, defenderPetId: 8n })).toEqual({ + covered: false, + reason: 'pet-not-covered', + }); + }); + + it('covers any pet under a blanket authorization', () => { + expect(authorizationCovers(VALID, { ...query, defenderPetId: 999999n }).covered).toBe(true); + }); + + it('ignores ruleset-hash casing', () => { + expect(authorizationCovers(VALID, { ...query, rulesetHash: RULESET.toUpperCase() as Hex }).covered).toBe(true); + }); +}); diff --git a/protocol/tests/consent/vectors.test.ts b/protocol/tests/consent/vectors.test.ts new file mode 100644 index 00000000..5902f0e6 --- /dev/null +++ b/protocol/tests/consent/vectors.test.ts @@ -0,0 +1,118 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { + type DefenseAuthorization, + defenseAuthorizationSolanaMessage, + hashDefenseAuthorization, +} from '../../src/consent'; +import type { ChainId } from '../../src/domain/chainId'; +import type { Hex } from '../../src/encoding/bytes'; + +/** + * Consumes contracts/test-vectors/protocol-consent.json. A failure means the + * encoding drifted, and the fix is the code, never the vector (`AGENTS.md`). + */ +interface ConsentFixture { + chainId: string; + deploymentId: string; + defenderOwner: string; + allPets: boolean; + petIds: string[]; + rulesetHash: string; + minLevel: number; + maxLevel: number; + maxBattlesPerDay: number; + notBefore: number; + expiresAt: number; + revocationNonce: number; +} + +interface ConsentCase { + name: string; + note: string; + auth: ConsentFixture; + expectedAuthorizationHash: string; + expectedSolanaMessage: string | null; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const vectorsPath = join(here, '../../../contracts/test-vectors/protocol-consent.json'); +const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')) as { cases: ConsentCase[] }; + +function toAuth(fixture: ConsentFixture): DefenseAuthorization { + return { + domain: { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }, + defenderOwner: fixture.defenderOwner, + scope: fixture.allPets + ? { kind: 'allPets' } + : { kind: 'pets', petIds: fixture.petIds.map((id) => BigInt(id)) }, + rulesetHash: fixture.rulesetHash as Hex, + minLevel: fixture.minLevel, + maxLevel: fixture.maxLevel, + maxBattlesPerDay: fixture.maxBattlesPerDay, + notBefore: fixture.notBefore, + expiresAt: fixture.expiresAt, + revocationNonce: fixture.revocationNonce, + }; +} + +const byName = new Map(vectors.cases.map((c) => [c.name, c])); +const hashOf = (name: string) => { + const found = byName.get(name); + if (!found) throw new Error(`vector case missing: ${name}`); + return hashDefenseAuthorization(toAuth(found.auth)); +}; + +describe('defense authorization golden vectors', () => { + for (const c of vectors.cases) { + it(`matches the recorded hash for "${c.name}"`, () => { + expect(hashDefenseAuthorization(toAuth(c.auth))).toBe(c.expectedAuthorizationHash); + }); + } + + for (const c of vectors.cases.filter((v) => v.expectedSolanaMessage !== null)) { + it(`matches the recorded Solana message for "${c.name}"`, () => { + expect(defenseAuthorizationSolanaMessage(toAuth(c.auth))).toBe(c.expectedSolanaMessage); + }); + } +}); + +describe('relationships the vectors exist to pin', () => { + it('treats the owner address as case-insensitive', () => { + expect(hashOf('evm-checksummed-owner')).toBe(hashOf('evm-all-pets')); + }); + + it('separates a blanket authorization from an explicit list', () => { + expect(hashOf('evm-specific-pets')).not.toBe(hashOf('evm-all-pets')); + }); + + it('separates a longer pet list from a prefix of it', () => { + expect(hashOf('evm-specific-pets-superset')).not.toBe(hashOf('evm-specific-pets')); + }); + + it('separates consent given under different rulesets', () => { + expect(hashOf('evm-other-ruleset')).not.toBe(hashOf('evm-all-pets')); + }); + + it('separates a re-signed authorization after a revocation', () => { + expect(hashOf('evm-revoked-then-resigned')).not.toBe(hashOf('evm-all-pets')); + }); + + it('separates level bands', () => { + expect(hashOf('evm-narrow-level-band')).not.toBe(hashOf('evm-all-pets')); + }); + + it('separates chains', () => { + expect(hashOf('solana-all-pets')).not.toBe(hashOf('evm-all-pets')); + }); + + it('produces a distinct hash for every case except the casing pair', () => { + const hashes = vectors.cases + .filter((c) => c.name !== 'evm-checksummed-owner') + .map((c) => c.expectedAuthorizationHash); + expect(new Set(hashes).size).toBe(hashes.length); + }); +}); From 96ee54e96e8a994d5acd90c9428bc5a5928a9e19 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 07:22:06 -0400 Subject: [PATCH 10/76] feat(protocol): add frozen pet snapshot schema and snapshot hashing --- contracts/test-vectors/protocol-snapshot.json | 329 ++++++++++++++++++ protocol/scripts/gen-vectors.ts | 174 +++++++++ protocol/src/index.ts | 1 + protocol/src/snapshot/hash.ts | 48 +++ protocol/src/snapshot/index.ts | 8 + protocol/src/snapshot/types.ts | 131 +++++++ protocol/tests/snapshot/types.test.ts | 159 +++++++++ protocol/tests/snapshot/vectors.test.ts | 121 +++++++ 8 files changed, 971 insertions(+) create mode 100644 contracts/test-vectors/protocol-snapshot.json create mode 100644 protocol/src/snapshot/hash.ts create mode 100644 protocol/src/snapshot/index.ts create mode 100644 protocol/src/snapshot/types.ts create mode 100644 protocol/tests/snapshot/types.test.ts create mode 100644 protocol/tests/snapshot/vectors.test.ts diff --git a/contracts/test-vectors/protocol-snapshot.json b/contracts/test-vectors/protocol-snapshot.json new file mode 100644 index 00000000..1f82e437 --- /dev/null +++ b/contracts/test-vectors/protocol-snapshot.json @@ -0,0 +1,329 @@ +{ + "description": "BattleSnapshot canonical-hash vectors (docs/plan-backend-battle-architecture.md §C, §F). Generated by protocol/scripts/gen-vectors.ts from protocol/src/snapshot. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.", + "cases": [ + { + "name": "evm-baseline", + "note": "Fresh attacker (no prior opponent) against a defender mid-streak.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "expectedSnapshotHash": "0x5b137baf7a1a10a790366ce353c153406543fc81d028997c2e441d048a5d28e3" + }, + { + "name": "evm-roles-swapped", + "note": "Same two pets with the roles exchanged. Must differ: roles are not symmetric, since the result is stated from the attacker perspective.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "defender": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "takenAt": 1861920000 + }, + "expectedSnapshotHash": "0x68100bae2e3f3f078f640b4064c2770b9e1f2afd88b61bdfc69ece0ba4c9f4cf" + }, + { + "name": "evm-level-up", + "note": "Attacker one level higher. Must differ: this is the front-run the snapshot exists to prevent.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 11, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "expectedSnapshotHash": "0x0040c1ffc5a7d6efdd1422b9df743dee9ef05ceda92a848214fff4f37903ddd9" + }, + { + "name": "evm-streak-advanced", + "note": "Defender streak advanced by one. Must differ: streak is an XP input, so it cannot be adjustable after the fact.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 3, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "expectedSnapshotHash": "0xb2044c6dfeea39477a5e41e247df87d6983a22966dc7bf815f28bee477f4c4a6" + }, + { + "name": "evm-other-source-version", + "note": "Same pet state read at a later indexed version. Must differ: which chain version a snapshot came from is part of what it claims.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918001" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "expectedSnapshotHash": "0x1a40c4026991c8777b8eaec112ea385c080c985623c4a1aa8f1f2955e7c99368" + }, + { + "name": "evm-later-takenAt", + "note": "Same pets, one second later. Must differ.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920001 + }, + "expectedSnapshotHash": "0x5ec22794dd7b9fa136ce047211a15a72855db3bf74971f548bd2ad3d717ea0f0" + }, + { + "name": "evm-checksummed-owner", + "note": "Baseline with the attacker owner checksummed. Must hash IDENTICALLY to evm-baseline.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xABcDEF0123456789abcDef0123456789aBCDeF01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "expectedSnapshotHash": "0x5b137baf7a1a10a790366ce353c153406543fc81d028997c2e441d048a5d28e3" + }, + { + "name": "evm-field-widths", + "note": "Pet ids near the 256-bit ceiling, maximum DNA, rarity 5, level and skill at u16 bounds, xp and streak at u32 bounds.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "115792089237316195423570985008687907853269984665640564039457584007913129639935", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "9999999999999999", + "rarity": 5, + "level": 65535, + "skill": 65535, + "xp": 4294967295, + "lastOpponentId": "340282366920938463463374607431768211457", + "streak": 4294967295, + "readyAt": 281474976710655, + "sourceVersion": "18446744073709551615" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "expectedSnapshotHash": "0xdb80bd83034a6697e4389905d1f1212cebd6f34fef00f1720e8bd7c3a1dfcd91" + }, + { + "name": "solana-baseline", + "note": "Solana snapshot with base58 owners. Must differ from the EVM baseline.", + "snapshot": { + "chainId": "solana:devnet", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "expectedSnapshotHash": "0x80f9ea9b20565fe195a64787186925cedaa7c208d6ac0478f0d72d644e45c131" + } + ] +} diff --git a/protocol/scripts/gen-vectors.ts b/protocol/scripts/gen-vectors.ts index ab852119..4b891e26 100644 --- a/protocol/scripts/gen-vectors.ts +++ b/protocol/scripts/gen-vectors.ts @@ -24,6 +24,7 @@ import { import type { ChainId } from '../src/domain/chainId'; import type { Hex } from '../src/encoding/bytes'; import { battleIntentSolanaMessage, type BattleIntent, hashBattleIntent } from '../src/intent'; +import { type BattleSnapshot, hashBattleSnapshot, type PetSnapshot } from '../src/snapshot'; const VECTORS_DIR = join(dirname(fileURLToPath(import.meta.url)), '../../contracts/test-vectors'); @@ -304,5 +305,178 @@ function writeConsentVectors(): void { process.stdout.write(`wrote ${out.cases.length} consent cases to ${path}\n`); } +/** Serializable form of a pet snapshot. */ +interface PetFixture { + petId: string; + owner: string; + dna: string; + rarity: number; + level: number; + skill: number; + xp: number; + lastOpponentId: string; + streak: number; + readyAt: number; + sourceVersion: string; +} + +interface SnapshotFixture { + chainId: string; + deploymentId: string; + attacker: PetFixture; + defender: PetFixture; + takenAt: number; +} + +const ATTACKER: PetFixture = { + petId: '1', + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: '1234567890123456', + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: '0', + streak: 0, + readyAt: 1861919000, + sourceVersion: '1861918000', +}; + +const DEFENDER: PetFixture = { + petId: '2', + owner: '0x2222222222222222222222222222222222222222', + dna: '6543210987654321', + rarity: 2, + level: 11, + skill: 7, + xp: 45, + lastOpponentId: '1', + streak: 2, + readyAt: 1861919500, + sourceVersion: '1861918500', +}; + +const SNAPSHOT_BASE: SnapshotFixture = { + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + attacker: ATTACKER, + defender: DEFENDER, + takenAt: 1861920000, +}; + +const snapshotCases: { name: string; note: string; snapshot: SnapshotFixture }[] = [ + { + name: 'evm-baseline', + note: 'Fresh attacker (no prior opponent) against a defender mid-streak.', + snapshot: SNAPSHOT_BASE, + }, + { + name: 'evm-roles-swapped', + note: 'Same two pets with the roles exchanged. Must differ: roles are not symmetric, since the result is stated from the attacker perspective.', + snapshot: { ...SNAPSHOT_BASE, attacker: DEFENDER, defender: ATTACKER }, + }, + { + name: 'evm-level-up', + note: 'Attacker one level higher. Must differ: this is the front-run the snapshot exists to prevent.', + snapshot: { ...SNAPSHOT_BASE, attacker: { ...ATTACKER, level: 11 } }, + }, + { + name: 'evm-streak-advanced', + note: 'Defender streak advanced by one. Must differ: streak is an XP input, so it cannot be adjustable after the fact.', + snapshot: { ...SNAPSHOT_BASE, defender: { ...DEFENDER, streak: 3 } }, + }, + { + name: 'evm-other-source-version', + note: 'Same pet state read at a later indexed version. Must differ: which chain version a snapshot came from is part of what it claims.', + snapshot: { ...SNAPSHOT_BASE, attacker: { ...ATTACKER, sourceVersion: '1861918001' } }, + }, + { + name: 'evm-later-takenAt', + note: 'Same pets, one second later. Must differ.', + snapshot: { ...SNAPSHOT_BASE, takenAt: 1861920001 }, + }, + { + name: 'evm-checksummed-owner', + note: 'Baseline with the attacker owner checksummed. Must hash IDENTICALLY to evm-baseline.', + snapshot: { + ...SNAPSHOT_BASE, + attacker: { ...ATTACKER, owner: '0xABcDEF0123456789abcDef0123456789aBCDeF01' }, + }, + }, + { + name: 'evm-field-widths', + note: 'Pet ids near the 256-bit ceiling, maximum DNA, rarity 5, level and skill at u16 bounds, xp and streak at u32 bounds.', + snapshot: { + ...SNAPSHOT_BASE, + attacker: { + ...ATTACKER, + petId: '115792089237316195423570985008687907853269984665640564039457584007913129639935', + dna: '9999999999999999', + rarity: 5, + level: 65535, + skill: 65535, + xp: 4294967295, + lastOpponentId: '340282366920938463463374607431768211457', + streak: 4294967295, + readyAt: 281474976710655, + sourceVersion: '18446744073709551615', + }, + }, + }, + { + name: 'solana-baseline', + note: 'Solana snapshot with base58 owners. Must differ from the EVM baseline.', + snapshot: { + ...SNAPSHOT_BASE, + chainId: 'solana:devnet', + attacker: { ...ATTACKER, owner: 'DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL' }, + defender: { ...DEFENDER, owner: 'GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp' }, + }, + }, +]; + +function petFromFixture(fixture: PetFixture): PetSnapshot { + return { + petId: BigInt(fixture.petId), + owner: fixture.owner, + dna: BigInt(fixture.dna), + rarity: fixture.rarity, + level: fixture.level, + skill: fixture.skill, + xp: fixture.xp, + lastOpponentId: BigInt(fixture.lastOpponentId), + streak: fixture.streak, + readyAt: fixture.readyAt, + sourceVersion: BigInt(fixture.sourceVersion), + }; +} + +/** Rebuilds a runtime snapshot from its serializable fixture. */ +export function snapshotFromFixture(fixture: SnapshotFixture): BattleSnapshot { + return { + domain: { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }, + attacker: petFromFixture(fixture.attacker), + defender: petFromFixture(fixture.defender), + takenAt: fixture.takenAt, + }; +} + +function writeSnapshotVectors(): void { + const out = { + description: + 'BattleSnapshot canonical-hash vectors (docs/plan-backend-battle-architecture.md §C, §F). Generated by protocol/scripts/gen-vectors.ts from protocol/src/snapshot. They lock the canonical byte layout; a failure means the implementation drifted. Never edit an expectation to match new output.', + cases: snapshotCases.map((c) => ({ + name: c.name, + note: c.note, + snapshot: c.snapshot, + expectedSnapshotHash: hashBattleSnapshot(snapshotFromFixture(c.snapshot)), + })), + }; + const path = join(VECTORS_DIR, 'protocol-snapshot.json'); + writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`); + process.stdout.write(`wrote ${out.cases.length} snapshot cases to ${path}\n`); +} + writeIntentVectors(); writeConsentVectors(); +writeSnapshotVectors(); diff --git a/protocol/src/index.ts b/protocol/src/index.ts index 8839bc6d..24253177 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -16,3 +16,4 @@ export * from './consent'; export * from './domain'; export * from './encoding'; export * from './intent'; +export * from './snapshot'; diff --git a/protocol/src/snapshot/hash.ts b/protocol/src/snapshot/hash.ts new file mode 100644 index 00000000..800684d6 --- /dev/null +++ b/protocol/src/snapshot/hash.ts @@ -0,0 +1,48 @@ +import { writeHeader } from '../domain/deployment'; +import type { Hex } from '../encoding/bytes'; +import { DOMAIN_TAGS } from '../encoding/domain'; +import { keccak256Hex } from '../encoding/hash'; +import { CanonicalWriter } from '../encoding/writer'; + +import { assertBattleSnapshot, type BattleSnapshot, type PetSnapshot } from './types'; + +/** + * Canonical encoding of a frozen battle snapshot: header, attacker, defender, + * `takenAt`. + * + * Attacker and defender are written in role order, not sorted by pet id. Roles are + * not symmetric here (the attacker pays, the attacker's `firstWins` defines the + * result), so swapping the two must produce a different hash. + * + * No `battleId` is included. A snapshot describes pets, and the commitment is what + * binds one snapshot to one battle by signing `battleId` and `snapshotHash` + * together (§E). + */ +export function encodeBattleSnapshot(snapshot: BattleSnapshot): Uint8Array { + const checked = assertBattleSnapshot(snapshot); + const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.SNAPSHOT); + writeHeader(writer, 'snapshot', checked.domain); + writePet(writer, checked.attacker); + writePet(writer, checked.defender); + return writer.u64(checked.takenAt).build(); +} + +/** `snapshotHash`: carried by the commitment (§E) and the receipt (§G). */ +export function hashBattleSnapshot(snapshot: BattleSnapshot): Hex { + return keccak256Hex(encodeBattleSnapshot(snapshot)); +} + +function writePet(writer: CanonicalWriter, pet: PetSnapshot): void { + writer + .u256(pet.petId) + .account(pet.owner) + .u256(pet.dna) + .u8(pet.rarity) + .u16(pet.level) + .u16(pet.skill) + .u32(pet.xp) + .u256(pet.lastOpponentId) + .u32(pet.streak) + .u64(pet.readyAt) + .u64(pet.sourceVersion); +} diff --git a/protocol/src/snapshot/index.ts b/protocol/src/snapshot/index.ts new file mode 100644 index 00000000..890ae64a --- /dev/null +++ b/protocol/src/snapshot/index.ts @@ -0,0 +1,8 @@ +export { encodeBattleSnapshot, hashBattleSnapshot } from './hash'; +export { + assertBattleSnapshot, + assertPetSnapshot, + type BattleSnapshot, + isBattleReady, + type PetSnapshot, +} from './types'; diff --git a/protocol/src/snapshot/types.ts b/protocol/src/snapshot/types.ts new file mode 100644 index 00000000..2dde4780 --- /dev/null +++ b/protocol/src/snapshot/types.ts @@ -0,0 +1,131 @@ +import { assertProtocolDomain, type ProtocolDomain } from '../domain/deployment'; +import { normalizeAccount } from '../encoding/bytes'; + +/** + * One pet, frozen at acceptance. The "photo" from Part 1 of the architecture doc. + * + * Two jobs. First, the fight is decided entirely by values written down before any + * randomness existed, so a level-up between acceptance and settlement cannot + * reroll a committed battle (the same attack `GameLogic.sol` closed on chain by + * snapshotting sim inputs). Second, it makes the battle replayable by a stranger: + * every input the ruleset consumes is here, in the receipt, rather than read live + * from a database only we can see. + * + * That second job is why progression state is included. XP depends on + * same-opponent decay, which lives in `lastOpponentId` and `streak`; without them + * in the snapshot, progression could only be recomputed by someone with access to + * our tables, which is not replay. + * + * Equipment is deliberately absent. Nothing equips anything yet, and inventing the + * field now would freeze a layout for a feature whose shape is undecided. Adding + * it is a `snapshot` schema-version bump, which is the honest cost. + */ +export interface PetSnapshot { + petId: bigint; + /** Owner at snapshot time, per finalized chain state. */ + owner: string; + /** 16-digit DNA, the sole source of base attributes. */ + dna: bigint; + /** Rarity tier 1-5, the DNA multiplier. */ + rarity: number; + level: number; + /** Skill archetype 0-7, or any other value for "no archetype" (`NO_SKILL`). */ + skill: number; + /** XP toward the next level at snapshot time. */ + xp: number; + /** Previous opponent, or 0 for a pet that has not fought. Drives XP decay. */ + lastOpponentId: bigint; + /** Consecutive prior battles against `lastOpponentId`. The XP decay shift. */ + streak: number; + /** Unix seconds this pet becomes battle-ready. Lets a verifier check cooldown. */ + readyAt: number; + /** + * Indexed chain version the pet was read at (EVM block timestamp / Solana + * slot), so a snapshot taken from an unfinalized write is identifiable after + * the fact rather than merely suspected (threat T10). + */ + sourceVersion: bigint; +} + +/** Both pets, frozen together. This is what `snapshotHash` covers. */ +export interface BattleSnapshot { + domain: ProtocolDomain; + attacker: PetSnapshot; + defender: PetSnapshot; + /** Unix seconds the snapshot was taken, which is acceptance time. */ + takenAt: number; +} + +/** DNA is a 16-digit number on both chains (see `combat/dna.ts`). */ +const MAX_DNA = 10n ** 16n; +const MAX_U256 = 1n << 256n; +const SAFE_ACCOUNT_PATTERN = /^[A-Za-z0-9._:-]+$/; + +/** Validates one pet snapshot, returning a normalized copy. */ +export function assertPetSnapshot(pet: PetSnapshot, label: string): PetSnapshot { + if (typeof pet.petId !== 'bigint' || pet.petId <= 0n || pet.petId >= MAX_U256) { + throw new Error(`${label}.petId is not a valid pet id: ${pet.petId}`); + } + if (typeof pet.owner !== 'string' || !SAFE_ACCOUNT_PATTERN.test(pet.owner)) { + throw new Error(`${label}.owner is not a valid account: ${JSON.stringify(pet.owner)}`); + } + if (typeof pet.dna !== 'bigint' || pet.dna < 0n || pet.dna >= MAX_DNA) { + throw new Error(`${label}.dna must be a 16-digit value, got ${pet.dna}`); + } + if (!Number.isSafeInteger(pet.rarity) || pet.rarity < 1 || pet.rarity > 5) { + throw new Error(`${label}.rarity must be 1-5, got ${pet.rarity}`); + } + assertU16(pet.level, `${label}.level`, 1); + assertU16(pet.skill, `${label}.skill`, 0); + assertU32(pet.xp, `${label}.xp`); + if (typeof pet.lastOpponentId !== 'bigint' || pet.lastOpponentId < 0n || pet.lastOpponentId >= MAX_U256) { + throw new Error(`${label}.lastOpponentId must be a pet id or 0, got ${pet.lastOpponentId}`); + } + assertU32(pet.streak, `${label}.streak`); + if (pet.lastOpponentId === 0n && pet.streak !== 0) { + // A streak against nobody is not a state the chain can produce, so accepting + // it would mean hashing a snapshot that cannot be reconciled with any + // history. Reject it here rather than let it decay XP silently. + throw new Error(`${label}.streak must be 0 when lastOpponentId is 0, got ${pet.streak}`); + } + assertUnixSeconds(pet.readyAt, `${label}.readyAt`, 0); + if (typeof pet.sourceVersion !== 'bigint' || pet.sourceVersion < 0n || pet.sourceVersion >= 1n << 64n) { + throw new Error(`${label}.sourceVersion must fit in 64 bits, got ${pet.sourceVersion}`); + } + return { ...pet, owner: normalizeAccount(pet.owner) }; +} + +/** Validates a battle snapshot, returning a normalized copy. */ +export function assertBattleSnapshot(snapshot: BattleSnapshot): BattleSnapshot { + const domain = assertProtocolDomain(snapshot.domain); + const attacker = assertPetSnapshot(snapshot.attacker, 'attacker'); + const defender = assertPetSnapshot(snapshot.defender, 'defender'); + if (attacker.petId === defender.petId) { + throw new Error(`a pet cannot fight itself (petId ${attacker.petId})`); + } + assertUnixSeconds(snapshot.takenAt, 'takenAt', 1); + return { domain, attacker, defender, takenAt: snapshot.takenAt }; +} + +/** Whether a pet was off cooldown when the snapshot was taken. */ +export function isBattleReady(pet: PetSnapshot, atSeconds: number): boolean { + return atSeconds >= pet.readyAt; +} + +function assertU16(value: number, field: string, min: number): void { + if (!Number.isSafeInteger(value) || value < min || value > 0xffff) { + throw new Error(`${field} must be ${min}-65535, got ${value}`); + } +} + +function assertU32(value: number, field: string): void { + if (!Number.isSafeInteger(value) || value < 0 || value > 0xffffffff) { + throw new Error(`${field} must be 0-4294967295, got ${value}`); + } +} + +function assertUnixSeconds(value: number, field: string, min: number): void { + if (!Number.isSafeInteger(value) || value < min || value > 0xffffffffffff) { + throw new Error(`${field} must be a unix-seconds integer >= ${min}, got ${value}`); + } +} diff --git a/protocol/tests/snapshot/types.test.ts b/protocol/tests/snapshot/types.test.ts new file mode 100644 index 00000000..58c8efa6 --- /dev/null +++ b/protocol/tests/snapshot/types.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { simulate } from '../../src/combat'; +import { + assertBattleSnapshot, + assertPetSnapshot, + type BattleSnapshot, + isBattleReady, + type PetSnapshot, +} from '../../src/snapshot'; + +const ATTACKER: PetSnapshot = { + petId: 1n, + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: 1234567890123456n, + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: 0n, + streak: 0, + readyAt: 1861919000, + sourceVersion: 1861918000n, +}; + +const DEFENDER: PetSnapshot = { + ...ATTACKER, + petId: 2n, + owner: '0x2222222222222222222222222222222222222222', + dna: 6543210987654321n, + rarity: 2, + level: 11, + skill: 7, + xp: 45, + lastOpponentId: 1n, + streak: 2, +}; + +const SNAPSHOT: BattleSnapshot = { + domain: { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }, + attacker: ATTACKER, + defender: DEFENDER, + takenAt: 1861920000, +}; + +describe('assertPetSnapshot', () => { + it('normalizes the owner', () => { + const checked = assertPetSnapshot( + { ...ATTACKER, owner: '0xABCDEF0123456789abcdef0123456789ABCDEF01' }, + 'attacker', + ); + expect(checked.owner).toBe('0xabcdef0123456789abcdef0123456789abcdef01'); + }); + + it.each([0n, -1n])('rejects petId %s', (petId) => { + expect(() => assertPetSnapshot({ ...ATTACKER, petId }, 'attacker')).toThrow(/attacker.petId/); + }); + + it('rejects DNA wider than 16 digits', () => { + expect(() => assertPetSnapshot({ ...ATTACKER, dna: 10n ** 16n }, 'attacker')).toThrow(/16-digit/); + }); + + it('accepts the largest 16-digit DNA', () => { + expect(() => assertPetSnapshot({ ...ATTACKER, dna: 10n ** 16n - 1n }, 'attacker')).not.toThrow(); + }); + + it.each([0, 6, -1])('rejects rarity %s', (rarity) => { + expect(() => assertPetSnapshot({ ...ATTACKER, rarity }, 'attacker')).toThrow(/rarity must be 1-5/); + }); + + it('rejects level 0, since no pet is level 0', () => { + expect(() => assertPetSnapshot({ ...ATTACKER, level: 0 }, 'attacker')).toThrow(/level/); + }); + + it('accepts any skill value, since anything outside 0-7 means no archetype', () => { + // NO_SKILL is 99 in the vectors, but the simulator treats every value + // outside 0-7 the same way, so the snapshot must not narrow it further. + for (const skill of [0, 7, 99, 65535]) { + expect(() => assertPetSnapshot({ ...ATTACKER, skill }, 'attacker')).not.toThrow(); + } + }); + + it('rejects a streak against nobody', () => { + // The chain cannot produce this state, so hashing it would freeze a + // snapshot that reconciles with no history. + expect(() => assertPetSnapshot({ ...ATTACKER, lastOpponentId: 0n, streak: 1 }, 'attacker')).toThrow( + /streak must be 0 when lastOpponentId is 0/, + ); + }); + + it('allows a zero streak against a real previous opponent', () => { + expect(() => assertPetSnapshot({ ...ATTACKER, lastOpponentId: 5n, streak: 0 }, 'attacker')).not.toThrow(); + }); + + it('rejects a sourceVersion wider than 64 bits', () => { + expect(() => assertPetSnapshot({ ...ATTACKER, sourceVersion: 1n << 64n }, 'attacker')).toThrow( + /sourceVersion/, + ); + }); + + it('names the pet it is complaining about', () => { + expect(() => assertPetSnapshot({ ...DEFENDER, rarity: 9 }, 'defender')).toThrow(/defender.rarity/); + }); +}); + +describe('assertBattleSnapshot', () => { + it('accepts a valid snapshot', () => { + expect(() => assertBattleSnapshot(SNAPSHOT)).not.toThrow(); + }); + + it('rejects a pet fighting itself', () => { + expect(() => assertBattleSnapshot({ ...SNAPSHOT, defender: { ...DEFENDER, petId: ATTACKER.petId } })).toThrow( + /cannot fight itself/, + ); + }); + + it('rejects an invalid domain', () => { + expect(() => + assertBattleSnapshot({ ...SNAPSHOT, domain: { ...SNAPSHOT.domain, deploymentId: 'Live' } }), + ).toThrow(/invalid deploymentId/); + }); + + it('rejects takenAt of 0', () => { + expect(() => assertBattleSnapshot({ ...SNAPSHOT, takenAt: 0 })).toThrow(/takenAt/); + }); +}); + +describe('isBattleReady', () => { + it('takes the time as an argument', () => { + expect(isBattleReady(ATTACKER, ATTACKER.readyAt - 1)).toBe(false); + expect(isBattleReady(ATTACKER, ATTACKER.readyAt)).toBe(true); + }); + + it('lets a verifier check cooldown from the receipt alone', () => { + expect(isBattleReady(SNAPSHOT.attacker, SNAPSHOT.takenAt)).toBe(true); + expect(isBattleReady(SNAPSHOT.defender, SNAPSHOT.takenAt)).toBe(true); + }); +}); + +describe('snapshot completeness', () => { + it('carries every input the fight function needs', () => { + // The point of the snapshot: a fight is reproducible from it plus a seed, + // with nothing read live. If this ever needs a field the snapshot lacks, + // this test is where that shows up. + const outcome = simulate( + SNAPSHOT.attacker.dna, + SNAPSHOT.attacker.rarity, + SNAPSHOT.attacker.level, + SNAPSHOT.attacker.skill, + SNAPSHOT.defender.dna, + SNAPSHOT.defender.rarity, + SNAPSHOT.defender.level, + SNAPSHOT.defender.skill, + 1n, + ); + expect(outcome.result.rounds).toBeGreaterThan(0); + expect(outcome.log.length).toBeGreaterThan(0); + }); +}); diff --git a/protocol/tests/snapshot/vectors.test.ts b/protocol/tests/snapshot/vectors.test.ts new file mode 100644 index 00000000..ddb07bf1 --- /dev/null +++ b/protocol/tests/snapshot/vectors.test.ts @@ -0,0 +1,121 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import type { ChainId } from '../../src/domain/chainId'; +import { type BattleSnapshot, hashBattleSnapshot, type PetSnapshot } from '../../src/snapshot'; + +/** + * Consumes contracts/test-vectors/protocol-snapshot.json. A failure means the + * encoding drifted, and the fix is the code, never the vector (`AGENTS.md`). + */ +interface PetFixture { + petId: string; + owner: string; + dna: string; + rarity: number; + level: number; + skill: number; + xp: number; + lastOpponentId: string; + streak: number; + readyAt: number; + sourceVersion: string; +} + +interface SnapshotCase { + name: string; + note: string; + snapshot: { + chainId: string; + deploymentId: string; + attacker: PetFixture; + defender: PetFixture; + takenAt: number; + }; + expectedSnapshotHash: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const vectorsPath = join(here, '../../../contracts/test-vectors/protocol-snapshot.json'); +const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')) as { cases: SnapshotCase[] }; + +function toPet(fixture: PetFixture): PetSnapshot { + return { + petId: BigInt(fixture.petId), + owner: fixture.owner, + dna: BigInt(fixture.dna), + rarity: fixture.rarity, + level: fixture.level, + skill: fixture.skill, + xp: fixture.xp, + lastOpponentId: BigInt(fixture.lastOpponentId), + streak: fixture.streak, + readyAt: fixture.readyAt, + sourceVersion: BigInt(fixture.sourceVersion), + }; +} + +function toSnapshot(c: SnapshotCase): BattleSnapshot { + return { + domain: { chainId: c.snapshot.chainId as ChainId, deploymentId: c.snapshot.deploymentId }, + attacker: toPet(c.snapshot.attacker), + defender: toPet(c.snapshot.defender), + takenAt: c.snapshot.takenAt, + }; +} + +const byName = new Map(vectors.cases.map((c) => [c.name, c])); +const hashOf = (name: string) => { + const found = byName.get(name); + if (!found) throw new Error(`vector case missing: ${name}`); + return hashBattleSnapshot(toSnapshot(found)); +}; + +describe('snapshot golden vectors', () => { + for (const c of vectors.cases) { + it(`matches the recorded hash for "${c.name}"`, () => { + expect(hashBattleSnapshot(toSnapshot(c))).toBe(c.expectedSnapshotHash); + }); + } +}); + +describe('relationships the vectors exist to pin', () => { + it('treats an EVM owner address as case-insensitive', () => { + expect(hashOf('evm-checksummed-owner')).toBe(hashOf('evm-baseline')); + }); + + it('is not symmetric in the two roles', () => { + // The result is stated from the attacker's perspective, so which pet is + // which has to be part of the hash. + expect(hashOf('evm-roles-swapped')).not.toBe(hashOf('evm-baseline')); + }); + + it('separates a level-up, which is the front-run this object exists to stop', () => { + expect(hashOf('evm-level-up')).not.toBe(hashOf('evm-baseline')); + }); + + it('separates an advanced streak, since streak is an XP input', () => { + expect(hashOf('evm-streak-advanced')).not.toBe(hashOf('evm-baseline')); + }); + + it('separates the same pet state read at a different chain version', () => { + expect(hashOf('evm-other-source-version')).not.toBe(hashOf('evm-baseline')); + }); + + it('separates snapshots taken a second apart', () => { + expect(hashOf('evm-later-takenAt')).not.toBe(hashOf('evm-baseline')); + }); + + it('separates chains', () => { + expect(hashOf('solana-baseline')).not.toBe(hashOf('evm-baseline')); + }); + + it('produces a distinct hash for every case except the casing pair', () => { + const hashes = vectors.cases + .filter((c) => c.name !== 'evm-checksummed-owner') + .map((c) => c.expectedSnapshotHash); + expect(new Set(hashes).size).toBe(hashes.length); + }); +}); From f491e717405733b1caf0afcb14ee92092e618d4b Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 07:27:11 -0400 Subject: [PATCH 11/76] feat(protocol): derive battle seed from drand randomness with domain separation --- contracts/test-vectors/protocol-seed.json | 135 ++++++++++++++++ docs/plan-backend-battle-architecture.md | 24 ++- protocol/scripts/gen-vectors.ts | 107 +++++++++++++ protocol/src/index.ts | 1 + protocol/src/randomness/index.ts | 7 + protocol/src/randomness/seed.ts | 93 +++++++++++ protocol/tests/randomness/seed.test.ts | 178 ++++++++++++++++++++++ 7 files changed, 538 insertions(+), 7 deletions(-) create mode 100644 contracts/test-vectors/protocol-seed.json create mode 100644 protocol/src/randomness/index.ts create mode 100644 protocol/src/randomness/seed.ts create mode 100644 protocol/tests/randomness/seed.test.ts diff --git a/contracts/test-vectors/protocol-seed.json b/contracts/test-vectors/protocol-seed.json new file mode 100644 index 00000000..2681ff68 --- /dev/null +++ b/contracts/test-vectors/protocol-seed.json @@ -0,0 +1,135 @@ +{ + "description": "Battle seed derivation vectors (docs/plan-backend-battle-architecture.md §E). Generated by protocol/scripts/gen-vectors.ts from protocol/src/randomness. The layout is length-prefixed via the canonical encoder rather than the bare concatenation §E sketches; field order matches §E exactly. A failure means the implementation drifted, and every historical battle depends on this layout. Never edit an expectation to match new output.", + "cases": [ + { + "name": "baseline", + "note": "Reference derivation. Note the randomness here is synthetic: a recorded quicknet round lands with beacon verification.", + "inputs": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "drandRandomness": "0x1f2e3d4c5b6a798897a6b5c4d3e2f100012233445566778899aabbccddeeff01", + "battleId": "btl_01hq8z0000000000000000", + "snapshotHash": "0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab" + }, + "expectedSeed": "0x9bf3b3d8ded98076028151c27d96120b7ac54215843447ee41f05dc10ef96116" + }, + { + "name": "randomness-one-bit", + "note": "Baseline with the final bit of the beacon value flipped. Must differ, and must differ everywhere, not just in the last byte.", + "inputs": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "drandRandomness": "0x1f2e3d4c5b6a798897a6b5c4d3e2f100012233445566778899aabbccddeeff00", + "battleId": "btl_01hq8z0000000000000000", + "snapshotHash": "0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab" + }, + "expectedSeed": "0x2e1fdb267cd7401cbb3b000cae04651820d0eed04d5eaafb22a4abd84f137b5b" + }, + { + "name": "randomness-max", + "note": "All-ones beacon value, so the 32-byte field is exercised at its bound.", + "inputs": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "drandRandomness": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "battleId": "btl_01hq8z0000000000000000", + "snapshotHash": "0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab" + }, + "expectedSeed": "0x625efc989bbe456e8841cb7cbd0186776187ec8241050b856589c682e725fcb1" + }, + { + "name": "other-battle-id", + "note": "Same beacon round, different battle. Must differ: one round seeds every battle bound to it, so the battle id is what separates them.", + "inputs": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "drandRandomness": "0x1f2e3d4c5b6a798897a6b5c4d3e2f100012233445566778899aabbccddeeff01", + "battleId": "btl_01hq8z0000000000000001", + "snapshotHash": "0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab" + }, + "expectedSeed": "0x31ecf8a726474ae30b53563fc3ce5bd9ff37cf631562c1ff2a2458dd6f9abbe2" + }, + { + "name": "other-snapshot", + "note": "Same round and battle id, different frozen pets. Must differ.", + "inputs": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "drandRandomness": "0x1f2e3d4c5b6a798897a6b5c4d3e2f100012233445566778899aabbccddeeff01", + "battleId": "btl_01hq8z0000000000000000", + "snapshotHash": "0x6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c6c", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab" + }, + "expectedSeed": "0x29b599595b186d0ed6001a5ba84a3f1939493ede96b3f18d310de91a5b167d6b" + }, + { + "name": "other-ruleset", + "note": "Same everything, different ruleset. Must differ: replaying under new rules must not reuse the old seed.", + "inputs": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "drandRandomness": "0x1f2e3d4c5b6a798897a6b5c4d3e2f100012233445566778899aabbccddeeff01", + "battleId": "btl_01hq8z0000000000000000", + "snapshotHash": "0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b", + "rulesetHash": "0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + }, + "expectedSeed": "0x600c92b9dd1728ef01aa593c79c527e5ee1af161392eac55f09447fffb5443cb" + }, + { + "name": "staging-deployment", + "note": "Same round on the same chain, different deployment. Must differ.", + "inputs": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-staging", + "drandRandomness": "0x1f2e3d4c5b6a798897a6b5c4d3e2f100012233445566778899aabbccddeeff01", + "battleId": "btl_01hq8z0000000000000000", + "snapshotHash": "0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab" + }, + "expectedSeed": "0xee63ae26c5767b399b1857eed79fc1a878690f7269d47356c275da231e19a0eb" + }, + { + "name": "solana-chain", + "note": "Same round, other chain. Must differ.", + "inputs": { + "chainId": "solana:devnet", + "deploymentId": "base-sepolia-live", + "drandRandomness": "0x1f2e3d4c5b6a798897a6b5c4d3e2f100012233445566778899aabbccddeeff01", + "battleId": "btl_01hq8z0000000000000000", + "snapshotHash": "0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab" + }, + "expectedSeed": "0x1e01f20be55655e0e4cb92a83f80c587ad0765132d9adcefad9bb7ef1f63d267" + }, + { + "name": "framing-ambiguity-a", + "note": "Pairs with framing-ambiguity-b: deployment \"ab\" + battle id \"c\" versus \"a\" + \"bc\". Bare concatenation would give these one seed; length-prefixed framing must give them two.", + "inputs": { + "chainId": "eip155:84532", + "deploymentId": "ab", + "drandRandomness": "0x1f2e3d4c5b6a798897a6b5c4d3e2f100012233445566778899aabbccddeeff01", + "battleId": "c", + "snapshotHash": "0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab" + }, + "expectedSeed": "0xa45b722b91db7749b1d2b15f5865e1ec82e5666f9babf54b770086b7caea27e2" + }, + { + "name": "framing-ambiguity-b", + "note": "See framing-ambiguity-a.", + "inputs": { + "chainId": "eip155:84532", + "deploymentId": "a", + "drandRandomness": "0x1f2e3d4c5b6a798897a6b5c4d3e2f100012233445566778899aabbccddeeff01", + "battleId": "bc", + "snapshotHash": "0x5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b", + "rulesetHash": "0xabababababababababababababababababababababababababababababababab" + }, + "expectedSeed": "0x442204e621c68b2f3b8bc6b045688a3732d27ac9fbd6588b8f2fa4231a3da34e" + } + ] +} diff --git a/docs/plan-backend-battle-architecture.md b/docs/plan-backend-battle-architecture.md index 470739b4..9f349c90 100644 --- a/docs/plan-backend-battle-architecture.md +++ b/docs/plan-backend-battle-architecture.md @@ -332,16 +332,26 @@ This is a real design decision. Without it, backend ranked mode is online-only. 6. Derive the seed with domain separation: ```text -battleSeed = keccak256( - "CRYPTOPETS_BATTLE_V1" || - chainId || deploymentId || - drandRandomness || - battleId || - snapshotHash || +battleSeed = keccak256(canonical( + "CRYPTOPETS_BATTLE_V1", + chainId, deploymentId, + drandRandomness, + battleId, + snapshotHash, rulesetHash -) +)) ``` +`canonical(...)` is the encoder in `protocol/src/encoding/writer.ts`: fixed-width integers, and a +4-byte length prefix on every variable-length element. Field order is as listed. This is not bare +`||` concatenation, and the difference matters: concatenated without prefixes, deployment `ab` with +battle id `c` produces the same preimage as `a` with `bc`, so a boundary an attacker can move is a +seed they can reach twice. `contracts/test-vectors/protocol-seed.json` carries that exact pair as a +case. + +The tag is the version. A future derivation gets a new tag, which leaves every historical seed +derivable under the old one, so there is no schema-version field here. + Never derive randomness from timestamps, UUIDs, backend secrets, or a round chosen after its value was known. diff --git a/protocol/scripts/gen-vectors.ts b/protocol/scripts/gen-vectors.ts index 4b891e26..8b5f6d66 100644 --- a/protocol/scripts/gen-vectors.ts +++ b/protocol/scripts/gen-vectors.ts @@ -24,6 +24,7 @@ import { import type { ChainId } from '../src/domain/chainId'; import type { Hex } from '../src/encoding/bytes'; import { battleIntentSolanaMessage, type BattleIntent, hashBattleIntent } from '../src/intent'; +import { deriveBattleSeed, type SeedInputs } from '../src/randomness'; import { type BattleSnapshot, hashBattleSnapshot, type PetSnapshot } from '../src/snapshot'; const VECTORS_DIR = join(dirname(fileURLToPath(import.meta.url)), '../../contracts/test-vectors'); @@ -477,6 +478,112 @@ function writeSnapshotVectors(): void { process.stdout.write(`wrote ${out.cases.length} snapshot cases to ${path}\n`); } +/** Serializable form of the seed inputs. */ +interface SeedFixture { + chainId: string; + deploymentId: string; + drandRandomness: string; + battleId: string; + snapshotHash: string; + rulesetHash: string; +} + +const RANDOMNESS = '0x1f2e3d4c5b6a798897a6b5c4d3e2f1000122334455667788' + '99aabbccddeeff01'; +const SNAPSHOT_HASH = `0x${'5b'.repeat(32)}`; + +const SEED_BASE: SeedFixture = { + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + drandRandomness: RANDOMNESS, + battleId: 'btl_01hq8z0000000000000000', + snapshotHash: SNAPSHOT_HASH, + rulesetHash: RULESET_HASH, +}; + +const seedCases: { name: string; note: string; inputs: SeedFixture }[] = [ + { + name: 'baseline', + note: 'Reference derivation. Note the randomness here is synthetic: a recorded quicknet round lands with beacon verification.', + inputs: SEED_BASE, + }, + { + name: 'randomness-one-bit', + note: 'Baseline with the final bit of the beacon value flipped. Must differ, and must differ everywhere, not just in the last byte.', + inputs: { + ...SEED_BASE, + drandRandomness: '0x1f2e3d4c5b6a798897a6b5c4d3e2f1000122334455667788' + '99aabbccddeeff00', + }, + }, + { + name: 'randomness-max', + note: 'All-ones beacon value, so the 32-byte field is exercised at its bound.', + inputs: { ...SEED_BASE, drandRandomness: `0x${'ff'.repeat(32)}` }, + }, + { + name: 'other-battle-id', + note: 'Same beacon round, different battle. Must differ: one round seeds every battle bound to it, so the battle id is what separates them.', + inputs: { ...SEED_BASE, battleId: 'btl_01hq8z0000000000000001' }, + }, + { + name: 'other-snapshot', + note: 'Same round and battle id, different frozen pets. Must differ.', + inputs: { ...SEED_BASE, snapshotHash: `0x${'6c'.repeat(32)}` }, + }, + { + name: 'other-ruleset', + note: 'Same everything, different ruleset. Must differ: replaying under new rules must not reuse the old seed.', + inputs: { ...SEED_BASE, rulesetHash: `0x${'cd'.repeat(32)}` }, + }, + { + name: 'staging-deployment', + note: 'Same round on the same chain, different deployment. Must differ.', + inputs: { ...SEED_BASE, deploymentId: 'base-sepolia-staging' }, + }, + { + name: 'solana-chain', + note: 'Same round, other chain. Must differ.', + inputs: { ...SEED_BASE, chainId: 'solana:devnet' }, + }, + { + name: 'framing-ambiguity-a', + note: 'Pairs with framing-ambiguity-b: deployment "ab" + battle id "c" versus "a" + "bc". Bare concatenation would give these one seed; length-prefixed framing must give them two.', + inputs: { ...SEED_BASE, deploymentId: 'ab', battleId: 'c' }, + }, + { + name: 'framing-ambiguity-b', + note: 'See framing-ambiguity-a.', + inputs: { ...SEED_BASE, deploymentId: 'a', battleId: 'bc' }, + }, +]; + +/** Rebuilds runtime seed inputs from a fixture. */ +export function seedInputsFromFixture(fixture: SeedFixture): SeedInputs { + return { + domain: { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }, + drandRandomness: fixture.drandRandomness as Hex, + battleId: fixture.battleId, + snapshotHash: fixture.snapshotHash as Hex, + rulesetHash: fixture.rulesetHash as Hex, + }; +} + +function writeSeedVectors(): void { + const out = { + description: + 'Battle seed derivation vectors (docs/plan-backend-battle-architecture.md §E). Generated by protocol/scripts/gen-vectors.ts from protocol/src/randomness. The layout is length-prefixed via the canonical encoder rather than the bare concatenation §E sketches; field order matches §E exactly. A failure means the implementation drifted, and every historical battle depends on this layout. Never edit an expectation to match new output.', + cases: seedCases.map((c) => ({ + name: c.name, + note: c.note, + inputs: c.inputs, + expectedSeed: deriveBattleSeed(seedInputsFromFixture(c.inputs)).hex, + })), + }; + const path = join(VECTORS_DIR, 'protocol-seed.json'); + writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`); + process.stdout.write(`wrote ${out.cases.length} seed cases to ${path}\n`); +} + writeIntentVectors(); writeConsentVectors(); writeSnapshotVectors(); +writeSeedVectors(); diff --git a/protocol/src/index.ts b/protocol/src/index.ts index 24253177..7075bd19 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -16,4 +16,5 @@ export * from './consent'; export * from './domain'; export * from './encoding'; export * from './intent'; +export * from './randomness'; export * from './snapshot'; diff --git a/protocol/src/randomness/index.ts b/protocol/src/randomness/index.ts new file mode 100644 index 00000000..9e233775 --- /dev/null +++ b/protocol/src/randomness/index.ts @@ -0,0 +1,7 @@ +export { + type BattleSeed, + deriveBattleSeed, + DRAND_RANDOMNESS_LENGTH, + encodeSeedInputs, + type SeedInputs, +} from './seed'; diff --git a/protocol/src/randomness/seed.ts b/protocol/src/randomness/seed.ts new file mode 100644 index 00000000..bc5f3791 --- /dev/null +++ b/protocol/src/randomness/seed.ts @@ -0,0 +1,93 @@ +import { assertProtocolDomain, type ProtocolDomain } from '../domain/deployment'; +import { type Hex, toBytes } from '../encoding/bytes'; +import { DOMAIN_TAGS } from '../encoding/domain'; +import { keccak256Hex } from '../encoding/hash'; +import { CanonicalWriter } from '../encoding/writer'; + +/** + * Everything the battle seed is derived from. + * + * All of these are fixed before the beacon value exists, and the beacon value + * itself is pinned by the commitment naming its round in advance. So nobody, + * including us, can steer the seed: we cannot choose the randomness (drand + * publishes it) and we cannot choose which randomness applies (the signed + * commitment already said which round). + */ +export interface SeedInputs { + domain: ProtocolDomain; + /** The 32-byte `randomness` of the committed drand round. */ + drandRandomness: Hex | Uint8Array; + /** Ledger id of this battle. */ + battleId: string; + snapshotHash: Hex; + rulesetHash: Hex; +} + +/** A derived seed, in both forms callers need. */ +export interface BattleSeed { + /** 0x-hex, as stored in the receipt. */ + hex: Hex; + /** The same value as the uint256 `simulate()` takes. */ + value: bigint; +} + +/** Length of a drand `randomness` value. */ +export const DRAND_RANDOMNESS_LENGTH = 32; + +const SAFE_BATTLE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/; + +/** + * The exact bytes the seed is hashed over. Exported because a seed mismatch + * between two implementations is otherwise a 32-byte shrug: comparing preimages + * says which field diverged. + * + * Two notes on the layout, since it differs from the pseudocode in §E of the + * architecture document. + * + * First, framing. §E writes the derivation as `keccak256(tag || chainId || ...)`, + * which reads as bare concatenation. This goes through the canonical encoder + * instead, so every element is length-prefixed. Bare concatenation of + * variable-length fields is ambiguous: deployment `ab` with battle id `c` gives + * the same bytes as `a` with `bc`, and a boundary an attacker can move is a seed + * they can reach twice. The document's field *order* is preserved exactly. + * + * Second, versioning. There is no schema-version field here, unlike the other + * hashed objects. `CRYPTOPETS_BATTLE_V1` is the version: a future derivation gets + * a new tag, which leaves every historical seed derivable under the old one. §E + * fixes this tag, so it must never change. + */ +export function encodeSeedInputs(inputs: SeedInputs): Uint8Array { + const domain = assertProtocolDomain(inputs.domain); + if (!SAFE_BATTLE_ID_PATTERN.test(inputs.battleId)) { + throw new Error(`battleId is not a valid id: ${JSON.stringify(inputs.battleId)}`); + } + return CanonicalWriter.withDomain(DOMAIN_TAGS.SEED) + .text(domain.chainId) + .text(domain.deploymentId) + .bytes(assertRandomness(inputs.drandRandomness)) + .text(inputs.battleId) + .hash(inputs.snapshotHash) + .hash(inputs.rulesetHash) + .build(); +} + +/** + * Derives the battle seed (§E). + * + * The domain, snapshot, and ruleset all feed in so one beacon round cannot produce + * the same seed for two battles, two deployments, or two rulesets. drand publishes + * one value per round to the entire world; this derivation is what makes our use + * of it specific to one fight. + */ +export function deriveBattleSeed(inputs: SeedInputs): BattleSeed { + const hex = keccak256Hex(encodeSeedInputs(inputs)); + return { hex, value: BigInt(hex) }; +} + +function assertRandomness(value: Hex | Uint8Array): Uint8Array { + const bytes = toBytes(value); + if (bytes.length !== DRAND_RANDOMNESS_LENGTH) { + throw new Error(`drandRandomness must be ${DRAND_RANDOMNESS_LENGTH} bytes, got ${bytes.length}`); + } + return bytes; +} diff --git a/protocol/tests/randomness/seed.test.ts b/protocol/tests/randomness/seed.test.ts new file mode 100644 index 00000000..599e27d1 --- /dev/null +++ b/protocol/tests/randomness/seed.test.ts @@ -0,0 +1,178 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { simulate } from '../../src/combat'; +import type { ChainId } from '../../src/domain/chainId'; +import { bytesToHex, type Hex } from '../../src/encoding/bytes'; +import { deriveBattleSeed, DRAND_RANDOMNESS_LENGTH, encodeSeedInputs, type SeedInputs } from '../../src/randomness'; + +interface SeedFixture { + chainId: string; + deploymentId: string; + drandRandomness: string; + battleId: string; + snapshotHash: string; + rulesetHash: string; +} + +interface SeedCase { + name: string; + note: string; + inputs: SeedFixture; + expectedSeed: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const vectorsPath = join(here, '../../../contracts/test-vectors/protocol-seed.json'); +const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')) as { cases: SeedCase[] }; + +function toInputs(fixture: SeedFixture): SeedInputs { + return { + domain: { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }, + drandRandomness: fixture.drandRandomness as Hex, + battleId: fixture.battleId, + snapshotHash: fixture.snapshotHash as Hex, + rulesetHash: fixture.rulesetHash as Hex, + }; +} + +const byName = new Map(vectors.cases.map((c) => [c.name, c])); +const seedOf = (name: string) => { + const found = byName.get(name); + if (!found) throw new Error(`vector case missing: ${name}`); + return deriveBattleSeed(toInputs(found.inputs)).hex; +}; + +const BASE = toInputs(byName.get('baseline')!.inputs); + +describe('seed golden vectors', () => { + for (const c of vectors.cases) { + it(`matches the recorded seed for "${c.name}"`, () => { + expect(deriveBattleSeed(toInputs(c.inputs)).hex).toBe(c.expectedSeed); + }); + } +}); + +describe('relationships the vectors exist to pin', () => { + it('separates two battles bound to the same beacon round', () => { + // One round seeds every battle committed to it, so the battle id is the + // only thing keeping those fights from sharing randomness. + expect(seedOf('other-battle-id')).not.toBe(seedOf('baseline')); + }); + + it('separates deployments and chains using the same round', () => { + expect(seedOf('staging-deployment')).not.toBe(seedOf('baseline')); + expect(seedOf('solana-chain')).not.toBe(seedOf('baseline')); + }); + + it('separates snapshots and rulesets', () => { + expect(seedOf('other-snapshot')).not.toBe(seedOf('baseline')); + expect(seedOf('other-ruleset')).not.toBe(seedOf('baseline')); + }); + + it('resists the framing ambiguity bare concatenation would allow', () => { + // deployment "ab" + battle "c" versus "a" + "bc". Concatenated without + // length prefixes these are one preimage; framed they are two. + expect(seedOf('framing-ambiguity-a')).not.toBe(seedOf('framing-ambiguity-b')); + }); + + it('gives every case a distinct seed', () => { + const seeds = vectors.cases.map((c) => c.expectedSeed); + expect(new Set(seeds).size).toBe(seeds.length); + }); +}); + +describe('deriveBattleSeed', () => { + it('returns the same value in hex and as a uint256', () => { + const seed = deriveBattleSeed(BASE); + expect(seed.value).toBe(BigInt(seed.hex)); + expect(seed.value).toBeLessThan(1n << 256n); + }); + + it('is deterministic', () => { + expect(deriveBattleSeed(BASE).hex).toBe(deriveBattleSeed({ ...BASE }).hex); + }); + + it('accepts the beacon value as bytes or hex', () => { + const asBytes = new Uint8Array(DRAND_RANDOMNESS_LENGTH).fill(0xff); + expect(deriveBattleSeed({ ...BASE, drandRandomness: asBytes }).hex).toBe( + deriveBattleSeed({ ...BASE, drandRandomness: bytesToHex(asBytes) }).hex, + ); + }); + + it('avalanches on a single flipped bit of beacon randomness', () => { + // A weak derivation could let a nearly-identical beacon value produce a + // nearly-identical seed, which would make outcomes partly predictable + // across rounds. + const a = seedOf('baseline'); + const b = seedOf('randomness-one-bit'); + const differingBytes = countDifferingBytes(a, b); + expect(differingBytes).toBeGreaterThan(20); + }); + + it('feeds the simulator directly', () => { + const seed = deriveBattleSeed(BASE); + const outcome = simulate(1234567890123456n, 3, 10, 4, 6543210987654321n, 2, 11, 7, seed.value); + expect(outcome.result.rounds).toBeGreaterThan(0); + }); +}); + +describe('validation', () => { + it.each([ + [31, 'too short'], + [33, 'too long'], + [48, 'a BLS signature rather than the randomness'], + ])('rejects %s-byte randomness (%s)', (length) => { + expect(() => deriveBattleSeed({ ...BASE, drandRandomness: new Uint8Array(length) })).toThrow( + /drandRandomness must be 32 bytes/, + ); + }); + + it('rejects malformed hex randomness', () => { + expect(() => deriveBattleSeed({ ...BASE, drandRandomness: '0xabc' as Hex })).toThrow(); + }); + + it.each(['', 'battle id with spaces', 'a'.repeat(65), 'id\nwith-newline'])( + 'rejects battleId %j', + (battleId) => { + expect(() => deriveBattleSeed({ ...BASE, battleId })).toThrow(/battleId/); + }, + ); + + it('rejects a snapshot or ruleset hash that is not 32 bytes', () => { + expect(() => deriveBattleSeed({ ...BASE, snapshotHash: '0x1234' })).toThrow(/32-byte/); + expect(() => deriveBattleSeed({ ...BASE, rulesetHash: '0x1234' })).toThrow(/32-byte/); + }); + + it('rejects an invalid domain', () => { + expect(() => deriveBattleSeed({ ...BASE, domain: { ...BASE.domain, deploymentId: 'Bad Id' } })).toThrow( + /invalid deploymentId/, + ); + }); +}); + +describe('encodeSeedInputs', () => { + it('exposes the preimage so a mismatch can be localized', () => { + // Comparing 32-byte digests tells you two implementations disagree; + // comparing preimages tells you which field. + const preimage = bytesToHex(encodeSeedInputs(BASE)); + expect(preimage.startsWith('0x00000014')).toBe(true); // 20-byte domain tag + expect(preimage).toContain(BASE.drandRandomness.toString().slice(2)); + }); + + it('changes whenever the derived seed changes', () => { + const a = bytesToHex(encodeSeedInputs(BASE)); + const b = bytesToHex(encodeSeedInputs({ ...BASE, battleId: 'other-battle' })); + expect(a).not.toBe(b); + }); +}); + +function countDifferingBytes(a: string, b: string): number { + let differing = 0; + for (let i = 2; i < a.length; i += 2) { + if (a.slice(i, i + 2) !== b.slice(i, i + 2)) differing++; + } + return differing; +} From ce0347a8ea2844e6b5ccf0bcc5ca9864bea93363 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 07:39:52 -0400 Subject: [PATCH 12/76] feat(protocol): verify drand quicknet BLS beacon signatures against a pinned key --- backend/package.json | 1 + pnpm-lock.yaml | 6 + protocol/README.md | 17 ++ protocol/package.json | 1 + protocol/src/randomness/drand.ts | 221 +++++++++++++++++++++ protocol/src/randomness/index.ts | 17 ++ protocol/tests/fixtures/drand.json | 45 +++++ protocol/tests/randomness/drand.test.ts | 246 ++++++++++++++++++++++++ 8 files changed, 554 insertions(+) create mode 100644 protocol/src/randomness/drand.ts create mode 100644 protocol/tests/fixtures/drand.json create mode 100644 protocol/tests/randomness/drand.test.ts diff --git a/backend/package.json b/backend/package.json index 0d63387f..3fdc56f7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "@ai-sdk/openai": "^3.0.68", "@coral-xyz/anchor": "^0.32.0", "@grpc/grpc-js": "^1.14.4", + "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0", "@grpc/proto-loader": "^0.8.1", "@prisma/adapter-pg": "^7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bfa0753..331171bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@grpc/proto-loader': specifier: ^0.8.1 version: 0.8.1 + '@noble/curves': + specifier: ^1.9.7 + version: 1.9.7 '@noble/hashes': specifier: ^1.8.0 version: 1.8.0 @@ -487,6 +490,9 @@ importers: protocol: dependencies: + '@noble/curves': + specifier: ^1.9.7 + version: 1.9.7 '@noble/hashes': specifier: ^1.8.0 version: 1.8.0 diff --git a/protocol/README.md b/protocol/README.md index c7e6d01a..04d6e56f 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -42,6 +42,23 @@ infrastructure. That rules out more than it sounds like: - **Golden vectors for anything hashed.** Every hash and every combat rule has vectors in `contracts/test-vectors/`, so a port or a refactor that changes a byte fails loudly. +## Browser cost of beacon verification + +§E of the architecture doc requires confirming what client-side BLS verification costs before +committing to drand, because a client that takes our word for the beacon value gets nothing from +commit-before-reveal. + +Measured with esbuild (minified, `platform: browser`, `target: es2022`): + +| Entry point | Minified | Minified + gzip | +|---|---|---| +| `verifyBeacon` + pinned quicknet params (pulls in `@noble/curves` BLS12-381) | 62.5 kB | 24.2 kB | +| Encoding and hashing only (`@noble/hashes`) | 4.7 kB | 2.1 kB | + +So verification costs roughly **22 kB gzipped** on top of hashing. Against a frontend bundle already +over 2 MB gzipped, that is noise, and it is the only thing that makes the client's verification real +rather than trust. Re-measure if `@noble/curves` is upgraded. + ## Consumption Raw TypeScript, no build step, same as `@shared/core`. Workspace packages depend on it directly; diff --git a/protocol/package.json b/protocol/package.json index d761c312..35972f3d 100644 --- a/protocol/package.json +++ b/protocol/package.json @@ -19,6 +19,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" }, "devDependencies": { diff --git a/protocol/src/randomness/drand.ts b/protocol/src/randomness/drand.ts new file mode 100644 index 00000000..258e32fe --- /dev/null +++ b/protocol/src/randomness/drand.ts @@ -0,0 +1,221 @@ +import { bls12_381 } from '@noble/curves/bls12-381'; +import { sha256 } from '@noble/hashes/sha256'; + +import { bytesToHex, type Hex, toBytes, uintToBytes } from '../encoding/bytes'; + +/** + * drand beacon verification. + * + * The point of using drand is that nobody here controls the randomness. That only + * holds if the beacon value is *verified* rather than accepted: an unverified + * beacon is just a number the backend handed over, which is exactly the situation + * commit-before-reveal exists to escape. So the client verifies too (§E), against + * a pinned public key, not against a key supplied alongside the value. + * + * Only quicknet is supported, deliberately. It publishes every 3 seconds, which + * keeps time-to-first-animation in the 3-6 second range the UX needs, and its + * scheme is unchained, so a round can be verified on its own without walking the + * chain back to genesis. + */ + +/** The only scheme this protocol verifies. */ +export const SUPPORTED_SCHEME = 'bls-unchained-g1-rfc9380'; + +/** Pinned parameters of one drand chain. */ +export interface DrandChain { + /** Identifies the chain. Receipts record it so a verifier knows which config to load. */ + chainHash: Hex; + /** 96-byte compressed G2 group public key. */ + publicKey: Hex; + scheme: typeof SUPPORTED_SCHEME; + periodSeconds: number; + genesisTimeSeconds: number; +} + +/** + * drand quicknet, pinned. + * + * Verified against the live network on 2026-07-26 (`tests/fixtures/drand.json`). + * These values are consensus parameters, not configuration: a wrong public key + * here does not fail loudly, it accepts forged beacons. They must never be read + * from a receipt, an environment variable, or an API response. + */ +export const QUICKNET: DrandChain = { + chainHash: '0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971', + publicKey: + '0x83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a', + scheme: SUPPORTED_SCHEME, + periodSeconds: 3, + genesisTimeSeconds: 1692803367, +}; + +/** Every chain this build will verify, by chain hash. */ +const PINNED_CHAINS: readonly DrandChain[] = [QUICKNET]; + +/** + * How far ahead a commitment reserves its round: a constant, never a per-battle + * choice (§E). + * + * A per-battle offset would hand the operator exactly the freedom the + * pre-commitment removes, since choosing "how far ahead" repeatedly is a way of + * choosing which value lands. Two rounds on quicknet is 3-6 seconds depending on + * where in the current round the request arrives. + */ +export const COMMITMENT_OFFSET_ROUNDS = 2; + +/** Length of a quicknet signature: compressed G1. */ +export const BEACON_SIGNATURE_LENGTH = 48; +/** Length of a quicknet group public key: compressed G2. */ +const PUBLIC_KEY_LENGTH = 96; + +/** One published beacon. */ +export interface Beacon { + round: number; + /** 48-byte compressed G1 signature. */ + signature: Hex | Uint8Array; +} + +/** A beacon that has been verified against a pinned chain. */ +export interface VerifiedBeacon { + chainHash: Hex; + round: number; + signature: Hex; + /** sha256 of the signature: the value seed derivation consumes. */ + randomness: Hex; +} + +/** + * Resolves the pinned chain for a chain hash. + * + * A verifier reads `drandChainHash` from a receipt and must map it to a *pinned* + * config here. Taking the public key from the receipt instead would let whoever + * wrote the receipt choose the key that validates it, which is not verification. + */ +export function resolveDrandChain(chainHash: string): DrandChain { + // Accepts either spelling: receipts store 0x-prefixed hex, drand's own API + // returns it bare. Matching on only one form turns a formatting difference + // into "unknown chain", which reads like a security failure and is not one. + const lower = chainHash.toLowerCase(); + const normalized = lower.startsWith('0x') ? lower : `0x${lower}`; + const found = PINNED_CHAINS.find((chain) => chain.chainHash === normalized); + if (!found) { + throw new Error(`drand chain ${chainHash} is not pinned by this build; refusing to verify against it`); + } + return found; +} + +/** + * The message a quicknet round signs: sha256 of the round number as a big-endian + * uint64. Unchained, so no previous signature is mixed in, which is what lets one + * round be verified in isolation. + */ +export function beaconMessage(round: number): Uint8Array { + assertRound(round); + return sha256(uintToBytes(round, 8)); +} + +/** The randomness a signature yields: sha256 of the signature bytes. */ +export function beaconRandomness(signature: Hex | Uint8Array): Hex { + return bytesToHex(sha256(assertSignature(signature))); +} + +/** + * Verifies a beacon's BLS signature against a pinned chain. + * + * Returns false for a signature that does not verify, including a malformed one: + * from a caller's perspective "this beacon is not genuine" is one answer. Throws + * only for a misconfigured chain, which is our bug rather than a bad input. + */ +export function verifyBeacon(chain: DrandChain, beacon: Beacon): boolean { + assertChain(chain); + let signature: Uint8Array; + try { + signature = assertSignature(beacon.signature); + } catch { + return false; + } + try { + const messagePoint = bls12_381.shortSignatures.hash(beaconMessage(beacon.round)); + return bls12_381.shortSignatures.verify(signature, messagePoint, toBytes(chain.publicKey)); + } catch { + // An uncompressible point or an off-curve signature is an invalid beacon, + // not an exception the caller should have to handle separately. + return false; + } +} + +/** + * Verifies a beacon and returns it with its derived randomness, or throws. + * + * This is the function the accept-to-settle path should use: it makes "we used an + * unverified beacon" impossible to express, because the only way to get the + * randomness out is to have verified the signature that produced it. + */ +export function assertVerifiedBeacon(chain: DrandChain, beacon: Beacon): VerifiedBeacon { + if (!verifyBeacon(chain, beacon)) { + throw new Error(`drand round ${beacon.round} failed BLS verification against chain ${chain.chainHash}`); + } + const signature = bytesToHex(assertSignature(beacon.signature)); + return { + chainHash: chain.chainHash, + round: beacon.round, + signature, + randomness: beaconRandomness(signature), + }; +} + +/** Unix seconds at which `round` is published. */ +export function roundTime(chain: DrandChain, round: number): number { + assertRound(round); + return chain.genesisTimeSeconds + round * chain.periodSeconds; +} + +/** The most recent round published at `unixSeconds`, or 0 before the first. */ +export function latestRoundAt(chain: DrandChain, unixSeconds: number): number { + if (!Number.isSafeInteger(unixSeconds)) { + throw new Error(`unixSeconds must be an integer, got ${unixSeconds}`); + } + if (unixSeconds < chain.genesisTimeSeconds + chain.periodSeconds) { + return 0; + } + return Math.floor((unixSeconds - chain.genesisTimeSeconds) / chain.periodSeconds); +} + +/** + * The round a commitment made at `nowSeconds` must name: the latest published + * round plus the fixed offset. Mechanical by construction, so there is no decision + * to audit. + */ +export function commitmentRound(chain: DrandChain, nowSeconds: number): number { + return latestRoundAt(chain, nowSeconds) + COMMITMENT_OFFSET_ROUNDS; +} + +function assertChain(chain: DrandChain): void { + if (chain.scheme !== SUPPORTED_SCHEME) { + // A chained chain needs the previous signature in its message, so verifying + // it with this code would be wrong rather than merely unsupported. + throw new Error(`unsupported drand scheme ${chain.scheme}; only ${SUPPORTED_SCHEME} is verifiable here`); + } + if (toBytes(chain.publicKey).length !== PUBLIC_KEY_LENGTH) { + throw new Error(`drand public key must be ${PUBLIC_KEY_LENGTH} bytes (compressed G2)`); + } + if (!Number.isSafeInteger(chain.periodSeconds) || chain.periodSeconds < 1) { + throw new Error(`drand period must be a positive integer, got ${chain.periodSeconds}`); + } +} + +function assertRound(round: number): void { + if (!Number.isSafeInteger(round) || round < 1) { + throw new Error(`drand round must be a positive integer, got ${round}`); + } +} + +function assertSignature(signature: Hex | Uint8Array): Uint8Array { + const bytes = toBytes(signature); + if (bytes.length !== BEACON_SIGNATURE_LENGTH) { + throw new Error( + `beacon signature must be ${BEACON_SIGNATURE_LENGTH} bytes (compressed G1), got ${bytes.length}`, + ); + } + return bytes; +} diff --git a/protocol/src/randomness/index.ts b/protocol/src/randomness/index.ts index 9e233775..a5b7c294 100644 --- a/protocol/src/randomness/index.ts +++ b/protocol/src/randomness/index.ts @@ -1,3 +1,20 @@ +export { + type Beacon, + BEACON_SIGNATURE_LENGTH, + beaconMessage, + beaconRandomness, + COMMITMENT_OFFSET_ROUNDS, + commitmentRound, + type DrandChain, + assertVerifiedBeacon, + latestRoundAt, + QUICKNET, + resolveDrandChain, + roundTime, + SUPPORTED_SCHEME, + verifyBeacon, + type VerifiedBeacon, +} from './drand'; export { type BattleSeed, deriveBattleSeed, diff --git a/protocol/tests/fixtures/drand.json b/protocol/tests/fixtures/drand.json new file mode 100644 index 00000000..5f11ff4d --- /dev/null +++ b/protocol/tests/fixtures/drand.json @@ -0,0 +1,45 @@ +{ + "description": "Real drand data, fetched 2026-07-26, used to test beacon verification against the live network's own output rather than against values this repo produced. Chain info from https://api.drand.sh/v2/beacons/{quicknet,default}/info; signatures from /v2/beacons/quicknet/rounds/{n}; the `randomness` values come from drand's v1 endpoint (https://api.drand.sh//public/), which publishes them directly, so they independently pin our sha256(signature) derivation instead of restating it.", + "quicknet": { + "info": { + "public_key": "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a", + "period": 3, + "genesis_time": 1692803367, + "chain_hash": "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "scheme": "bls-unchained-g1-rfc9380", + "beacon_id": "quicknet" + }, + "rounds": [ + { + "round": 1, + "signature": "b55e7cb2d5c613ee0b2e28d6750aabbb78c39dcc96bd9d38c2c2e12198df95571de8e8e402a0cc48871c7089a2b3af4b", + "randomness": "1466a6cd24e327188770752f6134001c64d6efcc590ccc26b721611ad96f165a" + }, + { + "round": 1000, + "signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "fe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" + }, + { + "round": 21000000, + "signature": "971cbe88adc436f6411fd26d51887ede7ba144264cd05edec6645b5e170a7702d16082947a85d89c89cb47cd8eb7d817", + "randomness": "36ecd957580ee415f951370e2a5e13273be97de9072418aaf14d38242979e3c1" + } + ] + }, + "chainedChain": { + "note": "The original drand mainnet chain. Chained scheme, 30s period, G1 public key and G2 signatures, so nothing about it is compatible with quicknet. Present so tests can prove an unpinned chain is refused rather than mis-verified.", + "info": { + "public_key": "868f005eb8e6e4ca0a47c8a77ceaa5309a47978a7c71bc5cce96366b5d7a569937c529eeda66c7293784a9402801af31", + "period": 30, + "genesis_time": 1595431050, + "chain_hash": "8990e7a9aaed2ffed73dbd7092123d6f289930540d7651336225dc172e51b2ce", + "scheme": "pedersen-bls-chained", + "beacon_id": "default" + }, + "round": { + "round": 1000000, + "signature": "87e355169c4410a8ad6d3e7f5094b2122932c1062f603e6628aba2e4cb54f46c3bf1083c3537cd3b99e8296784f46fb40e090961cf9634f02c7dc2a96b69fc3c03735bc419962780a71245b72f81882cf6bb9c961bcf32da5624993bb747c9e5" + } + } +} diff --git a/protocol/tests/randomness/drand.test.ts b/protocol/tests/randomness/drand.test.ts new file mode 100644 index 00000000..ad4a4e19 --- /dev/null +++ b/protocol/tests/randomness/drand.test.ts @@ -0,0 +1,246 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { bytesToHex, type Hex } from '../../src/encoding/bytes'; +import { + assertVerifiedBeacon, + beaconMessage, + beaconRandomness, + COMMITMENT_OFFSET_ROUNDS, + commitmentRound, + type DrandChain, + latestRoundAt, + QUICKNET, + resolveDrandChain, + roundTime, + SUPPORTED_SCHEME, + verifyBeacon, +} from '../../src/randomness'; + +/** + * Tests against real drand output (`tests/fixtures/drand.json`, fetched 2026-07-26). + * + * Synthetic keypairs would prove the BLS plumbing works while leaving the part + * that actually matters untested: whether the pinned quicknet public key, the + * message construction, and the hash-to-curve domain all match the live network. + * Any one of those being wrong fails every real round and no synthetic one. + */ +interface Fixture { + quicknet: { + info: { + public_key: string; + period: number; + genesis_time: number; + chain_hash: string; + scheme: string; + }; + rounds: { round: number; signature: string; randomness: string }[]; + }; + chainedChain: { + info: { public_key: string; chain_hash: string; scheme: string; period: number; genesis_time: number }; + round: { round: number; signature: string }; + }; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const fixture = JSON.parse(readFileSync(join(here, '../fixtures/drand.json'), 'utf8')) as Fixture; +const hex = (value: string): Hex => `0x${value}`; + +describe('pinned quicknet parameters', () => { + it('match the live chain info', () => { + // If this fails, either the pinned constants were edited or drand changed + // its parameters. Either way, nothing should verify until it is understood. + expect(QUICKNET.chainHash).toBe(hex(fixture.quicknet.info.chain_hash)); + expect(QUICKNET.publicKey).toBe(hex(fixture.quicknet.info.public_key)); + expect(QUICKNET.periodSeconds).toBe(fixture.quicknet.info.period); + expect(QUICKNET.genesisTimeSeconds).toBe(fixture.quicknet.info.genesis_time); + expect(QUICKNET.scheme).toBe(fixture.quicknet.info.scheme); + expect(SUPPORTED_SCHEME).toBe('bls-unchained-g1-rfc9380'); + }); +}); + +describe('verifyBeacon', () => { + for (const round of fixture.quicknet.rounds) { + it(`verifies real quicknet round ${round.round}`, () => { + expect(verifyBeacon(QUICKNET, { round: round.round, signature: hex(round.signature) })).toBe(true); + }); + } + + it('rejects a signature presented under the wrong round number', () => { + // The round number is the message, so this is the check that stops a real + // signature being replayed as a different round's value. + const [first, second] = fixture.quicknet.rounds; + expect(verifyBeacon(QUICKNET, { round: second!.round, signature: hex(first!.signature) })).toBe(false); + }); + + it('rejects a tampered signature', () => { + const original = fixture.quicknet.rounds[0]!; + const bytes = Uint8Array.from(Buffer.from(original.signature, 'hex')); + bytes.set([bytes[47]! ^ 0x01], 47); + expect(verifyBeacon(QUICKNET, { round: original.round, signature: bytesToHex(bytes) })).toBe(false); + }); + + it('rejects a malformed signature without throwing', () => { + // A bad input is "not a genuine beacon", one of the two answers this + // function has, not an exception every call site must handle. + expect(verifyBeacon(QUICKNET, { round: 1, signature: '0x00' })).toBe(false); + expect(verifyBeacon(QUICKNET, { round: 1, signature: new Uint8Array(48) })).toBe(false); + }); + + it('rejects a signature from another chain', () => { + const other = fixture.chainedChain.round; + expect(verifyBeacon(QUICKNET, { round: other.round, signature: hex(other.signature) })).toBe(false); + }); + + it('throws for a chain whose scheme this build cannot verify', () => { + // Chained schemes mix the previous signature into the message, so verifying + // one with this code would be wrong rather than unsupported. + const chained = { + ...QUICKNET, + scheme: fixture.chainedChain.info.scheme, + } as unknown as DrandChain; + expect(() => verifyBeacon(chained, { round: 1, signature: hex(fixture.quicknet.rounds[0]!.signature) })).toThrow( + /unsupported drand scheme/, + ); + }); + + it('throws for a public key of the wrong length', () => { + const bad = { ...QUICKNET, publicKey: hex(fixture.chainedChain.info.public_key) }; + expect(() => verifyBeacon(bad, { round: 1, signature: hex(fixture.quicknet.rounds[0]!.signature) })).toThrow( + /must be 96 bytes/, + ); + }); +}); + +describe('beaconRandomness', () => { + for (const round of fixture.quicknet.rounds) { + it(`derives the randomness drand itself publishes for round ${round.round}`, () => { + // Independently sourced: these values come from drand's own API, not + // from this implementation, so they pin sha256(signature) rather than + // restating whatever we compute. + expect(beaconRandomness(hex(round.signature))).toBe(hex(round.randomness)); + }); + } + + it('rejects a signature of the wrong length', () => { + expect(() => beaconRandomness(new Uint8Array(32))).toThrow(/48 bytes/); + }); +}); + +describe('assertVerifiedBeacon', () => { + it('returns the verified beacon with its randomness', () => { + const round = fixture.quicknet.rounds[1]!; + expect(assertVerifiedBeacon(QUICKNET, { round: round.round, signature: hex(round.signature) })).toEqual({ + chainHash: QUICKNET.chainHash, + round: round.round, + signature: hex(round.signature), + randomness: hex(round.randomness), + }); + }); + + it('throws rather than returning randomness for an unverified beacon', () => { + // The only way to obtain randomness through this function is to have + // verified the signature that produced it, so "we used an unverified + // beacon" is not expressible. + expect(() => assertVerifiedBeacon(QUICKNET, { round: 2, signature: hex(fixture.quicknet.rounds[0]!.signature) })).toThrow( + /failed BLS verification/, + ); + }); +}); + +describe('resolveDrandChain', () => { + it('resolves quicknet whether the hash is prefixed, bare, or uppercase', () => { + // Receipts carry 0x-prefixed hex; drand's API returns it bare. Both have to + // resolve, or a formatting difference reads like an unknown chain. + expect(resolveDrandChain(QUICKNET.chainHash)).toBe(QUICKNET); + expect(resolveDrandChain(fixture.quicknet.info.chain_hash)).toBe(QUICKNET); + expect(resolveDrandChain(fixture.quicknet.info.chain_hash.toUpperCase())).toBe(QUICKNET); + expect(resolveDrandChain(QUICKNET.chainHash.toUpperCase().replace('0X', '0x'))).toBe(QUICKNET); + }); + + it('refuses a chain this build does not pin', () => { + // A verifier reads the chain hash from a receipt. Accepting an unknown one, + // with a public key from the same receipt, would let the receipt's author + // choose the key that validates it. + expect(() => resolveDrandChain(hex(fixture.chainedChain.info.chain_hash))).toThrow(/is not pinned/); + }); +}); + +describe('round timing', () => { + it('places round n at genesis + n * period', () => { + expect(roundTime(QUICKNET, 1)).toBe(QUICKNET.genesisTimeSeconds + 3); + expect(roundTime(QUICKNET, 1000)).toBe(QUICKNET.genesisTimeSeconds + 3000); + }); + + it('round-trips through latestRoundAt', () => { + for (const round of [1, 2, 1000, 21000000, 30753975]) { + expect(latestRoundAt(QUICKNET, roundTime(QUICKNET, round))).toBe(round); + } + }); + + it('holds the round steady across its whole period', () => { + const time = roundTime(QUICKNET, 500); + expect(latestRoundAt(QUICKNET, time)).toBe(500); + expect(latestRoundAt(QUICKNET, time + 1)).toBe(500); + expect(latestRoundAt(QUICKNET, time + 2)).toBe(500); + expect(latestRoundAt(QUICKNET, time + 3)).toBe(501); + }); + + it('reports 0 before the first round', () => { + expect(latestRoundAt(QUICKNET, QUICKNET.genesisTimeSeconds)).toBe(0); + expect(latestRoundAt(QUICKNET, 0)).toBe(0); + }); + + it('rejects a non-integer round or time', () => { + expect(() => roundTime(QUICKNET, 1.5)).toThrow(/positive integer/); + expect(() => roundTime(QUICKNET, 0)).toThrow(/positive integer/); + expect(() => latestRoundAt(QUICKNET, 1.5)).toThrow(/must be an integer/); + }); +}); + +describe('commitmentRound', () => { + it('is the latest round plus a fixed offset', () => { + const now = roundTime(QUICKNET, 1000); + expect(commitmentRound(QUICKNET, now)).toBe(1000 + COMMITMENT_OFFSET_ROUNDS); + expect(COMMITMENT_OFFSET_ROUNDS).toBe(2); + }); + + it('always names a round that has not published yet', () => { + // The property the whole scheme rests on: at commitment time, the value + // being committed to does not exist. + for (const offsetIntoPeriod of [0, 1, 2]) { + const now = roundTime(QUICKNET, 5000) + offsetIntoPeriod; + const round = commitmentRound(QUICKNET, now); + expect(roundTime(QUICKNET, round)).toBeGreaterThan(now); + } + }); + + it('lands within the UX budget of 3 to 6 seconds', () => { + for (const offsetIntoPeriod of [0, 1, 2]) { + const now = roundTime(QUICKNET, 5000) + offsetIntoPeriod; + const wait = roundTime(QUICKNET, commitmentRound(QUICKNET, now)) - now; + expect(wait).toBeGreaterThanOrEqual(3); + expect(wait).toBeLessThanOrEqual(6); + } + }); +}); + +describe('beaconMessage', () => { + it('is sha256 of the round as a big-endian uint64', () => { + // Pinned by the real-round verifications above; asserted here so a change + // to the encoding fails with a readable name instead of as "every round is + // suddenly invalid". + expect(bytesToHex(beaconMessage(1))).toBe( + '0xcd2662154e6d76b2b2b92e70c0cac3ccf534f9b74eb5b89819ec509083d00a50', + ); + expect(bytesToHex(beaconMessage(1000))).toBe( + '0xf652498d092acd949bad74e40683bf3824fb817980504a0c7e6722cfc5a9c0a3', + ); + }); + + it('rejects a round below 1', () => { + expect(() => beaconMessage(0)).toThrow(/positive integer/); + }); +}); From e32efa9d490dbb06712cdc421e460f295dbaa2d6 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 07:47:52 -0400 Subject: [PATCH 13/76] feat(protocol): add battle commitment schema, hashing, and chain link --- .../test-vectors/protocol-commitment.json | 505 ++++++++++++++++++ protocol/scripts/gen-vectors.ts | 143 +++++ protocol/src/commitment/chain.ts | 81 +++ protocol/src/commitment/hash.ts | 43 ++ protocol/src/commitment/index.ts | 12 + protocol/src/commitment/types.ts | 143 +++++ protocol/src/index.ts | 1 + protocol/tests/commitment/types.test.ts | 218 ++++++++ protocol/tests/commitment/vectors.test.ts | 150 ++++++ 9 files changed, 1296 insertions(+) create mode 100644 contracts/test-vectors/protocol-commitment.json create mode 100644 protocol/src/commitment/chain.ts create mode 100644 protocol/src/commitment/hash.ts create mode 100644 protocol/src/commitment/index.ts create mode 100644 protocol/src/commitment/types.ts create mode 100644 protocol/tests/commitment/types.test.ts create mode 100644 protocol/tests/commitment/vectors.test.ts diff --git a/contracts/test-vectors/protocol-commitment.json b/contracts/test-vectors/protocol-commitment.json new file mode 100644 index 00000000..402c9e15 --- /dev/null +++ b/contracts/test-vectors/protocol-commitment.json @@ -0,0 +1,505 @@ +{ + "description": "BattleCommitment canonical-hash vectors (docs/plan-backend-battle-architecture.md §E). Generated by protocol/scripts/gen-vectors.ts from protocol/src/commitment. The snapshot enters the hash as snapshotHash, while the payload carries the full snapshot. A failure means the implementation drifted. Never edit an expectation to match new output.", + "cases": [ + { + "name": "baseline", + "note": "Reference commitment: accepted at quicknet round 1000 time, bound to round 1002.", + "commitment": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "defenseAuthorizationHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806366 + }, + "rulesetVersion": 1, + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "drandChainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "drandRound": 1002, + "acceptedAt": 1692806367, + "previousCommitmentHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "signingKeyId": "battle-signer-2026-07" + }, + "expectedCommitmentHash": "0x564a6a539f4b9e37ce504918fb8120c02b21ff67000296d96546a44398c8fdda" + }, + { + "name": "genesis-no-previous", + "note": "First commitment under a signing key, so the chain link is absent. Must differ from baseline: an absent link is not an empty one.", + "commitment": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "defenseAuthorizationHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806366 + }, + "rulesetVersion": 1, + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "drandChainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "drandRound": 1002, + "acceptedAt": 1692806367, + "previousCommitmentHash": null, + "signingKeyId": "battle-signer-2026-07" + }, + "expectedCommitmentHash": "0xb89de9f4d739eaad13e4c860c359d22af6c6dbb15b922f3950b8ca99a863abed" + }, + { + "name": "other-committed-round", + "note": "Same battle bound to round 1003 instead. Must differ: this is precisely the substitution a reroll would need, and the two signatures over one battleId are what make it provable.", + "commitment": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "defenseAuthorizationHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806366 + }, + "rulesetVersion": 1, + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "drandChainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "drandRound": 1003, + "acceptedAt": 1692806367, + "previousCommitmentHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "signingKeyId": "battle-signer-2026-07" + }, + "expectedCommitmentHash": "0x8c4d5f26979580ef43bbef07fece5b25d02fb3073d6454035a7f391cd415b629" + }, + { + "name": "other-battle-id", + "note": "Same everything, different battle. Must differ.", + "commitment": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000001", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "defenseAuthorizationHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806366 + }, + "rulesetVersion": 1, + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "drandChainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "drandRound": 1002, + "acceptedAt": 1692806367, + "previousCommitmentHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "signingKeyId": "battle-signer-2026-07" + }, + "expectedCommitmentHash": "0xa26aa33c4ab49154bc50e5ce18ca87d085c97eed5e1cc89fa960c59baa56ddb3" + }, + { + "name": "other-intent", + "note": "Same battle authorized by a different intent. Must differ.", + "commitment": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x4444444444444444444444444444444444444444444444444444444444444444", + "defenseAuthorizationHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806366 + }, + "rulesetVersion": 1, + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "drandChainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "drandRound": 1002, + "acceptedAt": 1692806367, + "previousCommitmentHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "signingKeyId": "battle-signer-2026-07" + }, + "expectedCommitmentHash": "0x30a4fa9c9daa1db9d13d36be94cb08957131992f55952eafebabd0d0eb7a88c2" + }, + { + "name": "other-consent", + "note": "Same battle relying on a different defence authorization. Must differ: which consent a battle leaned on is part of what is being claimed.", + "commitment": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "defenseAuthorizationHash": "0x5555555555555555555555555555555555555555555555555555555555555555", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806366 + }, + "rulesetVersion": 1, + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "drandChainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "drandRound": 1002, + "acceptedAt": 1692806367, + "previousCommitmentHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "signingKeyId": "battle-signer-2026-07" + }, + "expectedCommitmentHash": "0x613b1427894a23055289d23f824240f2cbb1aec8cf1765f93070c4bc1b2317de" + }, + { + "name": "levelled-up-snapshot", + "note": "Baseline with the attacker one level higher. Must differ: the commitment binds the frozen photo, so pets cannot change after acceptance.", + "commitment": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "defenseAuthorizationHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 11, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806366 + }, + "rulesetVersion": 1, + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "drandChainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "drandRound": 1002, + "acceptedAt": 1692806367, + "previousCommitmentHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "signingKeyId": "battle-signer-2026-07" + }, + "expectedCommitmentHash": "0x0fb015f24982d9fc179121330fadd39ae5973b1d751b604276fce37292aff108" + }, + { + "name": "other-ruleset-version", + "note": "Same ruleset hash, different version number. Must differ.", + "commitment": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "defenseAuthorizationHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806366 + }, + "rulesetVersion": 2, + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "drandChainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "drandRound": 1002, + "acceptedAt": 1692806367, + "previousCommitmentHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "signingKeyId": "battle-signer-2026-07" + }, + "expectedCommitmentHash": "0x24bddb6a595113a0099af9e93c5470a005b822e19c566dca55dfc88f9d467004" + }, + { + "name": "other-signing-key", + "note": "Same commitment attributed to a different key. Must differ: which key signed is part of the statement, so a rotated key cannot be retro-fitted to old commitments.", + "commitment": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "defenseAuthorizationHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806366 + }, + "rulesetVersion": 1, + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "drandChainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "drandRound": 1002, + "acceptedAt": 1692806367, + "previousCommitmentHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "signingKeyId": "battle-signer-2026-08" + }, + "expectedCommitmentHash": "0xb5a6d9f829fdfcfcd374e974eefc2d5c9f6875d7e37c01b349c84f9e0cb1a738" + }, + { + "name": "solana-deployment", + "note": "Solana battle. Must differ from the EVM baseline.", + "commitment": { + "chainId": "solana:devnet", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "defenseAuthorizationHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "snapshot": { + "chainId": "solana:devnet", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806366 + }, + "rulesetVersion": 1, + "rulesetHash": "0xabababababababababababababababababababababababababababababababab", + "drandChainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "drandRound": 1002, + "acceptedAt": 1692806367, + "previousCommitmentHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "signingKeyId": "battle-signer-2026-07" + }, + "expectedCommitmentHash": "0x907d0d0997268e4dcd495f5d973d00302cc5a8b6a365d7a56c6cc5874ecd3158" + } + ] +} diff --git a/protocol/scripts/gen-vectors.ts b/protocol/scripts/gen-vectors.ts index 8b5f6d66..a32e408c 100644 --- a/protocol/scripts/gen-vectors.ts +++ b/protocol/scripts/gen-vectors.ts @@ -16,6 +16,7 @@ import { writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { type BattleCommitment, hashBattleCommitment } from '../src/commitment'; import { type DefenseAuthorization, defenseAuthorizationSolanaMessage, @@ -583,7 +584,149 @@ function writeSeedVectors(): void { process.stdout.write(`wrote ${out.cases.length} seed cases to ${path}\n`); } +/** Serializable form of a commitment. Its snapshot reuses the snapshot fixtures. */ +interface CommitmentFixture { + chainId: string; + deploymentId: string; + battleId: string; + intentHash: string; + defenseAuthorizationHash: string; + snapshot: SnapshotFixture; + rulesetVersion: number; + rulesetHash: string; + drandChainHash: string; + drandRound: number; + acceptedAt: number; + previousCommitmentHash: string | null; + signingKeyId: string; +} + +// quicknet round 1000 publishes at genesis + 3000 = 1692806367, so a battle accepted +// then commits to round 1002 (offset 2), which publishes six seconds later. +const ACCEPTED_AT = 1692806367; +const COMMITTED_ROUND = 1002; +const QUICKNET_CHAIN_HASH = '0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971'; + +const COMMITMENT_BASE: CommitmentFixture = { + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + battleId: 'btl_01hq8z0000000000000000', + intentHash: `0x${'11'.repeat(32)}`, + defenseAuthorizationHash: `0x${'22'.repeat(32)}`, + snapshot: { ...SNAPSHOT_BASE, takenAt: ACCEPTED_AT - 1 }, + rulesetVersion: 1, + rulesetHash: RULESET_HASH, + drandChainHash: QUICKNET_CHAIN_HASH, + drandRound: COMMITTED_ROUND, + acceptedAt: ACCEPTED_AT, + previousCommitmentHash: `0x${'33'.repeat(32)}`, + signingKeyId: 'battle-signer-2026-07', +}; + +const commitmentCases: { name: string; note: string; commitment: CommitmentFixture }[] = [ + { + name: 'baseline', + note: 'Reference commitment: accepted at quicknet round 1000 time, bound to round 1002.', + commitment: COMMITMENT_BASE, + }, + { + name: 'genesis-no-previous', + note: 'First commitment under a signing key, so the chain link is absent. Must differ from baseline: an absent link is not an empty one.', + commitment: { ...COMMITMENT_BASE, previousCommitmentHash: null }, + }, + { + name: 'other-committed-round', + note: 'Same battle bound to round 1003 instead. Must differ: this is precisely the substitution a reroll would need, and the two signatures over one battleId are what make it provable.', + commitment: { ...COMMITMENT_BASE, drandRound: COMMITTED_ROUND + 1 }, + }, + { + name: 'other-battle-id', + note: 'Same everything, different battle. Must differ.', + commitment: { ...COMMITMENT_BASE, battleId: 'btl_01hq8z0000000000000001' }, + }, + { + name: 'other-intent', + note: 'Same battle authorized by a different intent. Must differ.', + commitment: { ...COMMITMENT_BASE, intentHash: `0x${'44'.repeat(32)}` }, + }, + { + name: 'other-consent', + note: 'Same battle relying on a different defence authorization. Must differ: which consent a battle leaned on is part of what is being claimed.', + commitment: { ...COMMITMENT_BASE, defenseAuthorizationHash: `0x${'55'.repeat(32)}` }, + }, + { + name: 'levelled-up-snapshot', + note: 'Baseline with the attacker one level higher. Must differ: the commitment binds the frozen photo, so pets cannot change after acceptance.', + commitment: { + ...COMMITMENT_BASE, + snapshot: { + ...COMMITMENT_BASE.snapshot, + attacker: { ...COMMITMENT_BASE.snapshot.attacker, level: 11 }, + }, + }, + }, + { + name: 'other-ruleset-version', + note: 'Same ruleset hash, different version number. Must differ.', + commitment: { ...COMMITMENT_BASE, rulesetVersion: 2 }, + }, + { + name: 'other-signing-key', + note: 'Same commitment attributed to a different key. Must differ: which key signed is part of the statement, so a rotated key cannot be retro-fitted to old commitments.', + commitment: { ...COMMITMENT_BASE, signingKeyId: 'battle-signer-2026-08' }, + }, + { + name: 'solana-deployment', + note: 'Solana battle. Must differ from the EVM baseline.', + commitment: { + ...COMMITMENT_BASE, + chainId: 'solana:devnet', + snapshot: { + ...COMMITMENT_BASE.snapshot, + chainId: 'solana:devnet', + attacker: { ...COMMITMENT_BASE.snapshot.attacker, owner: 'DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL' }, + defender: { ...COMMITMENT_BASE.snapshot.defender, owner: 'GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp' }, + }, + }, + }, +]; + +/** Rebuilds a runtime commitment from its serializable fixture. */ +export function commitmentFromFixture(fixture: CommitmentFixture): BattleCommitment { + return { + domain: { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }, + battleId: fixture.battleId, + intentHash: fixture.intentHash as Hex, + defenseAuthorizationHash: fixture.defenseAuthorizationHash as Hex, + snapshot: snapshotFromFixture(fixture.snapshot), + rulesetVersion: fixture.rulesetVersion, + rulesetHash: fixture.rulesetHash as Hex, + drandChainHash: fixture.drandChainHash as Hex, + drandRound: fixture.drandRound, + acceptedAt: fixture.acceptedAt, + previousCommitmentHash: fixture.previousCommitmentHash as Hex | null, + signingKeyId: fixture.signingKeyId, + }; +} + +function writeCommitmentVectors(): void { + const out = { + description: + 'BattleCommitment canonical-hash vectors (docs/plan-backend-battle-architecture.md §E). Generated by protocol/scripts/gen-vectors.ts from protocol/src/commitment. The snapshot enters the hash as snapshotHash, while the payload carries the full snapshot. A failure means the implementation drifted. Never edit an expectation to match new output.', + cases: commitmentCases.map((c) => ({ + name: c.name, + note: c.note, + commitment: c.commitment, + expectedCommitmentHash: hashBattleCommitment(commitmentFromFixture(c.commitment)), + })), + }; + const path = join(VECTORS_DIR, 'protocol-commitment.json'); + writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`); + process.stdout.write(`wrote ${out.cases.length} commitment cases to ${path}\n`); +} + writeIntentVectors(); writeConsentVectors(); writeSnapshotVectors(); writeSeedVectors(); +writeCommitmentVectors(); diff --git a/protocol/src/commitment/chain.ts b/protocol/src/commitment/chain.ts new file mode 100644 index 00000000..c0af0f19 --- /dev/null +++ b/protocol/src/commitment/chain.ts @@ -0,0 +1,81 @@ +import type { Hex } from '../encoding/bytes'; + +import { hashBattleCommitment } from './hash'; +import type { BattleCommitment } from './types'; + +/** + * Commitments carry their own hash chain, so the sequence of accepted battles is + * tamper-evident independently of receipts (§E). + * + * What that buys: an operator who accepted a battle and then quietly dropped it + * leaves a gap, and an operator who issued two commitments for one `battleId` + * leaves two chains that both claim the same predecessor. Neither is prevented by + * the chain. Both become demonstrable with two signatures. + */ + +/** Why a chain does not hold. */ +export type ChainFailure = + | 'wrong-anchor' + | 'broken-link' + | 'duplicate-battle-id' + | 'time-went-backwards'; + +export type ChainResult = { ok: true } | { ok: false; index: number; reason: ChainFailure }; + +/** + * Checks that a run of commitments forms an unbroken chain. + * + * `expectedAnchor` is what the first element must link back to: pass the hash of + * the commitment preceding this window, or `null` if the window starts at the very + * first commitment under the key. Pass `undefined` to skip the anchor check when + * auditing a slice out of the middle without its predecessor to hand. + * + * Also rejects a repeated `battleId` and a commitment that claims to precede its + * own predecessor in time. Neither is a hash-chain property, but both are cheap + * here and are exactly the shapes a fabricated history takes. + */ +export function verifyCommitmentChain( + commitments: readonly BattleCommitment[], + expectedAnchor?: Hex | null, +): ChainResult { + const seenBattleIds = new Set(); + let previousHash: Hex | null | undefined = expectedAnchor; + let previousAcceptedAt: number | undefined; + + for (let index = 0; index < commitments.length; index++) { + const commitment = commitments[index]!; + + if (previousHash !== undefined && commitment.previousCommitmentHash !== previousHash) { + return { ok: false, index, reason: index === 0 ? 'wrong-anchor' : 'broken-link' }; + } + if (seenBattleIds.has(commitment.battleId)) { + return { ok: false, index, reason: 'duplicate-battle-id' }; + } + if (previousAcceptedAt !== undefined && commitment.acceptedAt < previousAcceptedAt) { + return { ok: false, index, reason: 'time-went-backwards' }; + } + + seenBattleIds.add(commitment.battleId); + previousHash = hashBattleCommitment(commitment); + previousAcceptedAt = commitment.acceptedAt; + } + + return { ok: true }; +} + +/** + * Detects equivocation: two commitments for one `battleId`, each signed, which is + * what a reroll looks like from the outside. + * + * Returns the conflicting battle ids. An empty result is not proof of honesty, + * only that these particular commitments do not contradict each other. + */ +export function findEquivocations(commitments: readonly BattleCommitment[]): string[] { + const hashesByBattleId = new Map>(); + for (const commitment of commitments) { + const hashes = hashesByBattleId.get(commitment.battleId) ?? new Set(); + hashes.add(hashBattleCommitment(commitment)); + hashesByBattleId.set(commitment.battleId, hashes); + } + return [...hashesByBattleId.entries()].filter(([, hashes]) => hashes.size > 1).map(([battleId]) => battleId); +} diff --git a/protocol/src/commitment/hash.ts b/protocol/src/commitment/hash.ts new file mode 100644 index 00000000..cf0c2044 --- /dev/null +++ b/protocol/src/commitment/hash.ts @@ -0,0 +1,43 @@ +import { writeHeader } from '../domain/deployment'; +import type { Hex } from '../encoding/bytes'; +import { DOMAIN_TAGS } from '../encoding/domain'; +import { keccak256Hex } from '../encoding/hash'; +import { CanonicalWriter } from '../encoding/writer'; +import { hashBattleSnapshot } from '../snapshot/hash'; + +import { assertBattleCommitment, type BattleCommitment } from './types'; + +/** + * Canonical encoding of a commitment. + * + * The snapshot enters as its hash, not as its fields. §E lists `attackerSnapshot` + * and `defenderSnapshot` alongside `snapshotHash`, but hashing both would bind the + * same data twice: `snapshotHash` already commits to every frozen field. The + * delivered payload still carries the full snapshots, so a player can replay + * without asking us for anything; a verifier recomputes their hash and compares. + */ +export function encodeBattleCommitment(commitment: BattleCommitment): Uint8Array { + const checked = assertBattleCommitment(commitment); + const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.COMMITMENT); + return writeHeader(writer, 'commitment', checked.domain) + .text(checked.battleId) + .hash(checked.intentHash) + .hash(checked.defenseAuthorizationHash) + .hash(hashBattleSnapshot(checked.snapshot)) + .u32(checked.rulesetVersion) + .hash(checked.rulesetHash) + .hash(checked.drandChainHash) + .u64(checked.drandRound) + .u64(checked.acceptedAt) + .optional(checked.previousCommitmentHash, (w, v) => w.hash(v)) + .text(checked.signingKeyId) + .build(); +} + +/** + * `commitmentHash`: what the KMS key signs, what the next commitment links back + * to, and what a receipt references. + */ +export function hashBattleCommitment(commitment: BattleCommitment): Hex { + return keccak256Hex(encodeBattleCommitment(commitment)); +} diff --git a/protocol/src/commitment/index.ts b/protocol/src/commitment/index.ts new file mode 100644 index 00000000..0b447cc0 --- /dev/null +++ b/protocol/src/commitment/index.ts @@ -0,0 +1,12 @@ +export { + type ChainFailure, + type ChainResult, + findEquivocations, + verifyCommitmentChain, +} from './chain'; +export { encodeBattleCommitment, hashBattleCommitment } from './hash'; +export { + assertBattleCommitment, + type BattleCommitment, + MAX_COMMITMENT_OFFSET_ROUNDS, +} from './types'; diff --git a/protocol/src/commitment/types.ts b/protocol/src/commitment/types.ts new file mode 100644 index 00000000..6a9c7d02 --- /dev/null +++ b/protocol/src/commitment/types.ts @@ -0,0 +1,143 @@ +import { assertProtocolDomain, type ProtocolDomain } from '../domain/deployment'; +import { type Hex, hexToBytes } from '../encoding/bytes'; +import { latestRoundAt, resolveDrandChain, roundTime } from '../randomness/drand'; +import { assertBattleSnapshot, type BattleSnapshot } from '../snapshot/types'; + +/** + * Our signed statement, made before the dice land, of which future beacon round a + * battle will use. + * + * This is the load-bearing object in the whole design. Public randomness alone + * buys nothing: an operator who watches the beacon, dislikes the result, and then + * claims the battle was always bound to a later round produces a perfectly + * self-consistent lie. The fix is that the operator names the round in writing, + * signs it, and hands it to the player *before that round exists*. A reroll then + * requires a second signature over the same `battleId`, which either player's + * stored copy turns into proof. + * + * Persisting the round in our own database is not a commitment, because the + * database is ours. Neither is Merkle anchoring, which happens after computation. + * Only delivery before reveal counts (§E). + */ +export interface BattleCommitment { + domain: ProtocolDomain; + /** Ledger id of the battle this commits to. */ + battleId: string; + /** The wallet-signed intent that authorized it. */ + intentHash: Hex; + /** The defender's standing authorization this battle relied on. */ + defenseAuthorizationHash: Hex; + /** Both pets, frozen. `snapshotHash` is derived from this rather than supplied. */ + snapshot: BattleSnapshot; + rulesetVersion: number; + rulesetHash: Hex; + /** Which drand chain. Must be one this build pins. */ + drandChainHash: Hex; + /** The committed round. Must not have published at `acceptedAt`. */ + drandRound: number; + /** Unix seconds the battle was accepted and this commitment signed. */ + acceptedAt: number; + /** Previous commitment under the same signing key, or null for the first. */ + previousCommitmentHash: Hex | null; + /** Which signing key produced the signature over this commitment's digest. */ + signingKeyId: string; +} + +/** + * How far ahead a commitment may name its round. + * + * `COMMITMENT_OFFSET_ROUNDS` is what we use; this is the ceiling a *verifier* + * enforces, with slack for clock skew between the backend and the beacon. It has + * an upper bound at all because an unbounded one lets the operator name a round + * hours away and sit on the battle, which is a stall rather than a reroll but is + * still a decision nobody agreed to. + */ +export const MAX_COMMITMENT_OFFSET_ROUNDS = 10; + +const SAFE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/; + +/** Validates an untrusted commitment, returning a normalized copy. */ +export function assertBattleCommitment(commitment: BattleCommitment): BattleCommitment { + const domain = assertProtocolDomain(commitment.domain); + assertId(commitment.battleId, 'battleId'); + assertId(commitment.signingKeyId, 'signingKeyId'); + assertHash(commitment.intentHash, 'intentHash'); + assertHash(commitment.defenseAuthorizationHash, 'defenseAuthorizationHash'); + assertHash(commitment.rulesetHash, 'rulesetHash'); + if (commitment.previousCommitmentHash !== null) { + assertHash(commitment.previousCommitmentHash, 'previousCommitmentHash'); + } + + const snapshot = assertBattleSnapshot(commitment.snapshot); + + if (!Number.isSafeInteger(commitment.rulesetVersion) || commitment.rulesetVersion < 1) { + throw new Error(`rulesetVersion must be a positive integer, got ${commitment.rulesetVersion}`); + } + if (!Number.isSafeInteger(commitment.acceptedAt) || commitment.acceptedAt < 1) { + throw new Error(`acceptedAt must be a positive unix-seconds integer, got ${commitment.acceptedAt}`); + } + if (snapshot.takenAt > commitment.acceptedAt) { + throw new Error( + `snapshot was taken at ${snapshot.takenAt}, after acceptance at ${commitment.acceptedAt}; the photo must precede the commitment`, + ); + } + + // Resolving the chain rather than trusting a supplied key is the difference + // between verifying a beacon and being told about one. + const chain = resolveDrandChain(commitment.drandChainHash); + if (!Number.isSafeInteger(commitment.drandRound) || commitment.drandRound < 1) { + throw new Error(`drandRound must be a positive integer, got ${commitment.drandRound}`); + } + assertRoundIsStillFuture(chain.chainHash, commitment); + + return { + domain, + battleId: commitment.battleId, + intentHash: commitment.intentHash, + defenseAuthorizationHash: commitment.defenseAuthorizationHash, + snapshot, + rulesetVersion: commitment.rulesetVersion, + rulesetHash: commitment.rulesetHash, + drandChainHash: chain.chainHash, + drandRound: commitment.drandRound, + acceptedAt: commitment.acceptedAt, + previousCommitmentHash: commitment.previousCommitmentHash, + signingKeyId: commitment.signingKeyId, + }; +} + +/** + * The property the design rests on, checked rather than assumed: at acceptance the + * committed round had not published yet, and it is not so far ahead that naming it + * is a way of stalling. + * + * A verifier can run this from the commitment alone, which is the point. It turns + * "we promise we committed before the reveal" into arithmetic anyone can redo. + */ +function assertRoundIsStillFuture(chainHash: Hex, commitment: BattleCommitment): void { + const chain = resolveDrandChain(chainHash); + const publishedAt = roundTime(chain, commitment.drandRound); + if (publishedAt <= commitment.acceptedAt) { + throw new Error( + `drand round ${commitment.drandRound} published at ${publishedAt}, at or before acceptance at ${commitment.acceptedAt}; committing to a known value is the reroll attack`, + ); + } + const ceiling = latestRoundAt(chain, commitment.acceptedAt) + MAX_COMMITMENT_OFFSET_ROUNDS; + if (commitment.drandRound > ceiling) { + throw new Error( + `drand round ${commitment.drandRound} is more than ${MAX_COMMITMENT_OFFSET_ROUNDS} rounds past acceptance (ceiling ${ceiling})`, + ); + } +} + +function assertId(value: string, field: string): void { + if (typeof value !== 'string' || !SAFE_ID_PATTERN.test(value)) { + throw new Error(`${field} is not a valid id: ${JSON.stringify(value)}`); + } +} + +function assertHash(value: Hex, field: string): void { + if (hexToBytes(value).length !== 32) { + throw new Error(`${field} must be a 32-byte hash`); + } +} diff --git a/protocol/src/index.ts b/protocol/src/index.ts index 7075bd19..4b8855c4 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -12,6 +12,7 @@ export const PROTOCOL_PACKAGE = '@cryptopets/protocol'; export * from './combat'; +export * from './commitment'; export * from './consent'; export * from './domain'; export * from './encoding'; diff --git a/protocol/tests/commitment/types.test.ts b/protocol/tests/commitment/types.test.ts new file mode 100644 index 00000000..8a7fac51 --- /dev/null +++ b/protocol/tests/commitment/types.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertBattleCommitment, + type BattleCommitment, + findEquivocations, + hashBattleCommitment, + MAX_COMMITMENT_OFFSET_ROUNDS, + verifyCommitmentChain, +} from '../../src/commitment'; +import type { Hex } from '../../src/encoding/bytes'; +import { commitmentRound, QUICKNET, roundTime } from '../../src/randomness'; +import type { BattleSnapshot } from '../../src/snapshot'; + +const ACCEPTED_AT = roundTime(QUICKNET, 1000); + +const SNAPSHOT: BattleSnapshot = { + domain: { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }, + attacker: { + petId: 1n, + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: 1234567890123456n, + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: 0n, + streak: 0, + readyAt: ACCEPTED_AT - 100, + sourceVersion: BigInt(ACCEPTED_AT - 50), + }, + defender: { + petId: 2n, + owner: '0x2222222222222222222222222222222222222222', + dna: 6543210987654321n, + rarity: 2, + level: 11, + skill: 7, + xp: 45, + lastOpponentId: 1n, + streak: 2, + readyAt: ACCEPTED_AT - 100, + sourceVersion: BigInt(ACCEPTED_AT - 50), + }, + takenAt: ACCEPTED_AT - 1, +}; + +const VALID: BattleCommitment = { + domain: { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }, + battleId: 'btl_0001', + intentHash: `0x${'11'.repeat(32)}`, + defenseAuthorizationHash: `0x${'22'.repeat(32)}`, + snapshot: SNAPSHOT, + rulesetVersion: 1, + rulesetHash: `0x${'ab'.repeat(32)}`, + drandChainHash: QUICKNET.chainHash, + drandRound: commitmentRound(QUICKNET, ACCEPTED_AT), + acceptedAt: ACCEPTED_AT, + previousCommitmentHash: null, + signingKeyId: 'battle-signer-2026-07', +}; + +describe('the commit-before-reveal property', () => { + it('accepts a round that has not published at acceptance', () => { + expect(() => assertBattleCommitment(VALID)).not.toThrow(); + expect(roundTime(QUICKNET, VALID.drandRound)).toBeGreaterThan(VALID.acceptedAt); + }); + + it('rejects a round that already published', () => { + // Committing to a value already known is the reroll attack, so it is + // rejected by arithmetic anyone can redo from the commitment alone. + expect(() => assertBattleCommitment({ ...VALID, drandRound: 1000 })).toThrow( + /committing to a known value is the reroll attack/, + ); + }); + + it('rejects a round publishing exactly at acceptance', () => { + const round = latestRoundExactlyAt(VALID.acceptedAt); + expect(() => assertBattleCommitment({ ...VALID, drandRound: round })).toThrow(/reroll attack/); + }); + + it('rejects a round too far in the future', () => { + // Naming a round hours away is a stall rather than a reroll, but it is still + // a decision nobody agreed to. + const tooFar = VALID.drandRound + MAX_COMMITMENT_OFFSET_ROUNDS; + expect(() => assertBattleCommitment({ ...VALID, drandRound: tooFar })).toThrow(/rounds past acceptance/); + }); + + it('accepts the ceiling exactly', () => { + const ceiling = 1000 + MAX_COMMITMENT_OFFSET_ROUNDS; + expect(() => assertBattleCommitment({ ...VALID, drandRound: ceiling })).not.toThrow(); + }); + + it('rejects a snapshot taken after acceptance', () => { + expect(() => + assertBattleCommitment({ + ...VALID, + snapshot: { ...SNAPSHOT, takenAt: VALID.acceptedAt + 1 }, + }), + ).toThrow(/the photo must precede the commitment/); + }); + + it('refuses a drand chain this build does not pin', () => { + expect(() => + assertBattleCommitment({ + ...VALID, + drandChainHash: `0x${'99'.repeat(32)}`, + }), + ).toThrow(/is not pinned/); + }); +}); + +describe('assertBattleCommitment field validation', () => { + it.each([ + ['battleId', { battleId: 'battle id with spaces' }], + ['signingKeyId', { signingKeyId: '' }], + ['intentHash', { intentHash: '0x1234' as Hex }], + ['defenseAuthorizationHash', { defenseAuthorizationHash: '0x1234' as Hex }], + ['rulesetHash', { rulesetHash: '0x1234' as Hex }], + ['previousCommitmentHash', { previousCommitmentHash: '0x1234' as Hex }], + ['rulesetVersion', { rulesetVersion: 0 }], + ['acceptedAt', { acceptedAt: 0 }], + ])('rejects an invalid %s', (_field, patch) => { + expect(() => assertBattleCommitment({ ...VALID, ...patch } as BattleCommitment)).toThrow(); + }); + + it('allows a null previous hash for the first commitment under a key', () => { + expect(() => assertBattleCommitment({ ...VALID, previousCommitmentHash: null })).not.toThrow(); + }); +}); + +describe('verifyCommitmentChain', () => { + const first = { ...VALID, battleId: 'btl_0001', previousCommitmentHash: null }; + const second = { + ...VALID, + battleId: 'btl_0002', + acceptedAt: VALID.acceptedAt + 3, + drandRound: commitmentRound(QUICKNET, VALID.acceptedAt + 3), + previousCommitmentHash: hashBattleCommitment(first), + }; + const third = { + ...VALID, + battleId: 'btl_0003', + acceptedAt: VALID.acceptedAt + 6, + drandRound: commitmentRound(QUICKNET, VALID.acceptedAt + 6), + previousCommitmentHash: hashBattleCommitment(second), + }; + + it('accepts an unbroken run', () => { + expect(verifyCommitmentChain([first, second, third], null)).toEqual({ ok: true }); + }); + + it('accepts a window without checking its anchor when none is supplied', () => { + expect(verifyCommitmentChain([second, third])).toEqual({ ok: true }); + }); + + it('reports a wrong anchor at the first element', () => { + expect(verifyCommitmentChain([second, third], null)).toEqual({ + ok: false, + index: 0, + reason: 'wrong-anchor', + }); + }); + + it('reports a removed entry as a broken link', () => { + // Dropping a battle from the middle is the tamper this chain exists to make + // visible: `third` no longer links to its predecessor. + expect(verifyCommitmentChain([first, third], null)).toEqual({ + ok: false, + index: 1, + reason: 'broken-link', + }); + }); + + it('reports a repeated battle id', () => { + const duplicate = { ...second, battleId: first.battleId }; + expect(verifyCommitmentChain([first, duplicate], null)).toEqual({ + ok: false, + index: 1, + reason: 'duplicate-battle-id', + }); + }); + + it('reports acceptance time moving backwards', () => { + const backwards = { ...second, acceptedAt: first.acceptedAt - 3 }; + const relinked = { ...backwards, previousCommitmentHash: hashBattleCommitment(first) }; + expect(verifyCommitmentChain([first, relinked], null)).toEqual({ + ok: false, + index: 1, + reason: 'time-went-backwards', + }); + }); + + it('accepts an empty run', () => { + expect(verifyCommitmentChain([], null)).toEqual({ ok: true }); + }); +}); + +describe('findEquivocations', () => { + it('finds two different commitments for one battle', () => { + // What a reroll looks like from outside: same battleId, two signed + // statements about which round it uses. + const rerolled = { ...VALID, drandRound: VALID.drandRound + 1 }; + expect(findEquivocations([VALID, rerolled])).toEqual([VALID.battleId]); + }); + + it('ignores an exact duplicate, which is a re-delivery rather than a contradiction', () => { + expect(findEquivocations([VALID, { ...VALID }])).toEqual([]); + }); + + it('finds nothing in a clean set', () => { + expect(findEquivocations([VALID, { ...VALID, battleId: 'btl_0002' }])).toEqual([]); + }); +}); + +function latestRoundExactlyAt(seconds: number): number { + return (seconds - QUICKNET.genesisTimeSeconds) / QUICKNET.periodSeconds; +} diff --git a/protocol/tests/commitment/vectors.test.ts b/protocol/tests/commitment/vectors.test.ts new file mode 100644 index 00000000..77142e9d --- /dev/null +++ b/protocol/tests/commitment/vectors.test.ts @@ -0,0 +1,150 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { type BattleCommitment, hashBattleCommitment } from '../../src/commitment'; +import type { ChainId } from '../../src/domain/chainId'; +import type { Hex } from '../../src/encoding/bytes'; +import type { BattleSnapshot, PetSnapshot } from '../../src/snapshot'; + +/** + * Consumes contracts/test-vectors/protocol-commitment.json. A failure means the + * encoding drifted, and the fix is the code, never the vector (`AGENTS.md`). + */ +interface PetFixture { + petId: string; + owner: string; + dna: string; + rarity: number; + level: number; + skill: number; + xp: number; + lastOpponentId: string; + streak: number; + readyAt: number; + sourceVersion: string; +} + +interface CommitmentFixture { + chainId: string; + deploymentId: string; + battleId: string; + intentHash: string; + defenseAuthorizationHash: string; + snapshot: { + chainId: string; + deploymentId: string; + attacker: PetFixture; + defender: PetFixture; + takenAt: number; + }; + rulesetVersion: number; + rulesetHash: string; + drandChainHash: string; + drandRound: number; + acceptedAt: number; + previousCommitmentHash: string | null; + signingKeyId: string; +} + +interface CommitmentCase { + name: string; + note: string; + commitment: CommitmentFixture; + expectedCommitmentHash: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const vectorsPath = join(here, '../../../contracts/test-vectors/protocol-commitment.json'); +const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')) as { cases: CommitmentCase[] }; + +function toPet(fixture: PetFixture): PetSnapshot { + return { + petId: BigInt(fixture.petId), + owner: fixture.owner, + dna: BigInt(fixture.dna), + rarity: fixture.rarity, + level: fixture.level, + skill: fixture.skill, + xp: fixture.xp, + lastOpponentId: BigInt(fixture.lastOpponentId), + streak: fixture.streak, + readyAt: fixture.readyAt, + sourceVersion: BigInt(fixture.sourceVersion), + }; +} + +function toSnapshot(fixture: CommitmentFixture['snapshot']): BattleSnapshot { + return { + domain: { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }, + attacker: toPet(fixture.attacker), + defender: toPet(fixture.defender), + takenAt: fixture.takenAt, + }; +} + +export function toCommitment(fixture: CommitmentFixture): BattleCommitment { + return { + domain: { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }, + battleId: fixture.battleId, + intentHash: fixture.intentHash as Hex, + defenseAuthorizationHash: fixture.defenseAuthorizationHash as Hex, + snapshot: toSnapshot(fixture.snapshot), + rulesetVersion: fixture.rulesetVersion, + rulesetHash: fixture.rulesetHash as Hex, + drandChainHash: fixture.drandChainHash as Hex, + drandRound: fixture.drandRound, + acceptedAt: fixture.acceptedAt, + previousCommitmentHash: fixture.previousCommitmentHash as Hex | null, + signingKeyId: fixture.signingKeyId, + }; +} + +const byName = new Map(vectors.cases.map((c) => [c.name, c])); +const hashOf = (name: string) => { + const found = byName.get(name); + if (!found) throw new Error(`vector case missing: ${name}`); + return hashBattleCommitment(toCommitment(found.commitment)); +}; + +describe('commitment golden vectors', () => { + for (const c of vectors.cases) { + it(`matches the recorded hash for "${c.name}"`, () => { + expect(hashBattleCommitment(toCommitment(c.commitment))).toBe(c.expectedCommitmentHash); + }); + } +}); + +describe('relationships the vectors exist to pin', () => { + it('separates a commitment naming a different round', () => { + // The substitution a reroll needs. Two signatures over one battleId with + // different digests is what makes the lie provable. + expect(hashOf('other-committed-round')).not.toBe(hashOf('baseline')); + }); + + it('separates an absent chain link from a present one', () => { + expect(hashOf('genesis-no-previous')).not.toBe(hashOf('baseline')); + }); + + it('separates a changed snapshot', () => { + expect(hashOf('levelled-up-snapshot')).not.toBe(hashOf('baseline')); + }); + + it('separates a different intent and a different consent', () => { + expect(hashOf('other-intent')).not.toBe(hashOf('baseline')); + expect(hashOf('other-consent')).not.toBe(hashOf('baseline')); + }); + + it('separates ruleset versions and signing keys', () => { + expect(hashOf('other-ruleset-version')).not.toBe(hashOf('baseline')); + expect(hashOf('other-signing-key')).not.toBe(hashOf('baseline')); + }); + + it('gives every case a distinct hash', () => { + const hashes = vectors.cases.map((c) => c.expectedCommitmentHash); + expect(new Set(hashes).size).toBe(hashes.length); + }); +}); + +export { vectors as commitmentVectors }; From 301241f0f193c7685663609e42974c878c949112 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 08:00:40 -0400 Subject: [PATCH 14/76] feat(protocol): port XP and progression math to TypeScript with golden vectors --- AGENTS.md | 2 +- CLAUDE.md | 2 +- .../test-vectors/protocol-progression.json | 493 ++++++++++++++++++ protocol/scripts/gen-vectors.ts | 161 ++++++ protocol/src/combat/index.ts | 16 + protocol/src/combat/xp.ts | 133 +++++ protocol/src/index.ts | 1 + protocol/src/progression/index.ts | 7 + protocol/src/progression/progression.ts | 123 +++++ protocol/tests/combat/xp.test.ts | 143 +++++ protocol/tests/combat/xpGoldenVectors.test.ts | 70 +++ protocol/tests/progression/vectors.test.ts | 175 +++++++ 12 files changed, 1324 insertions(+), 2 deletions(-) create mode 100644 contracts/test-vectors/protocol-progression.json create mode 100644 protocol/src/combat/xp.ts create mode 100644 protocol/src/progression/index.ts create mode 100644 protocol/src/progression/progression.ts create mode 100644 protocol/tests/combat/xp.test.ts create mode 100644 protocol/tests/combat/xpGoldenVectors.test.ts create mode 100644 protocol/tests/progression/vectors.test.ts diff --git a/AGENTS.md b/AGENTS.md index b1042e0e..dedc6bef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Normative language: `MUST`/`MUST NOT` are mandatory. `SHOULD`/`SHOULD NOT` are e ## Non-Negotiables - `MUST NOT` edit the golden test vectors in `contracts/test-vectors/{battle,xp}.json` to make a failing test pass. If a vector fails, the Go or Rust port has drifted from the Solidity contract; fix the drifted port, never the vector. -- `MUST` update all four combat-simulator ports together (`contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, `indexer-go/internal/combat/`, `protocol/src/combat/`) when changing combat logic. Never patch one leg alone. The TS port (`protocol/src/combat/`, re-exported from `shared/src/utils/combat` for existing importers) covers fight math only, not XP — see its package doc. +- `MUST` update all four combat-simulator ports together (`contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, `indexer-go/internal/combat/`, `protocol/src/combat/`) when changing combat logic. Never patch one leg alone. The TS port (`protocol/src/combat/`, re-exported from `shared/src/utils/combat` for existing importers) now covers XP and level progression too (`protocol/src/combat/xp.ts`, validated against `contracts/test-vectors/xp.json`), so an XP or decay change is also a four-port change. `indexer-go/internal/combat/xp.go` covers the formula and the decay but not level-up; that gap closes when the Go verifier lands. - `MUST NOT` assume the `ChainAdapter` interface (`shared/src/hooks/adapters/`) covers more than pet-action mutations and reads. It is a real, shared interface (`useEvmAdapter`/`useSolanaAdapter` both implement it) and every public pet-action hook consumes it chain-blind, but the low-level chain wiring in `frontend/src/chains/{ethereum,solana}/`, the async battle/breed VRF flows, and the combat simulator remain intentionally separate per chain. See CLAUDE.md's cross-chain interfaces section for the exact boundary. - `MUST` match the license of the package being edited when adding new files: `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, and `protocol` are MIT; everything else is PolyForm Noncommercial 1.0.0 (root `LICENSE`). See the table in `README.md`. `protocol` is MIT on purpose (third parties have to be able to replay signed battle receipts), so it `MUST NOT` import from a PolyForm package; a test in that package enforces it. - `MUST NOT` treat the v1 contract gaps documented in `contracts/plan-contract-upgrade.md` (no battle authorization, the `changeDna` cheat, client-supplied Solana starter-pet DNA) as bugs to silently patch. They are the known baseline the v2 rewrite is designed around. diff --git a/CLAUDE.md b/CLAUDE.md index 84b7eddb..d048a175 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Note: `docs/README.md` and `docs/architecture.md` link to `indexer-go/ARCHITECTU What that adapter does NOT unify: `frontend/src/chains/ethereum/` (wagmi client, in-tree ABI JSONs: `combatSimAbi.json`, `gameConfigAbi.json`, `gameLogicAbi.json`, `petCoreAbi.json`) and `frontend/src/chains/solana/` (Anchor wallet/provider/signer) are still separate, low-level wiring with no shared interface between them, each adapter reaches into its own directly. The async battle/breed VRF flows (`useEvmBattleFlow.ts`, `battleWithSwitchboardVrf.ts`) and the combat simulator itself are also not unified; see the next section. Treat the adapter as a thin, uniform shape over pet-action mutations and reads, not a claim that the underlying chain logic is shared. ### Combat simulator is ported four times: golden vectors keep them in sync -The battle/combat logic is implemented independently in `contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, pure Go in `indexer-go/internal/combat/`, and pure TypeScript in `protocol/src/combat/` (the fourth port, added for client-side live battle replay — see `docs/plan-realtime-battle-impl.md` Phase 3; it lived in `shared/src/utils/combat/` until the backend-battle work moved it into the MIT `protocol` package, which now re-exports through that old path). All four are validated against the same golden test vectors at `contracts/test-vectors/{battle,xp}.json`, run by Hardhat, Anchor, `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts` respectively. Hashing uses **legacy Keccak-256** (`keccak256(abi.encodePacked(...))` byte layout); a SHA3-vs-Keccak mismatch fails every vector. The TS port covers fight math only, not XP (`xp.go`'s equivalent isn't ported): XP depends on on-chain same-opponent streak state the client can't know, and `BattleResolved` already carries `xpWin`/`xpLoss`. +The battle/combat logic is implemented independently in `contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, pure Go in `indexer-go/internal/combat/`, and pure TypeScript in `protocol/src/combat/` (the fourth port, added for client-side live battle replay — see `docs/plan-realtime-battle-impl.md` Phase 3; it lived in `shared/src/utils/combat/` until the backend-battle work moved it into the MIT `protocol` package, which now re-exports through that old path). All four are validated against the same golden test vectors at `contracts/test-vectors/{battle,xp}.json`, run by Hardhat, Anchor, `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts` respectively. Hashing uses **legacy Keccak-256** (`keccak256(abi.encodePacked(...))` byte layout); a SHA3-vs-Keccak mismatch fails every vector. The TS port covers XP and level progression as well as fight math: `protocol/src/combat/xp.ts` mirrors `GameLogic._calcXp` / `PetCore.addXp` / `PetCore.recordBattleOpponent` and is validated against `contracts/test-vectors/xp.json`, with the snapshot-shaped wrapper in `protocol/src/progression/` (vectors: `protocol-progression.json`). This became portable once `lastOpponentId`/`streak` were frozen into the battle snapshot; before that the client had no way to know the streak state XP depends on. Note the decay shift **must be clamped to 31** in TS: JavaScript's `>>` masks the shift count to 5 bits, so an unclamped `200 >> 32` returns 200 where Solidity, Rust, and Go all return 0. `indexer-go/internal/combat/xp.go` still covers only the formula and decay, not level-up. **If a golden vector test fails, the Go, Rust, or TS implementation has drifted from the Solidity contract. Fix the drifted port, never edit the vector.** ### Settle keeper: the second EVM battle/breed/mint transaction isn't the player's diff --git a/contracts/test-vectors/protocol-progression.json b/contracts/test-vectors/protocol-progression.json new file mode 100644 index 00000000..b249802f --- /dev/null +++ b/contracts/test-vectors/protocol-progression.json @@ -0,0 +1,493 @@ +{ + "description": "Progression-delta vectors in the frozen-snapshot shape (docs/plan-backend-battle-architecture.md §F). Generated by protocol/scripts/gen-vectors.ts from protocol/src/progression. The XP formula and decay themselves are pinned cross-language by contracts/test-vectors/xp.json; these cases pin the composition (which base applies to whom, which decay shift, and the level-threshold interaction). A failure means the implementation drifted. Never edit an expectation to match new output.", + "cases": [ + { + "name": "attacker-wins-fresh", + "note": "Baseline. Attacker has no prior opponent so takes no decay; defender is mid-streak against this attacker, so its loss XP is decayed.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "attackerWon": true, + "maxLevel": 100, + "expected": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 0, + "xpAwarded": 110, + "lastOpponentId": "2", + "streak": 0, + "level": 10, + "xp": 230, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 3, + "xpAwarded": 2, + "lastOpponentId": "1", + "streak": 3, + "level": 11, + "xp": 47, + "leveledUp": false + } + } + }, + { + "name": "defender-wins", + "note": "Same snapshot, other winner. The winner base (100) and loser base (25) swap sides, as do the level arguments.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "attackerWon": false, + "maxLevel": 100, + "expected": { + "attacker": { + "petId": "1", + "won": false, + "decayShift": 0, + "xpAwarded": 27, + "lastOpponentId": "2", + "streak": 0, + "level": 10, + "xp": 147, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": true, + "decayShift": 3, + "xpAwarded": 11, + "lastOpponentId": "1", + "streak": 3, + "level": 11, + "xp": 56, + "leveledUp": false + } + } + }, + { + "name": "rematch-both-streaked", + "note": "Both pets have fought each other last, so both streaks advance and both awards are halved.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "2", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 0, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "attackerWon": true, + "maxLevel": 100, + "expected": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 1, + "xpAwarded": 55, + "lastOpponentId": "2", + "streak": 1, + "level": 10, + "xp": 175, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 1, + "xpAwarded": 11, + "lastOpponentId": "1", + "streak": 1, + "level": 11, + "xp": 56, + "leveledUp": false + } + } + }, + { + "name": "streak-zeroes-award", + "note": "A long streak drives the award to zero, which then leaves level and XP untouched because both chains guard the write with `if (xp > 0)`.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "2", + "streak": 12, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 12, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "attackerWon": true, + "maxLevel": 100, + "expected": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 13, + "xpAwarded": 0, + "lastOpponentId": "2", + "streak": 13, + "level": 10, + "xp": 120, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 13, + "xpAwarded": 0, + "lastOpponentId": "1", + "streak": 13, + "level": 11, + "xp": 45, + "leveledUp": false + } + } + }, + { + "name": "punching-up", + "note": "Attacker ten levels below the defender wins: the multiplier caps at 200, so the award doubles.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 5, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 15, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "attackerWon": true, + "maxLevel": 100, + "expected": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 0, + "xpAwarded": 200, + "lastOpponentId": "2", + "streak": 0, + "level": 5, + "xp": 320, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 3, + "xpAwarded": 0, + "lastOpponentId": "1", + "streak": 3, + "level": 15, + "xp": 45, + "leveledUp": false + } + } + }, + { + "name": "punching-down", + "note": "Attacker ten levels above wins: multiplier floors at 0, so the winner earns nothing while the loser still earns its share.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 20, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 10, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "attackerWon": true, + "maxLevel": 100, + "expected": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 0, + "xpAwarded": 0, + "lastOpponentId": "2", + "streak": 0, + "level": 20, + "xp": 120, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 3, + "xpAwarded": 6, + "lastOpponentId": "1", + "streak": 3, + "level": 10, + "xp": 51, + "leveledUp": false + } + } + }, + { + "name": "level-up-on-win", + "note": "Attacker sitting one award short of its threshold levels up, carrying the remainder.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 950, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "attackerWon": true, + "maxLevel": 100, + "expected": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 0, + "xpAwarded": 110, + "lastOpponentId": "2", + "streak": 0, + "level": 11, + "xp": 60, + "leveledUp": true + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 3, + "xpAwarded": 2, + "lastOpponentId": "1", + "streak": 3, + "level": 11, + "xp": 47, + "leveledUp": false + } + } + }, + { + "name": "winner-at-level-cap", + "note": "Winner sits at the cap and accrues nothing, not even partial XP, while the loser below the cap still accrues. `xpAwarded` stays populated for both, mirroring the on-chain event, which reports the computed award whether or not the cap swallowed it.", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 12, + "skill": 4, + "xp": 40, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1861920000 + }, + "attackerWon": true, + "maxLevel": 12, + "expected": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 0, + "xpAwarded": 90, + "lastOpponentId": "2", + "streak": 0, + "level": 12, + "xp": 40, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 3, + "xpAwarded": 3, + "lastOpponentId": "1", + "streak": 3, + "level": 11, + "xp": 48, + "leveledUp": false + } + } + } + ] +} diff --git a/protocol/scripts/gen-vectors.ts b/protocol/scripts/gen-vectors.ts index a32e408c..d0c17620 100644 --- a/protocol/scripts/gen-vectors.ts +++ b/protocol/scripts/gen-vectors.ts @@ -25,6 +25,7 @@ import { import type { ChainId } from '../src/domain/chainId'; import type { Hex } from '../src/encoding/bytes'; import { battleIntentSolanaMessage, type BattleIntent, hashBattleIntent } from '../src/intent'; +import { computeProgression, type ProgressionParams } from '../src/progression'; import { deriveBattleSeed, type SeedInputs } from '../src/randomness'; import { type BattleSnapshot, hashBattleSnapshot, type PetSnapshot } from '../src/snapshot'; @@ -725,8 +726,168 @@ function writeCommitmentVectors(): void { process.stdout.write(`wrote ${out.cases.length} commitment cases to ${path}\n`); } +/** + * Progression cases, in the snapshot shape §F's workstream introduces. + * + * `contracts/test-vectors/xp.json` already pins the formula and the decay across + * Solidity, Rust, and Go, and this port is tested against that file directly. What + * it does not cover is the composition: which pet gets the winner's base, which + * decay shift applies to whom, and how the level threshold interacts with a zero + * award. That is what these cases pin, and what indexer-go's own progression port + * will have to match at Step 25. + */ +interface ProgressionFixture { + snapshot: SnapshotFixture; + attackerWon: boolean; + maxLevel: number; +} + +const PROGRESSION_BASE: ProgressionFixture = { + snapshot: SNAPSHOT_BASE, + attackerWon: true, + maxLevel: 100, +}; + +const progressionCases: { name: string; note: string; fixture: ProgressionFixture }[] = [ + { + name: 'attacker-wins-fresh', + note: 'Baseline. Attacker has no prior opponent so takes no decay; defender is mid-streak against this attacker, so its loss XP is decayed.', + fixture: PROGRESSION_BASE, + }, + { + name: 'defender-wins', + note: 'Same snapshot, other winner. The winner base (100) and loser base (25) swap sides, as do the level arguments.', + fixture: { ...PROGRESSION_BASE, attackerWon: false }, + }, + { + name: 'rematch-both-streaked', + note: 'Both pets have fought each other last, so both streaks advance and both awards are halved.', + fixture: { + ...PROGRESSION_BASE, + snapshot: { + ...SNAPSHOT_BASE, + attacker: { ...SNAPSHOT_BASE.attacker, lastOpponentId: '2', streak: 0 }, + defender: { ...SNAPSHOT_BASE.defender, lastOpponentId: '1', streak: 0 }, + }, + }, + }, + { + name: 'streak-zeroes-award', + note: 'A long streak drives the award to zero, which then leaves level and XP untouched because both chains guard the write with `if (xp > 0)`.', + fixture: { + ...PROGRESSION_BASE, + snapshot: { + ...SNAPSHOT_BASE, + attacker: { ...SNAPSHOT_BASE.attacker, lastOpponentId: '2', streak: 12 }, + defender: { ...SNAPSHOT_BASE.defender, lastOpponentId: '1', streak: 12 }, + }, + }, + }, + { + name: 'punching-up', + note: 'Attacker ten levels below the defender wins: the multiplier caps at 200, so the award doubles.', + fixture: { + ...PROGRESSION_BASE, + snapshot: { + ...SNAPSHOT_BASE, + attacker: { ...SNAPSHOT_BASE.attacker, level: 5 }, + defender: { ...SNAPSHOT_BASE.defender, level: 15 }, + }, + }, + }, + { + name: 'punching-down', + note: 'Attacker ten levels above wins: multiplier floors at 0, so the winner earns nothing while the loser still earns its share.', + fixture: { + ...PROGRESSION_BASE, + snapshot: { + ...SNAPSHOT_BASE, + attacker: { ...SNAPSHOT_BASE.attacker, level: 20 }, + defender: { ...SNAPSHOT_BASE.defender, level: 10 }, + }, + }, + }, + { + name: 'level-up-on-win', + note: 'Attacker sitting one award short of its threshold levels up, carrying the remainder.', + fixture: { + ...PROGRESSION_BASE, + snapshot: { + ...SNAPSHOT_BASE, + attacker: { ...SNAPSHOT_BASE.attacker, level: 10, xp: 950 }, + }, + }, + }, + { + name: 'winner-at-level-cap', + note: 'Winner sits at the cap and accrues nothing, not even partial XP, while the loser below the cap still accrues. `xpAwarded` stays populated for both, mirroring the on-chain event, which reports the computed award whether or not the cap swallowed it.', + fixture: { + ...PROGRESSION_BASE, + maxLevel: 12, + snapshot: { + ...SNAPSHOT_BASE, + attacker: { ...SNAPSHOT_BASE.attacker, level: 12, xp: 40 }, + }, + }, + }, +]; + +function writeProgressionVectors(): void { + const out = { + description: + 'Progression-delta vectors in the frozen-snapshot shape (docs/plan-backend-battle-architecture.md §F). Generated by protocol/scripts/gen-vectors.ts from protocol/src/progression. The XP formula and decay themselves are pinned cross-language by contracts/test-vectors/xp.json; these cases pin the composition (which base applies to whom, which decay shift, and the level-threshold interaction). A failure means the implementation drifted. Never edit an expectation to match new output.', + cases: progressionCases.map((c) => { + const params: ProgressionParams = { maxLevel: c.fixture.maxLevel }; + const delta = computeProgression( + snapshotFromFixture(c.fixture.snapshot), + c.fixture.attackerWon, + params, + ); + return { + name: c.name, + note: c.note, + snapshot: c.fixture.snapshot, + attackerWon: c.fixture.attackerWon, + maxLevel: c.fixture.maxLevel, + expected: { + attacker: serializeProgression(delta.attacker), + defender: serializeProgression(delta.defender), + }, + }; + }), + }; + const path = join(VECTORS_DIR, 'protocol-progression.json'); + writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`); + process.stdout.write(`wrote ${out.cases.length} progression cases to ${path}\n`); +} + +function serializeProgression(progression: { + petId: bigint; + won: boolean; + decayShift: number; + xpAwarded: number; + lastOpponentId: bigint; + streak: number; + level: number; + xp: number; + leveledUp: boolean; +}) { + return { + petId: progression.petId.toString(), + won: progression.won, + decayShift: progression.decayShift, + xpAwarded: progression.xpAwarded, + lastOpponentId: progression.lastOpponentId.toString(), + streak: progression.streak, + level: progression.level, + xp: progression.xp, + leveledUp: progression.leveledUp, + }; +} + writeIntentVectors(); writeConsentVectors(); writeSnapshotVectors(); writeSeedVectors(); writeCommitmentVectors(); +writeProgressionVectors(); diff --git a/protocol/src/combat/index.ts b/protocol/src/combat/index.ts index 266435f5..e2ac28d7 100644 --- a/protocol/src/combat/index.ts +++ b/protocol/src/combat/index.ts @@ -17,6 +17,22 @@ export { export type { StrikeOutcome } from './strike'; export { addHeal, strike } from './strike'; export { MAX_ROUNDS, simulate, type SimOutcome, type SimResult, type StrikeLogEntry } from './sim'; +export { + applyDecayShift, + applyXp, + BASE_XP_LOSS, + BASE_XP_WIN, + calcXp, + DEFAULT_MAX_LEVEL, + type LevelState, + type LevelStateUpdate, + MAX_DECAY_SHIFT, + MAX_SAME_OPPONENT_STREAK, + type OpponentHistory, + type OpponentHistoryUpdate, + recordBattleOpponent, + XP_PER_LEVEL_MULTIPLIER, +} from './xp'; export { encodeSimOutcome, decodeSimOutcome, diff --git a/protocol/src/combat/xp.ts b/protocol/src/combat/xp.ts new file mode 100644 index 00000000..b8b03001 --- /dev/null +++ b/protocol/src/combat/xp.ts @@ -0,0 +1,133 @@ +/** + * XP and level progression, ported from the on-chain implementations: + * `GameLogic._calcXp` + `PetCore.addXp` + `PetCore.recordBattleOpponent` (Solidity), + * `game::xp::calc_xp` + `PetAccount::add_xp` + `PetAccount::record_battle_opponent` + * (Rust), and `indexer-go/internal/combat/xp.go` (Go, which covers the formula and + * the decay but not level-up). + * + * Validated against `contracts/test-vectors/xp.json`, the same file Hardhat, Anchor, + * and indexer-go consume. If a case fails, this port drifted; fix the port, never + * the vector. + * + * Pure number math, no snapshot awareness. The snapshot-shaped wrapper that turns a + * battle result into a progression delta lives in `src/progression/`, so this file + * stays a line-for-line analogue of its siblings. + */ + +/** Base XP for the winner, before the level multiplier and decay. */ +export const BASE_XP_WIN = 100; +/** Base XP for the loser. */ +export const BASE_XP_LOSS = 25; +/** XP needed to advance: `100 * currentLevel`. */ +export const XP_PER_LEVEL_MULTIPLIER = 100; +/** `sameOpponentStreak` is a uint8 on both chains and saturates rather than wrapping. */ +export const MAX_SAME_OPPONENT_STREAK = 255; +/** + * Ceiling on the decay shift. + * + * The streak can reach 255, but the XP being shifted is a uint32. Solidity defines a + * shift at or past the operand width as 0; Rust would panic with overflow checks on, + * so `settle_battle.rs` clamps with `.min(31)`; Go also yields 0. JavaScript is the + * odd one out: `>>` masks the shift count to 5 bits, so `200 >> 32` is `200`, not 0. + * Clamping here is what keeps this port from silently paying full XP exactly where + * the chains pay none. + */ +export const MAX_DECAY_SHIFT = 31; + +/** Default level cap, mirroring `GameConfig.maxLevel`'s initializer. Owner-tunable on chain. */ +export const DEFAULT_MAX_LEVEL = 100; + +/** + * XP for one battle before decay: + * `baseXp * clamp(100 + 10 * (oppLevel - myLevel), 0, 200) / 100`. + * + * Punching up ten levels pays double; fighting ten levels down pays nothing. + */ +export function calcXp(baseXp: number, myLevel: number, oppLevel: number): number { + const diff = oppLevel - myLevel; + const mult = 100 + 10 * diff; + if (mult <= 0) { + return 0; + } + const capped = mult > 200 ? 200 : mult; + return Math.floor((baseXp * capped) / 100); +} + +/** Applies same-opponent decay to an XP award. */ +export function applyDecayShift(xp: number, decayShift: number): number { + const shift = decayShift > MAX_DECAY_SHIFT ? MAX_DECAY_SHIFT : decayShift; + return xp >>> shift; +} + +/** A pet's same-opponent tracking state, as frozen in a snapshot. */ +export interface OpponentHistory { + /** Previous opponent, or 0 for a pet that has not fought. */ + lastOpponentId: bigint; + /** Consecutive prior battles against `lastOpponentId`. */ + streak: number; +} + +/** `OpponentHistory` after a battle, plus the shift that battle earned. */ +export interface OpponentHistoryUpdate extends OpponentHistory { + /** XP right-shift for this battle: 0 = full, 1 = half, 2 = quarter. */ + decayShift: number; +} + +/** + * Advances a pet's same-opponent history, mirroring `recordBattleOpponent`. + * + * Fighting the same opponent again increments the streak (saturating at 255) and the + * new value is the shift, so the second consecutive rematch pays half, the third a + * quarter. Facing anyone else resets to 0, which is why grinding one opponent stops + * being worth it while switching targets always pays full. + */ +export function recordBattleOpponent(history: OpponentHistory, opponentId: bigint): OpponentHistoryUpdate { + if (history.lastOpponentId === opponentId) { + const streak = + history.streak < MAX_SAME_OPPONENT_STREAK ? history.streak + 1 : MAX_SAME_OPPONENT_STREAK; + return { lastOpponentId: history.lastOpponentId, streak, decayShift: streak }; + } + return { lastOpponentId: opponentId, streak: 0, decayShift: 0 }; +} + +/** A pet's level and XP. */ +export interface LevelState { + level: number; + xp: number; +} + +/** `LevelState` after an XP award. */ +export interface LevelStateUpdate extends LevelState { + leveledUp: boolean; +} + +/** + * Credits XP and advances at most one level, mirroring `PetCore.addXp` / + * `PetAccount::add_xp`. + * + * Three behaviours worth being explicit about, because all three are easy to + * "improve" into a divergence from the chains: + * + * - A pet at the level cap accrues nothing at all. Not capped XP, none: the on-chain + * version returns before touching `xp`. + * - At most one level per battle. Leftover XP beyond a second threshold stays as XP. + * - The threshold is `100 * level` at the pre-increment level. + */ +export function applyXp(state: LevelState, amount: number, maxLevel: number = DEFAULT_MAX_LEVEL): LevelStateUpdate { + if (state.level >= maxLevel) { + return { level: state.level, xp: state.xp, leveledUp: false }; + } + let xp = state.xp + amount; + let level = state.level; + const threshold = XP_PER_LEVEL_MULTIPLIER * level; + let leveledUp = false; + if (xp >= threshold) { + xp -= threshold; + level += 1; + if (level > maxLevel) { + level = maxLevel; + } + leveledUp = true; + } + return { level, xp, leveledUp }; +} diff --git a/protocol/src/index.ts b/protocol/src/index.ts index 4b8855c4..a5a1e623 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -17,5 +17,6 @@ export * from './consent'; export * from './domain'; export * from './encoding'; export * from './intent'; +export * from './progression'; export * from './randomness'; export * from './snapshot'; diff --git a/protocol/src/progression/index.ts b/protocol/src/progression/index.ts new file mode 100644 index 00000000..dd0e00a9 --- /dev/null +++ b/protocol/src/progression/index.ts @@ -0,0 +1,7 @@ +export { + computeProgression, + DEFAULT_PROGRESSION_PARAMS, + type PetProgression, + type ProgressionDelta, + type ProgressionParams, +} from './progression'; diff --git a/protocol/src/progression/progression.ts b/protocol/src/progression/progression.ts new file mode 100644 index 00000000..dfc9794d --- /dev/null +++ b/protocol/src/progression/progression.ts @@ -0,0 +1,123 @@ +import { + applyDecayShift, + applyXp, + BASE_XP_LOSS, + BASE_XP_WIN, + calcXp, + DEFAULT_MAX_LEVEL, + recordBattleOpponent, +} from '../combat/xp'; +import { assertBattleSnapshot, type BattleSnapshot, type PetSnapshot } from '../snapshot/types'; + +/** + * Turns a frozen snapshot plus a winner into the progression change a battle causes. + * + * This is the piece that made off-chain XP possible. XP depends on same-opponent + * decay state, which used to live only on chain, so the client-side port stopped at + * fight math and receipts could not carry a meaningful progression delta. Now that + * `lastOpponentId` and `streak` are frozen into the snapshot, progression is a pure + * function of the receipt's own inputs, which means a stranger can recompute it + * without access to any of our tables (§F). + * + * Levels come from the snapshot, not from live state, matching the fix already made + * on both chains: the simulation and the XP calculation agree on one set of committed + * inputs instead of one using frozen levels while the other reads whatever the live + * values happen to be at settle time. + */ + +/** Ruleset-supplied progression parameters. */ +export interface ProgressionParams { + /** Level cap. No XP accrues at or past it. */ + maxLevel: number; +} + +export const DEFAULT_PROGRESSION_PARAMS: ProgressionParams = { maxLevel: DEFAULT_MAX_LEVEL }; + +/** What one battle does to one pet. */ +export interface PetProgression { + petId: bigint; + won: boolean; + /** Same-opponent decay shift this battle earned. */ + decayShift: number; + /** + * The computed award, after the level multiplier and decay. + * + * This is the `xpWin`/`xpLoss` the chains emit in `BattleResolved`, which is + * reported whether or not it was actually credited: a pet at the level cap + * accrues nothing, and the event still carries the number. So compare + * `level`/`xp` to see what was applied, not this. + */ + xpAwarded: number; + /** Opponent history after the battle. */ + lastOpponentId: bigint; + streak: number; + /** Level and XP after the battle. */ + level: number; + xp: number; + leveledUp: boolean; +} + +/** What one battle does to both pets. */ +export interface ProgressionDelta { + attacker: PetProgression; + defender: PetProgression; +} + +/** + * Computes the progression delta for a settled battle. + * + * `attackerWon` is `SimResult.firstWins`, since the simulator states its result from + * the attacker's perspective. + */ +export function computeProgression( + snapshot: BattleSnapshot, + attackerWon: boolean, + params: ProgressionParams = DEFAULT_PROGRESSION_PARAMS, +): ProgressionDelta { + const checked = assertBattleSnapshot(snapshot); + const { attacker, defender } = checked; + + // Each pet records the other, so a rematch advances both streaks independently. + const attackerHistory = recordBattleOpponent(attacker, defender.petId); + const defenderHistory = recordBattleOpponent(defender, attacker.petId); + + const winner = attackerWon ? attacker : defender; + const loser = attackerWon ? defender : attacker; + const winnerShift = attackerWon ? attackerHistory.decayShift : defenderHistory.decayShift; + const loserShift = attackerWon ? defenderHistory.decayShift : attackerHistory.decayShift; + + const xpWin = applyDecayShift(calcXp(BASE_XP_WIN, winner.level, loser.level), winnerShift); + const xpLoss = applyDecayShift(calcXp(BASE_XP_LOSS, loser.level, winner.level), loserShift); + + return { + attacker: petProgression(attacker, attackerHistory, attackerWon, attackerWon ? xpWin : xpLoss, params), + defender: petProgression(defender, defenderHistory, !attackerWon, attackerWon ? xpLoss : xpWin, params), + }; +} + +function petProgression( + pet: PetSnapshot, + history: { lastOpponentId: bigint; streak: number; decayShift: number }, + won: boolean, + xpAwarded: number, + params: ProgressionParams, +): PetProgression { + // Both chains guard the XP write with `if (xp > 0)`, so a zero award leaves level + // and XP untouched rather than running the threshold check with nothing added. + const levelState = + xpAwarded > 0 + ? applyXp({ level: pet.level, xp: pet.xp }, xpAwarded, params.maxLevel) + : { level: pet.level, xp: pet.xp, leveledUp: false }; + + return { + petId: pet.petId, + won, + decayShift: history.decayShift, + xpAwarded, + lastOpponentId: history.lastOpponentId, + streak: history.streak, + level: levelState.level, + xp: levelState.xp, + leveledUp: levelState.leveledUp, + }; +} diff --git a/protocol/tests/combat/xp.test.ts b/protocol/tests/combat/xp.test.ts new file mode 100644 index 00000000..7bfba061 --- /dev/null +++ b/protocol/tests/combat/xp.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyDecayShift, + applyXp, + BASE_XP_LOSS, + BASE_XP_WIN, + calcXp, + DEFAULT_MAX_LEVEL, + MAX_DECAY_SHIFT, + MAX_SAME_OPPONENT_STREAK, + recordBattleOpponent, +} from '../../src/combat'; + +describe('calcXp', () => { + it('uses the base values the chains use', () => { + expect(BASE_XP_WIN).toBe(100); + expect(BASE_XP_LOSS).toBe(25); + }); + + it('floors rather than rounds, matching integer division on chain', () => { + // 25 * 150 / 100 = 37.5 on chain becomes 37, not 38. + expect(calcXp(25, 10, 15)).toBe(37); + }); + + it('clamps the multiplier at both ends', () => { + expect(calcXp(100, 10, 20)).toBe(200); // +10 levels, cap + expect(calcXp(100, 10, 30)).toBe(200); // beyond cap, same + expect(calcXp(100, 20, 10)).toBe(0); // -10 levels, floor + expect(calcXp(100, 30, 10)).toBe(0); // beyond floor, same + }); + + it('returns 0 rather than a negative award', () => { + expect(calcXp(100, 100, 1)).toBe(0); + }); +}); + +describe('applyDecayShift', () => { + it('halves per streak step', () => { + expect(applyDecayShift(100, 0)).toBe(100); + expect(applyDecayShift(100, 1)).toBe(50); + expect(applyDecayShift(100, 2)).toBe(25); + expect(applyDecayShift(100, 3)).toBe(12); + }); + + it('clamps the shift, because JavaScript would otherwise pay full XP', () => { + // `200 >> 32` is 200 in JavaScript: `>>` masks the count to 5 bits. Solidity + // yields 0 at or past the operand width, Rust clamps to 31, Go yields 0. Without + // the clamp this port would pay full XP exactly where the chains pay none. + expect(applyDecayShift(200, 32)).toBe(0); + expect(applyDecayShift(200, 33)).toBe(0); + expect(applyDecayShift(200, MAX_SAME_OPPONENT_STREAK)).toBe(0); + expect(MAX_DECAY_SHIFT).toBe(31); + }); + + it('reaches zero well before the clamp for real award sizes', () => { + // Base XP is at most 200, so any streak of 8 or more already pays nothing on + // every implementation. The clamp only matters for correctness of the tail. + expect(applyDecayShift(200, 8)).toBe(0); + }); +}); + +describe('recordBattleOpponent', () => { + it('starts a fresh pet at no decay', () => { + expect(recordBattleOpponent({ lastOpponentId: 0n, streak: 0 }, 7n)).toEqual({ + lastOpponentId: 7n, + streak: 0, + decayShift: 0, + }); + }); + + it('advances the streak on a rematch', () => { + expect(recordBattleOpponent({ lastOpponentId: 7n, streak: 0 }, 7n)).toEqual({ + lastOpponentId: 7n, + streak: 1, + decayShift: 1, + }); + expect(recordBattleOpponent({ lastOpponentId: 7n, streak: 1 }, 7n)).toEqual({ + lastOpponentId: 7n, + streak: 2, + decayShift: 2, + }); + }); + + it('resets when the opponent changes', () => { + expect(recordBattleOpponent({ lastOpponentId: 7n, streak: 5 }, 9n)).toEqual({ + lastOpponentId: 9n, + streak: 0, + decayShift: 0, + }); + }); + + it('saturates the streak instead of wrapping', () => { + // uint8 on both chains: `if (streak < type(uint8).max) streak++`. + expect(recordBattleOpponent({ lastOpponentId: 7n, streak: MAX_SAME_OPPONENT_STREAK }, 7n)).toEqual({ + lastOpponentId: 7n, + streak: MAX_SAME_OPPONENT_STREAK, + decayShift: MAX_SAME_OPPONENT_STREAK, + }); + }); + + it('treats opponent 0 as a real change for a pet whose last opponent was real', () => { + expect(recordBattleOpponent({ lastOpponentId: 7n, streak: 3 }, 0n).streak).toBe(0); + }); +}); + +describe('applyXp', () => { + it('accrues XP below the threshold', () => { + expect(applyXp({ level: 10, xp: 120 }, 200)).toEqual({ level: 10, xp: 320, leveledUp: false }); + }); + + it('levels up at exactly the threshold and carries the remainder', () => { + // threshold = 100 * level, subtracted rather than zeroed. + expect(applyXp({ level: 10, xp: 900 }, 100)).toEqual({ level: 11, xp: 0, leveledUp: true }); + expect(applyXp({ level: 10, xp: 950 }, 100)).toEqual({ level: 11, xp: 50, leveledUp: true }); + }); + + it('advances at most one level per battle', () => { + // 5000 XP at level 1 clears many thresholds; the chains still advance once. + expect(applyXp({ level: 1, xp: 0 }, 5000)).toEqual({ level: 2, xp: 4900, leveledUp: true }); + }); + + it('accrues nothing at all at the level cap', () => { + // The on-chain version returns before touching xp, so this is "no accrual", + // not "capped accrual". + expect(applyXp({ level: DEFAULT_MAX_LEVEL, xp: 10 }, 500)).toEqual({ + level: DEFAULT_MAX_LEVEL, + xp: 10, + leveledUp: false, + }); + }); + + it('respects a lowered cap from the ruleset', () => { + expect(applyXp({ level: 20, xp: 0 }, 5000, 20)).toEqual({ level: 20, xp: 0, leveledUp: false }); + // threshold at level 19 is 1900, so 2000 XP leaves 100 after the level-up. + expect(applyXp({ level: 19, xp: 1900 }, 100, 20)).toEqual({ level: 20, xp: 100, leveledUp: true }); + }); + + it('handles a level-1 pet, whose threshold is 100', () => { + expect(applyXp({ level: 1, xp: 0 }, 99)).toEqual({ level: 1, xp: 99, leveledUp: false }); + expect(applyXp({ level: 1, xp: 0 }, 100)).toEqual({ level: 2, xp: 0, leveledUp: true }); + }); +}); diff --git a/protocol/tests/combat/xpGoldenVectors.test.ts b/protocol/tests/combat/xpGoldenVectors.test.ts new file mode 100644 index 00000000..f6401020 --- /dev/null +++ b/protocol/tests/combat/xpGoldenVectors.test.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { applyDecayShift, calcXp, recordBattleOpponent } from '../../src/combat'; + +/** + * Consumes contracts/test-vectors/xp.json directly, the same file Hardhat + * (`GameLogic._calcXp`), Anchor (`game::xp::calc_xp`), and indexer-go + * (`xp.go`) already consume. No separate vector format, and no new expectations + * invented for this port: it either agrees with the three existing + * implementations or it does not. + * + * If a case fails, this TypeScript port drifted. Fix the port, never the vector. + */ +interface CalcXpCase { + name: string; + baseXp: number; + myLevel: number; + oppLevel: number; + expectedXp: number; +} + +interface DecaySequence { + name: string; + opponentIds: number[]; + expectedDecayShifts: number[]; + baseXp: number; + expectedXp: number[]; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const vectorsPath = join(here, '../../../contracts/test-vectors/xp.json'); +const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')) as { + calcXpCases: CalcXpCase[]; + decaySequences: DecaySequence[]; +}; + +describe('calcXp golden vectors', () => { + for (const c of vectors.calcXpCases) { + it(`matches "${c.name}"`, () => { + expect(calcXp(c.baseXp, c.myLevel, c.oppLevel)).toBe(c.expectedXp); + }); + } +}); + +describe('same-opponent decay golden vectors', () => { + for (const sequence of vectors.decaySequences) { + it(`reproduces the decay shifts for "${sequence.name}"`, () => { + // Folded from a fresh pet (lastOpponentId 0, streak 0), exactly as the + // vector file describes and as `applyDecay` does in xp.go. + let history = { lastOpponentId: 0n, streak: 0 }; + const shifts: number[] = []; + for (const opponentId of sequence.opponentIds) { + const update = recordBattleOpponent(history, BigInt(opponentId)); + shifts.push(update.decayShift); + history = { lastOpponentId: update.lastOpponentId, streak: update.streak }; + } + expect(shifts).toEqual(sequence.expectedDecayShifts); + }); + + it(`reproduces the decayed XP for "${sequence.name}"`, () => { + const awarded = sequence.expectedDecayShifts.map((shift) => + applyDecayShift(calcXp(sequence.baseXp, 10, 10), shift), + ); + expect(awarded).toEqual(sequence.expectedXp); + }); + } +}); diff --git a/protocol/tests/progression/vectors.test.ts b/protocol/tests/progression/vectors.test.ts new file mode 100644 index 00000000..2c041cf1 --- /dev/null +++ b/protocol/tests/progression/vectors.test.ts @@ -0,0 +1,175 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import type { ChainId } from '../../src/domain/chainId'; +import { computeProgression, type PetProgression } from '../../src/progression'; +import type { BattleSnapshot, PetSnapshot } from '../../src/snapshot'; + +/** + * Consumes contracts/test-vectors/protocol-progression.json. A failure means the + * implementation drifted, and the fix is the code, never the vector (`AGENTS.md`). + * + * The formula and decay are pinned cross-language by `xp.json` (see + * `tests/combat/xpGoldenVectors.test.ts`). These cases pin the composition around + * them, which is where a port is most likely to go wrong: swapping which base + * applies to whom, or applying the winner's decay shift to the loser. + */ +interface PetFixture { + petId: string; + owner: string; + dna: string; + rarity: number; + level: number; + skill: number; + xp: number; + lastOpponentId: string; + streak: number; + readyAt: number; + sourceVersion: string; +} + +interface SerializedProgression { + petId: string; + won: boolean; + decayShift: number; + xpAwarded: number; + lastOpponentId: string; + streak: number; + level: number; + xp: number; + leveledUp: boolean; +} + +interface ProgressionCase { + name: string; + note: string; + snapshot: { + chainId: string; + deploymentId: string; + attacker: PetFixture; + defender: PetFixture; + takenAt: number; + }; + attackerWon: boolean; + maxLevel: number; + expected: { attacker: SerializedProgression; defender: SerializedProgression }; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const vectorsPath = join(here, '../../../contracts/test-vectors/protocol-progression.json'); +const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')) as { cases: ProgressionCase[] }; + +function toPet(fixture: PetFixture): PetSnapshot { + return { + petId: BigInt(fixture.petId), + owner: fixture.owner, + dna: BigInt(fixture.dna), + rarity: fixture.rarity, + level: fixture.level, + skill: fixture.skill, + xp: fixture.xp, + lastOpponentId: BigInt(fixture.lastOpponentId), + streak: fixture.streak, + readyAt: fixture.readyAt, + sourceVersion: BigInt(fixture.sourceVersion), + }; +} + +function toSnapshot(c: ProgressionCase): BattleSnapshot { + return { + domain: { chainId: c.snapshot.chainId as ChainId, deploymentId: c.snapshot.deploymentId }, + attacker: toPet(c.snapshot.attacker), + defender: toPet(c.snapshot.defender), + takenAt: c.snapshot.takenAt, + }; +} + +function serialize(progression: PetProgression): SerializedProgression { + return { + petId: progression.petId.toString(), + won: progression.won, + decayShift: progression.decayShift, + xpAwarded: progression.xpAwarded, + lastOpponentId: progression.lastOpponentId.toString(), + streak: progression.streak, + level: progression.level, + xp: progression.xp, + leveledUp: progression.leveledUp, + }; +} + +const byName = new Map(vectors.cases.map((c) => [c.name, c])); +const deltaOf = (name: string) => { + const found = byName.get(name); + if (!found) throw new Error(`vector case missing: ${name}`); + return computeProgression(toSnapshot(found), found.attackerWon, { maxLevel: found.maxLevel }); +}; + +describe('progression golden vectors', () => { + for (const c of vectors.cases) { + it(`matches the recorded delta for "${c.name}"`, () => { + const delta = computeProgression(toSnapshot(c), c.attackerWon, { maxLevel: c.maxLevel }); + expect(serialize(delta.attacker)).toEqual(c.expected.attacker); + expect(serialize(delta.defender)).toEqual(c.expected.defender); + }); + } +}); + +describe('properties the vectors exist to pin', () => { + it('gives the winner the winner base and the loser the loser base', () => { + const attackerWins = deltaOf('attacker-wins-fresh'); + const defenderWins = deltaOf('defender-wins'); + // Same pets, same levels, only the winner differs, so the awards must swap + // rather than stay attached to a role. + expect(attackerWins.attacker.xpAwarded).toBeGreaterThan(attackerWins.defender.xpAwarded); + expect(defenderWins.attacker.won).toBe(false); + expect(defenderWins.defender.won).toBe(true); + }); + + it('applies each pet own decay shift, not the winner shift to both', () => { + const delta = deltaOf('attacker-wins-fresh'); + // Attacker faces a new opponent (shift 0); defender is on a streak (shift 3). + expect(delta.attacker.decayShift).toBe(0); + expect(delta.defender.decayShift).toBe(3); + }); + + it('advances both streaks on a rematch', () => { + const delta = deltaOf('rematch-both-streaked'); + expect(delta.attacker.streak).toBe(1); + expect(delta.defender.streak).toBe(1); + }); + + it('leaves level and XP untouched when decay zeroes the award', () => { + const delta = deltaOf('streak-zeroes-award'); + const source = byName.get('streak-zeroes-award')!; + expect(delta.attacker.xpAwarded).toBe(0); + expect(delta.attacker.xp).toBe(source.snapshot.attacker.xp); + expect(delta.attacker.level).toBe(source.snapshot.attacker.level); + }); + + it('doubles for punching up and zeroes for punching down', () => { + expect(deltaOf('punching-up').attacker.xpAwarded).toBe(200); + expect(deltaOf('punching-down').attacker.xpAwarded).toBe(0); + }); + + it('reports the award at the level cap while crediting nothing', () => { + // `xpAwarded` mirrors the on-chain event, which carries the computed number + // whether or not the cap swallowed it. What was applied is level/xp. + const delta = deltaOf('winner-at-level-cap'); + const source = byName.get('winner-at-level-cap')!; + expect(delta.attacker.xpAwarded).toBeGreaterThan(0); + expect(delta.attacker.xp).toBe(source.snapshot.attacker.xp); + expect(delta.attacker.level).toBe(source.snapshot.attacker.level); + // The loser is below the cap, so it still accrues. + expect(delta.defender.xp).toBeGreaterThan(source.snapshot.defender.xp); + }); + + it('levels up and carries the remainder', () => { + const delta = deltaOf('level-up-on-win'); + expect(delta.attacker.leveledUp).toBe(true); + expect(delta.attacker.level).toBe(11); + expect(delta.attacker.xp).toBe(60); + }); +}); From 90201139cdfa373f5b38aca3e8557c6975ab4b59 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 08:05:13 -0400 Subject: [PATCH 15/76] feat(protocol): add content-addressed ruleset versioning and hashing --- contracts/test-vectors/protocol-ruleset.json | 313 +++++++++++++++++++ protocol/scripts/gen-vectors.ts | 80 +++++ protocol/src/index.ts | 1 + protocol/src/ruleset/bundle.ts | 95 ++++++ protocol/src/ruleset/hash.ts | 47 +++ protocol/src/ruleset/index.ts | 10 + protocol/src/ruleset/types.ts | 144 +++++++++ protocol/tests/ruleset/bundle.test.ts | 152 +++++++++ protocol/tests/ruleset/vectors.test.ts | 58 ++++ 9 files changed, 900 insertions(+) create mode 100644 contracts/test-vectors/protocol-ruleset.json create mode 100644 protocol/src/ruleset/bundle.ts create mode 100644 protocol/src/ruleset/hash.ts create mode 100644 protocol/src/ruleset/index.ts create mode 100644 protocol/src/ruleset/types.ts create mode 100644 protocol/tests/ruleset/bundle.test.ts create mode 100644 protocol/tests/ruleset/vectors.test.ts diff --git a/contracts/test-vectors/protocol-ruleset.json b/contracts/test-vectors/protocol-ruleset.json new file mode 100644 index 00000000..43b7b2ef --- /dev/null +++ b/contracts/test-vectors/protocol-ruleset.json @@ -0,0 +1,313 @@ +{ + "description": "Ruleset canonical-hash vectors (docs/plan-backend-battle-architecture.md §F, §H). Generated by protocol/scripts/gen-vectors.ts from protocol/src/ruleset. A ruleset hash is chain-agnostic on purpose: the same rules can run on either chain. A failure means the implementation drifted. Never edit an expectation to match new output.", + "cases": [ + { + "name": "source-defaults", + "note": "The ruleset this build implements with GameConfig source defaults. Anchors every other case.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8" + }, + { + "name": "version-bump", + "note": "Same rules, higher version number. Must differ: the version is part of the identity, so a republished bundle cannot claim an old hash.", + "ruleset": { + "version": 2, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0x95596ce30d34285e0aa435b082d2d6336693f00180a9759bae2f09a780d5105a" + }, + { + "name": "engine-version-bump", + "note": "Same parameters, new engine version. Must differ: a fight-math change is recorded here, since code cannot hash itself.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 2, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0xe2a4b9d3d4a265ac946fc26987274b0da3e2ef17ea0ecd611a9068d594fa885e" + }, + { + "name": "other-engine-id", + "note": "Same parameters under a different engine. Must differ.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-go", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0x003b110932468f4f8d816306e114efbd865dd81fed12db96aecb7a16bde31d55" + }, + { + "name": "lower-max-level", + "note": "Level cap lowered. Must differ: the cap decides whether XP accrues at all.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 20 + }, + "expectedRulesetHash": "0xf296c4880a51aa8c908ca915a2a598febf81d29108ebaa384a3f7fb02a560725" + }, + { + "name": "fewer-max-rounds", + "note": "Round cap lowered. Must differ.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 20, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0xddf769742a1116f7c3916cc86c73c50f996de32ca3238659e69268d21b5d7a20" + }, + { + "name": "skill-tankHpMult", + "note": "tankHpMult raised by one. Must differ from source-defaults and from every other skill case: a tunable that does not move the hash is a balance change nobody consented to.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 121, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0x94dd18c980bc877ba4bab118a57a4334d13749e190bc76a532ad6b8ea21800ce" + }, + { + "name": "skill-shellDefMult", + "note": "shellDefMult raised by one. Must differ from source-defaults and from every other skill case: a tunable that does not move the hash is a balance change nobody consented to.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 126, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0x9c993e1793e1c6ae6682e5fc5ce94abf793602ee3dd20d020f16282d1e51ce68" + }, + { + "name": "skill-swiftCritBonus", + "note": "swiftCritBonus raised by one. Must differ from source-defaults and from every other skill case: a tunable that does not move the hash is a balance change nobody consented to.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 51, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0x85b08626437111d9110f65aff26b97dc6f20e3d392a4f9829e6c1f28ee6b972d" + }, + { + "name": "skill-cunningCritCap", + "note": "cunningCritCap raised by one. Must differ from source-defaults and from every other skill case: a tunable that does not move the hash is a balance change nobody consented to.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4001, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0x5c0e8c5327d0779f9ec296063d831f26756d9fbc027aba8c7e9403ee7f89a517" + }, + { + "name": "skill-furyDmgMult", + "note": "furyDmgMult raised by one. Must differ from source-defaults and from every other skill case: a tunable that does not move the hash is a balance change nobody consented to.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 131, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0x0aeb480795a659011127e5d2a70f3dd00a0811f5e9c7a243f46bfa6c9b7fa4e8" + }, + { + "name": "skill-furyHpThreshold", + "note": "furyHpThreshold raised by one. Must differ from source-defaults and from every other skill case: a tunable that does not move the hash is a balance change nobody consented to.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3001, + "sageMdefMult": 125, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0xe566ff3a06bd6d7baa02c1e68c172d6617d19a8288683d8cf3e4c085fc6090a6" + }, + { + "name": "skill-sageMdefMult", + "note": "sageMdefMult raised by one. Must differ from source-defaults and from every other skill case: a tunable that does not move the hash is a balance change nobody consented to.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 126, + "bloodlustBps": 150 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0x360a2d80b2191b26c665158d6e2118bbb62dbc40526f54bfcdf0b1bd00242cec" + }, + { + "name": "skill-bloodlustBps", + "note": "bloodlustBps raised by one. Must differ from source-defaults and from every other skill case: a tunable that does not move the hash is a balance change nobody consented to.", + "ruleset": { + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 151 + }, + "maxLevel": 100 + }, + "expectedRulesetHash": "0x973e0ece8b3e48b96727ea5ec21e9dae39471f8ec5a358236b5c7c415215b2a6" + } + ] +} diff --git a/protocol/scripts/gen-vectors.ts b/protocol/scripts/gen-vectors.ts index d0c17620..93530da6 100644 --- a/protocol/scripts/gen-vectors.ts +++ b/protocol/scripts/gen-vectors.ts @@ -27,6 +27,7 @@ import type { Hex } from '../src/encoding/bytes'; import { battleIntentSolanaMessage, type BattleIntent, hashBattleIntent } from '../src/intent'; import { computeProgression, type ProgressionParams } from '../src/progression'; import { deriveBattleSeed, type SeedInputs } from '../src/randomness'; +import { hashRuleset, type Ruleset, SOURCE_DEFAULT_RULESET } from '../src/ruleset'; import { type BattleSnapshot, hashBattleSnapshot, type PetSnapshot } from '../src/snapshot'; const VECTORS_DIR = join(dirname(fileURLToPath(import.meta.url)), '../../contracts/test-vectors'); @@ -885,9 +886,88 @@ function serializeProgression(progression: { }; } +/** + * Ruleset cases. + * + * Every tunable gets its own case, because the failure this guards against is a + * balance change that does not move the hash: consent bound to `rulesetHash` and + * historical replay both depend on one number changing whenever any rule does. + */ +const rulesetCases: { name: string; note: string; ruleset: Ruleset }[] = [ + { + name: 'source-defaults', + note: 'The ruleset this build implements with GameConfig source defaults. Anchors every other case.', + ruleset: SOURCE_DEFAULT_RULESET, + }, + { + name: 'version-bump', + note: 'Same rules, higher version number. Must differ: the version is part of the identity, so a republished bundle cannot claim an old hash.', + ruleset: { ...SOURCE_DEFAULT_RULESET, version: 2 }, + }, + { + name: 'engine-version-bump', + note: 'Same parameters, new engine version. Must differ: a fight-math change is recorded here, since code cannot hash itself.', + ruleset: { ...SOURCE_DEFAULT_RULESET, engineVersion: 2 }, + }, + { + name: 'other-engine-id', + note: 'Same parameters under a different engine. Must differ.', + ruleset: { ...SOURCE_DEFAULT_RULESET, engineId: 'cryptopets-combat-go' }, + }, + { + name: 'lower-max-level', + note: 'Level cap lowered. Must differ: the cap decides whether XP accrues at all.', + ruleset: { ...SOURCE_DEFAULT_RULESET, maxLevel: 20 }, + }, + { + name: 'fewer-max-rounds', + note: 'Round cap lowered. Must differ.', + ruleset: { ...SOURCE_DEFAULT_RULESET, maxRounds: 20 }, + }, + ...( + [ + 'tankHpMult', + 'shellDefMult', + 'swiftCritBonus', + 'cunningCritCap', + 'furyDmgMult', + 'furyHpThreshold', + 'sageMdefMult', + 'bloodlustBps', + ] as const + ).map((field) => ({ + name: `skill-${field}`, + note: `${field} raised by one. Must differ from source-defaults and from every other skill case: a tunable that does not move the hash is a balance change nobody consented to.`, + ruleset: { + ...SOURCE_DEFAULT_RULESET, + skillConfig: { + ...SOURCE_DEFAULT_RULESET.skillConfig, + [field]: SOURCE_DEFAULT_RULESET.skillConfig[field] + 1, + }, + }, + })), +]; + +function writeRulesetVectors(): void { + const out = { + description: + 'Ruleset canonical-hash vectors (docs/plan-backend-battle-architecture.md §F, §H). Generated by protocol/scripts/gen-vectors.ts from protocol/src/ruleset. A ruleset hash is chain-agnostic on purpose: the same rules can run on either chain. A failure means the implementation drifted. Never edit an expectation to match new output.', + cases: rulesetCases.map((c) => ({ + name: c.name, + note: c.note, + ruleset: c.ruleset, + expectedRulesetHash: hashRuleset(c.ruleset), + })), + }; + const path = join(VECTORS_DIR, 'protocol-ruleset.json'); + writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`); + process.stdout.write(`wrote ${out.cases.length} ruleset cases to ${path}\n`); +} + writeIntentVectors(); writeConsentVectors(); writeSnapshotVectors(); writeSeedVectors(); writeCommitmentVectors(); writeProgressionVectors(); +writeRulesetVectors(); diff --git a/protocol/src/index.ts b/protocol/src/index.ts index a5a1e623..491670db 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -19,4 +19,5 @@ export * from './encoding'; export * from './intent'; export * from './progression'; export * from './randomness'; +export * from './ruleset'; export * from './snapshot'; diff --git a/protocol/src/ruleset/bundle.ts b/protocol/src/ruleset/bundle.ts new file mode 100644 index 00000000..20ca299c --- /dev/null +++ b/protocol/src/ruleset/bundle.ts @@ -0,0 +1,95 @@ +import type { Hex } from '../encoding/bytes'; + +import { assertRulesetHash, hashRuleset } from './hash'; +import { assertRuleset, type Ruleset, SKILL_CONFIG_FIELDS } from './types'; + +/** + * The published, content-addressed ruleset artifact (§H item 2). + * + * A receipt names a `rulesetHash`; without the matching bundle, a third party has the + * name of the rules but not the rules, so replay stops being possible. Each version is + * therefore published as an immutable JSON document, and its integrity does not depend + * on where it was fetched from: a verifier parses it, recomputes `rulesetHash`, and + * compares against the receipt. + * + * The JSON is transport only. Hashing goes through the binary encoder, so the artifact + * does not have to be canonical JSON, and no verifier has to agree with us about + * property order or number formatting. + */ + +/** Serializes a ruleset for publication. Stable key order, so diffs stay readable. */ +export function serializeRuleset(ruleset: Ruleset): string { + const checked = assertRuleset(ruleset); + const skillConfig: Record = {}; + for (const field of SKILL_CONFIG_FIELDS) { + skillConfig[field] = checked.skillConfig[field]; + } + return `${JSON.stringify( + { + version: checked.version, + engineId: checked.engineId, + engineVersion: checked.engineVersion, + maxRounds: checked.maxRounds, + maxLevel: checked.maxLevel, + skillConfig, + }, + null, + 2, + )}\n`; +} + +const RULESET_KEYS = ['version', 'engineId', 'engineVersion', 'maxRounds', 'maxLevel', 'skillConfig'] as const; + +/** + * Parses a published bundle. + * + * Unknown keys are rejected. A bundle carrying an extra field would hash identically to + * one without it, so two different documents would answer to one `rulesetHash`, and a + * reader would have no way to tell which the battle used. Missing keys are rejected by + * `assertRuleset`. + */ +export function parseRulesetBundle(json: string): Ruleset { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (error) { + throw new Error(`ruleset bundle is not valid JSON: ${(error as Error).message}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('ruleset bundle must be a JSON object'); + } + const record = parsed as Record; + + const unexpected = Object.keys(record).filter((key) => !RULESET_KEYS.includes(key as never)); + if (unexpected.length > 0) { + throw new Error(`ruleset bundle has unexpected keys: ${unexpected.join(', ')}`); + } + const skillConfig = record.skillConfig; + if (typeof skillConfig === 'object' && skillConfig !== null) { + const unexpectedSkills = Object.keys(skillConfig as Record).filter( + (key) => !SKILL_CONFIG_FIELDS.includes(key as never), + ); + if (unexpectedSkills.length > 0) { + throw new Error(`ruleset bundle skillConfig has unexpected keys: ${unexpectedSkills.join(', ')}`); + } + } + + return assertRuleset(record as unknown as Ruleset); +} + +/** + * Parses a bundle and confirms it is the one a receipt named. + * + * This is the call a verifier makes: fetching a bundle from anywhere is safe as long as + * its hash matches the receipt, because the hash is the identity. + */ +export function loadRulesetBundle(json: string, expectedHash: Hex): Ruleset { + const ruleset = parseRulesetBundle(json); + assertRulesetHash(ruleset, expectedHash); + return ruleset; +} + +/** Convenience for publishing: the artifact plus the hash it will be addressed by. */ +export function publishRuleset(ruleset: Ruleset): { hash: Hex; json: string } { + return { hash: hashRuleset(ruleset), json: serializeRuleset(ruleset) }; +} diff --git a/protocol/src/ruleset/hash.ts b/protocol/src/ruleset/hash.ts new file mode 100644 index 00000000..7c473ed6 --- /dev/null +++ b/protocol/src/ruleset/hash.ts @@ -0,0 +1,47 @@ +import { currentSchemaVersion } from '../domain/schemaVersions'; +import type { Hex } from '../encoding/bytes'; +import { DOMAIN_TAGS } from '../encoding/domain'; +import { keccak256Hex } from '../encoding/hash'; +import { CanonicalWriter } from '../encoding/writer'; + +import { assertRuleset, type Ruleset, SKILL_CONFIG_FIELDS } from './types'; + +/** + * Canonical encoding of a ruleset. + * + * No chain id or deployment id, unlike every other hashed object here. A ruleset is + * portable by design: the same rules can run on either chain, and binding the hash to + * a deployment would give one set of rules as many identities as it has environments, + * which would make consent bound to `rulesetHash` mean less rather than more. Where a + * deployment matters, the object referencing the ruleset already carries it. + * + * The schema version is written directly for the same reason: `writeHeader` bundles + * version with domain, and there is no domain here. + */ +export function encodeRuleset(ruleset: Ruleset): Uint8Array { + const checked = assertRuleset(ruleset); + const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.RULESET) + .u16(currentSchemaVersion('ruleset')) + .u32(checked.version) + .text(checked.engineId) + .u32(checked.engineVersion) + .u16(checked.maxRounds) + .u16(checked.maxLevel); + for (const field of SKILL_CONFIG_FIELDS) { + writer.u32(checked.skillConfig[field]); + } + return writer.build(); +} + +/** `rulesetHash`: recorded in every receipt, and what defence consent is bound to. */ +export function hashRuleset(ruleset: Ruleset): Hex { + return keccak256Hex(encodeRuleset(ruleset)); +} + +/** Throws unless `ruleset` hashes to `expected`. */ +export function assertRulesetHash(ruleset: Ruleset, expected: Hex): void { + const actual = hashRuleset(ruleset); + if (actual.toLowerCase() !== expected.toLowerCase()) { + throw new Error(`ruleset hash mismatch: expected ${expected}, computed ${actual}`); + } +} diff --git a/protocol/src/ruleset/index.ts b/protocol/src/ruleset/index.ts new file mode 100644 index 00000000..bad30c43 --- /dev/null +++ b/protocol/src/ruleset/index.ts @@ -0,0 +1,10 @@ +export { loadRulesetBundle, parseRulesetBundle, publishRuleset, serializeRuleset } from './bundle'; +export { assertRulesetHash, encodeRuleset, hashRuleset } from './hash'; +export { + assertRuleset, + ENGINE_ID, + ENGINE_VERSION, + type Ruleset, + SKILL_CONFIG_FIELDS, + SOURCE_DEFAULT_RULESET, +} from './types'; diff --git a/protocol/src/ruleset/types.ts b/protocol/src/ruleset/types.ts new file mode 100644 index 00000000..9175a863 --- /dev/null +++ b/protocol/src/ruleset/types.ts @@ -0,0 +1,144 @@ +import { DEFAULT_SKILL_CONFIG, type SkillConfig } from '../combat/skills'; +import { MAX_ROUNDS } from '../combat/sim'; +import { DEFAULT_MAX_LEVEL } from '../combat/xp'; + +/** + * A versioned, content-addressed statement of the rules a battle was fought under. + * + * Every receipt records `rulesetVersion` and `rulesetHash`, and defence consent is + * bound to the hash. That is what makes "I agreed to the old combat rules" a + * checkable claim rather than an argument: the rules a battle used are named in the + * receipt, and the named bundle is published so anyone can replay against exactly + * those numbers years later (§F, §H). + * + * Two kinds of thing live here, and the distinction matters: + * + * - **Parameters the engine reads at runtime**: the skill/balance config and the + * level cap. These come from `GameConfig` on chain and are owner-tunable, so they + * have to travel with the receipt. + * - **Engine identity**: `engineId` and `engineVersion`, plus `maxRounds` as a + * declared constant. The engine's own code cannot hash itself, so a fight-math + * change is recorded by bumping `engineVersion`. That bump is a manual step, and + * skipping it is how two different implementations end up claiming one ruleset. + */ +export interface Ruleset { + /** Monotonic ruleset version. The number a receipt reports. */ + version: number; + /** Which engine implements the fight. */ + engineId: string; + /** Bumped whenever fight math or progression logic changes. */ + engineVersion: number; + /** Round cap the engine enforces. Declared here so a bundle is self-describing. */ + maxRounds: number; + /** Skill balance values, sourced from `GameConfig` on chain. */ + skillConfig: SkillConfig; + /** Level cap, sourced from `GameConfig.maxLevel`. */ + maxLevel: number; +} + +/** The engine this package implements. */ +export const ENGINE_ID = 'cryptopets-combat-ts'; + +/** + * Bumped when `src/combat/` changes what a fight or a progression delta produces. + * + * Not bumped for refactors that cannot change output, which is exactly the judgement + * call that makes this dangerous: when in doubt, bump. A missed bump means two + * implementations disagree while both claim the same `rulesetHash`, and the golden + * vectors are the only thing that would notice. + */ +export const ENGINE_VERSION = 1; + +/** + * The ruleset this build implements with source defaults. + * + * Source defaults, not live values: on chain the skill config and level cap come + * from `GameConfig` and are owner-tunable, so a deployment builds its ruleset by + * reading the contract. This constant is the local-development baseline and the + * thing golden vectors are anchored to. + */ +export const SOURCE_DEFAULT_RULESET: Ruleset = { + version: 1, + engineId: ENGINE_ID, + engineVersion: ENGINE_VERSION, + maxRounds: MAX_ROUNDS, + skillConfig: DEFAULT_SKILL_CONFIG, + maxLevel: DEFAULT_MAX_LEVEL, +}; + +/** Field order for `skillConfig`, which is also its canonical encoding order. */ +export const SKILL_CONFIG_FIELDS = [ + 'tankHpMult', + 'shellDefMult', + 'swiftCritBonus', + 'cunningCritCap', + 'furyDmgMult', + 'furyHpThreshold', + 'sageMdefMult', + 'bloodlustBps', +] as const satisfies readonly (keyof SkillConfig)[]; + +/** + * Sanity bounds, not game-design opinions. + * + * Multipliers are x/100 and must be positive: a zero HP multiplier would produce a + * pet that cannot exist. Basis-point fields cap at 10000 (100%), since a crit chance + * above certainty is not a tuning choice, it is a typo. Anything inside these bounds + * is the owner's call. + */ +const MULTIPLIER_BOUNDS = { min: 1, max: 10000 } as const; +const BPS_BOUNDS = { min: 0, max: 10000 } as const; + +const MULTIPLIER_FIELDS: readonly (keyof SkillConfig)[] = [ + 'tankHpMult', + 'shellDefMult', + 'furyDmgMult', + 'sageMdefMult', +]; + +const SAFE_ENGINE_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{0,63}$/; + +/** Validates an untrusted ruleset, returning a normalized copy. */ +export function assertRuleset(ruleset: Ruleset): Ruleset { + assertPositiveInt(ruleset.version, 'version'); + assertPositiveInt(ruleset.engineVersion, 'engineVersion'); + assertPositiveInt(ruleset.maxRounds, 'maxRounds'); + assertPositiveInt(ruleset.maxLevel, 'maxLevel'); + + if (typeof ruleset.engineId !== 'string' || !SAFE_ENGINE_ID_PATTERN.test(ruleset.engineId)) { + throw new Error(`engineId is not a valid identifier: ${JSON.stringify(ruleset.engineId)}`); + } + if (ruleset.maxRounds > 0xffff) { + throw new Error(`maxRounds must fit in 16 bits, got ${ruleset.maxRounds}`); + } + if (ruleset.maxLevel > 0xffff) { + throw new Error(`maxLevel must fit in 16 bits, got ${ruleset.maxLevel}`); + } + + const skillConfig = {} as SkillConfig; + for (const field of SKILL_CONFIG_FIELDS) { + const value = ruleset.skillConfig?.[field]; + const bounds = MULTIPLIER_FIELDS.includes(field) ? MULTIPLIER_BOUNDS : BPS_BOUNDS; + if (!Number.isSafeInteger(value) || value < bounds.min || value > bounds.max) { + throw new Error( + `skillConfig.${field} must be an integer between ${bounds.min} and ${bounds.max}, got ${value}`, + ); + } + skillConfig[field] = value; + } + + return { + version: ruleset.version, + engineId: ruleset.engineId, + engineVersion: ruleset.engineVersion, + maxRounds: ruleset.maxRounds, + skillConfig, + maxLevel: ruleset.maxLevel, + }; +} + +function assertPositiveInt(value: number, field: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${field} must be a positive integer, got ${value}`); + } +} diff --git a/protocol/tests/ruleset/bundle.test.ts b/protocol/tests/ruleset/bundle.test.ts new file mode 100644 index 00000000..b108c3e4 --- /dev/null +++ b/protocol/tests/ruleset/bundle.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_SKILL_CONFIG, MAX_ROUNDS } from '../../src/combat'; +import type { Hex } from '../../src/encoding/bytes'; +import { + assertRuleset, + assertRulesetHash, + ENGINE_ID, + ENGINE_VERSION, + hashRuleset, + loadRulesetBundle, + parseRulesetBundle, + publishRuleset, + type Ruleset, + serializeRuleset, + SOURCE_DEFAULT_RULESET, +} from '../../src/ruleset'; + +describe('SOURCE_DEFAULT_RULESET', () => { + it('reflects what the engine actually implements', () => { + // If a combat constant moves and this drifts, a receipt would name rules the + // engine is not running. + expect(SOURCE_DEFAULT_RULESET.maxRounds).toBe(MAX_ROUNDS); + expect(SOURCE_DEFAULT_RULESET.skillConfig).toEqual(DEFAULT_SKILL_CONFIG); + expect(SOURCE_DEFAULT_RULESET.engineId).toBe(ENGINE_ID); + expect(SOURCE_DEFAULT_RULESET.engineVersion).toBe(ENGINE_VERSION); + }); +}); + +describe('assertRuleset', () => { + it('accepts the source defaults', () => { + expect(() => assertRuleset(SOURCE_DEFAULT_RULESET)).not.toThrow(); + }); + + it.each([ + ['version', { version: 0 }], + ['engineVersion', { engineVersion: 0 }], + ['maxRounds', { maxRounds: 0 }], + ['maxLevel', { maxLevel: 0 }], + ['engineId', { engineId: 'Not An Id' }], + ])('rejects an invalid %s', (_field, patch) => { + expect(() => assertRuleset({ ...SOURCE_DEFAULT_RULESET, ...patch } as Ruleset)).toThrow(); + }); + + it('rejects a zero multiplier, which would produce a pet that cannot exist', () => { + expect(() => + assertRuleset({ + ...SOURCE_DEFAULT_RULESET, + skillConfig: { ...DEFAULT_SKILL_CONFIG, tankHpMult: 0 }, + }), + ).toThrow(/tankHpMult/); + }); + + it('rejects a basis-point value above certainty', () => { + expect(() => + assertRuleset({ + ...SOURCE_DEFAULT_RULESET, + skillConfig: { ...DEFAULT_SKILL_CONFIG, cunningCritCap: 10001 }, + }), + ).toThrow(/cunningCritCap/); + }); + + it('allows the owner-tunable range in between', () => { + expect(() => + assertRuleset({ + ...SOURCE_DEFAULT_RULESET, + skillConfig: { ...DEFAULT_SKILL_CONFIG, tankHpMult: 500, bloodlustBps: 10000 }, + }), + ).not.toThrow(); + }); + + it('rejects a missing skill field', () => { + const incomplete = { ...DEFAULT_SKILL_CONFIG } as Record; + delete incomplete.bloodlustBps; + expect(() => + assertRuleset({ ...SOURCE_DEFAULT_RULESET, skillConfig: incomplete as never }), + ).toThrow(/bloodlustBps/); + }); +}); + +describe('bundle round trip', () => { + it('parses back to the same ruleset and hash', () => { + const { hash, json } = publishRuleset(SOURCE_DEFAULT_RULESET); + expect(parseRulesetBundle(json)).toEqual(SOURCE_DEFAULT_RULESET); + expect(hashRuleset(parseRulesetBundle(json))).toBe(hash); + }); + + it('survives reformatting, since JSON is transport and the binary encoding is the hash', () => { + const compact = JSON.stringify(JSON.parse(serializeRuleset(SOURCE_DEFAULT_RULESET))); + expect(hashRuleset(parseRulesetBundle(compact))).toBe(hashRuleset(SOURCE_DEFAULT_RULESET)); + }); + + it('rejects unexpected top-level keys', () => { + // An extra field would hash identically to a bundle without it, so two + // documents would answer to one rulesetHash. + const tampered = JSON.stringify({ ...JSON.parse(serializeRuleset(SOURCE_DEFAULT_RULESET)), note: 'hi' }); + expect(() => parseRulesetBundle(tampered)).toThrow(/unexpected keys: note/); + }); + + it('rejects unexpected skillConfig keys', () => { + const parsed = JSON.parse(serializeRuleset(SOURCE_DEFAULT_RULESET)); + parsed.skillConfig.mysteryBonus = 1; + expect(() => parseRulesetBundle(JSON.stringify(parsed))).toThrow(/skillConfig has unexpected keys/); + }); + + it('rejects malformed JSON and non-objects', () => { + expect(() => parseRulesetBundle('{')).toThrow(/not valid JSON/); + expect(() => parseRulesetBundle('[]')).toThrow(/must be a JSON object/); + expect(() => parseRulesetBundle('"a string"')).toThrow(/must be a JSON object/); + }); +}); + +describe('loadRulesetBundle', () => { + it('accepts a bundle matching the hash a receipt named', () => { + const { hash, json } = publishRuleset(SOURCE_DEFAULT_RULESET); + // Fetching from anywhere is safe when the hash is the identity. + expect(loadRulesetBundle(json, hash)).toEqual(SOURCE_DEFAULT_RULESET); + }); + + it('rejects a bundle whose parameters were altered after publication', () => { + const { hash } = publishRuleset(SOURCE_DEFAULT_RULESET); + const tampered = serializeRuleset({ + ...SOURCE_DEFAULT_RULESET, + skillConfig: { ...DEFAULT_SKILL_CONFIG, furyDmgMult: 200 }, + }); + expect(() => loadRulesetBundle(tampered, hash)).toThrow(/ruleset hash mismatch/); + }); +}); + +describe('assertRulesetHash', () => { + it('ignores hash casing', () => { + const hash = hashRuleset(SOURCE_DEFAULT_RULESET); + expect(() => assertRulesetHash(SOURCE_DEFAULT_RULESET, hash.toUpperCase() as Hex)).not.toThrow(); + }); + + it('names both values when it fails', () => { + expect(() => assertRulesetHash(SOURCE_DEFAULT_RULESET, `0x${'00'.repeat(32)}`)).toThrow( + /expected 0x0{64}, computed 0x/, + ); + }); +}); + +describe('chain independence', () => { + it('does not bind a ruleset to a chain or deployment', () => { + // Deliberate: the same rules can run on either chain, and binding the hash to + // a deployment would give one set of rules several identities, which would make + // consent bound to rulesetHash weaker rather than stronger. + const json = serializeRuleset(SOURCE_DEFAULT_RULESET); + expect(json).not.toContain('chainId'); + expect(json).not.toContain('deploymentId'); + }); +}); diff --git a/protocol/tests/ruleset/vectors.test.ts b/protocol/tests/ruleset/vectors.test.ts new file mode 100644 index 00000000..cb1652bc --- /dev/null +++ b/protocol/tests/ruleset/vectors.test.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { hashRuleset, type Ruleset, SKILL_CONFIG_FIELDS, SOURCE_DEFAULT_RULESET } from '../../src/ruleset'; + +/** + * Consumes contracts/test-vectors/protocol-ruleset.json. A failure means the + * implementation drifted, and the fix is the code, never the vector (`AGENTS.md`). + */ +interface RulesetCase { + name: string; + note: string; + ruleset: Ruleset; + expectedRulesetHash: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const vectorsPath = join(here, '../../../contracts/test-vectors/protocol-ruleset.json'); +const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')) as { cases: RulesetCase[] }; + +const byName = new Map(vectors.cases.map((c) => [c.name, c])); + +describe('ruleset golden vectors', () => { + for (const c of vectors.cases) { + it(`matches the recorded hash for "${c.name}"`, () => { + expect(hashRuleset(c.ruleset)).toBe(c.expectedRulesetHash); + }); + } +}); + +describe('properties the vectors exist to pin', () => { + it('covers every skill tunable individually', () => { + // A tunable missing from this list could change without moving the hash, + // which would be a balance change nobody consented to. + for (const field of SKILL_CONFIG_FIELDS) { + expect(byName.has(`skill-${field}`)).toBe(true); + } + }); + + it('gives every case a distinct hash', () => { + const hashes = vectors.cases.map((c) => c.expectedRulesetHash); + expect(new Set(hashes).size).toBe(hashes.length); + }); + + it('anchors the source-default ruleset this build implements', () => { + const anchor = byName.get('source-defaults')!; + expect(hashRuleset(SOURCE_DEFAULT_RULESET)).toBe(anchor.expectedRulesetHash); + }); + + it('moves the hash for a version or engine bump', () => { + const base = byName.get('source-defaults')!.expectedRulesetHash; + expect(byName.get('version-bump')!.expectedRulesetHash).not.toBe(base); + expect(byName.get('engine-version-bump')!.expectedRulesetHash).not.toBe(base); + expect(byName.get('other-engine-id')!.expectedRulesetHash).not.toBe(base); + }); +}); From c74dee1e0ed1edeaeb1156c3236d51bafc53bace Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 08:16:54 -0400 Subject: [PATCH 16/76] feat(protocol): add signed battle receipt schema, hashing, and hash chains --- contracts/test-vectors/protocol-receipt.json | 557 +++++++++++++++++++ docs/plan-backend-battle-architecture.md | 29 +- protocol/scripts/gen-vectors.ts | 242 +++++++- protocol/src/index.ts | 1 + protocol/src/receipt/chain.ts | 139 +++++ protocol/src/receipt/combatLog.ts | 48 ++ protocol/src/receipt/hash.ts | 82 +++ protocol/src/receipt/index.ts | 22 + protocol/src/receipt/types.ts | 244 ++++++++ protocol/src/receipt/verify.ts | 89 +++ protocol/tests/receipt/receipt.test.ts | 407 ++++++++++++++ protocol/tests/receipt/vectors.test.ts | 205 +++++++ 12 files changed, 2057 insertions(+), 8 deletions(-) create mode 100644 contracts/test-vectors/protocol-receipt.json create mode 100644 protocol/src/receipt/chain.ts create mode 100644 protocol/src/receipt/combatLog.ts create mode 100644 protocol/src/receipt/hash.ts create mode 100644 protocol/src/receipt/index.ts create mode 100644 protocol/src/receipt/types.ts create mode 100644 protocol/src/receipt/verify.ts create mode 100644 protocol/tests/receipt/receipt.test.ts create mode 100644 protocol/tests/receipt/vectors.test.ts diff --git a/contracts/test-vectors/protocol-receipt.json b/contracts/test-vectors/protocol-receipt.json new file mode 100644 index 00000000..e263b4c9 --- /dev/null +++ b/contracts/test-vectors/protocol-receipt.json @@ -0,0 +1,557 @@ +{ + "description": "BattleReceipt canonical-hash vectors (docs/plan-backend-battle-architecture.md §G). Generated by protocol/scripts/gen-vectors.ts from protocol/src/receipt. Each case is a coherent receipt: real quicknet beacons, a seed derived from the receipt own inputs, a combat-log hash from an actual simulation, and a recomputed progression delta. Derived fields are recorded so a reader can see what the encoding covered. A failure means the implementation drifted. Never edit an expectation to match new output.", + "cases": [ + { + "name": "first-receipt-under-key", + "note": "Sequence 1, so every chain link is absent. Both pets are having their first backend battle.", + "fixture": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd", + "publishedAt": 1692806367 + }, + "attackerWon": true, + "maxLevel": 100, + "sequence": 1, + "previousReceiptHash": null, + "attackerPreviousReceiptHash": null, + "defenderPreviousReceiptHash": null, + "createdAt": 1692806368, + "signingKeyId": "battle-signer-2026-07" + }, + "derived": { + "seed": "0x6398528733dbcfde027557184931c482cb9966f752ed1031032a0ce5432c46ef", + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "combatLogHash": "0x2d982d4cb54b220c60b86c2e143f47a17b70c3f89629f4652800f94fc0e32f59", + "result": { + "attackerWon": true, + "rounds": 5, + "winnerHpRemaining": 199 + } + }, + "expectedReceiptHash": "0x8aeb80aae61d03b1e05f7a74caf34419b481354b90446ca648afeafa3c4bef61" + }, + { + "name": "linked-receipt", + "note": "Sequence 2 with the global link and both per-pet links present. Must differ from the first receipt: the links are part of the record, which is what makes a removed receipt detectable.", + "fixture": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000001", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd", + "publishedAt": 1692806367 + }, + "attackerWon": true, + "maxLevel": 100, + "sequence": 2, + "previousReceiptHash": "0x4444444444444444444444444444444444444444444444444444444444444444", + "attackerPreviousReceiptHash": "0x5555555555555555555555555555555555555555555555555555555555555555", + "defenderPreviousReceiptHash": "0x6666666666666666666666666666666666666666666666666666666666666666", + "createdAt": 1692806368, + "signingKeyId": "battle-signer-2026-07" + }, + "derived": { + "seed": "0xa52400a7b92b3a15c2c7e7396ec47641978cd95a6e31723c3a4b324a0e9956f1", + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "combatLogHash": "0xc0d0d29394d1466e0f9b4ee37ffb5520f53960f8d4153b5e08dbdbdc026fde68", + "result": { + "attackerWon": true, + "rounds": 3, + "winnerHpRemaining": 289 + } + }, + "expectedReceiptHash": "0xb2831972485f0fd642f5be355f2c5e0ac161e7b6347c917402e565c140cd2ff5" + }, + { + "name": "attacker-first-battle-defender-veteran", + "note": "Only the defender has a prior battle, so one per-pet link is present and the other is not. Must differ: an absent link and a present one are distinct.", + "fixture": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd", + "publishedAt": 1692806367 + }, + "attackerWon": true, + "maxLevel": 100, + "sequence": 1, + "previousReceiptHash": null, + "attackerPreviousReceiptHash": null, + "defenderPreviousReceiptHash": "0x6666666666666666666666666666666666666666666666666666666666666666", + "createdAt": 1692806368, + "signingKeyId": "battle-signer-2026-07" + }, + "derived": { + "seed": "0x6398528733dbcfde027557184931c482cb9966f752ed1031032a0ce5432c46ef", + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "combatLogHash": "0x2d982d4cb54b220c60b86c2e143f47a17b70c3f89629f4652800f94fc0e32f59", + "result": { + "attackerWon": true, + "rounds": 5, + "winnerHpRemaining": 199 + } + }, + "expectedReceiptHash": "0x7f9ccb0b1f970913c4f8abe206f962e0bd71f322be38ad7c20a7e118c6b7a0b4" + }, + { + "name": "defender-wins", + "note": "Same inputs, other outcome, with the progression delta recomputed accordingly. Must differ.", + "fixture": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd", + "publishedAt": 1692806367 + }, + "attackerWon": false, + "maxLevel": 100, + "sequence": 1, + "previousReceiptHash": null, + "attackerPreviousReceiptHash": null, + "defenderPreviousReceiptHash": null, + "createdAt": 1692806368, + "signingKeyId": "battle-signer-2026-07" + }, + "derived": { + "seed": "0x6398528733dbcfde027557184931c482cb9966f752ed1031032a0ce5432c46ef", + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "combatLogHash": "0x2d982d4cb54b220c60b86c2e143f47a17b70c3f89629f4652800f94fc0e32f59", + "result": { + "attackerWon": false, + "rounds": 5, + "winnerHpRemaining": 199 + } + }, + "expectedReceiptHash": "0x3879dc56dbe46607ab4b621b595af08fa589571af72122bc591b19c514c8947d" + }, + { + "name": "later-beacon-round", + "note": "A different real quicknet round, which changes the randomness and therefore the seed and the fight. Must differ.", + "fixture": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1755803361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 21000000, + "signature": "0x971cbe88adc436f6411fd26d51887ede7ba144264cd05edec6645b5e170a7702d16082947a85d89c89cb47cd8eb7d817", + "randomness": "0x36ecd957580ee415f951370e2a5e13273be97de9072418aaf14d38242979e3c1", + "publishedAt": 1755803367 + }, + "attackerWon": true, + "maxLevel": 100, + "sequence": 1, + "previousReceiptHash": null, + "attackerPreviousReceiptHash": null, + "defenderPreviousReceiptHash": null, + "createdAt": 1755803368, + "signingKeyId": "battle-signer-2026-07" + }, + "derived": { + "seed": "0xf673b794ea1e5a417b0512a64ec8c1620d74313d1003c123f42d863e83892086", + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "combatLogHash": "0x26169ec2972fc25e1e27e8113e5c18b6b679b01c497e5c98762576135f74edb3", + "result": { + "attackerWon": true, + "rounds": 6, + "winnerHpRemaining": 181 + } + }, + "expectedReceiptHash": "0x12e6000262c9e63b6031b0bde7e72d82bb7996e59c5d89cf6ae98ecdac16ec49" + }, + { + "name": "other-signing-key", + "note": "Same battle attributed to a different key. Must differ: which key signed is part of the record, so a rotated key cannot be retro-fitted.", + "fixture": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd", + "publishedAt": 1692806367 + }, + "attackerWon": true, + "maxLevel": 100, + "sequence": 1, + "previousReceiptHash": null, + "attackerPreviousReceiptHash": null, + "defenderPreviousReceiptHash": null, + "createdAt": 1692806368, + "signingKeyId": "battle-signer-2026-08" + }, + "derived": { + "seed": "0x6398528733dbcfde027557184931c482cb9966f752ed1031032a0ce5432c46ef", + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "combatLogHash": "0x2d982d4cb54b220c60b86c2e143f47a17b70c3f89629f4652800f94fc0e32f59", + "result": { + "attackerWon": true, + "rounds": 5, + "winnerHpRemaining": 199 + } + }, + "expectedReceiptHash": "0xfefcb8820c7bf7f2fe45ccc9b5ca160067e61594fa333549cbf5d1c63bfad3f9" + }, + { + "name": "staging-deployment", + "note": "Same battle on the same chain in another deployment. Must differ. The snapshot carries the same deployment, which the receipt enforces.", + "fixture": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-staging", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-staging", + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd", + "publishedAt": 1692806367 + }, + "attackerWon": true, + "maxLevel": 100, + "sequence": 1, + "previousReceiptHash": null, + "attackerPreviousReceiptHash": null, + "defenderPreviousReceiptHash": null, + "createdAt": 1692806368, + "signingKeyId": "battle-signer-2026-07" + }, + "derived": { + "seed": "0x0e6f20864811f2f10d787bf25c4b0bac072cd856c830bf4dd9d89effe610042b", + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "combatLogHash": "0xb4e08f5fdccf74387b03325ab76bd71c5125d41d98ce703e4b989675d6e0a89c", + "result": { + "attackerWon": true, + "rounds": 6, + "winnerHpRemaining": 109 + } + }, + "expectedReceiptHash": "0x9385d03f82cd7415ebd845e88ee057b51df27ab994b864cd28203ac5cf9bd75a" + }, + { + "name": "solana-deployment", + "note": "Solana battle with base58 owners. Must differ from the EVM baseline.", + "fixture": { + "chainId": "solana:devnet", + "deploymentId": "base-sepolia-live", + "battleId": "btl_01hq8z0000000000000000", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "chainId": "solana:devnet", + "deploymentId": "base-sepolia-live", + "attacker": { + "petId": "1", + "owner": "DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1861919000, + "sourceVersion": "1861918000" + }, + "defender": { + "petId": "2", + "owner": "GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1861919500, + "sourceVersion": "1861918500" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd", + "publishedAt": 1692806367 + }, + "attackerWon": true, + "maxLevel": 100, + "sequence": 1, + "previousReceiptHash": null, + "attackerPreviousReceiptHash": null, + "defenderPreviousReceiptHash": null, + "createdAt": 1692806368, + "signingKeyId": "battle-signer-2026-07" + }, + "derived": { + "seed": "0x22442adb46327d6f94e8a34e1f307ce07635ef573149fba377f1d6fb9f9e5a25", + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "combatLogHash": "0x67bac69d4c55a7ef594966240d58e572b4bfba44be148a2acb2e9d566b7f160c", + "result": { + "attackerWon": true, + "rounds": 4, + "winnerHpRemaining": 199 + } + }, + "expectedReceiptHash": "0x63df18f06fb98e297067654698511079de5988c35fd670e186c42f151ffd199e" + } + ] +} diff --git a/docs/plan-backend-battle-architecture.md b/docs/plan-backend-battle-architecture.md index 9f349c90..fa9cb990 100644 --- a/docs/plan-backend-battle-architecture.md +++ b/docs/plan-backend-battle-architecture.md @@ -485,27 +485,24 @@ replay. The existing four-port rule stays in force until the legacy path retires > it links to the previous one so nothing can be quietly removed. ```text -receiptSchemaVersion -battleId +receiptSchemaVersion <- header, written first chainId deploymentId +battleId intentHash commitmentHash defenseAuthorizationHash -attackerSnapshot -defenderSnapshot -snapshotHash -sourceChainVersions +snapshotHash <- full snapshot travels in the payload drandChainHash drandRound drandSignature drandRandomness +seed <- must follow from the fields above rulesetVersion rulesetHash result combatLogHash progressionDelta -rewardDelta sequence previousReceiptHash <- global chain, per signing key attackerProgressPrevReceiptHash <- per-pet chain @@ -514,6 +511,24 @@ createdAt signingKeyId ``` +Four notes where the implementation (`protocol/src/receipt/`) settled details this list left open. + +**Header first.** Schema version, chain id, and deployment id precede the body in every hashed +object, so the shared prefix is defined once rather than copy-pasted per object. That moves +`battleId` after the header. + +**The snapshot enters as `snapshotHash`.** Hashing the snapshots *and* their hash would bind the +same bytes twice. The full snapshot still travels in the payload, so replay needs nothing from us, +and `sourceChainVersions` is per pet inside it rather than a separate field. + +**`seed` is recorded and checked.** Validation rejects a receipt whose seed does not follow from its +own domain, beacon, battle id, snapshot, and ruleset, which makes a favourable seed impossible to +staple onto a real beacon. + +**`rewardDelta` is deferred.** Phase 3 receipts carry no transferable reward, and freezing the field +before the reward model exists would pin a layout to guesswork. Adding it in Phase 5 is a `receipt` +schema-version bump, which is what the version registry is for. + ### Why three hash links `sequence` alone is an ordering the operator asserts. The links make history tamper-evident. diff --git a/protocol/scripts/gen-vectors.ts b/protocol/scripts/gen-vectors.ts index 93530da6..2ed07225 100644 --- a/protocol/scripts/gen-vectors.ts +++ b/protocol/scripts/gen-vectors.ts @@ -25,7 +25,9 @@ import { import type { ChainId } from '../src/domain/chainId'; import type { Hex } from '../src/encoding/bytes'; import { battleIntentSolanaMessage, type BattleIntent, hashBattleIntent } from '../src/intent'; -import { computeProgression, type ProgressionParams } from '../src/progression'; +import { simulate } from '../src/combat'; +import { computeProgression, type ProgressionDelta, type ProgressionParams } from '../src/progression'; +import { type BattleReceipt, hashBattleReceipt, hashCombatLog } from '../src/receipt'; import { deriveBattleSeed, type SeedInputs } from '../src/randomness'; import { hashRuleset, type Ruleset, SOURCE_DEFAULT_RULESET } from '../src/ruleset'; import { type BattleSnapshot, hashBattleSnapshot, type PetSnapshot } from '../src/snapshot'; @@ -964,6 +966,243 @@ function writeRulesetVectors(): void { process.stdout.write(`wrote ${out.cases.length} ruleset cases to ${path}\n`); } +/** + * Receipt cases. + * + * Built as genuinely coherent receipts rather than field bags: real quicknet beacons from + * `tests/fixtures/drand.json`, seeds derived from each receipt's own inputs, progression + * recomputed, and the combat-log hash taken from an actual simulated fight. That is + * deliberate. `assertBattleReceipt` rejects a receipt whose seed does not follow from its + * inputs, so a fixture assembled by hand would not even validate, and a vector that + * cannot occur in production locks a layout nothing will ever produce. + */ +interface ReceiptBeaconFixture { + chainHash: string; + round: number; + signature: string; + randomness: string; + /** Unix seconds this round publishes: genesis (1692803367) + round * 3. */ + publishedAt: number; +} + +// From protocol/tests/fixtures/drand.json, fetched from the live network. +const BEACON_ROUND_1000: ReceiptBeaconFixture = { + chainHash: QUICKNET_CHAIN_HASH, + round: 1000, + signature: + '0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39', + randomness: '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd', + publishedAt: 1692806367, +}; + +const BEACON_ROUND_21M: ReceiptBeaconFixture = { + chainHash: QUICKNET_CHAIN_HASH, + round: 21000000, + signature: + '0x971cbe88adc436f6411fd26d51887ede7ba144264cd05edec6645b5e170a7702d16082947a85d89c89cb47cd8eb7d817', + randomness: '0x36ecd957580ee415f951370e2a5e13273be97de9072418aaf14d38242979e3c1', + publishedAt: 1755803367, +}; + +interface ReceiptFixture { + chainId: string; + deploymentId: string; + battleId: string; + intentHash: string; + commitmentHash: string; + defenseAuthorizationHash: string; + snapshot: SnapshotFixture; + beacon: ReceiptBeaconFixture; + attackerWon: boolean; + maxLevel: number; + sequence: number; + previousReceiptHash: string | null; + attackerPreviousReceiptHash: string | null; + defenderPreviousReceiptHash: string | null; + createdAt: number; + signingKeyId: string; +} + +const RECEIPT_SNAPSHOT: SnapshotFixture = { ...SNAPSHOT_BASE, takenAt: BEACON_ROUND_1000.publishedAt - 6 }; + +const RECEIPT_BASE: ReceiptFixture = { + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + battleId: 'btl_01hq8z0000000000000000', + intentHash: `0x${'11'.repeat(32)}`, + commitmentHash: `0x${'22'.repeat(32)}`, + defenseAuthorizationHash: `0x${'33'.repeat(32)}`, + snapshot: RECEIPT_SNAPSHOT, + beacon: BEACON_ROUND_1000, + attackerWon: true, + maxLevel: 100, + sequence: 1, + previousReceiptHash: null, + attackerPreviousReceiptHash: null, + defenderPreviousReceiptHash: null, + createdAt: BEACON_ROUND_1000.publishedAt + 1, + signingKeyId: 'battle-signer-2026-07', +}; + +const receiptCases: { name: string; note: string; receipt: ReceiptFixture }[] = [ + { + name: 'first-receipt-under-key', + note: 'Sequence 1, so every chain link is absent. Both pets are having their first backend battle.', + receipt: RECEIPT_BASE, + }, + { + name: 'linked-receipt', + note: 'Sequence 2 with the global link and both per-pet links present. Must differ from the first receipt: the links are part of the record, which is what makes a removed receipt detectable.', + receipt: { + ...RECEIPT_BASE, + battleId: 'btl_01hq8z0000000000000001', + sequence: 2, + previousReceiptHash: `0x${'44'.repeat(32)}`, + attackerPreviousReceiptHash: `0x${'55'.repeat(32)}`, + defenderPreviousReceiptHash: `0x${'66'.repeat(32)}`, + }, + }, + { + name: 'attacker-first-battle-defender-veteran', + note: 'Only the defender has a prior battle, so one per-pet link is present and the other is not. Must differ: an absent link and a present one are distinct.', + receipt: { ...RECEIPT_BASE, defenderPreviousReceiptHash: `0x${'66'.repeat(32)}` }, + }, + { + name: 'defender-wins', + note: 'Same inputs, other outcome, with the progression delta recomputed accordingly. Must differ.', + receipt: { ...RECEIPT_BASE, attackerWon: false }, + }, + { + name: 'later-beacon-round', + note: 'A different real quicknet round, which changes the randomness and therefore the seed and the fight. Must differ.', + receipt: { + ...RECEIPT_BASE, + beacon: BEACON_ROUND_21M, + snapshot: { ...SNAPSHOT_BASE, takenAt: BEACON_ROUND_21M.publishedAt - 6 }, + createdAt: BEACON_ROUND_21M.publishedAt + 1, + }, + }, + { + name: 'other-signing-key', + note: 'Same battle attributed to a different key. Must differ: which key signed is part of the record, so a rotated key cannot be retro-fitted.', + receipt: { ...RECEIPT_BASE, signingKeyId: 'battle-signer-2026-08' }, + }, + { + name: 'staging-deployment', + note: 'Same battle on the same chain in another deployment. Must differ. The snapshot carries the same deployment, which the receipt enforces.', + receipt: { + ...RECEIPT_BASE, + deploymentId: 'base-sepolia-staging', + snapshot: { ...RECEIPT_SNAPSHOT, deploymentId: 'base-sepolia-staging' }, + }, + }, + { + name: 'solana-deployment', + note: 'Solana battle with base58 owners. Must differ from the EVM baseline.', + receipt: { + ...RECEIPT_BASE, + chainId: 'solana:devnet', + snapshot: { + ...RECEIPT_SNAPSHOT, + chainId: 'solana:devnet', + attacker: { ...RECEIPT_SNAPSHOT.attacker, owner: 'DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL' }, + defender: { ...RECEIPT_SNAPSHOT.defender, owner: 'GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp' }, + }, + }, + }, +]; + +/** + * Builds a runtime receipt from a fixture, deriving everything derivable: the seed from + * the receipt's own inputs, the result and combat-log hash from an actual simulation, and + * the progression delta from the frozen snapshot. + */ +export function receiptFromFixture(fixture: ReceiptFixture): BattleReceipt { + const snapshot = snapshotFromFixture(fixture.snapshot); + const domain = { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }; + const rulesetHash = hashRuleset(SOURCE_DEFAULT_RULESET); + const seed = deriveBattleSeed({ + domain, + drandRandomness: fixture.beacon.randomness as Hex, + battleId: fixture.battleId, + snapshotHash: hashBattleSnapshot(snapshot), + rulesetHash, + }); + const outcome = simulate( + snapshot.attacker.dna, + snapshot.attacker.rarity, + snapshot.attacker.level, + snapshot.attacker.skill, + snapshot.defender.dna, + snapshot.defender.rarity, + snapshot.defender.level, + snapshot.defender.skill, + seed.value, + SOURCE_DEFAULT_RULESET.skillConfig, + ); + // The fixture chooses the winner so a case can cover both outcomes; the rounds and + // remaining HP still come from the simulation the seed produced. + const progression: ProgressionDelta = computeProgression(snapshot, fixture.attackerWon, { + maxLevel: fixture.maxLevel, + }); + + return { + domain, + battleId: fixture.battleId, + intentHash: fixture.intentHash as Hex, + commitmentHash: fixture.commitmentHash as Hex, + defenseAuthorizationHash: fixture.defenseAuthorizationHash as Hex, + snapshot, + beacon: { + chainHash: fixture.beacon.chainHash as Hex, + round: fixture.beacon.round, + signature: fixture.beacon.signature as Hex, + randomness: fixture.beacon.randomness as Hex, + }, + seed: seed.hex, + rulesetVersion: SOURCE_DEFAULT_RULESET.version, + rulesetHash, + result: { + attackerWon: fixture.attackerWon, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + }, + combatLogHash: hashCombatLog(outcome), + progression, + sequence: fixture.sequence, + previousReceiptHash: fixture.previousReceiptHash as Hex | null, + attackerPreviousReceiptHash: fixture.attackerPreviousReceiptHash as Hex | null, + defenderPreviousReceiptHash: fixture.defenderPreviousReceiptHash as Hex | null, + createdAt: fixture.createdAt, + signingKeyId: fixture.signingKeyId, + }; +} + +function writeReceiptVectors(): void { + const out = { + description: + 'BattleReceipt canonical-hash vectors (docs/plan-backend-battle-architecture.md §G). Generated by protocol/scripts/gen-vectors.ts from protocol/src/receipt. Each case is a coherent receipt: real quicknet beacons, a seed derived from the receipt own inputs, a combat-log hash from an actual simulation, and a recomputed progression delta. Derived fields are recorded so a reader can see what the encoding covered. A failure means the implementation drifted. Never edit an expectation to match new output.', + cases: receiptCases.map((c) => { + const receipt = receiptFromFixture(c.receipt); + return { + name: c.name, + note: c.note, + fixture: c.receipt, + derived: { + seed: receipt.seed, + rulesetHash: receipt.rulesetHash, + combatLogHash: receipt.combatLogHash, + result: receipt.result, + }, + expectedReceiptHash: hashBattleReceipt(receipt), + }; + }), + }; + const path = join(VECTORS_DIR, 'protocol-receipt.json'); + writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`); + process.stdout.write(`wrote ${out.cases.length} receipt cases to ${path}\n`); +} + writeIntentVectors(); writeConsentVectors(); writeSnapshotVectors(); @@ -971,3 +1210,4 @@ writeSeedVectors(); writeCommitmentVectors(); writeProgressionVectors(); writeRulesetVectors(); +writeReceiptVectors(); diff --git a/protocol/src/index.ts b/protocol/src/index.ts index 491670db..3a89947d 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -19,5 +19,6 @@ export * from './encoding'; export * from './intent'; export * from './progression'; export * from './randomness'; +export * from './receipt'; export * from './ruleset'; export * from './snapshot'; diff --git a/protocol/src/receipt/chain.ts b/protocol/src/receipt/chain.ts new file mode 100644 index 00000000..f6dfe20d --- /dev/null +++ b/protocol/src/receipt/chain.ts @@ -0,0 +1,139 @@ +import type { Hex } from '../encoding/bytes'; + +import { hashBattleReceipt } from './hash'; +import type { BattleReceipt } from './types'; + +/** + * Receipt hash chains: the global one per signing key, and one per pet. + * + * `sequence` alone is an ordering we assert. The links make history tamper-evident: + * remove a receipt and the next one no longer matches its predecessor, reorder them and + * the same, sign two with the same predecessor and the contradiction is provable. + * + * None of this prevents us withholding a receipt. It makes the gap visible, which is the + * honest claim (§G, threat T3). + */ + +export type ReceiptChainFailure = + | 'wrong-anchor' + | 'broken-link' + | 'sequence-not-consecutive' + | 'duplicate-battle-id' + | 'mixed-signing-key' + | 'time-went-backwards'; + +export type ReceiptChainResult = { ok: true } | { ok: false; index: number; reason: ReceiptChainFailure }; + +/** + * Checks a run of receipts from one signing key. + * + * `expectedAnchor` is what the first element must link to: `null` when the window starts + * at the key's first receipt, a hash when it continues an earlier window, `undefined` to + * skip the check when auditing a slice without its predecessor. + * + * Sequence numbers must be consecutive. A gap is exactly what a withheld receipt looks + * like, and unlike the hash link it names the missing position. + */ +export function verifyReceiptChain( + receipts: readonly BattleReceipt[], + expectedAnchor?: Hex | null, +): ReceiptChainResult { + const seenBattleIds = new Set(); + let previousHash: Hex | null | undefined = expectedAnchor; + let previousSequence: number | undefined; + let previousCreatedAt: number | undefined; + let signingKeyId: string | undefined; + + for (let index = 0; index < receipts.length; index++) { + const receipt = receipts[index]!; + + if (signingKeyId === undefined) { + signingKeyId = receipt.signingKeyId; + } else if (receipt.signingKeyId !== signingKeyId) { + // Each key has its own chain, so a mixed run is a malformed query rather + // than evidence of tampering. + return { ok: false, index, reason: 'mixed-signing-key' }; + } + if (previousHash !== undefined && receipt.previousReceiptHash !== previousHash) { + return { ok: false, index, reason: index === 0 ? 'wrong-anchor' : 'broken-link' }; + } + if (previousSequence !== undefined && receipt.sequence !== previousSequence + 1) { + return { ok: false, index, reason: 'sequence-not-consecutive' }; + } + if (seenBattleIds.has(receipt.battleId)) { + return { ok: false, index, reason: 'duplicate-battle-id' }; + } + if (previousCreatedAt !== undefined && receipt.createdAt < previousCreatedAt) { + return { ok: false, index, reason: 'time-went-backwards' }; + } + + seenBattleIds.add(receipt.battleId); + previousHash = hashBattleReceipt(receipt); + previousSequence = receipt.sequence; + previousCreatedAt = receipt.createdAt; + } + + return { ok: true }; +} + +/** + * Walks one pet's own chain. + * + * This is the check that makes off-chain progression auditable. A pet's level is not + * verifiable against the chain any more, so proving it really was level 12 means + * replaying the battles that got it there. The per-pet link is what makes that a walk + * rather than a scan of every receipt ever issued. + * + * `receipts` must be that pet's battles in order, each one having the pet as attacker or + * defender. The link followed is whichever side the pet was on. + */ +export function verifyPetReceiptChain( + petId: bigint, + receipts: readonly BattleReceipt[], + expectedAnchor?: Hex | null, +): ReceiptChainResult { + let previousHash: Hex | null | undefined = expectedAnchor; + + for (let index = 0; index < receipts.length; index++) { + const receipt = receipts[index]!; + const link = petPreviousReceiptHash(receipt, petId); + if (link === undefined) { + // Asking for a pet's chain and being handed someone else's battle is a bad + // query, reported as a broken link at the offending position. + return { ok: false, index, reason: 'broken-link' }; + } + if (previousHash !== undefined && link !== previousHash) { + return { ok: false, index, reason: index === 0 ? 'wrong-anchor' : 'broken-link' }; + } + previousHash = hashBattleReceipt(receipt); + } + + return { ok: true }; +} + +/** The link a given pet follows in a receipt, or undefined if the pet is not in it. */ +export function petPreviousReceiptHash(receipt: BattleReceipt, petId: bigint): Hex | null | undefined { + if (receipt.snapshot.attacker.petId === petId) { + return receipt.attackerPreviousReceiptHash; + } + if (receipt.snapshot.defender.petId === petId) { + return receipt.defenderPreviousReceiptHash; + } + return undefined; +} + +/** + * Receipts that contradict each other: one `battleId`, two different hashes. + * + * An empty result is not proof of honesty, only that these receipts do not contradict + * each other. + */ +export function findReceiptEquivocations(receipts: readonly BattleReceipt[]): string[] { + const hashesByBattleId = new Map>(); + for (const receipt of receipts) { + const hashes = hashesByBattleId.get(receipt.battleId) ?? new Set(); + hashes.add(hashBattleReceipt(receipt)); + hashesByBattleId.set(receipt.battleId, hashes); + } + return [...hashesByBattleId.entries()].filter(([, hashes]) => hashes.size > 1).map(([battleId]) => battleId); +} diff --git a/protocol/src/receipt/combatLog.ts b/protocol/src/receipt/combatLog.ts new file mode 100644 index 00000000..ea794b2c --- /dev/null +++ b/protocol/src/receipt/combatLog.ts @@ -0,0 +1,48 @@ +import type { SimOutcome, StrikeLogEntry } from '../combat/sim'; +import { currentSchemaVersion } from '../domain/schemaVersions'; +import type { Hex } from '../encoding/bytes'; +import { DOMAIN_TAGS } from '../encoding/domain'; +import { keccak256Hex } from '../encoding/hash'; +import { CanonicalWriter } from '../encoding/writer'; + +/** + * Hash of the blow-by-blow combat log. + * + * The log is presentation data: the client animates from it. Binding its hash into + * the receipt is what stops the animation and the result being two different stories. + * Without it, a player could be shown any sequence of strikes ending in the recorded + * winner, and nothing would contradict it. + * + * The log is not itself in the receipt, because it is large and most readers never + * want it. It is served separately and checked against this hash. + * + * No chain id or deployment id: the log is a pure function of the fight inputs, and the + * receipt that references this hash already carries the domain. + */ +export function encodeCombatLog(outcome: SimOutcome): Uint8Array { + const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.COMBAT_LOG) + .u16(currentSchemaVersion('combatLog')) + .u256(outcome.startHp1) + .u256(outcome.startHp2); + return writer.array(outcome.log, (w, entry) => writeStrike(w, entry)).build(); +} + +/** `combatLogHash`, as recorded in the receipt. */ +export function hashCombatLog(outcome: SimOutcome): Hex { + return keccak256Hex(encodeCombatLog(outcome)); +} + +function writeStrike(writer: CanonicalWriter, entry: StrikeLogEntry): void { + writer + .u32(entry.round) + .u8(entry.attacker) + .bool(entry.isMagic) + .bool(entry.crit) + .u256(entry.damage) + .u256(entry.heal) + .u16(entry.elementMult) + .bool(entry.furyTriggered) + .bool(entry.rebirthTriggered) + .u256(entry.hp1After) + .u256(entry.hp2After); +} diff --git a/protocol/src/receipt/hash.ts b/protocol/src/receipt/hash.ts new file mode 100644 index 00000000..a87c37f6 --- /dev/null +++ b/protocol/src/receipt/hash.ts @@ -0,0 +1,82 @@ +import { writeHeader } from '../domain/deployment'; +import type { Hex } from '../encoding/bytes'; +import { DOMAIN_TAGS } from '../encoding/domain'; +import { keccak256Hex } from '../encoding/hash'; +import { CanonicalWriter } from '../encoding/writer'; +import type { PetProgression, ProgressionDelta } from '../progression/progression'; +import { hashBattleSnapshot } from '../snapshot/hash'; + +import { assertBattleReceipt, type BattleReceipt } from './types'; + +/** + * Canonical encoding of a receipt. + * + * Field order is header-first, like every other object here: schema version, chain id, + * deployment id, then the body. §G lists `battleId` ahead of `chainId`; the header + * convention wins, so the shared prefix stays defined in one place instead of being + * copy-pasted per object. + * + * The snapshot enters as `snapshotHash`. §G lists the snapshots and their hash + * separately, but hashing both binds the same bytes twice. The full snapshot travels in + * the payload so replay needs nothing from us. + * + * The progression delta is encoded in full rather than as a hash, because it is small + * and because a verifier comparing its own recomputation against ours wants the numbers, + * not a digest that only says "different". + */ +export function encodeBattleReceipt(receipt: BattleReceipt): Uint8Array { + const checked = assertBattleReceipt(receipt); + const writer = CanonicalWriter.withDomain(DOMAIN_TAGS.RECEIPT); + writeHeader(writer, 'receipt', checked.domain) + .text(checked.battleId) + .hash(checked.intentHash) + .hash(checked.commitmentHash) + .hash(checked.defenseAuthorizationHash) + .hash(hashBattleSnapshot(checked.snapshot)) + .hash(checked.beacon.chainHash) + .u64(checked.beacon.round) + .bytes(checked.beacon.signature) + .bytes(checked.beacon.randomness) + .hash(checked.seed) + .u32(checked.rulesetVersion) + .hash(checked.rulesetHash) + .bool(checked.result.attackerWon) + .u16(checked.result.rounds) + .u16(checked.result.winnerHpRemaining) + .hash(checked.combatLogHash); + writeProgression(writer, checked.progression); + return writer + .u64(checked.sequence) + .optional(checked.previousReceiptHash, (w, v) => w.hash(v)) + .optional(checked.attackerPreviousReceiptHash, (w, v) => w.hash(v)) + .optional(checked.defenderPreviousReceiptHash, (w, v) => w.hash(v)) + .u64(checked.createdAt) + .text(checked.signingKeyId) + .build(); +} + +/** + * `receiptHash`: what the KMS key signs, what the next receipt links back to, and what a + * Merkle leaf commits to. + */ +export function hashBattleReceipt(receipt: BattleReceipt): Hex { + return keccak256Hex(encodeBattleReceipt(receipt)); +} + +function writeProgression(writer: CanonicalWriter, progression: ProgressionDelta): void { + writePetProgression(writer, progression.attacker); + writePetProgression(writer, progression.defender); +} + +function writePetProgression(writer: CanonicalWriter, pet: PetProgression): void { + writer + .u256(pet.petId) + .bool(pet.won) + .u32(pet.decayShift) + .u32(pet.xpAwarded) + .u256(pet.lastOpponentId) + .u32(pet.streak) + .u16(pet.level) + .u32(pet.xp) + .bool(pet.leveledUp); +} diff --git a/protocol/src/receipt/index.ts b/protocol/src/receipt/index.ts new file mode 100644 index 00000000..25661de2 --- /dev/null +++ b/protocol/src/receipt/index.ts @@ -0,0 +1,22 @@ +export { + findReceiptEquivocations, + petPreviousReceiptHash, + type ReceiptChainFailure, + type ReceiptChainResult, + verifyPetReceiptChain, + verifyReceiptChain, +} from './chain'; +export { encodeCombatLog, hashCombatLog } from './combatLog'; +export { encodeBattleReceipt, hashBattleReceipt } from './hash'; +export { + assertBattleReceipt, + type BattleReceipt, + type BattleResult, + type ReceiptBeacon, +} from './types'; +export { + type ReceiptCheck, + type ReceiptCheckFailure, + type ReceiptVerification, + verifyReceiptConsistency, +} from './verify'; diff --git a/protocol/src/receipt/types.ts b/protocol/src/receipt/types.ts new file mode 100644 index 00000000..06ed4296 --- /dev/null +++ b/protocol/src/receipt/types.ts @@ -0,0 +1,244 @@ +import { assertProtocolDomain, assertSameDomain, type ProtocolDomain } from '../domain/deployment'; +import { type Hex, hexToBytes, toBytes } from '../encoding/bytes'; +import type { ProgressionDelta } from '../progression/progression'; +import { beaconRandomness, BEACON_SIGNATURE_LENGTH, resolveDrandChain, roundTime } from '../randomness/drand'; +import { deriveBattleSeed, DRAND_RANDOMNESS_LENGTH } from '../randomness/seed'; +import { hashBattleSnapshot } from '../snapshot/hash'; +import { assertBattleSnapshot, type BattleSnapshot } from '../snapshot/types'; + +/** + * The signed, permanent record of one battle. + * + * Everything needed to recompute the fight is here or reachable from here, which is + * the whole point: we are not asking anyone to believe the result, we are publishing + * the homework so anyone can mark it (§G, §H). A receipt that cannot be independently + * recomputed is an assertion, and assertions are what this design exists to avoid. + * + * Three hash links, not one: + * + * - `previousReceiptHash` chains every receipt under a signing key, so removal or + * reordering breaks the chain and two receipts claiming one predecessor is provable + * equivocation. + * - the two per-pet links exist because off-chain XP is not verifiable against the + * chain. Confirming a pet really was level 12 means replaying that pet's prior + * backend battles, and without a per-pet link that means scanning the entire ledger. + * + * `rewardDelta` from §G is deliberately absent: Phase 3 receipts carry no transferable + * reward, and inventing the shape now would freeze a layout before the reward model + * exists. Adding it is a `receipt` schema-version bump, which is what the version + * registry is for. + */ +export interface BattleReceipt { + domain: ProtocolDomain; + battleId: string; + /** The wallet-signed intent that authorized the battle. */ + intentHash: Hex; + /** The pre-reveal commitment this battle was accepted under. */ + commitmentHash: Hex; + /** The defender's standing authorization it relied on. */ + defenseAuthorizationHash: Hex; + /** Both pets, frozen at acceptance. Carries each pet's source chain version. */ + snapshot: BattleSnapshot; + /** The beacon proof: which round, its signature, and the randomness it yields. */ + beacon: ReceiptBeacon; + /** The seed the fight ran on. Must follow from this receipt's own inputs. */ + seed: Hex; + rulesetVersion: number; + rulesetHash: Hex; + result: BattleResult; + /** Hash of the blow-by-blow log, which is served separately. */ + combatLogHash: Hex; + progression: ProgressionDelta; + /** Position in this signing key's chain. Starts at 1. */ + sequence: number; + /** Previous receipt under this key, or null for the first. */ + previousReceiptHash: Hex | null; + /** Previous receipt involving the attacker pet, or null for its first. */ + attackerPreviousReceiptHash: Hex | null; + /** Previous receipt involving the defender pet, or null for its first. */ + defenderPreviousReceiptHash: Hex | null; + createdAt: number; + signingKeyId: string; +} + +/** The beacon proof carried by a receipt, so the randomness is checkable, not asserted. */ +export interface ReceiptBeacon { + chainHash: Hex; + round: number; + /** 48-byte compressed G1 signature. */ + signature: Hex; + /** 32-byte randomness: sha256 of the signature. */ + randomness: Hex; +} + +/** The outcome, stated from the attacker's perspective, as `simulate` reports it. */ +export interface BattleResult { + attackerWon: boolean; + rounds: number; + winnerHpRemaining: number; +} + +const SAFE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/; + +/** + * Validates an untrusted receipt, returning a normalized copy. + * + * Beyond field shapes, this enforces the internal consistency a receipt can be held to + * without any external data: + * + * - the randomness is the hash of the signature it ships with; + * - the seed follows from this receipt's own domain, beacon, battle id, snapshot, and + * ruleset; + * - the beacon had published before the receipt was created; + * - the snapshot was taken before the receipt was created; + * - a null chain link happens only at sequence 1. + * + * Deliberately *not* done here: BLS verification and progression recomputation. Both + * are real checks (`verifyReceiptConsistency`) but too expensive to run every time a + * receipt is hashed. + */ +export function assertBattleReceipt(receipt: BattleReceipt): BattleReceipt { + const domain = assertProtocolDomain(receipt.domain); + assertId(receipt.battleId, 'battleId'); + assertId(receipt.signingKeyId, 'signingKeyId'); + assertHash(receipt.intentHash, 'intentHash'); + assertHash(receipt.commitmentHash, 'commitmentHash'); + assertHash(receipt.defenseAuthorizationHash, 'defenseAuthorizationHash'); + assertHash(receipt.rulesetHash, 'rulesetHash'); + assertHash(receipt.combatLogHash, 'combatLogHash'); + assertHash(receipt.seed, 'seed'); + assertOptionalHash(receipt.previousReceiptHash, 'previousReceiptHash'); + assertOptionalHash(receipt.attackerPreviousReceiptHash, 'attackerPreviousReceiptHash'); + assertOptionalHash(receipt.defenderPreviousReceiptHash, 'defenderPreviousReceiptHash'); + + const snapshot = assertBattleSnapshot(receipt.snapshot); + // A receipt whose snapshot names another deployment is incoherent, and it would also + // make the seed check ambiguous: the derivation binds one domain, and there would be + // two candidates for which. + assertSameDomain(domain, snapshot.domain); + const beacon = assertReceiptBeacon(receipt.beacon); + + if (!Number.isSafeInteger(receipt.rulesetVersion) || receipt.rulesetVersion < 1) { + throw new Error(`rulesetVersion must be a positive integer, got ${receipt.rulesetVersion}`); + } + assertResult(receipt.result); + + if (!Number.isSafeInteger(receipt.sequence) || receipt.sequence < 1) { + throw new Error(`sequence must be a positive integer, got ${receipt.sequence}`); + } + // A null link anywhere but the start of a key's chain would be an undetectable gap: + // the chain would simply restart, and nothing would say a receipt went missing. + if (receipt.sequence === 1 && receipt.previousReceiptHash !== null) { + throw new Error('the first receipt under a signing key must have no previousReceiptHash'); + } + if (receipt.sequence > 1 && receipt.previousReceiptHash === null) { + throw new Error(`receipt at sequence ${receipt.sequence} must link its predecessor`); + } + + if (!Number.isSafeInteger(receipt.createdAt) || receipt.createdAt < 1) { + throw new Error(`createdAt must be a positive unix-seconds integer, got ${receipt.createdAt}`); + } + if (receipt.createdAt < snapshot.takenAt) { + throw new Error(`createdAt ${receipt.createdAt} precedes the snapshot at ${snapshot.takenAt}`); + } + const chain = resolveDrandChain(beacon.chainHash); + const publishedAt = roundTime(chain, beacon.round); + if (receipt.createdAt < publishedAt) { + throw new Error( + `createdAt ${receipt.createdAt} precedes drand round ${beacon.round}, which publishes at ${publishedAt}`, + ); + } + + const expectedSeed = deriveBattleSeed({ + domain, + drandRandomness: beacon.randomness, + battleId: receipt.battleId, + snapshotHash: hashBattleSnapshot(snapshot), + rulesetHash: receipt.rulesetHash, + }).hex; + if (receipt.seed.toLowerCase() !== expectedSeed) { + throw new Error(`seed ${receipt.seed} does not follow from this receipt inputs (expected ${expectedSeed})`); + } + + return { + domain, + battleId: receipt.battleId, + intentHash: receipt.intentHash, + commitmentHash: receipt.commitmentHash, + defenseAuthorizationHash: receipt.defenseAuthorizationHash, + snapshot, + beacon, + seed: receipt.seed, + rulesetVersion: receipt.rulesetVersion, + rulesetHash: receipt.rulesetHash, + result: { ...receipt.result }, + combatLogHash: receipt.combatLogHash, + progression: receipt.progression, + sequence: receipt.sequence, + previousReceiptHash: receipt.previousReceiptHash, + attackerPreviousReceiptHash: receipt.attackerPreviousReceiptHash, + defenderPreviousReceiptHash: receipt.defenderPreviousReceiptHash, + createdAt: receipt.createdAt, + signingKeyId: receipt.signingKeyId, + }; +} + +function assertReceiptBeacon(beacon: ReceiptBeacon): ReceiptBeacon { + const chain = resolveDrandChain(beacon.chainHash); + if (!Number.isSafeInteger(beacon.round) || beacon.round < 1) { + throw new Error(`beacon round must be a positive integer, got ${beacon.round}`); + } + if (toBytes(beacon.signature).length !== BEACON_SIGNATURE_LENGTH) { + throw new Error(`beacon signature must be ${BEACON_SIGNATURE_LENGTH} bytes`); + } + if (toBytes(beacon.randomness).length !== DRAND_RANDOMNESS_LENGTH) { + throw new Error(`beacon randomness must be ${DRAND_RANDOMNESS_LENGTH} bytes`); + } + // Cheap and worth doing every time: randomness is defined as the hash of the + // signature, so a receipt where they disagree is malformed regardless of whether + // the signature itself verifies. + const derived = beaconRandomness(beacon.signature); + if (derived !== beacon.randomness.toLowerCase()) { + throw new Error(`beacon randomness ${beacon.randomness} is not the hash of the signature (${derived})`); + } + return { + chainHash: chain.chainHash, + round: beacon.round, + signature: beacon.signature.toLowerCase() as Hex, + randomness: beacon.randomness.toLowerCase() as Hex, + }; +} + +function assertResult(result: BattleResult): void { + if (typeof result?.attackerWon !== 'boolean') { + throw new Error('result.attackerWon must be a boolean'); + } + if (!Number.isSafeInteger(result.rounds) || result.rounds < 1 || result.rounds > 0xffff) { + throw new Error(`result.rounds must be 1-65535, got ${result.rounds}`); + } + if ( + !Number.isSafeInteger(result.winnerHpRemaining) || + result.winnerHpRemaining < 0 || + result.winnerHpRemaining > 0xffff + ) { + throw new Error(`result.winnerHpRemaining must be 0-65535, got ${result.winnerHpRemaining}`); + } +} + +function assertId(value: string, field: string): void { + if (typeof value !== 'string' || !SAFE_ID_PATTERN.test(value)) { + throw new Error(`${field} is not a valid id: ${JSON.stringify(value)}`); + } +} + +function assertHash(value: Hex, field: string): void { + if (hexToBytes(value).length !== 32) { + throw new Error(`${field} must be a 32-byte hash`); + } +} + +function assertOptionalHash(value: Hex | null, field: string): void { + if (value !== null) { + assertHash(value, field); + } +} diff --git a/protocol/src/receipt/verify.ts b/protocol/src/receipt/verify.ts new file mode 100644 index 00000000..29f7a4e0 --- /dev/null +++ b/protocol/src/receipt/verify.ts @@ -0,0 +1,89 @@ +import { computeProgression, type PetProgression, type ProgressionParams } from '../progression/progression'; +import { resolveDrandChain, verifyBeacon } from '../randomness/drand'; + +import { assertBattleReceipt, type BattleReceipt } from './types'; + +/** + * The checks a receipt can be held to on its own, without the combat log and without any + * data from us. + * + * `assertBattleReceipt` already covers the cheap internal consistency (randomness is the + * signature hash, seed follows from the inputs, times are ordered). This adds the two + * expensive ones: the BLS signature, and recomputing the progression delta. + * + * What is *not* here: replaying the fight itself. That needs the combat log, which the + * receipt only references, so it belongs to the standalone verifier where the log is + * fetched alongside. Progression is checkable here because the snapshot carries the + * streak state it depends on. + */ + +export type ReceiptCheck = 'beacon-signature' | 'progression'; + +export interface ReceiptCheckFailure { + check: ReceiptCheck; + detail: string; +} + +export type ReceiptVerification = { ok: true } | { ok: false; failures: ReceiptCheckFailure[] }; + +/** + * Verifies a receipt's beacon and progression. + * + * Reports every failure rather than the first, because "the beacon is forged and the XP + * is wrong" and "the XP is wrong" are different situations and the difference matters + * when deciding what a mismatch means. + * + * `params` supplies the level cap, which lives in the ruleset the receipt names. A caller + * that has loaded the pinned ruleset bundle passes its values; passing the wrong ones + * produces a progression mismatch, which is the correct outcome rather than a false pass. + */ +export function verifyReceiptConsistency(receipt: BattleReceipt, params: ProgressionParams): ReceiptVerification { + const checked = assertBattleReceipt(receipt); + const failures: ReceiptCheckFailure[] = []; + + const chain = resolveDrandChain(checked.beacon.chainHash); + if (!verifyBeacon(chain, { round: checked.beacon.round, signature: checked.beacon.signature })) { + failures.push({ + check: 'beacon-signature', + detail: `drand round ${checked.beacon.round} does not verify against chain ${chain.chainHash}`, + }); + } + + const recomputed = computeProgression(checked.snapshot, checked.result.attackerWon, params); + const mismatches = [ + ...compareProgression('attacker', recomputed.attacker, checked.progression.attacker), + ...compareProgression('defender', recomputed.defender, checked.progression.defender), + ]; + if (mismatches.length > 0) { + failures.push({ check: 'progression', detail: mismatches.join('; ') }); + } + + return failures.length === 0 ? { ok: true } : { ok: false, failures }; +} + +const PROGRESSION_FIELDS = [ + 'petId', + 'won', + 'decayShift', + 'xpAwarded', + 'lastOpponentId', + 'streak', + 'level', + 'xp', + 'leveledUp', +] as const satisfies readonly (keyof PetProgression)[]; + +function compareProgression(side: string, expected: PetProgression, actual: PetProgression | undefined): string[] { + if (!actual) { + return [`${side} progression missing`]; + } + const differences: string[] = []; + for (const field of PROGRESSION_FIELDS) { + // Stringified so bigint and number fields compare the same way, and so the + // message reads the same for both. + if (String(expected[field]) !== String(actual[field])) { + differences.push(`${side}.${field}: expected ${String(expected[field])}, got ${String(actual[field])}`); + } + } + return differences; +} diff --git a/protocol/tests/receipt/receipt.test.ts b/protocol/tests/receipt/receipt.test.ts new file mode 100644 index 00000000..468438be --- /dev/null +++ b/protocol/tests/receipt/receipt.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it } from 'vitest'; + +import { simulate } from '../../src/combat'; +import type { Hex } from '../../src/encoding/bytes'; +import { computeProgression } from '../../src/progression'; +import { deriveBattleSeed, QUICKNET, roundTime } from '../../src/randomness'; +import { + assertBattleReceipt, + type BattleReceipt, + findReceiptEquivocations, + hashBattleReceipt, + hashCombatLog, + petPreviousReceiptHash, + verifyPetReceiptChain, + verifyReceiptChain, + verifyReceiptConsistency, +} from '../../src/receipt'; +import { hashRuleset, SOURCE_DEFAULT_RULESET } from '../../src/ruleset'; +import { type BattleSnapshot, hashBattleSnapshot } from '../../src/snapshot'; + +/** Real quicknet round 1000 (tests/fixtures/drand.json), so beacon checks are genuine. */ +const BEACON = { + chainHash: QUICKNET.chainHash, + round: 1000, + signature: + '0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39' as Hex, + randomness: '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd' as Hex, +}; +const PUBLISHED_AT = roundTime(QUICKNET, BEACON.round); +const DOMAIN = { chainId: 'eip155:84532' as const, deploymentId: 'base-sepolia-live' }; +const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); + +const SNAPSHOT: BattleSnapshot = { + domain: DOMAIN, + attacker: { + petId: 1n, + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: 1234567890123456n, + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: 0n, + streak: 0, + readyAt: PUBLISHED_AT - 100, + sourceVersion: BigInt(PUBLISHED_AT - 50), + }, + defender: { + petId: 2n, + owner: '0x2222222222222222222222222222222222222222', + dna: 6543210987654321n, + rarity: 2, + level: 11, + skill: 7, + xp: 45, + lastOpponentId: 1n, + streak: 2, + readyAt: PUBLISHED_AT - 100, + sourceVersion: BigInt(PUBLISHED_AT - 50), + }, + takenAt: PUBLISHED_AT - 6, +}; + +function build(overrides: Partial = {}, battleId = 'btl_0001'): BattleReceipt { + // The seed is derived from whichever beacon the caller supplies, because it has to + // be: validation rejects a receipt whose seed does not follow from its own inputs, so + // there is no way to build a receipt with a beacon it was not seeded from. + const beacon = overrides.beacon ?? BEACON; + const seed = deriveBattleSeed({ + domain: DOMAIN, + drandRandomness: beacon.randomness, + battleId, + snapshotHash: hashBattleSnapshot(SNAPSHOT), + rulesetHash: RULESET_HASH, + }); + const outcome = simulate( + SNAPSHOT.attacker.dna, + SNAPSHOT.attacker.rarity, + SNAPSHOT.attacker.level, + SNAPSHOT.attacker.skill, + SNAPSHOT.defender.dna, + SNAPSHOT.defender.rarity, + SNAPSHOT.defender.level, + SNAPSHOT.defender.skill, + seed.value, + SOURCE_DEFAULT_RULESET.skillConfig, + ); + return { + domain: DOMAIN, + battleId, + intentHash: `0x${'11'.repeat(32)}`, + commitmentHash: `0x${'22'.repeat(32)}`, + defenseAuthorizationHash: `0x${'33'.repeat(32)}`, + snapshot: SNAPSHOT, + beacon, + seed: seed.hex, + rulesetVersion: SOURCE_DEFAULT_RULESET.version, + rulesetHash: RULESET_HASH, + result: { + attackerWon: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + }, + combatLogHash: hashCombatLog(outcome), + progression: computeProgression(SNAPSHOT, outcome.result.firstWins), + sequence: 1, + previousReceiptHash: null, + attackerPreviousReceiptHash: null, + defenderPreviousReceiptHash: null, + createdAt: PUBLISHED_AT + 1, + signingKeyId: 'battle-signer-2026-07', + ...overrides, + }; +} + +const VALID = build(); + +describe('internal consistency', () => { + it('accepts a coherent receipt', () => { + expect(() => assertBattleReceipt(VALID)).not.toThrow(); + }); + + it('rejects a seed that does not follow from its own inputs', () => { + // The check that makes a receipt self-checking: you cannot staple a favourable + // seed onto a real beacon and a real snapshot. + expect(() => assertBattleReceipt({ ...VALID, seed: `0x${'99'.repeat(32)}` })).toThrow( + /does not follow from this receipt inputs/, + ); + }); + + it('rejects randomness that is not the hash of the shipped signature', () => { + expect(() => + assertBattleReceipt({ + ...VALID, + beacon: { ...BEACON, randomness: `0x${'aa'.repeat(32)}` }, + }), + ).toThrow(/is not the hash of the signature/); + }); + + it('rejects a snapshot from another deployment', () => { + expect(() => + assertBattleReceipt({ + ...VALID, + snapshot: { ...SNAPSHOT, domain: { ...DOMAIN, deploymentId: 'base-sepolia-staging' } }, + }), + ).toThrow(/domain mismatch/); + }); + + it('rejects a receipt created before its beacon published', () => { + expect(() => assertBattleReceipt({ ...VALID, createdAt: PUBLISHED_AT - 1 })).toThrow( + /precedes drand round 1000/, + ); + }); + + it('rejects a receipt created before its snapshot was taken', () => { + expect(() => assertBattleReceipt({ ...VALID, createdAt: SNAPSHOT.takenAt - 1 })).toThrow( + /precedes the snapshot/, + ); + }); + + it('rejects an unpinned drand chain', () => { + expect(() => + assertBattleReceipt({ ...VALID, beacon: { ...BEACON, chainHash: `0x${'99'.repeat(32)}` } }), + ).toThrow(/is not pinned/); + }); + + it('ties a null global link to sequence 1 in both directions', () => { + // Otherwise a chain could silently restart, and a withheld receipt would leave no + // trace at all. + expect(() => assertBattleReceipt({ ...VALID, sequence: 2 })).toThrow(/must link its predecessor/); + expect(() => + assertBattleReceipt({ ...VALID, sequence: 1, previousReceiptHash: `0x${'44'.repeat(32)}` }), + ).toThrow(/first receipt under a signing key must have no previousReceiptHash/); + }); + + it('allows per-pet links to be absent independently of the global one', () => { + expect(() => + assertBattleReceipt({ + ...VALID, + sequence: 2, + previousReceiptHash: `0x${'44'.repeat(32)}`, + attackerPreviousReceiptHash: null, + defenderPreviousReceiptHash: `0x${'55'.repeat(32)}`, + }), + ).not.toThrow(); + }); + + it.each([ + ['rounds 0', { result: { ...VALID.result, rounds: 0 } }], + ['negative HP', { result: { ...VALID.result, winnerHpRemaining: -1 } }], + ['rulesetVersion 0', { rulesetVersion: 0 }], + ['sequence 0', { sequence: 0 }], + ['bad combatLogHash', { combatLogHash: '0x1234' as Hex }], + ])('rejects %s', (_label, patch) => { + expect(() => assertBattleReceipt({ ...VALID, ...patch } as BattleReceipt)).toThrow(); + }); +}); + +describe('verifyReceiptConsistency', () => { + it('passes a receipt whose beacon and progression both check out', () => { + expect(verifyReceiptConsistency(VALID, { maxLevel: 100 })).toEqual({ ok: true }); + }); + + it('reports a forged beacon signature', () => { + // Same round, a signature that is well-formed but not drand's. + const forged = build({ + // Round 21000000's real signature, presented as round 1000. Well-formed, the + // randomness matches the signature, and the seed follows from that randomness, + // so everything cheap passes. Only the BLS check catches it, because the round + // number is the message being signed. + beacon: { + ...BEACON, + signature: + '0x971cbe88adc436f6411fd26d51887ede7ba144264cd05edec6645b5e170a7702d16082947a85d89c89cb47cd8eb7d817', + randomness: '0x36ecd957580ee415f951370e2a5e13273be97de9072418aaf14d38242979e3c1', + }, + }); + const result = verifyReceiptConsistency(forged, { maxLevel: 100 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failures.map((f) => f.check)).toContain('beacon-signature'); + } + }); + + it('reports an inflated progression delta', () => { + const inflated = build({ + progression: { + ...VALID.progression, + attacker: { ...VALID.progression.attacker, xpAwarded: 9999, level: 99 }, + }, + }); + const result = verifyReceiptConsistency(inflated, { maxLevel: 100 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failures.map((f) => f.check)).toContain('progression'); + expect(result.failures[0]!.detail).toMatch(/xpAwarded: expected \d+, got 9999/); + } + }); + + it('reports both failures rather than stopping at the first', () => { + const broken = build({ + // Round 21000000's real signature, presented as round 1000. Well-formed, the + // randomness matches the signature, and the seed follows from that randomness, + // so everything cheap passes. Only the BLS check catches it, because the round + // number is the message being signed. + beacon: { + ...BEACON, + signature: + '0x971cbe88adc436f6411fd26d51887ede7ba144264cd05edec6645b5e170a7702d16082947a85d89c89cb47cd8eb7d817', + randomness: '0x36ecd957580ee415f951370e2a5e13273be97de9072418aaf14d38242979e3c1', + }, + progression: { + ...VALID.progression, + defender: { ...VALID.progression.defender, xp: 1 }, + }, + }); + const result = verifyReceiptConsistency(broken, { maxLevel: 100 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failures).toHaveLength(2); + } + }); + + it('fails rather than passing when given the wrong level cap', () => { + // Passing parameters that do not match the named ruleset must not produce a false + // pass: the progression simply will not reproduce. + const atCap = build({ progression: computeProgression(SNAPSHOT, VALID.result.attackerWon, { maxLevel: 5 }) }); + expect(verifyReceiptConsistency(atCap, { maxLevel: 100 }).ok).toBe(false); + }); +}); + +describe('global receipt chain', () => { + const first = build({}, 'btl_0001'); + const second = build( + { sequence: 2, previousReceiptHash: hashBattleReceipt(first), createdAt: first.createdAt + 1 }, + 'btl_0002', + ); + const third = build( + { sequence: 3, previousReceiptHash: hashBattleReceipt(second), createdAt: second.createdAt + 1 }, + 'btl_0003', + ); + + it('accepts an unbroken run', () => { + expect(verifyReceiptChain([first, second, third], null)).toEqual({ ok: true }); + }); + + it('detects a withheld receipt through the sequence gap', () => { + // The gap names the missing position, which the hash link alone cannot. + const relinked = build( + { sequence: 3, previousReceiptHash: hashBattleReceipt(first), createdAt: first.createdAt + 2 }, + 'btl_0003', + ); + expect(verifyReceiptChain([first, relinked], null)).toEqual({ + ok: false, + index: 1, + reason: 'sequence-not-consecutive', + }); + }); + + it('detects a removed receipt through the broken link', () => { + expect(verifyReceiptChain([first, third], null)).toEqual({ ok: false, index: 1, reason: 'broken-link' }); + }); + + it('rejects a run mixing signing keys', () => { + const other = build( + { + sequence: 2, + previousReceiptHash: hashBattleReceipt(first), + signingKeyId: 'battle-signer-2026-08', + createdAt: first.createdAt + 1, + }, + 'btl_0002', + ); + expect(verifyReceiptChain([first, other], null)).toEqual({ ok: false, index: 1, reason: 'mixed-signing-key' }); + }); + + it('rejects a wrong anchor and skips the check when none is given', () => { + expect(verifyReceiptChain([second, third], null)).toEqual({ ok: false, index: 0, reason: 'wrong-anchor' }); + expect(verifyReceiptChain([second, third])).toEqual({ ok: true }); + }); + + it('detects creation time moving backwards', () => { + const backwards = build( + { sequence: 2, previousReceiptHash: hashBattleReceipt(first), createdAt: first.createdAt - 1 }, + 'btl_0002', + ); + expect(verifyReceiptChain([first, backwards], null)).toEqual({ + ok: false, + index: 1, + reason: 'time-went-backwards', + }); + }); +}); + +describe('per-pet receipt chain', () => { + // Pet 1 as attacker, then pet 1 again as attacker in a second battle. + const first = build({}, 'btl_0001'); + const second = build( + { + sequence: 2, + previousReceiptHash: hashBattleReceipt(first), + attackerPreviousReceiptHash: hashBattleReceipt(first), + defenderPreviousReceiptHash: hashBattleReceipt(first), + createdAt: first.createdAt + 1, + }, + 'btl_0002', + ); + + it('walks one pet history without scanning everyone else', () => { + // The reason the per-pet link exists: off-chain XP is not verifiable against the + // chain, so proving a level means replaying that pet own battles. + expect(verifyPetReceiptChain(1n, [first, second], null)).toEqual({ ok: true }); + expect(verifyPetReceiptChain(2n, [first, second], null)).toEqual({ ok: true }); + }); + + it('detects a gap in a pet own history', () => { + const detached = build( + { + sequence: 2, + previousReceiptHash: hashBattleReceipt(first), + attackerPreviousReceiptHash: null, + createdAt: first.createdAt + 1, + }, + 'btl_0002', + ); + expect(verifyPetReceiptChain(1n, [first, detached], null)).toEqual({ + ok: false, + index: 1, + reason: 'broken-link', + }); + }); + + it('rejects a receipt the pet was not in', () => { + expect(verifyPetReceiptChain(99n, [first], null)).toEqual({ ok: false, index: 0, reason: 'broken-link' }); + }); + + it('reports which link a pet follows', () => { + expect(petPreviousReceiptHash(second, 1n)).toBe(second.attackerPreviousReceiptHash); + expect(petPreviousReceiptHash(second, 2n)).toBe(second.defenderPreviousReceiptHash); + expect(petPreviousReceiptHash(second, 3n)).toBeUndefined(); + }); +}); + +describe('findReceiptEquivocations', () => { + it('finds two different receipts for one battle', () => { + const other = build({ signingKeyId: 'battle-signer-2026-08' }, 'btl_0001'); + expect(findReceiptEquivocations([VALID, other])).toEqual(['btl_0001']); + }); + + it('ignores an exact duplicate', () => { + expect(findReceiptEquivocations([VALID, { ...VALID }])).toEqual([]); + }); +}); + +describe('combat log hash', () => { + it('changes when the log changes, even with the same result', () => { + // Binding the log stops the animation and the result being two different stories. + const outcomeA = simulate(1234567890123456n, 3, 10, 4, 6543210987654321n, 2, 11, 7, 1n); + const outcomeB = simulate(1234567890123456n, 3, 10, 4, 6543210987654321n, 2, 11, 7, 2n); + expect(hashCombatLog(outcomeA)).not.toBe(hashCombatLog(outcomeB)); + }); + + it('is deterministic', () => { + const outcome = simulate(1234567890123456n, 3, 10, 4, 6543210987654321n, 2, 11, 7, 1n); + expect(hashCombatLog(outcome)).toBe(hashCombatLog(outcome)); + }); +}); diff --git a/protocol/tests/receipt/vectors.test.ts b/protocol/tests/receipt/vectors.test.ts new file mode 100644 index 00000000..61a363ff --- /dev/null +++ b/protocol/tests/receipt/vectors.test.ts @@ -0,0 +1,205 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { simulate } from '../../src/combat'; +import type { ChainId } from '../../src/domain/chainId'; +import type { Hex } from '../../src/encoding/bytes'; +import { computeProgression } from '../../src/progression'; +import { deriveBattleSeed } from '../../src/randomness'; +import { type BattleReceipt, hashBattleReceipt, hashCombatLog } from '../../src/receipt'; +import { hashRuleset, SOURCE_DEFAULT_RULESET } from '../../src/ruleset'; +import { type BattleSnapshot, hashBattleSnapshot, type PetSnapshot } from '../../src/snapshot'; + +/** + * Consumes contracts/test-vectors/protocol-receipt.json. A failure means the + * implementation drifted, and the fix is the code, never the vector (`AGENTS.md`). + * + * The fixtures are inputs, not full receipts: the seed, result, combat-log hash, and + * progression are rebuilt here the same way the generator built them. That makes this + * test cover the composition too, since a receipt whose seed does not follow from its own + * inputs is rejected outright by validation. + */ +interface PetFixture { + petId: string; + owner: string; + dna: string; + rarity: number; + level: number; + skill: number; + xp: number; + lastOpponentId: string; + streak: number; + readyAt: number; + sourceVersion: string; +} + +interface ReceiptFixture { + chainId: string; + deploymentId: string; + battleId: string; + intentHash: string; + commitmentHash: string; + defenseAuthorizationHash: string; + snapshot: { + chainId: string; + deploymentId: string; + attacker: PetFixture; + defender: PetFixture; + takenAt: number; + }; + beacon: { chainHash: string; round: number; signature: string; randomness: string; publishedAt: number }; + attackerWon: boolean; + maxLevel: number; + sequence: number; + previousReceiptHash: string | null; + attackerPreviousReceiptHash: string | null; + defenderPreviousReceiptHash: string | null; + createdAt: number; + signingKeyId: string; +} + +interface ReceiptCase { + name: string; + note: string; + fixture: ReceiptFixture; + derived: { seed: string; rulesetHash: string; combatLogHash: string; result: unknown }; + expectedReceiptHash: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const vectorsPath = join(here, '../../../contracts/test-vectors/protocol-receipt.json'); +const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')) as { cases: ReceiptCase[] }; + +function toPet(fixture: PetFixture): PetSnapshot { + return { + petId: BigInt(fixture.petId), + owner: fixture.owner, + dna: BigInt(fixture.dna), + rarity: fixture.rarity, + level: fixture.level, + skill: fixture.skill, + xp: fixture.xp, + lastOpponentId: BigInt(fixture.lastOpponentId), + streak: fixture.streak, + readyAt: fixture.readyAt, + sourceVersion: BigInt(fixture.sourceVersion), + }; +} + +export function buildReceipt(fixture: ReceiptFixture): BattleReceipt { + const snapshot: BattleSnapshot = { + domain: { chainId: fixture.snapshot.chainId as ChainId, deploymentId: fixture.snapshot.deploymentId }, + attacker: toPet(fixture.snapshot.attacker), + defender: toPet(fixture.snapshot.defender), + takenAt: fixture.snapshot.takenAt, + }; + const domain = { chainId: fixture.chainId as ChainId, deploymentId: fixture.deploymentId }; + const rulesetHash = hashRuleset(SOURCE_DEFAULT_RULESET); + const seed = deriveBattleSeed({ + domain, + drandRandomness: fixture.beacon.randomness as Hex, + battleId: fixture.battleId, + snapshotHash: hashBattleSnapshot(snapshot), + rulesetHash, + }); + const outcome = simulate( + snapshot.attacker.dna, + snapshot.attacker.rarity, + snapshot.attacker.level, + snapshot.attacker.skill, + snapshot.defender.dna, + snapshot.defender.rarity, + snapshot.defender.level, + snapshot.defender.skill, + seed.value, + SOURCE_DEFAULT_RULESET.skillConfig, + ); + + return { + domain, + battleId: fixture.battleId, + intentHash: fixture.intentHash as Hex, + commitmentHash: fixture.commitmentHash as Hex, + defenseAuthorizationHash: fixture.defenseAuthorizationHash as Hex, + snapshot, + beacon: { + chainHash: fixture.beacon.chainHash as Hex, + round: fixture.beacon.round, + signature: fixture.beacon.signature as Hex, + randomness: fixture.beacon.randomness as Hex, + }, + seed: seed.hex, + rulesetVersion: SOURCE_DEFAULT_RULESET.version, + rulesetHash, + result: { + attackerWon: fixture.attackerWon, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + }, + combatLogHash: hashCombatLog(outcome), + progression: computeProgression(snapshot, fixture.attackerWon, { maxLevel: fixture.maxLevel }), + sequence: fixture.sequence, + previousReceiptHash: fixture.previousReceiptHash as Hex | null, + attackerPreviousReceiptHash: fixture.attackerPreviousReceiptHash as Hex | null, + defenderPreviousReceiptHash: fixture.defenderPreviousReceiptHash as Hex | null, + createdAt: fixture.createdAt, + signingKeyId: fixture.signingKeyId, + }; +} + +const byName = new Map(vectors.cases.map((c) => [c.name, c])); +const hashOf = (name: string) => { + const found = byName.get(name); + if (!found) throw new Error(`vector case missing: ${name}`); + return hashBattleReceipt(buildReceipt(found.fixture)); +}; + +describe('receipt golden vectors', () => { + for (const c of vectors.cases) { + it(`matches the recorded hash for "${c.name}"`, () => { + expect(hashBattleReceipt(buildReceipt(c.fixture))).toBe(c.expectedReceiptHash); + }); + + it(`reproduces the recorded derived values for "${c.name}"`, () => { + // The seed, combat-log hash, and result are derived rather than supplied, so + // recording them means a drift in seed derivation or in the fight itself shows + // up as its own failure instead of as an opaque receipt-hash mismatch. + const receipt = buildReceipt(c.fixture); + expect(receipt.seed).toBe(c.derived.seed); + expect(receipt.rulesetHash).toBe(c.derived.rulesetHash); + expect(receipt.combatLogHash).toBe(c.derived.combatLogHash); + expect(receipt.result).toEqual(c.derived.result); + }); + } +}); + +describe('relationships the vectors exist to pin', () => { + it('separates a linked receipt from an unlinked one', () => { + expect(hashOf('linked-receipt')).not.toBe(hashOf('first-receipt-under-key')); + }); + + it('separates one present per-pet link from none', () => { + expect(hashOf('attacker-first-battle-defender-veteran')).not.toBe(hashOf('first-receipt-under-key')); + }); + + it('separates the two outcomes', () => { + expect(hashOf('defender-wins')).not.toBe(hashOf('first-receipt-under-key')); + }); + + it('separates beacons, keys, deployments, and chains', () => { + const base = hashOf('first-receipt-under-key'); + expect(hashOf('later-beacon-round')).not.toBe(base); + expect(hashOf('other-signing-key')).not.toBe(base); + expect(hashOf('staging-deployment')).not.toBe(base); + expect(hashOf('solana-deployment')).not.toBe(base); + }); + + it('gives every case a distinct hash', () => { + const hashes = vectors.cases.map((c) => c.expectedReceiptHash); + expect(new Set(hashes).size).toBe(hashes.length); + }); +}); + +export { vectors as receiptVectors }; From f0c0c9d3478d551051eff52cea2477efc17decc7 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 08:21:53 -0400 Subject: [PATCH 17/76] feat(protocol): add canonical Merkle leaf encoding, proofs, and vectors --- contracts/test-vectors/protocol-merkle.json | 555 ++++++++++++++++++++ protocol/scripts/gen-vectors.ts | 59 +++ protocol/src/index.ts | 1 + protocol/src/merkle/index.ts | 14 + protocol/src/merkle/tree.ts | 192 +++++++ protocol/tests/merkle/merkle.test.ts | 179 +++++++ 6 files changed, 1000 insertions(+) create mode 100644 contracts/test-vectors/protocol-merkle.json create mode 100644 protocol/src/merkle/index.ts create mode 100644 protocol/src/merkle/tree.ts create mode 100644 protocol/tests/merkle/merkle.test.ts diff --git a/contracts/test-vectors/protocol-merkle.json b/contracts/test-vectors/protocol-merkle.json new file mode 100644 index 00000000..2dd5d1aa --- /dev/null +++ b/contracts/test-vectors/protocol-merkle.json @@ -0,0 +1,555 @@ +{ + "description": "Merkle leaf, root, and proof vectors (docs/plan-backend-battle-architecture.md §I). Generated by protocol/scripts/gen-vectors.ts from protocol/src/merkle. Layout notes for a Solidity implementation: leaf = keccak256(LEAF_DOMAIN || uint16 schemaVersion || receiptHash); node = keccak256(NODE_DOMAIN || min(a,b) || max(a,b)); all elements are fixed 32 bytes so abi.encodePacked matches; pairs are sorted so proofs carry no direction flags; an odd node is promoted unchanged rather than paired with itself. A failure means the implementation drifted. Never edit an expectation to match new output.", + "domains": { + "leaf": "0xea935f3622687fcaec0f16c38a011768b07df58a102da1ee85881dc236e9b935", + "node": "0xfc3a24400652b1e1b0cbd8aa53656a5a5af5f5ab615d7faeb2c37dd511e09ca1", + "leafSchemaVersion": 1 + }, + "cases": [ + { + "name": "batch-of-1", + "note": "Even leaf count.", + "receiptHashes": [ + "0x0101010101010101010101010101010101010101010101010101010101010101" + ], + "leaves": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5" + ], + "expectedRoot": "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "proofs": [ + { + "index": 0, + "proof": [] + } + ] + }, + { + "name": "batch-of-2", + "note": "Even leaf count.", + "receiptHashes": [ + "0x0101010101010101010101010101010101010101010101010101010101010101", + "0x0202020202020202020202020202020202020202020202020202020202020202" + ], + "leaves": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27" + ], + "expectedRoot": "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43", + "proofs": [ + { + "index": 0, + "proof": [ + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27" + ] + }, + { + "index": 1, + "proof": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5" + ] + } + ] + }, + { + "name": "batch-of-3", + "note": "Odd leaf count, so a node is promoted unchanged at least once.", + "receiptHashes": [ + "0x0101010101010101010101010101010101010101010101010101010101010101", + "0x0202020202020202020202020202020202020202020202020202020202020202", + "0x0303030303030303030303030303030303030303030303030303030303030303" + ], + "leaves": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb" + ], + "expectedRoot": "0x3b087560396c0cd1c3279997d96bf5ee5a303b932ccb661e1a5498db2f716237", + "proofs": [ + { + "index": 0, + "proof": [ + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb" + ] + }, + { + "index": 1, + "proof": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb" + ] + }, + { + "index": 2, + "proof": [ + "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43" + ] + } + ] + }, + { + "name": "batch-of-4", + "note": "Even leaf count.", + "receiptHashes": [ + "0x0101010101010101010101010101010101010101010101010101010101010101", + "0x0202020202020202020202020202020202020202020202020202020202020202", + "0x0303030303030303030303030303030303030303030303030303030303030303", + "0x0404040404040404040404040404040404040404040404040404040404040404" + ], + "leaves": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb", + "0x43924b1bd9db00857807839c2bed0551782a525024e6cc8884938a05e0e490eb" + ], + "expectedRoot": "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487", + "proofs": [ + { + "index": 0, + "proof": [ + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0xeb0ec07e5d46303c9a8697e9b9fe66f5cd8640f06a666d5858b852ba7ea37729" + ] + }, + { + "index": 1, + "proof": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0xeb0ec07e5d46303c9a8697e9b9fe66f5cd8640f06a666d5858b852ba7ea37729" + ] + }, + { + "index": 2, + "proof": [ + "0x43924b1bd9db00857807839c2bed0551782a525024e6cc8884938a05e0e490eb", + "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43" + ] + }, + { + "index": 3, + "proof": [ + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb", + "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43" + ] + } + ] + }, + { + "name": "batch-of-5", + "note": "Odd leaf count, so a node is promoted unchanged at least once.", + "receiptHashes": [ + "0x0101010101010101010101010101010101010101010101010101010101010101", + "0x0202020202020202020202020202020202020202020202020202020202020202", + "0x0303030303030303030303030303030303030303030303030303030303030303", + "0x0404040404040404040404040404040404040404040404040404040404040404", + "0x0505050505050505050505050505050505050505050505050505050505050505" + ], + "leaves": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb", + "0x43924b1bd9db00857807839c2bed0551782a525024e6cc8884938a05e0e490eb", + "0x516393ebd932eeda19fb798de9893e3e82585de18eeac730ad3cb61aeb9b1035" + ], + "expectedRoot": "0x7d11a24b504f6fca4577cd2f6c66705d6a2b08f12ac61ed379f844ba57150085", + "proofs": [ + { + "index": 0, + "proof": [ + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0xeb0ec07e5d46303c9a8697e9b9fe66f5cd8640f06a666d5858b852ba7ea37729", + "0x516393ebd932eeda19fb798de9893e3e82585de18eeac730ad3cb61aeb9b1035" + ] + }, + { + "index": 1, + "proof": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0xeb0ec07e5d46303c9a8697e9b9fe66f5cd8640f06a666d5858b852ba7ea37729", + "0x516393ebd932eeda19fb798de9893e3e82585de18eeac730ad3cb61aeb9b1035" + ] + }, + { + "index": 2, + "proof": [ + "0x43924b1bd9db00857807839c2bed0551782a525024e6cc8884938a05e0e490eb", + "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43", + "0x516393ebd932eeda19fb798de9893e3e82585de18eeac730ad3cb61aeb9b1035" + ] + }, + { + "index": 3, + "proof": [ + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb", + "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43", + "0x516393ebd932eeda19fb798de9893e3e82585de18eeac730ad3cb61aeb9b1035" + ] + }, + { + "index": 4, + "proof": [ + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487" + ] + } + ] + }, + { + "name": "batch-of-7", + "note": "Odd leaf count, so a node is promoted unchanged at least once.", + "receiptHashes": [ + "0x0101010101010101010101010101010101010101010101010101010101010101", + "0x0202020202020202020202020202020202020202020202020202020202020202", + "0x0303030303030303030303030303030303030303030303030303030303030303", + "0x0404040404040404040404040404040404040404040404040404040404040404", + "0x0505050505050505050505050505050505050505050505050505050505050505", + "0x0606060606060606060606060606060606060606060606060606060606060606", + "0x0707070707070707070707070707070707070707070707070707070707070707" + ], + "leaves": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb", + "0x43924b1bd9db00857807839c2bed0551782a525024e6cc8884938a05e0e490eb", + "0x516393ebd932eeda19fb798de9893e3e82585de18eeac730ad3cb61aeb9b1035", + "0x71ceff7c46dce456e7d3d5b87069bbe6d7e12e6dd058f1cb825f2237b6a12206", + "0x0cf8f2806a5d899bdab02481e5a8d0555dd5d22aff3e5b101679317aa7b20de1" + ], + "expectedRoot": "0xbbcfe7a136e8417660c7dd701013f05139c0b5ef3cbb265dfa3e45d2e5305890", + "proofs": [ + { + "index": 0, + "proof": [ + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0xeb0ec07e5d46303c9a8697e9b9fe66f5cd8640f06a666d5858b852ba7ea37729", + "0x44d02c7d88665e1e2e76cee89cc76f2dbba4fccfe8ae436ab4a5618d29549ef7" + ] + }, + { + "index": 1, + "proof": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0xeb0ec07e5d46303c9a8697e9b9fe66f5cd8640f06a666d5858b852ba7ea37729", + "0x44d02c7d88665e1e2e76cee89cc76f2dbba4fccfe8ae436ab4a5618d29549ef7" + ] + }, + { + "index": 2, + "proof": [ + "0x43924b1bd9db00857807839c2bed0551782a525024e6cc8884938a05e0e490eb", + "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43", + "0x44d02c7d88665e1e2e76cee89cc76f2dbba4fccfe8ae436ab4a5618d29549ef7" + ] + }, + { + "index": 3, + "proof": [ + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb", + "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43", + "0x44d02c7d88665e1e2e76cee89cc76f2dbba4fccfe8ae436ab4a5618d29549ef7" + ] + }, + { + "index": 4, + "proof": [ + "0x71ceff7c46dce456e7d3d5b87069bbe6d7e12e6dd058f1cb825f2237b6a12206", + "0x0cf8f2806a5d899bdab02481e5a8d0555dd5d22aff3e5b101679317aa7b20de1", + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487" + ] + }, + { + "index": 5, + "proof": [ + "0x516393ebd932eeda19fb798de9893e3e82585de18eeac730ad3cb61aeb9b1035", + "0x0cf8f2806a5d899bdab02481e5a8d0555dd5d22aff3e5b101679317aa7b20de1", + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487" + ] + }, + { + "index": 6, + "proof": [ + "0x8a9ffc7d9e2ad07196b4d22096a75869119cac0364463c816d525e731ae746f2", + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487" + ] + } + ] + }, + { + "name": "batch-of-8", + "note": "Even leaf count.", + "receiptHashes": [ + "0x0101010101010101010101010101010101010101010101010101010101010101", + "0x0202020202020202020202020202020202020202020202020202020202020202", + "0x0303030303030303030303030303030303030303030303030303030303030303", + "0x0404040404040404040404040404040404040404040404040404040404040404", + "0x0505050505050505050505050505050505050505050505050505050505050505", + "0x0606060606060606060606060606060606060606060606060606060606060606", + "0x0707070707070707070707070707070707070707070707070707070707070707", + "0x0808080808080808080808080808080808080808080808080808080808080808" + ], + "leaves": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb", + "0x43924b1bd9db00857807839c2bed0551782a525024e6cc8884938a05e0e490eb", + "0x516393ebd932eeda19fb798de9893e3e82585de18eeac730ad3cb61aeb9b1035", + "0x71ceff7c46dce456e7d3d5b87069bbe6d7e12e6dd058f1cb825f2237b6a12206", + "0x0cf8f2806a5d899bdab02481e5a8d0555dd5d22aff3e5b101679317aa7b20de1", + "0xbf17d60ebda1e739935da0a049f73b8343530110209ddabce3b2fcf10fa80a00" + ], + "expectedRoot": "0xaefa1b874b27ba094058654ad245cb7f977cf69f86f07dab66bf870da4a18e41", + "proofs": [ + { + "index": 0, + "proof": [ + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0xeb0ec07e5d46303c9a8697e9b9fe66f5cd8640f06a666d5858b852ba7ea37729", + "0xd8d2ddd06289ff23302593ffa29bafd75420605d9391cae6f57236a774de8972" + ] + }, + { + "index": 1, + "proof": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0xeb0ec07e5d46303c9a8697e9b9fe66f5cd8640f06a666d5858b852ba7ea37729", + "0xd8d2ddd06289ff23302593ffa29bafd75420605d9391cae6f57236a774de8972" + ] + }, + { + "index": 2, + "proof": [ + "0x43924b1bd9db00857807839c2bed0551782a525024e6cc8884938a05e0e490eb", + "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43", + "0xd8d2ddd06289ff23302593ffa29bafd75420605d9391cae6f57236a774de8972" + ] + }, + { + "index": 3, + "proof": [ + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb", + "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43", + "0xd8d2ddd06289ff23302593ffa29bafd75420605d9391cae6f57236a774de8972" + ] + }, + { + "index": 4, + "proof": [ + "0x71ceff7c46dce456e7d3d5b87069bbe6d7e12e6dd058f1cb825f2237b6a12206", + "0xdf171820a346cf9950737109d7116bd754f46c8cafb9bac5f4e365445059c120", + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487" + ] + }, + { + "index": 5, + "proof": [ + "0x516393ebd932eeda19fb798de9893e3e82585de18eeac730ad3cb61aeb9b1035", + "0xdf171820a346cf9950737109d7116bd754f46c8cafb9bac5f4e365445059c120", + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487" + ] + }, + { + "index": 6, + "proof": [ + "0xbf17d60ebda1e739935da0a049f73b8343530110209ddabce3b2fcf10fa80a00", + "0x8a9ffc7d9e2ad07196b4d22096a75869119cac0364463c816d525e731ae746f2", + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487" + ] + }, + { + "index": 7, + "proof": [ + "0x0cf8f2806a5d899bdab02481e5a8d0555dd5d22aff3e5b101679317aa7b20de1", + "0x8a9ffc7d9e2ad07196b4d22096a75869119cac0364463c816d525e731ae746f2", + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487" + ] + } + ] + }, + { + "name": "batch-of-16", + "note": "Even leaf count.", + "receiptHashes": [ + "0x0101010101010101010101010101010101010101010101010101010101010101", + "0x0202020202020202020202020202020202020202020202020202020202020202", + "0x0303030303030303030303030303030303030303030303030303030303030303", + "0x0404040404040404040404040404040404040404040404040404040404040404", + "0x0505050505050505050505050505050505050505050505050505050505050505", + "0x0606060606060606060606060606060606060606060606060606060606060606", + "0x0707070707070707070707070707070707070707070707070707070707070707", + "0x0808080808080808080808080808080808080808080808080808080808080808", + "0x0909090909090909090909090909090909090909090909090909090909090909", + "0x0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a", + "0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + "0x0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c", + "0x0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d", + "0x0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e", + "0x0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f", + "0x1010101010101010101010101010101010101010101010101010101010101010" + ], + "leaves": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb", + "0x43924b1bd9db00857807839c2bed0551782a525024e6cc8884938a05e0e490eb", + "0x516393ebd932eeda19fb798de9893e3e82585de18eeac730ad3cb61aeb9b1035", + "0x71ceff7c46dce456e7d3d5b87069bbe6d7e12e6dd058f1cb825f2237b6a12206", + "0x0cf8f2806a5d899bdab02481e5a8d0555dd5d22aff3e5b101679317aa7b20de1", + "0xbf17d60ebda1e739935da0a049f73b8343530110209ddabce3b2fcf10fa80a00", + "0x70bde66673fdb68e0028f5cd2eafc6beee169d9ec03a23546a42c39ffaf76a96", + "0xeec2d686654e5662275adf90da1e5540b5912aebe1caae0ac5903082402592c3", + "0xf2a12db096edaa9a3370cba26f147301eabb845b54d98f89fe0fbf4c93277ac5", + "0x91af3241d65843ee82feb5e2da43c96182395415eba0db3b18a81f9061d2f93f", + "0xf1d3755ec675071dde0669be795d2c6203b5e0974e01136306fc86f29ff84acd", + "0x055a48b55006a8ae6dd1f8550f35fe4f919c3cfff9442af494b192e3b4f7e353", + "0x866d30bde0cc09b26641d3a109a6cb7a0224895fbf8511b26d0b94bda6270fe3", + "0x72d0155b0141fa6de549569717238fa15be60c2da7ee86fed00e7b63383d67b4" + ], + "expectedRoot": "0xe04ac77c873ec7828c240ddff7660c6aff21fe7bbb62ed6af0ee6ae9979038ed", + "proofs": [ + { + "index": 0, + "proof": [ + "0x42090143219439c2cc361f1b1004e24b91996574f8a70fdaefb2c5b4ba0dba27", + "0xeb0ec07e5d46303c9a8697e9b9fe66f5cd8640f06a666d5858b852ba7ea37729", + "0xd8d2ddd06289ff23302593ffa29bafd75420605d9391cae6f57236a774de8972", + "0x55f0a585f2d83a3bc2ac9eef450b45a13dc76046fa4c7210e4a2aea216681357" + ] + }, + { + "index": 1, + "proof": [ + "0x47c992dc9ebfaa3287865a25d3ebd1117ff044eb3b17edfdb6e39fffc04eb0a5", + "0xeb0ec07e5d46303c9a8697e9b9fe66f5cd8640f06a666d5858b852ba7ea37729", + "0xd8d2ddd06289ff23302593ffa29bafd75420605d9391cae6f57236a774de8972", + "0x55f0a585f2d83a3bc2ac9eef450b45a13dc76046fa4c7210e4a2aea216681357" + ] + }, + { + "index": 2, + "proof": [ + "0x43924b1bd9db00857807839c2bed0551782a525024e6cc8884938a05e0e490eb", + "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43", + "0xd8d2ddd06289ff23302593ffa29bafd75420605d9391cae6f57236a774de8972", + "0x55f0a585f2d83a3bc2ac9eef450b45a13dc76046fa4c7210e4a2aea216681357" + ] + }, + { + "index": 3, + "proof": [ + "0x1a666a31f9705b161d2f1f59771645aed70acd944393f7130e2996a870a5ffdb", + "0xcbf6dd0d45ac1f36b9a6bd0d78e99921aeef55ddb24ee45a424354ff24e6ce43", + "0xd8d2ddd06289ff23302593ffa29bafd75420605d9391cae6f57236a774de8972", + "0x55f0a585f2d83a3bc2ac9eef450b45a13dc76046fa4c7210e4a2aea216681357" + ] + }, + { + "index": 4, + "proof": [ + "0x71ceff7c46dce456e7d3d5b87069bbe6d7e12e6dd058f1cb825f2237b6a12206", + "0xdf171820a346cf9950737109d7116bd754f46c8cafb9bac5f4e365445059c120", + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487", + "0x55f0a585f2d83a3bc2ac9eef450b45a13dc76046fa4c7210e4a2aea216681357" + ] + }, + { + "index": 5, + "proof": [ + "0x516393ebd932eeda19fb798de9893e3e82585de18eeac730ad3cb61aeb9b1035", + "0xdf171820a346cf9950737109d7116bd754f46c8cafb9bac5f4e365445059c120", + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487", + "0x55f0a585f2d83a3bc2ac9eef450b45a13dc76046fa4c7210e4a2aea216681357" + ] + }, + { + "index": 6, + "proof": [ + "0xbf17d60ebda1e739935da0a049f73b8343530110209ddabce3b2fcf10fa80a00", + "0x8a9ffc7d9e2ad07196b4d22096a75869119cac0364463c816d525e731ae746f2", + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487", + "0x55f0a585f2d83a3bc2ac9eef450b45a13dc76046fa4c7210e4a2aea216681357" + ] + }, + { + "index": 7, + "proof": [ + "0x0cf8f2806a5d899bdab02481e5a8d0555dd5d22aff3e5b101679317aa7b20de1", + "0x8a9ffc7d9e2ad07196b4d22096a75869119cac0364463c816d525e731ae746f2", + "0xc665bc9b2cba74a1a9c972a87253e4d05737cd392afddcc327f21e59e1fa7487", + "0x55f0a585f2d83a3bc2ac9eef450b45a13dc76046fa4c7210e4a2aea216681357" + ] + }, + { + "index": 8, + "proof": [ + "0xeec2d686654e5662275adf90da1e5540b5912aebe1caae0ac5903082402592c3", + "0xf7aba755773cb20e12bc1572e224d49e5ce4a2783d52f826276806a058adf10b", + "0x1158173afdd1f54638f344fcb55c9e94cf6e5b0d2fdea6fbe37ca7d3fc5b1119", + "0xaefa1b874b27ba094058654ad245cb7f977cf69f86f07dab66bf870da4a18e41" + ] + }, + { + "index": 9, + "proof": [ + "0x70bde66673fdb68e0028f5cd2eafc6beee169d9ec03a23546a42c39ffaf76a96", + "0xf7aba755773cb20e12bc1572e224d49e5ce4a2783d52f826276806a058adf10b", + "0x1158173afdd1f54638f344fcb55c9e94cf6e5b0d2fdea6fbe37ca7d3fc5b1119", + "0xaefa1b874b27ba094058654ad245cb7f977cf69f86f07dab66bf870da4a18e41" + ] + }, + { + "index": 10, + "proof": [ + "0x91af3241d65843ee82feb5e2da43c96182395415eba0db3b18a81f9061d2f93f", + "0xf7e6cf5b1a877b7ae9315fe50105ebd77d58900e682d890ca3e56a7608efbea2", + "0x1158173afdd1f54638f344fcb55c9e94cf6e5b0d2fdea6fbe37ca7d3fc5b1119", + "0xaefa1b874b27ba094058654ad245cb7f977cf69f86f07dab66bf870da4a18e41" + ] + }, + { + "index": 11, + "proof": [ + "0xf2a12db096edaa9a3370cba26f147301eabb845b54d98f89fe0fbf4c93277ac5", + "0xf7e6cf5b1a877b7ae9315fe50105ebd77d58900e682d890ca3e56a7608efbea2", + "0x1158173afdd1f54638f344fcb55c9e94cf6e5b0d2fdea6fbe37ca7d3fc5b1119", + "0xaefa1b874b27ba094058654ad245cb7f977cf69f86f07dab66bf870da4a18e41" + ] + }, + { + "index": 12, + "proof": [ + "0x055a48b55006a8ae6dd1f8550f35fe4f919c3cfff9442af494b192e3b4f7e353", + "0xc915e31f5493f4017bb3f63813322a8227b84997947980119e0cbc5521942278", + "0xf73bb9d3e69689426141050ab21e1bf97df48cb5d4ff7a1656fd17c3cf5a104b", + "0xaefa1b874b27ba094058654ad245cb7f977cf69f86f07dab66bf870da4a18e41" + ] + }, + { + "index": 13, + "proof": [ + "0xf1d3755ec675071dde0669be795d2c6203b5e0974e01136306fc86f29ff84acd", + "0xc915e31f5493f4017bb3f63813322a8227b84997947980119e0cbc5521942278", + "0xf73bb9d3e69689426141050ab21e1bf97df48cb5d4ff7a1656fd17c3cf5a104b", + "0xaefa1b874b27ba094058654ad245cb7f977cf69f86f07dab66bf870da4a18e41" + ] + }, + { + "index": 14, + "proof": [ + "0x72d0155b0141fa6de549569717238fa15be60c2da7ee86fed00e7b63383d67b4", + "0x6d1390cc7b70643dace8eeb70e486c4aa2e0904444b15f62641692f1e9ac4ab1", + "0xf73bb9d3e69689426141050ab21e1bf97df48cb5d4ff7a1656fd17c3cf5a104b", + "0xaefa1b874b27ba094058654ad245cb7f977cf69f86f07dab66bf870da4a18e41" + ] + }, + { + "index": 15, + "proof": [ + "0x866d30bde0cc09b26641d3a109a6cb7a0224895fbf8511b26d0b94bda6270fe3", + "0x6d1390cc7b70643dace8eeb70e486c4aa2e0904444b15f62641692f1e9ac4ab1", + "0xf73bb9d3e69689426141050ab21e1bf97df48cb5d4ff7a1656fd17c3cf5a104b", + "0xaefa1b874b27ba094058654ad245cb7f977cf69f86f07dab66bf870da4a18e41" + ] + } + ] + } + ] +} diff --git a/protocol/scripts/gen-vectors.ts b/protocol/scripts/gen-vectors.ts index 2ed07225..1ac070fe 100644 --- a/protocol/scripts/gen-vectors.ts +++ b/protocol/scripts/gen-vectors.ts @@ -26,6 +26,13 @@ import type { ChainId } from '../src/domain/chainId'; import type { Hex } from '../src/encoding/bytes'; import { battleIntentSolanaMessage, type BattleIntent, hashBattleIntent } from '../src/intent'; import { simulate } from '../src/combat'; +import { + buildMerkleTree, + MERKLE_LEAF_DOMAIN, + MERKLE_NODE_DOMAIN, + merkleLeaf, + merkleProof, +} from '../src/merkle'; import { computeProgression, type ProgressionDelta, type ProgressionParams } from '../src/progression'; import { type BattleReceipt, hashBattleReceipt, hashCombatLog } from '../src/receipt'; import { deriveBattleSeed, type SeedInputs } from '../src/randomness'; @@ -1203,6 +1210,57 @@ function writeReceiptVectors(): void { process.stdout.write(`wrote ${out.cases.length} receipt cases to ${path}\n`); } +/** + * Merkle cases. + * + * These exist mainly for the Solidity side: the root registry and claim contract have to + * agree with this layout byte for byte, so the contract tests consume this same file rather + * than a Solidity-authored fixture. Batch sizes cover both parities and the odd-node + * promotion at several depths, since that is where implementations usually diverge. + */ +const merkleBatchSizes = [1, 2, 3, 4, 5, 7, 8, 16]; + +function syntheticReceiptHash(index: number): Hex { + // Deterministic stand-ins for receipt hashes. What the tree does is independent of what + // the leaves mean, and the real receipt hashes already have their own vector file. + const byte = (index + 1) % 256; + return `0x${byte.toString(16).padStart(2, '0').repeat(32)}`; +} + +function writeMerkleVectors(): void { + const out = { + description: + 'Merkle leaf, root, and proof vectors (docs/plan-backend-battle-architecture.md §I). Generated by protocol/scripts/gen-vectors.ts from protocol/src/merkle. Layout notes for a Solidity implementation: leaf = keccak256(LEAF_DOMAIN || uint16 schemaVersion || receiptHash); node = keccak256(NODE_DOMAIN || min(a,b) || max(a,b)); all elements are fixed 32 bytes so abi.encodePacked matches; pairs are sorted so proofs carry no direction flags; an odd node is promoted unchanged rather than paired with itself. A failure means the implementation drifted. Never edit an expectation to match new output.', + domains: { + leaf: MERKLE_LEAF_DOMAIN, + node: MERKLE_NODE_DOMAIN, + leafSchemaVersion: 1, + }, + cases: merkleBatchSizes.map((size) => { + const receiptHashes = Array.from({ length: size }, (_, i) => syntheticReceiptHash(i)); + const leaves = receiptHashes.map((hash) => merkleLeaf(hash)); + const tree = buildMerkleTree(leaves); + return { + name: `batch-of-${size}`, + note: + size % 2 === 1 && size > 1 + ? 'Odd leaf count, so a node is promoted unchanged at least once.' + : 'Even leaf count.', + receiptHashes, + leaves, + expectedRoot: tree.root, + proofs: receiptHashes.map((_, index) => ({ + index, + proof: merkleProof(tree, index), + })), + }; + }), + }; + const path = join(VECTORS_DIR, 'protocol-merkle.json'); + writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`); + process.stdout.write(`wrote ${out.cases.length} merkle cases to ${path}\n`); +} + writeIntentVectors(); writeConsentVectors(); writeSnapshotVectors(); @@ -1211,3 +1269,4 @@ writeCommitmentVectors(); writeProgressionVectors(); writeRulesetVectors(); writeReceiptVectors(); +writeMerkleVectors(); diff --git a/protocol/src/index.ts b/protocol/src/index.ts index 3a89947d..e757893c 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -17,6 +17,7 @@ export * from './consent'; export * from './domain'; export * from './encoding'; export * from './intent'; +export * from './merkle'; export * from './progression'; export * from './randomness'; export * from './receipt'; diff --git a/protocol/src/merkle/index.ts b/protocol/src/merkle/index.ts new file mode 100644 index 00000000..437d4005 --- /dev/null +++ b/protocol/src/merkle/index.ts @@ -0,0 +1,14 @@ +export { + buildMerkleTree, + MERKLE_LEAF_DOMAIN, + MERKLE_NODE_DOMAIN, + merkleLeaf, + merkleLeafPreimage, + merkleNode, + merkleProof, + merkleRoot, + type MerkleTree, + processMerkleProof, + verifyMerkleProof, + verifyReceiptInclusion, +} from './tree'; diff --git a/protocol/src/merkle/tree.ts b/protocol/src/merkle/tree.ts new file mode 100644 index 00000000..d084a143 --- /dev/null +++ b/protocol/src/merkle/tree.ts @@ -0,0 +1,192 @@ +import { currentSchemaVersion } from '../domain/schemaVersions'; +import { bytesToHex, concatBytes, type Hex, toBytes, uintToBytes, utf8ToBytes } from '../encoding/bytes'; +import { DOMAIN_TAGS } from '../encoding/domain'; +import { keccak256Hex } from '../encoding/hash'; + +/** + * Merkle trees over receipts (§I). + * + * One anchored root stands for thousands of battles, so a season's rewards cost one + * transaction instead of one per fight. A claimant later shows that their receipt was in + * the pile. + * + * The layout here is chosen to be cheap for a Solidity verifier, since the root registry + * and claim contract have to agree with it byte for byte (Step 37): + * + * - Every hashed element is a fixed 32 bytes, so concatenation is unambiguous and no + * length prefixes are needed. This is the one place the canonical writer is not used; + * `abi.encodePacked(bytes32, bytes32, bytes32)` in a contract has to produce identical + * bytes, and framing it would mean reimplementing the writer in Solidity. + * - Pairs are sorted before hashing, so a proof is a list of siblings with no direction + * flags. Same convention as OpenZeppelin's `MerkleProof`. + * - Leaves and internal nodes are domain-separated, which OpenZeppelin does not do. Without + * it, an internal node can be presented as a leaf, and someone proves membership of + * something that was never in the set. + */ + +/** `keccak256("CRYPTOPETS_MERKLE_LEAF_V1")`, the leaf domain separator. */ +export const MERKLE_LEAF_DOMAIN: Hex = keccak256Hex(utf8ToBytes(DOMAIN_TAGS.MERKLE_LEAF)); +/** `keccak256("CRYPTOPETS_MERKLE_NODE_V1")`, the internal-node domain separator. */ +export const MERKLE_NODE_DOMAIN: Hex = keccak256Hex(utf8ToBytes(DOMAIN_TAGS.MERKLE_NODE)); + +/** + * Leaf for one receipt: `keccak256(LEAF_DOMAIN || schemaVersion || receiptHash)`. + * + * The receipt hash already binds every field of the battle, so nothing else needs to be in + * the leaf. Reward-bearing leaves will need their own kind when the reward model lands, and + * they will get their own domain tag rather than extending this one. + */ +export function merkleLeaf(receiptHash: Hex): Hex { + const hash = toBytes(receiptHash); + if (hash.length !== 32) { + throw new Error(`receipt hash must be 32 bytes, got ${hash.length}`); + } + return keccak256Hex( + concatBytes([ + toBytes(MERKLE_LEAF_DOMAIN), + uintToBytes(currentSchemaVersion('merkleLeaf'), 2), + hash, + ]), + ); +} + +/** Internal node: `keccak256(NODE_DOMAIN || min(a,b) || max(a,b))`. */ +export function merkleNode(a: Hex, b: Hex): Hex { + const left = toBytes(a); + const right = toBytes(b); + if (left.length !== 32 || right.length !== 32) { + throw new Error('merkle node children must both be 32 bytes'); + } + const [first, second] = compareBytes(left, right) <= 0 ? [left, right] : [right, left]; + return keccak256Hex(concatBytes([toBytes(MERKLE_NODE_DOMAIN), first, second])); +} + +/** A built tree: its root and every layer, bottom-up. */ +export interface MerkleTree { + root: Hex; + /** `layers[0]` is the leaves; the last layer is `[root]`. */ + layers: Hex[][]; +} + +/** + * Builds a tree from leaves, in the order given. + * + * An odd node is promoted to the next layer unchanged rather than paired with itself. + * Duplicating it, which some implementations do, lets someone prove membership of a leaf + * that appears once by presenting it as the duplicated pair. + * + * Duplicate leaves are rejected: two identical leaves make an inclusion proof ambiguous + * about which one it covers, and for reward claims that ambiguity is the whole attack. + * Receipt hashes are unique in practice, so a duplicate means the caller built the batch + * wrong. + */ +export function buildMerkleTree(leaves: readonly Hex[]): MerkleTree { + if (leaves.length === 0) { + throw new Error('cannot build a merkle tree over an empty set'); + } + const normalized = leaves.map((leaf, index) => { + if (toBytes(leaf).length !== 32) { + throw new Error(`leaf at index ${index} must be 32 bytes`); + } + return leaf.toLowerCase() as Hex; + }); + if (new Set(normalized).size !== normalized.length) { + throw new Error('merkle leaves must be unique; a duplicate makes an inclusion proof ambiguous'); + } + + const layers: Hex[][] = [normalized]; + while (layers[layers.length - 1]!.length > 1) { + const current = layers[layers.length - 1]!; + const next: Hex[] = []; + for (let i = 0; i < current.length; i += 2) { + const left = current[i]!; + const right = current[i + 1]; + next.push(right === undefined ? left : merkleNode(left, right)); + } + layers.push(next); + } + + return { root: layers[layers.length - 1]![0]!, layers }; +} + +/** Root for a set of leaves. */ +export function merkleRoot(leaves: readonly Hex[]): Hex { + return buildMerkleTree(leaves).root; +} + +/** + * Sibling hashes proving the leaf at `index` is in the tree. + * + * No direction flags, because pairs are sorted when hashed. A promoted odd node + * contributes no sibling at that level, which is why proof lengths vary within one tree. + */ +export function merkleProof(tree: MerkleTree, index: number): Hex[] { + if (!Number.isSafeInteger(index) || index < 0 || index >= tree.layers[0]!.length) { + throw new Error(`leaf index ${index} is out of range`); + } + const proof: Hex[] = []; + let position = index; + for (let level = 0; level < tree.layers.length - 1; level++) { + const layer = tree.layers[level]!; + const siblingIndex = position % 2 === 0 ? position + 1 : position - 1; + const sibling = layer[siblingIndex]; + if (sibling !== undefined) { + proof.push(sibling); + } + position = Math.floor(position / 2); + } + return proof; +} + +/** + * Recomputes the root from a leaf and its proof. + * + * This is what a contract does, so keeping it a plain fold over sorted pairs is the point. + */ +export function processMerkleProof(leaf: Hex, proof: readonly Hex[]): Hex { + let computed = leaf.toLowerCase() as Hex; + if (toBytes(computed).length !== 32) { + throw new Error('leaf must be 32 bytes'); + } + for (const sibling of proof) { + computed = merkleNode(computed, sibling); + } + return computed; +} + +/** Whether `leaf` with `proof` reaches `root`. */ +export function verifyMerkleProof(leaf: Hex, proof: readonly Hex[], root: Hex): boolean { + try { + return processMerkleProof(leaf, proof) === root.toLowerCase(); + } catch { + return false; + } +} + +/** Whether a receipt is in a batch, by its hash. */ +export function verifyReceiptInclusion(receiptHash: Hex, proof: readonly Hex[], root: Hex): boolean { + return verifyMerkleProof(merkleLeaf(receiptHash), proof, root); +} + +function compareBytes(a: Uint8Array, b: Uint8Array): number { + for (let i = 0; i < a.length; i++) { + const left = a[i]!; + const right = b[i]!; + if (left !== right) { + return left < right ? -1 : 1; + } + } + return 0; +} + +/** Exported for the vector generator and for debugging a proof mismatch. */ +export function merkleLeafPreimage(receiptHash: Hex): Hex { + return bytesToHex( + concatBytes([ + toBytes(MERKLE_LEAF_DOMAIN), + uintToBytes(currentSchemaVersion('merkleLeaf'), 2), + toBytes(receiptHash), + ]), + ); +} + diff --git a/protocol/tests/merkle/merkle.test.ts b/protocol/tests/merkle/merkle.test.ts new file mode 100644 index 00000000..a6a4744e --- /dev/null +++ b/protocol/tests/merkle/merkle.test.ts @@ -0,0 +1,179 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import type { Hex } from '../../src/encoding/bytes'; +import { + buildMerkleTree, + MERKLE_LEAF_DOMAIN, + MERKLE_NODE_DOMAIN, + merkleLeaf, + merkleNode, + merkleProof, + merkleRoot, + processMerkleProof, + verifyMerkleProof, + verifyReceiptInclusion, +} from '../../src/merkle'; + +/** + * Consumes contracts/test-vectors/protocol-merkle.json. A failure means the implementation + * drifted, and the fix is the code, never the vector (`AGENTS.md`). + * + * This file is also the contract-side specification: the root registry and claim contract + * consume the same vectors, so a Solidity implementation that disagrees fails here first. + */ +interface MerkleCase { + name: string; + note: string; + receiptHashes: string[]; + leaves: string[]; + expectedRoot: string; + proofs: { index: number; proof: string[] }[]; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const vectorsPath = join(here, '../../../contracts/test-vectors/protocol-merkle.json'); +const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8')) as { + domains: { leaf: string; node: string; leafSchemaVersion: number }; + cases: MerkleCase[]; +}; + +describe('domain separators', () => { + it('match the recorded constants', () => { + expect(MERKLE_LEAF_DOMAIN).toBe(vectors.domains.leaf); + expect(MERKLE_NODE_DOMAIN).toBe(vectors.domains.node); + }); + + it('differ from each other', () => { + // The property that stops an internal node being passed off as a leaf. + expect(MERKLE_LEAF_DOMAIN).not.toBe(MERKLE_NODE_DOMAIN); + }); +}); + +describe('golden vectors', () => { + for (const c of vectors.cases) { + it(`derives the recorded leaves and root for ${c.name}`, () => { + expect(c.receiptHashes.map((hash) => merkleLeaf(hash as Hex))).toEqual(c.leaves); + expect(merkleRoot(c.leaves as Hex[])).toBe(c.expectedRoot); + }); + + it(`verifies every recorded proof for ${c.name}`, () => { + for (const { index, proof } of c.proofs) { + expect( + verifyReceiptInclusion( + c.receiptHashes[index]! as Hex, + proof as Hex[], + c.expectedRoot as Hex, + ), + ).toBe(true); + } + }); + + it(`regenerates the recorded proofs for ${c.name}`, () => { + const tree = buildMerkleTree(c.leaves as Hex[]); + for (const { index, proof } of c.proofs) { + expect(merkleProof(tree, index)).toEqual(proof); + } + }); + } +}); + +describe('tree shape', () => { + const leaves = (n: number): Hex[] => + Array.from({ length: n }, (_, i) => merkleLeaf(`0x${((i + 1) % 256).toString(16).padStart(2, '0').repeat(32)}`)); + + it('makes a single leaf its own root', () => { + const single = leaves(1); + expect(merkleRoot(single)).toBe(single[0]); + }); + + it('promotes an odd node unchanged rather than pairing it with itself', () => { + // Self-pairing lets someone prove membership of a leaf that appears once by + // presenting it as the duplicated pair, so the promoted shape is deliberate. + const three = leaves(3); + const expected = merkleNode(merkleNode(three[0]!, three[1]!), three[2]!); + expect(merkleRoot(three)).toBe(expected); + }); + + it('gives shorter proofs to promoted nodes', () => { + const tree = buildMerkleTree(leaves(3)); + expect(merkleProof(tree, 0)).toHaveLength(2); + expect(merkleProof(tree, 2)).toHaveLength(1); + }); + + it('depends on leaf order', () => { + const [a, b, c] = leaves(3) as [Hex, Hex, Hex]; + expect(merkleRoot([a, b, c])).not.toBe(merkleRoot([c, b, a])); + }); + + it('hashes pairs commutatively, so proofs need no direction flags', () => { + const [a, b] = leaves(2) as [Hex, Hex]; + expect(merkleNode(a, b)).toBe(merkleNode(b, a)); + }); + + it('rejects an empty set', () => { + expect(() => merkleRoot([])).toThrow(/empty set/); + }); + + it('rejects duplicate leaves', () => { + // A duplicate makes an inclusion proof ambiguous about which entry it covers, and + // for reward claims that ambiguity is the attack. + const [a] = leaves(1) as [Hex]; + expect(() => merkleRoot([a, a])).toThrow(/must be unique/); + }); + + it('rejects a leaf of the wrong width', () => { + expect(() => merkleRoot(['0x1234' as Hex])).toThrow(/must be 32 bytes/); + expect(() => merkleLeaf('0x1234' as Hex)).toThrow(/must be 32 bytes/); + }); +}); + +describe('proof verification', () => { + const receiptHashes = Array.from({ length: 5 }, (_, i) => `0x${(i + 1).toString(16).padStart(2, '0').repeat(32)}` as Hex); + const leafHashes = receiptHashes.map((hash) => merkleLeaf(hash)); + const tree = buildMerkleTree(leafHashes); + + it('accepts a valid proof for each index', () => { + for (let index = 0; index < receiptHashes.length; index++) { + expect(verifyReceiptInclusion(receiptHashes[index]!, merkleProof(tree, index), tree.root)).toBe(true); + } + }); + + it('rejects a proof against the wrong root', () => { + expect(verifyMerkleProof(leafHashes[0]!, merkleProof(tree, 0), `0x${'99'.repeat(32)}`)).toBe(false); + }); + + it('rejects a proof for a leaf that is not in the tree', () => { + const outsider = merkleLeaf(`0x${'ee'.repeat(32)}`); + expect(verifyMerkleProof(outsider, merkleProof(tree, 0), tree.root)).toBe(false); + }); + + it('rejects a tampered sibling', () => { + const proof = merkleProof(tree, 1); + proof[0] = `0x${'77'.repeat(32)}`; + expect(verifyMerkleProof(leafHashes[1]!, proof, tree.root)).toBe(false); + }); + + it('rejects a truncated proof', () => { + expect(verifyMerkleProof(leafHashes[1]!, merkleProof(tree, 1).slice(1), tree.root)).toBe(false); + }); + + it('rejects an internal node presented as a leaf', () => { + // The second-preimage attack domain separation prevents: without distinct leaf and + // node domains, this node would verify as a member of the set. + const internalNode = tree.layers[1]![0]!; + const proofFromLevelOne = tree.layers.length > 2 ? [tree.layers[1]![1]!] : []; + expect(verifyMerkleProof(internalNode, proofFromLevelOne, tree.root)).toBe(false); + }); + + it('rejects a malformed leaf without throwing', () => { + expect(verifyMerkleProof('0xabcd' as Hex, [], tree.root)).toBe(false); + }); + + it('treats an empty proof as a claim that the leaf is the root', () => { + expect(processMerkleProof(tree.root, [])).toBe(tree.root); + expect(verifyMerkleProof(leafHashes[0]!, [], tree.root)).toBe(false); + }); +}); From a304143376acc8a13d79a98de67b8a8934bae908 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 08:33:35 -0400 Subject: [PATCH 18/76] feat(backend): add battle ledger, commitment, receipt, and progress models --- .../migration.sql | 292 +++++++++++++++ backend/prisma/schema.prisma | 339 ++++++++++++++++++ 2 files changed, 631 insertions(+) create mode 100644 backend/prisma/migrations/20260726090000_add_battle_ledger/migration.sql diff --git a/backend/prisma/migrations/20260726090000_add_battle_ledger/migration.sql b/backend/prisma/migrations/20260726090000_add_battle_ledger/migration.sql new file mode 100644 index 00000000..0c671437 --- /dev/null +++ b/backend/prisma/migrations/20260726090000_add_battle_ledger/migration.sql @@ -0,0 +1,292 @@ +-- CreateEnum +CREATE TYPE "battle_state" AS ENUM ('accepted', 'committed', 'seeded', 'computed', 'verified', 'signed', 'published', 'batched', 'rejected', 'expired', 'verification_failed', 'signing_failed', 'forfeited'); + +-- CreateTable +CREATE TABLE "battle_intent" ( + "intent_hash" TEXT NOT NULL, + "chain_id" TEXT NOT NULL, + "deployment_id" TEXT NOT NULL, + "attacker_owner" TEXT NOT NULL, + "attacker_pet_id" TEXT NOT NULL, + "defender_owner" TEXT NOT NULL, + "defender_pet_id" TEXT NOT NULL, + "challenge_id" TEXT, + "client_nonce" TEXT NOT NULL, + "ruleset_hash" TEXT NOT NULL, + "expires_at" BIGINT NOT NULL, + "signature" TEXT NOT NULL, + "signature_format" TEXT NOT NULL, + "submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "consumed_at" TIMESTAMP(3), + + CONSTRAINT "battle_intent_pkey" PRIMARY KEY ("intent_hash") +); + +-- CreateTable +CREATE TABLE "defense_authorization" ( + "authorization_hash" TEXT NOT NULL, + "chain_id" TEXT NOT NULL, + "deployment_id" TEXT NOT NULL, + "defender_owner" TEXT NOT NULL, + "all_pets" BOOLEAN NOT NULL, + "pet_ids" JSONB NOT NULL, + "ruleset_hash" TEXT NOT NULL, + "min_level" INTEGER NOT NULL, + "max_level" INTEGER NOT NULL, + "max_battles_per_day" INTEGER 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 "defense_authorization_pkey" PRIMARY KEY ("authorization_hash") +); + +-- CreateTable +CREATE TABLE "battle_ledger" ( + "battle_id" TEXT NOT NULL, + "chain_id" TEXT NOT NULL, + "deployment_id" TEXT NOT NULL, + "state" "battle_state" NOT NULL, + "failure_reason" TEXT, + "intent_hash" TEXT NOT NULL, + "authorization_hash" TEXT NOT NULL, + "attacker_pet_id" TEXT NOT NULL, + "attacker_owner" TEXT NOT NULL, + "defender_pet_id" TEXT NOT NULL, + "defender_owner" TEXT NOT NULL, + "snapshot" JSONB NOT NULL, + "snapshot_hash" TEXT NOT NULL, + "ruleset_hash" TEXT NOT NULL, + "ruleset_version" INTEGER NOT NULL, + "drand_chain_hash" TEXT NOT NULL, + "drand_round" BIGINT NOT NULL, + "accepted_at" BIGINT NOT NULL, + "beacon_signature" TEXT, + "beacon_randomness" TEXT, + "seed" TEXT, + "attacker_won" BOOLEAN, + "rounds" INTEGER, + "winner_hp_remaining" INTEGER, + "combat_log" JSONB, + "combat_log_hash" TEXT, + "progression" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "battle_ledger_pkey" PRIMARY KEY ("battle_id") +); + +-- CreateTable +CREATE TABLE "pet_battle_lock" ( + "chain_id" TEXT NOT NULL, + "pet_id" TEXT NOT NULL, + "battle_id" TEXT NOT NULL, + "locked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "pet_battle_lock_pkey" PRIMARY KEY ("chain_id","pet_id") +); + +-- CreateTable +CREATE TABLE "battle_commitment" ( + "commitment_hash" TEXT NOT NULL, + "battle_id" TEXT NOT NULL, + "sequence" BIGINT NOT NULL, + "previous_commitment_hash" TEXT, + "signing_key_id" TEXT NOT NULL, + "signature" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "accepted_at" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "delivered_at" TIMESTAMP(3), + + CONSTRAINT "battle_commitment_pkey" PRIMARY KEY ("commitment_hash") +); + +-- CreateTable +CREATE TABLE "battle_receipt" ( + "receipt_hash" TEXT NOT NULL, + "battle_id" TEXT NOT NULL, + "chain_id" TEXT NOT NULL, + "deployment_id" TEXT NOT NULL, + "attacker_pet_id" TEXT NOT NULL, + "defender_pet_id" TEXT NOT NULL, + "signing_key_id" TEXT NOT NULL, + "sequence" BIGINT NOT NULL, + "previous_receipt_hash" TEXT, + "attacker_previous_receipt_hash" TEXT, + "defender_previous_receipt_hash" TEXT, + "payload" JSONB NOT NULL, + "signature" TEXT NOT NULL, + "created_at" BIGINT NOT NULL, + "stored_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "batch_id" TEXT, + + CONSTRAINT "battle_receipt_pkey" PRIMARY KEY ("receipt_hash") +); + +-- CreateTable +CREATE TABLE "battle_batch" ( + "id" TEXT NOT NULL, + "chain_id" TEXT NOT NULL, + "deployment_id" TEXT NOT NULL, + "batch_number" BIGINT NOT NULL, + "previous_root" TEXT, + "merkle_root" TEXT NOT NULL, + "ruleset_set_hash" TEXT NOT NULL, + "first_sequence" BIGINT NOT NULL, + "last_sequence" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "anchored_tx_hash" TEXT, + "anchored_at" TIMESTAMP(3), + + CONSTRAINT "battle_batch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "battle_ruleset" ( + "ruleset_hash" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "engine_id" TEXT NOT NULL, + "engine_version" INTEGER NOT NULL, + "bundle" JSONB NOT NULL, + "published_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "retired_at" TIMESTAMP(3), + + CONSTRAINT "battle_ruleset_pkey" PRIMARY KEY ("ruleset_hash") +); + +-- CreateTable +CREATE TABLE "pet_battle_progress" ( + "chain_id" TEXT NOT NULL, + "deployment_id" TEXT NOT NULL, + "pet_id" TEXT NOT NULL, + "level" INTEGER NOT NULL DEFAULT 1, + "xp" INTEGER NOT NULL DEFAULT 0, + "last_opponent_id" TEXT NOT NULL DEFAULT '0', + "streak" INTEGER NOT NULL DEFAULT 0, + "win_count" INTEGER NOT NULL DEFAULT 0, + "loss_count" INTEGER NOT NULL DEFAULT 0, + "ready_at" BIGINT NOT NULL DEFAULT 0, + "last_receipt_hash" TEXT, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "pet_battle_progress_pkey" PRIMARY KEY ("chain_id","deployment_id","pet_id") +); + +-- CreateTable +CREATE TABLE "battle_outbox" ( + "id" TEXT NOT NULL, + "battle_id" TEXT NOT NULL, + "topic" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "available_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "attempts" INTEGER NOT NULL DEFAULT 0, + "locked_at" TIMESTAMP(3), + "locked_by" TEXT, + "processed_at" TIMESTAMP(3), + "dead_lettered_at" TIMESTAMP(3), + "last_error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "battle_outbox_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "battle_intent_chain_id_deployment_id_attacker_owner_idx" ON "battle_intent"("chain_id", "deployment_id", "attacker_owner"); + +-- CreateIndex +CREATE UNIQUE INDEX "battle_intent_chain_id_deployment_id_attacker_owner_client__key" ON "battle_intent"("chain_id", "deployment_id", "attacker_owner", "client_nonce"); + +-- CreateIndex +CREATE INDEX "defense_authorization_chain_id_deployment_id_defender_owner_idx" ON "defense_authorization"("chain_id", "deployment_id", "defender_owner"); + +-- CreateIndex +CREATE INDEX "defense_authorization_chain_id_deployment_id_ruleset_hash_idx" ON "defense_authorization"("chain_id", "deployment_id", "ruleset_hash"); + +-- CreateIndex +CREATE UNIQUE INDEX "battle_ledger_intent_hash_key" ON "battle_ledger"("intent_hash"); + +-- CreateIndex +CREATE INDEX "battle_ledger_chain_id_deployment_id_state_idx" ON "battle_ledger"("chain_id", "deployment_id", "state"); + +-- CreateIndex +CREATE INDEX "battle_ledger_chain_id_attacker_pet_id_idx" ON "battle_ledger"("chain_id", "attacker_pet_id"); + +-- CreateIndex +CREATE INDEX "battle_ledger_chain_id_defender_pet_id_idx" ON "battle_ledger"("chain_id", "defender_pet_id"); + +-- CreateIndex +CREATE INDEX "battle_ledger_drand_chain_hash_drand_round_idx" ON "battle_ledger"("drand_chain_hash", "drand_round"); + +-- CreateIndex +CREATE INDEX "pet_battle_lock_battle_id_idx" ON "pet_battle_lock"("battle_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "battle_commitment_battle_id_key" ON "battle_commitment"("battle_id"); + +-- CreateIndex +CREATE INDEX "battle_commitment_signing_key_id_created_at_idx" ON "battle_commitment"("signing_key_id", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "battle_commitment_signing_key_id_sequence_key" ON "battle_commitment"("signing_key_id", "sequence"); + +-- CreateIndex +CREATE UNIQUE INDEX "battle_receipt_battle_id_key" ON "battle_receipt"("battle_id"); + +-- CreateIndex +CREATE INDEX "battle_receipt_chain_id_attacker_pet_id_idx" ON "battle_receipt"("chain_id", "attacker_pet_id"); + +-- CreateIndex +CREATE INDEX "battle_receipt_chain_id_defender_pet_id_idx" ON "battle_receipt"("chain_id", "defender_pet_id"); + +-- CreateIndex +CREATE INDEX "battle_receipt_batch_id_idx" ON "battle_receipt"("batch_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "battle_receipt_signing_key_id_sequence_key" ON "battle_receipt"("signing_key_id", "sequence"); + +-- CreateIndex +CREATE INDEX "battle_batch_chain_id_deployment_id_anchored_at_idx" ON "battle_batch"("chain_id", "deployment_id", "anchored_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "battle_batch_chain_id_deployment_id_batch_number_key" ON "battle_batch"("chain_id", "deployment_id", "batch_number"); + +-- CreateIndex +CREATE UNIQUE INDEX "battle_ruleset_version_key" ON "battle_ruleset"("version"); + +-- CreateIndex +CREATE INDEX "pet_battle_progress_chain_id_deployment_id_ready_at_idx" ON "pet_battle_progress"("chain_id", "deployment_id", "ready_at"); + +-- CreateIndex +CREATE INDEX "battle_outbox_processed_at_available_at_idx" ON "battle_outbox"("processed_at", "available_at"); + +-- CreateIndex +CREATE INDEX "battle_outbox_battle_id_idx" ON "battle_outbox"("battle_id"); + +-- CreateIndex +CREATE INDEX "battle_outbox_topic_processed_at_idx" ON "battle_outbox"("topic", "processed_at"); + +-- AddForeignKey +ALTER TABLE "battle_ledger" ADD CONSTRAINT "battle_ledger_intent_hash_fkey" FOREIGN KEY ("intent_hash") REFERENCES "battle_intent"("intent_hash") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "battle_ledger" ADD CONSTRAINT "battle_ledger_authorization_hash_fkey" FOREIGN KEY ("authorization_hash") REFERENCES "defense_authorization"("authorization_hash") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "pet_battle_lock" ADD CONSTRAINT "pet_battle_lock_battle_id_fkey" FOREIGN KEY ("battle_id") REFERENCES "battle_ledger"("battle_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "battle_commitment" ADD CONSTRAINT "battle_commitment_battle_id_fkey" FOREIGN KEY ("battle_id") REFERENCES "battle_ledger"("battle_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "battle_receipt" ADD CONSTRAINT "battle_receipt_battle_id_fkey" FOREIGN KEY ("battle_id") REFERENCES "battle_ledger"("battle_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "battle_receipt" ADD CONSTRAINT "battle_receipt_batch_id_fkey" FOREIGN KEY ("batch_id") REFERENCES "battle_batch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "battle_outbox" ADD CONSTRAINT "battle_outbox_battle_id_fkey" FOREIGN KEY ("battle_id") REFERENCES "battle_ledger"("battle_id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 924d635c..73c7de5e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -144,3 +144,342 @@ model BattleConversation { @@index([chain, attacker, defender]) @@map("battle_conversation") } + +// ─── Backend-authoritative battles ──────────────────────────────────────────── +// Models for the design in docs/plan-backend-battle-architecture.md. The existing +// pet_roster / battle_history tables stay exactly as they are: they are projections +// of on-chain events, and the legacy on-chain battle path keeps writing them. +// Everything below is the durable workflow for battles the backend resolves itself. +// +// Conventions follow the tables above: pet ids and DNA are strings (an EVM uint256 +// does not fit a 64-bit BigInt), protocol timestamps are BigInt unix seconds, and row +// bookkeeping uses DateTime. Canonical hashes are 0x-prefixed lowercase hex. + +/// The §J state machine. `rejected` exists only before `committed`: once a battle is +/// committed to a drand round it resolves, because free abandonment after seeing a +/// seed is outcome grinding (threat T5). +enum BattleState { + accepted + committed + seeded + computed + verified + signed + published + batched + rejected + expired + verification_failed + signing_failed + forfeited + + @@map("battle_state") +} + +/// A wallet-signed request to fight (§D). Permission, not a result. The signature is +/// kept so a third party can be shown that the attacker really asked for this battle, +/// rather than taking our word that they did. +model BattleIntent { + /// `hashBattleIntent` from @cryptopets/protocol. + intentHash String @id @map("intent_hash") + chainId String @map("chain_id") // protocol chain id, e.g. 'eip155:84532' + deploymentId String @map("deployment_id") + attackerOwner String @map("attacker_owner") + attackerPetId String @map("attacker_pet_id") + defenderOwner String @map("defender_owner") + defenderPetId String @map("defender_pet_id") + challengeId String? @map("challenge_id") + /// Wallet-chosen idempotency nonce, consumed once (see the unique index below). + clientNonce String @map("client_nonce") + rulesetHash String @map("ruleset_hash") + expiresAt BigInt @map("expires_at") // unix seconds + /// Wallet signature over the EIP-712 typed data (EVM) or the signed message (Solana). + signature String + /// 'eip712' | 'solana-message' — which payload the signature covers. + signatureFormat String @map("signature_format") + submittedAt DateTime @default(now()) @map("submitted_at") + /// Set when this intent produced a ledger row. A second submission with the same + /// nonce is rejected by the unique index, never merged by an upsert. + consumedAt DateTime? @map("consumed_at") + + ledger BattleLedger? + + /// The replay guard: one nonce per wallet per deployment, enforced by the database + /// rather than by a check-then-insert race (§J, threat T7). + @@unique([chainId, deploymentId, attackerOwner, clientNonce], name: "battle_intent_nonce") + @@index([chainId, deploymentId, attackerOwner]) + @@map("battle_intent") +} + +/// A defender's standing, revocable permission to be challenged while offline (§D). +model DefenseAuthorization { + /// `hashDefenseAuthorization` from @cryptopets/protocol. + authorizationHash String @id @map("authorization_hash") + chainId String @map("chain_id") + deploymentId String @map("deployment_id") + defenderOwner String @map("defender_owner") + /// True = every pet the owner holds, now or later. False = `petIds` is the scope. + allPets Boolean @map("all_pets") + /// Ascending pet-id strings. Empty when `allPets`. + petIds Json @map("pet_ids") + /// Consent is per ruleset version: a rules change invalidates outstanding grants. + rulesetHash String @map("ruleset_hash") + minLevel Int @map("min_level") + maxLevel Int @map("max_level") + maxBattlesPerDay Int @map("max_battles_per_day") + 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, because receipts reference + /// this hash and a verifier must still be able to see what was consented to. + revokedAt DateTime? @map("revoked_at") + + ledger BattleLedger[] + + @@index([chainId, deploymentId, defenderOwner]) + @@index([chainId, deploymentId, rulesetHash]) + @@map("defense_authorization") +} + +/// The durable workflow row for one backend-resolved battle: the state machine, the +/// frozen snapshot, and the committed randomness round. +model BattleLedger { + /// Ledger id, also the `battleId` bound into the commitment, the seed, and the receipt. + battleId String @id @default(cuid()) @map("battle_id") + chainId String @map("chain_id") + deploymentId String @map("deployment_id") + state BattleState + /// Set on a failure transition, so `verification_failed` says what mismatched. + failureReason String? @map("failure_reason") + + intentHash String @unique @map("intent_hash") + intent BattleIntent @relation(fields: [intentHash], references: [intentHash]) + authorizationHash String @map("authorization_hash") + authorization DefenseAuthorization @relation(fields: [authorizationHash], references: [authorizationHash]) + + attackerPetId String @map("attacker_pet_id") + attackerOwner String @map("attacker_owner") + defenderPetId String @map("defender_pet_id") + defenderOwner String @map("defender_owner") + + /// The frozen photo, persisted before any randomness for this battle exists (§J). + snapshot Json + snapshotHash String @map("snapshot_hash") + rulesetHash String @map("ruleset_hash") + rulesetVersion Int @map("ruleset_version") + + /// The committed future round. Never substituted: on a fetch failure the same round + /// is retried, and a permanent beacon outage forfeits (§E). + drandChainHash String @map("drand_chain_hash") + drandRound BigInt @map("drand_round") + acceptedAt BigInt @map("accepted_at") // unix seconds + + /// Filled as the battle advances. Null until the state that produces them. + beaconSignature String? @map("beacon_signature") + beaconRandomness String? @map("beacon_randomness") + seed String? + attackerWon Boolean? @map("attacker_won") + rounds Int? + winnerHpRemaining Int? @map("winner_hp_remaining") + /// The full per-strike log, served separately from the receipt and bound by + /// `combatLogHash` inside it. + combatLog Json? @map("combat_log") + combatLogHash String? @map("combat_log_hash") + progression Json? + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + commitment BattleCommitment? + receipt BattleReceipt? + locks PetBattleLock[] + outbox BattleOutbox[] + + @@index([chainId, deploymentId, state]) + @@index([chainId, attackerPetId]) + @@index([chainId, defenderPetId]) + @@index([drandChainHash, drandRound]) + @@map("battle_ledger") +} + +/// One row per pet with a battle in flight, so "one open battle per pet" is a database +/// constraint rather than a convention. Deterministic lock ordering inside a +/// serializable transaction still applies; this makes the invariant survive a bug in +/// that ordering (threat T11). Rows are deleted when a battle reaches a terminal state. +model PetBattleLock { + chainId String @map("chain_id") + petId String @map("pet_id") + battleId String @map("battle_id") + battle BattleLedger @relation(fields: [battleId], references: [battleId], onDelete: Cascade) + lockedAt DateTime @default(now()) @map("locked_at") + + @@id([chainId, petId]) + @@index([battleId]) + @@map("pet_battle_lock") +} + +/// Our signed statement, made before the round published, of which round a battle uses +/// (§E). The payload is stored exactly as delivered to the players, because the +/// player's copy is the evidence and ours has to match it byte for byte. +model BattleCommitment { + /// `hashBattleCommitment` from @cryptopets/protocol. + commitmentHash String @id @map("commitment_hash") + battleId String @unique @map("battle_id") + battle BattleLedger @relation(fields: [battleId], references: [battleId]) + /// Position in this signing key's commitment chain. + sequence BigInt + previousCommitmentHash String? @map("previous_commitment_hash") + signingKeyId String @map("signing_key_id") + signature String + /// The canonical object, as signed and as returned in the accept response. + payload Json + acceptedAt BigInt @map("accepted_at") // unix seconds + createdAt DateTime @default(now()) @map("created_at") + /// When the accept response carrying this commitment reached the client. Null here + /// alongside a successful acceptance is the alert condition in §J: the player holds + /// no evidence, so commit-before-reveal proves nothing for that battle (threat T15). + deliveredAt DateTime? @map("delivered_at") + + @@unique([signingKeyId, sequence], name: "battle_commitment_chain_position") + @@index([signingKeyId, createdAt]) + @@map("battle_commitment") +} + +/// The signed permanent record of one battle (§G), with its three chain links. +model BattleReceipt { + /// `hashBattleReceipt` from @cryptopets/protocol. + receiptHash String @id @map("receipt_hash") + battleId String @unique @map("battle_id") + battle BattleLedger @relation(fields: [battleId], references: [battleId]) + + chainId String @map("chain_id") + deploymentId String @map("deployment_id") + /// Denormalized from the payload so the per-pet corpus and the per-pet chain walk are + /// indexed lookups rather than JSON scans. + attackerPetId String @map("attacker_pet_id") + defenderPetId String @map("defender_pet_id") + + signingKeyId String @map("signing_key_id") + sequence BigInt + previousReceiptHash String? @map("previous_receipt_hash") + attackerPreviousReceiptHash String? @map("attacker_previous_receipt_hash") + defenderPreviousReceiptHash String? @map("defender_previous_receipt_hash") + + /// The canonical object, as signed. + payload Json + signature String + createdAt BigInt @map("created_at") // unix seconds, from the receipt itself + storedAt DateTime @default(now()) @map("stored_at") + + /// Set when a batch anchoring this receipt is created. An unbatched receipt past the + /// inclusion SLO is operator failure, not a claim (§I). + batchId String? @map("batch_id") + batch BattleBatch? @relation(fields: [batchId], references: [id]) + + @@unique([signingKeyId, sequence], name: "battle_receipt_chain_position") + @@index([chainId, attackerPetId]) + @@index([chainId, defenderPetId]) + @@index([batchId]) + @@map("battle_receipt") +} + +/// A Merkle batch over receipts, and its on-chain anchor (§I). +model BattleBatch { + id String @id @default(cuid()) + chainId String @map("chain_id") + deploymentId String @map("deployment_id") + batchNumber BigInt @map("batch_number") + previousRoot String? @map("previous_root") + merkleRoot String @map("merkle_root") + /// Hash over the set of ruleset hashes the batched receipts used, so a batch names + /// the rules its contents were fought under. + rulesetSetHash String @map("ruleset_set_hash") + firstSequence BigInt @map("first_sequence") + lastSequence BigInt @map("last_sequence") + createdAt DateTime @default(now()) @map("created_at") + /// Null until the root is accepted on chain. + anchoredTxHash String? @map("anchored_tx_hash") + anchoredAt DateTime? @map("anchored_at") + + receipts BattleReceipt[] + + @@unique([chainId, deploymentId, batchNumber], name: "battle_batch_number") + @@index([chainId, deploymentId, anchoredAt]) + @@map("battle_batch") +} + +/// A published, content-addressed ruleset bundle (§H). Historical receipts name a hash +/// here, so rows are never deleted: without the bundle, a receipt names rules nobody +/// can replay. +model BattleRuleset { + rulesetHash String @id @map("ruleset_hash") + version Int @unique + engineId String @map("engine_id") + engineVersion Int @map("engine_version") + /// The full bundle exactly as published. + bundle Json + publishedAt DateTime @default(now()) @map("published_at") + /// Set when a newer version supersedes this one. Retired rulesets still verify. + retiredAt DateTime? @map("retired_at") + + @@map("battle_ruleset") +} + +/// Off-chain progression, kept distinct from NFT state (§C). +/// Deliberately not `pet_roster`: that table mirrors chain state, and mixing backend XP +/// into it would make it impossible to say which numbers the chain guarantees. Off-chain +/// XP is never represented as NFT state unless an aggregate claim applied it on chain. +model PetBattleProgress { + chainId String @map("chain_id") + deploymentId String @map("deployment_id") + petId String @map("pet_id") + + level Int @default(1) + xp Int @default(0) + /// Same-opponent decay state, frozen into each battle snapshot so progression stays + /// recomputable from a receipt alone. + lastOpponentId String @default("0") @map("last_opponent_id") + streak Int @default(0) + winCount Int @default(0) @map("win_count") + lossCount Int @default(0) @map("loss_count") + /// Backend-mode cooldown, separate from the on-chain `pet_roster.ready_at`. + readyAt BigInt @default(0) @map("ready_at") // unix seconds + /// Head of this pet's receipt chain: what the next receipt links back to. + lastReceiptHash String? @map("last_receipt_hash") + updatedAt DateTime @updatedAt @map("updated_at") + + @@id([chainId, deploymentId, petId]) + @@index([chainId, deploymentId, readyAt]) + @@map("pet_battle_progress") +} + +/// Transactional outbox. Every state transition and its message commit together, so a +/// crash cannot advance a battle without scheduling the work that follows (§J). +model BattleOutbox { + id String @id @default(cuid()) + battleId String @map("battle_id") + battle BattleLedger @relation(fields: [battleId], references: [battleId], onDelete: Cascade) + /// What to do next, e.g. 'await-beacon' | 'compute' | 'verify' | 'sign'. + topic String + payload Json + /// Earliest time a worker may claim this message. Used to wait for a beacon round + /// rather than spinning on it. + availableAt DateTime @default(now()) @map("available_at") + attempts Int @default(0) + lockedAt DateTime? @map("locked_at") + lockedBy String? @map("locked_by") + processedAt DateTime? @map("processed_at") + /// Set when retries are exhausted. A dead-lettered message is an incident, not a + /// dropped job. + deadLetteredAt DateTime? @map("dead_lettered_at") + lastError String? @map("last_error") + createdAt DateTime @default(now()) @map("created_at") + + @@index([processedAt, availableAt]) + @@index([battleId]) + @@index([topic, processedAt]) + @@map("battle_outbox") +} From fedd3d2e43a8737a1484ece241dccc6dcee3f5e0 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 09:43:17 -0400 Subject: [PATCH 19/76] feat(backend): add transactional battle ledger state machine and outbox --- backend/src/features/battle-ledger/index.ts | 35 ++++ backend/src/features/battle-ledger/outbox.ts | 185 +++++++++++++++++ backend/src/features/battle-ledger/state.ts | 115 +++++++++++ .../src/features/battle-ledger/transitions.ts | 162 +++++++++++++++ .../features/battle-ledger/outbox.test.ts | 168 +++++++++++++++ .../features/battle-ledger/state.test.ts | 147 +++++++++++++ .../battle-ledger/transitions.test.ts | 195 ++++++++++++++++++ 7 files changed, 1007 insertions(+) create mode 100644 backend/src/features/battle-ledger/index.ts create mode 100644 backend/src/features/battle-ledger/outbox.ts create mode 100644 backend/src/features/battle-ledger/state.ts create mode 100644 backend/src/features/battle-ledger/transitions.ts create mode 100644 backend/tests/features/battle-ledger/outbox.test.ts create mode 100644 backend/tests/features/battle-ledger/state.test.ts create mode 100644 backend/tests/features/battle-ledger/transitions.test.ts diff --git a/backend/src/features/battle-ledger/index.ts b/backend/src/features/battle-ledger/index.ts new file mode 100644 index 00000000..ce46e2dc --- /dev/null +++ b/backend/src/features/battle-ledger/index.ts @@ -0,0 +1,35 @@ +export { + type ClaimedMessage, + claimOutbox, + completeOutbox, + enqueueOutbox, + failOutbox, + listDeadLetters, + MAX_OUTBOX_ATTEMPTS, + OUTBOX_TOPICS, + type OutboxMessage, + type OutboxTopic, + retryDelaySeconds, +} from './outbox'; +export { + ALLOWED_TRANSITIONS, + BATTLE_HAPPY_PATH, + classifyTransition, + IllegalTransitionError, + isCommitted, + isTerminal, + shouldReleaseLocks, + TERMINAL_STATES, + type TransitionKind, +} from './state'; +export { + applyTransition, + type BattleLedgerPatch, + failBattle, + getBattleState, + openBattle, + type OpenBattleRequest, + sortPetIds, + type TransitionRequest, + type TransitionResult, +} from './transitions'; diff --git a/backend/src/features/battle-ledger/outbox.ts b/backend/src/features/battle-ledger/outbox.ts new file mode 100644 index 00000000..68b93698 --- /dev/null +++ b/backend/src/features/battle-ledger/outbox.ts @@ -0,0 +1,185 @@ +import type { Prisma } from '@generated/prisma/client'; + +import { prisma } from '@config/prisma'; + +/** + * Transactional outbox for the battle workflow (§J). + * + * A state transition and the message scheduling whatever comes next are written in one + * transaction, so a crash between them is impossible. Without that, a battle can end up + * `committed` with nobody waiting for its beacon: not lost exactly, but stalled until + * someone notices, which for a player is the same thing. + * + * Delivery is at least once, never exactly once. Handlers therefore have to be idempotent, + * which is what `classifyTransition`'s `noop` case is for. + */ + +/** What the message tells a worker to do next. */ +export const OUTBOX_TOPICS = { + /** Wait for the committed drand round, then verify it and derive the seed. */ + awaitBeacon: 'await-beacon', + /** Run the fight through the canonical engine. */ + compute: 'compute', + /** Ask the independent Go verifier to recompute the result. */ + verify: 'verify', + /** Sign the receipt with the KMS key. */ + sign: 'sign', + /** Publish the receipt to the public corpus. */ + publish: 'publish', + /** Include the receipt in the next Merkle batch. */ + batch: 'batch', +} as const; + +export type OutboxTopic = (typeof OUTBOX_TOPICS)[keyof typeof OUTBOX_TOPICS]; + +/** Retries before a message is dead-lettered. */ +export const MAX_OUTBOX_ATTEMPTS = 8; +/** First retry delay; doubles each attempt up to the cap. */ +const BASE_RETRY_SECONDS = 2; +const MAX_RETRY_SECONDS = 300; + +/** A message ready to be enqueued. */ +export interface OutboxMessage { + battleId: string; + topic: OutboxTopic; + payload?: Prisma.InputJsonValue; + /** Earliest time a worker may claim it. Used to wait for a round rather than spin. */ + availableAt?: Date; +} + +/** A claimed message, handed to a worker. */ +export interface ClaimedMessage { + id: string; + battleId: string; + topic: string; + payload: Prisma.JsonValue; + attempts: number; +} + +/** Minimal surface both `prisma` and a `$transaction` client satisfy. */ +export type OutboxClient = Pick; + +/** + * Enqueues messages. Takes a client so it can join the caller's transaction: enqueueing + * outside the transition's transaction is the bug this parameter exists to prevent. + */ +export async function enqueueOutbox(client: OutboxClient, messages: readonly OutboxMessage[]): Promise { + if (messages.length === 0) { + return; + } + await client.battleOutbox.createMany({ + data: messages.map((message) => ({ + battleId: message.battleId, + topic: message.topic, + payload: message.payload ?? {}, + ...(message.availableAt ? { availableAt: message.availableAt } : {}), + })), + }); +} + +/** + * Claims up to `limit` due messages for `workerId`. + * + * Claiming is a two-step read-then-update rather than a single `SELECT ... FOR UPDATE + * SKIP LOCKED`, because Prisma cannot express the latter without raw SQL. The update is + * guarded on `lockedAt: null`, so two workers racing for one message means one of them + * updates zero rows and simply does not get it. That is safe precisely because handlers + * are idempotent; if they were not, this would need the raw query. + */ +export async function claimOutbox( + topics: readonly OutboxTopic[], + workerId: string, + limit: number, + now: Date, +): Promise { + const candidates = await prisma.battleOutbox.findMany({ + where: { + processedAt: null, + deadLetteredAt: null, + lockedAt: null, + availableAt: { lte: now }, + topic: { in: [...topics] }, + }, + orderBy: { availableAt: 'asc' }, + take: limit, + }); + + const claimed: ClaimedMessage[] = []; + for (const candidate of candidates) { + const { count } = await prisma.battleOutbox.updateMany({ + where: { id: candidate.id, lockedAt: null, processedAt: null }, + data: { lockedAt: now, lockedBy: workerId, attempts: { increment: 1 } }, + }); + if (count === 1) { + claimed.push({ + id: candidate.id, + battleId: candidate.battleId, + topic: candidate.topic, + payload: candidate.payload, + attempts: candidate.attempts + 1, + }); + } + } + return claimed; +} + +/** Marks a message done. */ +export async function completeOutbox(id: string, now: Date): Promise { + await prisma.battleOutbox.update({ + where: { id }, + data: { processedAt: now, lockedAt: null, lockedBy: null, lastError: null }, + }); +} + +/** + * Records a failure and either schedules a retry or dead-letters the message. + * + * Dead-lettering is an incident, not a dropped job: the battle is stuck in a non-terminal + * state and something has to look at it. The backoff is exponential and capped, which + * matters most for `await-beacon` during a drand outage, where the right behaviour is to + * keep retrying the *same* round rather than give up on it (§E). + */ +export async function failOutbox( + message: Pick, + error: string, + now: Date, +): Promise<{ deadLettered: boolean; retryAt: Date | null }> { + if (message.attempts >= MAX_OUTBOX_ATTEMPTS) { + await prisma.battleOutbox.update({ + where: { id: message.id }, + data: { deadLetteredAt: now, lockedAt: null, lockedBy: null, lastError: error }, + }); + return { deadLettered: true, retryAt: null }; + } + const retryAt = new Date(now.getTime() + retryDelaySeconds(message.attempts) * 1000); + await prisma.battleOutbox.update({ + where: { id: message.id }, + data: { lockedAt: null, lockedBy: null, lastError: error, availableAt: retryAt }, + }); + return { deadLettered: false, retryAt }; +} + +/** Exponential backoff in seconds for the nth attempt (1-based), capped. */ +export function retryDelaySeconds(attempts: number): number { + const delay = BASE_RETRY_SECONDS * 2 ** Math.max(0, attempts - 1); + return Math.min(delay, MAX_RETRY_SECONDS); +} + +/** + * Messages that failed permanently. Surfaced for the §J alert rather than for a retry + * loop: a dead letter means a human decides what happens to that battle. + */ +export async function listDeadLetters(limit = 100): Promise { + const rows = await prisma.battleOutbox.findMany({ + where: { deadLetteredAt: { not: null } }, + orderBy: { deadLetteredAt: 'desc' }, + take: limit, + }); + return rows.map((row) => ({ + id: row.id, + battleId: row.battleId, + topic: row.topic, + payload: row.payload, + attempts: row.attempts, + })); +} diff --git a/backend/src/features/battle-ledger/state.ts b/backend/src/features/battle-ledger/state.ts new file mode 100644 index 00000000..fd9f18cd --- /dev/null +++ b/backend/src/features/battle-ledger/state.ts @@ -0,0 +1,115 @@ +import { BattleState } from '@generated/prisma/enums'; + +/** + * The battle lifecycle from §J of docs/plan-backend-battle-architecture.md, as code. + * + * Two properties are enforced here rather than left to the caller: + * + * - **`rejected` exists only before `committed`.** Once a battle is bound to a drand + * round it resolves. Free abandonment after a seed exists would let a player submit + * many battles and keep the ones that seeded well, which is outcome grinding (threat + * T5), so there is no edge from `committed` back to `rejected`. + * - **Every transition is idempotent.** Jobs are processed at least once, so a worker + * re-applying a transition that already landed must be a no-op rather than an error. + * `classifyTransition` says which of the three cases a request is, so a retry and a + * genuine illegal move never look alike. + */ + +/** Happy path, in order. */ +export const BATTLE_HAPPY_PATH: readonly BattleState[] = [ + BattleState.accepted, + BattleState.committed, + BattleState.seeded, + BattleState.computed, + BattleState.verified, + BattleState.signed, + BattleState.published, + BattleState.batched, +]; + +/** States a battle never leaves. */ +export const TERMINAL_STATES: readonly BattleState[] = [ + BattleState.batched, + BattleState.rejected, + BattleState.expired, + BattleState.verification_failed, + BattleState.signing_failed, + BattleState.forfeited, +]; + +/** + * Legal moves out of each state. + * + * `forfeited` is reachable from `committed` and `seeded` because a permanent beacon + * outage has to end the battle somehow, and ending it with no progression change plus a + * cooldown is the option that does not reward manufacturing an outage (§E). + * + * `verification_failed` is reachable only from `computed`: it means the TypeScript + * engine and the Go verifier disagreed, which stops signing for that ruleset rather + * than silently preferring one implementation (§F). + */ +export const ALLOWED_TRANSITIONS: Readonly> = { + [BattleState.accepted]: [BattleState.committed, BattleState.rejected, BattleState.expired], + [BattleState.committed]: [BattleState.seeded, BattleState.forfeited], + [BattleState.seeded]: [BattleState.computed, BattleState.forfeited], + [BattleState.computed]: [BattleState.verified, BattleState.verification_failed], + [BattleState.verified]: [BattleState.signed, BattleState.signing_failed], + [BattleState.signed]: [BattleState.published], + [BattleState.published]: [BattleState.batched], + [BattleState.batched]: [], + [BattleState.rejected]: [], + [BattleState.expired]: [], + [BattleState.verification_failed]: [], + [BattleState.signing_failed]: [], + [BattleState.forfeited]: [], +}; + +/** What a requested transition actually is. */ +export type TransitionKind = 'advance' | 'noop' | 'illegal'; + +/** + * Classifies a requested move. + * + * `noop` covers the retry case: the battle is already in the target state, so the work + * was done and re-running it changes nothing. Treating that as an error would turn + * at-least-once delivery into a stream of false alarms; treating it as an advance would + * let a transition's side effects run twice. + */ +export function classifyTransition(from: BattleState, to: BattleState): TransitionKind { + if (from === to) { + return 'noop'; + } + return ALLOWED_TRANSITIONS[from].includes(to) ? 'advance' : 'illegal'; +} + +/** Whether a battle has reached a state it never leaves. */ +export function isTerminal(state: BattleState): boolean { + return TERMINAL_STATES.includes(state); +} + +/** + * Whether a battle is past the point where it can still be rejected. + * + * The rule this expresses: after `committed`, a battle resolves. Callers wanting to + * abandon one should be checking this rather than reimplementing the reasoning. + */ +export function isCommitted(state: BattleState): boolean { + return state !== BattleState.accepted; +} + +/** Whether locks on both pets should be released once a battle reaches `state`. */ +export function shouldReleaseLocks(state: BattleState): boolean { + return isTerminal(state); +} + +/** Thrown for an illegal move, so callers can distinguish it from a retry. */ +export class IllegalTransitionError extends Error { + constructor( + readonly battleId: string, + readonly from: BattleState, + readonly to: BattleState, + ) { + super(`battle ${battleId} cannot move from ${from} to ${to}`); + this.name = 'IllegalTransitionError'; + } +} diff --git a/backend/src/features/battle-ledger/transitions.ts b/backend/src/features/battle-ledger/transitions.ts new file mode 100644 index 00000000..1f00b8fe --- /dev/null +++ b/backend/src/features/battle-ledger/transitions.ts @@ -0,0 +1,162 @@ +import type { BattleState } from '@generated/prisma/enums'; +import type { Prisma } from '@generated/prisma/client'; + +import { prisma } from '@config/prisma'; + +import { enqueueOutbox, type OutboxMessage } from './outbox'; +import { classifyTransition, IllegalTransitionError, shouldReleaseLocks } from './state'; + +/** + * Transactional state transitions for the battle ledger (§J). + * + * Every transition here does three things in one transaction: move the state, write the + * fields that move with it, and enqueue the outbox message for whatever comes next. If any + * of those can happen without the others, a battle can end up advanced with nothing + * scheduled, or scheduled twice. + * + * Concurrency is handled by guarding the update on the expected current state rather than + * by reading first and hoping. Two workers racing on the same transition means one updates + * zero rows, which is reported as a no-op, not an error: the work was done, just not by + * this caller. + */ + +/** Fields a transition may write alongside the state change. */ +export type BattleLedgerPatch = Omit; + +export interface TransitionRequest { + battleId: string; + /** The state the caller believes the battle is in. */ + from: BattleState; + to: BattleState; + /** Columns to write as part of the same transaction. */ + patch?: BattleLedgerPatch; + /** Messages to enqueue atomically with the transition. */ + outbox?: readonly OutboxMessage[]; +} + +export interface TransitionResult { + /** False when the battle was already in the target state, or another worker got there first. */ + applied: boolean; + state: BattleState; +} + +/** + * Applies one transition. + * + * Throws `IllegalTransitionError` for a move the state machine does not allow, which is a + * bug rather than a race. Returns `applied: false` for a retry or a lost race, which is + * normal under at-least-once delivery. + */ +export async function applyTransition(request: TransitionRequest): Promise { + const kind = classifyTransition(request.from, request.to); + if (kind === 'illegal') { + throw new IllegalTransitionError(request.battleId, request.from, request.to); + } + if (kind === 'noop') { + return { applied: false, state: request.to }; + } + + return prisma.$transaction( + async (tx) => { + const { count } = await tx.battleLedger.updateMany({ + // The guard is the concurrency control: only the caller that finds the + // battle in `from` gets to move it. + where: { battleId: request.battleId, state: request.from }, + data: { ...(request.patch ?? {}), state: request.to }, + }); + if (count === 0) { + const current = await tx.battleLedger.findUnique({ + where: { battleId: request.battleId }, + select: { state: true }, + }); + if (!current) { + throw new Error(`battle ${request.battleId} does not exist`); + } + // Someone else advanced it, or it was never in `from`. Either way this + // caller has nothing to do; the state it reports is the truth. + return { applied: false, state: current.state }; + } + + if (request.outbox && request.outbox.length > 0) { + await enqueueOutbox(tx, request.outbox); + } + if (shouldReleaseLocks(request.to)) { + // Terminal states free both pets. Doing it here rather than in a follow-up + // job means a pet cannot stay locked because a cleanup message was lost. + await tx.petBattleLock.deleteMany({ where: { battleId: request.battleId } }); + } + + return { applied: true, state: request.to }; + }, + { isolationLevel: 'Serializable' }, + ); +} + +/** What opening a battle needs, beyond the ledger row's own columns. */ +export interface OpenBattleRequest { + ledger: Prisma.BattleLedgerUncheckedCreateInput; + /** Pet ids to lock for the duration, as decimal strings. */ + petIds: readonly string[]; + outbox?: readonly OutboxMessage[]; +} + +/** + * Creates a ledger row, locks both pets, and enqueues the first message, atomically. + * + * Lock rows are inserted in ascending numeric pet-id order. Two battles involving the same + * pair, submitted at the same moment, therefore contend on the same row first, so one of + * them fails cleanly on the primary key instead of the two deadlocking against each other + * (threat T11). Numeric rather than lexicographic, because pet ids are decimal strings and + * `"10" < "9"` as text. + */ +export async function openBattle(request: OpenBattleRequest): Promise<{ battleId: string }> { + const petIds = sortPetIds(request.petIds); + + return prisma.$transaction( + async (tx) => { + const ledger = await tx.battleLedger.create({ data: request.ledger }); + for (const petId of petIds) { + // Sequential on purpose: the ordering is the deadlock avoidance, and + // issuing these in parallel would throw it away. + await tx.petBattleLock.create({ + data: { chainId: ledger.chainId, petId, battleId: ledger.battleId }, + }); + } + if (request.outbox && request.outbox.length > 0) { + await enqueueOutbox(tx, request.outbox); + } + return { battleId: ledger.battleId }; + }, + { isolationLevel: 'Serializable' }, + ); +} + +/** Ascending numeric order, which is the lock-acquisition order. */ +export function sortPetIds(petIds: readonly string[]): string[] { + return [...petIds].sort((a, b) => { + const left = BigInt(a); + const right = BigInt(b); + return left === right ? 0 : left < right ? -1 : 1; + }); +} + +/** Current state, or null if the battle does not exist. */ +export async function getBattleState(battleId: string): Promise { + const row = await prisma.battleLedger.findUnique({ where: { battleId }, select: { state: true } }); + return row?.state ?? null; +} + +/** + * Records a failure transition with its reason. + * + * The reason is not decoration: `verification_failed` without saying what mismatched leaves + * an operator diffing two engines by hand during an incident. + */ +export async function failBattle( + battleId: string, + from: BattleState, + to: BattleState, + reason: string, +): Promise { + return applyTransition({ battleId, from, to, patch: { failureReason: reason } }); +} diff --git a/backend/tests/features/battle-ledger/outbox.test.ts b/backend/tests/features/battle-ledger/outbox.test.ts new file mode 100644 index 00000000..fb2b2a5d --- /dev/null +++ b/backend/tests/features/battle-ledger/outbox.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@config/prisma', () => ({ + prisma: { + battleOutbox: { + findMany: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + createMany: vi.fn(), + }, + }, +})); + +import { prisma } from '@config/prisma'; + +import { + claimOutbox, + completeOutbox, + enqueueOutbox, + failOutbox, + listDeadLetters, + MAX_OUTBOX_ATTEMPTS, + OUTBOX_TOPICS, + retryDelaySeconds, +} from '@features/battle-ledger'; + +const NOW = new Date('2026-07-26T09:00:00.000Z'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('enqueueOutbox', () => { + it('writes through the client it is given, so it joins the caller transaction', async () => { + // Enqueueing outside the transition's transaction is the bug this parameter exists + // to prevent, so the client is never taken from module scope. + const client = { battleOutbox: { createMany: vi.fn() } }; + await enqueueOutbox(client as never, [{ battleId: 'btl_1', topic: OUTBOX_TOPICS.compute }]); + expect(client.battleOutbox.createMany).toHaveBeenCalledTimes(1); + expect(prisma.battleOutbox.createMany).not.toHaveBeenCalled(); + }); + + it('does nothing for an empty list', async () => { + const client = { battleOutbox: { createMany: vi.fn() } }; + await enqueueOutbox(client as never, []); + expect(client.battleOutbox.createMany).not.toHaveBeenCalled(); + }); + + it('defaults the payload and leaves availableAt to the database', async () => { + const client = { battleOutbox: { createMany: vi.fn() } }; + await enqueueOutbox(client as never, [{ battleId: 'btl_1', topic: OUTBOX_TOPICS.sign }]); + const data = client.battleOutbox.createMany.mock.calls[0]![0].data as Record[]; + expect(data[0]).toEqual({ battleId: 'btl_1', topic: 'sign', payload: {} }); + }); + + it('passes an explicit availableAt through, for waiting on a beacon round', async () => { + const client = { battleOutbox: { createMany: vi.fn() } }; + const availableAt = new Date(NOW.getTime() + 6000); + await enqueueOutbox(client as never, [ + { battleId: 'btl_1', topic: OUTBOX_TOPICS.awaitBeacon, availableAt }, + ]); + const data = client.battleOutbox.createMany.mock.calls[0]![0].data as Record[]; + expect(data[0]!.availableAt).toBe(availableAt); + }); +}); + +describe('claimOutbox', () => { + const candidate = { + id: 'msg_1', + battleId: 'btl_1', + topic: 'compute', + payload: {}, + attempts: 0, + }; + + it('claims a due message and increments its attempt count', async () => { + vi.mocked(prisma.battleOutbox.findMany).mockResolvedValue([candidate] as never); + vi.mocked(prisma.battleOutbox.updateMany).mockResolvedValue({ count: 1 } as never); + + const claimed = await claimOutbox([OUTBOX_TOPICS.compute], 'worker-a', 10, NOW); + + expect(claimed).toEqual([{ ...candidate, attempts: 1 }]); + expect(vi.mocked(prisma.battleOutbox.findMany).mock.calls[0]![0]).toMatchObject({ + where: { + processedAt: null, + deadLetteredAt: null, + lockedAt: null, + availableAt: { lte: NOW }, + topic: { in: ['compute'] }, + }, + }); + }); + + it('does not claim a message another worker already locked', async () => { + // The update is guarded on lockedAt: null, so the loser of the race updates zero + // rows and simply does not get the message. + vi.mocked(prisma.battleOutbox.findMany).mockResolvedValue([candidate] as never); + vi.mocked(prisma.battleOutbox.updateMany).mockResolvedValue({ count: 0 } as never); + + expect(await claimOutbox([OUTBOX_TOPICS.compute], 'worker-b', 10, NOW)).toEqual([]); + }); + + it('claims oldest-due first', async () => { + vi.mocked(prisma.battleOutbox.findMany).mockResolvedValue([] as never); + await claimOutbox([OUTBOX_TOPICS.compute], 'worker-a', 5, NOW); + expect(vi.mocked(prisma.battleOutbox.findMany).mock.calls[0]![0]).toMatchObject({ + orderBy: { availableAt: 'asc' }, + take: 5, + }); + }); +}); + +describe('failOutbox', () => { + it('schedules a backed-off retry while attempts remain', async () => { + const result = await failOutbox({ id: 'msg_1', attempts: 3 }, 'beacon fetch failed', NOW); + + expect(result.deadLettered).toBe(false); + expect(result.retryAt).toEqual(new Date(NOW.getTime() + retryDelaySeconds(3) * 1000)); + const data = vi.mocked(prisma.battleOutbox.update).mock.calls[0]![0].data as Record; + expect(data).toMatchObject({ lockedAt: null, lastError: 'beacon fetch failed' }); + expect(data.deadLetteredAt).toBeUndefined(); + }); + + it('dead-letters once attempts are exhausted', async () => { + const result = await failOutbox({ id: 'msg_1', attempts: MAX_OUTBOX_ATTEMPTS }, 'still failing', NOW); + + expect(result).toEqual({ deadLettered: true, retryAt: null }); + const data = vi.mocked(prisma.battleOutbox.update).mock.calls[0]![0].data as Record; + expect(data.deadLetteredAt).toBe(NOW); + }); + + it('backs off exponentially and then caps', async () => { + // Matters most during a drand outage, where the right behaviour is to keep retrying + // the same round rather than give up on it. + expect(retryDelaySeconds(1)).toBe(2); + expect(retryDelaySeconds(2)).toBe(4); + expect(retryDelaySeconds(3)).toBe(8); + expect(retryDelaySeconds(20)).toBe(300); + }); +}); + +describe('completeOutbox', () => { + it('marks the message processed and clears the lock', async () => { + await completeOutbox('msg_1', NOW); + expect(vi.mocked(prisma.battleOutbox.update).mock.calls[0]![0].data).toEqual({ + processedAt: NOW, + lockedAt: null, + lockedBy: null, + lastError: null, + }); + }); +}); + +describe('listDeadLetters', () => { + it('surfaces dead letters newest first, for the alert rather than a retry loop', async () => { + vi.mocked(prisma.battleOutbox.findMany).mockResolvedValue([ + { id: 'msg_9', battleId: 'btl_9', topic: 'sign', payload: {}, attempts: 8 }, + ] as never); + + const rows = await listDeadLetters(); + + expect(rows).toEqual([{ id: 'msg_9', battleId: 'btl_9', topic: 'sign', payload: {}, attempts: 8 }]); + expect(vi.mocked(prisma.battleOutbox.findMany).mock.calls[0]![0]).toMatchObject({ + where: { deadLetteredAt: { not: null } }, + orderBy: { deadLetteredAt: 'desc' }, + }); + }); +}); diff --git a/backend/tests/features/battle-ledger/state.test.ts b/backend/tests/features/battle-ledger/state.test.ts new file mode 100644 index 00000000..7c2984f4 --- /dev/null +++ b/backend/tests/features/battle-ledger/state.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; + +import { BattleState } from '@generated/prisma/enums'; + +import { + ALLOWED_TRANSITIONS, + BATTLE_HAPPY_PATH, + classifyTransition, + isCommitted, + isTerminal, + shouldReleaseLocks, + TERMINAL_STATES, +} from '@features/battle-ledger'; + +describe('happy path', () => { + it('is walkable end to end', () => { + for (let i = 0; i < BATTLE_HAPPY_PATH.length - 1; i++) { + expect(classifyTransition(BATTLE_HAPPY_PATH[i]!, BATTLE_HAPPY_PATH[i + 1]!)).toBe('advance'); + } + }); + + it('cannot be skipped', () => { + // Jumping straight to signed would mean signing a result nothing verified. + expect(classifyTransition(BattleState.committed, BattleState.signed)).toBe('illegal'); + expect(classifyTransition(BattleState.accepted, BattleState.computed)).toBe('illegal'); + }); + + it('cannot run backwards', () => { + expect(classifyTransition(BattleState.computed, BattleState.seeded)).toBe('illegal'); + expect(classifyTransition(BattleState.published, BattleState.signed)).toBe('illegal'); + }); +}); + +describe('no cancellation after commitment', () => { + it('allows rejection only before a round is committed', () => { + // The grinding defence from §E: a player who could abandon a seeded battle for + // free would keep only the ones that seeded well. + expect(classifyTransition(BattleState.accepted, BattleState.rejected)).toBe('advance'); + for (const state of [ + BattleState.committed, + BattleState.seeded, + BattleState.computed, + BattleState.verified, + BattleState.signed, + ]) { + expect(classifyTransition(state, BattleState.rejected)).toBe('illegal'); + } + }); + + it('reports whether a battle is past the point of no return', () => { + expect(isCommitted(BattleState.accepted)).toBe(false); + expect(isCommitted(BattleState.committed)).toBe(true); + expect(isCommitted(BattleState.seeded)).toBe(true); + }); + + it('offers forfeit instead, but only where a beacon can actually stall', () => { + // A permanent beacon outage has to end the battle somehow. Forfeit does it with no + // progression change, so manufacturing an outage gains nothing. + expect(classifyTransition(BattleState.committed, BattleState.forfeited)).toBe('advance'); + expect(classifyTransition(BattleState.seeded, BattleState.forfeited)).toBe('advance'); + expect(classifyTransition(BattleState.computed, BattleState.forfeited)).toBe('illegal'); + }); +}); + +describe('failure states', () => { + it('reaches verification_failed only from computed', () => { + expect(classifyTransition(BattleState.computed, BattleState.verification_failed)).toBe('advance'); + for (const state of [BattleState.seeded, BattleState.verified, BattleState.signed]) { + expect(classifyTransition(state, BattleState.verification_failed)).toBe('illegal'); + } + }); + + it('reaches signing_failed only from verified', () => { + expect(classifyTransition(BattleState.verified, BattleState.signing_failed)).toBe('advance'); + expect(classifyTransition(BattleState.computed, BattleState.signing_failed)).toBe('illegal'); + }); + + it('expires only before a commitment exists', () => { + expect(classifyTransition(BattleState.accepted, BattleState.expired)).toBe('advance'); + expect(classifyTransition(BattleState.committed, BattleState.expired)).toBe('illegal'); + }); +}); + +describe('idempotence', () => { + it('treats a repeat of the same state as a no-op', () => { + // At-least-once delivery means this is the normal case for a retry, not an error. + for (const state of Object.values(BattleState)) { + expect(classifyTransition(state, state)).toBe('noop'); + } + }); +}); + +describe('terminal states', () => { + it('have no outgoing transitions', () => { + for (const state of TERMINAL_STATES) { + expect(ALLOWED_TRANSITIONS[state]).toEqual([]); + expect(isTerminal(state)).toBe(true); + } + }); + + it('cover every state with no outgoing edge', () => { + // Keeps TERMINAL_STATES from drifting out of sync with the transition table. + const withoutEdges = Object.values(BattleState).filter((s) => ALLOWED_TRANSITIONS[s].length === 0); + expect([...withoutEdges].sort()).toEqual([...TERMINAL_STATES].sort()); + }); + + it('release both pets', () => { + for (const state of TERMINAL_STATES) { + expect(shouldReleaseLocks(state)).toBe(true); + } + for (const state of BATTLE_HAPPY_PATH.filter((s) => !isTerminal(s))) { + expect(shouldReleaseLocks(state)).toBe(false); + } + }); +}); + +describe('transition table completeness', () => { + it('has an entry for every state', () => { + for (const state of Object.values(BattleState)) { + expect(ALLOWED_TRANSITIONS[state]).toBeDefined(); + } + }); + + it('names only real states as targets', () => { + const known = new Set(Object.values(BattleState)); + for (const targets of Object.values(ALLOWED_TRANSITIONS)) { + for (const target of targets) { + expect(known.has(target)).toBe(true); + } + } + }); + + it('makes every non-terminal state reachable from accepted', () => { + const seen = new Set([BattleState.accepted]); + const queue: BattleState[] = [BattleState.accepted]; + while (queue.length > 0) { + for (const next of ALLOWED_TRANSITIONS[queue.shift()!]) { + if (!seen.has(next)) { + seen.add(next); + queue.push(next); + } + } + } + // An unreachable state is dead code that looks like a feature. + expect(seen.size).toBe(Object.values(BattleState).length); + }); +}); diff --git a/backend/tests/features/battle-ledger/transitions.test.ts b/backend/tests/features/battle-ledger/transitions.test.ts new file mode 100644 index 00000000..b0622d4e --- /dev/null +++ b/backend/tests/features/battle-ledger/transitions.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BattleState } from '@generated/prisma/enums'; + +/** + * The transaction client every transition runs against. `$transaction` hands the callback + * this object, so the tests can assert that the state change, the outbox write, and the + * lock release all happened against the *same* client, which is what makes them atomic. + */ +const tx = { + battleLedger: { updateMany: vi.fn(), findUnique: vi.fn(), create: vi.fn() }, + battleOutbox: { createMany: vi.fn() }, + petBattleLock: { create: vi.fn(), deleteMany: vi.fn() }, +}; + +vi.mock('@config/prisma', () => ({ + prisma: { + $transaction: vi.fn(), + battleLedger: { findUnique: vi.fn() }, + }, +})); + +import { prisma } from '@config/prisma'; + +import { + applyTransition, + failBattle, + IllegalTransitionError, + openBattle, + OUTBOX_TOPICS, + sortPetIds, +} from '@features/battle-ledger'; + +beforeEach(() => { + vi.clearAllMocks(); + // Run the callback inline with the fake client, and record the isolation level so the + // serializable requirement is testable. + vi.mocked(prisma.$transaction).mockImplementation(((callback: (client: typeof tx) => unknown) => + Promise.resolve(callback(tx))) as never); + tx.battleLedger.updateMany.mockResolvedValue({ count: 1 }); + tx.battleLedger.create.mockResolvedValue({ battleId: 'btl_1', chainId: 'eip155:84532' }); + tx.battleOutbox.createMany.mockResolvedValue({ count: 1 }); + tx.petBattleLock.create.mockResolvedValue({}); + tx.petBattleLock.deleteMany.mockResolvedValue({ count: 2 }); +}); + +describe('applyTransition', () => { + it('advances the state and enqueues in one transaction', async () => { + const result = await applyTransition({ + battleId: 'btl_1', + from: BattleState.accepted, + to: BattleState.committed, + patch: { drandRound: 1002n }, + outbox: [{ battleId: 'btl_1', topic: OUTBOX_TOPICS.awaitBeacon }], + }); + + expect(result).toEqual({ applied: true, state: BattleState.committed }); + expect(tx.battleLedger.updateMany).toHaveBeenCalledWith({ + where: { battleId: 'btl_1', state: BattleState.accepted }, + data: { drandRound: 1002n, state: BattleState.committed }, + }); + expect(tx.battleOutbox.createMany).toHaveBeenCalledTimes(1); + }); + + it('runs at serializable isolation', async () => { + await applyTransition({ battleId: 'btl_1', from: BattleState.accepted, to: BattleState.committed }); + expect(vi.mocked(prisma.$transaction).mock.calls[0]![1]).toEqual({ isolationLevel: 'Serializable' }); + }); + + it('guards the update on the expected current state', async () => { + // This guard is the concurrency control: only the caller that finds the battle in + // `from` gets to move it, so there is no read-then-write race to lose. + await applyTransition({ battleId: 'btl_1', from: BattleState.seeded, to: BattleState.computed }); + const where = tx.battleLedger.updateMany.mock.calls[0]![0].where; + expect(where).toEqual({ battleId: 'btl_1', state: BattleState.seeded }); + }); + + it('reports a lost race as not applied, with the state that won', async () => { + tx.battleLedger.updateMany.mockResolvedValue({ count: 0 }); + tx.battleLedger.findUnique.mockResolvedValue({ state: BattleState.computed }); + + const result = await applyTransition({ + battleId: 'btl_1', + from: BattleState.seeded, + to: BattleState.computed, + outbox: [{ battleId: 'btl_1', topic: OUTBOX_TOPICS.verify }], + }); + + expect(result).toEqual({ applied: false, state: BattleState.computed }); + // Nothing else may happen on a lost race: enqueueing anyway would double-schedule + // the follow-up work the winner already scheduled. + expect(tx.battleOutbox.createMany).not.toHaveBeenCalled(); + }); + + it('treats a repeat of the same state as a no-op without touching the database', async () => { + const result = await applyTransition({ + battleId: 'btl_1', + from: BattleState.computed, + to: BattleState.computed, + outbox: [{ battleId: 'btl_1', topic: OUTBOX_TOPICS.verify }], + }); + + expect(result).toEqual({ applied: false, state: BattleState.computed }); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it('throws for an illegal move rather than silently ignoring it', async () => { + await expect( + applyTransition({ battleId: 'btl_1', from: BattleState.committed, to: BattleState.rejected }), + ).rejects.toBeInstanceOf(IllegalTransitionError); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it('throws when the battle does not exist', async () => { + tx.battleLedger.updateMany.mockResolvedValue({ count: 0 }); + tx.battleLedger.findUnique.mockResolvedValue(null); + await expect( + applyTransition({ battleId: 'missing', from: BattleState.accepted, to: BattleState.committed }), + ).rejects.toThrow(/does not exist/); + }); + + it('releases both pet locks on a terminal state', async () => { + await applyTransition({ battleId: 'btl_1', from: BattleState.published, to: BattleState.batched }); + expect(tx.petBattleLock.deleteMany).toHaveBeenCalledWith({ where: { battleId: 'btl_1' } }); + }); + + it('releases locks on a failure terminal state too', async () => { + // A pet stuck locked because its battle failed is indistinguishable from a pet in a + // battle, and it would keep the owner from playing. + await applyTransition({ battleId: 'btl_1', from: BattleState.committed, to: BattleState.forfeited }); + expect(tx.petBattleLock.deleteMany).toHaveBeenCalledWith({ where: { battleId: 'btl_1' } }); + }); + + it('keeps locks while a battle is still in flight', async () => { + await applyTransition({ battleId: 'btl_1', from: BattleState.seeded, to: BattleState.computed }); + expect(tx.petBattleLock.deleteMany).not.toHaveBeenCalled(); + }); + + it('skips the outbox write when there is nothing to enqueue', async () => { + await applyTransition({ battleId: 'btl_1', from: BattleState.signed, to: BattleState.published }); + expect(tx.battleOutbox.createMany).not.toHaveBeenCalled(); + }); +}); + +describe('failBattle', () => { + it('records why it failed', async () => { + await failBattle('btl_1', BattleState.computed, BattleState.verification_failed, 'winner mismatch: ts=1 go=2'); + expect(tx.battleLedger.updateMany.mock.calls[0]![0].data).toEqual({ + failureReason: 'winner mismatch: ts=1 go=2', + state: BattleState.verification_failed, + }); + }); +}); + +describe('openBattle', () => { + it('creates the row, locks both pets, and enqueues in one transaction', async () => { + await openBattle({ + ledger: { chainId: 'eip155:84532' } as never, + petIds: ['9', '10'], + outbox: [{ battleId: 'btl_1', topic: OUTBOX_TOPICS.awaitBeacon }], + }); + + expect(tx.battleLedger.create).toHaveBeenCalledTimes(1); + expect(tx.petBattleLock.create).toHaveBeenCalledTimes(2); + expect(tx.battleOutbox.createMany).toHaveBeenCalledTimes(1); + expect(vi.mocked(prisma.$transaction).mock.calls[0]![1]).toEqual({ isolationLevel: 'Serializable' }); + }); + + it('takes locks in ascending numeric pet-id order', async () => { + // Deadlock avoidance: two battles over the same pair contend on the same row first, + // so one fails cleanly on the primary key instead of both waiting on each other. + await openBattle({ ledger: { chainId: 'eip155:84532' } as never, petIds: ['10', '9'] }); + const order = tx.petBattleLock.create.mock.calls.map((call) => call[0].data.petId); + expect(order).toEqual(['9', '10']); + }); +}); + +describe('sortPetIds', () => { + it('sorts numerically, not lexicographically', () => { + // Pet ids are decimal strings, and "10" < "9" as text. Getting this wrong would give + // two concurrent battles opposite lock orders, which is the deadlock. + expect(sortPetIds(['9', '10', '2'])).toEqual(['2', '9', '10']); + }); + + it('handles ids beyond Number.MAX_SAFE_INTEGER', () => { + const big = '115792089237316195423570985008687907853269984665640564039457584007913129639935'; + expect(sortPetIds([big, '7'])).toEqual(['7', big]); + }); + + it('does not mutate the input', () => { + const input = ['10', '9']; + sortPetIds(input); + expect(input).toEqual(['10', '9']); + }); +}); From a0d310f0f5d0d712df175ebf1abfea434011b8c7 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 10:00:49 -0400 Subject: [PATCH 20/76] feat(backend): accept wallet-signed battle intents --- backend/package.json | 3 +- backend/scripts/bundle-protocol.cjs | 35 + backend/src/app.ts | 2 + backend/src/config/env.ts | 22 + backend/src/features/battle-ledger/domain.ts | 49 + backend/src/features/battle-ledger/index.ts | 12 + .../battle-ledger/intent.controller.ts | 69 ++ .../features/battle-ledger/intent.service.ts | 243 +++++ backend/src/register-path-aliases.ts | 10 + backend/src/routes/battle.ts | 14 + .../battle-ledger/intent.service.test.ts | 262 ++++++ backend/tsconfig.json | 5 + backend/vitest.config.ts | 1 + pnpm-lock.yaml | 851 +++++++++--------- 14 files changed, 1153 insertions(+), 425 deletions(-) create mode 100644 backend/scripts/bundle-protocol.cjs create mode 100644 backend/src/features/battle-ledger/domain.ts create mode 100644 backend/src/features/battle-ledger/intent.controller.ts create mode 100644 backend/src/features/battle-ledger/intent.service.ts create mode 100644 backend/src/routes/battle.ts create mode 100644 backend/tests/features/battle-ledger/intent.service.test.ts diff --git a/backend/package.json b/backend/package.json index 3fdc56f7..a1b4312e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,7 @@ "node": ">=20" }, "scripts": { - "build": "prisma generate && tsc && node scripts/copy-proto.cjs && node scripts/bundle-shared-node.cjs", + "build": "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", @@ -24,6 +24,7 @@ "dependencies": { "@ai-sdk/openai": "^3.0.68", "@coral-xyz/anchor": "^0.32.0", + "@cryptopets/protocol": "workspace:*", "@grpc/grpc-js": "^1.14.4", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0", diff --git a/backend/scripts/bundle-protocol.cjs b/backend/scripts/bundle-protocol.cjs new file mode 100644 index 00000000..1d29687b --- /dev/null +++ b/backend/scripts/bundle-protocol.cjs @@ -0,0 +1,35 @@ +/** + * Bundle @cryptopets/protocol into plain CJS so production + * `node dist/src/server.js` never has to load raw TypeScript from protocol/. + * + * Same reasoning as bundle-shared-node.cjs: the backend is compiled by tsc rather + * than bundled, so a bare `require('@cryptopets/protocol')` in the emitted output + * would resolve to a .ts entry point at runtime and crash. `register-path-aliases` + * points the specifier at this bundle in production and at the raw source in dev, + * where tsx can load TypeScript directly. + */ +const path = require('node:path'); +const esbuild = require('esbuild'); + +const entry = path.resolve(__dirname, '../../protocol/src/index.ts'); +const outfile = path.resolve(__dirname, '../dist/protocol.cjs'); + +esbuild + .build({ + entryPoints: [entry], + outfile, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + // @noble/* stay external: they ship CJS and are real backend dependencies. + packages: 'external', + logLevel: 'info', + }) + .then(() => { + console.log(`[bundle-protocol] ${entry} -> ${outfile}`); + }) + .catch((err) => { + console.error('[bundle-protocol] failed:', err); + process.exit(1); + }); diff --git a/backend/src/app.ts b/backend/src/app.ts index a43b1bc6..ee765c64 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,6 +8,7 @@ import protectedRoutes from '@routes/protected'; import graphqlRoutes from '@routes/graphql'; import dialogueRoutes from '@routes/dialogue'; import battleRoomRoutes from '@routes/battle-room'; +import battleRoutes from '@routes/battle'; const app = express(); @@ -32,6 +33,7 @@ app.use('/api/health', healthRoutes); app.use('/graphql', graphqlRoutes); app.use('/api/battle-dialogue', dialogueRoutes); app.use('/api/battle-room', battleRoomRoutes); +app.use('/api/battle', battleRoutes); app.get('/', (_req: Request, res: Response) => { res.json({ diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index da6fb501..3b66a80b 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -128,4 +128,26 @@ export const env = { programId: process.env.KEEPER_SOLANA_PROGRAM_ID?.trim() || undefined, pollIntervalMs: Number(process.env.KEEPER_SOLANA_POLL_INTERVAL_MS?.trim() || '5000'), }, + + /** + * Backend-authoritative battles (docs/plan-backend-battle-architecture.md). + * + * Every wallet-signed object binds `chainId` and `deploymentId`, and that binding only + * stops a replay if this server refuses payloads naming a different one. Both values are + * configured rather than inferred: a deployment id has no on-chain source, and reading it + * from the database would make it whatever the data happened to say. + * + * `BATTLE_DEPLOYMENT_ID` must differ between environments, or a staging signature is a + * valid production signature. The default is deliberately a local-only value, so a + * production deployment that forgets to set it rejects everything rather than silently + * sharing staging's identity. + */ + battle: { + deploymentId: process.env.BATTLE_DEPLOYMENT_ID?.trim() || 'local-dev', + /** Comma-separated protocol chain ids, e.g. `eip155:84532,solana:devnet`. */ + chainIds: (process.env.BATTLE_CHAIN_IDS?.trim() || 'eip155:31337,solana:localnet') + .split(',') + .map((id) => id.trim()) + .filter((id) => id.length > 0), + }, } as const; diff --git a/backend/src/features/battle-ledger/domain.ts b/backend/src/features/battle-ledger/domain.ts new file mode 100644 index 00000000..b7f4b22b --- /dev/null +++ b/backend/src/features/battle-ledger/domain.ts @@ -0,0 +1,49 @@ +import { assertChainId, assertProtocolDomain, type ChainId, type ProtocolDomain } from '@cryptopets/protocol'; + +import { env } from '@config/env'; + +/** + * Which chain and deployment this backend serves. + * + * Every signed object binds `chainId` and `deploymentId` (§D), and that binding is only + * worth anything if the server refuses payloads naming a different one. Otherwise a + * signature captured from staging is a valid production signature, which is exactly the + * replay the fields exist to stop. + * + * Configured rather than inferred: a deployment id has no on-chain source, and guessing it + * from the database would make it whatever the data happened to say. + */ + +/** The deployment this process serves, e.g. `base-sepolia-live`. */ +export function servedDeploymentId(): string { + return env.battle.deploymentId; +} + +/** Chain ids this process accepts intents for. */ +export function servedChainIds(): ChainId[] { + return env.battle.chainIds.map((chainId) => assertChainId(chainId)); +} + +/** The domain for one chain, as this process serves it. */ +export function servedDomain(chainId: ChainId): ProtocolDomain { + return assertProtocolDomain({ chainId, deploymentId: servedDeploymentId() }); +} + +/** + * Throws unless `domain` is one this process serves. + * + * The message names both sides, because during an incident the useful question is which + * environment a signature actually came from. + */ +export function assertServedDomain(domain: ProtocolDomain): ProtocolDomain { + const checked = assertProtocolDomain(domain); + const deploymentId = servedDeploymentId(); + if (checked.deploymentId !== deploymentId) { + throw new Error(`this deployment is ${deploymentId}, got ${checked.deploymentId}`); + } + const allowed = servedChainIds(); + if (!allowed.includes(checked.chainId)) { + throw new Error(`chain ${checked.chainId} is not served here (serving ${allowed.join(', ')})`); + } + return checked; +} diff --git a/backend/src/features/battle-ledger/index.ts b/backend/src/features/battle-ledger/index.ts index ce46e2dc..9aebd4f0 100644 --- a/backend/src/features/battle-ledger/index.ts +++ b/backend/src/features/battle-ledger/index.ts @@ -1,3 +1,15 @@ +export { assertServedDomain, servedChainIds, servedDeploymentId, servedDomain } from './domain'; +export { postBattleIntent } from './intent.controller'; +export { + type BattleIntentWire, + type IntentRejection, + type SignatureFormat, + type SubmitIntentRequest, + type SubmitIntentResult, + submitBattleIntent, + toProtocolIntent, + verifyIntentSignature, +} from './intent.service'; export { type ClaimedMessage, claimOutbox, diff --git a/backend/src/features/battle-ledger/intent.controller.ts b/backend/src/features/battle-ledger/intent.controller.ts new file mode 100644 index 00000000..1b7658e5 --- /dev/null +++ b/backend/src/features/battle-ledger/intent.controller.ts @@ -0,0 +1,69 @@ +import type { Response } from 'express'; + +import type { AuthenticatedRequest } from '@middleware/auth'; + +import { + type BattleIntentWire, + type IntentRejection, + type SignatureFormat, + submitBattleIntent, +} from './intent.service'; + +/** + * POST handler for a signed battle intent. + * + * Returns 401 for a rejection that means "you are not who this intent says", 409 for a + * nonce or intent already used, and 422 for everything else that is the client's fault. The + * 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 = { + 'malformed-intent': 422, + 'wrong-deployment': 422, + expired: 422, + 'wallet-mismatch': 403, + 'wrong-signature-format': 422, + 'bad-signature': 401, + 'unknown-pet': 404, + 'not-pet-owner': 403, + 'self-battle': 422, + 'nonce-already-used': 409, + 'duplicate-intent': 409, +}; + +interface SubmitIntentBody { + intent?: BattleIntentWire; + signature?: string; + signatureFormat?: SignatureFormat; +} + +export async function postBattleIntent(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 SubmitIntentBody; + if (!body?.intent || typeof body.signature !== 'string' || !body.signatureFormat) { + res.status(422).json({ error: 'intent, signature, and signatureFormat are required' }); + return; + } + + const result = await submitBattleIntent({ + intent: body.intent, + signature: body.signature, + signatureFormat: body.signatureFormat, + authenticatedWallet: wallet, + // The clock enters here and nowhere deeper, so every layer below is testable + // without faking time. + 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({ intentHash: result.intentHash }); +} diff --git a/backend/src/features/battle-ledger/intent.service.ts b/backend/src/features/battle-ledger/intent.service.ts new file mode 100644 index 00000000..f65092a2 --- /dev/null +++ b/backend/src/features/battle-ledger/intent.service.ts @@ -0,0 +1,243 @@ +import { + assertBattleIntent, + type BattleIntent, + battleIntentSolanaMessage, + battleIntentTypedData, + chainFamily, + type ChainId, + hashBattleIntent, + isExpired, + normalizeAccount, +} from '@cryptopets/protocol'; +import { ethers } from 'ethers'; + +import { prisma } from '@config/prisma'; +import { verifySolanaSignature } from '@features/auth/solana'; +import { getPetById } from '@repositories/roster.repository'; + +import { assertServedDomain } from './domain'; + +/** + * Battle intent submission (§D). + * + * A JWT authorizes API access; it does not authorize a battle. It is a bearer token we + * issued to ourselves, so a compromised API could mint one for any wallet and spend + * someone else's pet's cooldown. The wallet signature is the authorization, and this + * module's job is to refuse anything that is not one. + * + * What this deliberately does not do: create a ledger row. §J's `accepted` state means the + * snapshot has been frozen and a drand round committed, which is the accept flow. Storing a + * verified intent first keeps the nonce consumed (so a replay cannot get a second battle + * even if acceptance fails) without inventing a ledger state that has no snapshot. + */ + +/** Wire shape of an intent, as a client sends it. */ +export interface BattleIntentWire { + chainId: string; + deploymentId: string; + attackerOwner: string; + attackerPetId: string; + defenderOwner: string; + defenderPetId: string; + challengeId: string | null; + clientNonce: string; + rulesetHash: string; + expiresAt: number; +} + +export type SignatureFormat = 'eip712' | 'solana-message'; + +export interface SubmitIntentRequest { + intent: BattleIntentWire; + signature: string; + signatureFormat: SignatureFormat; + /** 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. */ + nowSeconds: number; +} + +/** Why an intent was refused. Distinct values because they mean different things to a client. */ +export type IntentRejection = + | 'malformed-intent' + | 'wrong-deployment' + | 'expired' + | 'wallet-mismatch' + | 'wrong-signature-format' + | 'bad-signature' + | 'unknown-pet' + | 'not-pet-owner' + | 'self-battle' + | 'nonce-already-used' + | 'duplicate-intent'; + +export type SubmitIntentResult = + | { ok: true; intentHash: string } + | { ok: false; reason: IntentRejection; detail: string }; + +/** + * Validates, verifies, and records a signed intent. + * + * Order matters: cheap structural checks first, then the signature, then the database. The + * ownership read is last of the checks because it is the only one that touches Postgres, and + * an attacker spraying malformed intents should not get free queries out of it. + */ +export async function submitBattleIntent(request: SubmitIntentRequest): Promise { + let intent: BattleIntent; + try { + intent = assertBattleIntent(toProtocolIntent(request.intent)); + } catch (error) { + return reject('malformed-intent', (error as Error).message); + } + + try { + assertServedDomain(intent.domain); + } catch (error) { + // A staging signature replayed against production lands here, which is the whole + // reason both halves of the domain are inside the signed payload. + return reject('wrong-deployment', (error as Error).message); + } + + if (isExpired(intent, request.nowSeconds)) { + return reject('expired', `intent expired at ${intent.expiresAt}, now ${request.nowSeconds}`); + } + + if (normalizeAccount(request.authenticatedWallet) !== intent.attackerOwner) { + // §D: a JWT user never submits a battle for another wallet. + return reject( + 'wallet-mismatch', + `authenticated wallet ${request.authenticatedWallet} is not the attacker ${intent.attackerOwner}`, + ); + } + + if (intent.attackerPetId === intent.defenderPetId) { + return reject('self-battle', 'a pet cannot fight itself'); + } + + const expectedFormat: SignatureFormat = chainFamily(intent.domain.chainId) === 'evm' ? 'eip712' : 'solana-message'; + if (request.signatureFormat !== expectedFormat) { + return reject( + 'wrong-signature-format', + `${intent.domain.chainId} intents are signed as ${expectedFormat}, got ${request.signatureFormat}`, + ); + } + + if (!verifyIntentSignature(intent, request.signature, expectedFormat)) { + return reject('bad-signature', 'signature does not recover to the attacker owner'); + } + + const family = chainFamily(intent.domain.chainId); + const attacker = await getPetById(family, intent.attackerPetId.toString()); + if (!attacker) { + return reject('unknown-pet', `attacker pet ${intent.attackerPetId} is not in the roster`); + } + if (normalizeAccount(attacker.owner) !== intent.attackerOwner) { + // Ownership comes from indexed chain state, not from the signature. A pet sold + // between signing and submitting fails here (threat T10). + return reject( + 'not-pet-owner', + `pet ${intent.attackerPetId} belongs to ${attacker.owner}, not ${intent.attackerOwner}`, + ); + } + const defender = await getPetById(family, intent.defenderPetId.toString()); + if (!defender) { + return reject('unknown-pet', `defender pet ${intent.defenderPetId} is not in the roster`); + } + if (normalizeAccount(defender.owner) !== intent.defenderOwner) { + return reject( + 'not-pet-owner', + `pet ${intent.defenderPetId} belongs to ${defender.owner}, not ${intent.defenderOwner}`, + ); + } + + const intentHash = hashBattleIntent(intent); + + try { + await prisma.battleIntent.create({ + data: { + intentHash, + chainId: intent.domain.chainId, + deploymentId: intent.domain.deploymentId, + attackerOwner: intent.attackerOwner, + attackerPetId: intent.attackerPetId.toString(), + defenderOwner: intent.defenderOwner, + defenderPetId: intent.defenderPetId.toString(), + challengeId: intent.challengeId, + clientNonce: intent.clientNonce, + rulesetHash: intent.rulesetHash, + expiresAt: BigInt(intent.expiresAt), + signature: request.signature, + signatureFormat: request.signatureFormat, + }, + }); + } catch (error) { + return classifyWriteFailure(error, intentHash); + } + + return { ok: true, intentHash }; +} + +/** + * Verifies the wallet signature over the chain-specific payload. + * + * Note what is verified: the payload the wallet was shown, rebuilt from the intent, not a + * 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 { + try { + if (format === 'eip712') { + const typed = battleIntentTypedData(intent); + // The protocol declares its type list `as const` so field order cannot drift; + // ethers wants a mutable record. Structurally identical, so a cast rather than a + // 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 verifySolanaSignature(intent.attackerOwner, signature, battleIntentSolanaMessage(intent)); + } catch { + // A malformed signature is a refusal, not an exception for the route to handle. + return false; + } +} + +/** Maps the wire shape onto the protocol type, leaving validation to the protocol. */ +export function toProtocolIntent(wire: BattleIntentWire): BattleIntent { + return { + domain: { chainId: wire.chainId as ChainId, deploymentId: wire.deploymentId }, + attackerOwner: wire.attackerOwner, + attackerPetId: BigInt(wire.attackerPetId), + defenderOwner: wire.defenderOwner, + defenderPetId: BigInt(wire.defenderPetId), + challengeId: wire.challengeId, + clientNonce: wire.clientNonce, + rulesetHash: wire.rulesetHash as `0x${string}`, + expiresAt: wire.expiresAt, + }; +} + +/** + * Turns a write failure into a reason. + * + * The two unique constraints mean different things. A repeated nonce is a replay attempt + * (threat T7) and is worth alerting on; the same `intentHash` arriving twice is usually a + * client retrying a request whose response it never saw. Neither is ever resolved by an + * upsert: quietly merging would let a second, different payload inherit the first's + * acceptance. + */ +function classifyWriteFailure(error: unknown, intentHash: string): SubmitIntentResult { + const code = (error as { code?: string }).code; + const target = String((error as { meta?: { target?: unknown } }).meta?.target ?? ''); + if (code === 'P2002') { + if (target.includes('client_nonce') || target.includes('battle_intent_nonce')) { + return reject('nonce-already-used', 'this client nonce has already been used'); + } + return reject('duplicate-intent', `intent ${intentHash} has already been submitted`); + } + throw error; +} + +function reject(reason: IntentRejection, detail: string): SubmitIntentResult { + return { ok: false, reason, detail }; +} diff --git a/backend/src/register-path-aliases.ts b/backend/src/register-path-aliases.ts index c76fdfc7..acd8ca39 100644 --- a/backend/src/register-path-aliases.ts +++ b/backend/src/register-path-aliases.ts @@ -12,9 +12,19 @@ const sharedNodeBundle = path.join(root, '..', 'shared-node.cjs'); const sharedNodeDev = path.join(root, '..', '..', 'shared', 'src', 'node.ts'); const sharedNode = fs.existsSync(sharedNodeBundle) ? sharedNodeBundle : sharedNodeDev; +/** + * Same split for @cryptopets/protocol: the production build emits `dist/protocol.cjs` + * (see scripts/bundle-protocol.cjs), while `tsx src/server.ts` loads the raw source. + * Without this the compiled output would `require` a .ts entry point. + */ +const protocolBundle = path.join(root, '..', 'protocol.cjs'); +const protocolDev = path.join(root, '..', '..', 'protocol', 'src', 'index.ts'); +const protocolEntry = fs.existsSync(protocolBundle) ? protocolBundle : protocolDev; + // eslint-disable-next-line @typescript-eslint/no-require-imports require('module-alias').addAliases({ '@shared/core/node': sharedNode, + '@cryptopets/protocol': protocolEntry, '@config': path.join(root, 'config'), '@routes': path.join(root, 'routes'), '@features': path.join(root, 'features'), diff --git a/backend/src/routes/battle.ts b/backend/src/routes/battle.ts new file mode 100644 index 00000000..51fae934 --- /dev/null +++ b/backend/src/routes/battle.ts @@ -0,0 +1,14 @@ +import express, { Router } from 'express'; + +import { postBattleIntent } from '@features/battle-ledger'; +import { verifyToken } from '@middleware/auth'; +import { battleRoomRateLimit } from '@middleware/rateLimit'; + +const router: Router = express.Router(); + +// The JWT identifies the caller; the wallet signature inside the body is what authorizes +// the battle (§D). Rate limiting runs after verifyToken so the budget is per wallet rather +// than per IP, which is what makes it a per-wallet submission limit (threat T5). +router.post('/intents', verifyToken, battleRoomRateLimit, postBattleIntent); + +export default router; diff --git a/backend/tests/features/battle-ledger/intent.service.test.ts b/backend/tests/features/battle-ledger/intent.service.test.ts new file mode 100644 index 00000000..20484eea --- /dev/null +++ b/backend/tests/features/battle-ledger/intent.service.test.ts @@ -0,0 +1,262 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ethers } from 'ethers'; + +import { battleIntentSolanaMessage, battleIntentTypedData, hashBattleIntent } from '@cryptopets/protocol'; + +vi.mock('@config/prisma', () => ({ + prisma: { battleIntent: { create: vi.fn() } }, +})); + +vi.mock('@config/env', () => ({ + env: { + battle: { deploymentId: 'base-sepolia-live', chainIds: ['eip155:84532', 'solana:devnet'] }, + }, +})); + +vi.mock('@repositories/roster.repository', () => ({ + getPetById: vi.fn(), +})); + +import { prisma } from '@config/prisma'; +import { submitBattleIntent, toProtocolIntent, verifyIntentSignature } from '@features/battle-ledger'; +import { getPetById } from '@repositories/roster.repository'; + +/** + * A deterministic EVM wallet, so signatures in these tests are real ones rather than + * stubs: the point of this module is signature verification, and mocking it away would + * leave the only interesting behaviour untested. + */ +const wallet = new ethers.Wallet('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'); +const ATTACKER = wallet.address.toLowerCase(); +const DEFENDER = '0x2222222222222222222222222222222222222222'; + +const NOW = 1893456000; + +const wire = { + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + attackerOwner: ATTACKER, + attackerPetId: '1', + defenderOwner: DEFENDER, + defenderPetId: '2', + challengeId: null, + clientNonce: '01hq8z0000000000000000', + rulesetHash: `0x${'ab'.repeat(32)}`, + expiresAt: NOW + 300, +}; + +async function signWire(overrides: Partial = {}): Promise { + const typed = battleIntentTypedData(toProtocolIntent({ ...wire, ...overrides })); + return wallet.signTypedData(typed.domain, typed.types as never, typed.message); +} + +async function submit(overrides: Partial = {}, extras: Partial[0]> = {}) { + const intent = { ...wire, ...overrides }; + return submitBattleIntent({ + intent, + signature: extras.signature ?? (await signWire(overrides)), + signatureFormat: extras.signatureFormat ?? 'eip712', + authenticatedWallet: extras.authenticatedWallet ?? ATTACKER, + nowSeconds: extras.nowSeconds ?? NOW, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getPetById).mockImplementation((async (_chain: string, petId: string) => ({ + petId, + owner: petId === '1' ? ATTACKER : DEFENDER, + })) as never); + vi.mocked(prisma.battleIntent.create).mockResolvedValue({} as never); +}); + +describe('accepting a valid intent', () => { + it('records it and returns the intent hash', async () => { + const result = await submit(); + + expect(result).toEqual({ ok: true, intentHash: hashBattleIntent(toProtocolIntent(wire)) }); + const data = vi.mocked(prisma.battleIntent.create).mock.calls[0]![0].data as Record; + expect(data).toMatchObject({ + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + attackerOwner: ATTACKER, + attackerPetId: '1', + defenderPetId: '2', + clientNonce: '01hq8z0000000000000000', + signatureFormat: 'eip712', + }); + // Kept so a third party can be shown the attacker really asked for this battle. + expect(typeof data.signature).toBe('string'); + expect(data.expiresAt).toBe(BigInt(wire.expiresAt)); + }); + + it('accepts a checksummed authenticated wallet', async () => { + const result = await submit({}, { authenticatedWallet: wallet.address }); + expect(result.ok).toBe(true); + }); +}); + +describe('signature verification', () => { + it('rejects a signature from another wallet', async () => { + const other = new ethers.Wallet('0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'); + const typed = battleIntentTypedData(toProtocolIntent(wire)); + const signature = await other.signTypedData(typed.domain, typed.types as never, typed.message); + + const result = await submit({}, { signature }); + + expect(result).toMatchObject({ ok: false, reason: 'bad-signature' }); + expect(prisma.battleIntent.create).not.toHaveBeenCalled(); + }); + + it('rejects a signature over different fields', async () => { + // The payload verified is rebuilt from the fields the client claims, so a signature + // over a cheaper battle does not carry over to an expensive one. + const signature = await signWire({ defenderPetId: '99' }); + const result = await submit({}, { signature }); + expect(result).toMatchObject({ ok: false, reason: 'bad-signature' }); + }); + + it('rejects a malformed signature without throwing', async () => { + const result = await submit({}, { signature: '0xnotasignature' }); + expect(result).toMatchObject({ ok: false, reason: 'bad-signature' }); + }); + + it('requires the format that matches the chain', async () => { + const result = await submit({}, { signatureFormat: 'solana-message' }); + expect(result).toMatchObject({ ok: false, reason: 'wrong-signature-format' }); + }); + + it('verifies a real EIP-712 signature through the protocol payload', async () => { + const intent = toProtocolIntent(wire); + const signature = await signWire(); + expect(verifyIntentSignature(intent, signature, 'eip712')).toBe(true); + }); + + it('builds a Solana payload for Solana intents', async () => { + // Not signed here (no keypair), but the payload must be the labelled message rather + // than typed data, or a Solana wallet would be asked to sign the wrong thing. + const solanaIntent = toProtocolIntent({ + ...wire, + chainId: 'solana:devnet', + attackerOwner: 'DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL', + defenderOwner: 'GDDMwNyyx8uB6zrqwBFHjLLG3TBYk2F8Az4yrQC5RzMp', + }); + expect(battleIntentSolanaMessage(solanaIntent)).toContain('CryptoPets Battle Intent v1'); + expect(verifyIntentSignature(solanaIntent, 'not-a-signature', 'solana-message')).toBe(false); + }); +}); + +describe('the JWT never authorizes another wallet', () => { + it('rejects a caller submitting for someone else', async () => { + // §D: the token identifies the caller, the signature authorizes the battle. A + // compromised API must not be able to spend another wallet pet cooldown. + const result = await submit({}, { authenticatedWallet: DEFENDER }); + expect(result).toMatchObject({ ok: false, reason: 'wallet-mismatch' }); + expect(prisma.battleIntent.create).not.toHaveBeenCalled(); + }); +}); + +describe('domain binding', () => { + it('rejects an intent for another deployment', async () => { + // A staging signature replayed against production lands here. + const result = await submit({ deploymentId: 'base-sepolia-staging' }); + expect(result).toMatchObject({ ok: false, reason: 'wrong-deployment' }); + }); + + it('rejects a chain this deployment does not serve', async () => { + const result = await submit({ chainId: 'eip155:11155111' }); + expect(result).toMatchObject({ ok: false, reason: 'wrong-deployment' }); + }); +}); + +describe('expiry', () => { + it('rejects an expired intent', async () => { + const result = await submit({}, { nowSeconds: wire.expiresAt }); + expect(result).toMatchObject({ ok: false, reason: 'expired' }); + }); + + it('accepts one that expires a second from now', async () => { + const result = await submit({}, { nowSeconds: wire.expiresAt - 1 }); + expect(result.ok).toBe(true); + }); +}); + +describe('ownership', () => { + it('rejects an attacker pet the roster does not have', async () => { + vi.mocked(getPetById).mockResolvedValue(null as never); + const result = await submit(); + expect(result).toMatchObject({ ok: false, reason: 'unknown-pet' }); + }); + + it('rejects a pet sold between signing and submitting', async () => { + // Ownership comes from indexed chain state, not from the signature (threat T10). + vi.mocked(getPetById).mockImplementation((async (_chain: string, petId: string) => ({ + petId, + owner: petId === '1' ? '0x9999999999999999999999999999999999999999' : DEFENDER, + })) as never); + const result = await submit(); + expect(result).toMatchObject({ ok: false, reason: 'not-pet-owner' }); + }); + + it('rejects a defender whose recorded owner has changed', async () => { + vi.mocked(getPetById).mockImplementation((async (_chain: string, petId: string) => ({ + petId, + owner: petId === '1' ? ATTACKER : '0x8888888888888888888888888888888888888888', + })) as never); + const result = await submit(); + expect(result).toMatchObject({ ok: false, reason: 'not-pet-owner' }); + }); + + it('does not query the roster for a request that fails earlier', async () => { + // Cheap checks first, so spraying malformed intents does not buy free queries. + await submit({ deploymentId: 'nope' }); + expect(getPetById).not.toHaveBeenCalled(); + }); +}); + +describe('structural rejections', () => { + it('rejects a malformed intent', async () => { + // No real signature: a nonce this short cannot even be turned into a signing + // payload, which is the point. Validation runs before signature verification. + const result = await submit({ clientNonce: 'short' }, { signature: '0x00' }); + expect(result).toMatchObject({ ok: false, reason: 'malformed-intent' }); + }); + + it('rejects a pet fighting itself', async () => { + const result = await submit({ defenderPetId: '1', defenderOwner: ATTACKER }); + expect(result).toMatchObject({ ok: false, reason: 'self-battle' }); + }); +}); + +describe('nonce consumption', () => { + it('reports a reused nonce as its own reason', async () => { + // Worth distinguishing: a repeated nonce is a replay attempt (threat T7), while a + // repeated intent hash is usually a client retrying a request it never saw answered. + vi.mocked(prisma.battleIntent.create).mockRejectedValue( + Object.assign(new Error('unique'), { code: 'P2002', meta: { target: ['client_nonce'] } }), + ); + const result = await submit(); + expect(result).toMatchObject({ ok: false, reason: 'nonce-already-used' }); + }); + + it('reports a duplicate intent hash separately', async () => { + vi.mocked(prisma.battleIntent.create).mockRejectedValue( + Object.assign(new Error('unique'), { code: 'P2002', meta: { target: ['intent_hash'] } }), + ); + const result = await submit(); + expect(result).toMatchObject({ ok: false, reason: 'duplicate-intent' }); + }); + + it('never upserts over an existing row', async () => { + // Quietly merging would let a second, different payload inherit the first acceptance. + await submit(); + const call = vi.mocked(prisma.battleIntent.create).mock.calls[0]![0] as Record; + expect(call).not.toHaveProperty('update'); + }); + + it('rethrows an unexpected database error rather than hiding it as a rejection', async () => { + vi.mocked(prisma.battleIntent.create).mockRejectedValue(new Error('connection reset')); + await expect(submit()).rejects.toThrow(/connection reset/); + }); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index e26735ac..843adee0 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -5,6 +5,11 @@ "moduleResolution": "node", "ignoreDeprecations": "5.0", "paths": { + // `@cryptopets/protocol` is deliberately absent here. Like `@shared/core`, it + // resolves through the workspace symlink in node_modules, which keeps TypeScript + // treating it as an external module rather than a project source file outside + // `rootDir`. A `paths` entry pointing at ../protocol/src makes every file in it a + // TS6059 error. Runtime resolution is handled by register-path-aliases. "@config/*": [ "./src/config/*" ], diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 9e29a863..12355298 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -7,6 +7,7 @@ const src = (p: string) => resolve(__dirname, 'src', p); export default defineConfig({ resolve: { alias: { + '@cryptopets/protocol': resolve(__dirname, '../protocol/src/index.ts'), '@config': src('config'), '@routes': src('routes'), '@features': src('features'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 331171bf..a3c6c183 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@coral-xyz/anchor': specifier: ^0.32.0 version: 0.32.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@cryptopets/protocol': + specifier: workspace:* + version: link:../protocol '@grpc/grpc-js': specifier: ^1.14.4 version: 1.14.4 @@ -235,16 +238,16 @@ importers: dependencies: '@dynamic-labs/ethereum': specifier: ^4.37.1 - version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) '@dynamic-labs/sdk-react-core': specifier: ^4.37.1 version: 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) '@dynamic-labs/solana': specifier: ^4.37.1 - version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) '@dynamic-labs/wagmi-connector': specifier: ^4.37.1 - version: 4.40.1(lupvgyugmbc5ztyp7prdwbwueq) + version: 4.40.1(x3m74qb4qabheuvcs6rf3ordmy) '@shared/core': specifier: workspace:* version: link:../shared @@ -256,7 +259,7 @@ importers: version: 0.9.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-adapter-wallets': specifier: ^0.19.32 - version: 0.19.37(wcwzcvkiean7xoqtynzwkhqyla) + version: 0.19.37(k66plh6iifxyw5d3zjvlhcznga) '@solana/web3.js': specifier: ^1.95.2 version: 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -286,10 +289,10 @@ importers: version: 7.13.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) viem: specifier: ^2.37.7 - version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) wagmi: specifier: ^2.17.1 - version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -386,13 +389,13 @@ importers: version: 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) '@reown/appkit-react-native': specifier: ^2.0.1 - version: 2.0.1(htg2tpf3zzmsrhrwiskzh3ey3i) + version: 2.0.1(x6p2ghfntjx42rethspyovjvr4) '@reown/appkit-solana-react-native': specifier: ^2.0.1 version: 2.0.1(@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@reown/appkit-wagmi-react-native': specifier: ^2.0.1 - version: 2.0.1(gbsuv35t73ppduqv7v3dwovljy) + version: 2.0.1(shpadx773iilzq7h2tdwz2t7he) '@shared/core': specifier: workspace:* version: link:../shared @@ -428,10 +431,10 @@ importers: version: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) viem: specifier: ~2.38.3 - version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) wagmi: specifier: ^2.18.2 - version: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) devDependencies: '@babel/core': specifier: ^7.25.2 @@ -559,7 +562,7 @@ importers: version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) wagmi: specifier: ^2.0.0 - version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -12530,13 +12533,13 @@ snapshots: - utf-8-validate - zod - '@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/hashes': 1.8.0 clsx: 1.2.1 eventemitter3: 5.0.1 preact: 10.27.2 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - bufferutil - typescript @@ -12622,12 +12625,12 @@ snapshots: '@leichtgewicht/ip-codec': 2.0.5 utf8-codec: 1.0.0 - '@dynamic-labs-connectors/base-account-evm@4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': + '@dynamic-labs-connectors/base-account-evm@4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': dependencies: - '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@4.4.3) - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@types/react' - bufferutil @@ -12729,11 +12732,11 @@ snapshots: dependencies: '@dynamic-labs/logger': 4.40.1 - '@dynamic-labs/embedded-wallet-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': + '@dynamic-labs/embedded-wallet-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/embedded-wallet': 4.40.1(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 @@ -12742,9 +12745,9 @@ snapshots: '@dynamic-labs/webauthn': 4.40.1 '@turnkey/api-key-stamper': 0.4.7 '@turnkey/iframe-stamper': 2.5.0 - '@turnkey/viem': 0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + '@turnkey/viem': 0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) '@turnkey/webauthn-stamper': 0.5.1 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - bufferutil - encoding @@ -12754,7 +12757,7 @@ snapshots: - utf-8-validate - zod - '@dynamic-labs/embedded-wallet-solana@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@dynamic-labs/embedded-wallet-solana@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@dynamic-labs-sdk/client': 0.1.0-alpha.23(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@dynamic-labs/assert-package-version': 4.40.1 @@ -12769,9 +12772,9 @@ snapshots: '@dynamic-labs/webauthn': 4.40.1 '@solana/web3.js': 1.98.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@turnkey/iframe-stamper': 2.5.0 - '@turnkey/solana': 1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@turnkey/solana': 1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@turnkey/webauthn-stamper': 0.5.1 - viem: 2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - bufferutil - debug @@ -12801,7 +12804,7 @@ snapshots: - react - react-dom - '@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))': + '@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/logger': 4.40.1 @@ -12811,30 +12814,30 @@ snapshots: '@dynamic-labs/utils': 4.40.1 '@dynamic-labs/wallet-book': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - react - react-dom - '@dynamic-labs/ethereum@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': + '@dynamic-labs/ethereum@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': dependencies: - '@coinbase/wallet-sdk': 4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@dynamic-labs-connectors/base-account-evm': 4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + '@coinbase/wallet-sdk': 4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@dynamic-labs-connectors/base-account-evm': 4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/embedded-wallet-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@dynamic-labs/embedded-wallet-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/rpc-providers': 4.40.1 '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@dynamic-labs/waas-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@dynamic-labs/wallet-book': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@metamask/sdk': 0.33.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) - '@walletconnect/ethereum-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/ethereum-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) buffer: 6.0.3 eventemitter3: 5.0.1 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -12981,28 +12984,28 @@ snapshots: - typescript - utf-8-validate - '@dynamic-labs/solana@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': + '@dynamic-labs/solana@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/embedded-wallet-solana': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@dynamic-labs/embedded-wallet-solana': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/rpc-providers': 4.40.1 '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/solana-core': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas-svm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@dynamic-labs/waas-svm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@dynamic-labs/wallet-book': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@dynamic-labs/wallet-connect': 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@dynamic-labs/wallet-connect': 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@solana/web3.js': 1.98.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@wallet-standard/app': 1.0.1 '@wallet-standard/base': 1.0.1 '@wallet-standard/experimental-features': 0.1.1 '@wallet-standard/features': 1.0.3 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 5.0.0 eventemitter3: 5.0.1 tweetnacl: 1.0.3 @@ -13079,17 +13082,17 @@ snapshots: eventemitter3: 5.0.1 tldts: 6.0.16 - '@dynamic-labs/waas-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@dynamic-labs/waas-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@gql.tada/svelte-support' - '@gql.tada/vue-support' @@ -13103,7 +13106,7 @@ snapshots: - utf-8-validate - zod - '@dynamic-labs/waas-svm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))': + '@dynamic-labs/waas-svm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/logger': 4.40.1 @@ -13112,7 +13115,7 @@ snapshots: '@dynamic-labs/solana-core': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@solana/web3.js': 1.98.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) bs58: 5.0.0 @@ -13130,11 +13133,11 @@ snapshots: - utf-8-validate - viem - '@dynamic-labs/waas@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))': + '@dynamic-labs/waas@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))': dependencies: '@dynamic-labs-wallet/browser-wallet-client': 0.0.187(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/solana-core': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@dynamic-labs/sui-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3) @@ -13153,20 +13156,20 @@ snapshots: - utf-8-validate - viem - '@dynamic-labs/wagmi-connector@4.40.1(lupvgyugmbc5ztyp7prdwbwueq)': + '@dynamic-labs/wagmi-connector@4.40.1(x3m74qb4qabheuvcs6rf3ordmy)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/rpc-providers': 4.40.1 '@dynamic-labs/sdk-react-core': 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) '@dynamic-labs/types': 4.40.1 '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) eventemitter3: 5.0.4 react: 19.1.1 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) '@dynamic-labs/wallet-book@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: @@ -13180,11 +13183,11 @@ snapshots: util: 0.12.5 zod: 4.0.5 - '@dynamic-labs/wallet-connect@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@dynamic-labs/wallet-connect@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/logger': 4.40.1 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15757,11 +15760,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-common@1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-common@1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: big.js: 6.2.2 dayjs: 1.11.13 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - bufferutil - typescript @@ -15801,13 +15804,13 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-controllers@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15836,13 +15839,13 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15871,13 +15874,13 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15906,11 +15909,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-controllers@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: @@ -15953,12 +15956,12 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) lit: 3.3.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: @@ -15989,12 +15992,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) lit: 3.3.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: @@ -16025,12 +16028,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-pay@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) lit: 3.3.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: @@ -16069,14 +16072,14 @@ snapshots: dependencies: buffer: 6.0.3 - '@reown/appkit-react-native@2.0.1(htg2tpf3zzmsrhrwiskzh3ey3i)': + '@reown/appkit-react-native@2.0.1(x6p2ghfntjx42rethspyovjvr4)': dependencies: '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) '@reown/appkit-core-react-native': 2.0.1(@types/react@19.2.2)(@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) '@reown/appkit-ui-react-native': 2.0.1(react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) '@walletconnect/react-native-compat': 2.23.0(lh5jzsrjqwxruiai4runjz3fou) - '@walletconnect/universal-provider': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@4.4.3) react: 19.1.1 react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) react-native-safe-area-context: 5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) @@ -16109,12 +16112,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-scaffold-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-scaffold-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.1.0 transitivePeerDependencies: @@ -16146,12 +16149,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -16183,12 +16186,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -16220,12 +16223,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -16283,10 +16286,10 @@ snapshots: react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) react-native-svg: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@reown/appkit-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.1.0 qrcode: 1.5.3 @@ -16318,10 +16321,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -16353,10 +16356,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -16388,10 +16391,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -16423,16 +16426,16 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-utils@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.2 '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16461,16 +16464,16 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16499,16 +16502,16 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16537,14 +16540,14 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-utils@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: @@ -16575,17 +16578,17 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-wagmi-react-native@2.0.1(gbsuv35t73ppduqv7v3dwovljy)': + '@reown/appkit-wagmi-react-native@2.0.1(shpadx773iilzq7h2tdwz2t7he)': dependencies: '@react-native-community/netinfo': 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@reown/appkit-react-native': 2.0.1(htg2tpf3zzmsrhrwiskzh3ey3i) + '@reown/appkit-react-native': 2.0.1(x6p2ghfntjx42rethspyovjvr4) '@walletconnect/react-native-compat': 2.23.0(lh5jzsrjqwxruiai4runjz3fou) react: 19.1.1 react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) react-native-get-random-values: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16638,20 +16641,20 @@ snapshots: - typescript - utf-8-validate - '@reown/appkit@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.2 - '@reown/appkit-scaffold-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) - '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-scaffold-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16680,21 +16683,21 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16723,21 +16726,21 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16766,18 +16769,18 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-pay': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0 + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) @@ -17069,26 +17072,26 @@ snapshots: - react-native - typescript - '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/stake@0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/stake@0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/system@0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/system@0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))': + '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/accounts@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)': dependencies: @@ -17270,7 +17273,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17283,11 +17286,11 @@ snapshots: '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/rpc-parsed-types': 2.3.0(typescript@5.8.3) '@solana/rpc-spec-types': 2.3.0(typescript@5.8.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/signers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) typescript: 5.8.3 @@ -17377,14 +17380,14 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.8.3) '@solana/functional': 2.3.0(typescript@5.8.3) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.8.3) '@solana/subscribable': 2.3.0(typescript@5.8.3) typescript: 5.8.3 - ws: 8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@solana/rpc-subscriptions-spec@2.3.0(typescript@5.8.3)': dependencies: @@ -17394,7 +17397,7 @@ snapshots: '@solana/subscribable': 2.3.0(typescript@5.8.3) typescript: 5.8.3 - '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.8.3) '@solana/fast-stable-stringify': 2.3.0(typescript@5.8.3) @@ -17402,7 +17405,7 @@ snapshots: '@solana/promises': 2.3.0(typescript@5.8.3) '@solana/rpc-spec-types': 2.3.0(typescript@5.8.3) '@solana/rpc-subscriptions-api': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.8.3) '@solana/rpc-transformers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17580,7 +17583,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17588,7 +17591,7 @@ snapshots: '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/promises': 2.3.0(typescript@5.8.3) '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17871,11 +17874,11 @@ snapshots: - typescript - utf-8-validate - '@solana/wallet-adapter-trezor@0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/wallet-adapter-trezor@0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@trezor/connect-web': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/connect-web': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) buffer: 6.0.3 transitivePeerDependencies: - '@solana/sysvars' @@ -17905,11 +17908,11 @@ snapshots: '@solana/wallet-standard-util': 1.1.2 '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-walletconnect@0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@solana/wallet-adapter-walletconnect@0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/solana-adapter': 0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/solana-adapter': 0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -17938,7 +17941,7 @@ snapshots: - utf-8-validate - zod - '@solana/wallet-adapter-wallets@0.19.37(wcwzcvkiean7xoqtynzwkhqyla)': + '@solana/wallet-adapter-wallets@0.19.37(k66plh6iifxyw5d3zjvlhcznga)': dependencies: '@solana/wallet-adapter-alpha': 0.1.14(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-avana': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) @@ -17971,10 +17974,10 @@ snapshots: '@solana/wallet-adapter-tokenary': 0.1.16(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-tokenpocket': 0.4.23(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-torus': 0.11.32(@babel/runtime@7.28.4)(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-trust': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-unsafe-burner': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@solana/wallet-adapter-walletconnect': 0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@solana/wallet-adapter-walletconnect': 0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@solana/wallet-adapter-xdefi': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) transitivePeerDependencies: @@ -18461,13 +18464,13 @@ snapshots: - react-native - utf-8-validate - '@trezor/blockchain-link@2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/blockchain-link@2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: - '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@stellar/stellar-sdk': 13.3.0 '@trezor/blockchain-link-types': 1.4.4(tslib@2.8.1) @@ -18515,9 +18518,9 @@ snapshots: - expo-localization - react-native - '@trezor/connect-web@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/connect-web@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: - '@trezor/connect': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/connect': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@trezor/connect-common': 0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/utils': 9.4.4(tslib@2.8.1) '@trezor/websocket-client': 1.2.4(bufferutil@4.0.9)(tslib@2.8.1)(utf-8-validate@5.0.10) @@ -18536,7 +18539,7 @@ snapshots: - utf-8-validate - ws - '@trezor/connect@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/connect@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@ethereumjs/common': 10.0.0 '@ethereumjs/tx': 10.0.0 @@ -18544,12 +18547,12 @@ snapshots: '@mobily/ts-belt': 3.13.1 '@noble/hashes': 1.8.0 '@scure/bip39': 1.6.0 - '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/system': 0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@trezor/blockchain-link': 2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/system': 0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/blockchain-link': 2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@trezor/blockchain-link-types': 1.4.4(tslib@2.8.1) '@trezor/blockchain-link-utils': 1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10) '@trezor/connect-analytics': 1.3.6(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) @@ -18702,7 +18705,7 @@ snapshots: '@turnkey/api-key-stamper': 0.4.7 '@turnkey/encoding': 0.5.0 - '@turnkey/sdk-browser@5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@turnkey/sdk-browser@5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@turnkey/api-key-stamper': 0.4.7 '@turnkey/crypto': 2.5.0 @@ -18711,7 +18714,7 @@ snapshots: '@turnkey/iframe-stamper': 2.5.0 '@turnkey/indexed-db-stamper': 1.1.1 '@turnkey/sdk-types': 0.3.0 - '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@turnkey/webauthn-stamper': 0.5.1 bs58check: 4.0.0 buffer: 6.0.3 @@ -18724,11 +18727,11 @@ snapshots: - utf-8-validate - zod - '@turnkey/sdk-server@4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@turnkey/sdk-server@4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@turnkey/api-key-stamper': 0.4.7 '@turnkey/http': 3.10.0(encoding@0.1.13) - '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) buffer: 6.0.3 cross-fetch: 3.2.0(encoding@0.1.13) transitivePeerDependencies: @@ -18740,12 +18743,12 @@ snapshots: '@turnkey/sdk-types@0.3.0': {} - '@turnkey/solana@1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@turnkey/solana@1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@turnkey/http': 3.10.0(encoding@0.1.13) - '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - bufferutil - encoding @@ -18753,16 +18756,16 @@ snapshots: - utf-8-validate - zod - '@turnkey/viem@0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': + '@turnkey/viem@0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': dependencies: '@noble/curves': 1.8.0 '@openzeppelin/contracts': 4.9.6 '@turnkey/api-key-stamper': 0.4.7 '@turnkey/http': 3.10.0(encoding@0.1.13) - '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) cross-fetch: 4.1.0(encoding@0.1.13) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - bufferutil - encoding @@ -18770,12 +18773,12 @@ snapshots: - utf-8-validate - zod - '@turnkey/wallet-stamper@1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@turnkey/wallet-stamper@1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@turnkey/crypto': 2.5.0 '@turnkey/encoding': 0.5.0 optionalDependencies: - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - bufferutil - typescript @@ -19271,7 +19274,7 @@ snapshots: '@vue/shared@3.5.22': {} - '@wagmi/connectors@6.1.0(2orsghzlwewxwohejkwolumw4e)': + '@wagmi/connectors@6.1.0(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76))(zod@3.25.76)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) @@ -19280,9 +19283,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(6oek35uj62dxa7liwvqvv47ara) + porto: 0.2.19(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 @@ -19318,7 +19321,7 @@ snapshots: - wagmi - zod - '@wagmi/connectors@6.1.0(bgpzjh5q7yrj4ocuf4x4nrqhlm)': + '@wagmi/connectors@6.1.0(ck72wdzxjpfzfgbpsvtuebcwoi)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) @@ -19327,9 +19330,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(gvhepirkfl6ucqngccm4za6i6m) + porto: 0.2.19(q4cw5yhvj7zbif42fx5kul3uoi) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 @@ -19365,7 +19368,7 @@ snapshots: - wagmi - zod - '@wagmi/connectors@6.1.0(g3hyk7kpi5chrxeuitid5ge5f4)': + '@wagmi/connectors@6.1.0(qnfautsezg4qphd44hbshkqvfi)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@4.4.3) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@4.4.3) @@ -19374,9 +19377,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(dryu7ql2ha2chpe6amo3r4teni) + porto: 0.2.19(wbrvrvfq6sbyt2murnqn7zt4wm) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) optionalDependencies: typescript: 5.8.3 @@ -19491,7 +19494,7 @@ snapshots: dependencies: '@wallet-standard/base': 1.1.0 - '@walletconnect/core@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19505,7 +19508,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 events: 3.3.0 lodash.isequal: 4.5.0 @@ -19535,7 +19538,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19549,7 +19552,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19579,7 +19582,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19593,7 +19596,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19623,21 +19626,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19667,21 +19670,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0 + '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19711,7 +19714,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19725,7 +19728,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19755,21 +19758,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19799,21 +19802,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1 + '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19843,7 +19846,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19857,7 +19860,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.39.3 events: 3.3.0 @@ -19887,7 +19890,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 @@ -19901,7 +19904,7 @@ snapshots: '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.39.3 events: 3.3.0 @@ -19935,18 +19938,18 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -19976,18 +19979,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20017,18 +20020,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/ethereum-provider@2.21.1(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/sign-client': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1 + '@walletconnect/universal-provider': 2.21.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20058,18 +20061,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/ethereum-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20231,16 +20234,16 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/sign-client@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20267,16 +20270,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20303,16 +20306,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20339,16 +20342,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20375,16 +20378,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0 + '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20411,16 +20414,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20447,16 +20450,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20483,16 +20486,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1 + '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20519,16 +20522,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20555,16 +20558,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20591,13 +20594,13 @@ snapshots: - utf-8-validate - zod - '@walletconnect/solana-adapter@0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/solana-adapter@0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20689,7 +20692,7 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))': + '@walletconnect/types@2.21.0': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 @@ -20776,7 +20779,7 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))': + '@walletconnect/types@2.21.1': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 @@ -20921,7 +20924,7 @@ snapshots: - ioredis - uploadthing - '@walletconnect/universal-provider@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -20930,9 +20933,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 lodash: 4.17.21 transitivePeerDependencies: @@ -20961,7 +20964,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -20970,9 +20973,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21001,7 +21004,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -21010,9 +21013,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21041,18 +21044,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21081,18 +21084,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0 + '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21121,7 +21124,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -21130,9 +21133,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21161,18 +21164,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21201,18 +21204,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1 + '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21241,7 +21244,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -21250,9 +21253,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@4.4.3) es-toolkit: 1.39.3 events: 3.3.0 transitivePeerDependencies: @@ -21281,7 +21284,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) @@ -21290,9 +21293,9 @@ snapshots: '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.39.3 events: 3.3.0 transitivePeerDependencies: @@ -21321,7 +21324,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -21339,7 +21342,7 @@ snapshots: elliptic: 6.6.1 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21365,7 +21368,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -21384,7 +21387,7 @@ snapshots: elliptic: 6.6.1 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21410,7 +21413,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -21428,7 +21431,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21454,25 +21457,25 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21498,18 +21501,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.0 '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21542,7 +21545,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 @@ -21560,7 +21563,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21586,25 +21589,25 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21630,18 +21633,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.1 '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21674,7 +21677,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76)': + '@walletconnect/utils@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@4.4.3)': dependencies: '@msgpack/msgpack': 3.1.2 '@noble/ciphers': 1.3.0 @@ -21693,7 +21696,7 @@ snapshots: blakejs: 1.2.1 bs58: 6.0.0 detect-browser: 5.3.0 - ox: 0.9.3(typescript@5.8.3)(zod@3.25.76) + ox: 0.9.3(typescript@5.8.3)(zod@4.4.3) uint8arrays: 3.1.1 transitivePeerDependencies: - '@azure/app-configuration' @@ -21718,7 +21721,7 @@ snapshots: - uploadthing - zod - '@walletconnect/utils@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@msgpack/msgpack': 3.1.2 '@noble/ciphers': 1.3.0 @@ -21739,7 +21742,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.1 - viem: 2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -26439,7 +26442,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.7.1(typescript@5.8.3)(zod@4.4.3): + ox@0.7.1(typescript@5.8.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -26447,7 +26450,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.1(typescript@5.8.3)(zod@4.4.3) + abitype: 1.1.1(typescript@5.8.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 @@ -26469,7 +26472,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.9.3(typescript@5.8.3)(zod@3.25.76): + ox@0.9.3(typescript@5.8.3)(zod@4.4.3): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -26477,7 +26480,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.1(typescript@5.8.3)(zod@3.25.76) + abitype: 1.1.1(typescript@5.8.3)(zod@4.4.3) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 @@ -26741,7 +26744,7 @@ snapshots: pony-cause@2.1.11: {} - porto@0.2.19(6oek35uj62dxa7liwvqvv47ara): + porto@0.2.19(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)): dependencies: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) hono: 4.10.3 @@ -26755,47 +26758,47 @@ snapshots: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + wagmi: 2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) transitivePeerDependencies: - '@types/react' - immer - use-sync-external-store - porto@0.2.19(dryu7ql2ha2chpe6amo3r4teni): + porto@0.2.19(q4cw5yhvj7zbif42fx5kul3uoi): dependencies: - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) hono: 4.10.3 idb-keyval: 6.2.2 mipd: 0.0.7(typescript@5.8.3) ox: 0.9.12(typescript@5.8.3)(zod@4.4.3) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) zod: 4.4.3 zustand: 5.0.8(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(use-sync-external-store@1.4.0(react@19.1.1)) optionalDependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) transitivePeerDependencies: - '@types/react' - immer - use-sync-external-store - porto@0.2.19(gvhepirkfl6ucqngccm4za6i6m): + porto@0.2.19(wbrvrvfq6sbyt2murnqn7zt4wm): dependencies: - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) hono: 4.10.3 idb-keyval: 6.2.2 mipd: 0.0.7(typescript@5.8.3) ox: 0.9.12(typescript@5.8.3)(zod@4.4.3) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) zod: 4.4.3 zustand: 5.0.8(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(use-sync-external-store@1.4.0(react@19.1.1)) optionalDependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) transitivePeerDependencies: - '@types/react' - immer @@ -28508,15 +28511,15 @@ snapshots: - utf-8-validate - zod - viem@2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3): + viem@2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76): dependencies: '@noble/curves': 1.8.2 '@noble/hashes': 1.7.2 '@scure/bip32': 1.6.2 '@scure/bip39': 1.5.4 - abitype: 1.0.8(typescript@5.8.3)(zod@4.4.3) + abitype: 1.0.8(typescript@5.8.3)(zod@3.25.76) isows: 1.0.6(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - ox: 0.6.9(typescript@5.8.3)(zod@4.4.3) + ox: 0.6.9(typescript@5.8.3)(zod@3.25.76) ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -28525,15 +28528,15 @@ snapshots: - utf-8-validate - zod - viem@2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3): + viem@2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.8.3)(zod@4.4.3) + abitype: 1.0.8(typescript@5.8.3)(zod@3.25.76) isows: 1.0.7(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - ox: 0.7.1(typescript@5.8.3)(zod@4.4.3) + ox: 0.7.1(typescript@5.8.3)(zod@3.25.76) ws: 8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -28687,14 +28690,14 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): + wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): dependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) - '@wagmi/connectors': 6.1.0(g3hyk7kpi5chrxeuitid5ge5f4) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/connectors': 6.1.0(ck72wdzxjpfzfgbpsvtuebcwoi) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) react: 19.1.1 use-sync-external-store: 1.4.0(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -28726,14 +28729,14 @@ snapshots: - utf-8-validate - zod - wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): + wagmi@2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): dependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) - '@wagmi/connectors': 6.1.0(2orsghzlwewxwohejkwolumw4e) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@wagmi/connectors': 6.1.0(qnfautsezg4qphd44hbshkqvfi) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) react: 19.1.1 use-sync-external-store: 1.4.0(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -28765,10 +28768,10 @@ snapshots: - utf-8-validate - zod - wagmi@2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): + wagmi@2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): dependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) - '@wagmi/connectors': 6.1.0(bgpzjh5q7yrj4ocuf4x4nrqhlm) + '@wagmi/connectors': 6.1.0(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76))(zod@3.25.76) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) react: 19.1.1 use-sync-external-store: 1.4.0(react@19.1.1) From 014bd4f3dc460197c5195fd5b01a63f904c27d70 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 10:09:04 -0400 Subject: [PATCH 21/76] feat(backend): add standing defender consent with immediate revocation --- .../migration.sql | 15 + backend/prisma/schema.prisma | 24 ++ .../battle-ledger/consent.controller.ts | 78 ++++ .../features/battle-ledger/consent.service.ts | 391 ++++++++++++++++++ backend/src/features/battle-ledger/index.ts | 17 + backend/src/routes/battle.ts | 7 +- .../battle-ledger/consent.service.test.ts | 352 ++++++++++++++++ 7 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 backend/prisma/migrations/20260726100000_add_defense_usage/migration.sql create mode 100644 backend/src/features/battle-ledger/consent.controller.ts create mode 100644 backend/src/features/battle-ledger/consent.service.ts create mode 100644 backend/tests/features/battle-ledger/consent.service.test.ts diff --git a/backend/prisma/migrations/20260726100000_add_defense_usage/migration.sql b/backend/prisma/migrations/20260726100000_add_defense_usage/migration.sql new file mode 100644 index 00000000..7afdb0ac --- /dev/null +++ b/backend/prisma/migrations/20260726100000_add_defense_usage/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "defense_usage" ( + "authorization_hash" TEXT NOT NULL, + "day_bucket" INTEGER NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "defense_usage_pkey" PRIMARY KEY ("authorization_hash","day_bucket") +); + +-- CreateIndex +CREATE INDEX "defense_authorization_owner_nonce_idx" ON "defense_authorization"("chain_id", "deployment_id", "defender_owner", "revocation_nonce"); + +-- AddForeignKey +ALTER TABLE "defense_usage" ADD CONSTRAINT "defense_usage_authorization_hash_fkey" FOREIGN KEY ("authorization_hash") REFERENCES "defense_authorization"("authorization_hash") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 73c7de5e..51129cb7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -238,12 +238,36 @@ model DefenseAuthorization { revokedAt DateTime? @map("revoked_at") ledger BattleLedger[] + usage DefenseUsage[] @@index([chainId, deploymentId, defenderOwner]) @@index([chainId, deploymentId, rulesetHash]) + /// Revocation bumps a monotonic nonce per owner, so the highest nonce an owner has ever + /// used is looked up on every submission. + @@index([chainId, deploymentId, defenderOwner, revocationNonce], map: "defense_authorization_owner_nonce_idx") @@map("defense_authorization") } +/// Battles used against one authorization on one day, enforcing `maxBattlesPerDay` (§D). +/// +/// The bucket is a UTC epoch day (`floor(unixSeconds / 86400)`) rather than a calendar date +/// string, because a date implies a timezone and a defender's daily cap should not depend on +/// which one the server happens to think it is in. +/// +/// Counted per authorization rather than per pet: the cap is what the defender agreed to, and +/// a per-pet count would let a blanket authorization be spent many times over. +model DefenseUsage { + authorizationHash String @map("authorization_hash") + authorization DefenseAuthorization @relation(fields: [authorizationHash], references: [authorizationHash], onDelete: Cascade) + /// UTC epoch day. + dayBucket Int @map("day_bucket") + count Int @default(0) + updatedAt DateTime @updatedAt @map("updated_at") + + @@id([authorizationHash, dayBucket]) + @@map("defense_usage") +} + /// The durable workflow row for one backend-resolved battle: the state machine, the /// frozen snapshot, and the committed randomness round. model BattleLedger { diff --git a/backend/src/features/battle-ledger/consent.controller.ts b/backend/src/features/battle-ledger/consent.controller.ts new file mode 100644 index 00000000..dd39180c --- /dev/null +++ b/backend/src/features/battle-ledger/consent.controller.ts @@ -0,0 +1,78 @@ +import type { Response } from 'express'; + +import type { AuthenticatedRequest } from '@middleware/auth'; + +import { + type AuthorizationRejection, + type DefenseAuthorizationWire, + revokeDefenseAuthorizations, + submitDefenseAuthorization, +} from './consent.service'; +import type { SignatureFormat } from './intent.service'; + +const STATUS_BY_REASON: Record = { + 'malformed-authorization': 422, + 'wrong-deployment': 422, + 'wallet-mismatch': 403, + 'wrong-signature-format': 422, + 'bad-signature': 401, + 'already-expired': 422, + 'stale-revocation-nonce': 409, + 'duplicate-authorization': 409, +}; + +interface SubmitBody { + authorization?: DefenseAuthorizationWire; + signature?: string; + signatureFormat?: SignatureFormat; +} + +export async function postDefenseAuthorization(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?.authorization || typeof body.signature !== 'string' || !body.signatureFormat) { + res.status(422).json({ error: 'authorization, signature, and signatureFormat are required' }); + return; + } + + const result = await submitDefenseAuthorization({ + authorization: body.authorization, + 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({ authorizationHash: result.authorizationHash }); +} + +/** + * Withdraws consent for the authenticated wallet on one chain. + * + * Takes effect immediately and needs no wallet signature: the failure mode of an + * unauthorized revocation is fewer battles, never more, and requiring a signature would + * leave a player who lost their signing device unable to withdraw consent. + */ +export async function deleteDefenseAuthorizations(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 revokeDefenseAuthorizations(chainId, wallet, new Date()); + res.status(200).json({ revoked }); +} diff --git a/backend/src/features/battle-ledger/consent.service.ts b/backend/src/features/battle-ledger/consent.service.ts new file mode 100644 index 00000000..1e317a1a --- /dev/null +++ b/backend/src/features/battle-ledger/consent.service.ts @@ -0,0 +1,391 @@ +import { + assertDefenseAuthorization, + authorizationCovers, + type CoverageFailure, + type DefenseAuthorization, + defenseAuthorizationSolanaMessage, + defenseAuthorizationTypedData, + chainFamily, + type ChainId, + hashDefenseAuthorization, + normalizeAccount, +} 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'; + +/** + * Standing defender consent (§D). + * + * The problem: backend ranked mode must not apply cooldown and rating changes to an + * unwilling defender, but demanding a live signature per battle would mean you can only + * fight players who are online. That is a large product regression, so consent is signed + * once, in advance, and bounded by a level band, a daily cap, a validity window, and a + * ruleset version. + * + * The ruleset binding is the part with a running cost: a balance change invalidates every + * outstanding authorization and prompts every player to re-consent. That is the intended + * trade. It is what makes "I never agreed to these combat rules" a checkable claim instead + * of an argument. + */ + +/** Wire shape of an authorization, as a client sends it. */ +export interface DefenseAuthorizationWire { + chainId: string; + deploymentId: string; + defenderOwner: string; + allPets: boolean; + petIds: string[]; + rulesetHash: string; + minLevel: number; + maxLevel: number; + maxBattlesPerDay: number; + notBefore: number; + expiresAt: number; + revocationNonce: number; +} + +export interface SubmitAuthorizationRequest { + authorization: DefenseAuthorizationWire; + signature: string; + signatureFormat: SignatureFormat; + /** Wallet from the verified JWT. Must be the defender. */ + authenticatedWallet: string; + nowSeconds: number; +} + +export type AuthorizationRejection = + | 'malformed-authorization' + | 'wrong-deployment' + | 'wallet-mismatch' + | 'wrong-signature-format' + | 'bad-signature' + | 'already-expired' + | 'stale-revocation-nonce' + | 'duplicate-authorization'; + +export type SubmitAuthorizationResult = + | { ok: true; authorizationHash: string } + | { ok: false; reason: AuthorizationRejection; detail: string }; + +/** Records a signed authorization. */ +export async function submitDefenseAuthorization( + request: SubmitAuthorizationRequest, +): Promise { + let authorization: DefenseAuthorization; + try { + authorization = assertDefenseAuthorization(toProtocolAuthorization(request.authorization)); + } catch (error) { + return reject('malformed-authorization', (error as Error).message); + } + + try { + assertServedDomain(authorization.domain); + } catch (error) { + return reject('wrong-deployment', (error as Error).message); + } + + if (normalizeAccount(request.authenticatedWallet) !== authorization.defenderOwner) { + return reject( + 'wallet-mismatch', + `authenticated wallet ${request.authenticatedWallet} is not the defender ${authorization.defenderOwner}`, + ); + } + + if (request.nowSeconds >= authorization.expiresAt) { + // Storing a dead authorization would only produce confusing coverage failures later. + return reject( + 'already-expired', + `authorization expired at ${authorization.expiresAt}, now ${request.nowSeconds}`, + ); + } + + const expectedFormat: SignatureFormat = + chainFamily(authorization.domain.chainId) === 'evm' ? 'eip712' : 'solana-message'; + if (request.signatureFormat !== expectedFormat) { + return reject( + 'wrong-signature-format', + `${authorization.domain.chainId} authorizations are signed as ${expectedFormat}`, + ); + } + if (!verifyAuthorizationSignature(authorization, request.signature, expectedFormat)) { + return reject('bad-signature', 'signature does not recover to the defender owner'); + } + + const highest = await highestRevocationNonce(authorization); + if (highest !== null && authorization.revocationNonce < highest) { + // Revocation works by bumping this nonce, so accepting a lower one would let a + // previously revoked grant be reinstated by resubmitting it. + return reject( + 'stale-revocation-nonce', + `revocationNonce ${authorization.revocationNonce} is below this owner current ${highest}`, + ); + } + + const authorizationHash = hashDefenseAuthorization(authorization); + const petIds = authorization.scope.kind === 'pets' ? authorization.scope.petIds.map((id) => id.toString()) : []; + + try { + await prisma.defenseAuthorization.create({ + data: { + authorizationHash, + chainId: authorization.domain.chainId, + deploymentId: authorization.domain.deploymentId, + defenderOwner: authorization.defenderOwner, + allPets: authorization.scope.kind === 'allPets', + petIds, + rulesetHash: authorization.rulesetHash, + minLevel: authorization.minLevel, + maxLevel: authorization.maxLevel, + maxBattlesPerDay: authorization.maxBattlesPerDay, + notBefore: BigInt(authorization.notBefore), + expiresAt: BigInt(authorization.expiresAt), + revocationNonce: authorization.revocationNonce, + signature: request.signature, + signatureFormat: request.signatureFormat, + }, + }); + } catch (error) { + if ((error as { code?: string }).code === 'P2002') { + return reject('duplicate-authorization', `authorization ${authorizationHash} already exists`); + } + throw error; + } + + return { ok: true, authorizationHash }; +} + +/** + * Revokes every live authorization for a wallet, immediately. + * + * Only the JWT is required, not a wallet signature, and that is deliberate: the failure mode + * of an unauthorized revocation is fewer battles, never more. Requiring a signature would + * mean a player who has lost access to their signing device cannot withdraw consent, which + * is the wrong way round for a safety control. + * + * Rows are marked rather than deleted, because receipts reference the authorization hash and + * a verifier must still be able to see what was consented to, and when it stopped. + */ +export async function revokeDefenseAuthorizations( + chainId: string, + defenderOwner: string, + revokedAt: Date, +): Promise<{ revoked: number }> { + const { count } = await prisma.defenseAuthorization.updateMany({ + where: { + chainId, + deploymentId: servedDeploymentId(), + defenderOwner: normalizeAccount(defenderOwner), + revokedAt: null, + }, + data: { revokedAt }, + }); + return { revoked: count }; +} + +/** What a battle needs an authorization to permit. */ +export interface CoverageRequest { + chainId: string; + defenderOwner: string; + defenderPetId: string; + attackerLevel: number; + rulesetHash: string; + nowSeconds: number; +} + +export type ConsentFailure = CoverageFailure | 'no-authorization' | 'daily-cap-reached' | 'revoked'; + +export type ConsentResult = + | { ok: true; authorizationHash: string; maxBattlesPerDay: number } + | { ok: false; reason: ConsentFailure; detail: string }; + +/** + * Finds a live authorization covering this battle. + * + * Coverage itself is decided by the protocol (`authorizationCovers`), not reimplemented + * here: the same function a third party runs against a receipt is the one that gates the + * battle, so an operator cannot be more permissive than the published rule. + * + * When several authorizations could cover a battle, the most recently signed one wins. Not + * arbitrary: a player who tightens their terms expects the new terms to apply, and the + * alternative (picking the most permissive) would make tightening them ineffective. + */ +export async function findCoveringAuthorization(request: CoverageRequest): Promise { + const candidates = await prisma.defenseAuthorization.findMany({ + where: { + chainId: request.chainId, + deploymentId: servedDeploymentId(), + defenderOwner: normalizeAccount(request.defenderOwner), + revokedAt: null, + rulesetHash: request.rulesetHash, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (candidates.length === 0) { + return { ok: false, reason: 'no-authorization', detail: 'this defender has no live authorization' }; + } + + let lastFailure: CoverageFailure = 'pet-not-covered'; + for (const candidate of candidates) { + const coverage = authorizationCovers(fromRow(candidate), { + defenderPetId: BigInt(request.defenderPetId), + attackerLevel: request.attackerLevel, + rulesetHash: candidate.rulesetHash as `0x${string}`, + nowSeconds: request.nowSeconds, + }); + if (!coverage.covered) { + lastFailure = coverage.reason; + continue; + } + return { + ok: true, + authorizationHash: candidate.authorizationHash, + maxBattlesPerDay: candidate.maxBattlesPerDay, + }; + } + + // The reason from the closest candidate, so a player is told "you are below their level + // band" rather than a generic refusal. + return { ok: false, reason: lastFailure, detail: `no live authorization covers this battle (${lastFailure})` }; +} + +/** UTC epoch day for a unix-seconds timestamp. */ +export function epochDay(nowSeconds: number): number { + return Math.floor(nowSeconds / 86400); +} + +/** + * Consumes one battle from an authorization's daily budget. + * + * The increment is the check: `count < max` lives in the WHERE clause, so two concurrent + * battles cannot both read "one left" and both take it. A zero-row update means either the + * cap is reached or today's row does not exist yet, and those are distinguished by trying the + * insert and treating a duplicate-key failure as "another request created it first", after + * which the guarded update is retried once. + */ +export async function consumeDailyBudget( + authorizationHash: string, + maxBattlesPerDay: number, + nowSeconds: number, +): Promise<{ ok: true; used: number } | { ok: false; reason: 'daily-cap-reached' }> { + const dayBucket = epochDay(nowSeconds); + + const bump = async () => + prisma.defenseUsage.updateMany({ + where: { authorizationHash, dayBucket, count: { lt: maxBattlesPerDay } }, + data: { count: { increment: 1 } }, + }); + + let result = await bump(); + if (result.count === 0) { + try { + await prisma.defenseUsage.create({ data: { authorizationHash, dayBucket, count: 1 } }); + return { ok: true, used: 1 }; + } catch (error) { + if ((error as { code?: string }).code !== 'P2002') { + throw error; + } + // Someone else inserted today's row between our update and our insert. + result = await bump(); + } + } + if (result.count === 0) { + return { ok: false, reason: 'daily-cap-reached' }; + } + const row = await prisma.defenseUsage.findUnique({ + where: { authorizationHash_dayBucket: { authorizationHash, dayBucket } }, + select: { count: true }, + }); + return { ok: true, used: row?.count ?? 1 }; +} + +/** Verifies the defender's signature over the chain-specific payload. */ +export function verifyAuthorizationSignature( + authorization: DefenseAuthorization, + signature: string, + format: SignatureFormat, +): boolean { + try { + if (format === 'eip712') { + const typed = defenseAuthorizationTypedData(authorization); + const types = typed.types as unknown as Record; + const recovered = ethers.verifyTypedData(typed.domain, types, typed.message, signature); + return normalizeAccount(recovered) === authorization.defenderOwner; + } + return verifySolanaSignature( + authorization.defenderOwner, + signature, + defenseAuthorizationSolanaMessage(authorization), + ); + } catch { + return false; + } +} + +/** Maps the wire shape onto the protocol type. */ +export function toProtocolAuthorization(wire: DefenseAuthorizationWire): DefenseAuthorization { + return { + domain: { chainId: wire.chainId as ChainId, deploymentId: wire.deploymentId }, + defenderOwner: wire.defenderOwner, + scope: wire.allPets ? { kind: 'allPets' } : { kind: 'pets', petIds: wire.petIds.map((id) => BigInt(id)) }, + rulesetHash: wire.rulesetHash as `0x${string}`, + minLevel: wire.minLevel, + maxLevel: wire.maxLevel, + maxBattlesPerDay: wire.maxBattlesPerDay, + notBefore: wire.notBefore, + expiresAt: wire.expiresAt, + revocationNonce: wire.revocationNonce, + }; +} + +/** Rebuilds the protocol object from a stored row, so coverage runs on the published rule. */ +function fromRow(row: { + chainId: string; + deploymentId: string; + defenderOwner: string; + allPets: boolean; + petIds: unknown; + rulesetHash: string; + minLevel: number; + maxLevel: number; + maxBattlesPerDay: number; + notBefore: bigint; + expiresAt: bigint; + revocationNonce: number; +}): DefenseAuthorization { + const petIds = Array.isArray(row.petIds) ? (row.petIds as string[]) : []; + return { + domain: { chainId: row.chainId as ChainId, deploymentId: row.deploymentId }, + defenderOwner: row.defenderOwner, + scope: row.allPets ? { kind: 'allPets' } : { kind: 'pets', petIds: petIds.map((id) => BigInt(id)) }, + rulesetHash: row.rulesetHash as `0x${string}`, + minLevel: row.minLevel, + maxLevel: row.maxLevel, + maxBattlesPerDay: row.maxBattlesPerDay, + notBefore: Number(row.notBefore), + expiresAt: Number(row.expiresAt), + revocationNonce: row.revocationNonce, + }; +} + +async function highestRevocationNonce(authorization: DefenseAuthorization): Promise { + const row = await prisma.defenseAuthorization.findFirst({ + where: { + chainId: authorization.domain.chainId, + deploymentId: authorization.domain.deploymentId, + defenderOwner: authorization.defenderOwner, + }, + orderBy: { revocationNonce: 'desc' }, + select: { revocationNonce: true }, + }); + return row?.revocationNonce ?? null; +} + +function reject(reason: AuthorizationRejection, detail: string): SubmitAuthorizationResult { + return { ok: false, reason, detail }; +} diff --git a/backend/src/features/battle-ledger/index.ts b/backend/src/features/battle-ledger/index.ts index 9aebd4f0..0629f573 100644 --- a/backend/src/features/battle-ledger/index.ts +++ b/backend/src/features/battle-ledger/index.ts @@ -1,3 +1,20 @@ +export { deleteDefenseAuthorizations, postDefenseAuthorization } from './consent.controller'; +export { + type AuthorizationRejection, + type ConsentFailure, + type ConsentResult, + consumeDailyBudget, + type CoverageRequest, + type DefenseAuthorizationWire, + epochDay, + findCoveringAuthorization, + revokeDefenseAuthorizations, + type SubmitAuthorizationRequest, + type SubmitAuthorizationResult, + submitDefenseAuthorization, + toProtocolAuthorization, + verifyAuthorizationSignature, +} from './consent.service'; export { assertServedDomain, servedChainIds, servedDeploymentId, servedDomain } from './domain'; export { postBattleIntent } from './intent.controller'; export { diff --git a/backend/src/routes/battle.ts b/backend/src/routes/battle.ts index 51fae934..2e987aa4 100644 --- a/backend/src/routes/battle.ts +++ b/backend/src/routes/battle.ts @@ -1,6 +1,6 @@ import express, { Router } from 'express'; -import { postBattleIntent } from '@features/battle-ledger'; +import { deleteDefenseAuthorizations, postBattleIntent, postDefenseAuthorization } from '@features/battle-ledger'; import { verifyToken } from '@middleware/auth'; import { battleRoomRateLimit } from '@middleware/rateLimit'; @@ -11,4 +11,9 @@ const router: Router = express.Router(); // than per IP, which is what makes it a per-wallet submission limit (threat T5). router.post('/intents', verifyToken, battleRoomRateLimit, postBattleIntent); +// 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', verifyToken, battleRoomRateLimit, postDefenseAuthorization); +router.delete('/authorizations', verifyToken, deleteDefenseAuthorizations); + export default router; diff --git a/backend/tests/features/battle-ledger/consent.service.test.ts b/backend/tests/features/battle-ledger/consent.service.test.ts new file mode 100644 index 00000000..3c5ce136 --- /dev/null +++ b/backend/tests/features/battle-ledger/consent.service.test.ts @@ -0,0 +1,352 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ethers } from 'ethers'; + +import { defenseAuthorizationTypedData, hashDefenseAuthorization } from '@cryptopets/protocol'; + +vi.mock('@config/prisma', () => ({ + prisma: { + defenseAuthorization: { create: vi.fn(), findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn() }, + defenseUsage: { updateMany: vi.fn(), create: vi.fn(), findUnique: vi.fn() }, + }, +})); + +vi.mock('@config/env', () => ({ + env: { + battle: { deploymentId: 'base-sepolia-live', chainIds: ['eip155:84532', 'solana:devnet'] }, + }, +})); + +import { prisma } from '@config/prisma'; +import { + consumeDailyBudget, + epochDay, + findCoveringAuthorization, + revokeDefenseAuthorizations, + submitDefenseAuthorization, + toProtocolAuthorization, +} from '@features/battle-ledger'; + +const wallet = new ethers.Wallet('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'); +const DEFENDER = wallet.address.toLowerCase(); +const RULESET = `0x${'ab'.repeat(32)}`; +const NOW = 1893456000; + +const wire = { + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + defenderOwner: DEFENDER, + allPets: true, + petIds: [] as string[], + rulesetHash: RULESET, + minLevel: 5, + maxLevel: 15, + maxBattlesPerDay: 20, + notBefore: NOW - 3600, + expiresAt: NOW + 86400, + revocationNonce: 0, +}; + +async function sign(overrides: Partial = {}): Promise { + const typed = defenseAuthorizationTypedData(toProtocolAuthorization({ ...wire, ...overrides })); + return wallet.signTypedData(typed.domain, typed.types as never, typed.message); +} + +async function submit(overrides: Partial = {}, extras: { signature?: string; wallet?: string; now?: number } = {}) { + return submitDefenseAuthorization({ + authorization: { ...wire, ...overrides }, + signature: extras.signature ?? (await sign(overrides)), + signatureFormat: 'eip712', + authenticatedWallet: extras.wallet ?? DEFENDER, + nowSeconds: extras.now ?? NOW, + }); +} + +/** A stored row, as `findCoveringAuthorization` reads it back. */ +function row(overrides: Partial> = {}) { + return { + authorizationHash: `0x${'11'.repeat(32)}`, + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + defenderOwner: DEFENDER, + allPets: true, + petIds: [], + rulesetHash: RULESET, + minLevel: 5, + maxLevel: 15, + maxBattlesPerDay: 20, + notBefore: BigInt(NOW - 3600), + expiresAt: BigInt(NOW + 86400), + revocationNonce: 0, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.defenseAuthorization.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.defenseAuthorization.create).mockResolvedValue({} as never); +}); + +describe('submitting an authorization', () => { + it('records it with the signature and scope', async () => { + const result = await submit(); + + expect(result).toEqual({ + ok: true, + authorizationHash: hashDefenseAuthorization(toProtocolAuthorization(wire)), + }); + const data = vi.mocked(prisma.defenseAuthorization.create).mock.calls[0]![0].data as Record; + expect(data).toMatchObject({ + defenderOwner: DEFENDER, + allPets: true, + petIds: [], + rulesetHash: RULESET, + minLevel: 5, + maxLevel: 15, + maxBattlesPerDay: 20, + revocationNonce: 0, + }); + expect(data.expiresAt).toBe(BigInt(wire.expiresAt)); + }); + + it('stores an explicit pet scope as strings', async () => { + await submit({ allPets: false, petIds: ['7', '9'] }); + const data = vi.mocked(prisma.defenseAuthorization.create).mock.calls[0]![0].data as Record; + expect(data.allPets).toBe(false); + expect(data.petIds).toEqual(['7', '9']); + }); + + it('rejects a signature from another wallet', async () => { + const other = new ethers.Wallet('0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'); + const typed = defenseAuthorizationTypedData(toProtocolAuthorization(wire)); + const signature = await other.signTypedData(typed.domain, typed.types as never, typed.message); + expect(await submit({}, { signature })).toMatchObject({ ok: false, reason: 'bad-signature' }); + }); + + it('rejects a signature over looser terms than the ones submitted', async () => { + // Signing a level band of 5-15 must not authorize 1-99. + const signature = await sign({ minLevel: 1, maxLevel: 99 }); + expect(await submit({}, { signature })).toMatchObject({ ok: false, reason: 'bad-signature' }); + }); + + it('rejects a caller consenting on behalf of another wallet', async () => { + expect(await submit({}, { wallet: '0x2222222222222222222222222222222222222222' })).toMatchObject({ + ok: false, + reason: 'wallet-mismatch', + }); + }); + + it('rejects another deployment', async () => { + expect(await submit({ deploymentId: 'base-sepolia-staging' })).toMatchObject({ + ok: false, + reason: 'wrong-deployment', + }); + }); + + it('rejects an already-expired authorization', async () => { + // Storing a dead grant would only produce confusing coverage failures later. + expect(await submit({}, { now: wire.expiresAt })).toMatchObject({ ok: false, reason: 'already-expired' }); + }); + + it('rejects a malformed authorization', async () => { + expect(await submit({ maxBattlesPerDay: 0 }, { signature: '0x00' })).toMatchObject({ + ok: false, + reason: 'malformed-authorization', + }); + }); + + it('reports a duplicate authorization', async () => { + vi.mocked(prisma.defenseAuthorization.create).mockRejectedValue( + Object.assign(new Error('unique'), { code: 'P2002' }), + ); + expect(await submit()).toMatchObject({ ok: false, reason: 'duplicate-authorization' }); + }); +}); + +describe('revocation nonce monotonicity', () => { + it('rejects a nonce below the owner current one', async () => { + // Revocation works by bumping the nonce, so accepting a lower one would reinstate a + // grant the owner already withdrew. + vi.mocked(prisma.defenseAuthorization.findFirst).mockResolvedValue({ revocationNonce: 3 } as never); + expect(await submit({ revocationNonce: 2 })).toMatchObject({ ok: false, reason: 'stale-revocation-nonce' }); + }); + + it('accepts the same nonce, so a second grant at the current level is allowed', async () => { + vi.mocked(prisma.defenseAuthorization.findFirst).mockResolvedValue({ revocationNonce: 3 } as never); + expect((await submit({ revocationNonce: 3 })).ok).toBe(true); + }); + + it('accepts a higher nonce', async () => { + vi.mocked(prisma.defenseAuthorization.findFirst).mockResolvedValue({ revocationNonce: 3 } as never); + expect((await submit({ revocationNonce: 4 })).ok).toBe(true); + }); +}); + +describe('revokeDefenseAuthorizations', () => { + it('marks live rows revoked rather than deleting them', async () => { + // Receipts reference the hash, so a verifier must still see what was consented to. + vi.mocked(prisma.defenseAuthorization.updateMany).mockResolvedValue({ count: 2 } as never); + const revokedAt = new Date('2026-07-26T10:00:00.000Z'); + + expect(await revokeDefenseAuthorizations('eip155:84532', wallet.address, revokedAt)).toEqual({ revoked: 2 }); + const call = vi.mocked(prisma.defenseAuthorization.updateMany).mock.calls[0]![0]; + expect(call.where).toMatchObject({ + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + defenderOwner: DEFENDER, + revokedAt: null, + }); + expect(call.data).toEqual({ revokedAt }); + }); +}); + +describe('findCoveringAuthorization', () => { + const request = { + chainId: 'eip155:84532', + defenderOwner: DEFENDER, + defenderPetId: '2', + attackerLevel: 10, + rulesetHash: RULESET, + nowSeconds: NOW, + }; + + it('returns the authorization covering the battle', async () => { + vi.mocked(prisma.defenseAuthorization.findMany).mockResolvedValue([row()] as never); + expect(await findCoveringAuthorization(request)).toEqual({ + ok: true, + authorizationHash: `0x${'11'.repeat(32)}`, + maxBattlesPerDay: 20, + }); + }); + + it('only considers live grants for this ruleset', async () => { + vi.mocked(prisma.defenseAuthorization.findMany).mockResolvedValue([] as never); + await findCoveringAuthorization(request); + expect(vi.mocked(prisma.defenseAuthorization.findMany).mock.calls[0]![0]).toMatchObject({ + where: { + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + defenderOwner: DEFENDER, + revokedAt: null, + rulesetHash: RULESET, + }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('reports no-authorization when the defender has none', async () => { + vi.mocked(prisma.defenseAuthorization.findMany).mockResolvedValue([] as never); + expect(await findCoveringAuthorization(request)).toMatchObject({ ok: false, reason: 'no-authorization' }); + }); + + it('passes through the protocol coverage reason', async () => { + // Coverage is decided by the same function a third party runs against a receipt, so + // the operator cannot be more permissive than the published rule. + vi.mocked(prisma.defenseAuthorization.findMany).mockResolvedValue([row()] as never); + expect(await findCoveringAuthorization({ ...request, attackerLevel: 3 })).toMatchObject({ + ok: false, + reason: 'attacker-level-below-band', + }); + expect(await findCoveringAuthorization({ ...request, attackerLevel: 99 })).toMatchObject({ + ok: false, + reason: 'attacker-level-above-band', + }); + expect(await findCoveringAuthorization({ ...request, nowSeconds: NOW + 200000 })).toMatchObject({ + ok: false, + reason: 'expired', + }); + }); + + it('honours an explicit pet scope', async () => { + vi.mocked(prisma.defenseAuthorization.findMany).mockResolvedValue([ + row({ allPets: false, petIds: ['7'] }), + ] as never); + expect(await findCoveringAuthorization(request)).toMatchObject({ ok: false, reason: 'pet-not-covered' }); + expect((await findCoveringAuthorization({ ...request, defenderPetId: '7' })).ok).toBe(true); + }); + + it('prefers the most recently signed grant when several could apply', async () => { + // A player who tightens their terms expects the new terms to apply; picking the most + // permissive would make tightening them pointless. + vi.mocked(prisma.defenseAuthorization.findMany).mockResolvedValue([ + row({ authorizationHash: `0x${'22'.repeat(32)}`, minLevel: 9, maxLevel: 11 }), + row({ authorizationHash: `0x${'33'.repeat(32)}`, minLevel: 1, maxLevel: 99 }), + ] as never); + expect(await findCoveringAuthorization(request)).toMatchObject({ + ok: true, + authorizationHash: `0x${'22'.repeat(32)}`, + }); + }); + + it('falls through to a later grant when the newest does not cover', async () => { + vi.mocked(prisma.defenseAuthorization.findMany).mockResolvedValue([ + row({ authorizationHash: `0x${'22'.repeat(32)}`, minLevel: 20, maxLevel: 30 }), + row({ authorizationHash: `0x${'33'.repeat(32)}`, minLevel: 1, maxLevel: 99 }), + ] as never); + expect(await findCoveringAuthorization(request)).toMatchObject({ + ok: true, + authorizationHash: `0x${'33'.repeat(32)}`, + }); + }); +}); + +describe('epochDay', () => { + it('buckets by UTC day, with no timezone in sight', () => { + expect(epochDay(0)).toBe(0); + expect(epochDay(86399)).toBe(0); + expect(epochDay(86400)).toBe(1); + expect(epochDay(NOW)).toBe(Math.floor(NOW / 86400)); + }); +}); + +describe('consumeDailyBudget', () => { + it('increments an existing row under the cap', async () => { + vi.mocked(prisma.defenseUsage.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.defenseUsage.findUnique).mockResolvedValue({ count: 4 } as never); + + expect(await consumeDailyBudget('0xabc', 20, NOW)).toEqual({ ok: true, used: 4 }); + // The cap lives in the WHERE clause, so two concurrent battles cannot both read + // "one left" and both take it. + expect(vi.mocked(prisma.defenseUsage.updateMany).mock.calls[0]![0].where).toMatchObject({ + authorizationHash: '0xabc', + dayBucket: epochDay(NOW), + count: { lt: 20 }, + }); + }); + + it('creates today row on the first battle of the day', async () => { + vi.mocked(prisma.defenseUsage.updateMany).mockResolvedValue({ count: 0 } as never); + vi.mocked(prisma.defenseUsage.create).mockResolvedValue({} as never); + + expect(await consumeDailyBudget('0xabc', 20, NOW)).toEqual({ ok: true, used: 1 }); + }); + + it('retries the guarded update when another request created the row first', async () => { + vi.mocked(prisma.defenseUsage.updateMany) + .mockResolvedValueOnce({ count: 0 } as never) + .mockResolvedValueOnce({ count: 1 } as never); + vi.mocked(prisma.defenseUsage.create).mockRejectedValue( + Object.assign(new Error('unique'), { code: 'P2002' }), + ); + vi.mocked(prisma.defenseUsage.findUnique).mockResolvedValue({ count: 2 } as never); + + expect(await consumeDailyBudget('0xabc', 20, NOW)).toEqual({ ok: true, used: 2 }); + expect(prisma.defenseUsage.updateMany).toHaveBeenCalledTimes(2); + }); + + it('refuses once the cap is reached', async () => { + vi.mocked(prisma.defenseUsage.updateMany).mockResolvedValue({ count: 0 } as never); + vi.mocked(prisma.defenseUsage.create).mockRejectedValue( + Object.assign(new Error('unique'), { code: 'P2002' }), + ); + + expect(await consumeDailyBudget('0xabc', 20, NOW)).toEqual({ ok: false, reason: 'daily-cap-reached' }); + }); + + it('rethrows an unexpected database error', async () => { + vi.mocked(prisma.defenseUsage.updateMany).mockResolvedValue({ count: 0 } as never); + vi.mocked(prisma.defenseUsage.create).mockRejectedValue(new Error('connection reset')); + await expect(consumeDailyBudget('0xabc', 20, NOW)).rejects.toThrow(/connection reset/); + }); +}); From 8a4ad30e74b62b4f800a1a955da050cf2d96b091 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 10:41:25 -0400 Subject: [PATCH 22/76] feat(backend): add verified drand quicknet round client with same-round retry --- backend/env.example | 21 ++ backend/src/config/env.ts | 17 ++ .../battle-randomness/drand.client.ts | 279 +++++++++++++++++ .../src/features/battle-randomness/index.ts | 15 + .../battle-randomness/drand.client.test.ts | 283 ++++++++++++++++++ 5 files changed, 615 insertions(+) create mode 100644 backend/src/features/battle-randomness/drand.client.ts create mode 100644 backend/src/features/battle-randomness/index.ts create mode 100644 backend/tests/features/battle-randomness/drand.client.test.ts diff --git a/backend/env.example b/backend/env.example index 68dbb358..2860d65f 100644 --- a/backend/env.example +++ b/backend/env.example @@ -121,3 +121,24 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # KEEPER_SOLANA_PROGRAM_ID=EVzXwxHqwbTLMxfTG3amCb2Sjwmy5A7hqR59GbrvEyV1 # How often to poll for pending battle requests, in ms. Default: 5000. # KEEPER_SOLANA_POLL_INTERVAL_MS=5000 + +# --- Backend-authoritative battles (docs/plan-backend-battle-architecture.md) --- +# Which chain and deployment this process serves. Every wallet-signed object (battle +# intents, defence authorizations) binds both, and the server refuses payloads naming a +# different one, so a signature captured from staging is not a valid production signature. +# +# BATTLE_DEPLOYMENT_ID *must* differ between environments. The default is a local-only +# value, so a production deployment that forgets to set it rejects everything rather than +# silently sharing staging's identity. +# BATTLE_DEPLOYMENT_ID=base-sepolia-live +# Comma-separated protocol chain ids. Default: eip155:31337,solana:localnet +# BATTLE_CHAIN_IDS=eip155:84532,solana:devnet + +# drand quicknet endpoints, tried in order. Several by default because a battle waiting on +# its committed round cannot be moved to a different round if one endpoint is down: the +# only options are to keep trying or to forfeit, so redundancy here directly reduces +# forfeits. Every response is BLS verified against the pinned quicknet key regardless of +# which endpoint answered, so an untrustworthy mirror can only fail, never lie. +# BATTLE_DRAND_URLS=https://api.drand.sh,https://api2.drand.sh,https://api3.drand.sh +# Per-request timeout in ms. Default: 4000. +# BATTLE_DRAND_TIMEOUT_MS=4000 diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 3b66a80b..415caab5 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -149,5 +149,22 @@ export const env = { .split(',') .map((id) => id.trim()) .filter((id) => id.length > 0), + /** + * drand quicknet endpoints, tried in order. + * + * Several by default because a battle waiting on a committed round cannot be moved to + * a different round if one endpoint is down (§E): the only options are to keep trying + * or to forfeit, so redundancy here directly reduces forfeits. Every response is BLS + * verified against the pinned key regardless of which endpoint answered, so an + * untrustworthy mirror cannot do worse than fail. + */ + drandUrls: ( + process.env.BATTLE_DRAND_URLS?.trim() || + 'https://api.drand.sh,https://api2.drand.sh,https://api3.drand.sh' + ) + .split(',') + .map((url) => url.trim().replace(/\/$/, '')) + .filter((url) => url.length > 0), + drandTimeoutMs: Number(process.env.BATTLE_DRAND_TIMEOUT_MS?.trim() || '4000'), }, } as const; diff --git a/backend/src/features/battle-randomness/drand.client.ts b/backend/src/features/battle-randomness/drand.client.ts new file mode 100644 index 00000000..de95fd24 --- /dev/null +++ b/backend/src/features/battle-randomness/drand.client.ts @@ -0,0 +1,279 @@ +import { + assertVerifiedBeacon, + COMMITMENT_OFFSET_ROUNDS, + latestRoundAt, + QUICKNET, + roundTime, + type VerifiedBeacon, +} from '@cryptopets/protocol'; + +import { env } from '@config/env'; + +/** + * drand quicknet client (§E). + * + * Two rules shape this module, and both are about what it must refuse to do. + * + * **It never substitutes a round.** Settlement asks for one specific round, by number, and + * keeps asking. An endpoint that is down, slow, or lying can delay a battle or force a + * forfeit; it can never move the battle onto a round whose value is already known, which is + * the reroll this whole design exists to prevent. There is deliberately no code path from + * "round R failed to fetch" to "use round R+1". + * + * **It never trusts a response.** Every beacon is BLS verified against the pinned quicknet + * public key in `@cryptopets/protocol` before it is returned or cached, so a hostile mirror + * gets no further than an unverified reply we discard. + */ + +/** Timing and failure counts for the §J drand alerts. */ +export interface DrandMetrics { + /** Rounds served from the in-memory cache. */ + cacheHits: number; + /** Successful verified fetches. */ + fetches: number; + /** Responses that failed BLS verification. Non-zero here is an incident, not noise. */ + verificationFailures: number; + /** Transport or status failures, per endpoint. */ + transportFailures: number; + /** Seconds between a round publishing and our first verified read of it. */ + lastFetchDelaySeconds: number | null; + maxFetchDelaySeconds: number; +} + +const metrics: DrandMetrics = { + cacheHits: 0, + fetches: 0, + verificationFailures: 0, + transportFailures: 0, + lastFetchDelaySeconds: null, + maxFetchDelaySeconds: 0, +}; + +/** + * Verified rounds, cached forever. + * + * A drand round is immutable: round 12345 has exactly one value for all time. So there is no + * staleness to reason about, and a cached round needs no expiry. The cache is bounded by + * eviction of the oldest entries rather than by time. + */ +const cache = new Map(); +const MAX_CACHED_ROUNDS = 5000; + +/** The transport, injectable so tests never touch the network. */ +export type DrandTransport = (url: string, timeoutMs: number) => Promise; + +export interface DrandResponse { + status: number; + body: unknown; +} + +let transport: DrandTransport = httpTransport; + +/** Replaces the transport. Tests only. */ +export function setDrandTransport(next: DrandTransport): void { + transport = next; +} + +/** Restores the real HTTP transport. */ +export function resetDrandTransport(): void { + transport = httpTransport; +} + +/** Clears the round cache and metrics. Tests only. */ +export function resetDrandCache(): void { + cache.clear(); + metrics.cacheHits = 0; + metrics.fetches = 0; + metrics.verificationFailures = 0; + metrics.transportFailures = 0; + metrics.lastFetchDelaySeconds = null; + metrics.maxFetchDelaySeconds = 0; +} + +export function drandMetrics(): DrandMetrics { + return { ...metrics }; +} + +/** Whether a round is due to have published by `nowSeconds`. */ +export function isRoundDue(round: number, nowSeconds: number): boolean { + return nowSeconds >= roundTime(QUICKNET, round); +} + +/** When a round publishes, as a Date, for scheduling an outbox retry. */ +export function roundPublishTime(round: number): Date { + return new Date(roundTime(QUICKNET, round) * 1000); +} + +export type FetchOutcome = + | { status: 'verified'; beacon: VerifiedBeacon } + /** Not published yet. Retry the same round later; never move on. */ + | { status: 'not-yet-published' } + /** Every endpoint failed. Also retry the same round. */ + | { status: 'unavailable'; detail: string }; + +/** + * Fetches one specific round and verifies it. + * + * Note the return shape: no variant of it offers a different round. A caller handling + * `not-yet-published` or `unavailable` has nothing to reach for except retrying the same + * number, which is the property §E requires and the reason this is a union rather than a + * throw-or-value. + */ +export async function fetchVerifiedRound(round: number, nowSeconds: number): Promise { + const cached = cache.get(round); + if (cached) { + metrics.cacheHits += 1; + return { status: 'verified', beacon: cached }; + } + + if (!isRoundDue(round, nowSeconds)) { + return { status: 'not-yet-published' }; + } + + const failures: string[] = []; + for (const baseUrl of env.battle.drandUrls) { + const url = `${baseUrl}/v2/beacons/quicknet/rounds/${round}`; + let response: DrandResponse; + try { + response = await transport(url, env.battle.drandTimeoutMs); + } catch (error) { + metrics.transportFailures += 1; + failures.push(`${baseUrl}: ${(error as Error).message}`); + continue; + } + if (response.status === 404) { + // Published time has passed but the endpoint has not caught up. Still the same + // round; still just wait. + failures.push(`${baseUrl}: 404`); + continue; + } + if (response.status !== 200) { + metrics.transportFailures += 1; + failures.push(`${baseUrl}: HTTP ${response.status}`); + continue; + } + + const parsed = parseRoundBody(response.body); + if (!parsed || parsed.round !== round) { + // An endpoint answering with a different round is answering a question we did not + // ask, which is exactly the substitution to refuse. + metrics.verificationFailures += 1; + failures.push(`${baseUrl}: response was for round ${parsed?.round ?? 'unknown'}`); + continue; + } + + let beacon: VerifiedBeacon; + try { + beacon = assertVerifiedBeacon(QUICKNET, { round, signature: parsed.signature }); + } catch (error) { + metrics.verificationFailures += 1; + failures.push(`${baseUrl}: ${(error as Error).message}`); + continue; + } + + remember(round, beacon); + recordDelay(round, nowSeconds); + metrics.fetches += 1; + return { status: 'verified', beacon }; + } + + return { status: 'unavailable', detail: failures.join('; ') }; +} + +/** + * The round a new commitment must name: the latest verified round plus the fixed offset. + * + * Fails rather than falling back to a clock-derived round. A clock running behind would make + * `latestRoundAt(now) + 2` land on a round that has already published, which is precisely the + * situation commit-before-reveal forbids. Refusing to accept battles while drand is + * unreachable is the safe direction: players wait, and no commitment is ever made to a value + * somebody could already have seen. + */ +export async function chooseCommitmentRound( + nowSeconds: number, +): Promise<{ ok: true; round: number; latestVerified: number } | { ok: false; detail: string }> { + const latest = await fetchLatestVerifiedRound(); + if (!latest.ok) { + return { ok: false, detail: latest.detail }; + } + // Also guard against a local clock ahead of the beacon: take whichever base is later, so + // the chosen round is in the future by both measures. + const clockBase = latestRoundAt(QUICKNET, nowSeconds); + const base = Math.max(latest.round, clockBase); + return { ok: true, round: base + COMMITMENT_OFFSET_ROUNDS, latestVerified: latest.round }; +} + +/** Fetches and verifies the newest round, used only for choosing a commitment round. */ +export async function fetchLatestVerifiedRound(): Promise< + { ok: true; round: number } | { ok: false; detail: string } +> { + const failures: string[] = []; + for (const baseUrl of env.battle.drandUrls) { + const url = `${baseUrl}/v2/beacons/quicknet/rounds/latest`; + try { + const response = await transport(url, env.battle.drandTimeoutMs); + if (response.status !== 200) { + metrics.transportFailures += 1; + failures.push(`${baseUrl}: HTTP ${response.status}`); + continue; + } + const parsed = parseRoundBody(response.body); + if (!parsed) { + metrics.verificationFailures += 1; + failures.push(`${baseUrl}: unparseable response`); + continue; + } + // Verified even though it is only used to pick a future round: an unverified + // "latest" from a hostile mirror could otherwise steer the choice. + const beacon = assertVerifiedBeacon(QUICKNET, { round: parsed.round, signature: parsed.signature }); + remember(beacon.round, beacon); + metrics.fetches += 1; + return { ok: true, round: beacon.round }; + } catch (error) { + metrics.transportFailures += 1; + failures.push(`${baseUrl}: ${(error as Error).message}`); + } + } + return { ok: false, detail: failures.join('; ') }; +} + +function remember(round: number, beacon: VerifiedBeacon): void { + cache.set(round, beacon); + if (cache.size > MAX_CACHED_ROUNDS) { + const oldest = cache.keys().next(); + if (!oldest.done) { + cache.delete(oldest.value); + } + } +} + +function recordDelay(round: number, nowSeconds: number): void { + const delay = Math.max(0, nowSeconds - roundTime(QUICKNET, round)); + metrics.lastFetchDelaySeconds = delay; + metrics.maxFetchDelaySeconds = Math.max(metrics.maxFetchDelaySeconds, delay); +} + +/** drand v2 returns `{ round, signature }`; anything else is unusable. */ +function parseRoundBody(body: unknown): { round: number; signature: `0x${string}` } | null { + if (typeof body !== 'object' || body === null) { + return null; + } + const record = body as { round?: unknown; signature?: unknown }; + if (typeof record.round !== 'number' || typeof record.signature !== 'string') { + return null; + } + const signature = record.signature.startsWith('0x') ? record.signature : `0x${record.signature}`; + return { round: record.round, signature: signature as `0x${string}` }; +} + +async function httpTransport(url: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal }); + const body = response.status === 200 ? await response.json() : null; + return { status: response.status, body }; + } finally { + clearTimeout(timer); + } +} diff --git a/backend/src/features/battle-randomness/index.ts b/backend/src/features/battle-randomness/index.ts new file mode 100644 index 00000000..7a0c766e --- /dev/null +++ b/backend/src/features/battle-randomness/index.ts @@ -0,0 +1,15 @@ +export { + chooseCommitmentRound, + type DrandMetrics, + drandMetrics, + type DrandResponse, + type DrandTransport, + type FetchOutcome, + fetchLatestVerifiedRound, + fetchVerifiedRound, + isRoundDue, + resetDrandCache, + resetDrandTransport, + roundPublishTime, + setDrandTransport, +} from './drand.client'; diff --git a/backend/tests/features/battle-randomness/drand.client.test.ts b/backend/tests/features/battle-randomness/drand.client.test.ts new file mode 100644 index 00000000..fd00e286 --- /dev/null +++ b/backend/tests/features/battle-randomness/drand.client.test.ts @@ -0,0 +1,283 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { COMMITMENT_OFFSET_ROUNDS, QUICKNET, roundTime } from '@cryptopets/protocol'; + +vi.mock('@config/env', () => ({ + env: { + battle: { + deploymentId: 'base-sepolia-live', + chainIds: ['eip155:84532'], + drandUrls: ['https://primary.example', 'https://secondary.example'], + drandTimeoutMs: 1000, + }, + }, +})); + +import { + chooseCommitmentRound, + type DrandResponse, + drandMetrics, + fetchVerifiedRound, + isRoundDue, + resetDrandCache, + resetDrandTransport, + roundPublishTime, + setDrandTransport, +} from '@features/battle-randomness'; + +/** + * Real quicknet beacons, from the same fixtures the protocol tests use. The whole point of + * this client is that it verifies what an endpoint returns, so serving it synthetic + * signatures would test the plumbing while skipping the check. + */ +interface Fixture { + quicknet: { rounds: { round: number; signature: string; randomness: string }[] }; +} +const here = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')); +const fixture = JSON.parse( + readFileSync(join(here, '../../../../protocol/tests/fixtures/drand.json'), 'utf8'), +) as Fixture; + +const ROUND_1000 = fixture.quicknet.rounds.find((r) => r.round === 1000)!; +const ROUND_21M = fixture.quicknet.rounds.find((r) => r.round === 21000000)!; +const PUBLISHED_1000 = roundTime(QUICKNET, ROUND_1000.round); + +/** A transport that answers from a per-URL script. */ +function scriptedTransport(handlers: Record Promise>) { + const calls: string[] = []; + const transport = async (url: string): Promise => { + calls.push(url); + const host = new URL(url).origin; + const handler = handlers[host]; + if (!handler) { + throw new Error(`no handler for ${host}`); + } + return handler(url); + }; + return { transport, calls }; +} + +const ok = (round: { round: number; signature: string }) => async (): Promise => ({ + status: 200, + body: { round: round.round, signature: round.signature.replace(/^0x/, '') }, +}); + +beforeEach(() => { + resetDrandCache(); +}); + +afterEach(() => { + resetDrandTransport(); +}); + +describe('fetching a specific round', () => { + it('verifies a real beacon and returns its randomness', async () => { + const { transport } = scriptedTransport({ 'https://primary.example': ok(ROUND_1000) }); + setDrandTransport(transport); + + const outcome = await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 + 1); + + expect(outcome.status).toBe('verified'); + if (outcome.status === 'verified') { + expect(outcome.beacon.round).toBe(ROUND_1000.round); + expect(outcome.beacon.randomness).toBe(`0x${ROUND_1000.randomness}`); + } + }); + + it('rejects a forged signature rather than caching it', async () => { + // A hostile mirror gets no further than an unverified reply we discard. + const { transport } = scriptedTransport({ + 'https://primary.example': async () => ({ + status: 200, + // Round 21000000's real signature, offered as round 1000. + body: { round: ROUND_1000.round, signature: ROUND_21M.signature }, + }), + 'https://secondary.example': async () => ({ status: 500, body: null }), + }); + setDrandTransport(transport); + + const outcome = await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 + 1); + + expect(outcome.status).toBe('unavailable'); + expect(drandMetrics().verificationFailures).toBe(1); + }); + + it('refuses a response for a different round', async () => { + // An endpoint answering a question we did not ask is the substitution to refuse. + const { transport } = scriptedTransport({ + 'https://primary.example': ok(ROUND_21M), + 'https://secondary.example': async () => ({ status: 500, body: null }), + }); + setDrandTransport(transport); + + const outcome = await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 + 1); + expect(outcome.status).toBe('unavailable'); + }); + + it('reports not-yet-published without contacting any endpoint', async () => { + const { transport, calls } = scriptedTransport({}); + setDrandTransport(transport); + + expect(await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 - 1)).toEqual({ + status: 'not-yet-published', + }); + expect(calls).toEqual([]); + }); + + it('falls back to the next endpoint, then reports unavailable', async () => { + const { transport, calls } = scriptedTransport({ + 'https://primary.example': async () => { + throw new Error('connect ETIMEDOUT'); + }, + 'https://secondary.example': ok(ROUND_1000), + }); + setDrandTransport(transport); + + const outcome = await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 + 1); + expect(outcome.status).toBe('verified'); + expect(calls).toHaveLength(2); + expect(drandMetrics().transportFailures).toBe(1); + }); + + it('treats a 404 past publish time as still waiting, not as a reason to move on', async () => { + const { transport } = scriptedTransport({ + 'https://primary.example': async () => ({ status: 404, body: null }), + 'https://secondary.example': async () => ({ status: 404, body: null }), + }); + setDrandTransport(transport); + + const outcome = await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 + 1); + expect(outcome.status).toBe('unavailable'); + if (outcome.status === 'unavailable') { + expect(outcome.detail).toContain('404'); + } + }); + + it('offers no outcome that names a different round', async () => { + // The property §E requires, asserted structurally: a caller handling a failure has + // nothing to reach for except the same round number again. + const { transport } = scriptedTransport({ + 'https://primary.example': async () => ({ status: 500, body: null }), + 'https://secondary.example': async () => ({ status: 500, body: null }), + }); + setDrandTransport(transport); + + const outcome = await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 + 1); + expect(JSON.stringify(outcome)).not.toContain(String(ROUND_1000.round + 1)); + expect(Object.keys(outcome)).not.toContain('beacon'); + }); +}); + +describe('caching', () => { + it('serves a repeated round from cache without a second request', async () => { + // Rounds are immutable, so a cached round needs no expiry. + const { transport, calls } = scriptedTransport({ 'https://primary.example': ok(ROUND_1000) }); + setDrandTransport(transport); + + await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 + 1); + await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 + 9); + + expect(calls).toHaveLength(1); + expect(drandMetrics().cacheHits).toBe(1); + }); + + it('caches only what verified', async () => { + const { transport, calls } = scriptedTransport({ + 'https://primary.example': async () => ({ + status: 200, + body: { round: ROUND_1000.round, signature: ROUND_21M.signature }, + }), + 'https://secondary.example': async () => ({ status: 500, body: null }), + }); + setDrandTransport(transport); + + await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 + 1); + await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 + 1); + + expect(calls).toHaveLength(4); + expect(drandMetrics().cacheHits).toBe(0); + }); +}); + +describe('metrics', () => { + it('records how far behind publication the first read was', async () => { + const { transport } = scriptedTransport({ 'https://primary.example': ok(ROUND_1000) }); + setDrandTransport(transport); + + await fetchVerifiedRound(ROUND_1000.round, PUBLISHED_1000 + 4); + + expect(drandMetrics().lastFetchDelaySeconds).toBe(4); + expect(drandMetrics().maxFetchDelaySeconds).toBe(4); + }); +}); + +describe('choosing a commitment round', () => { + it('is the latest verified round plus the fixed offset', async () => { + const { transport } = scriptedTransport({ 'https://primary.example': ok(ROUND_1000) }); + setDrandTransport(transport); + + const chosen = await chooseCommitmentRound(PUBLISHED_1000 + 1); + + expect(chosen).toEqual({ + ok: true, + round: ROUND_1000.round + COMMITMENT_OFFSET_ROUNDS, + latestVerified: ROUND_1000.round, + }); + }); + + it('fails closed when drand is unreachable', async () => { + // A clock running behind would make a clock-derived round land in the past, which is + // exactly what commit-before-reveal forbids. Waiting is the safe direction. + const { transport } = scriptedTransport({ + 'https://primary.example': async () => { + throw new Error('offline'); + }, + 'https://secondary.example': async () => ({ status: 503, body: null }), + }); + setDrandTransport(transport); + + const chosen = await chooseCommitmentRound(PUBLISHED_1000 + 1); + expect(chosen.ok).toBe(false); + }); + + it('uses the clock when it is ahead of the endpoint, so the round is future by both', async () => { + const { transport } = scriptedTransport({ 'https://primary.example': ok(ROUND_1000) }); + setDrandTransport(transport); + + const muchLater = roundTime(QUICKNET, ROUND_1000.round + 100); + const chosen = await chooseCommitmentRound(muchLater); + + expect(chosen.ok).toBe(true); + if (chosen.ok) { + expect(chosen.round).toBe(ROUND_1000.round + 100 + COMMITMENT_OFFSET_ROUNDS); + } + }); + + it('always names a round that has not published yet', async () => { + const { transport } = scriptedTransport({ 'https://primary.example': ok(ROUND_1000) }); + setDrandTransport(transport); + + for (const offset of [0, 1, 2]) { + resetDrandCache(); + const now = PUBLISHED_1000 + offset; + const chosen = await chooseCommitmentRound(now); + expect(chosen.ok).toBe(true); + if (chosen.ok) { + expect(roundTime(QUICKNET, chosen.round)).toBeGreaterThan(now); + } + } + }); +}); + +describe('scheduling helpers', () => { + it('reports when a round is due', () => { + expect(isRoundDue(ROUND_1000.round, PUBLISHED_1000 - 1)).toBe(false); + expect(isRoundDue(ROUND_1000.round, PUBLISHED_1000)).toBe(true); + }); + + it('gives the publish time as a Date, for an outbox retry', () => { + expect(roundPublishTime(ROUND_1000.round)).toEqual(new Date(PUBLISHED_1000 * 1000)); + }); +}); From 7a3d64a2d604a38d1a67bd22ebdca968e358121e Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 10:52:58 -0400 Subject: [PATCH 23/76] feat(backend): add schema-restricted KMS signer for commitments and receipts --- backend/env.example | 20 + backend/src/config/env.ts | 24 ++ backend/src/features/battle-signer/index.ts | 22 ++ .../src/features/battle-signer/signer.kms.ts | 26 ++ .../features/battle-signer/signer.local.ts | 49 +++ .../features/battle-signer/signer.service.ts | 202 ++++++++++ .../features/battle-signer/signer.types.ts | 100 +++++ .../battle-signer/signer.service.test.ts | 363 ++++++++++++++++++ 8 files changed, 806 insertions(+) create mode 100644 backend/src/features/battle-signer/index.ts create mode 100644 backend/src/features/battle-signer/signer.kms.ts create mode 100644 backend/src/features/battle-signer/signer.local.ts create mode 100644 backend/src/features/battle-signer/signer.service.ts create mode 100644 backend/src/features/battle-signer/signer.types.ts create mode 100644 backend/tests/features/battle-signer/signer.service.test.ts diff --git a/backend/env.example b/backend/env.example index 2860d65f..66134110 100644 --- a/backend/env.example +++ b/backend/env.example @@ -142,3 +142,23 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # BATTLE_DRAND_URLS=https://api.drand.sh,https://api2.drand.sh,https://api3.drand.sh # Per-request timeout in ms. Default: 4000. # BATTLE_DRAND_TIMEOUT_MS=4000 + +# --- Battle signer (§G) --- +# The key that signs commitments and receipts. It is the one credential in this design that +# can produce a lie nobody could detect from outside, so it is deliberately narrow: the signer +# only ever signs a commitment or receipt digest it recomputed itself, and holds no asset or +# withdrawal authority. +# +# Production MUST use a KMS. BATTLE_SIGNER_PRIVATE_KEY is refused when NODE_ENV=production, so +# a deployment cannot quietly fall back to an in-process key; the process starts with signing +# disabled instead, and battle acceptance fails rather than proceeding unsigned. +# 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. +# BATTLE_SIGNER_KMS_PROVIDER=aws-kms +# 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 +# BATTLE_SIGNER_REQUIRED_ATTESTERS=typescript-engine,go-verifier diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 415caab5..39c7d414 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -167,4 +167,28 @@ export const env = { .filter((url) => url.length > 0), drandTimeoutMs: Number(process.env.BATTLE_DRAND_TIMEOUT_MS?.trim() || '4000'), }, + + /** + * The battle signer (§G). Separate from `battle` because these are credentials, and keeping + * them in their own block makes it obvious which config is sensitive. + * + * Production must use a KMS: `BATTLE_SIGNER_PRIVATE_KEY` is refused when NODE_ENV is + * 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. + */ + 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. */ + kmsProvider: process.env.BATTLE_SIGNER_KMS_PROVIDER?.trim() || undefined, + requiredAttesters: (process.env.BATTLE_SIGNER_REQUIRED_ATTESTERS?.trim() || 'typescript-engine') + .split(',') + .map((name) => name.trim()) + .filter((name) => name.length > 0), + }, } as const; diff --git a/backend/src/features/battle-signer/index.ts b/backend/src/features/battle-signer/index.ts new file mode 100644 index 00000000..0d8e74f5 --- /dev/null +++ b/backend/src/features/battle-signer/index.ts @@ -0,0 +1,22 @@ +export { createKmsSigner } from './signer.kms'; +export { createLocalSigner } from './signer.local'; +export { + activeSigningKey, + configureSigner, + listSigningKeys, + registerRotatedKey, + resetSigner, + sign, + signerAuditLog, +} from './signer.service'; +export { + type EngineAttestation, + type SignableKind, + type SignerAuditEntry, + type SignerBackend, + SignerRefusedError, + type SigningKeyDescriptor, + type SignRefusal, + type SignRequest, + type SignResult, +} from './signer.types'; diff --git a/backend/src/features/battle-signer/signer.kms.ts b/backend/src/features/battle-signer/signer.kms.ts new file mode 100644 index 00000000..64d1dace --- /dev/null +++ b/backend/src/features/battle-signer/signer.kms.ts @@ -0,0 +1,26 @@ +import type { SignerBackend } from './signer.types'; + +/** + * KMS-backed signer: not implemented yet, on purpose. + * + * §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. + * + * 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. + * + * 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. + */ +export function createKmsSigner(provider: string): SignerBackend { + throw new Error( + `KMS signer provider "${provider}" is not implemented. Production signing must use a managed ` + + 'KMS/HSM key restricted to commitment and receipt digests (docs/plan-backend-battle-architecture.md §G). ' + + 'Implement an adapter here rather than setting BATTLE_SIGNER_PRIVATE_KEY in production.', + ); +} diff --git a/backend/src/features/battle-signer/signer.local.ts b/backend/src/features/battle-signer/signer.local.ts new file mode 100644 index 00000000..03ade5cb --- /dev/null +++ b/backend/src/features/battle-signer/signer.local.ts @@ -0,0 +1,49 @@ +import type { Hex } from '@cryptopets/protocol'; +import { ethers } from 'ethers'; + +import type { SignerBackend, SigningKeyDescriptor } from './signer.types'; + +/** + * Local development signer: a plain secp256k1 key held in this process. + * + * Explicitly not the production story. §G requires the key to live in a managed KMS, out of + * the API and worker environments, precisely so that compromising a server does not + * compromise the key. This backend exists so local development and tests can exercise the + * signing path without a cloud dependency, and `createSignerBackend` refuses to select it + * when the environment is production. + */ +export function createLocalSigner(options: { + keyId: string; + privateKey: string; + notBefore: number; +}): SignerBackend { + const signingKey = new ethers.SigningKey(normalizePrivateKey(options.privateKey)); + const key: SigningKeyDescriptor = { + keyId: options.keyId, + algorithm: 'secp256k1', + publicKey: signingKey.publicKey as Hex, + address: ethers.computeAddress(signingKey.publicKey).toLowerCase() as Hex, + notBefore: options.notBefore, + notAfter: null, + status: 'active', + }; + + return { + key, + async sign(digest: Uint8Array): Promise { + if (digest.length !== 32) { + // A backend that signs arbitrary-length input is a general-purpose oracle. + throw new Error(`expected a 32-byte digest, got ${digest.length}`); + } + // Signs the digest as-is, with no EIP-191 prefix: what is signed must be exactly + // the canonical receipt or commitment hash, so an on-chain verifier can recompute + // it without knowing about a message-prefix convention. + return signingKey.sign(digest).serialized as Hex; + }, + }; +} + +function normalizePrivateKey(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith('0x') ? trimmed : `0x${trimmed}`; +} diff --git a/backend/src/features/battle-signer/signer.service.ts b/backend/src/features/battle-signer/signer.service.ts new file mode 100644 index 00000000..58295470 --- /dev/null +++ b/backend/src/features/battle-signer/signer.service.ts @@ -0,0 +1,202 @@ +import { + assertBattleCommitment, + assertBattleReceipt, + hashBattleCommitment, + hashBattleReceipt, + type Hex, + toBytes, +} from '@cryptopets/protocol'; + +import { env } from '@config/env'; + +import { createKmsSigner } from './signer.kms'; +import { createLocalSigner } from './signer.local'; +import { + type EngineAttestation, + type SignerAuditEntry, + type SignerBackend, + SignerRefusedError, + type SigningKeyDescriptor, + type SignRequest, + type SignResult, +} from './signer.types'; + +/** + * The signing service (§G). + * + * The digest is always recomputed here from the typed object. That is the load-bearing design + * choice: a caller cannot ask for a signature over bytes of its own choosing, so a compromised + * worker can at most get a signature over a *well-formed* commitment or receipt, which the + * rest of the system can then check. It cannot obtain a signature over anything else at all. + */ + +let backend: SignerBackend | null = null; +let backendError: string | null = null; +const rotatedKeys: SigningKeyDescriptor[] = []; +const auditLog: SignerAuditEntry[] = []; +const MAX_AUDIT_ENTRIES = 1000; + +/** + * Selects a backend from configuration. + * + * Refuses to use the in-process key in production. A key sitting in an environment variable on + * an API host is the thing §G's KMS requirement exists to prevent, and making that a startup + * 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; + backendError = null; + + const { keyId, privateKey, kmsProvider } = env.battleSigner; + + if (kmsProvider) { + try { + backend = createKmsSigner(kmsProvider); + } catch (error) { + backendError = (error as Error).message; + } + return; + } + + if (!privateKey) { + backendError = 'no signing backend configured (set BATTLE_SIGNER_KMS_PROVIDER, or a dev key locally)'; + return; + } + + if (env.isProduction) { + backendError = + 'refusing to use BATTLE_SIGNER_PRIVATE_KEY in production; the signing key must live in a KMS (§G)'; + return; + } + + backend = createLocalSigner({ keyId, privateKey, notBefore: nowSeconds }); +} + +/** Registers a key that is no longer signing but must stay published for verification. */ +export function registerRotatedKey(key: SigningKeyDescriptor): void { + rotatedKeys.push(key); +} + +/** The key currently signing, or null when the signer is unconfigured. */ +export function activeSigningKey(): SigningKeyDescriptor | null { + return backend?.key ?? null; +} + +/** + * Every key a verifier may need, active and retired. + * + * Retired keys are never dropped: a receipt signed under a rotated key must still verify, and + * removing the key would make that receipt unverifiable rather than invalid, which is a + * different and worse thing (§H item 4). + */ +export function listSigningKeys(): SigningKeyDescriptor[] { + const active = activeSigningKey(); + return active ? [active, ...rotatedKeys] : [...rotatedKeys]; +} + +/** The signer's own audit trail, newest last. Reconciled against the KMS log during an incident. */ +export function signerAuditLog(): SignerAuditEntry[] { + return [...auditLog]; +} + +/** Clears state. Tests only. */ +export function resetSigner(): void { + backend = null; + backendError = null; + rotatedKeys.length = 0; + auditLog.length = 0; +} + +/** + * Signs a commitment or a receipt. + * + * Receipts additionally require an attestation from every configured attester, all naming the + * same receipt hash. That is §F's circuit breaker expressed as a precondition: if the engine + * and the independent verifier have not both agreed, there is no way to obtain a signature, + * 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; + try { + digest = + request.kind === 'commitment' + ? hashBattleCommitment(assertBattleCommitment(request.commitment)) + : hashBattleReceipt(assertBattleReceipt(request.receipt)); + } 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); + } + + if (request.kind === 'receipt') { + const problem = checkAttestations(request.attestations, digest, nowSeconds); + if (problem) { + return refuse(problem.reason, problem.detail, nowSeconds); + } + } + + const signature = await backend.sign(toBytes(digest)); + record({ + at: nowSeconds, + kind: request.kind, + keyId: backend.key.keyId, + digest, + outcome: 'signed', + }); + return { kind: request.kind, digest, signature, keyId: backend.key.keyId }; +} + +/** + * Checks that every required attester has vouched for exactly this receipt. + * + * Matching on the receipt hash rather than on a battle id matters: an attestation for an + * earlier version of the same battle's receipt must not carry over, since the point of the + * attestation is that a specific set of bytes was recomputed and agreed with. + */ +function checkAttestations( + attestations: readonly EngineAttestation[], + digest: Hex, + nowSeconds: number, +): { reason: 'missing-attestation' | 'attestation-mismatch' | 'stale-attestation'; detail: string } | null { + const required = env.battleSigner.requiredAttesters; + for (const attester of required) { + const match = attestations.find((a) => a.attester === attester); + if (!match) { + return { + reason: 'missing-attestation', + detail: `no attestation from ${attester} (required: ${required.join(', ')})`, + }; + } + if (match.receiptHash.toLowerCase() !== digest.toLowerCase()) { + return { + reason: 'attestation-mismatch', + detail: `${attester} attested to ${match.receiptHash}, but this receipt hashes to ${digest}`, + }; + } + if (match.attestedAt > nowSeconds + 60) { + return { reason: 'stale-attestation', detail: `${attester} attestation is dated in the future` }; + } + } + return null; +} + +function refuse( + reason: SignerRefusedError['reason'], + detail: string, + nowSeconds: number, +): never { + record({ at: nowSeconds, kind: 'refused', keyId: null, digest: null, outcome: 'refused', detail }); + throw new SignerRefusedError(reason, detail); +} + +function record(entry: SignerAuditEntry): void { + auditLog.push(entry); + if (auditLog.length > MAX_AUDIT_ENTRIES) { + auditLog.shift(); + } +} diff --git a/backend/src/features/battle-signer/signer.types.ts b/backend/src/features/battle-signer/signer.types.ts new file mode 100644 index 00000000..8a734838 --- /dev/null +++ b/backend/src/features/battle-signer/signer.types.ts @@ -0,0 +1,100 @@ +import type { BattleCommitment, BattleReceipt, Hex } from '@cryptopets/protocol'; + +/** + * The signer's shape, and the reason it is shaped this way (§G). + * + * The signing key is the one credential in this design that can produce a lie nobody can + * detect from the outside. So the signer is built to be narrow rather than convenient: + * + * - **It never accepts a digest.** Callers pass a typed commitment or receipt, and the signer + * re-encodes and hashes it itself. A signer that accepts arbitrary 32 bytes is a signing + * oracle, and a stolen credential for one is worth as much as the key itself. + * - **It signs exactly two kinds of object.** There is no generic state-mutation path, so + * there is nothing to widen later without noticing. + * - **Receipts require attestations.** The engine and the independent verifier must both have + * agreed before a result becomes signed history. + * - **Every request is logged with its digest and key id**, so a KMS audit log can be + * reconciled against what the pipeline believes it asked for. Unmatched digests are how a + * key compromise is spotted at all. + */ + +/** The only two objects this signer will sign. */ +export type SignableKind = 'commitment' | 'receipt'; + +/** One implementation's claim that it computed a result. */ +export interface EngineAttestation { + /** Who computed it, e.g. `typescript-engine` or `go-verifier`. */ + attester: string; + /** Digest of the receipt the attester agrees with. */ + receiptHash: Hex; + /** When the attestation was produced, unix seconds. */ + attestedAt: number; +} + +/** A key the signer can use, as published for verification. */ +export interface SigningKeyDescriptor { + keyId: string; + /** Only secp256k1 for now: it keeps on-chain verification of a receipt possible later. */ + algorithm: 'secp256k1'; + /** Uncompressed public key, 0x-hex. */ + publicKey: Hex; + /** EVM address form, convenient for on-chain checks. */ + address: Hex; + /** Unix seconds this key became valid. */ + notBefore: number; + /** Unix seconds it stopped being used, or null while active. */ + notAfter: number | null; + /** + * `rotated` and `compromised` keys stay published: historical receipts still verify + * against them, and removing one would make its receipts unverifiable rather than invalid. + */ + status: 'active' | 'rotated' | 'compromised'; +} + +/** What a backend must provide. Deliberately just "sign this digest with this key". */ +export interface SignerBackend { + readonly key: SigningKeyDescriptor; + /** Signs a 32-byte digest. Returns a 0x-hex signature. */ + sign(digest: Uint8Array): Promise; +} + +/** A signing request, as the pipeline makes it. */ +export type SignRequest = + | { kind: 'commitment'; commitment: BattleCommitment } + | { kind: 'receipt'; receipt: BattleReceipt; attestations: readonly EngineAttestation[] }; + +export interface SignResult { + kind: SignableKind; + /** What was signed, recomputed by the signer rather than supplied. */ + digest: Hex; + signature: Hex; + keyId: string; +} + +/** Why a signing request was refused. */ +export type SignRefusal = + | 'signer-not-configured' + | 'invalid-payload' + | 'missing-attestation' + | 'attestation-mismatch' + | 'stale-attestation'; + +export class SignerRefusedError extends Error { + constructor( + readonly reason: SignRefusal, + detail: string, + ) { + super(`signer refused (${reason}): ${detail}`); + this.name = 'SignerRefusedError'; + } +} + +/** One line of the signer's own audit trail. */ +export interface SignerAuditEntry { + at: number; + kind: SignableKind | 'refused'; + keyId: string | null; + digest: Hex | null; + outcome: 'signed' | 'refused'; + detail?: string; +} diff --git a/backend/tests/features/battle-signer/signer.service.test.ts b/backend/tests/features/battle-signer/signer.service.test.ts new file mode 100644 index 00000000..e2b0ac87 --- /dev/null +++ b/backend/tests/features/battle-signer/signer.service.test.ts @@ -0,0 +1,363 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ethers } from 'ethers'; + +import { + type BattleCommitment, + type BattleReceipt, + commitmentRound, + computeProgression, + deriveBattleSeed, + hashBattleCommitment, + hashBattleReceipt, + hashBattleSnapshot, + hashCombatLog, + hashRuleset, + QUICKNET, + roundTime, + simulate, + SOURCE_DEFAULT_RULESET, + type BattleSnapshot, + type Hex, +} from '@cryptopets/protocol'; + +const DEV_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; + +// Hoisted, because vi.mock's factory runs before top-level statements. The tests mutate this +// to exercise production refusal and the attester list. +const envMock = vi.hoisted(() => ({ + isProduction: false, + battleSigner: { + keyId: 'battle-signer-test', + privateKey: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as string | undefined, + kmsProvider: undefined as string | undefined, + requiredAttesters: ['typescript-engine'] as string[], + }, +})); + +vi.mock('@config/env', () => ({ env: envMock })); + +import { + activeSigningKey, + configureSigner, + listSigningKeys, + registerRotatedKey, + resetSigner, + sign, + signerAuditLog, + SignerRefusedError, +} from '@features/battle-signer'; + +const NOW = roundTime(QUICKNET, 1000) + 1; +const DOMAIN = { chainId: 'eip155:84532' as const, deploymentId: 'base-sepolia-live' }; +const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); + +const SNAPSHOT: BattleSnapshot = { + domain: DOMAIN, + attacker: { + petId: 1n, + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: 1234567890123456n, + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: 0n, + streak: 0, + readyAt: NOW - 100, + sourceVersion: BigInt(NOW - 50), + }, + defender: { + petId: 2n, + owner: '0x2222222222222222222222222222222222222222', + dna: 6543210987654321n, + rarity: 2, + level: 11, + skill: 7, + xp: 45, + lastOpponentId: 1n, + streak: 2, + readyAt: NOW - 100, + sourceVersion: BigInt(NOW - 50), + }, + takenAt: NOW - 7, +}; + +const COMMITMENT: BattleCommitment = { + domain: DOMAIN, + battleId: 'btl_0001', + intentHash: `0x${'11'.repeat(32)}`, + defenseAuthorizationHash: `0x${'22'.repeat(32)}`, + snapshot: { ...SNAPSHOT, takenAt: roundTime(QUICKNET, 1000) - 1 }, + rulesetVersion: SOURCE_DEFAULT_RULESET.version, + rulesetHash: RULESET_HASH, + drandChainHash: QUICKNET.chainHash, + drandRound: commitmentRound(QUICKNET, roundTime(QUICKNET, 1000)), + acceptedAt: roundTime(QUICKNET, 1000), + previousCommitmentHash: null, + signingKeyId: 'battle-signer-test', +}; + +const BEACON = { + chainHash: QUICKNET.chainHash, + round: 1000, + signature: + '0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39' as Hex, + randomness: '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd' as Hex, +}; + +function buildReceipt(): BattleReceipt { + const seed = deriveBattleSeed({ + domain: DOMAIN, + drandRandomness: BEACON.randomness, + battleId: 'btl_0001', + snapshotHash: hashBattleSnapshot(SNAPSHOT), + rulesetHash: RULESET_HASH, + }); + const outcome = simulate( + SNAPSHOT.attacker.dna, + SNAPSHOT.attacker.rarity, + SNAPSHOT.attacker.level, + SNAPSHOT.attacker.skill, + SNAPSHOT.defender.dna, + SNAPSHOT.defender.rarity, + SNAPSHOT.defender.level, + SNAPSHOT.defender.skill, + seed.value, + SOURCE_DEFAULT_RULESET.skillConfig, + ); + return { + domain: DOMAIN, + battleId: 'btl_0001', + intentHash: `0x${'11'.repeat(32)}`, + commitmentHash: hashBattleCommitment(COMMITMENT), + defenseAuthorizationHash: `0x${'22'.repeat(32)}`, + snapshot: SNAPSHOT, + beacon: BEACON, + seed: seed.hex, + rulesetVersion: SOURCE_DEFAULT_RULESET.version, + rulesetHash: RULESET_HASH, + result: { + attackerWon: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + }, + combatLogHash: hashCombatLog(outcome), + progression: computeProgression(SNAPSHOT, outcome.result.firstWins), + sequence: 1, + previousReceiptHash: null, + attackerPreviousReceiptHash: null, + defenderPreviousReceiptHash: null, + createdAt: NOW, + signingKeyId: 'battle-signer-test', + }; +} + +const RECEIPT = buildReceipt(); +const RECEIPT_HASH = hashBattleReceipt(RECEIPT); +const goodAttestation = { attester: 'typescript-engine', receiptHash: RECEIPT_HASH, attestedAt: NOW }; + +beforeEach(() => { + resetSigner(); + envMock.isProduction = false; + envMock.battleSigner.privateKey = DEV_KEY; + envMock.battleSigner.kmsProvider = undefined; + envMock.battleSigner.requiredAttesters = ['typescript-engine']; + configureSigner(NOW); +}); + +describe('signing a commitment', () => { + it('signs the digest it computes itself', async () => { + const result = await sign({ kind: 'commitment', commitment: COMMITMENT }, NOW); + + expect(result.kind).toBe('commitment'); + expect(result.digest).toBe(hashBattleCommitment(COMMITMENT)); + expect(result.keyId).toBe('battle-signer-test'); + }); + + it('produces a signature that recovers to the published key', async () => { + // 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); + }); + + it('signs the digest with no message prefix', async () => { + // What is signed must be exactly the canonical hash, so an on-chain verifier can + // recompute it without knowing about EIP-191. + const result = await sign({ kind: 'commitment', commitment: COMMITMENT }, NOW); + const wallet = new ethers.Wallet(DEV_KEY); + expect(result.signature).toBe(wallet.signingKey.sign(result.digest).serialized); + }); + + it('needs no attestations, since nothing has been computed yet', async () => { + await expect(sign({ kind: 'commitment', commitment: COMMITMENT }, NOW)).resolves.toBeDefined(); + }); +}); + +describe('there is no way to sign arbitrary bytes', () => { + it('rejects a payload that does not validate', async () => { + // The signer is the last place a malformed receipt can be refused. After it, there is + // only history. + const broken = { ...RECEIPT, seed: `0x${'99'.repeat(32)}` as Hex }; + await expect( + sign({ kind: 'receipt', receipt: broken, attestations: [goodAttestation] }, NOW), + ).rejects.toMatchObject({ reason: 'invalid-payload' }); + }); + + it('ignores a caller-supplied digest entirely', async () => { + // There is no field for one: the request type carries objects, not bytes. This asserts + // the recomputed digest wins over anything smuggled in alongside. + const result = await sign( + { kind: 'commitment', commitment: COMMITMENT, digest: `0x${'ee'.repeat(32)}` } as never, + NOW, + ); + expect(result.digest).toBe(hashBattleCommitment(COMMITMENT)); + }); +}); + +describe('receipt attestations', () => { + it('signs when every required attester agrees', async () => { + const result = await sign({ kind: 'receipt', receipt: RECEIPT, attestations: [goodAttestation] }, NOW); + expect(result.digest).toBe(RECEIPT_HASH); + }); + + it('refuses when an attestation is missing', async () => { + // §F's circuit breaker as a precondition: with no agreement there is no signature to + // be had, so a mismatch cannot be signed past by mistake. + envMock.battleSigner.requiredAttesters = ['typescript-engine', 'go-verifier']; + await expect( + sign({ kind: 'receipt', receipt: RECEIPT, attestations: [goodAttestation] }, NOW), + ).rejects.toMatchObject({ reason: 'missing-attestation' }); + }); + + it('signs once both engines have attested', async () => { + envMock.battleSigner.requiredAttesters = ['typescript-engine', 'go-verifier']; + const result = await sign( + { + kind: 'receipt', + receipt: RECEIPT, + attestations: [goodAttestation, { attester: 'go-verifier', receiptHash: RECEIPT_HASH, attestedAt: NOW }], + }, + NOW, + ); + expect(result.digest).toBe(RECEIPT_HASH); + }); + + it('refuses an attestation for a different receipt', async () => { + // Matching on the receipt hash rather than a battle id means an attestation for an + // earlier version of the same battle cannot carry over. + await expect( + sign( + { + kind: 'receipt', + receipt: RECEIPT, + attestations: [{ ...goodAttestation, receiptHash: `0x${'77'.repeat(32)}` }], + }, + NOW, + ), + ).rejects.toMatchObject({ reason: 'attestation-mismatch' }); + }); + + it('refuses an attestation dated in the future', async () => { + await expect( + sign( + { kind: 'receipt', receipt: RECEIPT, attestations: [{ ...goodAttestation, attestedAt: NOW + 3600 }] }, + NOW, + ), + ).rejects.toMatchObject({ reason: 'stale-attestation' }); + }); + + it('ignores attesters that are not required', async () => { + const result = await sign( + { + kind: 'receipt', + receipt: RECEIPT, + attestations: [goodAttestation, { attester: 'someone-else', receiptHash: `0x${'00'.repeat(32)}`, attestedAt: NOW }], + }, + NOW, + ); + expect(result.digest).toBe(RECEIPT_HASH); + }); +}); + +describe('backend selection', () => { + it('refuses an in-process key in production', async () => { + // The whole point of the KMS requirement: a key in an environment variable on an API + // host is what it exists to prevent, so this is a hard failure rather than a warning. + envMock.isProduction = true; + configureSigner(NOW); + + expect(activeSigningKey()).toBeNull(); + await expect(sign({ kind: 'commitment', commitment: COMMITMENT }, NOW)).rejects.toMatchObject({ + reason: 'signer-not-configured', + }); + }); + + it('refuses to start with an unimplemented KMS provider rather than falling back', async () => { + envMock.battleSigner.kmsProvider = 'aws-kms'; + configureSigner(NOW); + + expect(activeSigningKey()).toBeNull(); + await expect(sign({ kind: 'commitment', commitment: COMMITMENT }, NOW)).rejects.toBeInstanceOf( + SignerRefusedError, + ); + }); + + it('reports being unconfigured when no key is available at all', async () => { + envMock.battleSigner.privateKey = undefined; + configureSigner(NOW); + await expect(sign({ kind: 'commitment', commitment: COMMITMENT }, NOW)).rejects.toMatchObject({ + reason: 'signer-not-configured', + }); + }); +}); + +describe('key registry', () => { + it('publishes the active key', () => { + const key = activeSigningKey()!; + expect(key.algorithm).toBe('secp256k1'); + expect(key.status).toBe('active'); + expect(key.notAfter).toBeNull(); + expect(listSigningKeys()).toContainEqual(key); + }); + + it('keeps retired keys published', () => { + // A receipt signed under a rotated key must still verify. Dropping the key would make + // it unverifiable rather than invalid, which is worse. + registerRotatedKey({ + keyId: 'battle-signer-2026-06', + algorithm: 'secp256k1', + publicKey: `0x${'04'.repeat(32)}`, + address: `0x${'ab'.repeat(20)}`, + notBefore: NOW - 86400, + notAfter: NOW - 3600, + status: 'rotated', + }); + expect(listSigningKeys().map((k) => k.keyId)).toEqual(['battle-signer-test', 'battle-signer-2026-06']); + }); +}); + +describe('audit log', () => { + it('records the digest and key of every signature', async () => { + // Reconciling this against the KMS request log is how an unaccounted-for signature is + // spotted at all (threat T4). + await sign({ kind: 'commitment', commitment: COMMITMENT }, NOW); + const [entry] = signerAuditLog(); + expect(entry).toMatchObject({ + kind: 'commitment', + keyId: 'battle-signer-test', + digest: hashBattleCommitment(COMMITMENT), + outcome: 'signed', + }); + }); + + it('records refusals too, with the reason', async () => { + await expect( + sign({ kind: 'receipt', receipt: RECEIPT, attestations: [] }, NOW), + ).rejects.toBeInstanceOf(SignerRefusedError); + const entries = signerAuditLog(); + expect(entries.at(-1)).toMatchObject({ outcome: 'refused', kind: 'refused' }); + expect(entries.at(-1)!.detail).toContain('typescript-engine'); + }); +}); From 4c40838bb52fb9a2612181e9e00c29100593aeb2 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 11:10:30 -0400 Subject: [PATCH 24/76] feat(backend): sign and deliver battle commitment before the drand round publishes --- .../battle-ledger/accept.controller.ts | 74 ++++ .../features/battle-ledger/accept.service.ts | 352 ++++++++++++++++++ backend/src/features/battle-ledger/index.ts | 10 + .../battle-ledger/snapshot.builder.ts | 100 +++++ .../src/features/battle-ledger/transitions.ts | 98 ++++- backend/src/routes/battle.ts | 14 +- .../battle-ledger/accept.service.test.ts | 347 +++++++++++++++++ .../battle-ledger/snapshot.builder.test.ts | 173 +++++++++ .../battle-ledger/transitions.test.ts | 45 ++- 9 files changed, 1192 insertions(+), 21 deletions(-) create mode 100644 backend/src/features/battle-ledger/accept.controller.ts create mode 100644 backend/src/features/battle-ledger/accept.service.ts create mode 100644 backend/src/features/battle-ledger/snapshot.builder.ts create mode 100644 backend/tests/features/battle-ledger/accept.service.test.ts create mode 100644 backend/tests/features/battle-ledger/snapshot.builder.test.ts diff --git a/backend/src/features/battle-ledger/accept.controller.ts b/backend/src/features/battle-ledger/accept.controller.ts new file mode 100644 index 00000000..e6d164da --- /dev/null +++ b/backend/src/features/battle-ledger/accept.controller.ts @@ -0,0 +1,74 @@ +import type { Response } from 'express'; + +import type { AuthenticatedRequest } from '@middleware/auth'; + +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. + */ +const STATUS_BY_REASON: Record = { + 'intent-not-found': 404, + 'intent-already-consumed': 409, + 'intent-expired': 422, + 'attacker-pet-missing': 404, + 'defender-pet-missing': 404, + 'attacker-not-ready': 409, + 'defender-not-ready': 409, + 'not-yet-valid': 409, + expired: 409, + 'pet-not-covered': 403, + 'attacker-level-below-band': 403, + 'attacker-level-above-band': 403, + 'ruleset-mismatch': 403, + 'no-authorization': 403, + revoked: 403, + 'daily-cap-reached': 429, + 'pet-locked': 409, + 'drand-unavailable': 503, + 'signer-unavailable': 503, +}; + +interface AcceptBody { + intentHash?: string; +} + +/** + * Accepts a previously-submitted intent: freezes the snapshot, commits to a future drand + * round, signs the commitment, and returns it in this same response. The signed commitment in + * the response body is the player's own evidence for commit-before-reveal (§E) — the client + * must persist it, since this is the only place it is ever handed over synchronously. + */ +export async function postAcceptBattle(req: AuthenticatedRequest, res: Response): Promise { + if (!req.user?.address) { + res.status(401).json({ error: 'authentication required' }); + return; + } + const body = req.body as AcceptBody; + if (typeof body?.intentHash !== 'string') { + res.status(422).json({ error: 'intentHash is required' }); + return; + } + + const result = await acceptBattle({ intentHash: body.intentHash, 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({ + battleId: result.battle.battleId, + commitmentHash: result.battle.commitmentHash, + signature: result.battle.signature, + signingKeyId: result.battle.signingKeyId, + commitment: serializeCommitmentForWire(result.battle.commitment), + }); +} + +function serializeCommitmentForWire(commitment: unknown): unknown { + return JSON.parse(JSON.stringify(commitment, (_key, value) => (typeof value === 'bigint' ? value.toString() : value))); +} diff --git a/backend/src/features/battle-ledger/accept.service.ts b/backend/src/features/battle-ledger/accept.service.ts new file mode 100644 index 00000000..96378f24 --- /dev/null +++ b/backend/src/features/battle-ledger/accept.service.ts @@ -0,0 +1,352 @@ +import { randomUUID } from 'node:crypto'; + +import { + type BattleCommitment, + type BattleSnapshot, + type ChainId, + hashBattleSnapshot, + hashRuleset, + isBattleReady, + type Hex, + publishRuleset, + QUICKNET, + SOURCE_DEFAULT_RULESET, +} from '@cryptopets/protocol'; +import type { Prisma } from '@generated/prisma/client'; +import { BattleState } from '@generated/prisma/enums'; + +import { prisma } from '@config/prisma'; + +import { activeSigningKey, sign, SignerRefusedError } from '../battle-signer'; +import { chooseCommitmentRound, roundPublishTime } from '../battle-randomness'; + +import { type ConsentFailure, consumeDailyBudget, findCoveringAuthorization } from './consent.service'; +import { servedDeploymentId } from './domain'; +import { OUTBOX_TOPICS } from './outbox'; +import { buildPetSnapshot } from './snapshot.builder'; +import { applyTransition, openBattle } from './transitions'; + +/** + * The accept flow (§E, §J). + * + * This is where the one ordering that can never be relaxed is enforced in code: the photo is + * taken, a round that has not published yet is chosen, the commitment is signed, and the + * signed commitment is the thing this function *returns* — synchronously, in the same + * response that told the caller their battle was accepted. There is no path that answers + * "accepted" without the caller also receiving the signed commitment: if round-selection or + * signing fails, the ledger row this call created is unwound to `rejected` and the caller is + * told the battle was never accepted at all. + * + * Three stages, deliberately not one transaction: + * + * - **Stage A** (one DB transaction, in `openBattle`): consume the intent, lock both pets, and + * persist the frozen snapshot — before any randomness for this battle exists. This is + * `accepted`. + * - **Stage B** (network + KMS, no DB writes): choose a drand round that has not published, + * build the commitment, sign it. + * - **Stage C** (one DB transaction, in `applyTransition`): record the commitment and move to + * `committed`; or, if Stage B failed, move Stage A's row to `rejected` and release the locks. + * + * Splitting it this way means a crash between Stage A and Stage C leaves a diagnosable + * `accepted` row rather than losing the intent's consumption with no trace at all. + */ + +export interface AcceptBattleRequest { + intentHash: string; + nowSeconds: number; +} + +export type AcceptRejection = + | 'intent-not-found' + | 'intent-already-consumed' + | 'intent-expired' + | 'attacker-pet-missing' + | 'defender-pet-missing' + | 'attacker-not-ready' + | 'defender-not-ready' + | ConsentFailure + | 'pet-locked' + | 'drand-unavailable' + | 'signer-unavailable'; + +export interface AcceptedBattle { + battleId: string; + commitment: BattleCommitment; + commitmentHash: Hex; + signature: Hex; + signingKeyId: string; +} + +export type AcceptBattleResult = + | { ok: true; battle: AcceptedBattle } + | { ok: false; reason: AcceptRejection; detail: string }; + +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 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 ruleset = SOURCE_DEFAULT_RULESET; + const rulesetHash = hashRuleset(ruleset); + await ensureRulesetPublished(rulesetHash); + + const coverage = await findCoveringAuthorization({ + chainId, + defenderOwner: defender.owner, + defenderPetId: defender.petId.toString(), + attackerLevel: attacker.level, + rulesetHash, + nowSeconds: request.nowSeconds, + }); + if (!coverage.ok) { + return reject(coverage.reason, coverage.detail); + } + + // A network read, deliberately before any write: a battle should never be accepted (and + // an intent never consumed) over a round choice that then turns out to be unobtainable. + const roundChoice = await chooseCommitmentRound(request.nowSeconds); + if (!roundChoice.ok) { + return reject('drand-unavailable', roundChoice.detail); + } + + // Consumes one use of the defender's daily budget before the ledger row exists. A later + // pet-locked conflict (rare: it means a lock slipped past the readiness check above between + // here and Stage A) would then leave this use spent with no battle created. That is a + // conservative failure, not a permissive one — the cap is never exceeded, only occasionally + // reached one battle early — and fixing it needs threading a transaction client through + // consumeDailyBudget, which is not worth the added complexity for a race this narrow. + const budget = await consumeDailyBudget(coverage.authorizationHash, coverage.maxBattlesPerDay, request.nowSeconds); + if (!budget.ok) { + return reject('daily-cap-reached', 'defender daily battle cap reached'); + } + + const battleId = `btl_${randomUUID()}`; + const domain = { chainId, deploymentId: servedDeploymentId() }; + const snapshot: BattleSnapshot = { domain, attacker, defender, takenAt: request.nowSeconds }; + const snapshotHash = hashBattleSnapshot(snapshot); + + // Stage A: everything that must be durable before any randomness exists. + const opened = await openBattle({ + consumeIntentHash: intent.intentHash, + petIds: [attacker.petId.toString(), defender.petId.toString()], + ledger: { + battleId, + chainId, + deploymentId: domain.deploymentId, + state: BattleState.accepted, + intentHash: intent.intentHash, + authorizationHash: coverage.authorizationHash, + attackerPetId: attacker.petId.toString(), + attackerOwner: attacker.owner, + defenderPetId: defender.petId.toString(), + defenderOwner: defender.owner, + snapshot: serializeBigints(snapshot), + snapshotHash, + rulesetHash, + rulesetVersion: ruleset.version, + // 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, + }, + }); + if (!opened.ok) { + return opened.reason === 'pet-locked' + ? reject('pet-locked', `pet ${opened.petId} already has an open battle`) + : reject('intent-already-consumed', 'this intent already produced a battle'); + } + + // Stage B + C: sign the commitment and record it, retrying if another accept call under the + // same signing key wins the chain position first. + try { + const signed = await signAndRecordCommitment({ + battleId, + domain, + intentHash: intent.intentHash as Hex, + defenseAuthorizationHash: coverage.authorizationHash as Hex, + snapshot, + rulesetVersion: ruleset.version, + rulesetHash, + drandChainHash: QUICKNET.chainHash, + drandRound: roundChoice.round, + acceptedAt: request.nowSeconds, + }); + return { ok: true, battle: { battleId, ...signed } }; + } catch (error) { + await unwindToRejected(battleId, error instanceof Error ? error.message : String(error)); + if (error instanceof SignerRefusedError) { + return reject('signer-unavailable', error.message); + } + throw error; + } +} + +type CommitmentSeed = Omit; + +/** + * Builds, signs, and durably records a commitment, retrying if the chain-position write + * conflicts with another accept call under the same signing key. + * + * The retry re-reads the chain head and re-signs on every attempt, because the signature + * covers `sequence` and `previousCommitmentHash`: a stale link cannot be patched onto an + * already-produced signature, only replaced by producing a new one. + * + * Recording happens inside the same `applyTransition` that moves the ledger row from + * `accepted` to `committed`, so a chain-position conflict rolls the *whole* transaction back — + * including the state move — leaving the row exactly as retriable as it was before this + * attempt. + */ +async function signAndRecordCommitment( + seed: CommitmentSeed, +): Promise<{ commitment: BattleCommitment; commitmentHash: Hex; signature: Hex; signingKeyId: string }> { + for (let attempt = 0; attempt < MAX_COMMITMENT_CHAIN_RETRIES; attempt++) { + const key = activeSigningKey(); + if (!key) { + throw new SignerRefusedError('signer-not-configured', 'no active signing key'); + } + + const previous = await prisma.battleCommitment.findFirst({ + where: { signingKeyId: key.keyId }, + orderBy: { sequence: 'desc' }, + select: { commitmentHash: true, sequence: true }, + }); + const sequence = previous ? previous.sequence + 1n : 1n; + + const commitment: BattleCommitment = { + ...seed, + previousCommitmentHash: (previous?.commitmentHash ?? null) as Hex | null, + signingKeyId: key.keyId, + }; + + const signResult = await sign({ kind: 'commitment', commitment }, seed.acceptedAt); + + try { + await applyTransition({ + battleId: seed.battleId, + from: BattleState.accepted, + to: BattleState.committed, + patch: { + drandChainHash: commitment.drandChainHash, + drandRound: BigInt(commitment.drandRound), + acceptedAt: BigInt(commitment.acceptedAt), + }, + onApplied: async (tx) => { + await tx.battleCommitment.create({ + data: { + commitmentHash: signResult.digest, + battleId: seed.battleId, + sequence, + previousCommitmentHash: commitment.previousCommitmentHash, + signingKeyId: signResult.keyId, + signature: signResult.signature, + payload: serializeBigints(commitment), + acceptedAt: BigInt(commitment.acceptedAt), + // Set in the same transaction that persists the commitment: the + // accept response is built from this exact result immediately + // after, so "delivered" and "persisted" land together (threat T15). + deliveredAt: new Date(), + }, + }); + }, + outbox: [ + { + battleId: seed.battleId, + topic: OUTBOX_TOPICS.awaitBeacon, + availableAt: roundPublishTime(commitment.drandRound), + }, + ], + }); + return { + commitment, + commitmentHash: signResult.digest, + signature: signResult.signature, + signingKeyId: signResult.keyId, + }; + } catch (error) { + if ((error as { code?: string }).code === 'P2002') { + continue; // another accept call took this chain position; retry with a fresh read + } + throw error; + } + } + throw new Error(`could not claim a commitment chain position after ${MAX_COMMITMENT_CHAIN_RETRIES} attempts`); +} + +async function unwindToRejected(battleId: string, reason: string): Promise { + await applyTransition({ + battleId, + from: BattleState.accepted, + to: BattleState.rejected, + patch: { failureReason: reason }, + }); +} + +/** + * Publishes the active ruleset bundle on first use. + * + * A receipt or commitment naming a `rulesetHash` with no matching published bundle cannot be + * 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 { + const existing = await prisma.battleRuleset.findUnique({ where: { rulesetHash: expectedHash } }); + if (existing) { + return; + } + const { hash, json } = publishRuleset(SOURCE_DEFAULT_RULESET); + try { + await prisma.battleRuleset.create({ + data: { + rulesetHash: hash, + version: SOURCE_DEFAULT_RULESET.version, + engineId: SOURCE_DEFAULT_RULESET.engineId, + engineVersion: SOURCE_DEFAULT_RULESET.engineVersion, + bundle: JSON.parse(json), + }, + }); + } catch (error) { + if ((error as { code?: string }).code !== 'P2002') { + throw error; + } + // Another concurrent accept call published it first; that is fine, the row exists now. + } +} + +/** Prisma's Json columns cannot hold a bigint; stringify it in place before storing. */ +function serializeBigints(value: T): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? v.toString() : v))); +} + +function reject(reason: AcceptRejection, detail: string): AcceptBattleResult { + return { ok: false, reason, detail }; +} diff --git a/backend/src/features/battle-ledger/index.ts b/backend/src/features/battle-ledger/index.ts index 0629f573..140684c8 100644 --- a/backend/src/features/battle-ledger/index.ts +++ b/backend/src/features/battle-ledger/index.ts @@ -1,3 +1,11 @@ +export { postAcceptBattle } from './accept.controller'; +export { + type AcceptBattleRequest, + type AcceptBattleResult, + acceptBattle, + type AcceptedBattle, + type AcceptRejection, +} from './accept.service'; export { deleteDefenseAuthorizations, postDefenseAuthorization } from './consent.controller'; export { type AuthorizationRejection, @@ -58,7 +66,9 @@ export { getBattleState, openBattle, type OpenBattleRequest, + type OpenBattleResult, sortPetIds, type TransitionRequest, type TransitionResult, } from './transitions'; +export { buildPetSnapshot } from './snapshot.builder'; diff --git a/backend/src/features/battle-ledger/snapshot.builder.ts b/backend/src/features/battle-ledger/snapshot.builder.ts new file mode 100644 index 00000000..9cf7a857 --- /dev/null +++ b/backend/src/features/battle-ledger/snapshot.builder.ts @@ -0,0 +1,100 @@ +import { chainFamily, type ChainId, type PetSnapshot } from '@cryptopets/protocol'; + +import { prisma } from '@config/prisma'; + +import { servedDeploymentId } from './domain'; + +/** + * Builds the frozen "photo" for one pet at acceptance (§C). + * + * Two sources are read and merged, and the split is deliberate: + * + * - `pet_roster` is the indexed projection of on-chain state (owner, DNA, rarity, level, + * species), keyed by chain *family* (`evm` | `solana`). It is never written by backend + * battles, so it stays exactly what the chain guarantees. + * - `pet_battle_progress` is the backend-only progression state (§C): off-chain level, XP, + * same-opponent streak, backend cooldown. Kept in a separate table so mixing the two never + * becomes possible by accident. It is keyed by the specific protocol `ChainId` (e.g. + * `eip155:84532`, not just `evm`), because one deployment can serve more than one chain of + * the same family, and the family alone would not disambiguate their pet-id namespaces. + * + * A pet with no progress row yet is initialized from its on-chain level (a level-40 pet's + * first backend battle starts at level 40, not level 1), XP zeroed (the backend threshold + * curve starts its own cycle rather than inheriting a partial on-chain counter that may not + * even use the same formula), and no opponent history. + */ + +const SKILL_ARCHETYPES = 8; + +export async function buildPetSnapshot(chainId: ChainId, petId: string): Promise { + const family = chainFamily(chainId); + const roster = await prisma.petRoster.findUnique({ where: { chain_petId: { chain: family, petId } } }); + if (!roster) { + return null; + } + + const progress = await getOrInitProgress(chainId, petId, { + level: roster.level, + winCount: roster.winCount, + lossCount: roster.lossCount, + }); + + return { + petId: BigInt(petId), + owner: roster.owner, + dna: BigInt(roster.dna), + rarity: roster.rarity, + level: progress.level, + skill: roster.speciesId % SKILL_ARCHETYPES, + xp: progress.xp, + lastOpponentId: BigInt(progress.lastOpponentId), + streak: progress.streak, + readyAt: Number(progress.readyAt), + sourceVersion: roster.lastVersion, + }; +} + +/** + * Reads a pet's backend progression, creating the row on first use. + * + * The create is best-effort under a race: two concurrent first-battles for the same pet can + * both miss the row and both attempt to create it. Whichever loses the unique-constraint race + * simply re-reads, which is safe because the initial values are a pure function of the + * on-chain state passed in, not of anything the loser would have computed differently. + */ +async function getOrInitProgress( + chainId: ChainId, + petId: string, + seed: { level: number; winCount: number; lossCount: number }, +) { + const deploymentId = servedDeploymentId(); + const key = { chainId_deploymentId_petId: { chainId, deploymentId, petId } }; + + const existing = await prisma.petBattleProgress.findUnique({ where: key }); + if (existing) { + return existing; + } + + try { + return await prisma.petBattleProgress.create({ + data: { + chainId, + deploymentId, + petId, + level: seed.level, + xp: 0, + winCount: seed.winCount, + lossCount: seed.lossCount, + }, + }); + } catch (error) { + if ((error as { code?: string }).code !== 'P2002') { + throw error; + } + const created = await prisma.petBattleProgress.findUnique({ where: key }); + if (!created) { + throw new Error(`pet_battle_progress for ${chainId}/${petId} vanished after a create race`); + } + return created; + } +} diff --git a/backend/src/features/battle-ledger/transitions.ts b/backend/src/features/battle-ledger/transitions.ts index 1f00b8fe..53b46589 100644 --- a/backend/src/features/battle-ledger/transitions.ts +++ b/backend/src/features/battle-ledger/transitions.ts @@ -32,6 +32,13 @@ export interface TransitionRequest { patch?: BattleLedgerPatch; /** Messages to enqueue atomically with the transition. */ outbox?: readonly OutboxMessage[]; + /** + * Extra work to run in the same transaction, after the state guard succeeds and before + * the outbox write. For a transition that also creates a related row — the accept flow's + * `accepted` -> `committed` move creates the `BattleCommitment` row alongside it — so that + * row cannot exist without the state change that produced it, or vice versa. + */ + onApplied?: (tx: Prisma.TransactionClient) => Promise; } export interface TransitionResult { @@ -77,6 +84,9 @@ export async function applyTransition(request: TransitionRequest): Promise 0) { await enqueueOutbox(tx, request.outbox); } @@ -98,10 +108,36 @@ export interface OpenBattleRequest { /** Pet ids to lock for the duration, as decimal strings. */ petIds: readonly string[]; outbox?: readonly OutboxMessage[]; + /** + * Marks the originating intent consumed in the same transaction, guarded on it not + * already being consumed. Two accept calls racing on one intent must not both succeed: + * whichever loses this guard gets `intentAlreadyConsumed`, never a second ledger row. + */ + consumeIntentHash?: string; +} + +export type OpenBattleResult = + | { ok: true; battleId: string } + | { ok: false; reason: 'pet-locked'; petId: string } + | { ok: false; reason: 'intent-already-consumed' }; + +/** + * Signals a clean, expected abort of the `openBattle` transaction. + * + * Prisma's interactive transactions only roll back when the callback throws; returning a + * value, even one that *looks* like a failure, commits whatever ran so far. So an aborted + * ledger row or a lock taken before the conflict must be undone by throwing, not by returning + * `{ ok: false }` directly from inside the callback. + */ +class OpenBattleAbort extends Error { + constructor(readonly result: Extract) { + super(`openBattle aborted: ${result.reason}`); + } } /** - * Creates a ledger row, locks both pets, and enqueues the first message, atomically. + * Creates a ledger row, locks both pets, consumes the originating intent, and enqueues the + * first message, all atomically. * * Lock rows are inserted in ascending numeric pet-id order. Two battles involving the same * pair, submitted at the same moment, therefore contend on the same row first, so one of @@ -109,26 +145,50 @@ export interface OpenBattleRequest { * (threat T11). Numeric rather than lexicographic, because pet ids are decimal strings and * `"10" < "9"` as text. */ -export async function openBattle(request: OpenBattleRequest): Promise<{ battleId: string }> { +export async function openBattle(request: OpenBattleRequest): Promise { const petIds = sortPetIds(request.petIds); - return prisma.$transaction( - async (tx) => { - const ledger = await tx.battleLedger.create({ data: request.ledger }); - for (const petId of petIds) { - // Sequential on purpose: the ordering is the deadlock avoidance, and - // issuing these in parallel would throw it away. - await tx.petBattleLock.create({ - data: { chainId: ledger.chainId, petId, battleId: ledger.battleId }, - }); - } - if (request.outbox && request.outbox.length > 0) { - await enqueueOutbox(tx, request.outbox); - } - return { battleId: ledger.battleId }; - }, - { isolationLevel: 'Serializable' }, - ); + try { + return await prisma.$transaction( + async (tx) => { + if (request.consumeIntentHash) { + const { count } = await tx.battleIntent.updateMany({ + where: { intentHash: request.consumeIntentHash, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + if (count === 0) { + throw new OpenBattleAbort({ ok: false, reason: 'intent-already-consumed' }); + } + } + + const ledger = await tx.battleLedger.create({ data: request.ledger }); + for (const petId of petIds) { + // Sequential on purpose: the ordering is the deadlock avoidance, and + // issuing these in parallel would throw it away. + try { + await tx.petBattleLock.create({ + data: { chainId: ledger.chainId, petId, battleId: ledger.battleId }, + }); + } catch (error) { + if ((error as { code?: string }).code === 'P2002') { + throw new OpenBattleAbort({ ok: false, reason: 'pet-locked', petId }); + } + throw error; + } + } + if (request.outbox && request.outbox.length > 0) { + await enqueueOutbox(tx, request.outbox); + } + return { ok: true, battleId: ledger.battleId }; + }, + { isolationLevel: 'Serializable' }, + ); + } catch (error) { + if (error instanceof OpenBattleAbort) { + return error.result; + } + throw error; + } } /** Ascending numeric order, which is the lock-acquisition order. */ diff --git a/backend/src/routes/battle.ts b/backend/src/routes/battle.ts index 2e987aa4..226734a8 100644 --- a/backend/src/routes/battle.ts +++ b/backend/src/routes/battle.ts @@ -1,6 +1,11 @@ import express, { Router } from 'express'; -import { deleteDefenseAuthorizations, postBattleIntent, postDefenseAuthorization } from '@features/battle-ledger'; +import { + deleteDefenseAuthorizations, + postAcceptBattle, + postBattleIntent, + postDefenseAuthorization, +} from '@features/battle-ledger'; import { verifyToken } from '@middleware/auth'; import { battleRoomRateLimit } from '@middleware/rateLimit'; @@ -11,6 +16,13 @@ const router: Router = express.Router(); // than per IP, which is what makes it a per-wallet submission limit (threat T5). router.post('/intents', verifyToken, battleRoomRateLimit, 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', verifyToken, battleRoomRateLimit, (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', verifyToken, battleRoomRateLimit, postDefenseAuthorization); diff --git a/backend/tests/features/battle-ledger/accept.service.test.ts b/backend/tests/features/battle-ledger/accept.service.test.ts new file mode 100644 index 00000000..5cc6d6d7 --- /dev/null +++ b/backend/tests/features/battle-ledger/accept.service.test.ts @@ -0,0 +1,347 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QUICKNET, roundTime } from '@cryptopets/protocol'; + +vi.mock('@config/env', () => ({ + env: { battle: { deploymentId: 'base-sepolia-live', chainIds: ['eip155:84532'] } }, +})); + +vi.mock('@config/prisma', () => ({ + prisma: { + battleIntent: { findUnique: vi.fn() }, + battleRuleset: { findUnique: vi.fn(), create: vi.fn() }, + battleCommitment: { findFirst: vi.fn() }, + }, +})); + +vi.mock('../../../src/features/battle-ledger/snapshot.builder', () => ({ + buildPetSnapshot: vi.fn(), +})); + +vi.mock('../../../src/features/battle-ledger/consent.service', () => ({ + findCoveringAuthorization: vi.fn(), + consumeDailyBudget: vi.fn(), +})); + +vi.mock('../../../src/features/battle-randomness', () => ({ + chooseCommitmentRound: vi.fn(), + roundPublishTime: vi.fn((round: number) => new Date(roundTime(QUICKNET, round) * 1000)), +})); + +vi.mock('../../../src/features/battle-signer', () => ({ + activeSigningKey: vi.fn(), + sign: vi.fn(), + SignerRefusedError: class SignerRefusedError extends Error { + constructor( + public reason: string, + detail: string, + ) { + super(detail); + } + }, +})); + +vi.mock('../../../src/features/battle-ledger/transitions', () => ({ + openBattle: vi.fn(), + applyTransition: vi.fn(), +})); + +import { prisma } from '@config/prisma'; +import { acceptBattle } from '@features/battle-ledger'; +import { chooseCommitmentRound, roundPublishTime } from '@features/battle-randomness'; +import { activeSigningKey, sign, SignerRefusedError } from '@features/battle-signer'; +import { consumeDailyBudget, findCoveringAuthorization } from '../../../src/features/battle-ledger/consent.service'; +import { buildPetSnapshot } from '../../../src/features/battle-ledger/snapshot.builder'; +import { applyTransition, openBattle } from '../../../src/features/battle-ledger/transitions'; + +const ROUND_1000_TIME = roundTime(QUICKNET, 1000); +const NOW = ROUND_1000_TIME + 1; + +const ATTACKER = { + petId: 1n, + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: 1234567890123456n, + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: 0n, + streak: 0, + readyAt: NOW - 100, + sourceVersion: BigInt(NOW - 50), +}; + +const DEFENDER = { + ...ATTACKER, + petId: 2n, + owner: '0x2222222222222222222222222222222222222222', + lastOpponentId: 1n, + streak: 2, +}; + +const INTENT = { + intentHash: '0xaa'.padEnd(66, '1'), + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + attackerPetId: '1', + defenderPetId: '2', + consumedAt: null as Date | null, + expiresAt: BigInt(NOW + 300), +}; + +const SIGNING_KEY = { + keyId: 'battle-signer-test', + algorithm: 'secp256k1' as const, + publicKey: `0x${'04'.repeat(64)}` as const, + address: `0x${'ab'.repeat(20)}` as const, + notBefore: NOW - 1000, + notAfter: null, + status: 'active' as const, +}; + +function baseline() { + vi.mocked(prisma.battleIntent.findUnique).mockResolvedValue(INTENT as never); + vi.mocked(prisma.battleRuleset.findUnique).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); + vi.mocked(findCoveringAuthorization).mockResolvedValue({ + ok: true, + authorizationHash: '0xauth'.padEnd(66, '2'), + maxBattlesPerDay: 20, + } as never); + vi.mocked(consumeDailyBudget).mockResolvedValue({ ok: true, used: 1 }); + vi.mocked(chooseCommitmentRound).mockResolvedValue({ ok: true, round: 1002, latestVerified: 1000 }); + vi.mocked(activeSigningKey).mockReturnValue(SIGNING_KEY as never); + vi.mocked(openBattle).mockImplementation((async (req: { ledger: { battleId: string } }) => ({ + ok: true, + battleId: req.ledger.battleId, + })) as never); + vi.mocked(sign).mockImplementation((async (request: { kind: string }) => ({ + kind: request.kind, + digest: '0xdigest'.padEnd(66, '3'), + signature: '0xsig'.padEnd(132, '4'), + keyId: SIGNING_KEY.keyId, + })) as never); + vi.mocked(applyTransition).mockImplementation((async (req: { onApplied?: (tx: unknown) => Promise }) => { + if (req.onApplied) { + await req.onApplied(fakeTx()); + } + return { applied: true, state: 'committed' }; + }) as never); +} + +/** A minimal stand-in for the Prisma transaction client `onApplied` writes through. */ +function fakeTx() { + return { battleCommitment: { create: vi.fn().mockResolvedValue({}) } }; +} + +beforeEach(() => { + vi.clearAllMocks(); + baseline(); +}); + +describe('the happy path', () => { + it('accepts, commits to a future round, and returns the signed commitment synchronously', async () => { + const result = await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.battle.commitment.drandRound).toBe(1002); + expect(result.battle.commitment.drandChainHash).toBe(QUICKNET.chainHash); + expect(result.battle.signature).toMatch(/^0xsig/); + expect(result.battle.commitmentHash).toMatch(/^0xdigest/); + } + }); + + it('never returns a round that has already published', async () => { + // The property the whole design rests on, checked here at the orchestration level too, + // not just inside protocol's own validation. + const result = await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(roundTime(QUICKNET, result.battle.commitment.drandRound)).toBeGreaterThan(NOW); + } + }); + + it('chooses the round and opens the ledger before ever asking the signer', async () => { + // Ordering matters: a battle must never be accepted (nor an intent consumed) over a + // round choice that could still turn out to be unobtainable. + await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + + const roundCallOrder = vi.mocked(chooseCommitmentRound).mock.invocationCallOrder[0]!; + const openCallOrder = vi.mocked(openBattle).mock.invocationCallOrder[0]!; + const signCallOrder = vi.mocked(sign).mock.invocationCallOrder[0]!; + expect(roundCallOrder).toBeLessThan(openCallOrder); + expect(openCallOrder).toBeLessThan(signCallOrder); + }); + + it('schedules the await-beacon outbox message for when the round actually publishes', async () => { + await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + expect(roundPublishTime).toHaveBeenCalledWith(1002); + const call = vi.mocked(applyTransition).mock.calls[0]![0] as { outbox: { availableAt: Date }[] }; + expect(call.outbox[0]!.availableAt.getTime()).toBe(roundTime(QUICKNET, 1002) * 1000); + }); + + it('publishes the active ruleset the first time it is referenced', async () => { + vi.mocked(prisma.battleRuleset.findUnique).mockResolvedValue(null); + await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + expect(prisma.battleRuleset.create).toHaveBeenCalledTimes(1); + }); + + it('does not republish an already-published ruleset', async () => { + await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + expect(prisma.battleRuleset.create).not.toHaveBeenCalled(); + }); +}); + +describe('intent checks', () => { + it('rejects an unknown intent', async () => { + vi.mocked(prisma.battleIntent.findUnique).mockResolvedValue(null); + expect(await acceptBattle({ intentHash: '0xmissing', nowSeconds: NOW })).toMatchObject({ + ok: false, + reason: 'intent-not-found', + }); + }); + + it('rejects an already-consumed intent without touching the pet snapshots', async () => { + vi.mocked(prisma.battleIntent.findUnique).mockResolvedValue({ ...INTENT, consumedAt: new Date() } as never); + const result = await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + expect(result).toMatchObject({ ok: false, reason: 'intent-already-consumed' }); + expect(buildPetSnapshot).not.toHaveBeenCalled(); + }); + + it('rejects an expired intent', async () => { + const result = await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: Number(INTENT.expiresAt) }); + expect(result).toMatchObject({ ok: false, reason: 'intent-expired' }); + }); +}); + +describe('pet checks', () => { + it('rejects when the attacker pet is missing from the roster', async () => { + vi.mocked(buildPetSnapshot).mockImplementation((async (_chainId: string, petId: string) => + petId === '1' ? null : DEFENDER) as never); + expect(await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).toMatchObject({ + ok: false, + reason: 'attacker-pet-missing', + }); + }); + + it('rejects when either pet is on cooldown, mirroring GameLogic requiring both pets ready', async () => { + vi.mocked(buildPetSnapshot).mockImplementation((async (_chainId: string, petId: string) => + petId === '1' ? { ...ATTACKER, readyAt: NOW + 100 } : DEFENDER) as never); + expect(await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).toMatchObject({ + ok: false, + reason: 'attacker-not-ready', + }); + + vi.mocked(buildPetSnapshot).mockImplementation((async (_chainId: string, petId: string) => + petId === '1' ? ATTACKER : { ...DEFENDER, readyAt: NOW + 100 }) as never); + expect(await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).toMatchObject({ + ok: false, + reason: 'defender-not-ready', + }); + }); +}); + +describe('consent and budget', () => { + it('passes through the consent-coverage failure reason unchanged', async () => { + vi.mocked(findCoveringAuthorization).mockResolvedValue({ + ok: false, + reason: 'attacker-level-below-band', + detail: 'too low', + }); + expect(await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).toMatchObject({ + ok: false, + reason: 'attacker-level-below-band', + }); + }); + + it('rejects once the daily cap is reached, without ever opening a battle', async () => { + vi.mocked(consumeDailyBudget).mockResolvedValue({ ok: false, reason: 'daily-cap-reached' }); + const result = await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + expect(result).toMatchObject({ ok: false, reason: 'daily-cap-reached' }); + expect(openBattle).not.toHaveBeenCalled(); + }); +}); + +describe('drand unavailability', () => { + it('fails closed rather than accepting without a committed round', async () => { + vi.mocked(chooseCommitmentRound).mockResolvedValue({ ok: false, detail: 'all endpoints down' }); + const result = await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + expect(result).toMatchObject({ ok: false, reason: 'drand-unavailable' }); + // Nothing was opened or consumed: a battle should never be accepted over a round choice + // that turned out to be unobtainable. + expect(openBattle).not.toHaveBeenCalled(); + expect(consumeDailyBudget).not.toHaveBeenCalled(); + }); +}); + +describe('opening the ledger', () => { + it('reports a locked pet as its own reason', async () => { + vi.mocked(openBattle).mockResolvedValue({ ok: false, reason: 'pet-locked', petId: '2' }); + expect(await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).toMatchObject({ + ok: false, + reason: 'pet-locked', + }); + expect(sign).not.toHaveBeenCalled(); + }); + + it('locks both pets, in numeric order, and consumes the originating intent atomically', async () => { + await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + const call = vi.mocked(openBattle).mock.calls[0]![0]; + expect(call.petIds).toEqual(['1', '2']); + expect(call.consumeIntentHash).toBe(INTENT.intentHash); + }); + + it('persists the frozen snapshot before any randomness for the battle exists', async () => { + // §J: the photo has to be durable before the commitment step even starts. + await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + const ledger = vi.mocked(openBattle).mock.calls[0]![0].ledger as { snapshotHash: string; drandRound: bigint }; + expect(typeof ledger.snapshotHash).toBe('string'); + expect(ledger.drandRound).toBe(0n); + }); +}); + +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')); + + const result = await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + + expect(result).toMatchObject({ ok: false, reason: 'signer-unavailable' }); + const rejectedBattleId = vi.mocked(openBattle).mock.calls[0]![0].ledger.battleId as string; + expect(applyTransition).toHaveBeenCalledWith( + expect.objectContaining({ battleId: rejectedBattleId, from: 'accepted', to: 'rejected' }), + ); + }); + + it('propagates an unexpected error rather than swallowing it as a rejection', async () => { + vi.mocked(sign).mockRejectedValue(new Error('kms unreachable')); + await expect(acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW })).rejects.toThrow( + /kms unreachable/, + ); + }); +}); + +describe('commitment chain-position retry', () => { + it('retries with a fresh chain head when another accept call wins the position first', async () => { + vi.mocked(prisma.battleCommitment.findFirst) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ commitmentHash: '0xhead'.padEnd(66, '5'), sequence: 1n } as never); + vi.mocked(applyTransition) + .mockImplementationOnce((async () => { + throw Object.assign(new Error('unique'), { code: 'P2002' }); + }) as never) + .mockImplementationOnce((async (req: { onApplied?: (tx: unknown) => Promise }) => { + if (req.onApplied) await req.onApplied(fakeTx()); + return { applied: true, state: 'committed' }; + }) as never); + + const result = await acceptBattle({ intentHash: INTENT.intentHash, nowSeconds: NOW }); + + expect(result.ok).toBe(true); + expect(sign).toHaveBeenCalledTimes(2); + expect(applyTransition).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/tests/features/battle-ledger/snapshot.builder.test.ts b/backend/tests/features/battle-ledger/snapshot.builder.test.ts new file mode 100644 index 00000000..5324b989 --- /dev/null +++ b/backend/tests/features/battle-ledger/snapshot.builder.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@config/env', () => ({ + env: { battle: { deploymentId: 'base-sepolia-live', chainIds: ['eip155:84532'] } }, +})); + +vi.mock('@config/prisma', () => ({ + prisma: { + petRoster: { findUnique: vi.fn() }, + petBattleProgress: { findUnique: vi.fn(), create: vi.fn() }, + }, +})); + +import { prisma } from '@config/prisma'; +import { buildPetSnapshot } from '@features/battle-ledger'; + +const ROSTER_ROW = { + chain: 'evm', + petId: '1', + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + level: 40, + rarity: 3, + dna: '1234567890123456', + winCount: 12, + lossCount: 3, + speciesId: 12, // 12 % 8 = 4 + lastVersion: 999888n, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('a pet the roster does not have', () => { + it('returns null rather than throwing', async () => { + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(null); + expect(await buildPetSnapshot('eip155:84532', '999')).toBeNull(); + expect(prisma.petBattleProgress.findUnique).not.toHaveBeenCalled(); + }); +}); + +describe('merging roster and progress', () => { + it('derives skill from speciesId % 8', async () => { + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(ROSTER_ROW as never); + vi.mocked(prisma.petBattleProgress.findUnique).mockResolvedValue({ + level: 40, + xp: 500, + lastOpponentId: '7', + streak: 2, + readyAt: 1000n, + } as never); + + const snapshot = await buildPetSnapshot('eip155:84532', '1'); + + expect(snapshot).toEqual({ + petId: 1n, + owner: ROSTER_ROW.owner, + dna: 1234567890123456n, + rarity: 3, + level: 40, + skill: 4, + xp: 500, + lastOpponentId: 7n, + streak: 2, + readyAt: 1000, + sourceVersion: 999888n, + }); + }); + + it('queries pet_roster by chain family, not the full protocol chain id', async () => { + // pet_roster is keyed by 'evm' | 'solana'; passing the specific chain id straight + // through would silently match nothing. + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(ROSTER_ROW as never); + vi.mocked(prisma.petBattleProgress.findUnique).mockResolvedValue({ + level: 40, + xp: 0, + lastOpponentId: '0', + streak: 0, + readyAt: 0n, + } as never); + + await buildPetSnapshot('eip155:84532', '1'); + + expect(prisma.petRoster.findUnique).toHaveBeenCalledWith({ + where: { chain_petId: { chain: 'evm', petId: '1' } }, + }); + }); + + it('keys progress by the specific protocol chain id and this deployment', async () => { + // One deployment can serve more than one chain of the same family, so the family alone + // would not disambiguate their pet-id namespaces. + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(ROSTER_ROW as never); + vi.mocked(prisma.petBattleProgress.findUnique).mockResolvedValue({ + level: 40, + xp: 0, + lastOpponentId: '0', + streak: 0, + readyAt: 0n, + } as never); + + await buildPetSnapshot('eip155:84532', '1'); + + expect(prisma.petBattleProgress.findUnique).toHaveBeenCalledWith({ + where: { + chainId_deploymentId_petId: { + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + petId: '1', + }, + }, + }); + }); +}); + +describe('first backend battle for a pet', () => { + it('seeds progress from on-chain level, zeroes XP, and starts with no opponent history', async () => { + // A level-40 pet's first backend battle starts at level 40, not level 1. XP starts a + // fresh cycle rather than inheriting a partial on-chain counter under a different + // formula. + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(ROSTER_ROW as never); + vi.mocked(prisma.petBattleProgress.findUnique).mockResolvedValue(null); + vi.mocked(prisma.petBattleProgress.create).mockResolvedValue({ + level: 40, + xp: 0, + lastOpponentId: '0', + streak: 0, + readyAt: 0n, + } as never); + + const snapshot = await buildPetSnapshot('eip155:84532', '1'); + + expect(prisma.petBattleProgress.create).toHaveBeenCalledWith({ + data: { + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + petId: '1', + level: 40, + xp: 0, + winCount: 12, + lossCount: 3, + }, + }); + expect(snapshot!.level).toBe(40); + expect(snapshot!.xp).toBe(0); + expect(snapshot!.lastOpponentId).toBe(0n); + expect(snapshot!.streak).toBe(0); + }); + + it('re-reads rather than erroring when two first battles race to create the row', async () => { + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(ROSTER_ROW as never); + vi.mocked(prisma.petBattleProgress.findUnique) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ level: 40, xp: 0, lastOpponentId: '0', streak: 0, readyAt: 0n } as never); + vi.mocked(prisma.petBattleProgress.create).mockRejectedValue( + Object.assign(new Error('unique'), { code: 'P2002' }), + ); + + const snapshot = await buildPetSnapshot('eip155:84532', '1'); + + // Safe because the initial values are a pure function of on-chain state, not of + // anything the losing caller would have computed differently. + expect(snapshot!.level).toBe(40); + expect(prisma.petBattleProgress.findUnique).toHaveBeenCalledTimes(2); + }); + + it('rethrows an unexpected error rather than treating it as a lost race', async () => { + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(ROSTER_ROW as never); + vi.mocked(prisma.petBattleProgress.findUnique).mockResolvedValue(null); + vi.mocked(prisma.petBattleProgress.create).mockRejectedValue(new Error('connection reset')); + + await expect(buildPetSnapshot('eip155:84532', '1')).rejects.toThrow(/connection reset/); + }); +}); diff --git a/backend/tests/features/battle-ledger/transitions.test.ts b/backend/tests/features/battle-ledger/transitions.test.ts index b0622d4e..23867c3b 100644 --- a/backend/tests/features/battle-ledger/transitions.test.ts +++ b/backend/tests/features/battle-ledger/transitions.test.ts @@ -11,6 +11,7 @@ const tx = { battleLedger: { updateMany: vi.fn(), findUnique: vi.fn(), create: vi.fn() }, battleOutbox: { createMany: vi.fn() }, petBattleLock: { create: vi.fn(), deleteMany: vi.fn() }, + battleIntent: { updateMany: vi.fn() }, }; vi.mock('@config/prisma', () => ({ @@ -42,6 +43,7 @@ beforeEach(() => { tx.battleOutbox.createMany.mockResolvedValue({ count: 1 }); tx.petBattleLock.create.mockResolvedValue({}); tx.petBattleLock.deleteMany.mockResolvedValue({ count: 2 }); + tx.battleIntent.updateMany.mockResolvedValue({ count: 1 }); }); describe('applyTransition', () => { @@ -154,12 +156,13 @@ describe('failBattle', () => { describe('openBattle', () => { it('creates the row, locks both pets, and enqueues in one transaction', async () => { - await openBattle({ + const result = await openBattle({ ledger: { chainId: 'eip155:84532' } as never, petIds: ['9', '10'], outbox: [{ battleId: 'btl_1', topic: OUTBOX_TOPICS.awaitBeacon }], }); + expect(result).toEqual({ ok: true, battleId: 'btl_1' }); expect(tx.battleLedger.create).toHaveBeenCalledTimes(1); expect(tx.petBattleLock.create).toHaveBeenCalledTimes(2); expect(tx.battleOutbox.createMany).toHaveBeenCalledTimes(1); @@ -173,6 +176,46 @@ describe('openBattle', () => { const order = tx.petBattleLock.create.mock.calls.map((call) => call[0].data.petId); expect(order).toEqual(['9', '10']); }); + + it('consumes the originating intent in the same transaction, guarded on it not already being spent', async () => { + await openBattle({ + ledger: { chainId: 'eip155:84532' } as never, + petIds: ['9', '10'], + consumeIntentHash: '0xabc', + }); + expect(tx.battleIntent.updateMany).toHaveBeenCalledWith({ + where: { intentHash: '0xabc', consumedAt: null }, + data: { consumedAt: expect.any(Date) }, + }); + }); + + it('aborts without creating a ledger row when the intent was already consumed', async () => { + // Two accept calls racing on one intent must not both succeed. The abort has to roll + // back everything in the transaction, not just skip the ledger create. + tx.battleIntent.updateMany.mockResolvedValue({ count: 0 }); + const result = await openBattle({ + ledger: { chainId: 'eip155:84532' } as never, + petIds: ['9', '10'], + consumeIntentHash: '0xabc', + }); + expect(result).toEqual({ ok: false, reason: 'intent-already-consumed' }); + expect(tx.battleLedger.create).not.toHaveBeenCalled(); + }); + + it('reports which pet was already locked, rather than a raw database error', async () => { + tx.petBattleLock.create.mockResolvedValueOnce({}).mockRejectedValueOnce( + Object.assign(new Error('unique'), { code: 'P2002' }), + ); + const result = await openBattle({ ledger: { chainId: 'eip155:84532' } as never, petIds: ['9', '10'] }); + expect(result).toEqual({ ok: false, reason: 'pet-locked', petId: '10' }); + }); + + it('rethrows an unexpected lock error rather than reporting a conflict', async () => { + tx.petBattleLock.create.mockRejectedValueOnce(new Error('connection reset')); + await expect( + openBattle({ ledger: { chainId: 'eip155:84532' } as never, petIds: ['9', '10'] }), + ).rejects.toThrow(/connection reset/); + }); }); describe('sortPetIds', () => { From 216062667bfeaaf9b586ad6ddb3814826c931c23 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 11:20:54 -0400 Subject: [PATCH 25/76] feat(backend): compute battles from verified drand seeds in a worker --- backend/env.example | 10 ++ backend/src/config/env.ts | 12 ++ backend/src/features/battle-ledger/index.ts | 1 + backend/src/features/battle-ledger/outbox.ts | 16 ++ .../features/battle-worker/beacon.worker.ts | 107 ++++++++++++ .../features/battle-worker/compute.worker.ts | 124 ++++++++++++++ backend/src/features/battle-worker/index.ts | 7 + backend/src/features/battle-worker/runner.ts | 60 +++++++ backend/src/server.ts | 13 ++ .../battle-ledger/outbox.reschedule.test.ts | 27 +++ .../battle-worker/beacon.worker.test.ts | 159 ++++++++++++++++++ .../battle-worker/compute.worker.test.ts | 144 ++++++++++++++++ .../features/battle-worker/runner.test.ts | 90 ++++++++++ 13 files changed, 770 insertions(+) create mode 100644 backend/src/features/battle-worker/beacon.worker.ts create mode 100644 backend/src/features/battle-worker/compute.worker.ts create mode 100644 backend/src/features/battle-worker/index.ts create mode 100644 backend/src/features/battle-worker/runner.ts create mode 100644 backend/tests/features/battle-ledger/outbox.reschedule.test.ts create mode 100644 backend/tests/features/battle-worker/beacon.worker.test.ts create mode 100644 backend/tests/features/battle-worker/compute.worker.test.ts create mode 100644 backend/tests/features/battle-worker/runner.test.ts diff --git a/backend/env.example b/backend/env.example index 66134110..f781b057 100644 --- a/backend/env.example +++ b/backend/env.example @@ -162,3 +162,13 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # 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 # BATTLE_SIGNER_REQUIRED_ATTESTERS=typescript-engine,go-verifier + +# How long a battle waits on its committed drand round before forfeiting (§E). Measured from +# when the round was due to publish, not from acceptance — a couple of rounds' offset is +# expected delay, not an outage. Default: 300s. +# BATTLE_FORFEIT_AFTER_SECONDS=300 +# How often the battle worker polls the outbox for due await-beacon/compute messages, in ms. +# Default: 2000. +# BATTLE_WORKER_POLL_INTERVAL_MS=2000 +# Messages claimed per poll, per topic. Default: 10. +# BATTLE_WORKER_BATCH_SIZE=10 diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 39c7d414..7c4001fb 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -166,6 +166,18 @@ export const env = { .map((url) => url.trim().replace(/\/$/, '')) .filter((url) => url.length > 0), drandTimeoutMs: Number(process.env.BATTLE_DRAND_TIMEOUT_MS?.trim() || '4000'), + /** + * How long a battle waits on its committed round before forfeiting (§E). Measured from + * when the round was *due* to publish, not from acceptance, since a couple of rounds' + * offset is expected delay, not an outage. Long enough that ordinary drand jitter never + * forfeits a battle; short enough that a genuine outage does not leave pets locked + * indefinitely. Default: 300s (100 quicknet rounds). + */ + forfeitAfterSeconds: Number(process.env.BATTLE_FORFEIT_AFTER_SECONDS?.trim() || '300'), + /** How often the compute worker polls the outbox for due messages, in ms. */ + workerPollIntervalMs: Number(process.env.BATTLE_WORKER_POLL_INTERVAL_MS?.trim() || '2000'), + /** Messages claimed per poll, per topic. */ + workerBatchSize: Number(process.env.BATTLE_WORKER_BATCH_SIZE?.trim() || '10'), }, /** diff --git a/backend/src/features/battle-ledger/index.ts b/backend/src/features/battle-ledger/index.ts index 140684c8..6093854d 100644 --- a/backend/src/features/battle-ledger/index.ts +++ b/backend/src/features/battle-ledger/index.ts @@ -46,6 +46,7 @@ export { OUTBOX_TOPICS, type OutboxMessage, type OutboxTopic, + rescheduleOutbox, retryDelaySeconds, } from './outbox'; export { diff --git a/backend/src/features/battle-ledger/outbox.ts b/backend/src/features/battle-ledger/outbox.ts index 68b93698..c9750240 100644 --- a/backend/src/features/battle-ledger/outbox.ts +++ b/backend/src/features/battle-ledger/outbox.ts @@ -159,6 +159,22 @@ export async function failOutbox( return { deadLettered: false, retryAt }; } +/** + * Reschedules a message without treating the wait as a failure. + * + * Waiting for a drand round to publish is the expected, common case for `await-beacon`, not an + * error: `failOutbox`'s exponential backoff and eventual dead-lettering exist for something + * actually going wrong, and applying them here would dead-letter a perfectly healthy battle + * just because its committed round has not arrived yet. `attempts` and `lastError` are left + * untouched, so a genuine failure later still starts its own backoff from zero. + */ +export async function rescheduleOutbox(id: string, availableAt: Date): Promise { + await prisma.battleOutbox.update({ + where: { id }, + data: { availableAt, lockedAt: null, lockedBy: null }, + }); +} + /** Exponential backoff in seconds for the nth attempt (1-based), capped. */ export function retryDelaySeconds(attempts: number): number { const delay = BASE_RETRY_SECONDS * 2 ** Math.max(0, attempts - 1); diff --git a/backend/src/features/battle-worker/beacon.worker.ts b/backend/src/features/battle-worker/beacon.worker.ts new file mode 100644 index 00000000..8ef699c6 --- /dev/null +++ b/backend/src/features/battle-worker/beacon.worker.ts @@ -0,0 +1,107 @@ +import { type ChainId, deriveBattleSeed, type Hex } from '@cryptopets/protocol'; +import { BattleState } from '@generated/prisma/enums'; +import type { Prisma } from '@generated/prisma/client'; + +import { env } from '@config/env'; +import { prisma } from '@config/prisma'; +import { + applyTransition, + type ClaimedMessage, + completeOutbox, + OUTBOX_TOPICS, + rescheduleOutbox, +} from '@features/battle-ledger'; +import { fetchVerifiedRound, roundPublishTime } from '@features/battle-randomness'; + +/** + * Handles `await-beacon` messages: `committed` -> `seeded` (§E, §J). + * + * Three outcomes for one message, and only one of them completes it: + * + * - **Verified.** The committed round published and its signature checks out. Derive the + * seed, move to `seeded`, enqueue `compute`. This is the only path that finishes the + * message. + * - **Not yet published, or every drand endpoint failed.** Reschedule for the round's next + * expected publish time (or a short poll interval if it is already overdue). This is never + * treated as a job failure — §E requires retrying the *same* round indefinitely, and the + * outbox's exponential backoff exists for something actually wrong, not for ordinary + * waiting. + * - **The round has been overdue longer than `forfeitAfterSeconds`.** Move to `forfeited` + * instead of continuing to wait. No progression change, both pets stay locked through their + * normal cooldown (locks release on any terminal state, forfeited included, so this is + * "cooldown", not "stuck locked forever" — a repeat-forfeiter is a rate-limit matter, not + * this worker's job). + * + * A round is never substituted for a different one at any point in this function. The only + * two things that ever happen to a stalled round are "keep waiting" and "give up entirely." + */ +export async function processAwaitBeaconMessage(message: ClaimedMessage, nowSeconds: number): Promise { + const battle = await prisma.battleLedger.findUnique({ where: { battleId: message.battleId } }); + if (!battle) { + // The battle was rejected or expired before this message was ever claimed (Stage A + // failed after Stage A's own outbox entry, if any, was already written). Nothing to do. + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + if (battle.state !== BattleState.committed) { + // Already advanced past this by another worker, or by a retry of this same message + // that completed after a timeout. Either way this is the idempotent no-op case. + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + + const round = Number(battle.drandRound); + const dueAt = roundPublishTime(round); + const overdueSeconds = nowSeconds - Math.floor(dueAt.getTime() / 1000); + + const outcome = await fetchVerifiedRound(round, nowSeconds); + + if (outcome.status === 'verified') { + const seed = deriveBattleSeed({ + domain: { chainId: battle.chainId as ChainId, deploymentId: battle.deploymentId }, + drandRandomness: outcome.beacon.randomness, + battleId: battle.battleId, + snapshotHash: battle.snapshotHash as Hex, + rulesetHash: battle.rulesetHash as Hex, + }); + + const patch: Prisma.BattleLedgerUncheckedUpdateInput = { + beaconSignature: outcome.beacon.signature, + beaconRandomness: outcome.beacon.randomness, + seed: seed.hex, + }; + await applyTransition({ + battleId: battle.battleId, + from: BattleState.committed, + to: BattleState.seeded, + patch, + outbox: [{ battleId: battle.battleId, topic: OUTBOX_TOPICS.compute }], + }); + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + + if (overdueSeconds > env.battle.forfeitAfterSeconds) { + await applyTransition({ + battleId: battle.battleId, + from: BattleState.committed, + to: BattleState.forfeited, + patch: { failureReason: `drand round ${round} unavailable for ${overdueSeconds}s: ${describeOutcome(outcome)}` }, + }); + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + + // Still within the forfeit window: reschedule, never fail. Poll again either at the + // round's due time (if it has not arrived yet) or on a short fixed interval (if it is + // merely late), so a slow endpoint gets checked again soon rather than sitting idle. + const nextCheck = + outcome.status === 'not-yet-published' + ? dueAt + : new Date(nowSeconds * 1000 + env.battle.workerPollIntervalMs); + await rescheduleOutbox(message.id, nextCheck); +} + +function describeOutcome(outcome: Awaited>): string { + return outcome.status === 'unavailable' ? outcome.detail : outcome.status; +} diff --git a/backend/src/features/battle-worker/compute.worker.ts b/backend/src/features/battle-worker/compute.worker.ts new file mode 100644 index 00000000..f3b687d9 --- /dev/null +++ b/backend/src/features/battle-worker/compute.worker.ts @@ -0,0 +1,124 @@ +import { + type BattleSnapshot, + computeProgression, + hashCombatLog, + type Hex, + loadRulesetBundle, + simulate, +} from '@cryptopets/protocol'; +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'; + +/** + * Handles `compute` messages: `seeded` -> `computed` (§F). + * + * Runs the canonical TypeScript engine on the frozen snapshot and the verified seed, records + * the result, the progression delta, and the combat log alongside its hash, and hands off to + * `verify` — the independent Go recomputation that has to agree before anything here can be + * signed (Step 25; this worker never signs anything itself). + * + * The ruleset is loaded from the published bundle this battle actually named, not from + * whatever the process's current defaults happen to be. A balance change between acceptance + * and this worker running must not retroactively change the fight; `loadRulesetBundle` checks + * the bundle's hash against `rulesetHash` before trusting a single field in it. + */ +export async function processComputeMessage(message: ClaimedMessage, nowSeconds: number): Promise { + const battle = await prisma.battleLedger.findUnique({ where: { battleId: message.battleId } }); + if (!battle) { + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + if (battle.state !== BattleState.seeded) { + // Idempotent no-op: already computed by another worker, or this message is a stale + // retry of a transition that already landed. + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + if (!battle.seed) { + throw new Error(`battle ${battle.battleId} is seeded but has no seed recorded`); + } + + const rulesetRow = await prisma.battleRuleset.findUnique({ where: { rulesetHash: battle.rulesetHash } }); + if (!rulesetRow) { + throw new Error(`no published ruleset bundle for ${battle.rulesetHash}; cannot compute battle ${battle.battleId}`); + } + 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 outcome = simulate( + attacker.dna, + attacker.rarity, + attacker.level, + attacker.skill, + defender.dna, + defender.rarity, + defender.level, + defender.skill, + BigInt(battle.seed), + ruleset.skillConfig, + ); + + const progression = computeProgression( + { ...snapshot, attacker, defender }, + outcome.result.firstWins, + { maxLevel: ruleset.maxLevel }, + ); + const combatLogHash = hashCombatLog(outcome); + + const patch: Prisma.BattleLedgerUncheckedUpdateInput = { + attackerWon: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + combatLog: serializeBigints(outcome.log), + combatLogHash, + progression: serializeBigints(progression), + }; + + await applyTransition({ + battleId: battle.battleId, + from: BattleState.seeded, + to: BattleState.computed, + patch, + outbox: [{ battleId: battle.battleId, topic: OUTBOX_TOPICS.verify }], + }); + await completeOutbox(message.id, new Date(nowSeconds * 1000)); +} + +/** 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; +}) { + 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), + }; +} + +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/index.ts b/backend/src/features/battle-worker/index.ts new file mode 100644 index 00000000..7d13d0be --- /dev/null +++ b/backend/src/features/battle-worker/index.ts @@ -0,0 +1,7 @@ +export { processAwaitBeaconMessage } from './beacon.worker'; +export { processComputeMessage } from './compute.worker'; +export { + type BattleWorkerHandle, + runBattleWorkerOnce, + startBattleWorker, +} from './runner'; diff --git a/backend/src/features/battle-worker/runner.ts b/backend/src/features/battle-worker/runner.ts new file mode 100644 index 00000000..1dec8936 --- /dev/null +++ b/backend/src/features/battle-worker/runner.ts @@ -0,0 +1,60 @@ +import { env } from '@config/env'; +import { type ClaimedMessage, claimOutbox, failOutbox, OUTBOX_TOPICS } from '@features/battle-ledger'; + +import { processAwaitBeaconMessage } from './beacon.worker'; +import { processComputeMessage } from './compute.worker'; + +/** + * Dispatches claimed outbox messages to their handler. + * + * A handler that throws is a real failure — a network exception, a database error, a + * programming bug — and goes through `failOutbox`'s backoff-then-dead-letter path. A handler + * that returns normally is expected to have already called `completeOutbox`, + * `rescheduleOutbox`, or `applyTransition` itself; the dispatcher does not call + * `completeOutbox` a second time; a handler that does neither leaves the message claimed and + * is a bug in that handler, not something this loop papers over. + */ +const HANDLERS: Record Promise> = { + [OUTBOX_TOPICS.awaitBeacon]: processAwaitBeaconMessage, + [OUTBOX_TOPICS.compute]: processComputeMessage, +}; + +/** One poll: claims due messages for the topics this worker owns and processes each in turn. */ +export async function runBattleWorkerOnce(workerId: string, now: Date = new Date()): Promise<{ processed: number }> { + const topics = Object.keys(HANDLERS) as (typeof OUTBOX_TOPICS)[keyof typeof OUTBOX_TOPICS][]; + const messages = await claimOutbox(topics, workerId, env.battle.workerBatchSize, now); + const nowSeconds = Math.floor(now.getTime() / 1000); + + for (const message of messages) { + const handler = HANDLERS[message.topic]; + if (!handler) { + // Claimed a topic this process does not know how to run. That is a deployment or + // routing bug, not a transient failure, but dead-lettering it immediately is safer + // than leaving it claimed forever with nothing to process it. + await failOutbox(message, `no handler for topic ${message.topic}`, now); + continue; + } + try { + await handler(message, nowSeconds); + } catch (error) { + await failOutbox(message, (error as Error).message, now); + } + } + + return { processed: messages.length }; +} + +export interface BattleWorkerHandle { + stop(): void; +} + +/** Starts polling on an interval. `workerId` should be unique per process for the outbox lock. */ +export function startBattleWorker(workerId: string): BattleWorkerHandle { + const timer = setInterval(() => { + void runBattleWorkerOnce(workerId).catch((error: Error) => { + // eslint-disable-next-line no-console + console.error(`[battle-worker] poll failed: ${error.message}`); + }); + }, env.battle.workerPollIntervalMs); + return { stop: () => clearInterval(timer) }; +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 934c2493..ed0cf48b 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -3,10 +3,14 @@ import { env } from '@config/env'; import { prisma } from '@config/prisma'; import app from './app'; import { startBattleStream, stopBattleStream } from '@grpc-client/battleStream'; +import { configureSigner } from '@features/battle-signer'; import { startSettleKeeper, stopSettleKeeper } from '@features/settle-keeper'; import { startSolanaSettleKeeperFeature, stopSolanaSettleKeeperFeature } from '@features/settle-keeper-solana'; +import { type BattleWorkerHandle, startBattleWorker } from '@features/battle-worker'; import { startLiveBattleSocket, stopLiveBattleSocket } from '@ws/liveBattleSocket'; +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', () => { @@ -29,6 +33,14 @@ const server = app.listen(env.port, '0.0.0.0', () => { // Settles Solana commit_battle requests once Switchboard reveals. No-op unless // KEEPER_SOLANA_ENABLED is set. startSolanaSettleKeeperFeature(); + + // Backend-authoritative battles (docs/plan-backend-battle-architecture.md). Selects the + // signing backend (refuses an in-process key in production; see @features/battle-signer) + // and starts the outbox worker that carries accepted battles from `committed` through + // `computed`. Both are always on: unlike the settle keepers there is no separate enable + // flag yet, since accepting a battle already requires a configured signer to succeed. + configureSigner(Math.floor(Date.now() / 1000)); + battleWorker = startBattleWorker(`backend-${process.pid}`); }); /** Force-exit deadline: don't let a stuck connection block the orchestrator forever. */ @@ -55,6 +67,7 @@ async function shutdown(signal: NodeJS.Signals): Promise { stopBattleStream(); stopSettleKeeper(); stopSolanaSettleKeeperFeature(); + battleWorker?.stop(); stopLiveBattleSocket(); await new Promise((resolve) => server.close(() => resolve())); await prisma.$disconnect(); diff --git a/backend/tests/features/battle-ledger/outbox.reschedule.test.ts b/backend/tests/features/battle-ledger/outbox.reschedule.test.ts new file mode 100644 index 00000000..b5fbb6e8 --- /dev/null +++ b/backend/tests/features/battle-ledger/outbox.reschedule.test.ts @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@config/prisma', () => ({ + prisma: { battleOutbox: { update: vi.fn() } }, +})); + +import { prisma } from '@config/prisma'; +import { rescheduleOutbox } from '@features/battle-ledger'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('rescheduleOutbox', () => { + it('moves availableAt and releases the lock without touching attempts or lastError', async () => { + // Waiting for a drand round is the expected case, not a failure: applying + // failOutbox's backoff-then-dead-letter here would eventually dead-letter a perfectly + // healthy battle just because its round has not published yet. + const availableAt = new Date('2026-07-26T12:00:00.000Z'); + await rescheduleOutbox('msg_1', availableAt); + + expect(vi.mocked(prisma.battleOutbox.update).mock.calls[0]![0]).toEqual({ + where: { id: 'msg_1' }, + data: { availableAt, lockedAt: null, lockedBy: null }, + }); + }); +}); diff --git a/backend/tests/features/battle-worker/beacon.worker.test.ts b/backend/tests/features/battle-worker/beacon.worker.test.ts new file mode 100644 index 00000000..6803d094 --- /dev/null +++ b/backend/tests/features/battle-worker/beacon.worker.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deriveBattleSeed, QUICKNET, roundTime } from '@cryptopets/protocol'; + +vi.mock('@config/env', () => ({ + env: { battle: { forfeitAfterSeconds: 300, workerPollIntervalMs: 2000 } }, +})); + +vi.mock('@config/prisma', () => ({ + prisma: { battleLedger: { findUnique: vi.fn() } }, +})); + +vi.mock('@features/battle-ledger', () => ({ + applyTransition: vi.fn(), + completeOutbox: vi.fn(), + rescheduleOutbox: vi.fn(), + OUTBOX_TOPICS: { compute: 'compute' }, +})); + +vi.mock('@features/battle-randomness', () => ({ + fetchVerifiedRound: vi.fn(), + roundPublishTime: vi.fn((round: number) => new Date(roundTime(QUICKNET, round) * 1000)), +})); + +import { prisma } from '@config/prisma'; +import { applyTransition, completeOutbox, rescheduleOutbox } from '@features/battle-ledger'; +import { fetchVerifiedRound } from '@features/battle-randomness'; +import { processAwaitBeaconMessage } from '@features/battle-worker'; + +const ROUND = 1000; +const PUBLISHED_AT = roundTime(QUICKNET, ROUND); +const BEACON = { + round: ROUND, + chainHash: QUICKNET.chainHash, + signature: + '0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39', + randomness: '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd', +}; + +const MESSAGE = { id: 'msg_1', battleId: 'btl_1', topic: 'await-beacon', payload: {}, attempts: 1 }; + +const BATTLE = { + battleId: 'btl_1', + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + state: 'committed', + drandRound: BigInt(ROUND), + snapshotHash: `0x${'11'.repeat(32)}`, + rulesetHash: `0x${'22'.repeat(32)}`, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(BATTLE as never); +}); + +describe('the round has verified', () => { + it('derives the seed, moves to seeded, and enqueues compute', async () => { + vi.mocked(fetchVerifiedRound).mockResolvedValue({ status: 'verified', beacon: BEACON as never }); + + await processAwaitBeaconMessage(MESSAGE, PUBLISHED_AT + 1); + + const call = vi.mocked(applyTransition).mock.calls[0]![0] as { + from: string; + to: string; + patch: { seed: string }; + outbox: { topic: string }[]; + }; + expect(call.from).toBe('committed'); + expect(call.to).toBe('seeded'); + const expectedSeed = deriveBattleSeed({ + domain: { chainId: BATTLE.chainId as never, deploymentId: BATTLE.deploymentId }, + drandRandomness: BEACON.randomness as never, + battleId: BATTLE.battleId, + snapshotHash: BATTLE.snapshotHash as never, + rulesetHash: BATTLE.rulesetHash as never, + }); + expect(call.patch.seed).toBe(expectedSeed.hex); + expect(call.outbox[0]!.topic).toBe('compute'); + expect(completeOutbox).toHaveBeenCalledWith('msg_1', expect.any(Date)); + }); + + it('never re-derives a different seed for the same message', async () => { + vi.mocked(fetchVerifiedRound).mockResolvedValue({ status: 'verified', beacon: BEACON as never }); + await processAwaitBeaconMessage(MESSAGE, PUBLISHED_AT + 1); + await processAwaitBeaconMessage(MESSAGE, PUBLISHED_AT + 5); + const seeds = vi.mocked(applyTransition).mock.calls.map((c) => (c[0] as { patch: { seed: string } }).patch.seed); + expect(new Set(seeds).size).toBe(1); + }); +}); + +describe('the round has not published yet', () => { + it('reschedules for the round due time and never calls failOutbox-style backoff', async () => { + vi.mocked(fetchVerifiedRound).mockResolvedValue({ status: 'not-yet-published' }); + + await processAwaitBeaconMessage(MESSAGE, PUBLISHED_AT - 1); + + expect(rescheduleOutbox).toHaveBeenCalledWith('msg_1', new Date(PUBLISHED_AT * 1000)); + expect(applyTransition).not.toHaveBeenCalled(); + expect(completeOutbox).not.toHaveBeenCalled(); + }); +}); + +describe('every endpoint is unavailable, within the forfeit window', () => { + it('reschedules on a short poll interval rather than treating it as a failure', async () => { + vi.mocked(fetchVerifiedRound).mockResolvedValue({ status: 'unavailable', detail: 'all down' }); + + await processAwaitBeaconMessage(MESSAGE, PUBLISHED_AT + 10); + + expect(rescheduleOutbox).toHaveBeenCalledWith('msg_1', new Date((PUBLISHED_AT + 10) * 1000 + 2000)); + expect(applyTransition).not.toHaveBeenCalled(); + }); +}); + +describe('the outage has outlasted the forfeit window', () => { + it('forfeits rather than waiting forever', async () => { + vi.mocked(fetchVerifiedRound).mockResolvedValue({ status: 'unavailable', detail: 'all down' }); + + await processAwaitBeaconMessage(MESSAGE, PUBLISHED_AT + 301); + + const call = vi.mocked(applyTransition).mock.calls[0]![0] as { from: string; to: string }; + expect(call.from).toBe('committed'); + expect(call.to).toBe('forfeited'); + expect(rescheduleOutbox).not.toHaveBeenCalled(); + expect(completeOutbox).toHaveBeenCalled(); + }); + + it('measures the window from the round due time, not from a fixed poll count', async () => { + // A couple of rounds' offset delay is expected, not an outage; the clock starts only + // once the round is actually overdue. + vi.mocked(fetchVerifiedRound).mockResolvedValue({ status: 'not-yet-published' }); + await processAwaitBeaconMessage(MESSAGE, PUBLISHED_AT - 500); + expect(applyTransition).not.toHaveBeenCalled(); + }); +}); + +describe('idempotence', () => { + it('completes without acting when the battle has already moved on', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ ...BATTLE, state: 'seeded' } as never); + await processAwaitBeaconMessage(MESSAGE, PUBLISHED_AT + 1); + expect(applyTransition).not.toHaveBeenCalled(); + expect(completeOutbox).toHaveBeenCalled(); + }); + + it('completes without acting when the battle no longer exists', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(null); + await processAwaitBeaconMessage(MESSAGE, PUBLISHED_AT + 1); + expect(applyTransition).not.toHaveBeenCalled(); + expect(completeOutbox).toHaveBeenCalled(); + }); +}); + +describe('never substitutes a different round', () => { + it('always fetches exactly the round the ledger recorded', async () => { + vi.mocked(fetchVerifiedRound).mockResolvedValue({ status: 'verified', beacon: BEACON as never }); + await processAwaitBeaconMessage(MESSAGE, PUBLISHED_AT + 1); + expect(fetchVerifiedRound).toHaveBeenCalledWith(ROUND, PUBLISHED_AT + 1); + }); +}); diff --git a/backend/tests/features/battle-worker/compute.worker.test.ts b/backend/tests/features/battle-worker/compute.worker.test.ts new file mode 100644 index 00000000..4862df7a --- /dev/null +++ b/backend/tests/features/battle-worker/compute.worker.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deriveBattleSeed, hashRuleset, publishRuleset, QUICKNET, roundTime, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; + +vi.mock('@config/prisma', () => ({ + prisma: { + battleLedger: { findUnique: vi.fn() }, + battleRuleset: { findUnique: vi.fn() }, + }, +})); + +vi.mock('@features/battle-ledger', () => ({ + applyTransition: vi.fn(), + completeOutbox: vi.fn(), + OUTBOX_TOPICS: { verify: 'verify' }, +})); + +import { prisma } from '@config/prisma'; +import { applyTransition, completeOutbox } from '@features/battle-ledger'; +import { processComputeMessage } from '@features/battle-worker'; + +const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); +const NOW = roundTime(QUICKNET, 1000) + 5; +const DOMAIN = { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }; + +const ATTACKER = { + petId: '1', + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: '1234567890123456', + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: '0', + streak: 0, + readyAt: NOW - 100, + sourceVersion: '1000', +}; +const DEFENDER = { + ...ATTACKER, + petId: '2', + owner: '0x2222222222222222222222222222222222222222', + dna: '6543210987654321', + rarity: 2, + level: 11, + skill: 7, + lastOpponentId: '1', + streak: 2, +}; + +const SNAPSHOT = { domain: DOMAIN, attacker: ATTACKER, defender: DEFENDER, takenAt: NOW - 6 }; + +const seed = deriveBattleSeed({ + domain: DOMAIN as never, + drandRandomness: '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd', + battleId: 'btl_1', + snapshotHash: `0x${'11'.repeat(32)}`, + rulesetHash: RULESET_HASH, +}); + +const BATTLE = { + battleId: 'btl_1', + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + state: 'seeded', + seed: seed.hex, + snapshot: SNAPSHOT, + rulesetHash: RULESET_HASH, +}; + +const MESSAGE = { id: 'msg_1', battleId: 'btl_1', topic: 'compute', payload: {}, attempts: 1 }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(BATTLE as never); + const { json } = publishRuleset(SOURCE_DEFAULT_RULESET); + vi.mocked(prisma.battleRuleset.findUnique).mockResolvedValue({ bundle: JSON.parse(json) } as never); +}); + +describe('running the fight', () => { + it('computes the result, progression, and combat log hash, then moves to computed', async () => { + await processComputeMessage(MESSAGE, NOW); + + const call = vi.mocked(applyTransition).mock.calls[0]![0] as { + from: string; + to: string; + patch: { rounds: number; combatLogHash: string; combatLog: unknown; progression: unknown }; + outbox: { topic: string }[]; + }; + expect(call.from).toBe('seeded'); + expect(call.to).toBe('computed'); + expect(call.patch.rounds).toBeGreaterThan(0); + expect(call.patch.combatLogHash).toMatch(/^0x[0-9a-f]{64}$/); + expect(Array.isArray(call.patch.combatLog)).toBe(true); + expect(call.outbox[0]!.topic).toBe('verify'); + expect(completeOutbox).toHaveBeenCalledWith('msg_1', expect.any(Date)); + }); + + it('is deterministic: the same seeded battle always computes the same result', async () => { + await processComputeMessage(MESSAGE, NOW); + await processComputeMessage(MESSAGE, NOW + 1); + const results = vi.mocked(applyTransition).mock.calls.map( + (c) => (c[0] as { patch: { combatLogHash: string } }).patch.combatLogHash, + ); + expect(results[0]).toBe(results[1]); + }); + + it('loads the ruleset the battle actually named, not whatever the process default is', async () => { + await processComputeMessage(MESSAGE, NOW); + expect(prisma.battleRuleset.findUnique).toHaveBeenCalledWith({ where: { rulesetHash: RULESET_HASH } }); + }); + + it('rejects a published bundle whose hash does not match, rather than trusting it blindly', async () => { + const { json } = publishRuleset({ ...SOURCE_DEFAULT_RULESET, version: 2 }); + vi.mocked(prisma.battleRuleset.findUnique).mockResolvedValue({ bundle: JSON.parse(json) } as never); + await expect(processComputeMessage(MESSAGE, NOW)).rejects.toThrow(/ruleset hash mismatch/); + }); + + it('throws when no bundle was ever published for this hash', async () => { + vi.mocked(prisma.battleRuleset.findUnique).mockResolvedValue(null); + await expect(processComputeMessage(MESSAGE, NOW)).rejects.toThrow(/no published ruleset bundle/); + }); +}); + +describe('idempotence', () => { + it('completes without recomputing when the battle has already moved on', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ ...BATTLE, state: 'computed' } as never); + await processComputeMessage(MESSAGE, NOW); + expect(applyTransition).not.toHaveBeenCalled(); + expect(completeOutbox).toHaveBeenCalled(); + }); + + it('completes without acting when the battle no longer exists', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(null); + await processComputeMessage(MESSAGE, NOW); + expect(applyTransition).not.toHaveBeenCalled(); + expect(completeOutbox).toHaveBeenCalled(); + }); + + it('throws if seeded but somehow missing its seed, rather than computing garbage', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ ...BATTLE, seed: null } as never); + await expect(processComputeMessage(MESSAGE, NOW)).rejects.toThrow(/no seed recorded/); + }); +}); diff --git a/backend/tests/features/battle-worker/runner.test.ts b/backend/tests/features/battle-worker/runner.test.ts new file mode 100644 index 00000000..3077ccee --- /dev/null +++ b/backend/tests/features/battle-worker/runner.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@config/env', () => ({ + env: { battle: { workerBatchSize: 10, workerPollIntervalMs: 2000 } }, +})); + +vi.mock('@features/battle-ledger', () => ({ + claimOutbox: vi.fn(), + failOutbox: vi.fn(), + OUTBOX_TOPICS: { awaitBeacon: 'await-beacon', compute: 'compute' }, +})); + +vi.mock('@features/battle-worker/beacon.worker', () => ({ + processAwaitBeaconMessage: vi.fn(), +})); +vi.mock('@features/battle-worker/compute.worker', () => ({ + processComputeMessage: vi.fn(), +})); + +import { claimOutbox, failOutbox } from '@features/battle-ledger'; +import { processAwaitBeaconMessage } from '@features/battle-worker/beacon.worker'; +import { processComputeMessage } from '@features/battle-worker/compute.worker'; +import { runBattleWorkerOnce } from '@features/battle-worker/runner'; + +const NOW = new Date('2026-07-26T12:00:00.000Z'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('dispatch', () => { + it('routes each message to its topic handler', async () => { + vi.mocked(claimOutbox).mockResolvedValue([ + { id: 'm1', battleId: 'btl_1', topic: 'await-beacon', payload: {}, attempts: 1 }, + { id: 'm2', battleId: 'btl_2', topic: 'compute', payload: {}, attempts: 1 }, + ]); + + const result = await runBattleWorkerOnce('worker-a', NOW); + + expect(result).toEqual({ processed: 2 }); + expect(processAwaitBeaconMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'm1' }), + Math.floor(NOW.getTime() / 1000), + ); + expect(processComputeMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'm2' }), + Math.floor(NOW.getTime() / 1000), + ); + }); + + it('claims only the topics this worker owns, with the configured batch size', async () => { + vi.mocked(claimOutbox).mockResolvedValue([]); + await runBattleWorkerOnce('worker-a', NOW); + expect(claimOutbox).toHaveBeenCalledWith(['await-beacon', 'compute'], 'worker-a', 10, NOW); + }); + + it('sends a real handler failure through failOutbox for backoff, not a silent swallow', async () => { + vi.mocked(claimOutbox).mockResolvedValue([ + { id: 'm1', battleId: 'btl_1', topic: 'compute', payload: {}, attempts: 1 }, + ]); + vi.mocked(processComputeMessage).mockRejectedValue(new Error('kms unreachable')); + + await runBattleWorkerOnce('worker-a', NOW); + + expect(failOutbox).toHaveBeenCalledWith( + expect.objectContaining({ id: 'm1' }), + 'kms unreachable', + NOW, + ); + }); + + it('dead-letters a message whose topic has no handler, rather than leaving it claimed forever', async () => { + vi.mocked(claimOutbox).mockResolvedValue([ + { id: 'm1', battleId: 'btl_1', topic: 'sign', payload: {}, attempts: 1 }, + ]); + + await runBattleWorkerOnce('worker-a', NOW); + + expect(failOutbox).toHaveBeenCalledWith( + expect.objectContaining({ id: 'm1' }), + expect.stringContaining('no handler'), + NOW, + ); + }); + + it('processes nothing when there is nothing due', async () => { + vi.mocked(claimOutbox).mockResolvedValue([]); + expect(await runBattleWorkerOnce('worker-a', NOW)).toEqual({ processed: 0 }); + }); +}); From bba252784b08958558666664c64b7dba7051a559 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 12:28:06 -0400 Subject: [PATCH 26/76] feat(indexer-go): verify backend battle results independently before signing --- backend/env.example | 9 +- .../migration.sql | 2 + backend/prisma/schema.prisma | 8 + backend/src/features/battle-worker/index.ts | 1 + backend/src/features/battle-worker/runner.ts | 2 + .../features/battle-worker/verify.worker.ts | 211 +++++ backend/src/grpc/verifyBattle.ts | 178 ++++ .../features/battle-worker/runner.test.ts | 7 +- .../battle-worker/verify.worker.test.ts | 297 +++++++ backend/tests/grpc/verifyBattle.test.ts | 84 ++ indexer-go/README.md | 28 +- indexer-go/internal/combat/progression.go | 94 +++ .../internal/combat/progression_test.go | 109 +++ indexer-go/internal/combat/simlog.go | 175 ++++ indexer-go/internal/combat/simlog_test.go | 69 ++ indexer-go/internal/combat/strike.go | 51 +- indexer-go/internal/combat/verify.go | 64 ++ indexer-go/internal/combat/xp.go | 100 +++ indexer-go/internal/grpcsrv/verify.go | 156 ++++ indexer-go/internal/grpcsrv/verify_test.go | 219 +++++ indexer-go/pb/cryptopets.pb.go | 765 +++++++++++++++++- indexer-go/pb/cryptopets_grpc.pb.go | 54 ++ proto/cryptopets.proto | 88 ++ 23 files changed, 2724 insertions(+), 47 deletions(-) create mode 100644 backend/prisma/migrations/20260726110000_add_verification_detail/migration.sql create mode 100644 backend/src/features/battle-worker/verify.worker.ts create mode 100644 backend/src/grpc/verifyBattle.ts create mode 100644 backend/tests/features/battle-worker/verify.worker.test.ts create mode 100644 backend/tests/grpc/verifyBattle.test.ts create mode 100644 indexer-go/internal/combat/progression.go create mode 100644 indexer-go/internal/combat/progression_test.go create mode 100644 indexer-go/internal/combat/simlog.go create mode 100644 indexer-go/internal/combat/simlog_test.go create mode 100644 indexer-go/internal/combat/verify.go create mode 100644 indexer-go/internal/grpcsrv/verify.go create mode 100644 indexer-go/internal/grpcsrv/verify_test.go diff --git a/backend/env.example b/backend/env.example index f781b057..ee457e40 100644 --- a/backend/env.example +++ b/backend/env.example @@ -66,8 +66,13 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # Leave unset to allow all origins (default for local dev). # CORS_ORIGIN=https://your-frontend.onrender.com,https://your-domain.com -# --- indexer-go gRPC link (optional) --- -# StreamLiveBattles: chain-truth battle pushes from the Go indexer. Unset = off. +# --- indexer-go gRPC link --- +# StreamLiveBattles: chain-truth battle pushes from the Go indexer. Also gates +# VerifyBattle, the independent Go recomputation backend battles require before +# signing (§F) — unlike the read paths below, which fail open, verification +# fails CLOSED: with this unset, no backend battle can pass the 'verify' stage +# unless BATTLE_SIGNER_REQUIRED_ATTESTERS omits go-verifier (it does by +# default; add it once this is configured everywhere backend battles run). # INDEXER_GRPC_ADDR=localhost:50051 # Path to the shared proto contract. Optional — auto-finds proto/cryptopets.proto # from monorepo root (Render) or ../proto (local backend cwd). diff --git a/backend/prisma/migrations/20260726110000_add_verification_detail/migration.sql b/backend/prisma/migrations/20260726110000_add_verification_detail/migration.sql new file mode 100644 index 00000000..38ec9bb4 --- /dev/null +++ b/backend/prisma/migrations/20260726110000_add_verification_detail/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "battle_ledger" ADD COLUMN "verification_detail" JSONB; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 51129cb7..b3a83d67 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -313,6 +313,14 @@ model BattleLedger { combatLog Json? @map("combat_log") combatLogHash String? @map("combat_log_hash") progression Json? + /// The independent Go recomputation, retained whether it matched or not (§F). + /// On a match this is what a `go-verifier` EngineAttestation is built from at + /// signing time; on a mismatch it is the postmortem — the whole point of + /// retaining both outputs is that nobody has to reproduce the disagreement by + /// re-running indexer-go against a battle that may no longer reconstruct the + /// same way (a ruleset or dependency change between the incident and the + /// investigation). + verificationDetail Json? @map("verification_detail") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/src/features/battle-worker/index.ts b/backend/src/features/battle-worker/index.ts index 7d13d0be..dbddce65 100644 --- a/backend/src/features/battle-worker/index.ts +++ b/backend/src/features/battle-worker/index.ts @@ -1,5 +1,6 @@ export { processAwaitBeaconMessage } from './beacon.worker'; export { processComputeMessage } from './compute.worker'; +export { processVerifyMessage } from './verify.worker'; export { type BattleWorkerHandle, runBattleWorkerOnce, diff --git a/backend/src/features/battle-worker/runner.ts b/backend/src/features/battle-worker/runner.ts index 1dec8936..ebad7bdf 100644 --- a/backend/src/features/battle-worker/runner.ts +++ b/backend/src/features/battle-worker/runner.ts @@ -3,6 +3,7 @@ import { type ClaimedMessage, claimOutbox, failOutbox, OUTBOX_TOPICS } from '@fe import { processAwaitBeaconMessage } from './beacon.worker'; import { processComputeMessage } from './compute.worker'; +import { processVerifyMessage } from './verify.worker'; /** * Dispatches claimed outbox messages to their handler. @@ -17,6 +18,7 @@ import { processComputeMessage } from './compute.worker'; const HANDLERS: Record Promise> = { [OUTBOX_TOPICS.awaitBeacon]: processAwaitBeaconMessage, [OUTBOX_TOPICS.compute]: processComputeMessage, + [OUTBOX_TOPICS.verify]: processVerifyMessage, }; /** One poll: claims due messages for the topics this worker owns and processes each in turn. */ diff --git a/backend/src/features/battle-worker/verify.worker.ts b/backend/src/features/battle-worker/verify.worker.ts new file mode 100644 index 00000000..d9006159 --- /dev/null +++ b/backend/src/features/battle-worker/verify.worker.ts @@ -0,0 +1,211 @@ +import { + type BattleSnapshot, + type Hex, + hashCombatLog, + loadRulesetBundle, + type PetProgression, + type ProgressionDelta, + type SimOutcome, +} from '@cryptopets/protocol'; +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 { callVerifyBattle, type VerifyBattleWire, type VerifyPetProgressionWire } from '@grpc-client/verifyBattle'; + +/** + * Handles `verify` messages: `computed` -> `verified` (§F). + * + * This is the circuit breaker the architecture doc describes: before a receipt + * can be signed, the independent Go recomputation has to agree with the + * TypeScript engine's own result exactly, on winner, rounds, winner HP, the + * combat-log hash, and the full progression delta for both pets. Any + * disagreement stops signing for this battle and moves it to + * `verification_failed` with both outputs retained — never a silent + * preference for one implementation over the other. + * + * A failure to even *run* Go's recomputation (indexer-go unreachable, not + * configured, breaker open) is a different thing entirely from a mismatch, + * and is handled differently: it throws, which the dispatcher turns into a + * real job failure with backoff and eventual dead-lettering. Treating "we + * could not check" the same as "they disagree" would be wrong in both + * directions — it would forfeit a battle over a transient network blip, and + * it would make a genuine disagreement look like ordinary infrastructure + * flakiness. + */ +export async function processVerifyMessage(message: ClaimedMessage, nowSeconds: number): Promise { + const battle = await prisma.battleLedger.findUnique({ where: { battleId: message.battleId } }); + if (!battle) { + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + if (battle.state !== BattleState.computed) { + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + if (!battle.seed || !battle.combatLogHash || battle.rounds === null || battle.winnerHpRemaining === null || battle.attackerWon === null) { + throw new Error(`battle ${battle.battleId} is computed but is missing a computed field`); + } + + const rulesetRow = await prisma.battleRuleset.findUnique({ where: { rulesetHash: battle.rulesetHash } }); + if (!rulesetRow) { + throw new Error(`no published ruleset bundle for ${battle.rulesetHash}; cannot verify battle ${battle.battleId}`); + } + 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 outcome = await callVerifyBattle({ + attacker: toWirePet(attacker), + defender: toWirePet(defender), + seed: battle.seed, + skillConfig: ruleset.skillConfig, + maxLevel: ruleset.maxLevel, + }); + if (!outcome.ok) { + // A real failure, not a disagreement: let the dispatcher's backoff handle it. + throw new Error(`indexer-go verification unavailable (${outcome.reason}): ${outcome.detail}`); + } + + const mismatches = compareEverything(battle, outcome.response); + + const verificationDetail = serializeBigints({ + goResponse: outcome.response, + mismatches, + checkedAt: nowSeconds, + }); + + if (mismatches.length === 0) { + await applyTransition({ + battleId: battle.battleId, + from: BattleState.computed, + to: BattleState.verified, + patch: { verificationDetail }, + outbox: [{ battleId: battle.battleId, topic: OUTBOX_TOPICS.sign }], + }); + } else { + await applyTransition({ + battleId: battle.battleId, + from: BattleState.computed, + to: BattleState.verification_failed, + patch: { + failureReason: `engine mismatch: ${mismatches.join('; ')}`, + verificationDetail, + }, + }); + } + await completeOutbox(message.id, new Date(nowSeconds * 1000)); +} + +function toWirePet(pet: Record) { + 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), + lastOpponentId: String(pet.lastOpponentId), + streak: Number(pet.streak), + }; +} + +/** + * Every field §F requires to match: winner, rounds, winner HP, the combat-log + * hash (recomputed here from Go's structured log using the same canonical + * encoder the TypeScript engine's own hash was taken with — see + * indexer-go/internal/combat/verify.go's doc comment for why Go never + * reimplements that encoding itself), and the full progression delta for both + * pets. + */ +function compareEverything( + battle: { + attackerWon: boolean | null; + rounds: number | null; + winnerHpRemaining: number | null; + combatLogHash: string | null; + progression: Prisma.JsonValue; + }, + go: VerifyBattleWire, +): string[] { + const mismatches: string[] = []; + + if (battle.attackerWon !== go.firstWins) { + mismatches.push(`winner: ts=${battle.attackerWon} go=${go.firstWins}`); + } + if (battle.rounds !== go.rounds) { + mismatches.push(`rounds: ts=${battle.rounds} go=${go.rounds}`); + } + if (battle.winnerHpRemaining !== go.winnerHpRemaining) { + mismatches.push(`winnerHpRemaining: ts=${battle.winnerHpRemaining} go=${go.winnerHpRemaining}`); + } + + const goOutcome: SimOutcome = { + result: { firstWins: go.firstWins, rounds: go.rounds, winnerHpRemaining: go.winnerHpRemaining }, + log: go.log.map((entry) => ({ + round: entry.round, + attacker: entry.attacker as 1 | 2, + isMagic: entry.isMagic, + crit: entry.crit, + damage: BigInt(entry.damage), + heal: BigInt(entry.heal), + elementMult: entry.elementMult, + furyTriggered: entry.furyTriggered, + rebirthTriggered: entry.rebirthTriggered, + hp1After: BigInt(entry.hp1After), + hp2After: BigInt(entry.hp2After), + })), + startHp1: BigInt(go.startHp1), + startHp2: BigInt(go.startHp2), + }; + const goCombatLogHash = hashCombatLog(goOutcome); + if (goCombatLogHash.toLowerCase() !== battle.combatLogHash?.toLowerCase()) { + mismatches.push(`combatLogHash: ts=${battle.combatLogHash} go=${goCombatLogHash}`); + } + + const tsProgression = battle.progression as unknown as ProgressionDelta; + mismatches.push(...compareProgression('attacker', tsProgression?.attacker, go.attacker)); + mismatches.push(...compareProgression('defender', tsProgression?.defender, go.defender)); + + return mismatches; +} + +const PROGRESSION_FIELDS = [ + 'petId', + 'won', + 'decayShift', + 'xpAwarded', + 'lastOpponentId', + 'streak', + 'level', + 'xp', + 'leveledUp', +] as const; + +function compareProgression( + side: string, + ts: PetProgression | undefined, + go: VerifyPetProgressionWire, +): string[] { + if (!ts) { + return [`${side} progression: missing on the TS side`]; + } + const tsRecord = ts as unknown as Record; + const goRecord = go as unknown as Record; + const mismatches: string[] = []; + for (const field of PROGRESSION_FIELDS) { + // Stringified so bigint (TS) and string (Go wire) forms of petId/lastOpponentId + // compare equal, and every other field compares the same way. + if (String(tsRecord[field]) !== String(goRecord[field])) { + mismatches.push(`${side}.${field}: ts=${String(tsRecord[field])} go=${String(goRecord[field])}`); + } + } + return mismatches; +} + +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/grpc/verifyBattle.ts b/backend/src/grpc/verifyBattle.ts new file mode 100644 index 00000000..26eb3925 --- /dev/null +++ b/backend/src/grpc/verifyBattle.ts @@ -0,0 +1,178 @@ +import * as grpc from '@grpc/grpc-js'; + +import { env } from '@config/env'; + +import { createCircuitBreaker } from './circuitBreaker'; +import { loadGameDataService } from './gameData'; + +/** + * VerifyBattle client: the independent Go recomputation §F requires before a + * receipt can be signed. + * + * Deliberately **fail-closed**, unlike this directory's other clients + * (roster reads, EstimateWin), which fail open because a degraded read is an + * acceptable UX cost. A degraded *verification* is not acceptable: skipping it + * would mean signing on the TypeScript engine's word alone, exactly what the + * independent check exists to prevent. So every failure here — no address + * configured, breaker open, timeout, transport error — comes back as a + * `{ ok: false }` the caller must treat as "verification did not happen," + * never as "verification passed." The breaker still exists, for the same + * reason it exists on the read paths: skip calls to a process that is + * clearly down rather than paying the deadline on every one, but skipping + * here still resolves to a failure the caller retries, not a silent pass. + */ + +const DEADLINE_MS = 2000; +const BREAKER_THRESHOLD = 3; +const BREAKER_COOLDOWN_MS = 30_000; + +export interface VerifyPetInputsWire { + petId: string; + dna: string; + rarity: number; + level: number; + skill: number; + xp: number; + lastOpponentId: string; + streak: number; +} + +export interface VerifySkillConfigWire { + tankHpMult: number; + shellDefMult: number; + swiftCritBonus: number; + cunningCritCap: number; + furyDmgMult: number; + furyHpThreshold: number; + sageMdefMult: number; + bloodlustBps: number; +} + +export interface VerifyBattleParams { + attacker: VerifyPetInputsWire; + defender: VerifyPetInputsWire; + /** 32-byte seed, 0x-hex. */ + seed: string; + skillConfig: VerifySkillConfigWire; + maxLevel: number; +} + +export interface VerifyStrikeLogEntryWire { + round: number; + attacker: number; + isMagic: boolean; + crit: boolean; + damage: string; + heal: string; + elementMult: number; + furyTriggered: boolean; + rebirthTriggered: boolean; + hp1After: number; + hp2After: number; +} + +export interface VerifyPetProgressionWire { + petId: string; + won: boolean; + decayShift: number; + xpAwarded: number; + lastOpponentId: string; + streak: number; + level: number; + xp: number; + leveledUp: boolean; +} + +export interface VerifyBattleWire { + firstWins: boolean; + rounds: number; + winnerHpRemaining: number; + startHp1: number; + startHp2: number; + log: VerifyStrikeLogEntryWire[]; + attacker: VerifyPetProgressionWire; + defender: VerifyPetProgressionWire; +} + +export type VerifyBattleResult = + | { ok: true; response: VerifyBattleWire } + | { ok: false; reason: 'not-configured' | 'breaker-open' | 'transport-error'; detail: string }; + +type VerifyClient = grpc.Client & { + verifyBattle( + request: Record, + options: grpc.CallOptions, + callback: (err: grpc.ServiceError | null, res: VerifyBattleWire) => void, + ): void; +}; + +let client: VerifyClient | null = null; +let breaker = createCircuitBreaker({ + threshold: BREAKER_THRESHOLD, + cooldownMs: BREAKER_COOLDOWN_MS, + label: '[verify-battle-grpc]', +}); + +function getClient(): VerifyClient | null { + const { addr } = env.indexerGrpc; + if (!addr) return null; + if (!client) { + const Service = loadGameDataService(); + client = new Service(addr, grpc.credentials.createInsecure()) as VerifyClient; + } + return client; +} + +/** Resets the cached client and the circuit breaker. Tests only. */ +export function resetVerifyBattleClient(): void { + client = null; + breaker = createCircuitBreaker({ + threshold: BREAKER_THRESHOLD, + cooldownMs: BREAKER_COOLDOWN_MS, + label: '[verify-battle-grpc]', + }); +} + +export function callVerifyBattle(params: VerifyBattleParams): Promise { + if (!env.indexerGrpc.addr) { + return Promise.resolve({ + ok: false, + reason: 'not-configured', + detail: 'INDEXER_GRPC_ADDR is not set; independent verification cannot run', + }); + } + if (!breaker.allows()) { + return Promise.resolve({ + ok: false, + reason: 'breaker-open', + detail: 'indexer-go verify breaker is open after repeated failures', + }); + } + const verifyClient = getClient(); + if (!verifyClient) { + return Promise.resolve({ ok: false, reason: 'not-configured', detail: 'no gRPC client available' }); + } + + return new Promise((resolve) => { + const deadline = new Date(Date.now() + DEADLINE_MS); + verifyClient.verifyBattle( + { + attacker: params.attacker, + defender: params.defender, + seed: Buffer.from(params.seed.replace(/^0x/, ''), 'hex'), + skillConfig: params.skillConfig, + maxLevel: params.maxLevel, + }, + { deadline }, + (err, res) => { + if (err) { + breaker.recordFailure(err.message); + resolve({ ok: false, reason: 'transport-error', detail: err.message }); + return; + } + breaker.recordSuccess(); + resolve({ ok: true, response: res }); + }, + ); + }); +} diff --git a/backend/tests/features/battle-worker/runner.test.ts b/backend/tests/features/battle-worker/runner.test.ts index 3077ccee..d1e368e5 100644 --- a/backend/tests/features/battle-worker/runner.test.ts +++ b/backend/tests/features/battle-worker/runner.test.ts @@ -7,7 +7,7 @@ vi.mock('@config/env', () => ({ vi.mock('@features/battle-ledger', () => ({ claimOutbox: vi.fn(), failOutbox: vi.fn(), - OUTBOX_TOPICS: { awaitBeacon: 'await-beacon', compute: 'compute' }, + OUTBOX_TOPICS: { awaitBeacon: 'await-beacon', compute: 'compute', verify: 'verify' }, })); vi.mock('@features/battle-worker/beacon.worker', () => ({ @@ -16,6 +16,9 @@ vi.mock('@features/battle-worker/beacon.worker', () => ({ vi.mock('@features/battle-worker/compute.worker', () => ({ processComputeMessage: vi.fn(), })); +vi.mock('@features/battle-worker/verify.worker', () => ({ + processVerifyMessage: vi.fn(), +})); import { claimOutbox, failOutbox } from '@features/battle-ledger'; import { processAwaitBeaconMessage } from '@features/battle-worker/beacon.worker'; @@ -51,7 +54,7 @@ describe('dispatch', () => { it('claims only the topics this worker owns, with the configured batch size', async () => { vi.mocked(claimOutbox).mockResolvedValue([]); await runBattleWorkerOnce('worker-a', NOW); - expect(claimOutbox).toHaveBeenCalledWith(['await-beacon', 'compute'], 'worker-a', 10, NOW); + expect(claimOutbox).toHaveBeenCalledWith(['await-beacon', 'compute', 'verify'], 'worker-a', 10, NOW); }); it('sends a real handler failure through failOutbox for backoff, not a silent swallow', async () => { diff --git a/backend/tests/features/battle-worker/verify.worker.test.ts b/backend/tests/features/battle-worker/verify.worker.test.ts new file mode 100644 index 00000000..e56c9606 --- /dev/null +++ b/backend/tests/features/battle-worker/verify.worker.test.ts @@ -0,0 +1,297 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + computeProgression, + deriveBattleSeed, + hashCombatLog, + hashRuleset, + publishRuleset, + QUICKNET, + roundTime, + simulate, + SOURCE_DEFAULT_RULESET, +} from '@cryptopets/protocol'; + +vi.mock('@config/prisma', () => ({ + prisma: { + battleLedger: { findUnique: vi.fn() }, + battleRuleset: { findUnique: vi.fn() }, + }, +})); + +vi.mock('@features/battle-ledger', () => ({ + applyTransition: vi.fn(), + completeOutbox: vi.fn(), + OUTBOX_TOPICS: { sign: 'sign' }, +})); + +vi.mock('@grpc-client/verifyBattle', () => ({ + callVerifyBattle: vi.fn(), +})); + +import { prisma } from '@config/prisma'; +import { applyTransition, completeOutbox } from '@features/battle-ledger'; +import { processVerifyMessage } from '@features/battle-worker'; +import { callVerifyBattle } from '@grpc-client/verifyBattle'; + +const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); +const NOW = roundTime(QUICKNET, 1000) + 5; +const DOMAIN = { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }; + +const ATTACKER_FIXTURE = { + petId: '1', + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: '1234567890123456', + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: '0', + streak: 0, + readyAt: NOW - 100, + sourceVersion: '1000', +}; +const DEFENDER_FIXTURE = { + ...ATTACKER_FIXTURE, + petId: '2', + owner: '0x2222222222222222222222222222222222222222', + dna: '6543210987654321', + rarity: 2, + level: 11, + skill: 7, + lastOpponentId: '1', + streak: 2, +}; + +const SNAPSHOT = { domain: DOMAIN, attacker: ATTACKER_FIXTURE, defender: DEFENDER_FIXTURE, takenAt: NOW - 6 }; + +const seed = deriveBattleSeed({ + domain: DOMAIN as never, + drandRandomness: '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd', + battleId: 'btl_1', + snapshotHash: `0x${'11'.repeat(32)}`, + rulesetHash: RULESET_HASH, +}); + +const outcome = simulate( + BigInt(ATTACKER_FIXTURE.dna), + ATTACKER_FIXTURE.rarity, + ATTACKER_FIXTURE.level, + ATTACKER_FIXTURE.skill, + BigInt(DEFENDER_FIXTURE.dna), + DEFENDER_FIXTURE.rarity, + DEFENDER_FIXTURE.level, + DEFENDER_FIXTURE.skill, + seed.value, + SOURCE_DEFAULT_RULESET.skillConfig, +); +const combatLogHash = hashCombatLog(outcome); +const progression = computeProgression( + { + domain: DOMAIN as never, + attacker: { + ...ATTACKER_FIXTURE, + petId: 1n, + dna: BigInt(ATTACKER_FIXTURE.dna), + lastOpponentId: 0n, + sourceVersion: BigInt(ATTACKER_FIXTURE.sourceVersion), + } as never, + defender: { + ...DEFENDER_FIXTURE, + petId: 2n, + dna: BigInt(DEFENDER_FIXTURE.dna), + lastOpponentId: 1n, + sourceVersion: BigInt(DEFENDER_FIXTURE.sourceVersion), + } as never, + takenAt: SNAPSHOT.takenAt, + }, + outcome.result.firstWins, +); + +const BATTLE = { + battleId: 'btl_1', + rulesetHash: RULESET_HASH, + snapshot: SNAPSHOT, + seed: seed.hex, + state: 'computed', + attackerWon: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + combatLogHash, + progression: JSON.parse(JSON.stringify(progression, (_k, v) => (typeof v === 'bigint' ? v.toString() : v))), +}; + +const MESSAGE = { id: 'msg_1', battleId: 'btl_1', topic: 'verify', payload: {}, attempts: 1 }; + +/** Converts the real TS outcome/progression into the shape Go's wire response takes. */ +function goWireAgreeing() { + return { + firstWins: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + startHp1: Number(outcome.startHp1), + startHp2: Number(outcome.startHp2), + log: outcome.log.map((e) => ({ + round: e.round, + attacker: e.attacker, + isMagic: e.isMagic, + crit: e.crit, + damage: e.damage.toString(), + heal: e.heal.toString(), + elementMult: e.elementMult, + furyTriggered: e.furyTriggered, + rebirthTriggered: e.rebirthTriggered, + hp1After: Number(e.hp1After), + hp2After: Number(e.hp2After), + })), + attacker: { + petId: progression.attacker.petId.toString(), + won: progression.attacker.won, + decayShift: progression.attacker.decayShift, + xpAwarded: progression.attacker.xpAwarded, + lastOpponentId: progression.attacker.lastOpponentId.toString(), + streak: progression.attacker.streak, + level: progression.attacker.level, + xp: progression.attacker.xp, + leveledUp: progression.attacker.leveledUp, + }, + defender: { + petId: progression.defender.petId.toString(), + won: progression.defender.won, + decayShift: progression.defender.decayShift, + xpAwarded: progression.defender.xpAwarded, + lastOpponentId: progression.defender.lastOpponentId.toString(), + streak: progression.defender.streak, + level: progression.defender.level, + xp: progression.defender.xp, + leveledUp: progression.defender.leveledUp, + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(BATTLE as never); + const { json } = publishRuleset(SOURCE_DEFAULT_RULESET); + vi.mocked(prisma.battleRuleset.findUnique).mockResolvedValue({ bundle: JSON.parse(json) } as never); +}); + +describe('agreement', () => { + it('moves to verified and enqueues sign when everything matches', async () => { + vi.mocked(callVerifyBattle).mockResolvedValue({ ok: true, response: goWireAgreeing() as never }); + + await processVerifyMessage(MESSAGE, NOW); + + const call = vi.mocked(applyTransition).mock.calls[0]![0] as { from: string; to: string; outbox: { topic: string }[] }; + expect(call.from).toBe('computed'); + expect(call.to).toBe('verified'); + expect(call.outbox[0]!.topic).toBe('sign'); + expect(completeOutbox).toHaveBeenCalled(); + }); + + it('recomputes the combat-log hash from Go structured log using the real canonical encoder', async () => { + // Not comparing a hash Go sent — Go never sends one. This is the property that + // makes the check meaningful: the same encoder, fed two engines' outputs. + vi.mocked(callVerifyBattle).mockResolvedValue({ ok: true, response: goWireAgreeing() as never }); + await processVerifyMessage(MESSAGE, NOW); + expect(vi.mocked(applyTransition).mock.calls[0]![0]).toMatchObject({ to: 'verified' }); + }); +}); + +describe('disagreement', () => { + it('moves to verification_failed and retains both outputs when the winner disagrees', async () => { + const wire = goWireAgreeing(); + wire.firstWins = !wire.firstWins; + vi.mocked(callVerifyBattle).mockResolvedValue({ ok: true, response: wire as never }); + + await processVerifyMessage(MESSAGE, NOW); + + const call = vi.mocked(applyTransition).mock.calls.at(-1)![0] as { + to: string; + patch: { failureReason: string; verificationDetail: { mismatches: string[] } }; + }; + expect(call.to).toBe('verification_failed'); + expect(call.patch.failureReason).toContain('winner'); + expect(call.patch.verificationDetail.mismatches.length).toBeGreaterThan(0); + }); + + it('flags a progression mismatch even when the fight result agrees', async () => { + const wire = goWireAgreeing(); + wire.attacker.xp = wire.attacker.xp + 9999; + vi.mocked(callVerifyBattle).mockResolvedValue({ ok: true, response: wire as never }); + + await processVerifyMessage(MESSAGE, NOW); + + const call = vi.mocked(applyTransition).mock.calls[0]![0] as { to: string; patch: { failureReason: string } }; + expect(call.to).toBe('verification_failed'); + expect(call.patch.failureReason).toContain('attacker.xp'); + }); + + it('flags a combat-log divergence even when the summary result agrees', async () => { + const wire = goWireAgreeing(); + wire.log[0]!.damage = String(BigInt(wire.log[0]!.damage) + 1n); + vi.mocked(callVerifyBattle).mockResolvedValue({ ok: true, response: wire as never }); + + await processVerifyMessage(MESSAGE, NOW); + + const call = vi.mocked(applyTransition).mock.calls[0]![0] as { to: string; patch: { failureReason: string } }; + expect(call.to).toBe('verification_failed'); + expect(call.patch.failureReason).toContain('combatLogHash'); + }); + + it('never signs on a mismatch: no sign message is ever enqueued', async () => { + const wire = goWireAgreeing(); + wire.rounds = wire.rounds + 1; + vi.mocked(callVerifyBattle).mockResolvedValue({ ok: true, response: wire as never }); + + await processVerifyMessage(MESSAGE, NOW); + + const call = vi.mocked(applyTransition).mock.calls[0]![0] as { outbox?: unknown[] }; + expect(call.outbox ?? []).toEqual([]); + }); +}); + +describe('a verification failure is not a disagreement', () => { + it('throws when indexer-go is unreachable, rather than treating it as a mismatch', async () => { + vi.mocked(callVerifyBattle).mockResolvedValue({ + ok: false, + reason: 'transport-error', + detail: 'deadline exceeded', + }); + + await expect(processVerifyMessage(MESSAGE, NOW)).rejects.toThrow(/deadline exceeded/); + // A real failure must go through the dispatcher's backoff, not be recorded as a + // verified/verification_failed transition. + expect(applyTransition).not.toHaveBeenCalled(); + }); + + it('throws when indexer-go is not configured', async () => { + vi.mocked(callVerifyBattle).mockResolvedValue({ + ok: false, + reason: 'not-configured', + detail: 'INDEXER_GRPC_ADDR is not set', + }); + await expect(processVerifyMessage(MESSAGE, NOW)).rejects.toThrow(/not-configured|not set/); + }); +}); + +describe('idempotence', () => { + it('completes without acting when the battle has already moved on', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ ...BATTLE, state: 'verified' } as never); + await processVerifyMessage(MESSAGE, NOW); + expect(applyTransition).not.toHaveBeenCalled(); + expect(completeOutbox).toHaveBeenCalled(); + }); + + it('completes without acting when the battle no longer exists', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(null); + await processVerifyMessage(MESSAGE, NOW); + expect(applyTransition).not.toHaveBeenCalled(); + expect(completeOutbox).toHaveBeenCalled(); + }); + + it('throws if computed but missing a computed field, rather than verifying garbage', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ ...BATTLE, combatLogHash: null } as never); + await expect(processVerifyMessage(MESSAGE, NOW)).rejects.toThrow(/missing a computed field/); + }); +}); diff --git a/backend/tests/grpc/verifyBattle.test.ts b/backend/tests/grpc/verifyBattle.test.ts new file mode 100644 index 00000000..1aa47512 --- /dev/null +++ b/backend/tests/grpc/verifyBattle.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const verifyBattleMock = vi.hoisted(() => vi.fn()); +const envMock = vi.hoisted(() => ({ + indexerGrpc: { addr: 'localhost:50051' as string | undefined, protoPath: undefined }, +})); + +vi.mock('@config/env', () => ({ env: envMock })); + +vi.mock('../../src/grpc/gameData', () => ({ + loadGameDataService: () => + class { + verifyBattle = verifyBattleMock; + }, +})); + +import { callVerifyBattle, resetVerifyBattleClient } from '../../src/grpc/verifyBattle'; + +const PARAMS = { + attacker: { petId: '1', dna: '1234567890123456', rarity: 3, level: 10, skill: 4, xp: 0, lastOpponentId: '0', streak: 0 }, + defender: { petId: '2', dna: '6543210987654321', rarity: 2, level: 11, skill: 7, xp: 0, lastOpponentId: '0', streak: 0 }, + seed: `0x${'ab'.repeat(32)}`, + skillConfig: { + tankHpMult: 120, + shellDefMult: 125, + swiftCritBonus: 50, + cunningCritCap: 4000, + furyDmgMult: 130, + furyHpThreshold: 3000, + sageMdefMult: 125, + bloodlustBps: 150, + }, + maxLevel: 100, +}; + +beforeEach(() => { + vi.clearAllMocks(); + resetVerifyBattleClient(); + envMock.indexerGrpc.addr = 'localhost:50051'; +}); + +afterEach(() => { + resetVerifyBattleClient(); +}); + +describe('fail-closed by contract', () => { + it('reports not-configured rather than resolving as if verification passed', async () => { + envMock.indexerGrpc.addr = undefined; + const result = await callVerifyBattle(PARAMS); + expect(result).toMatchObject({ ok: false, reason: 'not-configured' }); + expect(verifyBattleMock).not.toHaveBeenCalled(); + }); + + it('surfaces a transport error as a failure, never as a silent pass', async () => { + verifyBattleMock.mockImplementation((_req, _opts, cb) => cb({ message: 'deadline exceeded' }, null)); + const result = await callVerifyBattle(PARAMS); + expect(result).toMatchObject({ ok: false, reason: 'transport-error', detail: 'deadline exceeded' }); + }); + + it('opens the breaker after repeated failures and still reports a failure, not a pass', async () => { + verifyBattleMock.mockImplementation((_req, _opts, cb) => cb({ message: 'down' }, null)); + await callVerifyBattle(PARAMS); + await callVerifyBattle(PARAMS); + await callVerifyBattle(PARAMS); + + const result = await callVerifyBattle(PARAMS); + expect(result).toMatchObject({ ok: false, reason: 'breaker-open' }); + // The breaker being open must not translate into fewer gRPC attempts being treated + // as a pass: only 3 real attempts happened, this 4th was skipped and still failed. + expect(verifyBattleMock).toHaveBeenCalledTimes(3); + }); +}); + +describe('a successful call', () => { + it('resolves ok with the response and encodes the seed as raw bytes', async () => { + verifyBattleMock.mockImplementation((req, _opts, cb) => { + expect(req.seed).toBeInstanceOf(Buffer); + expect((req.seed as Buffer).length).toBe(32); + cb(null, { firstWins: true, rounds: 5, winnerHpRemaining: 100, log: [], attacker: {}, defender: {} }); + }); + const result = await callVerifyBattle(PARAMS); + expect(result).toMatchObject({ ok: true }); + }); +}); diff --git a/indexer-go/README.md b/indexer-go/README.md index 6ab4335e..43c1db2d 100644 --- a/indexer-go/README.md +++ b/indexer-go/README.md @@ -95,6 +95,30 @@ hashing is **legacy Keccak-256** with the exact `keccak256(abi.encodePacked)` byte layout; a SHA3-vs-Keccak slip would fail every vector. If a vector fails, the Go has drifted from the contracts — fix the Go, never the vector. +## Independent verification for backend-authoritative battles (§F) + +`internal/combat/verify.go` recomputes a backend-resolved battle from a frozen +snapshot and a verified drand seed, surfaced over gRPC as `VerifyBattle` +(`internal/grpcsrv/verify.go`): winner, rounds, winner HP, the full per-strike +log (`SimulateWithLog` in `simlog.go`), and the progression delta for both pets +(`progression.go`, which ported level-up from `PetCore.addXp` so this can check +the whole delta, not just the XP formula `xp.go` already covered). + +This is release safety, not a trust boundary — see +`docs/plan-backend-battle-architecture.md` §F's "what the Go verifier is for" +before reusing it as anything stronger. The backend +(`backend/src/features/battle-worker/verify.worker.ts`) calls it, converts the +structured log back into `@cryptopets/protocol`'s `SimOutcome` shape, and hashes +it with the *real* canonical encoder — Go never reimplements that encoding +itself, so the only question this check answers is whether the two engines +computed the same strikes, never whether Go's encoding agrees with TS's (there +is only one canonical encoding, and it lives in `protocol/`). + +`TestProgressionMatchesGoldenVectors` in `progression_test.go` consumes +`contracts/test-vectors/protocol-progression.json`, the same file +`protocol/tests/progression/vectors.test.ts` consumes, so the composition +around the formula (not just the formula itself) is cross-language locked too. + ## Layout ``` @@ -103,8 +127,8 @@ internal/indexer/ ChainIndexer contract + pipeline types internal/evm/ subgraph watermark adapter (pets + battles) internal/solana/ WS push adapter, Borsh decode, reconnect/backfill internal/store/ single version-guarded batch writer (pgx) -internal/combat/ pure Go combat sim (cross-chain parity via golden vectors) +internal/combat/ pure Go combat sim + independent verify (cross-chain parity via golden vectors) internal/battlebus/ fan-out to gRPC stream subscribers -internal/grpcsrv/ StreamLiveBattles + reads + EstimateWin server +internal/grpcsrv/ StreamLiveBattles + reads + EstimateWin + VerifyBattle server pb/ generated stubs (buf generate ../proto) ``` diff --git a/indexer-go/internal/combat/progression.go b/indexer-go/internal/combat/progression.go new file mode 100644 index 00000000..533f3c21 --- /dev/null +++ b/indexer-go/internal/combat/progression.go @@ -0,0 +1,94 @@ +package combat + +// Progression composition, mirroring protocol/src/progression/progression.ts's +// computeProgression. xp.go pins the formula and decay shift in isolation; this +// file is where a port most often drifts even when the formula itself is +// right — which base (100 win / 25 loss) applies to whom, whose decay shift +// applies to whom, and whether a zero-XP award still touches level/XP (it must +// not, mirroring both chains' `if (xp > 0)` guard around the write). + +// Base XP before the level multiplier and decay, mirroring +// GameLogic._calcXp's call sites. +const ( + BaseXPWin = 100 + BaseXPLoss = 25 +) + +// PetProgression is what one battle does to one pet. Mirrors protocol's +// PetProgression field for field. +type PetProgression struct { + PetID uint64 + Won bool + DecayShift uint32 + XPAwarded uint32 + LastOpponentID uint64 + Streak uint32 + Level uint16 + XP uint32 + LeveledUp bool +} + +// BattleProgression is what one battle does to both pets. +type BattleProgression struct { + Attacker PetProgression + Defender PetProgression +} + +// ComputeProgression computes the progression delta for a settled battle. +// `attackerWon` is Result.FirstWins, since the simulator states its result +// from the attacker's perspective. +func ComputeProgression(attacker, defender PetInputs, attackerWon bool, maxLevel uint16) BattleProgression { + attackerBase, defenderBase := uint32(BaseXPLoss), uint32(BaseXPWin) + if attackerWon { + attackerBase, defenderBase = BaseXPWin, BaseXPLoss + } + return BattleProgression{ + Attacker: progressionFor(attacker, defender, attackerWon, attackerBase, maxLevel), + Defender: progressionFor(defender, attacker, !attackerWon, defenderBase, maxLevel), + } +} + +func progressionFor(self, opponent PetInputs, won bool, baseXP uint32, maxLevel uint16) PetProgression { + history := RecordBattleOpponent( + OpponentHistory{LastOpponentID: self.LastOpponentID, Streak: self.Streak}, + opponent.PetID, + ) + awarded := ApplyDecayShift(calcXP(baseXP, self.Level, opponent.Level), history.DecayShift) + + // Both chains guard the XP write with `if (xp > 0)`, so a zero award leaves + // level and XP untouched rather than running the threshold check with + // nothing added. + level, xp, leveledUp := self.Level, self.XP, false + if awarded > 0 { + update := ApplyXP(LevelState{Level: self.Level, XP: self.XP}, awarded, maxLevel) + level, xp, leveledUp = update.Level, update.XP, update.LeveledUp + } + + return PetProgression{ + PetID: self.PetID, + Won: won, + DecayShift: history.DecayShift, + XPAwarded: awarded, + LastOpponentID: history.LastOpponentID, + Streak: history.Streak, + Level: level, + XP: xp, + LeveledUp: leveledUp, + } +} + +// PetInputs is one pet's frozen snapshot fields, as needed to recompute a +// fight and its progression. Deliberately not a port of protocol.PetSnapshot +// itself (Go has no such type; owner/dna-as-decimal-string/readyAt/ +// sourceVersion are not needed to run the fight or the progression math), just +// the subset both computations actually read. +type PetInputs struct { + PetID uint64 + DNA uint64 + Rarity uint8 + Level uint16 + Skill uint8 + XP uint32 + LastOpponentID uint64 + Streak uint32 +} diff --git a/indexer-go/internal/combat/progression_test.go b/indexer-go/internal/combat/progression_test.go new file mode 100644 index 00000000..e7835450 --- /dev/null +++ b/indexer-go/internal/combat/progression_test.go @@ -0,0 +1,109 @@ +package combat + +import ( + "strconv" + "testing" +) + +// Consumes contracts/test-vectors/protocol-progression.json, the same file +// protocol/tests/progression/vectors.test.ts consumes, calling the real +// ComputeProgression rather than a reimplementation of it — otherwise this +// test would only ever verify a second copy of the composition logic against +// itself. xp.json already pins the formula and decay shift cross-language +// (see TestCalcXPAndDecayMatchGoldenVectors); this file pins the composition +// around them, which is where a port most often drifts even when the formula +// itself is right. If a case fails, this Go port has drifted; fix the port, +// never the vector. +const progressionVectorsPath = "../../../contracts/test-vectors/protocol-progression.json" + +type progressionPetFixture struct { + PetID string `json:"petId"` + Level uint16 `json:"level"` + XP uint32 `json:"xp"` + LastOpponentID string `json:"lastOpponentId"` + Streak uint32 `json:"streak"` +} + +type progressionExpected struct { + PetID string `json:"petId"` + Won bool `json:"won"` + DecayShift uint32 `json:"decayShift"` + XPAwarded uint32 `json:"xpAwarded"` + LastOpponentID string `json:"lastOpponentId"` + Streak uint32 `json:"streak"` + Level uint16 `json:"level"` + XP uint32 `json:"xp"` + LeveledUp bool `json:"leveledUp"` +} + +type progressionVectors struct { + Cases []struct { + Name string `json:"name"` + Snapshot struct { + Attacker progressionPetFixture `json:"attacker"` + Defender progressionPetFixture `json:"defender"` + } `json:"snapshot"` + AttackerWon bool `json:"attackerWon"` + MaxLevel uint16 `json:"maxLevel"` + Expected struct { + Attacker progressionExpected `json:"attacker"` + Defender progressionExpected `json:"defender"` + } `json:"expected"` + } `json:"cases"` +} + +func parsePetID(t *testing.T, s string) uint64 { + t.Helper() + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + t.Fatalf("bad pet id %q: %v", s, err) + } + return v +} + +func toPetInputs(t *testing.T, f progressionPetFixture) PetInputs { + t.Helper() + return PetInputs{ + PetID: parsePetID(t, f.PetID), + Level: f.Level, + XP: f.XP, + LastOpponentID: parsePetID(t, f.LastOpponentID), + Streak: f.Streak, + } +} + +func toExpected(p PetProgression) progressionExpected { + return progressionExpected{ + PetID: strconv.FormatUint(p.PetID, 10), + Won: p.Won, + DecayShift: p.DecayShift, + XPAwarded: p.XPAwarded, + LastOpponentID: strconv.FormatUint(p.LastOpponentID, 10), + Streak: p.Streak, + Level: p.Level, + XP: p.XP, + LeveledUp: p.LeveledUp, + } +} + +func TestProgressionMatchesGoldenVectors(t *testing.T) { + var v progressionVectors + loadJSON(t, progressionVectorsPath, &v) + if len(v.Cases) == 0 { + t.Fatal("no progression vectors loaded") + } + + for _, c := range v.Cases { + attacker := toPetInputs(t, c.Snapshot.Attacker) + defender := toPetInputs(t, c.Snapshot.Defender) + + got := ComputeProgression(attacker, defender, c.AttackerWon, c.MaxLevel) + + if gotAttacker := toExpected(got.Attacker); gotAttacker != c.Expected.Attacker { + t.Errorf("vector %q attacker: got %+v, want %+v", c.Name, gotAttacker, c.Expected.Attacker) + } + if gotDefender := toExpected(got.Defender); gotDefender != c.Expected.Defender { + t.Errorf("vector %q defender: got %+v, want %+v", c.Name, gotDefender, c.Expected.Defender) + } + } +} diff --git a/indexer-go/internal/combat/simlog.go b/indexer-go/internal/combat/simlog.go new file mode 100644 index 00000000..13098055 --- /dev/null +++ b/indexer-go/internal/combat/simlog.go @@ -0,0 +1,175 @@ +package combat + +// StrikeLogEntry is one resolved attack, in fight order. Mirrors +// protocol/src/combat/sim.ts's StrikeLogEntry field for field, so a caller can +// convert this into the same shape the TypeScript engine produces and hash it +// with the identical canonical encoding — verification never needs Go to +// reimplement that encoding itself, only to reproduce the same sequence of +// strikes (see indexer-go/README.md for why this exists). +type StrikeLogEntry struct { + Round uint32 + Attacker uint8 // 1 = pet1/attacker, 2 = pet2/defender + IsMagic bool + Crit bool + Damage uint64 + Heal uint64 + ElementMult uint16 // 85 | 100 | 115 + FuryTriggered bool + RebirthTriggered bool + Hp1After uint32 + Hp2After uint32 +} + +// LoggedResult is Simulate's Result plus the per-strike log and both pets' +// starting HP (post pre-battle skill modifiers), matching protocol's +// SimOutcome shape. +type LoggedResult struct { + Result Result + Log []StrikeLogEntry + StartHp1 uint32 + StartHp2 uint32 +} + +// SimulateWithLog is Simulate's logging twin: identical fight math to +// Simulate (both call strikeDetailed under the hood, so there is exactly one +// implementation of a strike, not two that could drift), plus the per-strike +// log the TypeScript engine also produces. Used only for verification, never +// for the on-chain-facing Result Simulate itself returns. +func SimulateWithLog( + dna1 uint64, rarity1 uint8, level1 uint16, skill1 uint8, + dna2 uint64, rarity2 uint8, level2 uint16, skill2 uint8, + seed [32]byte, sc SkillConfig, +) LoggedResult { + a := Extract(dna1, rarity1, level1) + b := Extract(dna2, rarity2, level2) + + if skill1 == SkillTank { + a.HP = uint16(uint32(a.HP) * uint32(sc.TankHPMult) / 100) + } + if skill2 == SkillTank { + b.HP = uint16(uint32(b.HP) * uint32(sc.TankHPMult) / 100) + } + if skill1 == SkillShell { + a.DEF = uint16(uint32(a.DEF) * uint32(sc.ShellDefMult) / 100) + } + if skill2 == SkillShell { + b.DEF = uint16(uint32(b.DEF) * uint32(sc.ShellDefMult) / 100) + } + if skill1 == SkillSage { + a.MDEF = uint16(uint32(a.MDEF) * uint32(sc.SageMdefMult) / 100) + } + if skill2 == SkillSage { + b.MDEF = uint16(uint32(b.MDEF) * uint32(sc.SageMdefMult) / 100) + } + + hpA, hpB := uint32(a.HP), uint32(b.HP) + startHpA, startHpB := uint32(a.HP), uint32(b.HP) + + elemAB := elementMod(a.Element, b.Element) + elemBA := elementMod(b.Element, a.Element) + + var rebirthUsed1, rebirthUsed2 bool + log := make([]StrikeLogEntry, 0, MaxRounds*2) + + var r uint8 + for r = 0; r < MaxRounds && hpA > 0 && hpB > 0; r++ { + rs := roundSeed(seed, r) + + var aFirst bool + switch { + case skill1 == SkillShell && skill2 != SkillShell: + aFirst = false + case skill2 == SkillShell && skill1 != SkillShell: + aFirst = true + case a.INT != b.INT: + aFirst = a.INT > b.INT + default: + aFirst = skill1 == SkillSwift || skill2 != SkillSwift + } + + if aFirst { + first := strikeDetailed(a, skill1, hpA, startHpA, b.DEF, b.MDEF, hpB, elemAB, rs, 0, sc) + hpB = first.NewHpDef + hpA = addHeal(hpA, first.Heal, startHpA) + rebirth2 := false + if hpB == 0 && skill2 == SkillRebirth && !rebirthUsed2 { + hpB, rebirthUsed2, rebirth2 = 1, true, true + } + log = append(log, StrikeLogEntry{ + Round: uint32(r), Attacker: 1, IsMagic: first.IsMagic, Crit: first.Crit, + Damage: first.Damage, Heal: uint64(first.Heal), ElementMult: uint16(first.ElementMult), + FuryTriggered: first.FuryTriggered, RebirthTriggered: rebirth2, Hp1After: hpA, Hp2After: hpB, + }) + if hpB > 0 { + second := strikeDetailed(b, skill2, hpB, startHpB, a.DEF, a.MDEF, hpA, elemBA, rs, 2, sc) + hpA = second.NewHpDef + hpB = addHeal(hpB, second.Heal, startHpB) + rebirth1 := false + if hpA == 0 && skill1 == SkillRebirth && !rebirthUsed1 { + hpA, rebirthUsed1, rebirth1 = 1, true, true + } + log = append(log, StrikeLogEntry{ + Round: uint32(r), Attacker: 2, IsMagic: second.IsMagic, Crit: second.Crit, + Damage: second.Damage, Heal: uint64(second.Heal), ElementMult: uint16(second.ElementMult), + FuryTriggered: second.FuryTriggered, RebirthTriggered: rebirth1, Hp1After: hpA, Hp2After: hpB, + }) + } + } else { + first := strikeDetailed(b, skill2, hpB, startHpB, a.DEF, a.MDEF, hpA, elemBA, rs, 0, sc) + hpA = first.NewHpDef + hpB = addHeal(hpB, first.Heal, startHpB) + rebirth1 := false + if hpA == 0 && skill1 == SkillRebirth && !rebirthUsed1 { + hpA, rebirthUsed1, rebirth1 = 1, true, true + } + log = append(log, StrikeLogEntry{ + Round: uint32(r), Attacker: 2, IsMagic: first.IsMagic, Crit: first.Crit, + Damage: first.Damage, Heal: uint64(first.Heal), ElementMult: uint16(first.ElementMult), + FuryTriggered: first.FuryTriggered, RebirthTriggered: rebirth1, Hp1After: hpA, Hp2After: hpB, + }) + if hpA > 0 { + second := strikeDetailed(a, skill1, hpA, startHpA, b.DEF, b.MDEF, hpB, elemAB, rs, 2, sc) + hpB = second.NewHpDef + hpA = addHeal(hpA, second.Heal, startHpA) + rebirth2 := false + if hpB == 0 && skill2 == SkillRebirth && !rebirthUsed2 { + hpB, rebirthUsed2, rebirth2 = 1, true, true + } + log = append(log, StrikeLogEntry{ + Round: uint32(r), Attacker: 1, IsMagic: second.IsMagic, Crit: second.Crit, + Damage: second.Damage, Heal: uint64(second.Heal), ElementMult: uint16(second.ElementMult), + FuryTriggered: second.FuryTriggered, RebirthTriggered: rebirth2, Hp1After: hpA, Hp2After: hpB, + }) + } + } + } + + var firstWins bool + switch { + case hpA > 0 && hpB == 0: + firstWins = true + case hpB > 0 && hpA == 0: + firstWins = false + default: + bpsA := uint64(hpA) * 10000 / uint64(startHpA) + bpsB := uint64(hpB) * 10000 / uint64(startHpB) + firstWins = bpsA > bpsB + } + + winnerHP := hpB + if firstWins { + winnerHP = hpA + } + winnerHP = min(winnerHP, 0xFFFF) + + return LoggedResult{ + Result: Result{ + FirstWins: firstWins, + Rounds: r, + WinnerHpRemaining: uint16(winnerHP), + }, + Log: log, + StartHp1: startHpA, + StartHp2: startHpB, + } +} diff --git a/indexer-go/internal/combat/simlog_test.go b/indexer-go/internal/combat/simlog_test.go new file mode 100644 index 00000000..ab80d13a --- /dev/null +++ b/indexer-go/internal/combat/simlog_test.go @@ -0,0 +1,69 @@ +package combat + +import "testing" + +// SimulateWithLog must never disagree with Simulate: both are built from the same +// strikeDetailed calls, so any divergence here would mean the logging path took a +// different branch than the result path — exactly the kind of drift a caller +// trusting the log to explain a signed result cannot afford. +func TestSimulateWithLogMatchesSimulate(t *testing.T) { + var v battleVectors + loadJSON(t, battleVectorsPath, &v) + if len(v.Cases) == 0 { + t.Fatal("no battle vectors loaded") + } + sc := DefaultSkillConfig() + + for _, c := range v.Cases { + plain := Simulate( + parseDNA(t, c.DNA1), c.Rarity1, c.Level1, c.Skill1, + parseDNA(t, c.DNA2), c.Rarity2, c.Level2, c.Skill2, + seedBytes(t, c.Seed), sc, + ) + logged := SimulateWithLog( + parseDNA(t, c.DNA1), c.Rarity1, c.Level1, c.Skill1, + parseDNA(t, c.DNA2), c.Rarity2, c.Level2, c.Skill2, + seedBytes(t, c.Seed), sc, + ) + + if logged.Result != plain { + t.Errorf("vector %q: SimulateWithLog result %+v != Simulate result %+v", c.Name, logged.Result, plain) + } + if len(logged.Log) == 0 { + t.Errorf("vector %q: empty log", c.Name) + } + } +} + +// The log must actually explain the result it is attached to: the final entry's +// HP has to match the winner's remaining HP the result reports (capped the same +// way), and the round it is stamped with has to match Rounds-1. This would catch +// a bug where the log-recording path diverges from the math it claims to +// describe. +func TestSimulateWithLogEntriesExplainResult(t *testing.T) { + var v battleVectors + loadJSON(t, battleVectorsPath, &v) + sc := DefaultSkillConfig() + + for _, c := range v.Cases { + logged := SimulateWithLog( + parseDNA(t, c.DNA1), c.Rarity1, c.Level1, c.Skill1, + parseDNA(t, c.DNA2), c.Rarity2, c.Level2, c.Skill2, + seedBytes(t, c.Seed), sc, + ) + last := logged.Log[len(logged.Log)-1] + if last.Round != uint32(logged.Result.Rounds)-1 { + t.Errorf("vector %q: last entry round %d, want %d", c.Name, last.Round, logged.Result.Rounds-1) + } + winnerHP := last.Hp2After + if logged.Result.FirstWins { + winnerHP = last.Hp1After + } + if winnerHP > 0xFFFF { + winnerHP = 0xFFFF + } + if uint16(winnerHP) != logged.Result.WinnerHpRemaining { + t.Errorf("vector %q: log implies winner HP %d, result says %d", c.Name, winnerHP, logged.Result.WinnerHpRemaining) + } + } +} diff --git a/indexer-go/internal/combat/strike.go b/indexer-go/internal/combat/strike.go index 0f746d37..20334c4f 100644 --- a/indexer-go/internal/combat/strike.go +++ b/indexer-go/internal/combat/strike.go @@ -1,12 +1,26 @@ package combat -// strike executes one attack. Returns (newHpDef, atkHeal) where atkHeal is -// Bloodlust lifesteal. Mirrors CombatSim._strike / combat::strike. -func strike( +// StrikeOutcome carries every value one strike computes. `Simulate` and +// `SimulateWithLog` both derive from `strikeDetailed`, so the logged +// per-strike detail can never drift from the math the result itself is +// computed from — there is exactly one place damage, crit, and element are +// decided. +type StrikeOutcome struct { + NewHpDef uint32 + Heal uint32 + IsMagic bool + Crit bool + Damage uint64 + ElementMult uint64 + FuryTriggered bool +} + +// strikeDetailed executes one attack. Mirrors CombatSim._strike / combat::strike. +func strikeDetailed( atk Attrs, atkSkill uint8, hpAtk, startHpAtk uint32, defDef, defMdef uint16, hpDef uint32, elemMult uint64, rs [32]byte, slotOffset uint8, sc SkillConfig, -) (newHpDef, atkHeal uint32) { +) StrikeOutcome { total := uint64(atk.ATK) + uint64(atk.INT) pMagicBps := 10000 * uint64(atk.INT) / total typeRoll := strikeRoll(rs, slotOffset) @@ -30,9 +44,11 @@ func strike( dmg = dmg * effElem / 100 // Fury: +furyDmgMult% while own HP < furyHpThreshold bps of start. + furyTriggered := false if atkSkill == SkillFury && startHpAtk > 0 { if uint64(hpAtk)*10000/uint64(startHpAtk) < uint64(sc.FuryHPThreshold) { dmg = dmg * uint64(sc.FuryDmgMult) / 100 + furyTriggered = true } } @@ -46,22 +62,45 @@ func strike( critBase += uint64(sc.SwiftCritBonus) } critBps := min(critBase+25*uint64(atk.INT), critCap) - if strikeRoll(rs, slotOffset+1) < critBps { + crit := strikeRoll(rs, slotOffset+1) < critBps + if crit { dmg = dmg * 150 / 100 } if dmg == 0 { dmg = 1 } + var newHpDef uint32 if hpDef > uint32(dmg) { newHpDef = hpDef - uint32(dmg) } // Bloodlust: heal attacker for bloodlustBps/10000 of physical damage dealt. + var atkHeal uint32 if atkSkill == SkillBloodlust && !isMagic { atkHeal = uint32(dmg * uint64(sc.BloodlustBps) / 10000) } - return newHpDef, atkHeal + + return StrikeOutcome{ + NewHpDef: newHpDef, + Heal: atkHeal, + IsMagic: isMagic, + Crit: crit, + Damage: dmg, + ElementMult: effElem, + FuryTriggered: furyTriggered, + } +} + +// strike is the legacy two-value form `Simulate` uses. Kept so the +// golden-vector-tested function is untouched by the logging addition. +func strike( + atk Attrs, atkSkill uint8, hpAtk, startHpAtk uint32, + defDef, defMdef uint16, hpDef uint32, elemMult uint64, + rs [32]byte, slotOffset uint8, sc SkillConfig, +) (newHpDef, atkHeal uint32) { + o := strikeDetailed(atk, atkSkill, hpAtk, startHpAtk, defDef, defMdef, hpDef, elemMult, rs, slotOffset, sc) + return o.NewHpDef, o.Heal } // addHeal adds heal to hp, capped at startHp (prevents overheal). diff --git a/indexer-go/internal/combat/verify.go b/indexer-go/internal/combat/verify.go new file mode 100644 index 00000000..bcbbeed9 --- /dev/null +++ b/indexer-go/internal/combat/verify.go @@ -0,0 +1,64 @@ +package combat + +// Verify: the independent recomputation §F calls the circuit breaker. +// +// This is release safety, not a trust boundary. Both this port and the +// TypeScript engine descend from the same on-chain simulator, are held in +// lockstep by the same golden vectors, and — in production — are run by the +// same operator. What this catches is implementation drift, a bad deploy, or +// a transcription bug between the two: it does not, and cannot, constrain an +// operator who controls both processes. That constraint is public replay +// (§H), not this. See the architecture doc's "what the Go verifier is for" +// section before reusing this for anything it does not claim to do. +// +// Verify takes exactly what it needs and nothing it would have to fetch: +// no database, no network, no clock. That is what makes it a genuine second +// implementation of the computation rather than a second call into the first. + +// VerifyRequest is everything needed to independently recompute one battle. +type VerifyRequest struct { + Attacker PetInputs + Defender PetInputs + Seed [32]byte + SkillConfig SkillConfig + MaxLevel uint16 +} + +// VerifyResult is Go's independent recomputation. +// +// The per-strike log travels back as structured data (Log), not as a hash: +// Go never reimplements the canonical byte-encoding scheme the receipt's +// `combatLogHash` is taken under (see simlog.go). The caller — the backend, +// which already has the real encoder from @cryptopets/protocol — converts +// this into the same shape the TypeScript engine produced and hashes both the +// same way, so the comparison is never "does Go's encoding match TS's +// encoding" (a question with no right answer, since only one encoding is +// canonical) but "did the two engines compute the same strikes." +type VerifyResult struct { + Result Result + Log []StrikeLogEntry + StartHp1 uint32 + StartHp2 uint32 + Attacker PetProgression + Defender PetProgression +} + +// Verify runs the fight and the progression composition against a frozen +// snapshot and a verified seed. +func Verify(req VerifyRequest) VerifyResult { + logged := SimulateWithLog( + req.Attacker.DNA, req.Attacker.Rarity, req.Attacker.Level, req.Attacker.Skill, + req.Defender.DNA, req.Defender.Rarity, req.Defender.Level, req.Defender.Skill, + req.Seed, req.SkillConfig, + ) + progression := ComputeProgression(req.Attacker, req.Defender, logged.Result.FirstWins, req.MaxLevel) + + return VerifyResult{ + Result: logged.Result, + Log: logged.Log, + StartHp1: logged.StartHp1, + StartHp2: logged.StartHp2, + Attacker: progression.Attacker, + Defender: progression.Defender, + } +} diff --git a/indexer-go/internal/combat/xp.go b/indexer-go/internal/combat/xp.go index 3c2143ea..c1850a24 100644 --- a/indexer-go/internal/combat/xp.go +++ b/indexer-go/internal/combat/xp.go @@ -42,3 +42,103 @@ func applyDecay(opponentIDs []uint32) []uint32 { } return shifts } + +// Level-up progression, ported from PetCore.addXp / PetAccount::add_xp so Go can +// independently recompute a full progression delta, not just the XP formula and +// decay (plan §3.4). Mirrors protocol/src/combat/xp.ts's applyXp exactly, +// including the two behaviours easiest to get subtly wrong: +// +// - a pet at the level cap accrues nothing at all, not capped XP: the on-chain +// version returns before touching xp, so this does too; +// - at most one level per battle, with the remainder carried as XP rather than +// the threshold being reapplied. +const XPPerLevelMultiplier = 100 + +// MaxSameOpponentStreak: sameOpponentStreak is a uint8 on both chains and +// saturates rather than wraps. +const MaxSameOpponentStreak = 255 + +// MaxDecayShift caps the right-shift applied to an XP award. +// +// The streak can reach 255, but the XP being shifted fits in uint32. Go's `>>` +// on a fixed-width unsigned integer already yields 0 once the shift reaches or +// exceeds the operand's bit width (unlike JavaScript's `>>`, which masks the +// shift count to 5 bits), so this constant exists to document the same ceiling +// the TypeScript port has to enforce explicitly, not to work around a Go +// quirk — go vet would catch a shift literal outside this range, but a +// runtime-computed shift needs the same clamp as every other port. +const MaxDecayShift = 31 + +// ApplyDecayShift applies same-opponent decay to an XP award. +func ApplyDecayShift(xp uint32, decayShift uint32) uint32 { + return xp >> min(decayShift, MaxDecayShift) +} + +// OpponentHistory is a pet's same-opponent tracking state, as frozen in a +// snapshot. Mirrors protocol's OpponentHistory. +type OpponentHistory struct { + LastOpponentID uint64 // pet id; 0 = no battles yet + Streak uint32 +} + +// OpponentHistoryUpdate is OpponentHistory after a battle, plus the shift that +// battle earned. +type OpponentHistoryUpdate struct { + OpponentHistory + DecayShift uint32 +} + +// RecordBattleOpponent advances a pet's same-opponent history, mirroring +// recordBattleOpponent / record_battle_opponent. Fighting the same opponent +// again increments the streak (saturating at 255); the new value is the shift, +// so the second consecutive rematch pays half, the third a quarter. Facing +// anyone else resets to 0. +func RecordBattleOpponent(history OpponentHistory, opponentID uint64) OpponentHistoryUpdate { + if history.LastOpponentID == opponentID { + streak := history.Streak + if streak < MaxSameOpponentStreak { + streak++ + } + return OpponentHistoryUpdate{ + OpponentHistory: OpponentHistory{LastOpponentID: history.LastOpponentID, Streak: streak}, + DecayShift: streak, + } + } + return OpponentHistoryUpdate{ + OpponentHistory: OpponentHistory{LastOpponentID: opponentID, Streak: 0}, + DecayShift: 0, + } +} + +// LevelState is a pet's level and XP. +type LevelState struct { + Level uint16 + XP uint32 +} + +// LevelStateUpdate is LevelState after an XP award. +type LevelStateUpdate struct { + LevelState + LeveledUp bool +} + +// ApplyXP credits XP and advances at most one level, mirroring +// PetCore.addXp / PetAccount::add_xp. +func ApplyXP(state LevelState, amount uint32, maxLevel uint16) LevelStateUpdate { + if state.Level >= maxLevel { + return LevelStateUpdate{LevelState: state, LeveledUp: false} + } + xp := state.XP + amount + level := state.Level + threshold := uint32(XPPerLevelMultiplier) * uint32(level) + leveledUp := false + if xp >= threshold { + xp -= threshold + level++ + if level > maxLevel { + level = maxLevel + } + leveledUp = true + } + return LevelStateUpdate{LevelState: LevelState{Level: level, XP: xp}, LeveledUp: leveledUp} +} diff --git a/indexer-go/internal/grpcsrv/verify.go b/indexer-go/internal/grpcsrv/verify.go new file mode 100644 index 00000000..a5899d55 --- /dev/null +++ b/indexer-go/internal/grpcsrv/verify.go @@ -0,0 +1,156 @@ +package grpcsrv + +import ( + "context" + "fmt" + "strconv" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/radcrew/do-not-stop/indexer-go/internal/combat" + "github.com/radcrew/do-not-stop/indexer-go/pb" +) + +// VerifyBattle independently recomputes a backend-authoritative battle result +// (docs/plan-backend-battle-architecture.md §F). Unlike GetPetState/ +// ListReadyOpponents/EstimateWin, it reads nothing from the roster cache and +// needs no warm-up: every input arrives in the request, which is what makes +// this a genuine second implementation of the computation rather than a +// second call into the first. +func (s *Server) VerifyBattle(_ context.Context, req *pb.VerifyBattleRequest) (*pb.VerifyBattleResponse, error) { + attacker, err := petInputsFromProto(req.GetAttacker()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "attacker: %v", err) + } + defender, err := petInputsFromProto(req.GetDefender()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "defender: %v", err) + } + + seed := req.GetSeed() + if len(seed) != 32 { + return nil, status.Errorf(codes.InvalidArgument, "seed must be 32 bytes, got %d", len(seed)) + } + var seedBytes [32]byte + copy(seedBytes[:], seed) + + sc := skillConfigFromProto(req.GetSkillConfig()) + maxLevel := req.GetMaxLevel() + if maxLevel == 0 || maxLevel > 0xFFFF { + return nil, status.Errorf(codes.InvalidArgument, "max_level must be 1-65535, got %d", maxLevel) + } + + result := combat.Verify(combat.VerifyRequest{ + Attacker: attacker, + Defender: defender, + Seed: seedBytes, + SkillConfig: sc, + MaxLevel: uint16(maxLevel), + }) + + return verifyResultToProto(result), nil +} + +func petInputsFromProto(p *pb.VerifyPetInputs) (combat.PetInputs, error) { + if p == nil { + return combat.PetInputs{}, fmt.Errorf("missing pet inputs") + } + petID, err := strconv.ParseUint(p.GetPetId(), 10, 64) + if err != nil { + return combat.PetInputs{}, fmt.Errorf("invalid pet_id %q: %w", p.GetPetId(), err) + } + dna, err := strconv.ParseUint(p.GetDna(), 10, 64) + if err != nil { + return combat.PetInputs{}, fmt.Errorf("invalid dna %q: %w", p.GetDna(), err) + } + // proto3's zero value for an unset string field is "", not "0" — and "" is + // exactly what a fresh pet with no prior opponent sends. Treat it as 0 + // rather than a parse error, matching every other port's "no history" value. + lastOpponentIDStr := p.GetLastOpponentId() + if lastOpponentIDStr == "" { + lastOpponentIDStr = "0" + } + lastOpponentID, err := strconv.ParseUint(lastOpponentIDStr, 10, 64) + if err != nil { + return combat.PetInputs{}, fmt.Errorf("invalid last_opponent_id %q: %w", p.GetLastOpponentId(), err) + } + if p.GetRarity() == 0 || p.GetRarity() > 255 { + return combat.PetInputs{}, fmt.Errorf("rarity out of range: %d", p.GetRarity()) + } + if p.GetLevel() > 0xFFFF { + return combat.PetInputs{}, fmt.Errorf("level out of range: %d", p.GetLevel()) + } + if p.GetSkill() > 255 { + return combat.PetInputs{}, fmt.Errorf("skill out of range: %d", p.GetSkill()) + } + return combat.PetInputs{ + PetID: petID, + DNA: dna, + Rarity: uint8(p.GetRarity()), + Level: uint16(p.GetLevel()), + Skill: uint8(p.GetSkill()), + XP: p.GetXp(), + LastOpponentID: lastOpponentID, + Streak: p.GetStreak(), + }, nil +} + +func skillConfigFromProto(p *pb.VerifySkillConfig) combat.SkillConfig { + if p == nil { + return combat.DefaultSkillConfig() + } + return combat.SkillConfig{ + TankHPMult: uint16(p.GetTankHpMult()), + ShellDefMult: uint16(p.GetShellDefMult()), + SwiftCritBonus: uint16(p.GetSwiftCritBonus()), + CunningCritCap: uint16(p.GetCunningCritCap()), + FuryDmgMult: uint16(p.GetFuryDmgMult()), + FuryHPThreshold: uint16(p.GetFuryHpThreshold()), + SageMdefMult: uint16(p.GetSageMdefMult()), + BloodlustBps: uint16(p.GetBloodlustBps()), + } +} + +func verifyResultToProto(r combat.VerifyResult) *pb.VerifyBattleResponse { + log := make([]*pb.VerifyStrikeLogEntry, 0, len(r.Log)) + for _, entry := range r.Log { + log = append(log, &pb.VerifyStrikeLogEntry{ + Round: entry.Round, + Attacker: uint32(entry.Attacker), + IsMagic: entry.IsMagic, + Crit: entry.Crit, + Damage: entry.Damage, + Heal: entry.Heal, + ElementMult: uint32(entry.ElementMult), + FuryTriggered: entry.FuryTriggered, + RebirthTriggered: entry.RebirthTriggered, + Hp1After: entry.Hp1After, + Hp2After: entry.Hp2After, + }) + } + return &pb.VerifyBattleResponse{ + FirstWins: r.Result.FirstWins, + Rounds: uint32(r.Result.Rounds), + WinnerHpRemaining: uint32(r.Result.WinnerHpRemaining), + StartHp1: r.StartHp1, + StartHp2: r.StartHp2, + Log: log, + Attacker: petProgressionToProto(r.Attacker), + Defender: petProgressionToProto(r.Defender), + } +} + +func petProgressionToProto(p combat.PetProgression) *pb.VerifyPetProgression { + return &pb.VerifyPetProgression{ + PetId: strconv.FormatUint(p.PetID, 10), + Won: p.Won, + DecayShift: p.DecayShift, + XpAwarded: p.XPAwarded, + LastOpponentId: strconv.FormatUint(p.LastOpponentID, 10), + Streak: p.Streak, + Level: uint32(p.Level), + Xp: p.XP, + LeveledUp: p.LeveledUp, + } +} diff --git a/indexer-go/internal/grpcsrv/verify_test.go b/indexer-go/internal/grpcsrv/verify_test.go new file mode 100644 index 00000000..4246ba8f --- /dev/null +++ b/indexer-go/internal/grpcsrv/verify_test.go @@ -0,0 +1,219 @@ +package grpcsrv + +import ( + "context" + "encoding/json" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/radcrew/do-not-stop/indexer-go/internal/battlebus" + "github.com/radcrew/do-not-stop/indexer-go/pb" +) + +// VerifyBattle needs no cache and no warm-up: everything arrives in the +// request, unlike GetPetState/ListReadyOpponents/EstimateWin. Passing a nil +// roster (the pre-promotion, cache-disabled state those RPCs refuse) proves +// that independently. +func verifyClient(t *testing.T) pb.GameDataServiceClient { + t.Helper() + return startServer(t, battlebus.New(), nil, nil) +} + +func TestVerifyBattleWorksWithNoCache(t *testing.T) { + client := verifyClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := client.VerifyBattle(ctx, &pb.VerifyBattleRequest{ + Attacker: &pb.VerifyPetInputs{PetId: "1", Dna: "1234567890123456", Rarity: 3, Level: 10, Skill: 4, Xp: 120}, + Defender: &pb.VerifyPetInputs{PetId: "2", Dna: "6543210987654321", Rarity: 2, Level: 11, Skill: 7, Xp: 45, LastOpponentId: "1", Streak: 2}, + Seed: make([]byte, 32), + MaxLevel: 100, + }) + if err != nil { + t.Fatalf("VerifyBattle: %v", err) + } + if resp.GetRounds() == 0 { + t.Error("expected at least one round") + } + if len(resp.GetLog()) == 0 { + t.Error("expected a non-empty strike log") + } + if resp.GetAttacker().GetPetId() != "1" || resp.GetDefender().GetPetId() != "2" { + t.Errorf("progression pet ids: attacker=%s defender=%s", resp.GetAttacker().GetPetId(), resp.GetDefender().GetPetId()) + } +} + +func TestVerifyBattleDefaultsSkillConfig(t *testing.T) { + client := verifyClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // No skill_config sent at all: must fall back to the same defaults the + // contracts ship, not to a zeroed struct that would silently disable every + // skill bonus. + withDefaults, err := client.VerifyBattle(ctx, &pb.VerifyBattleRequest{ + Attacker: &pb.VerifyPetInputs{PetId: "1", Dna: "1234567890123456", Rarity: 3, Level: 10}, + Defender: &pb.VerifyPetInputs{PetId: "2", Dna: "6543210987654321", Rarity: 2, Level: 11}, + Seed: make([]byte, 32), + MaxLevel: 100, + }) + if err != nil { + t.Fatalf("VerifyBattle: %v", err) + } + + withExplicitDefaults, err := client.VerifyBattle(ctx, &pb.VerifyBattleRequest{ + Attacker: &pb.VerifyPetInputs{PetId: "1", Dna: "1234567890123456", Rarity: 3, Level: 10}, + Defender: &pb.VerifyPetInputs{PetId: "2", Dna: "6543210987654321", Rarity: 2, Level: 11}, + Seed: make([]byte, 32), + MaxLevel: 100, + SkillConfig: &pb.VerifySkillConfig{ + TankHpMult: 120, ShellDefMult: 125, SwiftCritBonus: 50, CunningCritCap: 4000, + FuryDmgMult: 130, FuryHpThreshold: 3000, SageMdefMult: 125, BloodlustBps: 150, + }, + }) + if err != nil { + t.Fatalf("VerifyBattle: %v", err) + } + + if withDefaults.GetRounds() != withExplicitDefaults.GetRounds() || + withDefaults.GetFirstWins() != withExplicitDefaults.GetFirstWins() { + t.Error("implicit and explicit default skill configs produced different results") + } +} + +func TestVerifyBattleRejectsMalformedInput(t *testing.T) { + client := verifyClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + base := &pb.VerifyBattleRequest{ + Attacker: &pb.VerifyPetInputs{PetId: "1", Dna: "1234567890123456", Rarity: 3, Level: 10}, + Defender: &pb.VerifyPetInputs{PetId: "2", Dna: "6543210987654321", Rarity: 2, Level: 11}, + Seed: make([]byte, 32), + MaxLevel: 100, + } + + cases := []struct { + name string + modify func(*pb.VerifyBattleRequest) + }{ + {"short seed", func(r *pb.VerifyBattleRequest) { r.Seed = make([]byte, 16) }}, + {"non-numeric pet id", func(r *pb.VerifyBattleRequest) { r.Attacker.PetId = "not-a-number" }}, + {"non-numeric dna", func(r *pb.VerifyBattleRequest) { r.Attacker.Dna = "not-a-number" }}, + {"zero rarity", func(r *pb.VerifyBattleRequest) { r.Attacker.Rarity = 0 }}, + {"zero max level", func(r *pb.VerifyBattleRequest) { r.MaxLevel = 0 }}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := &pb.VerifyBattleRequest{ + Attacker: &pb.VerifyPetInputs{PetId: base.Attacker.PetId, Dna: base.Attacker.Dna, Rarity: base.Attacker.Rarity, Level: base.Attacker.Level}, + Defender: base.Defender, + Seed: base.Seed, + MaxLevel: base.MaxLevel, + } + c.modify(req) + _, err := client.VerifyBattle(ctx, req) + if status.Code(err) != codes.InvalidArgument { + t.Errorf("%s: code = %v, want InvalidArgument", c.name, status.Code(err)) + } + }) + } +} + +// The same battle.json vectors every other port is validated against, run +// through the wire (proto marshal/unmarshal) rather than calling combat.Verify +// directly — this is the layer combat_golden_test.go cannot cover, since a +// field mis-mapped between protobuf and Go types would pass a pure-Go test but +// fail here. +func TestVerifyBattleMatchesGoldenVectorsOverGRPC(t *testing.T) { + var v battleVectorsFixture + loadJSONFixture(t, "../../../contracts/test-vectors/battle.json", &v) + if len(v.Cases) == 0 { + t.Fatal("no battle vectors loaded") + } + + client := verifyClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + for _, c := range v.Cases { + seed, ok := new(big.Int).SetString(c.Seed, 10) + if !ok { + t.Fatalf("vector %q: bad seed %q", c.Name, c.Seed) + } + seedBytes := make([]byte, 32) + seed.FillBytes(seedBytes) + + resp, err := client.VerifyBattle(ctx, &pb.VerifyBattleRequest{ + Attacker: &pb.VerifyPetInputs{PetId: "1", Dna: c.DNA1, Rarity: uint32(c.Rarity1), Level: uint32(c.Level1), Skill: uint32(c.Skill1)}, + Defender: &pb.VerifyPetInputs{PetId: "2", Dna: c.DNA2, Rarity: uint32(c.Rarity2), Level: uint32(c.Level2), Skill: uint32(c.Skill2)}, + Seed: seedBytes, + MaxLevel: 100, + SkillConfig: &pb.VerifySkillConfig{ + TankHpMult: v.SkillConfig.TankHpMult, ShellDefMult: v.SkillConfig.ShellDefMult, + SwiftCritBonus: v.SkillConfig.SwiftCritBonus, CunningCritCap: v.SkillConfig.CunningCritCap, + FuryDmgMult: v.SkillConfig.FuryDmgMult, FuryHpThreshold: v.SkillConfig.FuryHpThreshold, + SageMdefMult: v.SkillConfig.SageMdefMult, BloodlustBps: v.SkillConfig.BloodlustBps, + }, + }) + if err != nil { + t.Fatalf("vector %q: VerifyBattle: %v", c.Name, err) + } + if resp.GetFirstWins() != c.Expected.FirstWins || + resp.GetRounds() != uint32(c.Expected.Rounds) || + resp.GetWinnerHpRemaining() != uint32(c.Expected.WinnerHpRemaining) { + t.Errorf("vector %q: got firstWins=%v rounds=%d winnerHp=%d, want firstWins=%v rounds=%d winnerHp=%d", + c.Name, resp.GetFirstWins(), resp.GetRounds(), resp.GetWinnerHpRemaining(), + c.Expected.FirstWins, c.Expected.Rounds, c.Expected.WinnerHpRemaining) + } + } +} + +type battleVectorsFixture struct { + SkillConfig struct { + TankHpMult uint32 `json:"tankHpMult"` + ShellDefMult uint32 `json:"shellDefMult"` + SwiftCritBonus uint32 `json:"swiftCritBonus"` + CunningCritCap uint32 `json:"cunningCritCap"` + FuryDmgMult uint32 `json:"furyDmgMult"` + FuryHpThreshold uint32 `json:"furyHpThreshold"` + SageMdefMult uint32 `json:"sageMdefMult"` + BloodlustBps uint32 `json:"bloodlustBps"` + } `json:"skillConfig"` + Cases []struct { + Name string `json:"name"` + DNA1 string `json:"dna1"` + Rarity1 uint8 `json:"rarity1"` + Level1 uint16 `json:"level1"` + Skill1 uint8 `json:"skill1"` + DNA2 string `json:"dna2"` + Rarity2 uint8 `json:"rarity2"` + Level2 uint16 `json:"level2"` + Skill2 uint8 `json:"skill2"` + Seed string `json:"seed"` + Expected struct { + FirstWins bool `json:"firstWins"` + Rounds uint8 `json:"rounds"` + WinnerHpRemaining uint16 `json:"winnerHpRemaining"` + } `json:"expected"` + } `json:"cases"` +} + +func loadJSONFixture(t *testing.T, path string, out any) { + t.Helper() + data, err := os.ReadFile(filepath.FromSlash(path)) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if err := json.Unmarshal(data, out); err != nil { + t.Fatalf("parse %s: %v", path, err) + } +} diff --git a/indexer-go/pb/cryptopets.pb.go b/indexer-go/pb/cryptopets.pb.go index 14cc89a9..93f7c7c2 100644 --- a/indexer-go/pb/cryptopets.pb.go +++ b/indexer-go/pb/cryptopets.pb.go @@ -531,6 +531,625 @@ func (x *WinResponse) GetSamples() uint32 { return 0 } +// One pet's frozen snapshot fields, as needed to recompute a fight and its +// progression. Mirrors indexer-go's combat.PetInputs / protocol's PetSnapshot +// (the subset both computations actually read — owner, readyAt, and +// sourceVersion are not needed to run the fight). +type VerifyPetInputs struct { + state protoimpl.MessageState `protogen:"open.v1"` + PetId string `protobuf:"bytes,1,opt,name=pet_id,json=petId,proto3" json:"pet_id,omitempty"` // decimal string; parsed to uint64 server-side + Dna string `protobuf:"bytes,2,opt,name=dna,proto3" json:"dna,omitempty"` // decimal string (16-digit DNA fits uint64) + Rarity uint32 `protobuf:"varint,3,opt,name=rarity,proto3" json:"rarity,omitempty"` + Level uint32 `protobuf:"varint,4,opt,name=level,proto3" json:"level,omitempty"` + Skill uint32 `protobuf:"varint,5,opt,name=skill,proto3" json:"skill,omitempty"` + Xp uint32 `protobuf:"varint,6,opt,name=xp,proto3" json:"xp,omitempty"` + LastOpponentId string `protobuf:"bytes,7,opt,name=last_opponent_id,json=lastOpponentId,proto3" json:"last_opponent_id,omitempty"` // decimal string; "0" = no prior opponent + Streak uint32 `protobuf:"varint,8,opt,name=streak,proto3" json:"streak,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyPetInputs) Reset() { + *x = VerifyPetInputs{} + mi := &file_cryptopets_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyPetInputs) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyPetInputs) ProtoMessage() {} + +func (x *VerifyPetInputs) ProtoReflect() protoreflect.Message { + mi := &file_cryptopets_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyPetInputs.ProtoReflect.Descriptor instead. +func (*VerifyPetInputs) Descriptor() ([]byte, []int) { + return file_cryptopets_proto_rawDescGZIP(), []int{6} +} + +func (x *VerifyPetInputs) GetPetId() string { + if x != nil { + return x.PetId + } + return "" +} + +func (x *VerifyPetInputs) GetDna() string { + if x != nil { + return x.Dna + } + return "" +} + +func (x *VerifyPetInputs) GetRarity() uint32 { + if x != nil { + return x.Rarity + } + return 0 +} + +func (x *VerifyPetInputs) GetLevel() uint32 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *VerifyPetInputs) GetSkill() uint32 { + if x != nil { + return x.Skill + } + return 0 +} + +func (x *VerifyPetInputs) GetXp() uint32 { + if x != nil { + return x.Xp + } + return 0 +} + +func (x *VerifyPetInputs) GetLastOpponentId() string { + if x != nil { + return x.LastOpponentId + } + return "" +} + +func (x *VerifyPetInputs) GetStreak() uint32 { + if x != nil { + return x.Streak + } + return 0 +} + +// Mirrors protocol's SkillConfig field for field. +type VerifySkillConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + TankHpMult uint32 `protobuf:"varint,1,opt,name=tank_hp_mult,json=tankHpMult,proto3" json:"tank_hp_mult,omitempty"` + ShellDefMult uint32 `protobuf:"varint,2,opt,name=shell_def_mult,json=shellDefMult,proto3" json:"shell_def_mult,omitempty"` + SwiftCritBonus uint32 `protobuf:"varint,3,opt,name=swift_crit_bonus,json=swiftCritBonus,proto3" json:"swift_crit_bonus,omitempty"` + CunningCritCap uint32 `protobuf:"varint,4,opt,name=cunning_crit_cap,json=cunningCritCap,proto3" json:"cunning_crit_cap,omitempty"` + FuryDmgMult uint32 `protobuf:"varint,5,opt,name=fury_dmg_mult,json=furyDmgMult,proto3" json:"fury_dmg_mult,omitempty"` + FuryHpThreshold uint32 `protobuf:"varint,6,opt,name=fury_hp_threshold,json=furyHpThreshold,proto3" json:"fury_hp_threshold,omitempty"` + SageMdefMult uint32 `protobuf:"varint,7,opt,name=sage_mdef_mult,json=sageMdefMult,proto3" json:"sage_mdef_mult,omitempty"` + BloodlustBps uint32 `protobuf:"varint,8,opt,name=bloodlust_bps,json=bloodlustBps,proto3" json:"bloodlust_bps,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifySkillConfig) Reset() { + *x = VerifySkillConfig{} + mi := &file_cryptopets_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifySkillConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifySkillConfig) ProtoMessage() {} + +func (x *VerifySkillConfig) ProtoReflect() protoreflect.Message { + mi := &file_cryptopets_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifySkillConfig.ProtoReflect.Descriptor instead. +func (*VerifySkillConfig) Descriptor() ([]byte, []int) { + return file_cryptopets_proto_rawDescGZIP(), []int{7} +} + +func (x *VerifySkillConfig) GetTankHpMult() uint32 { + if x != nil { + return x.TankHpMult + } + return 0 +} + +func (x *VerifySkillConfig) GetShellDefMult() uint32 { + if x != nil { + return x.ShellDefMult + } + return 0 +} + +func (x *VerifySkillConfig) GetSwiftCritBonus() uint32 { + if x != nil { + return x.SwiftCritBonus + } + return 0 +} + +func (x *VerifySkillConfig) GetCunningCritCap() uint32 { + if x != nil { + return x.CunningCritCap + } + return 0 +} + +func (x *VerifySkillConfig) GetFuryDmgMult() uint32 { + if x != nil { + return x.FuryDmgMult + } + return 0 +} + +func (x *VerifySkillConfig) GetFuryHpThreshold() uint32 { + if x != nil { + return x.FuryHpThreshold + } + return 0 +} + +func (x *VerifySkillConfig) GetSageMdefMult() uint32 { + if x != nil { + return x.SageMdefMult + } + return 0 +} + +func (x *VerifySkillConfig) GetBloodlustBps() uint32 { + if x != nil { + return x.BloodlustBps + } + return 0 +} + +type VerifyBattleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Attacker *VerifyPetInputs `protobuf:"bytes,1,opt,name=attacker,proto3" json:"attacker,omitempty"` + Defender *VerifyPetInputs `protobuf:"bytes,2,opt,name=defender,proto3" json:"defender,omitempty"` + Seed []byte `protobuf:"bytes,3,opt,name=seed,proto3" json:"seed,omitempty"` // 32 bytes, big-endian, matching the on-chain uint256 seed + SkillConfig *VerifySkillConfig `protobuf:"bytes,4,opt,name=skill_config,json=skillConfig,proto3" json:"skill_config,omitempty"` + MaxLevel uint32 `protobuf:"varint,5,opt,name=max_level,json=maxLevel,proto3" json:"max_level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyBattleRequest) Reset() { + *x = VerifyBattleRequest{} + mi := &file_cryptopets_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyBattleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyBattleRequest) ProtoMessage() {} + +func (x *VerifyBattleRequest) ProtoReflect() protoreflect.Message { + mi := &file_cryptopets_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyBattleRequest.ProtoReflect.Descriptor instead. +func (*VerifyBattleRequest) Descriptor() ([]byte, []int) { + return file_cryptopets_proto_rawDescGZIP(), []int{8} +} + +func (x *VerifyBattleRequest) GetAttacker() *VerifyPetInputs { + if x != nil { + return x.Attacker + } + return nil +} + +func (x *VerifyBattleRequest) GetDefender() *VerifyPetInputs { + if x != nil { + return x.Defender + } + return nil +} + +func (x *VerifyBattleRequest) GetSeed() []byte { + if x != nil { + return x.Seed + } + return nil +} + +func (x *VerifyBattleRequest) GetSkillConfig() *VerifySkillConfig { + if x != nil { + return x.SkillConfig + } + return nil +} + +func (x *VerifyBattleRequest) GetMaxLevel() uint32 { + if x != nil { + return x.MaxLevel + } + return 0 +} + +// One resolved attack, in fight order. Mirrors indexer-go's +// combat.StrikeLogEntry / protocol's StrikeLogEntry field for field, so the +// caller can convert this into the same shape the TypeScript engine produces +// and hash both with @cryptopets/protocol's canonical encoding — Go never +// reimplements that encoding itself (see indexer-go's simlog.go doc comment). +type VerifyStrikeLogEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Round uint32 `protobuf:"varint,1,opt,name=round,proto3" json:"round,omitempty"` + Attacker uint32 `protobuf:"varint,2,opt,name=attacker,proto3" json:"attacker,omitempty"` // 1 = pet1/attacker, 2 = pet2/defender + IsMagic bool `protobuf:"varint,3,opt,name=is_magic,json=isMagic,proto3" json:"is_magic,omitempty"` + Crit bool `protobuf:"varint,4,opt,name=crit,proto3" json:"crit,omitempty"` + Damage uint64 `protobuf:"varint,5,opt,name=damage,proto3" json:"damage,omitempty"` + Heal uint64 `protobuf:"varint,6,opt,name=heal,proto3" json:"heal,omitempty"` + ElementMult uint32 `protobuf:"varint,7,opt,name=element_mult,json=elementMult,proto3" json:"element_mult,omitempty"` // 85 | 100 | 115 + FuryTriggered bool `protobuf:"varint,8,opt,name=fury_triggered,json=furyTriggered,proto3" json:"fury_triggered,omitempty"` + RebirthTriggered bool `protobuf:"varint,9,opt,name=rebirth_triggered,json=rebirthTriggered,proto3" json:"rebirth_triggered,omitempty"` + Hp1After uint32 `protobuf:"varint,10,opt,name=hp1_after,json=hp1After,proto3" json:"hp1_after,omitempty"` + Hp2After uint32 `protobuf:"varint,11,opt,name=hp2_after,json=hp2After,proto3" json:"hp2_after,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyStrikeLogEntry) Reset() { + *x = VerifyStrikeLogEntry{} + mi := &file_cryptopets_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyStrikeLogEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyStrikeLogEntry) ProtoMessage() {} + +func (x *VerifyStrikeLogEntry) ProtoReflect() protoreflect.Message { + mi := &file_cryptopets_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyStrikeLogEntry.ProtoReflect.Descriptor instead. +func (*VerifyStrikeLogEntry) Descriptor() ([]byte, []int) { + return file_cryptopets_proto_rawDescGZIP(), []int{9} +} + +func (x *VerifyStrikeLogEntry) GetRound() uint32 { + if x != nil { + return x.Round + } + return 0 +} + +func (x *VerifyStrikeLogEntry) GetAttacker() uint32 { + if x != nil { + return x.Attacker + } + return 0 +} + +func (x *VerifyStrikeLogEntry) GetIsMagic() bool { + if x != nil { + return x.IsMagic + } + return false +} + +func (x *VerifyStrikeLogEntry) GetCrit() bool { + if x != nil { + return x.Crit + } + return false +} + +func (x *VerifyStrikeLogEntry) GetDamage() uint64 { + if x != nil { + return x.Damage + } + return 0 +} + +func (x *VerifyStrikeLogEntry) GetHeal() uint64 { + if x != nil { + return x.Heal + } + return 0 +} + +func (x *VerifyStrikeLogEntry) GetElementMult() uint32 { + if x != nil { + return x.ElementMult + } + return 0 +} + +func (x *VerifyStrikeLogEntry) GetFuryTriggered() bool { + if x != nil { + return x.FuryTriggered + } + return false +} + +func (x *VerifyStrikeLogEntry) GetRebirthTriggered() bool { + if x != nil { + return x.RebirthTriggered + } + return false +} + +func (x *VerifyStrikeLogEntry) GetHp1After() uint32 { + if x != nil { + return x.Hp1After + } + return 0 +} + +func (x *VerifyStrikeLogEntry) GetHp2After() uint32 { + if x != nil { + return x.Hp2After + } + return 0 +} + +// Mirrors indexer-go's combat.PetProgression / protocol's PetProgression. +type VerifyPetProgression struct { + state protoimpl.MessageState `protogen:"open.v1"` + PetId string `protobuf:"bytes,1,opt,name=pet_id,json=petId,proto3" json:"pet_id,omitempty"` + Won bool `protobuf:"varint,2,opt,name=won,proto3" json:"won,omitempty"` + DecayShift uint32 `protobuf:"varint,3,opt,name=decay_shift,json=decayShift,proto3" json:"decay_shift,omitempty"` + XpAwarded uint32 `protobuf:"varint,4,opt,name=xp_awarded,json=xpAwarded,proto3" json:"xp_awarded,omitempty"` + LastOpponentId string `protobuf:"bytes,5,opt,name=last_opponent_id,json=lastOpponentId,proto3" json:"last_opponent_id,omitempty"` + Streak uint32 `protobuf:"varint,6,opt,name=streak,proto3" json:"streak,omitempty"` + Level uint32 `protobuf:"varint,7,opt,name=level,proto3" json:"level,omitempty"` + Xp uint32 `protobuf:"varint,8,opt,name=xp,proto3" json:"xp,omitempty"` + LeveledUp bool `protobuf:"varint,9,opt,name=leveled_up,json=leveledUp,proto3" json:"leveled_up,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyPetProgression) Reset() { + *x = VerifyPetProgression{} + mi := &file_cryptopets_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyPetProgression) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyPetProgression) ProtoMessage() {} + +func (x *VerifyPetProgression) ProtoReflect() protoreflect.Message { + mi := &file_cryptopets_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyPetProgression.ProtoReflect.Descriptor instead. +func (*VerifyPetProgression) Descriptor() ([]byte, []int) { + return file_cryptopets_proto_rawDescGZIP(), []int{10} +} + +func (x *VerifyPetProgression) GetPetId() string { + if x != nil { + return x.PetId + } + return "" +} + +func (x *VerifyPetProgression) GetWon() bool { + if x != nil { + return x.Won + } + return false +} + +func (x *VerifyPetProgression) GetDecayShift() uint32 { + if x != nil { + return x.DecayShift + } + return 0 +} + +func (x *VerifyPetProgression) GetXpAwarded() uint32 { + if x != nil { + return x.XpAwarded + } + return 0 +} + +func (x *VerifyPetProgression) GetLastOpponentId() string { + if x != nil { + return x.LastOpponentId + } + return "" +} + +func (x *VerifyPetProgression) GetStreak() uint32 { + if x != nil { + return x.Streak + } + return 0 +} + +func (x *VerifyPetProgression) GetLevel() uint32 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *VerifyPetProgression) GetXp() uint32 { + if x != nil { + return x.Xp + } + return 0 +} + +func (x *VerifyPetProgression) GetLeveledUp() bool { + if x != nil { + return x.LeveledUp + } + return false +} + +type VerifyBattleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + FirstWins bool `protobuf:"varint,1,opt,name=first_wins,json=firstWins,proto3" json:"first_wins,omitempty"` + Rounds uint32 `protobuf:"varint,2,opt,name=rounds,proto3" json:"rounds,omitempty"` + WinnerHpRemaining uint32 `protobuf:"varint,3,opt,name=winner_hp_remaining,json=winnerHpRemaining,proto3" json:"winner_hp_remaining,omitempty"` + StartHp1 uint32 `protobuf:"varint,4,opt,name=start_hp1,json=startHp1,proto3" json:"start_hp1,omitempty"` + StartHp2 uint32 `protobuf:"varint,5,opt,name=start_hp2,json=startHp2,proto3" json:"start_hp2,omitempty"` + Log []*VerifyStrikeLogEntry `protobuf:"bytes,6,rep,name=log,proto3" json:"log,omitempty"` + Attacker *VerifyPetProgression `protobuf:"bytes,7,opt,name=attacker,proto3" json:"attacker,omitempty"` + Defender *VerifyPetProgression `protobuf:"bytes,8,opt,name=defender,proto3" json:"defender,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyBattleResponse) Reset() { + *x = VerifyBattleResponse{} + mi := &file_cryptopets_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyBattleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyBattleResponse) ProtoMessage() {} + +func (x *VerifyBattleResponse) ProtoReflect() protoreflect.Message { + mi := &file_cryptopets_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyBattleResponse.ProtoReflect.Descriptor instead. +func (*VerifyBattleResponse) Descriptor() ([]byte, []int) { + return file_cryptopets_proto_rawDescGZIP(), []int{11} +} + +func (x *VerifyBattleResponse) GetFirstWins() bool { + if x != nil { + return x.FirstWins + } + return false +} + +func (x *VerifyBattleResponse) GetRounds() uint32 { + if x != nil { + return x.Rounds + } + return 0 +} + +func (x *VerifyBattleResponse) GetWinnerHpRemaining() uint32 { + if x != nil { + return x.WinnerHpRemaining + } + return 0 +} + +func (x *VerifyBattleResponse) GetStartHp1() uint32 { + if x != nil { + return x.StartHp1 + } + return 0 +} + +func (x *VerifyBattleResponse) GetStartHp2() uint32 { + if x != nil { + return x.StartHp2 + } + return 0 +} + +func (x *VerifyBattleResponse) GetLog() []*VerifyStrikeLogEntry { + if x != nil { + return x.Log + } + return nil +} + +func (x *VerifyBattleResponse) GetAttacker() *VerifyPetProgression { + if x != nil { + return x.Attacker + } + return nil +} + +func (x *VerifyBattleResponse) GetDefender() *VerifyPetProgression { + if x != nil { + return x.Defender + } + return nil +} + type StreamRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Resume point per chain so a reconnecting client misses nothing: @@ -543,7 +1162,7 @@ type StreamRequest struct { func (x *StreamRequest) Reset() { *x = StreamRequest{} - mi := &file_cryptopets_proto_msgTypes[6] + mi := &file_cryptopets_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -555,7 +1174,7 @@ func (x *StreamRequest) String() string { func (*StreamRequest) ProtoMessage() {} func (x *StreamRequest) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[6] + mi := &file_cryptopets_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -568,7 +1187,7 @@ func (x *StreamRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamRequest.ProtoReflect.Descriptor instead. func (*StreamRequest) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{6} + return file_cryptopets_proto_rawDescGZIP(), []int{12} } func (x *StreamRequest) GetAfterVersion() map[string]uint64 { @@ -600,7 +1219,7 @@ type BattleEvent struct { func (x *BattleEvent) Reset() { *x = BattleEvent{} - mi := &file_cryptopets_proto_msgTypes[7] + mi := &file_cryptopets_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -612,7 +1231,7 @@ func (x *BattleEvent) String() string { func (*BattleEvent) ProtoMessage() {} func (x *BattleEvent) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[7] + mi := &file_cryptopets_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -625,7 +1244,7 @@ func (x *BattleEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use BattleEvent.ProtoReflect.Descriptor instead. func (*BattleEvent) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{7} + return file_cryptopets_proto_rawDescGZIP(), []int{13} } func (x *BattleEvent) GetChain() string { @@ -776,7 +1395,68 @@ const file_cryptopets_proto_rawDesc = "" + "\asamples\x18\x04 \x01(\rR\asamples\"P\n" + "\vWinResponse\x12'\n" + "\x0fwin_probability\x18\x01 \x01(\x01R\x0ewinProbability\x12\x18\n" + - "\asamples\x18\x02 \x01(\rR\asamples\"\xa2\x01\n" + + "\asamples\x18\x02 \x01(\rR\asamples\"\xd0\x01\n" + + "\x0fVerifyPetInputs\x12\x15\n" + + "\x06pet_id\x18\x01 \x01(\tR\x05petId\x12\x10\n" + + "\x03dna\x18\x02 \x01(\tR\x03dna\x12\x16\n" + + "\x06rarity\x18\x03 \x01(\rR\x06rarity\x12\x14\n" + + "\x05level\x18\x04 \x01(\rR\x05level\x12\x14\n" + + "\x05skill\x18\x05 \x01(\rR\x05skill\x12\x0e\n" + + "\x02xp\x18\x06 \x01(\rR\x02xp\x12(\n" + + "\x10last_opponent_id\x18\a \x01(\tR\x0elastOpponentId\x12\x16\n" + + "\x06streak\x18\b \x01(\rR\x06streak\"\xca\x02\n" + + "\x11VerifySkillConfig\x12 \n" + + "\ftank_hp_mult\x18\x01 \x01(\rR\n" + + "tankHpMult\x12$\n" + + "\x0eshell_def_mult\x18\x02 \x01(\rR\fshellDefMult\x12(\n" + + "\x10swift_crit_bonus\x18\x03 \x01(\rR\x0eswiftCritBonus\x12(\n" + + "\x10cunning_crit_cap\x18\x04 \x01(\rR\x0ecunningCritCap\x12\"\n" + + "\rfury_dmg_mult\x18\x05 \x01(\rR\vfuryDmgMult\x12*\n" + + "\x11fury_hp_threshold\x18\x06 \x01(\rR\x0ffuryHpThreshold\x12$\n" + + "\x0esage_mdef_mult\x18\a \x01(\rR\fsageMdefMult\x12#\n" + + "\rbloodlust_bps\x18\b \x01(\rR\fbloodlustBps\"\xfa\x01\n" + + "\x13VerifyBattleRequest\x127\n" + + "\battacker\x18\x01 \x01(\v2\x1b.cryptopets.VerifyPetInputsR\battacker\x127\n" + + "\bdefender\x18\x02 \x01(\v2\x1b.cryptopets.VerifyPetInputsR\bdefender\x12\x12\n" + + "\x04seed\x18\x03 \x01(\fR\x04seed\x12@\n" + + "\fskill_config\x18\x04 \x01(\v2\x1d.cryptopets.VerifySkillConfigR\vskillConfig\x12\x1b\n" + + "\tmax_level\x18\x05 \x01(\rR\bmaxLevel\"\xd4\x02\n" + + "\x14VerifyStrikeLogEntry\x12\x14\n" + + "\x05round\x18\x01 \x01(\rR\x05round\x12\x1a\n" + + "\battacker\x18\x02 \x01(\rR\battacker\x12\x19\n" + + "\bis_magic\x18\x03 \x01(\bR\aisMagic\x12\x12\n" + + "\x04crit\x18\x04 \x01(\bR\x04crit\x12\x16\n" + + "\x06damage\x18\x05 \x01(\x04R\x06damage\x12\x12\n" + + "\x04heal\x18\x06 \x01(\x04R\x04heal\x12!\n" + + "\felement_mult\x18\a \x01(\rR\velementMult\x12%\n" + + "\x0efury_triggered\x18\b \x01(\bR\rfuryTriggered\x12+\n" + + "\x11rebirth_triggered\x18\t \x01(\bR\x10rebirthTriggered\x12\x1b\n" + + "\thp1_after\x18\n" + + " \x01(\rR\bhp1After\x12\x1b\n" + + "\thp2_after\x18\v \x01(\rR\bhp2After\"\x86\x02\n" + + "\x14VerifyPetProgression\x12\x15\n" + + "\x06pet_id\x18\x01 \x01(\tR\x05petId\x12\x10\n" + + "\x03won\x18\x02 \x01(\bR\x03won\x12\x1f\n" + + "\vdecay_shift\x18\x03 \x01(\rR\n" + + "decayShift\x12\x1d\n" + + "\n" + + "xp_awarded\x18\x04 \x01(\rR\txpAwarded\x12(\n" + + "\x10last_opponent_id\x18\x05 \x01(\tR\x0elastOpponentId\x12\x16\n" + + "\x06streak\x18\x06 \x01(\rR\x06streak\x12\x14\n" + + "\x05level\x18\a \x01(\rR\x05level\x12\x0e\n" + + "\x02xp\x18\b \x01(\rR\x02xp\x12\x1d\n" + + "\n" + + "leveled_up\x18\t \x01(\bR\tleveledUp\"\xe7\x02\n" + + "\x14VerifyBattleResponse\x12\x1d\n" + + "\n" + + "first_wins\x18\x01 \x01(\bR\tfirstWins\x12\x16\n" + + "\x06rounds\x18\x02 \x01(\rR\x06rounds\x12.\n" + + "\x13winner_hp_remaining\x18\x03 \x01(\rR\x11winnerHpRemaining\x12\x1b\n" + + "\tstart_hp1\x18\x04 \x01(\rR\bstartHp1\x12\x1b\n" + + "\tstart_hp2\x18\x05 \x01(\rR\bstartHp2\x122\n" + + "\x03log\x18\x06 \x03(\v2 .cryptopets.VerifyStrikeLogEntryR\x03log\x12<\n" + + "\battacker\x18\a \x01(\v2 .cryptopets.VerifyPetProgressionR\battacker\x12<\n" + + "\bdefender\x18\b \x01(\v2 .cryptopets.VerifyPetProgressionR\bdefender\"\xa2\x01\n" + "\rStreamRequest\x12P\n" + "\rafter_version\x18\x01 \x03(\v2+.cryptopets.StreamRequest.AfterVersionEntryR\fafterVersion\x1a?\n" + "\x11AfterVersionEntry\x12\x10\n" + @@ -797,12 +1477,13 @@ const file_cryptopets_proto_rawDesc = "" + " \x01(\rR\x06rounds\x12.\n" + "\x13winner_hp_remaining\x18\v \x01(\rR\x11winnerHpRemaining\x12\x15\n" + "\x06xp_win\x18\f \x01(\rR\x05xpWin\x12\x17\n" + - "\axp_loss\x18\r \x01(\rR\x06xpLoss2\xaf\x02\n" + + "\axp_loss\x18\r \x01(\rR\x06xpLoss2\x82\x03\n" + "\x0fGameDataService\x12I\n" + "\x11StreamLiveBattles\x12\x19.cryptopets.StreamRequest\x1a\x17.cryptopets.BattleEvent0\x01\x12>\n" + "\vGetPetState\x12\x16.cryptopets.PetRequest\x1a\x17.cryptopets.PetResponse\x12Q\n" + "\x12ListReadyOpponents\x12\x1c.cryptopets.OpponentsRequest\x1a\x1d.cryptopets.OpponentsResponse\x12>\n" + - "\vEstimateWin\x12\x16.cryptopets.WinRequest\x1a\x17.cryptopets.WinResponseB.Z,github.com/radcrew/do-not-stop/indexer-go/pbb\x06proto3" + "\vEstimateWin\x12\x16.cryptopets.WinRequest\x1a\x17.cryptopets.WinResponse\x12Q\n" + + "\fVerifyBattle\x12\x1f.cryptopets.VerifyBattleRequest\x1a .cryptopets.VerifyBattleResponseB.Z,github.com/radcrew/do-not-stop/indexer-go/pbb\x06proto3" var ( file_cryptopets_proto_rawDescOnce sync.Once @@ -816,34 +1497,48 @@ func file_cryptopets_proto_rawDescGZIP() []byte { return file_cryptopets_proto_rawDescData } -var file_cryptopets_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_cryptopets_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_cryptopets_proto_goTypes = []any{ - (*PetRequest)(nil), // 0: cryptopets.PetRequest - (*PetResponse)(nil), // 1: cryptopets.PetResponse - (*OpponentsRequest)(nil), // 2: cryptopets.OpponentsRequest - (*OpponentsResponse)(nil), // 3: cryptopets.OpponentsResponse - (*WinRequest)(nil), // 4: cryptopets.WinRequest - (*WinResponse)(nil), // 5: cryptopets.WinResponse - (*StreamRequest)(nil), // 6: cryptopets.StreamRequest - (*BattleEvent)(nil), // 7: cryptopets.BattleEvent - nil, // 8: cryptopets.StreamRequest.AfterVersionEntry + (*PetRequest)(nil), // 0: cryptopets.PetRequest + (*PetResponse)(nil), // 1: cryptopets.PetResponse + (*OpponentsRequest)(nil), // 2: cryptopets.OpponentsRequest + (*OpponentsResponse)(nil), // 3: cryptopets.OpponentsResponse + (*WinRequest)(nil), // 4: cryptopets.WinRequest + (*WinResponse)(nil), // 5: cryptopets.WinResponse + (*VerifyPetInputs)(nil), // 6: cryptopets.VerifyPetInputs + (*VerifySkillConfig)(nil), // 7: cryptopets.VerifySkillConfig + (*VerifyBattleRequest)(nil), // 8: cryptopets.VerifyBattleRequest + (*VerifyStrikeLogEntry)(nil), // 9: cryptopets.VerifyStrikeLogEntry + (*VerifyPetProgression)(nil), // 10: cryptopets.VerifyPetProgression + (*VerifyBattleResponse)(nil), // 11: cryptopets.VerifyBattleResponse + (*StreamRequest)(nil), // 12: cryptopets.StreamRequest + (*BattleEvent)(nil), // 13: cryptopets.BattleEvent + nil, // 14: cryptopets.StreamRequest.AfterVersionEntry } var file_cryptopets_proto_depIdxs = []int32{ - 1, // 0: cryptopets.OpponentsResponse.pets:type_name -> cryptopets.PetResponse - 8, // 1: cryptopets.StreamRequest.after_version:type_name -> cryptopets.StreamRequest.AfterVersionEntry - 6, // 2: cryptopets.GameDataService.StreamLiveBattles:input_type -> cryptopets.StreamRequest - 0, // 3: cryptopets.GameDataService.GetPetState:input_type -> cryptopets.PetRequest - 2, // 4: cryptopets.GameDataService.ListReadyOpponents:input_type -> cryptopets.OpponentsRequest - 4, // 5: cryptopets.GameDataService.EstimateWin:input_type -> cryptopets.WinRequest - 7, // 6: cryptopets.GameDataService.StreamLiveBattles:output_type -> cryptopets.BattleEvent - 1, // 7: cryptopets.GameDataService.GetPetState:output_type -> cryptopets.PetResponse - 3, // 8: cryptopets.GameDataService.ListReadyOpponents:output_type -> cryptopets.OpponentsResponse - 5, // 9: cryptopets.GameDataService.EstimateWin:output_type -> cryptopets.WinResponse - 6, // [6:10] is the sub-list for method output_type - 2, // [2:6] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 1, // 0: cryptopets.OpponentsResponse.pets:type_name -> cryptopets.PetResponse + 6, // 1: cryptopets.VerifyBattleRequest.attacker:type_name -> cryptopets.VerifyPetInputs + 6, // 2: cryptopets.VerifyBattleRequest.defender:type_name -> cryptopets.VerifyPetInputs + 7, // 3: cryptopets.VerifyBattleRequest.skill_config:type_name -> cryptopets.VerifySkillConfig + 9, // 4: cryptopets.VerifyBattleResponse.log:type_name -> cryptopets.VerifyStrikeLogEntry + 10, // 5: cryptopets.VerifyBattleResponse.attacker:type_name -> cryptopets.VerifyPetProgression + 10, // 6: cryptopets.VerifyBattleResponse.defender:type_name -> cryptopets.VerifyPetProgression + 14, // 7: cryptopets.StreamRequest.after_version:type_name -> cryptopets.StreamRequest.AfterVersionEntry + 12, // 8: cryptopets.GameDataService.StreamLiveBattles:input_type -> cryptopets.StreamRequest + 0, // 9: cryptopets.GameDataService.GetPetState:input_type -> cryptopets.PetRequest + 2, // 10: cryptopets.GameDataService.ListReadyOpponents:input_type -> cryptopets.OpponentsRequest + 4, // 11: cryptopets.GameDataService.EstimateWin:input_type -> cryptopets.WinRequest + 8, // 12: cryptopets.GameDataService.VerifyBattle:input_type -> cryptopets.VerifyBattleRequest + 13, // 13: cryptopets.GameDataService.StreamLiveBattles:output_type -> cryptopets.BattleEvent + 1, // 14: cryptopets.GameDataService.GetPetState:output_type -> cryptopets.PetResponse + 3, // 15: cryptopets.GameDataService.ListReadyOpponents:output_type -> cryptopets.OpponentsResponse + 5, // 16: cryptopets.GameDataService.EstimateWin:output_type -> cryptopets.WinResponse + 11, // 17: cryptopets.GameDataService.VerifyBattle:output_type -> cryptopets.VerifyBattleResponse + 13, // [13:18] is the sub-list for method output_type + 8, // [8:13] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_cryptopets_proto_init() } @@ -857,7 +1552,7 @@ func file_cryptopets_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_cryptopets_proto_rawDesc), len(file_cryptopets_proto_rawDesc)), NumEnums: 0, - NumMessages: 9, + NumMessages: 15, NumExtensions: 0, NumServices: 1, }, diff --git a/indexer-go/pb/cryptopets_grpc.pb.go b/indexer-go/pb/cryptopets_grpc.pb.go index 793071a0..2fda2e72 100644 --- a/indexer-go/pb/cryptopets_grpc.pb.go +++ b/indexer-go/pb/cryptopets_grpc.pb.go @@ -27,6 +27,7 @@ const ( GameDataService_GetPetState_FullMethodName = "/cryptopets.GameDataService/GetPetState" GameDataService_ListReadyOpponents_FullMethodName = "/cryptopets.GameDataService/ListReadyOpponents" GameDataService_EstimateWin_FullMethodName = "/cryptopets.GameDataService/EstimateWin" + GameDataService_VerifyBattle_FullMethodName = "/cryptopets.GameDataService/VerifyBattle" ) // GameDataServiceClient is the client API for GameDataService service. @@ -50,6 +51,15 @@ type GameDataServiceClient interface { // many seeds for pet_id1 vs pet_id2 (both read from the warm roster cache) // and returns pet_id1's win probability. UNAVAILABLE until the cache is warm. EstimateWin(ctx context.Context, in *WinRequest, opts ...grpc.CallOption) (*WinResponse, error) + // Independent recomputation of a backend-authoritative battle + // (docs/plan-backend-battle-architecture.md §F). Takes everything needed to + // rerun the fight and the progression composition — no database, no cache, + // no chain state — so a mismatch against the TypeScript engine's own result + // means the two implementations disagree, not that either read something + // different. Release safety only: this does not constrain an operator who + // controls both processes (see the architecture doc's "what the Go verifier + // is for" section). Never wired to any state-mutating path. + VerifyBattle(ctx context.Context, in *VerifyBattleRequest, opts ...grpc.CallOption) (*VerifyBattleResponse, error) } type gameDataServiceClient struct { @@ -109,6 +119,16 @@ func (c *gameDataServiceClient) EstimateWin(ctx context.Context, in *WinRequest, return out, nil } +func (c *gameDataServiceClient) VerifyBattle(ctx context.Context, in *VerifyBattleRequest, opts ...grpc.CallOption) (*VerifyBattleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(VerifyBattleResponse) + err := c.cc.Invoke(ctx, GameDataService_VerifyBattle_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // GameDataServiceServer is the server API for GameDataService service. // All implementations must embed UnimplementedGameDataServiceServer // for forward compatibility. @@ -130,6 +150,15 @@ type GameDataServiceServer interface { // many seeds for pet_id1 vs pet_id2 (both read from the warm roster cache) // and returns pet_id1's win probability. UNAVAILABLE until the cache is warm. EstimateWin(context.Context, *WinRequest) (*WinResponse, error) + // Independent recomputation of a backend-authoritative battle + // (docs/plan-backend-battle-architecture.md §F). Takes everything needed to + // rerun the fight and the progression composition — no database, no cache, + // no chain state — so a mismatch against the TypeScript engine's own result + // means the two implementations disagree, not that either read something + // different. Release safety only: this does not constrain an operator who + // controls both processes (see the architecture doc's "what the Go verifier + // is for" section). Never wired to any state-mutating path. + VerifyBattle(context.Context, *VerifyBattleRequest) (*VerifyBattleResponse, error) mustEmbedUnimplementedGameDataServiceServer() } @@ -152,6 +181,9 @@ func (UnimplementedGameDataServiceServer) ListReadyOpponents(context.Context, *O func (UnimplementedGameDataServiceServer) EstimateWin(context.Context, *WinRequest) (*WinResponse, error) { return nil, status.Error(codes.Unimplemented, "method EstimateWin not implemented") } +func (UnimplementedGameDataServiceServer) VerifyBattle(context.Context, *VerifyBattleRequest) (*VerifyBattleResponse, error) { + return nil, status.Error(codes.Unimplemented, "method VerifyBattle not implemented") +} func (UnimplementedGameDataServiceServer) mustEmbedUnimplementedGameDataServiceServer() {} func (UnimplementedGameDataServiceServer) testEmbeddedByValue() {} @@ -238,6 +270,24 @@ func _GameDataService_EstimateWin_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _GameDataService_VerifyBattle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(VerifyBattleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GameDataServiceServer).VerifyBattle(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GameDataService_VerifyBattle_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GameDataServiceServer).VerifyBattle(ctx, req.(*VerifyBattleRequest)) + } + return interceptor(ctx, in, info, handler) +} + // GameDataService_ServiceDesc is the grpc.ServiceDesc for GameDataService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -257,6 +307,10 @@ var GameDataService_ServiceDesc = grpc.ServiceDesc{ MethodName: "EstimateWin", Handler: _GameDataService_EstimateWin_Handler, }, + { + MethodName: "VerifyBattle", + Handler: _GameDataService_VerifyBattle_Handler, + }, }, Streams: []grpc.StreamDesc{ { diff --git a/proto/cryptopets.proto b/proto/cryptopets.proto index 954ec32d..6a8dcdf2 100644 --- a/proto/cryptopets.proto +++ b/proto/cryptopets.proto @@ -27,6 +27,16 @@ service GameDataService { // many seeds for pet_id1 vs pet_id2 (both read from the warm roster cache) // and returns pet_id1's win probability. UNAVAILABLE until the cache is warm. rpc EstimateWin(WinRequest) returns (WinResponse); + + // Independent recomputation of a backend-authoritative battle + // (docs/plan-backend-battle-architecture.md §F). Takes everything needed to + // rerun the fight and the progression composition — no database, no cache, + // no chain state — so a mismatch against the TypeScript engine's own result + // means the two implementations disagree, not that either read something + // different. Release safety only: this does not constrain an operator who + // controls both processes (see the architecture doc's "what the Go verifier + // is for" section). Never wired to any state-mutating path. + rpc VerifyBattle(VerifyBattleRequest) returns (VerifyBattleResponse); } message PetRequest { @@ -86,6 +96,84 @@ message WinResponse { uint32 samples = 2; // seeds actually sampled } +// One pet's frozen snapshot fields, as needed to recompute a fight and its +// progression. Mirrors indexer-go's combat.PetInputs / protocol's PetSnapshot +// (the subset both computations actually read — owner, readyAt, and +// sourceVersion are not needed to run the fight). +message VerifyPetInputs { + string pet_id = 1; // decimal string; parsed to uint64 server-side + string dna = 2; // decimal string (16-digit DNA fits uint64) + uint32 rarity = 3; + uint32 level = 4; + uint32 skill = 5; + uint32 xp = 6; + string last_opponent_id = 7; // decimal string; "0" = no prior opponent + uint32 streak = 8; +} + +// Mirrors protocol's SkillConfig field for field. +message VerifySkillConfig { + uint32 tank_hp_mult = 1; + uint32 shell_def_mult = 2; + uint32 swift_crit_bonus = 3; + uint32 cunning_crit_cap = 4; + uint32 fury_dmg_mult = 5; + uint32 fury_hp_threshold = 6; + uint32 sage_mdef_mult = 7; + uint32 bloodlust_bps = 8; +} + +message VerifyBattleRequest { + VerifyPetInputs attacker = 1; + VerifyPetInputs defender = 2; + bytes seed = 3; // 32 bytes, big-endian, matching the on-chain uint256 seed + VerifySkillConfig skill_config = 4; + uint32 max_level = 5; +} + +// One resolved attack, in fight order. Mirrors indexer-go's +// combat.StrikeLogEntry / protocol's StrikeLogEntry field for field, so the +// caller can convert this into the same shape the TypeScript engine produces +// and hash both with @cryptopets/protocol's canonical encoding — Go never +// reimplements that encoding itself (see indexer-go's simlog.go doc comment). +message VerifyStrikeLogEntry { + uint32 round = 1; + uint32 attacker = 2; // 1 = pet1/attacker, 2 = pet2/defender + bool is_magic = 3; + bool crit = 4; + uint64 damage = 5; + uint64 heal = 6; + uint32 element_mult = 7; // 85 | 100 | 115 + bool fury_triggered = 8; + bool rebirth_triggered = 9; + uint32 hp1_after = 10; + uint32 hp2_after = 11; +} + +// Mirrors indexer-go's combat.PetProgression / protocol's PetProgression. +message VerifyPetProgression { + string pet_id = 1; + bool won = 2; + uint32 decay_shift = 3; + uint32 xp_awarded = 4; + string last_opponent_id = 5; + uint32 streak = 6; + uint32 level = 7; + uint32 xp = 8; + bool leveled_up = 9; +} + +message VerifyBattleResponse { + bool first_wins = 1; + uint32 rounds = 2; + uint32 winner_hp_remaining = 3; + uint32 start_hp1 = 4; + uint32 start_hp2 = 5; + repeated VerifyStrikeLogEntry log = 6; + VerifyPetProgression attacker = 7; + VerifyPetProgression defender = 8; +} + message StreamRequest { // Resume point per chain so a reconnecting client misses nothing: // chain -> last seen version (Solana slot / EVM block timestamp). From 6ff69dbe4b81d157f4eae54720b7a4baa8d133d1 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 12:43:40 -0400 Subject: [PATCH 27/76] feat(backend): sign battle receipts and append global and per-pet hash chains --- backend/env.example | 5 + backend/src/config/env.ts | 8 + backend/src/features/battle-ledger/state.ts | 14 +- backend/src/features/battle-worker/index.ts | 1 + backend/src/features/battle-worker/runner.ts | 2 + .../src/features/battle-worker/sign.worker.ts | 373 ++++++++++++++++++ .../features/battle-ledger/state.test.ts | 11 +- .../features/battle-worker/runner.test.ts | 14 +- .../battle-worker/sign.worker.test.ts | 355 +++++++++++++++++ 9 files changed, 776 insertions(+), 7 deletions(-) create mode 100644 backend/src/features/battle-worker/sign.worker.ts create mode 100644 backend/tests/features/battle-worker/sign.worker.test.ts diff --git a/backend/env.example b/backend/env.example index ee457e40..3922bc68 100644 --- a/backend/env.example +++ b/backend/env.example @@ -177,3 +177,8 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # BATTLE_WORKER_POLL_INTERVAL_MS=2000 # Messages claimed per poll, per topic. Default: 10. # BATTLE_WORKER_BATCH_SIZE=10 +# Backend-mode post-battle cooldown applied to both pets once a receipt signs, +# separate from the on-chain pet_roster.ready_at. Matches GameConfig.battleCooldown's +# 900s on-chain default as a starting value only — the two are not required to agree. +# Default: 900. +# BATTLE_COOLDOWN_SECONDS=900 diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 7c4001fb..22c18559 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -178,6 +178,14 @@ export const env = { workerPollIntervalMs: Number(process.env.BATTLE_WORKER_POLL_INTERVAL_MS?.trim() || '2000'), /** Messages claimed per poll, per topic. */ workerBatchSize: Number(process.env.BATTLE_WORKER_BATCH_SIZE?.trim() || '10'), + /** + * Backend-mode post-battle cooldown, applied to both pets after a signed + * receipt (§C's `pet_battle_progress`, distinct from the on-chain + * `pet_roster.ready_at`). Matches `GameConfig.battleCooldown`'s 900s + * on-chain default; there is no requirement the two agree going forward; this + * is just a sane starting value rather than an arbitrary one. + */ + cooldownSeconds: Number(process.env.BATTLE_COOLDOWN_SECONDS?.trim() || '900'), }, /** diff --git a/backend/src/features/battle-ledger/state.ts b/backend/src/features/battle-ledger/state.ts index fd9f18cd..62a6973a 100644 --- a/backend/src/features/battle-ledger/state.ts +++ b/backend/src/features/battle-ledger/state.ts @@ -97,9 +97,19 @@ export function isCommitted(state: BattleState): boolean { return state !== BattleState.accepted; } -/** Whether locks on both pets should be released once a battle reaches `state`. */ +/** + * Whether locks on both pets should be released once a battle reaches `state`. + * + * Every terminal state releases, plus `signed` specifically: once a receipt is + * signed, the fight is fully sealed and nothing about `published`/`batched` + * afterward can change a pet's outcome. Waiting for `batched` instead — the + * periodic, possibly hours-later aggregation step (§I) — would leave both pets + * unable to battle again for however long batching happens to take, which has + * nothing to do with why a lock exists in the first place (one open battle per + * pet, not "until the receipt is aggregated"). + */ export function shouldReleaseLocks(state: BattleState): boolean { - return isTerminal(state); + return isTerminal(state) || state === BattleState.signed; } /** Thrown for an illegal move, so callers can distinguish it from a retry. */ diff --git a/backend/src/features/battle-worker/index.ts b/backend/src/features/battle-worker/index.ts index dbddce65..dd20f7c1 100644 --- a/backend/src/features/battle-worker/index.ts +++ b/backend/src/features/battle-worker/index.ts @@ -1,5 +1,6 @@ export { processAwaitBeaconMessage } from './beacon.worker'; export { processComputeMessage } from './compute.worker'; +export { processSignMessage } from './sign.worker'; export { processVerifyMessage } from './verify.worker'; export { type BattleWorkerHandle, diff --git a/backend/src/features/battle-worker/runner.ts b/backend/src/features/battle-worker/runner.ts index ebad7bdf..5b478e6f 100644 --- a/backend/src/features/battle-worker/runner.ts +++ b/backend/src/features/battle-worker/runner.ts @@ -3,6 +3,7 @@ import { type ClaimedMessage, claimOutbox, failOutbox, OUTBOX_TOPICS } from '@fe import { processAwaitBeaconMessage } from './beacon.worker'; import { processComputeMessage } from './compute.worker'; +import { processSignMessage } from './sign.worker'; import { processVerifyMessage } from './verify.worker'; /** @@ -19,6 +20,7 @@ const HANDLERS: Record [OUTBOX_TOPICS.awaitBeacon]: processAwaitBeaconMessage, [OUTBOX_TOPICS.compute]: processComputeMessage, [OUTBOX_TOPICS.verify]: processVerifyMessage, + [OUTBOX_TOPICS.sign]: processSignMessage, }; /** One poll: claims due messages for the topics this worker owns and processes each in turn. */ diff --git a/backend/src/features/battle-worker/sign.worker.ts b/backend/src/features/battle-worker/sign.worker.ts new file mode 100644 index 00000000..7a1ca08c --- /dev/null +++ b/backend/src/features/battle-worker/sign.worker.ts @@ -0,0 +1,373 @@ +import { + type BattleReceipt, + type BattleSnapshot, + hashBattleReceipt, + type Hex, + type ProgressionDelta, +} from '@cryptopets/protocol'; +import { BattleState } from '@generated/prisma/enums'; +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'; + +/** + * Handles `sign` messages: `verified` -> `signed` (§G). + * + * This is where a computed, cross-verified battle becomes a permanent, checkable + * record. Three things happen atomically with the state move: the receipt row is + * created, both pets' off-chain progression is applied (level, XP, streak, + * opponent history, win/loss count, cooldown), and both pets' per-pet receipt + * chain head advances — because a receipt that updated progression but did not + * record itself as that pet's new chain head would make the next battle's + * `attackerPreviousReceiptHash` a lie. + * + * The signer itself refuses to sign anything that is not a well-formed receipt + * with the required attestations (`battle-signer`'s job, not this file's), so + * this worker's job is assembling the receipt correctly and handling the one + * real race: two different battles under the same signing key contending for + * the next global chain position. + */ +export async function processSignMessage(message: ClaimedMessage, nowSeconds: number): Promise { + const battle = await prisma.battleLedger.findUnique({ where: { battleId: message.battleId } }); + if (!battle) { + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + if (battle.state !== BattleState.verified) { + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + if ( + !battle.seed || + !battle.combatLogHash || + !battle.beaconSignature || + !battle.beaconRandomness || + battle.attackerWon === null || + battle.rounds === null || + battle.winnerHpRemaining === null || + !battle.progression + ) { + 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, + }; + // Same deserialization need: PetProgression.petId/lastOpponentId are bigint in + // the protocol type but decimal strings in storage. + const storedProgression = battle.progression as unknown as { + attacker: StoredProgression; + defender: StoredProgression; + }; + const progression: ProgressionDelta = { + attacker: deserializeProgression(storedProgression.attacker), + defender: deserializeProgression(storedProgression.defender), + }; + + for (let attempt = 0; attempt < MAX_RECEIPT_CHAIN_RETRIES; attempt++) { + const key = activeSigningKey(); + if (!key) { + await failSigning(battle.battleId, 'no active signing key'); + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + + const [globalHead, attackerHead, defenderHead] = await Promise.all([ + prisma.battleReceipt.findFirst({ + where: { signingKeyId: key.keyId }, + orderBy: { sequence: 'desc' }, + select: { receiptHash: true, sequence: true }, + }), + prisma.petBattleProgress.findUnique({ + where: { + chainId_deploymentId_petId: { + chainId: battle.chainId, + deploymentId: battle.deploymentId, + petId: battle.attackerPetId, + }, + }, + select: { lastReceiptHash: true }, + }), + prisma.petBattleProgress.findUnique({ + where: { + chainId_deploymentId_petId: { + chainId: battle.chainId, + deploymentId: battle.deploymentId, + petId: battle.defenderPetId, + }, + }, + select: { lastReceiptHash: true }, + }), + ]); + + const receipt: BattleReceipt = { + domain: { chainId: battle.chainId as never, deploymentId: battle.deploymentId }, + battleId: battle.battleId, + intentHash: battle.intentHash as Hex, + commitmentHash: (await commitmentHashFor(battle.battleId)) as Hex, + defenseAuthorizationHash: battle.authorizationHash as Hex, + snapshot, + beacon: { + chainHash: battle.drandChainHash as Hex, + round: Number(battle.drandRound), + signature: battle.beaconSignature as Hex, + randomness: battle.beaconRandomness as Hex, + }, + seed: battle.seed as Hex, + rulesetVersion: battle.rulesetVersion, + rulesetHash: battle.rulesetHash as Hex, + result: { + attackerWon: battle.attackerWon, + rounds: battle.rounds, + winnerHpRemaining: battle.winnerHpRemaining, + }, + combatLogHash: battle.combatLogHash as Hex, + progression, + sequence: globalHead ? Number(globalHead.sequence) + 1 : 1, + previousReceiptHash: (globalHead?.receiptHash ?? null) as Hex | null, + attackerPreviousReceiptHash: (attackerHead?.lastReceiptHash ?? null) as Hex | null, + defenderPreviousReceiptHash: (defenderHead?.lastReceiptHash ?? null) as Hex | null, + createdAt: nowSeconds, + signingKeyId: key.keyId, + }; + + // The signer refuses a stale attestation, so it has to name the digest of + // *this exact* receipt. Computed here rather than left to the signer alone, + // so the attestation list is correct before the call rather than trusting a + // round trip to line the two up. + const receiptHash = hashBattleReceipt(receipt); + const attestations = buildAttestations(battle, receiptHash, nowSeconds); + + let signed: Awaited>; + try { + signed = await sign({ kind: 'receipt', receipt, attestations }, nowSeconds); + } catch (error) { + if (error instanceof SignerRefusedError) { + await failSigning(battle.battleId, error.message); + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + throw error; + } + + try { + await applyTransition({ + battleId: battle.battleId, + from: BattleState.verified, + to: BattleState.signed, + onApplied: async (tx) => { + await tx.battleReceipt.create({ + data: { + receiptHash: signed.digest, + battleId: battle.battleId, + chainId: battle.chainId, + deploymentId: battle.deploymentId, + attackerPetId: battle.attackerPetId, + defenderPetId: battle.defenderPetId, + signingKeyId: signed.keyId, + sequence: BigInt(receipt.sequence), + previousReceiptHash: receipt.previousReceiptHash, + attackerPreviousReceiptHash: receipt.attackerPreviousReceiptHash, + defenderPreviousReceiptHash: receipt.defenderPreviousReceiptHash, + payload: serializeBigints(receipt), + signature: signed.signature, + createdAt: BigInt(receipt.createdAt), + }, + }); + await applyProgression(tx, battle, progression, signed.digest, nowSeconds); + }, + outbox: [{ battleId: battle.battleId, topic: OUTBOX_TOPICS.publish }], + }); + } catch (error) { + if ((error as { code?: string }).code === 'P2002') { + continue; // another battle under this key took the chain position; retry + } + throw error; + } + + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + throw new Error(`could not claim a receipt chain position for battle ${battle.battleId} after ${MAX_RECEIPT_CHAIN_RETRIES} attempts`); +} + +const MAX_RECEIPT_CHAIN_RETRIES = 5; + +/** + * Every attestation this receipt has earned. The TypeScript engine's own agreement + * is implicit in having computed the receipt at all, so it is always included. + * `go-verifier`'s is included whenever independent verification actually ran and + * matched — which is the only way this battle reached `verified` in the first + * place, so its presence here is just formalizing a check already passed, not + * re-deciding anything. + * + * Both name `receiptHash`, the digest of *this* receipt: the signer refuses any + * attestation whose `receiptHash` does not match what it independently + * recomputes, so an attestation naming the wrong receipt (stale, or for a + * different battle entirely) is caught there, not trusted here. + */ +function buildAttestations( + battle: { verificationDetail: Prisma.JsonValue }, + receiptHash: Hex, + nowSeconds: number, +): EngineAttestation[] { + const attestations: EngineAttestation[] = [ + { attester: 'typescript-engine', receiptHash, attestedAt: nowSeconds }, + ]; + if (battle.verificationDetail) { + attestations.push({ attester: 'go-verifier', receiptHash, attestedAt: nowSeconds }); + } + return attestations; +} + +async function commitmentHashFor(battleId: string): Promise { + const commitment = await prisma.battleCommitment.findUnique({ + where: { battleId }, + select: { commitmentHash: true }, + }); + if (!commitment) { + throw new Error(`battle ${battleId} has no commitment row; cannot build its receipt`); + } + return commitment.commitmentHash; +} + +/** + * Applies the signed battle's outcome to both pets' off-chain progression: + * level, XP, same-opponent history, win/loss count, and the backend-mode + * cooldown, plus advancing this pet's per-pet receipt chain head — in the same + * transaction as the receipt itself, so a receipt can never exist without the + * progression it describes, or vice versa. + */ +async function applyProgression( + tx: Prisma.TransactionClient, + battle: { chainId: string; deploymentId: string; attackerPetId: string; defenderPetId: string }, + progression: ProgressionDelta, + receiptHash: Hex, + nowSeconds: number, +): Promise { + const readyAt = BigInt(nowSeconds + env.battle.cooldownSeconds); + await tx.petBattleProgress.update({ + where: { + chainId_deploymentId_petId: { + chainId: battle.chainId, + deploymentId: battle.deploymentId, + petId: battle.attackerPetId, + }, + }, + data: { + level: progression.attacker.level, + xp: progression.attacker.xp, + lastOpponentId: progression.attacker.lastOpponentId.toString(), + streak: progression.attacker.streak, + winCount: { increment: progression.attacker.won ? 1 : 0 }, + lossCount: { increment: progression.attacker.won ? 0 : 1 }, + readyAt, + lastReceiptHash: receiptHash, + }, + }); + await tx.petBattleProgress.update({ + where: { + chainId_deploymentId_petId: { + chainId: battle.chainId, + deploymentId: battle.deploymentId, + petId: battle.defenderPetId, + }, + }, + data: { + level: progression.defender.level, + xp: progression.defender.xp, + lastOpponentId: progression.defender.lastOpponentId.toString(), + streak: progression.defender.streak, + winCount: { increment: progression.defender.won ? 1 : 0 }, + lossCount: { increment: progression.defender.won ? 0 : 1 }, + readyAt, + lastReceiptHash: receiptHash, + }, + }); +} + +async function failSigning(battleId: string, reason: string): Promise { + await applyTransition({ + battleId, + from: BattleState.verified, + to: BattleState.signing_failed, + patch: { failureReason: reason }, + }); +} + +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; + decayShift: number; + xpAwarded: number; + lastOpponentId: string | bigint; + streak: number; + level: number; + xp: number; + leveledUp: boolean; +} + +/** Reverses `serializeBigints` for one pet's progression fields. */ +function deserializeProgression(pet: StoredProgression): ProgressionDelta['attacker'] { + return { + petId: BigInt(pet.petId), + won: pet.won, + decayShift: pet.decayShift, + xpAwarded: pet.xpAwarded, + lastOpponentId: BigInt(pet.lastOpponentId), + streak: pet.streak, + level: pet.level, + xp: pet.xp, + leveledUp: pet.leveledUp, + }; +} diff --git a/backend/tests/features/battle-ledger/state.test.ts b/backend/tests/features/battle-ledger/state.test.ts index 7c2984f4..666c8b71 100644 --- a/backend/tests/features/battle-ledger/state.test.ts +++ b/backend/tests/features/battle-ledger/state.test.ts @@ -104,11 +104,18 @@ describe('terminal states', () => { expect([...withoutEdges].sort()).toEqual([...TERMINAL_STATES].sort()); }); - it('release both pets', () => { + it('release both pets on every terminal state, and on signed', () => { for (const state of TERMINAL_STATES) { expect(shouldReleaseLocks(state)).toBe(true); } - for (const state of BATTLE_HAPPY_PATH.filter((s) => !isTerminal(s))) { + expect(shouldReleaseLocks(BattleState.signed)).toBe(true); + }); + + it('keeps locks held only while the fight itself could still change', () => { + // Once signed, nothing about publishing or batching can change a pet's outcome, so the + // release check itself does not need to fire again at those later states — the pets are + // already free from the moment signing succeeds. + for (const state of BATTLE_HAPPY_PATH.filter((s) => !isTerminal(s) && s !== BattleState.signed)) { expect(shouldReleaseLocks(state)).toBe(false); } }); diff --git a/backend/tests/features/battle-worker/runner.test.ts b/backend/tests/features/battle-worker/runner.test.ts index d1e368e5..09cabfaa 100644 --- a/backend/tests/features/battle-worker/runner.test.ts +++ b/backend/tests/features/battle-worker/runner.test.ts @@ -7,7 +7,7 @@ vi.mock('@config/env', () => ({ vi.mock('@features/battle-ledger', () => ({ claimOutbox: vi.fn(), failOutbox: vi.fn(), - OUTBOX_TOPICS: { awaitBeacon: 'await-beacon', compute: 'compute', verify: 'verify' }, + OUTBOX_TOPICS: { awaitBeacon: 'await-beacon', compute: 'compute', verify: 'verify', sign: 'sign' }, })); vi.mock('@features/battle-worker/beacon.worker', () => ({ @@ -19,6 +19,9 @@ vi.mock('@features/battle-worker/compute.worker', () => ({ vi.mock('@features/battle-worker/verify.worker', () => ({ processVerifyMessage: vi.fn(), })); +vi.mock('@features/battle-worker/sign.worker', () => ({ + processSignMessage: vi.fn(), +})); import { claimOutbox, failOutbox } from '@features/battle-ledger'; import { processAwaitBeaconMessage } from '@features/battle-worker/beacon.worker'; @@ -54,7 +57,12 @@ describe('dispatch', () => { it('claims only the topics this worker owns, with the configured batch size', async () => { vi.mocked(claimOutbox).mockResolvedValue([]); await runBattleWorkerOnce('worker-a', NOW); - expect(claimOutbox).toHaveBeenCalledWith(['await-beacon', 'compute', 'verify'], 'worker-a', 10, NOW); + expect(claimOutbox).toHaveBeenCalledWith( + ['await-beacon', 'compute', 'verify', 'sign'], + 'worker-a', + 10, + NOW, + ); }); it('sends a real handler failure through failOutbox for backoff, not a silent swallow', async () => { @@ -74,7 +82,7 @@ describe('dispatch', () => { it('dead-letters a message whose topic has no handler, rather than leaving it claimed forever', async () => { vi.mocked(claimOutbox).mockResolvedValue([ - { id: 'm1', battleId: 'btl_1', topic: 'sign', payload: {}, attempts: 1 }, + { id: 'm1', battleId: 'btl_1', topic: 'publish', 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 new file mode 100644 index 00000000..5905d95c --- /dev/null +++ b/backend/tests/features/battle-worker/sign.worker.test.ts @@ -0,0 +1,355 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + computeProgression, + deriveBattleSeed, + hashBattleReceipt, + hashBattleSnapshot, + hashCombatLog, + hashRuleset, + QUICKNET, + roundTime, + simulate, + SOURCE_DEFAULT_RULESET, +} from '@cryptopets/protocol'; + +vi.mock('@config/env', () => ({ + env: { battle: { cooldownSeconds: 900 } }, +})); + +vi.mock('@config/prisma', () => ({ + prisma: { + battleLedger: { findUnique: vi.fn() }, + battleReceipt: { findFirst: vi.fn() }, + petBattleProgress: { findUnique: vi.fn() }, + battleCommitment: { findUnique: vi.fn() }, + }, +})); + +vi.mock('@features/battle-ledger', () => ({ + applyTransition: vi.fn(), + completeOutbox: vi.fn(), + OUTBOX_TOPICS: { publish: 'publish' }, +})); + +vi.mock('@features/battle-signer', async () => { + const actual = await vi.importActual('@features/battle-signer'); + return { + activeSigningKey: vi.fn(), + sign: vi.fn(), + SignerRefusedError: actual.SignerRefusedError, + }; +}); + +import { prisma } from '@config/prisma'; +import { applyTransition, completeOutbox } from '@features/battle-ledger'; +import { activeSigningKey, sign, SignerRefusedError } from '@features/battle-signer'; +import { processSignMessage } from '@features/battle-worker'; + +const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); +const NOW = roundTime(QUICKNET, 1000) + 5; +const DOMAIN = { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }; + +const ATTACKER = { + petId: '1', + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: '1234567890123456', + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: '0', + streak: 0, + readyAt: NOW - 100, + sourceVersion: '1000', +}; +const DEFENDER = { + ...ATTACKER, + petId: '2', + owner: '0x2222222222222222222222222222222222222222', + dna: '6543210987654321', + rarity: 2, + level: 11, + skill: 7, + 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, +}); + +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( + { + 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: [] }, +}; + +const MESSAGE = { id: 'msg_1', battleId: 'btl_1', topic: 'sign', payload: {}, attempts: 1 }; + +const SIGNING_KEY = { keyId: 'battle-signer-test' }; + +function fakeTx() { + return { + battleReceipt: { create: vi.fn().mockResolvedValue({}) }, + petBattleProgress: { update: vi.fn().mockResolvedValue({}) }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(BATTLE as never); + vi.mocked(prisma.battleReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.petBattleProgress.findUnique).mockResolvedValue(null); + vi.mocked(prisma.battleCommitment.findUnique).mockResolvedValue({ commitmentHash: `0x${'cc'.repeat(32)}` } as never); + vi.mocked(activeSigningKey).mockReturnValue(SIGNING_KEY as never); + vi.mocked(sign).mockImplementation((async (req: { kind: string }) => ({ + kind: req.kind, + digest: `0x${'dd'.repeat(32)}`, + signature: '0xsig'.padEnd(132, '5'), + keyId: SIGNING_KEY.keyId, + })) as never); + vi.mocked(applyTransition).mockImplementation((async (req: { onApplied?: (tx: unknown) => Promise }) => { + if (req.onApplied) await req.onApplied(fakeTx()); + return { applied: true, state: 'signed' }; + }) as never); +}); + +describe('the happy path', () => { + it('signs the first receipt under a key with no chain link', async () => { + await processSignMessage(MESSAGE, NOW); + + const signCall = vi.mocked(sign).mock.calls[0]![0] as { receipt: { sequence: number; previousReceiptHash: string | null } }; + expect(signCall.receipt.sequence).toBe(1); + expect(signCall.receipt.previousReceiptHash).toBeNull(); + + const call = vi.mocked(applyTransition).mock.calls[0]![0] as { from: string; to: string; outbox: { topic: string }[] }; + expect(call.from).toBe('verified'); + expect(call.to).toBe('signed'); + expect(call.outbox[0]!.topic).toBe('publish'); + }); + + it('links to the prior receipt under the same signing key', async () => { + vi.mocked(prisma.battleReceipt.findFirst).mockResolvedValue({ + receiptHash: `0x${'ee'.repeat(32)}`, + sequence: 4n, + } as never); + + await processSignMessage(MESSAGE, NOW); + + const signCall = vi.mocked(sign).mock.calls[0]![0] as { receipt: { sequence: number; previousReceiptHash: string } }; + expect(signCall.receipt.sequence).toBe(5); + expect(signCall.receipt.previousReceiptHash).toBe(`0x${'ee'.repeat(32)}`); + }); + + it('links each pet to its own prior receipt independently', async () => { + vi.mocked(prisma.petBattleProgress.findUnique) + .mockResolvedValueOnce({ lastReceiptHash: `0x${'11'.repeat(32)}` } as never) // attacker + .mockResolvedValueOnce(null as never); // defender: first battle + + await processSignMessage(MESSAGE, NOW); + + const signCall = vi.mocked(sign).mock.calls[0]![0] as { + receipt: { attackerPreviousReceiptHash: string | null; defenderPreviousReceiptHash: string | null }; + }; + expect(signCall.receipt.attackerPreviousReceiptHash).toBe(`0x${'11'.repeat(32)}`); + expect(signCall.receipt.defenderPreviousReceiptHash).toBeNull(); + }); + + it('includes a typescript-engine attestation with the receipt own hash', async () => { + await processSignMessage(MESSAGE, NOW); + const signCall = vi.mocked(sign).mock.calls[0]![0] as { + receipt: unknown; + attestations: { attester: string; receiptHash: string }[]; + }; + const expectedHash = hashBattleReceipt(signCall.receipt as never); + expect(signCall.attestations.find((a) => a.attester === 'typescript-engine')?.receiptHash).toBe(expectedHash); + }); + + it('includes a go-verifier attestation whenever independent verification ran', async () => { + await processSignMessage(MESSAGE, NOW); + const signCall = vi.mocked(sign).mock.calls[0]![0] as { attestations: { attester: string }[] }; + expect(signCall.attestations.some((a) => a.attester === 'go-verifier')).toBe(true); + }); + + it('omits the go-verifier attestation when no verification ever ran', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ ...BATTLE, verificationDetail: null } as never); + await processSignMessage(MESSAGE, NOW); + const signCall = vi.mocked(sign).mock.calls[0]![0] as { attestations: { attester: string }[] }; + expect(signCall.attestations.some((a) => a.attester === 'go-verifier')).toBe(false); + }); + + it('applies progression and cooldown to both pets in the same transaction as the 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); + + expect(tx.battleReceipt.create).toHaveBeenCalledTimes(1); + expect(tx.petBattleProgress.update).toHaveBeenCalledTimes(2); + const attackerUpdate = tx.petBattleProgress.update.mock.calls[0]![0]; + expect(attackerUpdate.where.chainId_deploymentId_petId.petId).toBe('1'); + expect(attackerUpdate.data.readyAt).toBe(BigInt(NOW + 900)); + expect(attackerUpdate.data.lastReceiptHash).toBe(`0x${'dd'.repeat(32)}`); + }); + + it('credits a win to the winner and a loss to the loser', 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 attackerUpdate = tx.petBattleProgress.update.mock.calls[0]![0]; + const defenderUpdate = tx.petBattleProgress.update.mock.calls[1]![0]; + if (outcome.result.firstWins) { + expect(attackerUpdate.data.winCount).toEqual({ increment: 1 }); + expect(defenderUpdate.data.lossCount).toEqual({ increment: 1 }); + } else { + expect(attackerUpdate.data.lossCount).toEqual({ increment: 1 }); + expect(defenderUpdate.data.winCount).toEqual({ increment: 1 }); + } + }); +}); + +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) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ receiptHash: `0x${'22'.repeat(32)}`, sequence: 1n } as never); + vi.mocked(applyTransition) + .mockImplementationOnce((async () => { + throw Object.assign(new Error('unique'), { code: 'P2002' }); + }) as never) + .mockImplementationOnce((async (req: { onApplied?: (tx: unknown) => Promise }) => { + if (req.onApplied) await req.onApplied(fakeTx()); + return { applied: true, state: 'signed' }; + }) as never); + + await processSignMessage(MESSAGE, NOW); + + expect(sign).toHaveBeenCalledTimes(2); + expect(applyTransition).toHaveBeenCalledTimes(2); + const secondSignCall = vi.mocked(sign).mock.calls[1]![0] as { receipt: { sequence: number } }; + expect(secondSignCall.receipt.sequence).toBe(2); + }); +}); + +describe('signing failure', () => { + it('moves to signing_failed and never enqueues publish', async () => { + vi.mocked(sign).mockRejectedValue(new SignerRefusedError('signer-not-configured', 'no key')); + + await processSignMessage(MESSAGE, NOW); + + expect(applyTransition).toHaveBeenCalledWith( + expect.objectContaining({ battleId: 'btl_1', from: 'verified', to: 'signing_failed' }), + ); + }); + + it('never signs when there is no active signing key at all', async () => { + vi.mocked(activeSigningKey).mockReturnValue(null); + await processSignMessage(MESSAGE, NOW); + expect(sign).not.toHaveBeenCalled(); + expect(applyTransition).toHaveBeenCalledWith( + expect.objectContaining({ from: 'verified', to: 'signing_failed' }), + ); + }); + + 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/); + }); +}); + +describe('idempotence', () => { + it('completes without acting when the battle has already moved on', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ ...BATTLE, state: 'signed' } as never); + await processSignMessage(MESSAGE, NOW); + expect(sign).not.toHaveBeenCalled(); + expect(completeOutbox).toHaveBeenCalled(); + }); + + it('completes without acting when the battle no longer exists', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(null); + await processSignMessage(MESSAGE, NOW); + expect(sign).not.toHaveBeenCalled(); + expect(completeOutbox).toHaveBeenCalled(); + }); + + it('throws if verified but missing a field sign needs', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ ...BATTLE, combatLogHash: null } as never); + await expect(processSignMessage(MESSAGE, NOW)).rejects.toThrow(/missing a field sign needs/); + }); + + it('throws if the commitment row is missing', async () => { + vi.mocked(prisma.battleCommitment.findUnique).mockResolvedValue(null); + await expect(processSignMessage(MESSAGE, NOW)).rejects.toThrow(/no commitment row/); + }); +}); From eee8eacdd1808b53e37f8ea0917136dcbfaa0fe4 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 12:53:11 -0400 Subject: [PATCH 28/76] feat(backend): expose battle state, commitment, receipt, and key endpoints --- backend/API.md | 36 +++ backend/src/features/battle-ledger/index.ts | 26 ++ .../battle-ledger/reads.controller.ts | 79 +++++ .../features/battle-ledger/reads.service.ts | 225 ++++++++++++++ backend/src/routes/battle.ts | 21 ++ .../battle-ledger/reads.controller.test.ts | 157 ++++++++++ .../battle-ledger/reads.service.test.ts | 280 ++++++++++++++++++ 7 files changed, 824 insertions(+) create mode 100644 backend/src/features/battle-ledger/reads.controller.ts create mode 100644 backend/src/features/battle-ledger/reads.service.ts create mode 100644 backend/tests/features/battle-ledger/reads.controller.test.ts create mode 100644 backend/tests/features/battle-ledger/reads.service.test.ts diff --git a/backend/API.md b/backend/API.md index df613932..1d8ca701 100644 --- a/backend/API.md +++ b/backend/API.md @@ -238,6 +238,42 @@ it was just being sent from the player's wallet by default. Off unless if the keeper hasn't within ~45s. See `docs/plan-realtime-battle-ux.md` / `docs/plan-realtime-battle-impl.md` for the design and threat model. +### Backend-authoritative battles (v2) + +`backend/src/routes/battle.ts` — the workflow described in +`docs/plan-backend-battle-architecture.md`. Submission and consent require a JWT +(the wallet signature inside the body is what actually authorizes the action, +per §D); the reads below require nothing, because every value they return is +either already public on chain or is itself a signed artifact anyone is meant +to check independently. + +| Method | Path | Auth | Purpose | +| --- | --- | --- | --- | +| 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. | +| 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. | +| GET | `/api/battle/:battleId/receipt` | none | The signed receipt, once signing completes. | +| GET | `/api/battle/:battleId/combat-log` | none | The per-strike log plus its hash, served separately from the receipt per §G. | +| GET | `/api/battle/signing-keys` | none | Every signing key this process currently publishes, active and retired (§G). | +| GET | `/api/battle/rulesets` | none | Metadata for every published ruleset bundle. | +| GET | `/api/battle/rulesets/:rulesetHash` | none | One ruleset's full bundle, for replaying against it. | +| POST | `/api/battle/verify-receipt` | none | Body `{ receiptHash }`. Checks the stored signature against a published key and that the payload is well-formed — §A's "operator signature, verified against a published key" row, nothing more. It does **not** re-run the fight, check the drand BLS signature, or recompute progression; that is the standalone verifier's job (§H), which runs with no backend access so its answer cannot depend on this process telling the truth. Passing this check is necessary, not sufficient. | + +These reads are what let the live-battle WebSocket become a notification only, +never a source of truth (`docs/plan-backend-battle-architecture.md` §J): a +client refetches from the routes above after reconnecting rather than trusting +whatever the socket last pushed. `backend/src/ws/liveBattleSocket.ts` itself +still broadcasts globally as of this writing — scoping it per room and marking +it notification-only in its own right is a separate, later change (§J). + +Known gap: `GET /api/battle/signing-keys` serves whatever +`@features/battle-signer`'s in-memory registry currently holds. A rotated key +registered via `registerRotatedKey` does not survive a process restart today, +so historical-key durability is not yet backed by persistent storage. + ### Relevant environment variables | Var | Purpose | diff --git a/backend/src/features/battle-ledger/index.ts b/backend/src/features/battle-ledger/index.ts index 6093854d..339bce53 100644 --- a/backend/src/features/battle-ledger/index.ts +++ b/backend/src/features/battle-ledger/index.ts @@ -7,6 +7,32 @@ export { type AcceptRejection, } from './accept.service'; export { deleteDefenseAuthorizations, postDefenseAuthorization } from './consent.controller'; +export { + getBattleCombatLog, + getBattleCommitment, + getBattleReceipt, + getBattleStateHandler, + getRulesetByHash, + getRulesets, + getSigningKeys, + postVerifyReceipt, +} from './reads.controller'; +export { + type BattleStateSummary, + type CombatLogResponse, + getBattleStateSummary, + getCombatLog, + getRuleset, + getSignedCommitment, + getSignedReceipt, + listActiveSigningKeys, + listRulesets, + type RulesetSummary, + type SignedArtifact, + verifyReceiptSignature, + type VerifyReceiptFailure, + type VerifyReceiptResult, +} from './reads.service'; export { type AuthorizationRejection, type ConsentFailure, diff --git a/backend/src/features/battle-ledger/reads.controller.ts b/backend/src/features/battle-ledger/reads.controller.ts new file mode 100644 index 00000000..fb75ee51 --- /dev/null +++ b/backend/src/features/battle-ledger/reads.controller.ts @@ -0,0 +1,79 @@ +import type { Request, Response } from 'express'; + +import { + getBattleStateSummary, + getCombatLog, + getRuleset, + getSignedCommitment, + getSignedReceipt, + listActiveSigningKeys, + listRulesets, + verifyReceiptSignature, +} from './reads.service'; + +export async function getBattleStateHandler(req: Request, res: Response): Promise { + const summary = await getBattleStateSummary(req.params.battleId as string); + if (!summary) { + res.status(404).json({ error: 'battle-not-found' }); + return; + } + res.status(200).json(summary); +} + +export async function getBattleCommitment(req: Request, res: Response): Promise { + const commitment = await getSignedCommitment(req.params.battleId as string); + if (!commitment) { + res.status(404).json({ error: 'commitment-not-found' }); + return; + } + res.status(200).json(commitment); +} + +export async function getBattleReceipt(req: Request, res: Response): Promise { + const receipt = await getSignedReceipt(req.params.battleId as string); + if (!receipt) { + res.status(404).json({ error: 'receipt-not-found' }); + return; + } + res.status(200).json(receipt); +} + +export async function getBattleCombatLog(req: Request, res: Response): Promise { + const log = await getCombatLog(req.params.battleId as string); + if (!log) { + res.status(404).json({ error: 'combat-log-not-found' }); + return; + } + res.status(200).json(log); +} + +export function getSigningKeys(_req: Request, res: Response): void { + res.status(200).json({ keys: listActiveSigningKeys() }); +} + +export async function getRulesets(_req: Request, res: Response): Promise { + res.status(200).json({ rulesets: await listRulesets() }); +} + +export async function getRulesetByHash(req: Request, res: Response): Promise { + const ruleset = await getRuleset(req.params.rulesetHash as string); + if (!ruleset) { + res.status(404).json({ error: 'ruleset-not-found' }); + return; + } + res.status(200).json(ruleset); +} + +interface VerifyReceiptBody { + receiptHash?: string; +} + +export async function postVerifyReceipt(req: Request, res: Response): Promise { + const body = req.body as VerifyReceiptBody; + if (typeof body?.receiptHash !== 'string') { + res.status(422).json({ error: 'receiptHash is required' }); + return; + } + const result = await verifyReceiptSignature(body.receiptHash); + res.status(result.ok ? 200 : 422).json(result); +} diff --git a/backend/src/features/battle-ledger/reads.service.ts b/backend/src/features/battle-ledger/reads.service.ts new file mode 100644 index 00000000..4ed68812 --- /dev/null +++ b/backend/src/features/battle-ledger/reads.service.ts @@ -0,0 +1,225 @@ +import type { Hex } from '@cryptopets/protocol'; +import { ethers } from 'ethers'; + +import { prisma } from '@config/prisma'; +import { listSigningKeys } from '@features/battle-signer'; + +/** + * Public, authoritative reads for a battle in flight or settled (§J). + * + * "Authoritative" here means these are what the frontend refetches after a + * reconnect, and what any third party — a spectator, a curious player, someone + * building their own client — can read without asking us for anything special. + * The live-battle WebSocket (Step 29) becomes a notification only once these + * exist: it can tell a client something changed, but never has to be trusted + * for what changed. + * + * Nothing here requires authentication. Every value returned is either already + * public on chain (ownership, pet ids) or is itself a signed artifact meant to + * be checked by anyone (a commitment, a receipt) — gating these behind a JWT + * would just mean a spectator with a room link can't do the one thing this + * design is supposed to let them do. + */ + +export interface BattleStateSummary { + battleId: string; + chainId: string; + deploymentId: string; + state: string; + failureReason: string | null; + attackerPetId: string; + attackerOwner: string; + defenderPetId: string; + defenderOwner: string; + rulesetHash: string; + createdAt: string; + updatedAt: string; +} + +export async function getBattleStateSummary(battleId: string): Promise { + const battle = await prisma.battleLedger.findUnique({ where: { battleId } }); + if (!battle) return null; + return { + battleId: battle.battleId, + chainId: battle.chainId, + deploymentId: battle.deploymentId, + state: battle.state, + failureReason: battle.failureReason, + attackerPetId: battle.attackerPetId, + attackerOwner: battle.attackerOwner, + defenderPetId: battle.defenderPetId, + defenderOwner: battle.defenderOwner, + rulesetHash: battle.rulesetHash, + createdAt: battle.createdAt.toISOString(), + updatedAt: battle.updatedAt.toISOString(), + }; +} + +export interface SignedArtifact { + hash: string; + signature: string; + signingKeyId: string; + /** The canonical object exactly as signed. */ + payload: unknown; +} + +/** + * The signed commitment for a battle, exactly as delivered in the accept + * response (§E). Served here too because the player's own copy is what makes + * commit-before-reveal provable, and a copy that only ever lived in local + * storage is one bad reload away from being lost. + */ +export async function getSignedCommitment(battleId: string): Promise { + const commitment = await prisma.battleCommitment.findUnique({ where: { battleId } }); + if (!commitment) return null; + return { + hash: commitment.commitmentHash, + signature: commitment.signature, + signingKeyId: commitment.signingKeyId, + payload: commitment.payload, + }; +} + +/** The signed receipt for a battle, once signing has completed (§G). */ +export async function getSignedReceipt(battleId: string): Promise { + const receipt = await prisma.battleReceipt.findUnique({ where: { battleId } }); + if (!receipt) return null; + return { + hash: receipt.receiptHash, + signature: receipt.signature, + signingKeyId: receipt.signingKeyId, + payload: receipt.payload, + }; +} + +export interface CombatLogResponse { + combatLogHash: string; + log: unknown; +} + +/** + * The per-strike combat log, served separately from the receipt (§G): the + * receipt only carries `combatLogHash`, so a client wanting to animate the + * fight fetches the log here and checks it against that hash itself, the same + * check the standalone verifier makes. + */ +export async function getCombatLog(battleId: string): Promise { + const battle = await prisma.battleLedger.findUnique({ + where: { battleId }, + select: { combatLog: true, combatLogHash: true }, + }); + if (!battle || !battle.combatLog || !battle.combatLogHash) return null; + return { combatLogHash: battle.combatLogHash, log: battle.combatLog }; +} + +/** + * Every signing key a verifier may need, active and retired (§G). Retired keys + * stay published so a receipt signed under a rotated key still verifies. + * + * The registry itself is in-memory (`battle-signer`'s `listSigningKeys`), so a + * process restart currently loses any rotated key registered only via + * `registerRotatedKey` and not reloaded at startup — this endpoint serves + * whatever the running process knows, which is a real gap worth flagging + * rather than a claim that key history is durable today. + */ +export function listActiveSigningKeys() { + return listSigningKeys(); +} + +export interface RulesetSummary { + rulesetHash: string; + version: number; + engineId: string; + engineVersion: number; + publishedAt: string; + retiredAt: string | null; +} + +/** Every published ruleset bundle's metadata, newest first. */ +export async function listRulesets(): Promise { + const rows = await prisma.battleRuleset.findMany({ orderBy: { version: 'desc' } }); + return rows.map(toRulesetSummary); +} + +/** One ruleset's full bundle, keyed by its hash — what a client needs to replay against it. */ +export async function getRuleset(rulesetHash: string): Promise<(RulesetSummary & { bundle: unknown }) | null> { + const row = await prisma.battleRuleset.findUnique({ where: { rulesetHash } }); + if (!row) return null; + return { ...toRulesetSummary(row), bundle: row.bundle }; +} + +function toRulesetSummary(row: { + rulesetHash: string; + version: number; + engineId: string; + engineVersion: number; + publishedAt: Date; + retiredAt: Date | null; +}): RulesetSummary { + return { + rulesetHash: row.rulesetHash, + version: row.version, + engineId: row.engineId, + engineVersion: row.engineVersion, + publishedAt: row.publishedAt.toISOString(), + retiredAt: row.retiredAt?.toISOString() ?? null, + }; +} + +export type VerifyReceiptFailure = + | 'not-found' + | 'unknown-signing-key' + | 'bad-signature' + | 'malformed-payload'; + +export type VerifyReceiptResult = + | { ok: true; receiptHash: string } + | { ok: false; reason: VerifyReceiptFailure; detail: string }; + +/** + * The lightweight check this backend can make on its own: does the stored + * signature actually verify against a publicly known key, and is the stored + * payload the well-formed object it claims to hash to. + * + * This is §A's "which battles the operator claims happened -> operator + * signature -> verify against a published key" row, nothing more. It does not + * re-run the fight, check the drand BLS signature, or recompute progression — + * that is the standalone verifier's job (§H, build order steps 30-32), which + * runs with no backend access at all so its answer cannot depend on this + * process telling the truth. Passing this check is necessary, not sufficient. + */ +export async function verifyReceiptSignature(receiptHash: string): Promise { + const receipt = await prisma.battleReceipt.findUnique({ where: { receiptHash } }); + if (!receipt) { + return { ok: false, reason: 'not-found', detail: `no receipt ${receiptHash}` }; + } + + const key = listSigningKeys().find((k) => k.keyId === receipt.signingKeyId); + if (!key) { + return { + ok: false, + reason: 'unknown-signing-key', + detail: `signing key ${receipt.signingKeyId} is not in this process's published registry`, + }; + } + + let recovered: string; + try { + recovered = ethers.recoverAddress(receiptHash as Hex, receipt.signature); + } catch (error) { + return { ok: false, reason: 'bad-signature', detail: (error as Error).message }; + } + if (recovered.toLowerCase() !== key.address.toLowerCase()) { + return { + ok: false, + reason: 'bad-signature', + detail: `signature recovers to ${recovered.toLowerCase()}, not ${key.address}`, + }; + } + + if (typeof receipt.payload !== 'object' || receipt.payload === null) { + return { ok: false, reason: 'malformed-payload', detail: 'stored payload is not an object' }; + } + + return { ok: true, receiptHash: receipt.receiptHash }; +} diff --git a/backend/src/routes/battle.ts b/backend/src/routes/battle.ts index 226734a8..0f920af9 100644 --- a/backend/src/routes/battle.ts +++ b/backend/src/routes/battle.ts @@ -2,9 +2,17 @@ import express, { Router } from 'express'; import { deleteDefenseAuthorizations, + getBattleCombatLog, + getBattleCommitment, + getBattleReceipt, + getBattleStateHandler, + getRulesetByHash, + getRulesets, + getSigningKeys, postAcceptBattle, postBattleIntent, postDefenseAuthorization, + postVerifyReceipt, } from '@features/battle-ledger'; import { verifyToken } from '@middleware/auth'; import { battleRoomRateLimit } from '@middleware/rateLimit'; @@ -28,4 +36,17 @@ router.post('/intents/:intentHash/accept', verifyToken, battleRoomRateLimit, (re router.post('/authorizations', verifyToken, battleRoomRateLimit, postDefenseAuthorization); router.delete('/authorizations', verifyToken, deleteDefenseAuthorizations); +// 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 +// these behind a JWT would stop a spectator with a room link from doing the one thing +// this design exists to let them do. +router.get('/signing-keys', getSigningKeys); +router.get('/rulesets', getRulesets); +router.get('/rulesets/:rulesetHash', getRulesetByHash); +router.post('/verify-receipt', postVerifyReceipt); +router.get('/:battleId', getBattleStateHandler); +router.get('/:battleId/commitment', getBattleCommitment); +router.get('/:battleId/receipt', getBattleReceipt); +router.get('/:battleId/combat-log', getBattleCombatLog); + export default router; diff --git a/backend/tests/features/battle-ledger/reads.controller.test.ts b/backend/tests/features/battle-ledger/reads.controller.test.ts new file mode 100644 index 00000000..caf7616d --- /dev/null +++ b/backend/tests/features/battle-ledger/reads.controller.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@features/battle-ledger/reads.service', () => ({ + getBattleStateSummary: vi.fn(), + getSignedCommitment: vi.fn(), + getSignedReceipt: vi.fn(), + getCombatLog: vi.fn(), + listActiveSigningKeys: vi.fn(), + listRulesets: vi.fn(), + getRuleset: vi.fn(), + verifyReceiptSignature: vi.fn(), +})); + +import { + getBattleStateSummary, + getCombatLog, + getRuleset, + getSignedCommitment, + getSignedReceipt, + listActiveSigningKeys, + listRulesets, + verifyReceiptSignature, +} from '../../../src/features/battle-ledger/reads.service'; +import { + getBattleCombatLog, + getBattleCommitment, + getBattleReceipt, + getBattleStateHandler, + getRulesetByHash, + getRulesets, + getSigningKeys, + postVerifyReceipt, +} from '../../../src/features/battle-ledger/reads.controller'; + +function mockRes() { + const res = { status: vi.fn(), json: vi.fn() }; + res.status.mockReturnValue(res); + return res as unknown as { status: ReturnType; json: ReturnType }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getBattleStateHandler', () => { + it('returns 404 for an unknown battle', async () => { + vi.mocked(getBattleStateSummary).mockResolvedValue(null); + const res = mockRes(); + await getBattleStateHandler({ params: { battleId: 'missing' } } as never, res as never); + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 200 with the summary, reading battleId from the route param', async () => { + vi.mocked(getBattleStateSummary).mockResolvedValue({ battleId: 'btl_1' } as never); + const res = mockRes(); + await getBattleStateHandler({ params: { battleId: 'btl_1' } } as never, res as never); + expect(getBattleStateSummary).toHaveBeenCalledWith('btl_1'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ battleId: 'btl_1' }); + }); +}); + +describe('getBattleCommitment', () => { + it('returns 404 when no commitment exists yet', async () => { + vi.mocked(getSignedCommitment).mockResolvedValue(null); + const res = mockRes(); + await getBattleCommitment({ params: { battleId: 'btl_1' } } as never, res as never); + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns the commitment on success', async () => { + vi.mocked(getSignedCommitment).mockResolvedValue({ hash: '0xabc' } as never); + const res = mockRes(); + await getBattleCommitment({ params: { battleId: 'btl_1' } } as never, res as never); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); + +describe('getBattleReceipt', () => { + it('returns 404 before signing completes', async () => { + vi.mocked(getSignedReceipt).mockResolvedValue(null); + const res = mockRes(); + await getBattleReceipt({ params: { battleId: 'btl_1' } } as never, res as never); + expect(res.status).toHaveBeenCalledWith(404); + }); +}); + +describe('getBattleCombatLog', () => { + it('returns 404 before the fight has been computed', async () => { + vi.mocked(getCombatLog).mockResolvedValue(null); + const res = mockRes(); + await getBattleCombatLog({ params: { battleId: 'btl_1' } } as never, res as never); + expect(res.status).toHaveBeenCalledWith(404); + }); +}); + +describe('getSigningKeys', () => { + it('serves whatever the running process currently publishes, synchronously', () => { + vi.mocked(listActiveSigningKeys).mockReturnValue([{ keyId: 'a' } as never]); + const res = mockRes(); + getSigningKeys({} as never, res as never); + expect(res.json).toHaveBeenCalledWith({ keys: [{ keyId: 'a' }] }); + }); +}); + +describe('getRulesets', () => { + it('wraps the list under a rulesets key', async () => { + vi.mocked(listRulesets).mockResolvedValue([{ version: 1 } as never]); + const res = mockRes(); + await getRulesets({} as never, res as never); + expect(res.json).toHaveBeenCalledWith({ rulesets: [{ version: 1 }] }); + }); +}); + +describe('getRulesetByHash', () => { + it('returns 404 for an unpublished hash', async () => { + vi.mocked(getRuleset).mockResolvedValue(null); + const res = mockRes(); + await getRulesetByHash({ params: { rulesetHash: '0xdead' } } as never, res as never); + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('reads the hash from the route param', async () => { + vi.mocked(getRuleset).mockResolvedValue({ version: 1 } as never); + const res = mockRes(); + await getRulesetByHash({ params: { rulesetHash: '0xabc' } } as never, res as never); + expect(getRuleset).toHaveBeenCalledWith('0xabc'); + }); +}); + +describe('postVerifyReceipt', () => { + it('rejects a request missing receiptHash', async () => { + const res = mockRes(); + await postVerifyReceipt({ body: {} } as never, res as never); + expect(res.status).toHaveBeenCalledWith(422); + expect(verifyReceiptSignature).not.toHaveBeenCalled(); + }); + + it('returns 200 when verification passes', async () => { + vi.mocked(verifyReceiptSignature).mockResolvedValue({ ok: true, receiptHash: '0xabc' }); + const res = mockRes(); + await postVerifyReceipt({ body: { receiptHash: '0xabc' } } as never, res as never); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('returns 422, not 200, when verification fails', async () => { + // A failed check is a client-meaningful outcome, not a server error. + vi.mocked(verifyReceiptSignature).mockResolvedValue({ + ok: false, + reason: 'bad-signature', + detail: 'nope', + }); + const res = mockRes(); + await postVerifyReceipt({ body: { receiptHash: '0xabc' } } as never, res as never); + expect(res.status).toHaveBeenCalledWith(422); + }); +}); diff --git a/backend/tests/features/battle-ledger/reads.service.test.ts b/backend/tests/features/battle-ledger/reads.service.test.ts new file mode 100644 index 00000000..b849231e --- /dev/null +++ b/backend/tests/features/battle-ledger/reads.service.test.ts @@ -0,0 +1,280 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ethers } from 'ethers'; + +vi.mock('@config/prisma', () => ({ + prisma: { + battleLedger: { findUnique: vi.fn() }, + battleCommitment: { findUnique: vi.fn() }, + battleReceipt: { findUnique: vi.fn() }, + battleRuleset: { findMany: vi.fn(), findUnique: vi.fn() }, + }, +})); + +vi.mock('@features/battle-signer', () => ({ + listSigningKeys: vi.fn(), +})); + +import { prisma } from '@config/prisma'; +import { + getBattleStateSummary, + getCombatLog, + getRuleset, + getSignedCommitment, + getSignedReceipt, + listActiveSigningKeys, + listRulesets, + verifyReceiptSignature, +} from '@features/battle-ledger'; +import { listSigningKeys } from '@features/battle-signer'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getBattleStateSummary', () => { + it('returns null for an unknown battle', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(null); + expect(await getBattleStateSummary('missing')).toBeNull(); + }); + + it('projects the ledger row without the internal-only fields', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ + battleId: 'btl_1', + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + state: 'signed', + failureReason: null, + attackerPetId: '1', + attackerOwner: '0xabc', + defenderPetId: '2', + defenderOwner: '0xdef', + rulesetHash: `0x${'11'.repeat(32)}`, + seed: '0xsecret-ish-but-not-really', + createdAt: new Date('2026-07-26T00:00:00.000Z'), + updatedAt: new Date('2026-07-26T00:05:00.000Z'), + } as never); + + const summary = await getBattleStateSummary('btl_1'); + + expect(summary).toEqual({ + battleId: 'btl_1', + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + state: 'signed', + failureReason: null, + attackerPetId: '1', + attackerOwner: '0xabc', + defenderPetId: '2', + defenderOwner: '0xdef', + rulesetHash: `0x${'11'.repeat(32)}`, + createdAt: '2026-07-26T00:00:00.000Z', + updatedAt: '2026-07-26T00:05:00.000Z', + }); + expect(summary).not.toHaveProperty('seed'); + }); +}); + +describe('getSignedCommitment', () => { + it('returns null when no commitment exists yet', async () => { + vi.mocked(prisma.battleCommitment.findUnique).mockResolvedValue(null); + expect(await getSignedCommitment('btl_1')).toBeNull(); + }); + + it('returns the commitment exactly as delivered at accept time', async () => { + // The player's own copy is the evidence for commit-before-reveal; this endpoint has to + // serve the identical payload or a re-fetch after a lost localStorage entry is useless. + vi.mocked(prisma.battleCommitment.findUnique).mockResolvedValue({ + commitmentHash: `0x${'22'.repeat(32)}`, + signature: '0xsig', + signingKeyId: 'battle-signer-2026-07', + payload: { battleId: 'btl_1' }, + } as never); + + expect(await getSignedCommitment('btl_1')).toEqual({ + hash: `0x${'22'.repeat(32)}`, + signature: '0xsig', + signingKeyId: 'battle-signer-2026-07', + payload: { battleId: 'btl_1' }, + }); + }); +}); + +describe('getSignedReceipt', () => { + it('returns null before signing completes', async () => { + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue(null); + expect(await getSignedReceipt('btl_1')).toBeNull(); + }); + + it('returns the signed receipt', async () => { + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash: `0x${'33'.repeat(32)}`, + signature: '0xsig', + signingKeyId: 'battle-signer-2026-07', + payload: { battleId: 'btl_1' }, + } as never); + expect(await getSignedReceipt('btl_1')).toMatchObject({ hash: `0x${'33'.repeat(32)}` }); + }); +}); + +describe('getCombatLog', () => { + it('returns null before the fight has been computed', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ + combatLog: null, + combatLogHash: null, + } as never); + expect(await getCombatLog('btl_1')).toBeNull(); + }); + + it('serves the log alongside its hash, so a client checks it the same way the verifier does', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ + combatLog: [{ round: 0 }], + combatLogHash: `0x${'44'.repeat(32)}`, + } as never); + expect(await getCombatLog('btl_1')).toEqual({ + combatLogHash: `0x${'44'.repeat(32)}`, + log: [{ round: 0 }], + }); + }); +}); + +describe('listActiveSigningKeys', () => { + it('delegates to the signer registry', () => { + vi.mocked(listSigningKeys).mockReturnValue([{ keyId: 'a' } as never]); + expect(listActiveSigningKeys()).toEqual([{ keyId: 'a' }]); + }); +}); + +describe('rulesets', () => { + it('lists published rulesets newest-version first', async () => { + vi.mocked(prisma.battleRuleset.findMany).mockResolvedValue([ + { + rulesetHash: `0x${'55'.repeat(32)}`, + version: 1, + engineId: 'cryptopets-combat-ts', + engineVersion: 1, + publishedAt: new Date('2026-07-01T00:00:00.000Z'), + retiredAt: null, + }, + ] as never); + const rulesets = await listRulesets(); + expect(vi.mocked(prisma.battleRuleset.findMany).mock.calls[0]![0]).toMatchObject({ + orderBy: { version: 'desc' }, + }); + expect(rulesets[0]).toMatchObject({ version: 1, retiredAt: null }); + }); + + it('returns null for an unpublished ruleset hash', async () => { + vi.mocked(prisma.battleRuleset.findUnique).mockResolvedValue(null); + expect(await getRuleset(`0x${'99'.repeat(32)}`)).toBeNull(); + }); + + it('includes the full bundle for a known ruleset, so a client can replay against it', async () => { + vi.mocked(prisma.battleRuleset.findUnique).mockResolvedValue({ + rulesetHash: `0x${'55'.repeat(32)}`, + version: 1, + engineId: 'cryptopets-combat-ts', + engineVersion: 1, + publishedAt: new Date('2026-07-01T00:00:00.000Z'), + retiredAt: null, + bundle: { skillConfig: {} }, + } as never); + const ruleset = await getRuleset(`0x${'55'.repeat(32)}`); + expect(ruleset?.bundle).toEqual({ skillConfig: {} }); + }); +}); + +describe('verifyReceiptSignature', () => { + const wallet = new ethers.Wallet('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'); + const receiptHash = `0x${'66'.repeat(32)}` as const; + + it('reports not-found for an unknown receipt', async () => { + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue(null); + expect(await verifyReceiptSignature(receiptHash)).toMatchObject({ ok: false, reason: 'not-found' }); + }); + + it('reports unknown-signing-key when the key is not in the published registry', async () => { + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash, + signingKeyId: 'some-other-key', + signature: '0xsig', + payload: {}, + } as never); + vi.mocked(listSigningKeys).mockReturnValue([]); + expect(await verifyReceiptSignature(receiptHash)).toMatchObject({ ok: false, reason: 'unknown-signing-key' }); + }); + + it('verifies a real signature against the recorded key address', async () => { + const signature = wallet.signingKey.sign(receiptHash).serialized; + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash, + signingKeyId: 'battle-signer-test', + signature, + payload: { battleId: 'btl_1' }, + } as never); + vi.mocked(listSigningKeys).mockReturnValue([ + { keyId: 'battle-signer-test', address: wallet.address.toLowerCase() } as never, + ]); + + expect(await verifyReceiptSignature(receiptHash)).toEqual({ ok: true, receiptHash }); + }); + + it('rejects a signature that does not recover to the claimed key', async () => { + const other = new ethers.Wallet('0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'); + const signature = other.signingKey.sign(receiptHash).serialized; + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash, + signingKeyId: 'battle-signer-test', + signature, + payload: { battleId: 'btl_1' }, + } as never); + vi.mocked(listSigningKeys).mockReturnValue([ + { keyId: 'battle-signer-test', address: wallet.address.toLowerCase() } as never, + ]); + + expect(await verifyReceiptSignature(receiptHash)).toMatchObject({ ok: false, reason: 'bad-signature' }); + }); + + it('rejects a malformed signature without throwing', async () => { + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash, + signingKeyId: 'battle-signer-test', + signature: '0xnotasignature', + payload: {}, + } as never); + vi.mocked(listSigningKeys).mockReturnValue([ + { keyId: 'battle-signer-test', address: wallet.address.toLowerCase() } as never, + ]); + expect(await verifyReceiptSignature(receiptHash)).toMatchObject({ ok: false, reason: 'bad-signature' }); + }); + + it('rejects a non-object stored payload', async () => { + const signature = wallet.signingKey.sign(receiptHash).serialized; + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash, + signingKeyId: 'battle-signer-test', + signature, + payload: null, + } as never); + vi.mocked(listSigningKeys).mockReturnValue([ + { keyId: 'battle-signer-test', address: wallet.address.toLowerCase() } as never, + ]); + expect(await verifyReceiptSignature(receiptHash)).toMatchObject({ ok: false, reason: 'malformed-payload' }); + }); + + it('does not itself replay the fight or check the drand signature', async () => { + // This is the boundary that matters: passing this check is necessary, not sufficient. + // The standalone verifier (no backend access) is what actually re-runs the battle. + const signature = wallet.signingKey.sign(receiptHash).serialized; + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash, + signingKeyId: 'battle-signer-test', + signature, + payload: { seed: 'anything-goes-here-this-check-does-not-look' }, + } as never); + vi.mocked(listSigningKeys).mockReturnValue([ + { keyId: 'battle-signer-test', address: wallet.address.toLowerCase() } as never, + ]); + expect(await verifyReceiptSignature(receiptHash)).toMatchObject({ ok: true }); + }); +}); From 8dadfef8d8c80a20a7b4df63070752a6e63f3889 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 12:59:22 -0400 Subject: [PATCH 29/76] feat(backend): publish a paginated public receipt corpus --- backend/API.md | 22 +++ backend/src/app.ts | 2 + .../battle-ledger/corpus.controller.ts | 43 +++++ .../features/battle-ledger/corpus.service.ts | 174 ++++++++++++++++++ backend/src/features/battle-ledger/index.ts | 9 + backend/src/routes/receipts.ts | 17 ++ .../battle-ledger/corpus.controller.test.ts | 77 ++++++++ .../battle-ledger/corpus.service.test.ts | 172 +++++++++++++++++ 8 files changed, 516 insertions(+) create mode 100644 backend/src/features/battle-ledger/corpus.controller.ts create mode 100644 backend/src/features/battle-ledger/corpus.service.ts create mode 100644 backend/src/routes/receipts.ts create mode 100644 backend/tests/features/battle-ledger/corpus.controller.test.ts create mode 100644 backend/tests/features/battle-ledger/corpus.service.test.ts diff --git a/backend/API.md b/backend/API.md index 1d8ca701..3c8dfb63 100644 --- a/backend/API.md +++ b/backend/API.md @@ -269,6 +269,28 @@ whatever the socket last pushed. `backend/src/ws/liveBattleSocket.ts` itself still broadcasts globally as of this writing — scoping it per room and marking it notification-only in its own right is a separate, later change (§J). +### Public receipt corpus (v2) + +`backend/src/routes/receipts.ts` — the paginated export §H item 3 calls for. +No authentication on any route here, deliberately: public replay only works if +anyone can *get* the receipts to replay, not just verify a signature over one +they already have. Every route is cursor-paginated; a response's +`nextCursor`/`nextAfter` is `null` once there is nothing further to fetch, so a +client can stop after the first short page instead of making one guaranteed- +empty extra request. + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/api/receipts/by-pet/:chainId/:petId?cursor=&limit=` | Every receipt naming this pet as attacker or defender, oldest first. This is the export a per-pet chain walk (§G) starts from — proving a pet was really level 12 means replaying the receipts that got it there. | +| GET | `/api/receipts/by-wallet/:wallet?cursor=&limit=` | Every receipt where this wallet owned either side, oldest first. Matched case-insensitively against the ledger's owner columns (the receipt table itself has no owner field, only pet ids). | +| GET | `/api/receipts?signingKeyId=&after=&limit=` | Receipts under one signing key, strictly in `sequence` order — the order the *global* hash chain requires. `signingKeyId` is required; this is the endpoint for walking one key's whole chain end to end, not for a general receipt search. | + +`limit` defaults to 100 and is clamped to 500 on every route. The by-pet and +by-wallet exports order by `(createdAt, receiptHash)`, since two receipts can +share a `createdAt` (concurrent battles resolving in the same second) and an +order that isn't fully deterministic makes cursor pagination silently skip or +repeat rows at a page boundary. + Known gap: `GET /api/battle/signing-keys` serves whatever `@features/battle-signer`'s in-memory registry currently holds. A rotated key registered via `registerRotatedKey` does not survive a process restart today, diff --git a/backend/src/app.ts b/backend/src/app.ts index ee765c64..fa33b5a3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,7 @@ import graphqlRoutes from '@routes/graphql'; import dialogueRoutes from '@routes/dialogue'; import battleRoomRoutes from '@routes/battle-room'; import battleRoutes from '@routes/battle'; +import receiptRoutes from '@routes/receipts'; const app = express(); @@ -34,6 +35,7 @@ app.use('/graphql', graphqlRoutes); app.use('/api/battle-dialogue', dialogueRoutes); app.use('/api/battle-room', battleRoomRoutes); app.use('/api/battle', battleRoutes); +app.use('/api/receipts', receiptRoutes); app.get('/', (_req: Request, res: Response) => { res.json({ diff --git a/backend/src/features/battle-ledger/corpus.controller.ts b/backend/src/features/battle-ledger/corpus.controller.ts new file mode 100644 index 00000000..777173ef --- /dev/null +++ b/backend/src/features/battle-ledger/corpus.controller.ts @@ -0,0 +1,43 @@ +import type { Request, Response } from 'express'; + +import { listReceiptsByPet, listReceiptsBySequence, listReceiptsByWallet } from './corpus.service'; + +/** + * The public receipt corpus (§H item 3). No `verifyToken` on any of these — see + * `corpus.service.ts`'s doc comment for why authentication would defeat the point. + */ + +interface PaginationQuery { + cursor?: string; + limit?: string; +} + +export async function getReceiptsByPet(req: Request, res: Response): Promise { + const { chainId, petId } = req.params as { chainId: string; petId: string }; + const { cursor, limit } = req.query as PaginationQuery; + const page = await listReceiptsByPet(chainId, petId, cursor, limit ? Number(limit) : undefined); + res.status(200).json(page); +} + +export async function getReceiptsByWallet(req: Request, res: Response): Promise { + const { wallet } = req.params as { wallet: string }; + const { cursor, limit } = req.query as PaginationQuery; + const page = await listReceiptsByWallet(wallet, cursor, limit ? Number(limit) : undefined); + res.status(200).json(page); +} + +interface SequenceQuery { + signingKeyId?: string; + after?: string; + limit?: string; +} + +export async function getReceiptsBySequence(req: Request, res: Response): Promise { + const { signingKeyId, after, limit } = req.query as SequenceQuery; + if (!signingKeyId) { + res.status(422).json({ error: 'signingKeyId is required' }); + return; + } + const page = await listReceiptsBySequence(signingKeyId, after, limit ? Number(limit) : undefined); + res.status(200).json(page); +} diff --git a/backend/src/features/battle-ledger/corpus.service.ts b/backend/src/features/battle-ledger/corpus.service.ts new file mode 100644 index 00000000..3d09189e --- /dev/null +++ b/backend/src/features/battle-ledger/corpus.service.ts @@ -0,0 +1,174 @@ +import { normalizeAccount } from '@cryptopets/protocol'; + +import { prisma } from '@config/prisma'; + +/** + * The public receipt corpus (§H item 3): paginated export by pet, by wallet, and + * by signing-key sequence range, with no authentication. + * + * "No special access" is the point. §H's whole argument is that anyone can take + * a receipt and redo the fight, and that only holds if anyone can also *get* + * the receipts in the first place — a corpus that needed an API key or a + * relationship with us would quietly become a corpus only we (and whoever we + * chose) could use to check our own homework. + */ + +const DEFAULT_LIMIT = 100; +const MAX_LIMIT = 500; + +function clampLimit(limit: number | undefined): number { + if (!limit || !Number.isFinite(limit) || limit <= 0) return DEFAULT_LIMIT; + return Math.min(Math.trunc(limit), MAX_LIMIT); +} + +export interface ReceiptSummary { + receiptHash: string; + battleId: string; + chainId: string; + deploymentId: string; + attackerPetId: string; + defenderPetId: string; + signingKeyId: string; + sequence: string; + previousReceiptHash: string | null; + attackerPreviousReceiptHash: string | null; + defenderPreviousReceiptHash: string | null; + payload: unknown; + signature: string; + createdAt: number; +} + +function toSummary(row: { + receiptHash: string; + battleId: string; + chainId: string; + deploymentId: string; + attackerPetId: string; + defenderPetId: string; + signingKeyId: string; + sequence: bigint; + previousReceiptHash: string | null; + attackerPreviousReceiptHash: string | null; + defenderPreviousReceiptHash: string | null; + payload: unknown; + signature: string; + createdAt: bigint; +}): ReceiptSummary { + return { + receiptHash: row.receiptHash, + battleId: row.battleId, + chainId: row.chainId, + deploymentId: row.deploymentId, + attackerPetId: row.attackerPetId, + defenderPetId: row.defenderPetId, + signingKeyId: row.signingKeyId, + sequence: row.sequence.toString(), + previousReceiptHash: row.previousReceiptHash, + attackerPreviousReceiptHash: row.attackerPreviousReceiptHash, + defenderPreviousReceiptHash: row.defenderPreviousReceiptHash, + payload: row.payload, + signature: row.signature, + createdAt: Number(row.createdAt), + }; +} + +export interface CursorPage { + receipts: ReceiptSummary[]; + /** Pass as `cursor` to fetch the next page; null once there are no more rows. */ + nextCursor: string | null; +} + +/** + * A page of receipts involving one pet, oldest first. + * + * Ordered by `(createdAt, receiptHash)` rather than just `createdAt`, because two + * receipts can share a `createdAt` (concurrent battles resolving in the same + * second) and an order that isn't fully deterministic makes cursor pagination + * silently skip or repeat rows at the boundary between pages. + */ +export async function listReceiptsByPet( + chainId: string, + petId: string, + cursor?: string, + limit?: number, +): Promise { + const take = clampLimit(limit); + const rows = await prisma.battleReceipt.findMany({ + where: { chainId, OR: [{ attackerPetId: petId }, { defenderPetId: petId }] }, + orderBy: [{ createdAt: 'asc' }, { receiptHash: 'asc' }], + ...(cursor ? { cursor: { receiptHash: cursor }, skip: 1 } : {}), + take, + }); + return paginate(rows, take); +} + +/** + * A page of receipts where the given wallet was either side of the battle. + * + * The receipt table itself has no owner column (only pet ids); ownership lives + * on `battle_ledger`, joined through the relation. Matched case-insensitively + * rather than assuming a stored casing convention, since the same wallet can + * arrive here checksummed or lowercased depending on which path wrote it. + */ +export async function listReceiptsByWallet(wallet: string, cursor?: string, limit?: number): Promise { + const normalized = normalizeAccount(wallet); + const take = clampLimit(limit); + const rows = await prisma.battleReceipt.findMany({ + where: { + battle: { + OR: [ + { attackerOwner: { equals: normalized, mode: 'insensitive' } }, + { defenderOwner: { equals: normalized, mode: 'insensitive' } }, + ], + }, + }, + orderBy: [{ createdAt: 'asc' }, { receiptHash: 'asc' }], + ...(cursor ? { cursor: { receiptHash: cursor }, skip: 1 } : {}), + take, + }); + return paginate(rows, take); +} + +export interface SequencePage { + receipts: ReceiptSummary[]; + /** Pass as `after` to fetch the next page; null once there are no more rows. */ + nextAfter: string | null; +} + +/** + * A page of receipts under one signing key, ordered by `sequence` — the exact + * order the global hash chain requires (§G): receipt N's `previousReceiptHash` + * is receipt N-1's hash under this same key, so walking the chain means walking + * this endpoint's pages in order, not the pet/wallet views above (which can mix + * receipts from different keys with no single chain between them). + */ +export async function listReceiptsBySequence( + signingKeyId: string, + afterSequence?: string, + limit?: number, +): Promise { + const take = clampLimit(limit); + const rows = await prisma.battleReceipt.findMany({ + where: { + signingKeyId, + ...(afterSequence !== undefined ? { sequence: { gt: BigInt(afterSequence) } } : {}), + }, + orderBy: { sequence: 'asc' }, + take, + }); + const receipts = rows.map(toSummary); + // A short page (fewer rows than requested) means the result set is exhausted; + // signalling "more" in that case would send a client back for one guaranteed- + // empty extra round trip on every single export. + const exhausted = receipts.length < take; + return { receipts, nextAfter: exhausted ? null : (receipts[receipts.length - 1]?.sequence ?? null) }; +} + +function paginate(rows: Parameters[0][], take: number): CursorPage { + const receipts = rows.map(toSummary); + const exhausted = receipts.length < take; + return { + receipts, + nextCursor: exhausted ? null : (receipts[receipts.length - 1]?.receiptHash ?? null), + }; +} diff --git a/backend/src/features/battle-ledger/index.ts b/backend/src/features/battle-ledger/index.ts index 339bce53..1f497da9 100644 --- a/backend/src/features/battle-ledger/index.ts +++ b/backend/src/features/battle-ledger/index.ts @@ -6,6 +6,15 @@ export { type AcceptedBattle, type AcceptRejection, } from './accept.service'; +export { + type CursorPage, + listReceiptsByPet, + listReceiptsBySequence, + listReceiptsByWallet, + type ReceiptSummary, + type SequencePage, +} from './corpus.service'; +export { getReceiptsByPet, getReceiptsBySequence, getReceiptsByWallet } from './corpus.controller'; export { deleteDefenseAuthorizations, postDefenseAuthorization } from './consent.controller'; export { getBattleCombatLog, diff --git a/backend/src/routes/receipts.ts b/backend/src/routes/receipts.ts new file mode 100644 index 00000000..f991b06d --- /dev/null +++ b/backend/src/routes/receipts.ts @@ -0,0 +1,17 @@ +import express, { Router } from 'express'; + +import { getReceiptsByPet, getReceiptsBySequence, getReceiptsByWallet } from '@features/battle-ledger'; + +/** + * The public receipt corpus (§H item 3): paginated export by pet, by wallet, and + * by signing-key sequence range. Deliberately no `verifyToken` anywhere in this + * file — public replay needs no special access, and gating this behind a JWT + * would make it a corpus only account holders could use to check our work. + */ +const router: Router = express.Router(); + +router.get('/by-pet/:chainId/:petId', getReceiptsByPet); +router.get('/by-wallet/:wallet', getReceiptsByWallet); +router.get('/', getReceiptsBySequence); + +export default router; diff --git a/backend/tests/features/battle-ledger/corpus.controller.test.ts b/backend/tests/features/battle-ledger/corpus.controller.test.ts new file mode 100644 index 00000000..0d96b039 --- /dev/null +++ b/backend/tests/features/battle-ledger/corpus.controller.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@features/battle-ledger/corpus.service', () => ({ + listReceiptsByPet: vi.fn(), + listReceiptsByWallet: vi.fn(), + listReceiptsBySequence: vi.fn(), +})); + +import { + listReceiptsByPet, + listReceiptsBySequence, + listReceiptsByWallet, +} from '../../../src/features/battle-ledger/corpus.service'; +import { + getReceiptsByPet, + getReceiptsBySequence, + getReceiptsByWallet, +} from '../../../src/features/battle-ledger/corpus.controller'; + +function mockRes() { + const res = { status: vi.fn(), json: vi.fn() }; + res.status.mockReturnValue(res); + return res as unknown as { status: ReturnType; json: ReturnType }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getReceiptsByPet', () => { + it('reads chainId and petId from the route and forwards query pagination', async () => { + vi.mocked(listReceiptsByPet).mockResolvedValue({ receipts: [], nextCursor: null }); + const res = mockRes(); + await getReceiptsByPet( + { params: { chainId: 'eip155:84532', petId: '7' }, query: { cursor: '0xabc', limit: '25' } } as never, + res as never, + ); + expect(listReceiptsByPet).toHaveBeenCalledWith('eip155:84532', '7', '0xabc', 25); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('passes undefined limit through rather than NaN when none is given', async () => { + vi.mocked(listReceiptsByPet).mockResolvedValue({ receipts: [], nextCursor: null }); + await getReceiptsByPet({ params: { chainId: 'eip155:84532', petId: '7' }, query: {} } as never, mockRes() as never); + expect(listReceiptsByPet).toHaveBeenCalledWith('eip155:84532', '7', undefined, undefined); + }); +}); + +describe('getReceiptsByWallet', () => { + it('reads the wallet from the route param', async () => { + vi.mocked(listReceiptsByWallet).mockResolvedValue({ receipts: [], nextCursor: null }); + const res = mockRes(); + await getReceiptsByWallet({ params: { wallet: '0xabc' }, query: {} } as never, res as never); + expect(listReceiptsByWallet).toHaveBeenCalledWith('0xabc', undefined, undefined); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); + +describe('getReceiptsBySequence', () => { + it('requires signingKeyId', async () => { + const res = mockRes(); + await getReceiptsBySequence({ query: {} } as never, res as never); + expect(res.status).toHaveBeenCalledWith(422); + expect(listReceiptsBySequence).not.toHaveBeenCalled(); + }); + + it('forwards signingKeyId, after, and limit', async () => { + vi.mocked(listReceiptsBySequence).mockResolvedValue({ receipts: [], nextAfter: null }); + const res = mockRes(); + await getReceiptsBySequence( + { query: { signingKeyId: 'battle-signer-2026-07', after: '10', limit: '50' } } as never, + res as never, + ); + expect(listReceiptsBySequence).toHaveBeenCalledWith('battle-signer-2026-07', '10', 50); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/backend/tests/features/battle-ledger/corpus.service.test.ts b/backend/tests/features/battle-ledger/corpus.service.test.ts new file mode 100644 index 00000000..09f45752 --- /dev/null +++ b/backend/tests/features/battle-ledger/corpus.service.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@config/prisma', () => ({ + prisma: { battleReceipt: { findMany: vi.fn() } }, +})); + +import { prisma } from '@config/prisma'; +import { listReceiptsByPet, listReceiptsBySequence, listReceiptsByWallet } from '@features/battle-ledger'; + +function row(overrides: Partial> = {}) { + return { + receiptHash: `0x${'11'.repeat(32)}`, + battleId: 'btl_1', + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + attackerPetId: '1', + defenderPetId: '2', + signingKeyId: 'battle-signer-2026-07', + sequence: 1n, + previousReceiptHash: null, + attackerPreviousReceiptHash: null, + defenderPreviousReceiptHash: null, + payload: { battleId: 'btl_1' }, + signature: '0xsig', + createdAt: 1893456000n, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('listReceiptsByPet', () => { + it('matches a pet as either attacker or defender', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + await listReceiptsByPet('eip155:84532', '7'); + expect(vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0]).toMatchObject({ + where: { chainId: 'eip155:84532', OR: [{ attackerPetId: '7' }, { defenderPetId: '7' }] }, + }); + }); + + it('orders by createdAt then receiptHash, so concurrent battles in one second stay stable', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + await listReceiptsByPet('eip155:84532', '7'); + expect(vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0]).toMatchObject({ + orderBy: [{ createdAt: 'asc' }, { receiptHash: 'asc' }], + }); + }); + + it('paginates via cursor on the primary key, not offset', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + await listReceiptsByPet('eip155:84532', '7', `0x${'22'.repeat(32)}`, 50); + expect(vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0]).toMatchObject({ + cursor: { receiptHash: `0x${'22'.repeat(32)}` }, + skip: 1, + take: 50, + }); + }); + + it('omits cursor and skip on the first page', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + await listReceiptsByPet('eip155:84532', '7'); + const call = vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0] as Record; + expect(call.cursor).toBeUndefined(); + expect(call.skip).toBeUndefined(); + }); + + it('clamps a requested limit above the maximum', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + await listReceiptsByPet('eip155:84532', '7', undefined, 10000); + expect(vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0]).toMatchObject({ take: 500 }); + }); + + it('falls back to the default limit for an invalid one', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + await listReceiptsByPet('eip155:84532', '7', undefined, -5); + expect(vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0]).toMatchObject({ take: 100 }); + }); + + it('serializes bigint fields to strings', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([row()] as never); + const page = await listReceiptsByPet('eip155:84532', '1'); + expect(page.receipts[0]).toMatchObject({ sequence: '1', createdAt: 1893456000 }); + }); + + it('signals more pages only when the page was full', async () => { + const full = Array.from({ length: 100 }, (_, i) => row({ receiptHash: `0x${String(i).padStart(64, '0')}` })); + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue(full as never); + const page = await listReceiptsByPet('eip155:84532', '1'); + expect(page.nextCursor).toBe(full[99]!.receiptHash); + }); + + it('reports no more pages when the page came back short', async () => { + // A short page means the result set is exhausted; signalling "more" here would send a + // client back for one guaranteed-empty extra round trip on every single export. + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([row()] as never); + const page = await listReceiptsByPet('eip155:84532', '1', undefined, 100); + expect(page.nextCursor).toBeNull(); + }); + + it('reports no more pages on an empty result', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + expect((await listReceiptsByPet('eip155:84532', '1')).nextCursor).toBeNull(); + }); +}); + +describe('listReceiptsByWallet', () => { + it('matches either side of the battle through the ledger relation', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + await listReceiptsByWallet('0xABCDEF0123456789abcdef0123456789ABCDEF01'); + expect(vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0]).toMatchObject({ + where: { + battle: { + OR: [ + { attackerOwner: { equals: '0xabcdef0123456789abcdef0123456789abcdef01', mode: 'insensitive' } }, + { defenderOwner: { equals: '0xabcdef0123456789abcdef0123456789abcdef01', mode: 'insensitive' } }, + ], + }, + }, + }); + }); + + it('leaves a Solana base58 wallet untouched, since base58 is case-sensitive', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + const pubkey = 'DRiP2Pn2K6fuMLKQmt5rZWyHiUZ6aK3TzhBd8ZUqzTqL'; + await listReceiptsByWallet(pubkey); + const call = vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0] as { + where: { battle: { OR: { attackerOwner: { equals: string } }[] } }; + }; + expect(call.where.battle.OR[0]!.attackerOwner.equals).toBe(pubkey); + }); +}); + +describe('listReceiptsBySequence', () => { + it('walks one signing key strictly in chain order', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + await listReceiptsBySequence('battle-signer-2026-07'); + expect(vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0]).toMatchObject({ + where: { signingKeyId: 'battle-signer-2026-07' }, + orderBy: { sequence: 'asc' }, + }); + }); + + it('filters strictly after the given sequence, matching the chain-walk contract', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + await listReceiptsBySequence('battle-signer-2026-07', '5'); + expect(vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0]).toMatchObject({ + where: { signingKeyId: 'battle-signer-2026-07', sequence: { gt: 5n } }, + }); + }); + + it('omits the sequence filter on the first page', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([]); + await listReceiptsBySequence('battle-signer-2026-07'); + const call = vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0] as { where: Record }; + expect(call.where).not.toHaveProperty('sequence'); + }); + + it('reports nextAfter as the last sequence in a full page', async () => { + const full = Array.from({ length: 100 }, (_, i) => row({ sequence: BigInt(i + 1) })); + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue(full as never); + const page = await listReceiptsBySequence('battle-signer-2026-07'); + expect(page.nextAfter).toBe('100'); + }); + + it('reports nextAfter null once the chain is exhausted', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([row({ sequence: 42n })] as never); + const page = await listReceiptsBySequence('battle-signer-2026-07', undefined, 100); + expect(page.nextAfter).toBeNull(); + }); +}); From 4f51bb29aa33a939db1b286f992e748e419fd2b6 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 13:25:32 -0400 Subject: [PATCH 30/76] feat(backend): scope live-battle notifications to a per-room WebSocket --- backend/API.md | 33 ++++- .../migration.sql | 2 + backend/prisma/schema.prisma | 8 ++ .../battle-ledger/accept.controller.ts | 8 +- .../features/battle-ledger/accept.service.ts | 19 +++ .../features/battle-worker/beacon.worker.ts | 3 + .../features/battle-worker/compute.worker.ts | 2 + .../src/features/battle-worker/sign.worker.ts | 9 +- .../features/battle-worker/verify.worker.ts | 7 + backend/src/server.ts | 5 + backend/src/ws/battleRoomSocket.ts | 102 +++++++++++++++ .../battle-worker/beacon.worker.test.ts | 16 +++ .../battle-worker/compute.worker.test.ts | 11 ++ .../battle-worker/sign.worker.test.ts | 21 +++ .../battle-worker/verify.worker.test.ts | 16 +++ backend/tests/ws/battleRoomSocket.test.ts | 122 ++++++++++++++++++ 16 files changed, 374 insertions(+), 10 deletions(-) create mode 100644 backend/prisma/migrations/20260726120000_add_battle_ledger_room_id/migration.sql create mode 100644 backend/src/ws/battleRoomSocket.ts create mode 100644 backend/tests/ws/battleRoomSocket.test.ts diff --git a/backend/API.md b/backend/API.md index 3c8dfb63..25361ebc 100644 --- a/backend/API.md +++ b/backend/API.md @@ -262,12 +262,33 @@ to check independently. | GET | `/api/battle/rulesets/:rulesetHash` | none | One ruleset's full bundle, for replaying against it. | | POST | `/api/battle/verify-receipt` | none | Body `{ receiptHash }`. Checks the stored signature against a published key and that the payload is well-formed — §A's "operator signature, verified against a published key" row, nothing more. It does **not** re-run the fight, check the drand BLS signature, or recompute progression; that is the standalone verifier's job (§H), which runs with no backend access so its answer cannot depend on this process telling the truth. Passing this check is necessary, not sufficient. | -These reads are what let the live-battle WebSocket become a notification only, -never a source of truth (`docs/plan-backend-battle-architecture.md` §J): a -client refetches from the routes above after reconnecting rather than trusting -whatever the socket last pushed. `backend/src/ws/liveBattleSocket.ts` itself -still broadcasts globally as of this writing — scoping it per room and marking -it notification-only in its own right is a separate, later change (§J). +### Battle room WebSocket (v2) + +``` +ws(s):///ws/battle-room?roomId= +``` + +Notification-only, per-room channel for backend-authoritative battles (§J). +Connecting without a `roomId` query parameter closes the socket immediately +(code `1008`). Every message is the same small shape: + +```json +{ "type": "battle-updated", "battleId": "btl_...", "state": "signed" } +``` + +The payload never carries battle content, only "this battle changed state, go +re-fetch it" from the read routes above — a client that never connected, or +missed a message, gets the exact same information by polling those same +endpoints; this socket only makes that faster, never more authoritative. A +battle only has a `roomId` if it was accepted with one (`POST +/api/battle/intents/:intentHash/accept` takes an optional `roomId` in its +body); a battle accepted without one still runs through every state normally, +it just has no spectator link to push a notification through. + +This is a second, separate socket from `backend/src/ws/liveBattleSocket.ts`, +not a change to it — that socket keeps broadcasting globally, which remains +correct for the legacy on-chain settle-keeper flow it carries (chain-derived +data, filtered client-side by `(chainId, requestId)`). ### Public receipt corpus (v2) diff --git a/backend/prisma/migrations/20260726120000_add_battle_ledger_room_id/migration.sql b/backend/prisma/migrations/20260726120000_add_battle_ledger_room_id/migration.sql new file mode 100644 index 00000000..f815ceff --- /dev/null +++ b/backend/prisma/migrations/20260726120000_add_battle_ledger_room_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "battle_ledger" ADD COLUMN "room_id" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b3a83d67..8d3d01a4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -301,6 +301,14 @@ model BattleLedger { drandRound BigInt @map("drand_round") acceptedAt BigInt @map("accepted_at") // unix seconds + /// The shareable room this battle was accepted through, if any (battle_room.id). + /// No FK: a room is minted before any battleId exists and outlives this one battle's + /// row, so the link is one-directional and best-effort. Null means the battle was + /// accepted without a room (e.g. accepted directly by intentHash) — every state + /// change still lands in the read APIs (§J), it just has no spectator link to push + /// a notification through. + roomId String? @map("room_id") + /// Filled as the battle advances. Null until the state that produces them. beaconSignature String? @map("beacon_signature") beaconRandomness String? @map("beacon_randomness") diff --git a/backend/src/features/battle-ledger/accept.controller.ts b/backend/src/features/battle-ledger/accept.controller.ts index e6d164da..99f4106f 100644 --- a/backend/src/features/battle-ledger/accept.controller.ts +++ b/backend/src/features/battle-ledger/accept.controller.ts @@ -34,6 +34,8 @@ const STATUS_BY_REASON: Record = { interface AcceptBody { intentHash?: string; + /** The shareable room this accept call is happening through, if any (§J). */ + roomId?: string; } /** @@ -53,7 +55,11 @@ export async function postAcceptBattle(req: AuthenticatedRequest, res: Response) return; } - const result = await acceptBattle({ intentHash: body.intentHash, nowSeconds: Math.floor(Date.now() / 1000) }); + const result = await acceptBattle({ + intentHash: body.intentHash, + ...(typeof body.roomId === 'string' ? { roomId: body.roomId } : {}), + nowSeconds: Math.floor(Date.now() / 1000), + }); if (!result.ok) { res.status(STATUS_BY_REASON[result.reason]).json({ error: result.reason, detail: result.detail }); diff --git a/backend/src/features/battle-ledger/accept.service.ts b/backend/src/features/battle-ledger/accept.service.ts index 96378f24..11e81936 100644 --- a/backend/src/features/battle-ledger/accept.service.ts +++ b/backend/src/features/battle-ledger/accept.service.ts @@ -16,6 +16,7 @@ import type { Prisma } from '@generated/prisma/client'; import { BattleState } from '@generated/prisma/enums'; import { prisma } from '@config/prisma'; +import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; import { activeSigningKey, sign, SignerRefusedError } from '../battle-signer'; import { chooseCommitmentRound, roundPublishTime } from '../battle-randomness'; @@ -54,6 +55,13 @@ import { applyTransition, openBattle } from './transitions'; export interface AcceptBattleRequest { intentHash: string; nowSeconds: number; + /** + * The shareable room this accept call came through, if any (§J). Optional: a + * battle accepted without a room simply gets no spectator notifications — every + * state change still lands in the read APIs (Step 27), just with no push channel + * to announce it faster. + */ + roomId?: string; } export type AcceptRejection = @@ -178,6 +186,7 @@ export async function acceptBattle(request: AcceptBattleRequest): Promise `seeded` (§E, §J). @@ -77,6 +78,7 @@ export async function processAwaitBeaconMessage(message: ClaimedMessage, nowSeco patch, outbox: [{ battleId: battle.battleId, topic: OUTBOX_TOPICS.compute }], }); + notifyBattleRoomIfPresent(battle.roomId, { type: 'battle-updated', battleId: battle.battleId, state: BattleState.seeded }); await completeOutbox(message.id, new Date(nowSeconds * 1000)); return; } @@ -88,6 +90,7 @@ export async function processAwaitBeaconMessage(message: ClaimedMessage, nowSeco to: BattleState.forfeited, patch: { failureReason: `drand round ${round} unavailable for ${overdueSeconds}s: ${describeOutcome(outcome)}` }, }); + notifyBattleRoomIfPresent(battle.roomId, { type: 'battle-updated', battleId: battle.battleId, state: BattleState.forfeited }); await completeOutbox(message.id, new Date(nowSeconds * 1000)); return; } diff --git a/backend/src/features/battle-worker/compute.worker.ts b/backend/src/features/battle-worker/compute.worker.ts index f3b687d9..ff6b8f13 100644 --- a/backend/src/features/battle-worker/compute.worker.ts +++ b/backend/src/features/battle-worker/compute.worker.ts @@ -11,6 +11,7 @@ import type { Prisma } from '@generated/prisma/client'; import { prisma } from '@config/prisma'; import { applyTransition, type ClaimedMessage, completeOutbox, OUTBOX_TOPICS } from '@features/battle-ledger'; +import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; /** * Handles `compute` messages: `seeded` -> `computed` (§F). @@ -87,6 +88,7 @@ export async function processComputeMessage(message: ClaimedMessage, nowSeconds: patch, outbox: [{ battleId: battle.battleId, topic: OUTBOX_TOPICS.verify }], }); + notifyBattleRoomIfPresent(battle.roomId, { type: 'battle-updated', battleId: battle.battleId, state: BattleState.computed }); await completeOutbox(message.id, new Date(nowSeconds * 1000)); } diff --git a/backend/src/features/battle-worker/sign.worker.ts b/backend/src/features/battle-worker/sign.worker.ts index 7a1ca08c..df01869f 100644 --- a/backend/src/features/battle-worker/sign.worker.ts +++ b/backend/src/features/battle-worker/sign.worker.ts @@ -12,6 +12,7 @@ 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 { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; /** * Handles `sign` messages: `verified` -> `signed` (§G). @@ -82,7 +83,7 @@ export async function processSignMessage(message: ClaimedMessage, nowSeconds: nu for (let attempt = 0; attempt < MAX_RECEIPT_CHAIN_RETRIES; attempt++) { const key = activeSigningKey(); if (!key) { - await failSigning(battle.battleId, 'no active signing key'); + await failSigning(battle.battleId, battle.roomId, 'no active signing key'); await completeOutbox(message.id, new Date(nowSeconds * 1000)); return; } @@ -158,7 +159,7 @@ export async function processSignMessage(message: ClaimedMessage, nowSeconds: nu signed = await sign({ kind: 'receipt', receipt, attestations }, nowSeconds); } catch (error) { if (error instanceof SignerRefusedError) { - await failSigning(battle.battleId, error.message); + await failSigning(battle.battleId, battle.roomId, error.message); await completeOutbox(message.id, new Date(nowSeconds * 1000)); return; } @@ -200,6 +201,7 @@ export async function processSignMessage(message: ClaimedMessage, nowSeconds: nu throw error; } + notifyBattleRoomIfPresent(battle.roomId, { type: 'battle-updated', battleId: battle.battleId, state: BattleState.signed }); await completeOutbox(message.id, new Date(nowSeconds * 1000)); return; } @@ -301,13 +303,14 @@ async function applyProgression( }); } -async function failSigning(battleId: string, reason: string): Promise { +async function failSigning(battleId: string, roomId: string | null, reason: string): Promise { await applyTransition({ battleId, from: BattleState.verified, to: BattleState.signing_failed, patch: { failureReason: reason }, }); + notifyBattleRoomIfPresent(roomId, { type: 'battle-updated', battleId, state: BattleState.signing_failed }); } function serializeBigints(value: T): Prisma.InputJsonValue { diff --git a/backend/src/features/battle-worker/verify.worker.ts b/backend/src/features/battle-worker/verify.worker.ts index d9006159..bdc06896 100644 --- a/backend/src/features/battle-worker/verify.worker.ts +++ b/backend/src/features/battle-worker/verify.worker.ts @@ -13,6 +13,7 @@ import type { Prisma } from '@generated/prisma/client'; import { prisma } from '@config/prisma'; import { applyTransition, type ClaimedMessage, completeOutbox, OUTBOX_TOPICS } from '@features/battle-ledger'; import { callVerifyBattle, type VerifyBattleWire, type VerifyPetProgressionWire } from '@grpc-client/verifyBattle'; +import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; /** * Handles `verify` messages: `computed` -> `verified` (§F). @@ -86,6 +87,7 @@ export async function processVerifyMessage(message: ClaimedMessage, nowSeconds: patch: { verificationDetail }, outbox: [{ battleId: battle.battleId, topic: OUTBOX_TOPICS.sign }], }); + notifyBattleRoomIfPresent(battle.roomId, { type: 'battle-updated', battleId: battle.battleId, state: BattleState.verified }); } else { await applyTransition({ battleId: battle.battleId, @@ -96,6 +98,11 @@ export async function processVerifyMessage(message: ClaimedMessage, nowSeconds: verificationDetail, }, }); + notifyBattleRoomIfPresent(battle.roomId, { + type: 'battle-updated', + battleId: battle.battleId, + state: BattleState.verification_failed, + }); } await completeOutbox(message.id, new Date(nowSeconds * 1000)); } diff --git a/backend/src/server.ts b/backend/src/server.ts index ed0cf48b..f8135dc6 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -8,6 +8,7 @@ import { startSettleKeeper, stopSettleKeeper } from '@features/settle-keeper'; import { startSolanaSettleKeeperFeature, stopSolanaSettleKeeperFeature } from '@features/settle-keeper-solana'; import { type BattleWorkerHandle, startBattleWorker } from '@features/battle-worker'; import { startLiveBattleSocket, stopLiveBattleSocket } from '@ws/liveBattleSocket'; +import { startBattleRoomSocket, stopBattleRoomSocket } from '@ws/battleRoomSocket'; let battleWorker: BattleWorkerHandle | undefined; @@ -25,6 +26,9 @@ const server = app.listen(env.port, '0.0.0.0', () => { // keeper's job). Always listening; only actually broadcasts once the keeper is enabled // with KEEPER_GAME_CONFIG_ADDRESS set. startLiveBattleSocket(server); + // Notification-only per-room channel for backend-authoritative battles (§J). Always on; + // a client only gets pushed to if it connected with a roomId it already knows about. + startBattleRoomSocket(server); // indexer-go battle push (chain-truth settles). No-op unless INDEXER_GRPC_ADDR is set. startBattleStream(); // Settles GameLogic battle/breed/mint requests once entropy reveals. No-op unless @@ -69,6 +73,7 @@ async function shutdown(signal: NodeJS.Signals): Promise { stopSolanaSettleKeeperFeature(); battleWorker?.stop(); stopLiveBattleSocket(); + stopBattleRoomSocket(); await new Promise((resolve) => server.close(() => resolve())); await prisma.$disconnect(); diff --git a/backend/src/ws/battleRoomSocket.ts b/backend/src/ws/battleRoomSocket.ts new file mode 100644 index 00000000..3d155b5a --- /dev/null +++ b/backend/src/ws/battleRoomSocket.ts @@ -0,0 +1,102 @@ +import type { Server } from 'node:http'; +import { URL } from 'node:url'; + +// `WebSocket.Server` (liveBattleSocket.ts's form) is only attached to the default export +// under `ws`'s CJS entry point; its ESM entry (`wrapper.mjs`, what Vitest resolves) exports +// the server class only as the named `WebSocketServer`, with no `.Server` static property. +// The named form resolves correctly under both, so it's used here instead. +import WebSocket, { WebSocketServer } from 'ws'; + +/** + * The per-room, notification-only channel for backend-authoritative battles + * (docs/plan-backend-battle-architecture.md §J). + * + * This is deliberately a second, separate socket from `liveBattleSocket.ts`, not + * a change to it. That socket's global broadcast is correct for what it carries + * today: chain-derived data for the legacy on-chain flow, filtered client-side + * by `(chainId, requestId)`, which is fine because anyone could read the same + * data straight off the chain anyway. Backend-resolved battles carry full + * combat logs, which is not chain-derived data — a global broadcast would tell + * every connected client the outcome of every battle as it resolves. So this + * channel scopes delivery to one room, and carries no battle content at all: + * only "battleId X changed to state Y, go re-fetch it" (§J's read APIs, Step + * 27). A client that missed a notification, or was never connected, gets the + * exact same information by polling those same endpoints — this socket makes + * that faster, never more authoritative. + */ + +export interface BattleRoomNotification { + type: 'battle-updated'; + battleId: string; + state: string; +} + +let wss: WebSocketServer | null = null; +const roomMembers = new Map>(); + +export function startBattleRoomSocket(server: Server): void { + wss = new WebSocketServer({ server, path: '/ws/battle-room' }); + wss.on('connection', (socket, request) => { + const roomId = roomIdFromUrl(request.url); + if (!roomId) { + socket.close(1008, 'roomId query parameter is required'); + return; + } + joinRoom(roomId, socket); + socket.on('close', () => leaveRoom(roomId, socket)); + }); + console.log('[battle-room-ws] listening on /ws/battle-room'); +} + +export function stopBattleRoomSocket(): void { + wss?.close(); + wss = null; + roomMembers.clear(); +} + +/** + * Notifies every client watching `roomId`. A no-op, not an error, when nobody + * is connected — most battles are never watched live at all, and that is an + * entirely normal outcome, not a delivery failure worth surfacing. + */ +export function notifyBattleRoom(roomId: string, message: BattleRoomNotification): void { + const members = roomMembers.get(roomId); + if (!members || members.size === 0) return; + const payload = JSON.stringify(message); + for (const client of members) { + if (client.readyState === WebSocket.OPEN) client.send(payload); + } +} + +/** Same as `notifyBattleRoom`, but a no-op when there is no room to notify at all. */ +export function notifyBattleRoomIfPresent(roomId: string | null, message: BattleRoomNotification): void { + if (roomId) notifyBattleRoom(roomId, message); +} + +function joinRoom(roomId: string, socket: WebSocket): void { + let members = roomMembers.get(roomId); + if (!members) { + members = new Set(); + roomMembers.set(roomId, members); + } + members.add(socket); +} + +function leaveRoom(roomId: string, socket: WebSocket): void { + const members = roomMembers.get(roomId); + if (!members) return; + members.delete(socket); + if (members.size === 0) roomMembers.delete(roomId); +} + +function roomIdFromUrl(url: string | undefined): string | null { + if (!url) return null; + try { + // The base is irrelevant and discarded — only used because WHATWG URL requires + // an absolute URL to parse a relative one against. + const parsed = new URL(url, 'http://internal'); + return parsed.searchParams.get('roomId'); + } catch { + return null; + } +} diff --git a/backend/tests/features/battle-worker/beacon.worker.test.ts b/backend/tests/features/battle-worker/beacon.worker.test.ts index 6803d094..88051416 100644 --- a/backend/tests/features/battle-worker/beacon.worker.test.ts +++ b/backend/tests/features/battle-worker/beacon.worker.test.ts @@ -22,10 +22,15 @@ vi.mock('@features/battle-randomness', () => ({ roundPublishTime: vi.fn((round: number) => new Date(roundTime(QUICKNET, round) * 1000)), })); +vi.mock('@ws/battleRoomSocket', () => ({ + notifyBattleRoomIfPresent: vi.fn(), +})); + import { prisma } from '@config/prisma'; import { applyTransition, completeOutbox, rescheduleOutbox } from '@features/battle-ledger'; import { fetchVerifiedRound } from '@features/battle-randomness'; import { processAwaitBeaconMessage } from '@features/battle-worker'; +import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; const ROUND = 1000; const PUBLISHED_AT = roundTime(QUICKNET, ROUND); @@ -47,6 +52,7 @@ const BATTLE = { drandRound: BigInt(ROUND), snapshotHash: `0x${'11'.repeat(32)}`, rulesetHash: `0x${'22'.repeat(32)}`, + roomId: 'room_1', }; beforeEach(() => { @@ -78,6 +84,11 @@ describe('the round has verified', () => { expect(call.patch.seed).toBe(expectedSeed.hex); expect(call.outbox[0]!.topic).toBe('compute'); expect(completeOutbox).toHaveBeenCalledWith('msg_1', expect.any(Date)); + expect(notifyBattleRoomIfPresent).toHaveBeenCalledWith('room_1', { + type: 'battle-updated', + battleId: 'btl_1', + state: 'seeded', + }); }); it('never re-derives a different seed for the same message', async () => { @@ -123,6 +134,11 @@ describe('the outage has outlasted the forfeit window', () => { expect(call.to).toBe('forfeited'); expect(rescheduleOutbox).not.toHaveBeenCalled(); expect(completeOutbox).toHaveBeenCalled(); + expect(notifyBattleRoomIfPresent).toHaveBeenCalledWith('room_1', { + type: 'battle-updated', + battleId: 'btl_1', + state: 'forfeited', + }); }); it('measures the window from the round due time, not from a fixed poll count', async () => { diff --git a/backend/tests/features/battle-worker/compute.worker.test.ts b/backend/tests/features/battle-worker/compute.worker.test.ts index 4862df7a..4c6a2324 100644 --- a/backend/tests/features/battle-worker/compute.worker.test.ts +++ b/backend/tests/features/battle-worker/compute.worker.test.ts @@ -15,9 +15,14 @@ vi.mock('@features/battle-ledger', () => ({ OUTBOX_TOPICS: { verify: 'verify' }, })); +vi.mock('@ws/battleRoomSocket', () => ({ + notifyBattleRoomIfPresent: vi.fn(), +})); + import { prisma } from '@config/prisma'; import { applyTransition, completeOutbox } from '@features/battle-ledger'; import { processComputeMessage } from '@features/battle-worker'; +import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); const NOW = roundTime(QUICKNET, 1000) + 5; @@ -66,6 +71,7 @@ const BATTLE = { seed: seed.hex, snapshot: SNAPSHOT, rulesetHash: RULESET_HASH, + roomId: 'room_1', }; const MESSAGE = { id: 'msg_1', battleId: 'btl_1', topic: 'compute', payload: {}, attempts: 1 }; @@ -94,6 +100,11 @@ describe('running the fight', () => { expect(Array.isArray(call.patch.combatLog)).toBe(true); expect(call.outbox[0]!.topic).toBe('verify'); expect(completeOutbox).toHaveBeenCalledWith('msg_1', expect.any(Date)); + expect(notifyBattleRoomIfPresent).toHaveBeenCalledWith('room_1', { + type: 'battle-updated', + battleId: 'btl_1', + state: 'computed', + }); }); it('is deterministic: the same seeded battle always computes the same result', async () => { diff --git a/backend/tests/features/battle-worker/sign.worker.test.ts b/backend/tests/features/battle-worker/sign.worker.test.ts index 5905d95c..0151fa3e 100644 --- a/backend/tests/features/battle-worker/sign.worker.test.ts +++ b/backend/tests/features/battle-worker/sign.worker.test.ts @@ -41,10 +41,15 @@ vi.mock('@features/battle-signer', async () => { }; }); +vi.mock('@ws/battleRoomSocket', () => ({ + notifyBattleRoomIfPresent: vi.fn(), +})); + import { prisma } from '@config/prisma'; import { applyTransition, completeOutbox } from '@features/battle-ledger'; import { activeSigningKey, sign, SignerRefusedError } from '@features/battle-signer'; import { processSignMessage } from '@features/battle-worker'; +import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); const NOW = roundTime(QUICKNET, 1000) + 5; @@ -145,6 +150,7 @@ const BATTLE = { combatLogHash, progression: serializedProgression, verificationDetail: { mismatches: [] }, + roomId: 'room_1', }; const MESSAGE = { id: 'msg_1', battleId: 'btl_1', topic: 'sign', payload: {}, attempts: 1 }; @@ -189,6 +195,11 @@ describe('the happy path', () => { expect(call.from).toBe('verified'); expect(call.to).toBe('signed'); expect(call.outbox[0]!.topic).toBe('publish'); + expect(notifyBattleRoomIfPresent).toHaveBeenCalledWith('room_1', { + type: 'battle-updated', + battleId: 'btl_1', + state: 'signed', + }); }); it('links to the prior receipt under the same signing key', async () => { @@ -311,6 +322,11 @@ describe('signing failure', () => { expect(applyTransition).toHaveBeenCalledWith( expect.objectContaining({ battleId: 'btl_1', from: 'verified', to: 'signing_failed' }), ); + expect(notifyBattleRoomIfPresent).toHaveBeenCalledWith('room_1', { + type: 'battle-updated', + battleId: 'btl_1', + state: 'signing_failed', + }); }); it('never signs when there is no active signing key at all', async () => { @@ -320,6 +336,11 @@ describe('signing failure', () => { expect(applyTransition).toHaveBeenCalledWith( expect.objectContaining({ from: 'verified', to: 'signing_failed' }), ); + expect(notifyBattleRoomIfPresent).toHaveBeenCalledWith('room_1', { + type: 'battle-updated', + battleId: 'btl_1', + state: 'signing_failed', + }); }); it('propagates an unexpected signer error rather than treating it as signing_failed', async () => { diff --git a/backend/tests/features/battle-worker/verify.worker.test.ts b/backend/tests/features/battle-worker/verify.worker.test.ts index e56c9606..0dee9dad 100644 --- a/backend/tests/features/battle-worker/verify.worker.test.ts +++ b/backend/tests/features/battle-worker/verify.worker.test.ts @@ -29,10 +29,15 @@ vi.mock('@grpc-client/verifyBattle', () => ({ callVerifyBattle: vi.fn(), })); +vi.mock('@ws/battleRoomSocket', () => ({ + notifyBattleRoomIfPresent: vi.fn(), +})); + import { prisma } from '@config/prisma'; import { applyTransition, completeOutbox } from '@features/battle-ledger'; import { processVerifyMessage } from '@features/battle-worker'; import { callVerifyBattle } from '@grpc-client/verifyBattle'; +import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); const NOW = roundTime(QUICKNET, 1000) + 5; @@ -119,6 +124,7 @@ const BATTLE = { winnerHpRemaining: outcome.result.winnerHpRemaining, combatLogHash, progression: JSON.parse(JSON.stringify(progression, (_k, v) => (typeof v === 'bigint' ? v.toString() : v))), + roomId: 'room_1', }; const MESSAGE = { id: 'msg_1', battleId: 'btl_1', topic: 'verify', payload: {}, attempts: 1 }; @@ -187,6 +193,11 @@ describe('agreement', () => { expect(call.to).toBe('verified'); expect(call.outbox[0]!.topic).toBe('sign'); expect(completeOutbox).toHaveBeenCalled(); + expect(notifyBattleRoomIfPresent).toHaveBeenCalledWith('room_1', { + type: 'battle-updated', + battleId: 'btl_1', + state: 'verified', + }); }); it('recomputes the combat-log hash from Go structured log using the real canonical encoder', async () => { @@ -213,6 +224,11 @@ describe('disagreement', () => { expect(call.to).toBe('verification_failed'); expect(call.patch.failureReason).toContain('winner'); expect(call.patch.verificationDetail.mismatches.length).toBeGreaterThan(0); + expect(notifyBattleRoomIfPresent).toHaveBeenCalledWith('room_1', { + type: 'battle-updated', + battleId: 'btl_1', + state: 'verification_failed', + }); }); it('flags a progression mismatch even when the fight result agrees', async () => { diff --git a/backend/tests/ws/battleRoomSocket.test.ts b/backend/tests/ws/battleRoomSocket.test.ts new file mode 100644 index 00000000..c5f85024 --- /dev/null +++ b/backend/tests/ws/battleRoomSocket.test.ts @@ -0,0 +1,122 @@ +import { createServer, type Server } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import WebSocket from 'ws'; + +import { + notifyBattleRoom, + notifyBattleRoomIfPresent, + startBattleRoomSocket, + stopBattleRoomSocket, +} from '@ws/battleRoomSocket'; + +/** + * Real HTTP server + real `ws` clients, not mocks: the property under test is + * actual room-scoped delivery over the wire (§J) — a client in room A must + * never receive a message meant for room B, and a message with no listeners + * must not throw. + */ + +let server: Server; +let baseUrl: string; + +beforeEach(async () => { + server = createServer(); + startBattleRoomSocket(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('expected a bound TCP address'); + } + baseUrl = `ws://127.0.0.1:${address.port}/ws/battle-room`; +}); + +afterEach(async () => { + stopBattleRoomSocket(); + await new Promise((resolve) => server.close(() => resolve())); +}); + +function connect(roomId: string | null): Promise { + return new Promise((resolve, reject) => { + const url = roomId === null ? baseUrl : `${baseUrl}?roomId=${roomId}`; + const socket = new WebSocket(url); + socket.once('open', () => resolve(socket)); + socket.once('error', reject); + }); +} + +function nextMessage(socket: WebSocket): Promise { + return new Promise((resolve, reject) => { + socket.once('message', (data) => resolve(JSON.parse(data.toString()))); + socket.once('close', (code, reason) => reject(new Error(`socket closed before a message arrived: ${code} ${reason}`))); + }); +} + +function nextClose(socket: WebSocket): Promise<{ code: number; reason: string }> { + return new Promise((resolve) => { + socket.once('close', (code, reason) => resolve({ code, reason: reason.toString() })); + }); +} + +describe('room scoping', () => { + it('delivers a notification only to clients connected to that room', async () => { + const inRoom = await connect('room_1'); + const inOtherRoom = await connect('room_2'); + const otherRoomMessage = nextMessage(inOtherRoom); + + const received = nextMessage(inRoom); + notifyBattleRoom('room_1', { type: 'battle-updated', battleId: 'btl_1', state: 'signed' }); + + await expect(received).resolves.toEqual({ type: 'battle-updated', battleId: 'btl_1', state: 'signed' }); + + // The other room's client must never see this message. Race it against a message + // that will definitely arrive (a second notification to its own room) so the test + // does not depend on a fixed timeout to prove a negative. + notifyBattleRoom('room_2', { type: 'battle-updated', battleId: 'btl_2', state: 'signed' }); + await expect(otherRoomMessage).resolves.toEqual({ type: 'battle-updated', battleId: 'btl_2', state: 'signed' }); + + inRoom.close(); + inOtherRoom.close(); + }); + + it('delivers to every client connected to the same room', async () => { + const first = await connect('room_shared'); + const second = await connect('room_shared'); + + const firstMessage = nextMessage(first); + const secondMessage = nextMessage(second); + notifyBattleRoom('room_shared', { type: 'battle-updated', battleId: 'btl_1', state: 'computed' }); + + await expect(firstMessage).resolves.toEqual({ type: 'battle-updated', battleId: 'btl_1', state: 'computed' }); + await expect(secondMessage).resolves.toEqual({ type: 'battle-updated', battleId: 'btl_1', state: 'computed' }); + + first.close(); + second.close(); + }); + + it('does not throw when notifying a room with no connected clients', () => { + expect(() => notifyBattleRoom('nobody-here', { type: 'battle-updated', battleId: 'btl_1', state: 'signed' })).not.toThrow(); + }); + + it('notifyBattleRoomIfPresent is a no-op for a null roomId', () => { + expect(() => notifyBattleRoomIfPresent(null, { type: 'battle-updated', battleId: 'btl_1', state: 'signed' })).not.toThrow(); + }); + + it('stops delivering to a client after it disconnects', async () => { + const socket = await connect('room_1'); + socket.close(); + await nextClose(socket); + + // No listener remains, so this must behave like the empty-room case above rather + // than throwing on a stale reference. + expect(() => notifyBattleRoom('room_1', { type: 'battle-updated', battleId: 'btl_1', state: 'signed' })).not.toThrow(); + }); +}); + +describe('connection requirements', () => { + it('closes the connection when roomId is missing', async () => { + const socket = new WebSocket(baseUrl); + const closed = nextClose(socket); + const { code } = await closed; + expect(code).toBe(1008); + }); +}); From 3e6e22b4e12583c2c423e99655ee15a2d5dfa181 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 14:29:29 -0400 Subject: [PATCH 31/76] feat(verifier): scaffold standalone MIT receipt verifier CLI --- AGENTS.md | 2 +- CLAUDE.md | 6 +- README.md | 2 +- package.json | 4 +- pnpm-lock.yaml | 1257 +++++++++-------- pnpm-workspace.yaml | 1 + protocol/src/index.ts | 1 + protocol/src/receipt/index.ts | 8 + protocol/src/receipt/wire.ts | 78 + protocol/src/signature/address.ts | 51 + protocol/src/signature/index.ts | 1 + protocol/tests/receipt/wire.test.ts | 142 ++ protocol/tests/signature/address.test.ts | 46 + verifier/LICENSE | 21 + verifier/README.md | 77 + verifier/eslint.config.js | 39 + verifier/package.json | 38 + verifier/src/checks/chainContinuity.ts | 24 + verifier/src/checks/index.ts | 3 + verifier/src/checks/operatorSignature.ts | 73 + verifier/src/checks/types.ts | 6 + verifier/src/cli.ts | 37 + verifier/src/index.ts | 14 + verifier/src/io/index.ts | 4 + verifier/src/io/loadReceipts.ts | 52 + verifier/src/io/loadSigningKeys.ts | 32 + verifier/src/io/source.ts | 21 + verifier/src/io/types.ts | 32 + verifier/src/io/util.ts | 24 + verifier/src/verify.ts | 51 + verifier/tests/checks/chainContinuity.test.ts | 58 + .../tests/checks/operatorSignature.test.ts | 88 ++ verifier/tests/fixtures/signedReceipt.ts | 190 +++ verifier/tests/io/loadReceipts.test.ts | 127 ++ verifier/tests/io/loadSigningKeys.test.ts | 58 + verifier/tests/verify.test.ts | 59 + verifier/tsconfig.json | 27 + verifier/vitest.config.ts | 16 + 38 files changed, 2156 insertions(+), 614 deletions(-) create mode 100644 protocol/src/receipt/wire.ts create mode 100644 protocol/src/signature/address.ts create mode 100644 protocol/src/signature/index.ts create mode 100644 protocol/tests/receipt/wire.test.ts create mode 100644 protocol/tests/signature/address.test.ts create mode 100644 verifier/LICENSE create mode 100644 verifier/README.md create mode 100644 verifier/eslint.config.js create mode 100644 verifier/package.json create mode 100644 verifier/src/checks/chainContinuity.ts create mode 100644 verifier/src/checks/index.ts create mode 100644 verifier/src/checks/operatorSignature.ts create mode 100644 verifier/src/checks/types.ts create mode 100644 verifier/src/cli.ts create mode 100644 verifier/src/index.ts create mode 100644 verifier/src/io/index.ts create mode 100644 verifier/src/io/loadReceipts.ts create mode 100644 verifier/src/io/loadSigningKeys.ts create mode 100644 verifier/src/io/source.ts create mode 100644 verifier/src/io/types.ts create mode 100644 verifier/src/io/util.ts create mode 100644 verifier/src/verify.ts create mode 100644 verifier/tests/checks/chainContinuity.test.ts create mode 100644 verifier/tests/checks/operatorSignature.test.ts create mode 100644 verifier/tests/fixtures/signedReceipt.ts create mode 100644 verifier/tests/io/loadReceipts.test.ts create mode 100644 verifier/tests/io/loadSigningKeys.test.ts create mode 100644 verifier/tests/verify.test.ts create mode 100644 verifier/tsconfig.json create mode 100644 verifier/vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index dedc6bef..a90cb864 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Normative language: `MUST`/`MUST NOT` are mandatory. `SHOULD`/`SHOULD NOT` are e - `MUST NOT` edit the golden test vectors in `contracts/test-vectors/{battle,xp}.json` to make a failing test pass. If a vector fails, the Go or Rust port has drifted from the Solidity contract; fix the drifted port, never the vector. - `MUST` update all four combat-simulator ports together (`contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, `indexer-go/internal/combat/`, `protocol/src/combat/`) when changing combat logic. Never patch one leg alone. The TS port (`protocol/src/combat/`, re-exported from `shared/src/utils/combat` for existing importers) now covers XP and level progression too (`protocol/src/combat/xp.ts`, validated against `contracts/test-vectors/xp.json`), so an XP or decay change is also a four-port change. `indexer-go/internal/combat/xp.go` covers the formula and the decay but not level-up; that gap closes when the Go verifier lands. - `MUST NOT` assume the `ChainAdapter` interface (`shared/src/hooks/adapters/`) covers more than pet-action mutations and reads. It is a real, shared interface (`useEvmAdapter`/`useSolanaAdapter` both implement it) and every public pet-action hook consumes it chain-blind, but the low-level chain wiring in `frontend/src/chains/{ethereum,solana}/`, the async battle/breed VRF flows, and the combat simulator remain intentionally separate per chain. See CLAUDE.md's cross-chain interfaces section for the exact boundary. -- `MUST` match the license of the package being edited when adding new files: `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, and `protocol` are MIT; everything else is PolyForm Noncommercial 1.0.0 (root `LICENSE`). See the table in `README.md`. `protocol` is MIT on purpose (third parties have to be able to replay signed battle receipts), so it `MUST NOT` import from a PolyForm package; a test in that package enforces it. +- `MUST` match the license of the package being edited when adding new files: `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, `protocol`, and `verifier` are MIT; everything else is PolyForm Noncommercial 1.0.0 (root `LICENSE`). See the table in `README.md`. `protocol` is MIT on purpose (third parties have to be able to replay signed battle receipts), so it `MUST NOT` import from a PolyForm package; a test in that package enforces it. `verifier` is MIT for the same reason and depends on nothing but `protocol`. - `MUST NOT` treat the v1 contract gaps documented in `contracts/plan-contract-upgrade.md` (no battle authorization, the `changeDna` cheat, client-supplied Solana starter-pet DNA) as bugs to silently patch. They are the known baseline the v2 rewrite is designed around. - `MUST` run the smallest scoped lint/test/build command for the package you touched (see Command Baseline below), not a full monorepo run, unless the change is broad. - `SHOULD NOT` trust `DEVELOPMENT.md`, `contracts/ethereum/README.md`, or the root `eth:deploy` / `eth:vrf:watch` scripts at face value. Several reference commands removed in a past refactor; see CLAUDE.md's Commands section for what is actually current. diff --git a/CLAUDE.md b/CLAUDE.md index d048a175..52629cd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ pnpm build # compile contracts + build backend + frontend + we | `backend` | *(none)* | `pnpm --filter backend test` (vitest) | `pnpm --filter backend build` (`prisma generate && tsc`) | `pnpm --filter backend exec vitest run ` | | `shared` (`@shared/core`) | `pnpm --filter @shared/core lint` | `pnpm --filter @shared/core test` (vitest) | *(none, consumed as raw TS)* | same vitest pattern | | `protocol` (`@cryptopets/protocol`) | `pnpm --filter @cryptopets/protocol lint` | `pnpm --filter @cryptopets/protocol test` (vitest) | *(none, consumed as raw TS; `typecheck` runs `tsc --noEmit`)* | same vitest pattern | +| `verifier` (`@cryptopets/verifier`) | `pnpm --filter @cryptopets/verifier lint` | `pnpm --filter @cryptopets/verifier test` (vitest) | *(none, consumed as raw TS; `typecheck` runs `tsc --noEmit`)* | same vitest pattern | | `mobile` | `pnpm --filter mobile lint` | `pnpm --filter mobile test` (jest) | *(none, RN, use `android`/`ios` scripts)* | `pnpm --filter mobile exec jest ` or `-t ""` | | `website` | `pnpm --filter website lint` (`next lint`) | *(no test script)* | `pnpm --filter website build` | n/a | | `contracts/ethereum` | *(none)* | `pnpm --prefix contracts/ethereum test` (`pnpm hh test`) | `pnpm compile` (`pnpm hh compile --force`) | `pnpm --prefix contracts/ethereum hh test test/.test.ts` | @@ -86,7 +87,8 @@ pnpm build # compile contracts + build backend + frontend + we | `contracts/ethereum` | Solidity, Hardhat | EVM contracts + subgraph | | `contracts/solana/cryptopets` | Rust, Anchor | Solana programs | | `shared` (`@shared/core`) | TypeScript | Common utils/types/hooks, consumed as raw TS (no build step), shared by frontend + mobile | -| `protocol` (`@cryptopets/protocol`) | TypeScript | MIT, dependency-free battle protocol: the TS combat engine plus (in progress) canonical encodings, hashes, and drand seed derivation. Consumed as raw TS by `shared`/`backend` and by the public receipt verifier | +| `protocol` (`@cryptopets/protocol`) | TypeScript | MIT, dependency-free battle protocol: the TS combat engine plus (in progress) canonical encodings, hashes, and drand seed derivation. Consumed as raw TS by `shared`/`backend` and by `verifier` | +| `verifier` (`@cryptopets/verifier`) | TypeScript | MIT, standalone public receipt verifier (§H). Depends only on `protocol`; no backend access, no database. So far: operator-signature and hash-chain-continuity checks (Step 30); drand/seed/replay/progression checks land in Step 31 | | `proto` | Protobuf/Buf | gRPC contract (`GameDataService`) between `indexer-go` and `backend` | ### Data flow @@ -153,6 +155,6 @@ See `docs/testing.md` for the full per-package suite table. Test work is expecte ## Licensing -This monorepo has split licensing; see the table in `README.md`. `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, and `protocol` are MIT; everything else (`frontend`, `backend`, `mobile`, `website`, `shared`) is PolyForm Noncommercial 1.0.0 (root `LICENSE`). Match the license of whichever package you're editing when adding new files. +This monorepo has split licensing; see the table in `README.md`. `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, `protocol`, and `verifier` are MIT; everything else (`frontend`, `backend`, `mobile`, `website`, `shared`) is PolyForm Noncommercial 1.0.0 (root `LICENSE`). Match the license of whichever package you're editing when adding new files. `protocol` (`@cryptopets/protocol`) is MIT deliberately: the backend-authoritative battle design (`docs/plan-backend-battle-architecture.md` §H) only holds up if outsiders can run the receipt verifier, and the verifier depends on this package. So it must never import from a PolyForm package (`tests/package.test.ts` enforces it), and it must stay free of clock reads, ambient randomness, and I/O (eslint enforces the first two). The TS combat engine lives here now, re-exported from `shared/src/utils/combat` so existing importers are unchanged. diff --git a/README.md b/README.md index 202aede6..d8b62548 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ This monorepo uses two licenses depending on the package: | Package(s) | License | | --- | --- | -| `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, `protocol` | [MIT](./contracts/LICENSE) — fully permissive | +| `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, `protocol`, `verifier` | [MIT](./contracts/LICENSE) — fully permissive | | `frontend`, `backend`, `mobile`, `website`, `shared` (and anything else) | [PolyForm Noncommercial 1.0.0](./LICENSE) — free for any noncommercial purpose; commercial use requires permission | Each package's `package.json` / `go.mod` directory points at the license that diff --git a/package.json b/package.json index 3a4c2ca4..6cc47824 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "test": "pnpm --prefix contracts/ethereum test", "build": "pnpm compile && pnpm --prefix backend build && pnpm --prefix frontend build && pnpm --prefix website build", "build:backend": "pnpm --filter backend build", - "lint": "pnpm --filter frontend lint:check && pnpm --filter @cryptopets/protocol lint && pnpm --filter @shared/core lint && pnpm --filter website lint && pnpm --filter mobile lint", - "lint:fix": "pnpm --filter frontend lint:fix && pnpm --filter @cryptopets/protocol lint:fix && pnpm --filter @shared/core lint:fix && pnpm --filter website lint:fix && pnpm --filter mobile lint:fix", + "lint": "pnpm --filter frontend lint:check && pnpm --filter @cryptopets/protocol lint && pnpm --filter @cryptopets/verifier lint && pnpm --filter @shared/core lint && pnpm --filter website lint && pnpm --filter mobile lint", + "lint:fix": "pnpm --filter frontend lint:fix && pnpm --filter @cryptopets/protocol lint:fix && pnpm --filter @cryptopets/verifier lint:fix && pnpm --filter @shared/core lint:fix && pnpm --filter website lint:fix && pnpm --filter mobile lint:fix", "eslint": "pnpm lint", "prepare": "husky" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3c6c183..4fd4cff7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,28 +238,28 @@ importers: dependencies: '@dynamic-labs/ethereum': specifier: ^4.37.1 - version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@dynamic-labs/sdk-react-core': specifier: ^4.37.1 - version: 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) + version: 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) '@dynamic-labs/solana': specifier: ^4.37.1 - version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@dynamic-labs/wagmi-connector': specifier: ^4.37.1 - version: 4.40.1(x3m74qb4qabheuvcs6rf3ordmy) + version: 4.40.1(eztakswpwypzfani4hwnwntxkq) '@shared/core': specifier: workspace:* version: link:../shared '@solana/wallet-adapter-react': specifier: ^0.15.35 - version: 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + version: 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-adapter-react-ui': specifier: ^0.9.35 - version: 0.9.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + version: 0.9.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-adapter-wallets': specifier: ^0.19.32 - version: 0.19.37(k66plh6iifxyw5d3zjvlhcznga) + version: 0.19.37(g72kdmcu56a5ty4czgn6xpkdlu) '@solana/web3.js': specifier: ^1.95.2 version: 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -289,10 +289,10 @@ importers: version: 7.13.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) viem: specifier: ^2.37.7 - version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) wagmi: specifier: ^2.17.1 - version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -383,19 +383,19 @@ importers: version: 0.32.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@react-native-async-storage/async-storage': specifier: ^2.2.0 - version: 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + version: 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) '@react-native-community/netinfo': specifier: ^11.4.1 - version: 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + version: 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) '@reown/appkit-react-native': specifier: ^2.0.1 - version: 2.0.1(x6p2ghfntjx42rethspyovjvr4) + version: 2.0.1(oamxhoebs4lkohisorpqzkfdmy) '@reown/appkit-solana-react-native': specifier: ^2.0.1 - version: 2.0.1(@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) + version: 2.0.1(@walletconnect/react-native-compat@2.23.0(o2cbduf7egsa2inysox7pg5zyu))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@reown/appkit-wagmi-react-native': specifier: ^2.0.1 - version: 2.0.1(shpadx773iilzq7h2tdwz2t7he) + version: 2.0.1(djaefxiucauy2vu2b3pakp2lue) '@shared/core': specifier: workspace:* version: link:../shared @@ -407,7 +407,7 @@ importers: version: 5.90.5(react@19.1.1) '@walletconnect/react-native-compat': specifier: ^2.23.0 - version: 2.23.0(lh5jzsrjqwxruiai4runjz3fou) + version: 2.23.0(o2cbduf7egsa2inysox7pg5zyu) bs58: specifier: ^6.0.0 version: 6.0.0 @@ -416,25 +416,25 @@ importers: version: 19.1.1 react-native: specifier: 0.82.0 - version: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + version: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) react-native-dotenv: specifier: ^3.4.11 version: 3.4.11(@babel/runtime@7.28.4) react-native-get-random-values: specifier: ^2.0.0 - version: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + version: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) react-native-safe-area-context: specifier: ^5.5.2 - version: 5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + version: 5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) react-native-svg: specifier: ^15.14.0 - version: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + version: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) viem: specifier: ~2.38.3 - version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) wagmi: specifier: ^2.18.2 - version: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + version: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) devDependencies: '@babel/core': specifier: ^7.25.2 @@ -459,7 +459,7 @@ importers: version: 0.82.0(eslint@8.57.1)(jest@29.7.0(@types/node@22.18.12))(prettier@2.8.8)(typescript@5.8.3) '@react-native/metro-config': specifier: 0.82.0 - version: 0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10) + version: 0.82.0(@babel/core@7.28.5) '@react-native/typescript-config': specifier: 0.82.0 version: 0.82.0 @@ -486,7 +486,7 @@ importers: version: 19.1.1(react@19.1.1) reactotron-react-native: specifier: ^5.0.0 - version: 5.1.18(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + version: 5.1.18(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) typescript: specifier: ^5.8.3 version: 5.8.3 @@ -562,7 +562,7 @@ importers: version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) wagmi: specifier: ^2.0.0 - version: 2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -607,6 +607,49 @@ importers: specifier: ^4.1.8 version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.18.12)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) + verifier: + dependencies: + '@cryptopets/protocol': + specifier: workspace:* + version: link:../protocol + devDependencies: + '@eslint/js': + specifier: ^9.36.0 + version: 9.38.0 + '@noble/curves': + specifier: ^1.9.7 + version: 1.9.7 + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 + '@types/node': + specifier: ^22.18.6 + version: 22.18.12 + '@vitest/coverage-v8': + specifier: ^4.1.8 + version: 4.1.9(vitest@4.1.9) + eslint: + specifier: ^9.36.0 + version: 9.38.0(jiti@2.7.0) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.7.0))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.38.0(jiti@2.7.0)) + globals: + specifier: ^16.4.0 + version: 16.4.0 + tsx: + specifier: ^4.20.6 + version: 4.20.6 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + typescript-eslint: + specifier: ^8.44.0 + version: 8.46.2(eslint@9.38.0(jiti@2.7.0))(typescript@5.8.3) + vitest: + specifier: ^4.1.8 + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.18.12)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.1.12(@types/node@22.18.12)(jiti@2.7.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) + website: dependencies: clsx: @@ -12533,13 +12576,13 @@ snapshots: - utf-8-validate - zod - '@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/hashes': 1.8.0 clsx: 1.2.1 eventemitter3: 5.0.1 preact: 10.27.2 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - typescript @@ -12625,12 +12668,12 @@ snapshots: '@leichtgewicht/ip-codec': 2.0.5 utf8-codec: 1.0.0 - '@dynamic-labs-connectors/base-account-evm@4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': + '@dynamic-labs-connectors/base-account-evm@4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: - '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@4.4.3) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@types/react' - bufferutil @@ -12732,11 +12775,11 @@ snapshots: dependencies: '@dynamic-labs/logger': 4.40.1 - '@dynamic-labs/embedded-wallet-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': + '@dynamic-labs/embedded-wallet-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/embedded-wallet': 4.40.1(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 @@ -12745,9 +12788,9 @@ snapshots: '@dynamic-labs/webauthn': 4.40.1 '@turnkey/api-key-stamper': 0.4.7 '@turnkey/iframe-stamper': 2.5.0 - '@turnkey/viem': 0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + '@turnkey/viem': 0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@turnkey/webauthn-stamper': 0.5.1 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - encoding @@ -12757,7 +12800,7 @@ snapshots: - utf-8-validate - zod - '@dynamic-labs/embedded-wallet-solana@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@dynamic-labs/embedded-wallet-solana@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@dynamic-labs-sdk/client': 0.1.0-alpha.23(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@dynamic-labs/assert-package-version': 4.40.1 @@ -12772,9 +12815,9 @@ snapshots: '@dynamic-labs/webauthn': 4.40.1 '@solana/web3.js': 1.98.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@turnkey/iframe-stamper': 2.5.0 - '@turnkey/solana': 1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/solana': 1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@turnkey/webauthn-stamper': 0.5.1 - viem: 2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - debug @@ -12804,7 +12847,7 @@ snapshots: - react - react-dom - '@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))': + '@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/logger': 4.40.1 @@ -12814,30 +12857,30 @@ snapshots: '@dynamic-labs/utils': 4.40.1 '@dynamic-labs/wallet-book': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - react - react-dom - '@dynamic-labs/ethereum@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': + '@dynamic-labs/ethereum@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: - '@coinbase/wallet-sdk': 4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@dynamic-labs-connectors/base-account-evm': 4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + '@coinbase/wallet-sdk': 4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@dynamic-labs-connectors/base-account-evm': 4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/embedded-wallet-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/embedded-wallet-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/rpc-providers': 4.40.1 '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@dynamic-labs/waas-evm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@dynamic-labs/wallet-book': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@metamask/sdk': 0.33.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) - '@walletconnect/ethereum-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/ethereum-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) buffer: 6.0.3 eventemitter3: 5.0.1 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -12882,11 +12925,11 @@ snapshots: react-dom: 19.1.1(react@19.1.1) sharp: 0.33.5 - '@dynamic-labs/locale@4.40.1(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': + '@dynamic-labs/locale@4.40.1(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 i18next: 23.4.6 - react-i18next: 13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + react-i18next: 13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) transitivePeerDependencies: - react - react-dom @@ -12927,12 +12970,12 @@ snapshots: '@dynamic-labs/sdk-api-core@0.0.813': {} - '@dynamic-labs/sdk-react-core@4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10)': + '@dynamic-labs/sdk-react-core@4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10)': dependencies: '@dynamic-labs-sdk/client': 0.1.0-alpha.23(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/iconic': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@dynamic-labs/locale': 4.40.1(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@dynamic-labs/locale': 4.40.1(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/multi-wallet': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@dynamic-labs/rpc-providers': 4.40.1 @@ -12953,7 +12996,7 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) react-focus-lock: 2.13.6(@types/react@19.2.2)(react@19.1.1) - react-i18next: 13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + react-i18next: 13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) react-international-phone: 4.5.0(react@19.1.1) yup: 0.32.11 transitivePeerDependencies: @@ -12984,28 +13027,28 @@ snapshots: - typescript - utf-8-validate - '@dynamic-labs/solana@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': + '@dynamic-labs/solana@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/embedded-wallet-solana': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@dynamic-labs/embedded-wallet-solana': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/rpc-providers': 4.40.1 '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/solana-core': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas-svm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/waas-svm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/wallet-book': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@dynamic-labs/wallet-connect': 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@dynamic-labs/wallet-connect': 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@solana/web3.js': 1.98.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@wallet-standard/app': 1.0.1 '@wallet-standard/base': 1.0.1 '@wallet-standard/experimental-features': 0.1.1 '@wallet-standard/features': 1.0.3 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 5.0.0 eventemitter3: 5.0.1 tweetnacl: 1.0.3 @@ -13082,17 +13125,17 @@ snapshots: eventemitter3: 5.0.1 tldts: 6.0.16 - '@dynamic-labs/waas-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@dynamic-labs/waas-evm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@gql.tada/svelte-support' - '@gql.tada/vue-support' @@ -13106,7 +13149,7 @@ snapshots: - utf-8-validate - zod - '@dynamic-labs/waas-svm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))': + '@dynamic-labs/waas-svm@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/logger': 4.40.1 @@ -13115,7 +13158,7 @@ snapshots: '@dynamic-labs/solana-core': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@dynamic-labs/types': 4.40.1 '@dynamic-labs/utils': 4.40.1 - '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/waas': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@solana/web3.js': 1.98.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) bs58: 5.0.0 @@ -13133,11 +13176,11 @@ snapshots: - utf-8-validate - viem - '@dynamic-labs/waas@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))': + '@dynamic-labs/waas@4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))': dependencies: '@dynamic-labs-wallet/browser-wallet-client': 0.0.187(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/sdk-api-core': 0.0.813 '@dynamic-labs/solana-core': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@dynamic-labs/sui-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3) @@ -13156,20 +13199,20 @@ snapshots: - utf-8-validate - viem - '@dynamic-labs/wagmi-connector@4.40.1(x3m74qb4qabheuvcs6rf3ordmy)': + '@dynamic-labs/wagmi-connector@4.40.1(eztakswpwypzfani4hwnwntxkq)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 - '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/rpc-providers': 4.40.1 - '@dynamic-labs/sdk-react-core': 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) + '@dynamic-labs/sdk-react-core': 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) '@dynamic-labs/types': 4.40.1 '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) eventemitter3: 5.0.4 react: 19.1.1 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@dynamic-labs/wallet-book@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: @@ -13183,11 +13226,11 @@ snapshots: util: 0.12.5 zod: 4.0.5 - '@dynamic-labs/wallet-connect@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@dynamic-labs/wallet-connect@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/logger': 4.40.1 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15424,16 +15467,16 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 - '@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': + '@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': dependencies: merge-options: 3.0.4 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) optional: true - '@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': + '@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': dependencies: merge-options: 3.0.4 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) '@react-native-community/cli-clean@20.0.0': dependencies: @@ -15564,9 +15607,9 @@ snapshots: - typescript - utf-8-validate - '@react-native-community/netinfo@11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': + '@react-native-community/netinfo@11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': dependencies: - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) '@react-native/assets-registry@0.82.0': {} @@ -15638,7 +15681,7 @@ snapshots: nullthrows: 1.1.1 yargs: 17.7.2 - '@react-native/community-cli-plugin@0.82.0(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + '@react-native/community-cli-plugin@0.82.0(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(bufferutil@4.0.9)(utf-8-validate@5.0.10)': dependencies: '@react-native/dev-middleware': 0.82.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) debug: 4.4.3(supports-color@8.1.1) @@ -15649,7 +15692,7 @@ snapshots: semver: 7.7.3 optionalDependencies: '@react-native-community/cli': 20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@react-native/metro-config': 0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@react-native/metro-config': 0.82.0(@babel/core@7.28.5) transitivePeerDependencies: - bufferutil - supports-color @@ -15717,7 +15760,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + '@react-native/metro-config@0.82.0(@babel/core@7.28.5)': dependencies: '@react-native/js-polyfills': 0.82.0 '@react-native/metro-babel-transformer': 0.82.0(@babel/core@7.28.5) @@ -15725,29 +15768,27 @@ snapshots: metro-runtime: 0.83.3 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.82.0': {} '@react-native/typescript-config@0.82.0': {} - '@react-native/virtualized-lists@0.82.0(@types/react@19.2.2)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': + '@react-native/virtualized-lists@0.82.0(@types/react@19.2.2)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) optionalDependencies: '@types/react': 19.2.2 - '@reown/appkit-common-react-native@2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': + '@reown/appkit-common-react-native@2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': dependencies: bignumber.js: 9.1.2 dayjs: 1.11.10 react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) '@reown/appkit-common@1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.22.4)': dependencies: @@ -15760,11 +15801,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-common@1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-common@1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: big.js: 6.2.2 dayjs: 1.11.13 - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - typescript @@ -15804,13 +15845,13 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-controllers@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15839,13 +15880,13 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15874,13 +15915,13 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15909,11 +15950,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: @@ -15944,24 +15985,24 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-core-react-native@2.0.1(@types/react@19.2.2)(@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': + '@reown/appkit-core-react-native@2.0.1(@types/react@19.2.2)(@walletconnect/react-native-compat@2.23.0(o2cbduf7egsa2inysox7pg5zyu))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': dependencies: - '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@walletconnect/react-native-compat': 2.23.0(lh5jzsrjqwxruiai4runjz3fou) + '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@walletconnect/react-native-compat': 2.23.0(o2cbduf7egsa2inysox7pg5zyu) countries-and-timezones: 3.7.2 derive-valtio: 0.2.0(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1)) react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: - '@types/react' - '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) lit: 3.3.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: @@ -15992,12 +16033,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) lit: 3.3.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: @@ -16028,12 +16069,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) lit: 3.3.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: @@ -16072,18 +16113,18 @@ snapshots: dependencies: buffer: 6.0.3 - '@reown/appkit-react-native@2.0.1(x6p2ghfntjx42rethspyovjvr4)': + '@reown/appkit-react-native@2.0.1(oamxhoebs4lkohisorpqzkfdmy)': dependencies: - '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@reown/appkit-core-react-native': 2.0.1(@types/react@19.2.2)(@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@reown/appkit-ui-react-native': 2.0.1(react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@walletconnect/react-native-compat': 2.23.0(lh5jzsrjqwxruiai4runjz3fou) - '@walletconnect/universal-provider': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@4.4.3) + '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@reown/appkit-core-react-native': 2.0.1(@types/react@19.2.2)(@walletconnect/react-native-compat@2.23.0(o2cbduf7egsa2inysox7pg5zyu))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@reown/appkit-ui-react-native': 2.0.1(react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@walletconnect/react-native-compat': 2.23.0(o2cbduf7egsa2inysox7pg5zyu) + '@walletconnect/universal-provider': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-safe-area-context: 5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - react-native-svg: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native-safe-area-context: 5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + react-native-svg: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: - '@azure/app-configuration' @@ -16112,12 +16153,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-scaffold-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.1.0 transitivePeerDependencies: @@ -16149,12 +16190,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -16186,12 +16227,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -16223,12 +16264,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -16260,15 +16301,15 @@ snapshots: - valtio - zod - '@reown/appkit-solana-react-native@2.0.1(@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)': + '@reown/appkit-solana-react-native@2.0.1(@walletconnect/react-native-compat@2.23.0(o2cbduf7egsa2inysox7pg5zyu))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)': dependencies: - '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) '@solana/spl-token': 0.4.13(@solana/web3.js@1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) '@solana/web3.js': 1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/react-native-compat': 2.23.0(lh5jzsrjqwxruiai4runjz3fou) + '@walletconnect/react-native-compat': 2.23.0(o2cbduf7egsa2inysox7pg5zyu) bs58: 6.0.0 react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) tweetnacl: 1.0.3 transitivePeerDependencies: - bufferutil @@ -16277,19 +16318,19 @@ snapshots: - typescript - utf-8-validate - '@reown/appkit-ui-react-native@2.0.1(react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': + '@reown/appkit-ui-react-native@2.0.1(react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': dependencies: - '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) polished: 4.3.1 qrcode: 1.5.3 react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-svg: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native-svg: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@reown/appkit-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.1.0 qrcode: 1.5.3 @@ -16321,10 +16362,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -16356,10 +16397,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -16391,10 +16432,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -16426,16 +16467,16 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-utils@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.2 '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16464,16 +16505,16 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16502,16 +16543,16 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16540,14 +16581,14 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: @@ -16578,17 +16619,17 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-wagmi-react-native@2.0.1(shpadx773iilzq7h2tdwz2t7he)': + '@reown/appkit-wagmi-react-native@2.0.1(djaefxiucauy2vu2b3pakp2lue)': dependencies: - '@react-native-community/netinfo': 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) - '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@reown/appkit-react-native': 2.0.1(x6p2ghfntjx42rethspyovjvr4) - '@walletconnect/react-native-compat': 2.23.0(lh5jzsrjqwxruiai4runjz3fou) + '@react-native-community/netinfo': 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@reown/appkit-react-native': 2.0.1(oamxhoebs4lkohisorpqzkfdmy) + '@walletconnect/react-native-compat': 2.23.0(o2cbduf7egsa2inysox7pg5zyu) react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-get-random-values: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native-get-random-values: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16641,20 +16682,20 @@ snapshots: - typescript - utf-8-validate - '@reown/appkit@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.2 - '@reown/appkit-scaffold-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) - '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16683,21 +16724,21 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16726,21 +16767,21 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16769,18 +16810,18 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-pay': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0 - '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) @@ -17009,9 +17050,9 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} - '@solana-mobile/mobile-wallet-adapter-protocol-web3js@2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana-mobile/mobile-wallet-adapter-protocol-web3js@2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: - '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) bs58: 5.0.0 js-base64: 3.7.8 @@ -17022,14 +17063,14 @@ snapshots: - react-native - typescript - '@solana-mobile/mobile-wallet-adapter-protocol@2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana-mobile/mobile-wallet-adapter-protocol@2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: '@solana/codecs-strings': 4.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/wallet-standard': 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(react@19.1.1) '@solana/wallet-standard-util': 1.1.2 '@wallet-standard/core': 1.1.1 js-base64: 3.7.8 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) transitivePeerDependencies: - '@solana/wallet-adapter-base' - '@solana/web3.js' @@ -17038,25 +17079,25 @@ snapshots: - react - typescript - '@solana-mobile/wallet-adapter-mobile@2.2.5(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana-mobile/wallet-adapter-mobile@2.2.5(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: - '@solana-mobile/mobile-wallet-adapter-protocol-web3js': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) - '@solana-mobile/wallet-standard-mobile': 0.4.3(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana-mobile/mobile-wallet-adapter-protocol-web3js': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana-mobile/wallet-standard-mobile': 0.4.3(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-standard-features': 1.3.0 '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) js-base64: 3.7.8 optionalDependencies: - '@react-native-async-storage/async-storage': 1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@react-native-async-storage/async-storage': 1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) transitivePeerDependencies: - fastestsmallesttextencoderdecoder - react - react-native - typescript - '@solana-mobile/wallet-standard-mobile@0.4.3(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana-mobile/wallet-standard-mobile@0.4.3(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: - '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-standard-chains': 1.1.1 '@solana/wallet-standard-features': 1.3.0 '@wallet-standard/base': 1.1.0 @@ -17072,26 +17113,26 @@ snapshots: - react-native - typescript - '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/stake@0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/stake@0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/system@0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/system@0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))': + '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/accounts@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)': dependencies: @@ -17273,7 +17314,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17286,11 +17327,11 @@ snapshots: '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/rpc-parsed-types': 2.3.0(typescript@5.8.3) '@solana/rpc-spec-types': 2.3.0(typescript@5.8.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/signers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) typescript: 5.8.3 @@ -17380,14 +17421,14 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.8.3) '@solana/functional': 2.3.0(typescript@5.8.3) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.8.3) '@solana/subscribable': 2.3.0(typescript@5.8.3) typescript: 5.8.3 - ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@solana/rpc-subscriptions-spec@2.3.0(typescript@5.8.3)': dependencies: @@ -17397,7 +17438,7 @@ snapshots: '@solana/subscribable': 2.3.0(typescript@5.8.3) typescript: 5.8.3 - '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.8.3) '@solana/fast-stable-stringify': 2.3.0(typescript@5.8.3) @@ -17405,7 +17446,7 @@ snapshots: '@solana/promises': 2.3.0(typescript@5.8.3) '@solana/rpc-spec-types': 2.3.0(typescript@5.8.3) '@solana/rpc-subscriptions-api': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.8.3) '@solana/rpc-transformers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17583,7 +17624,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17591,7 +17632,7 @@ snapshots: '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/promises': 2.3.0(typescript@5.8.3) '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17643,9 +17684,9 @@ snapshots: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-base-ui@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana/wallet-adapter-base-ui@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: - '@solana/wallet-adapter-react': 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana/wallet-adapter-react': 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) react: 19.1.1 transitivePeerDependencies: @@ -17779,11 +17820,11 @@ snapshots: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-react-ui@0.9.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana/wallet-adapter-react-ui@0.9.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@solana/wallet-adapter-base-ui': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) - '@solana/wallet-adapter-react': 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana/wallet-adapter-base-ui': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana/wallet-adapter-react': 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) react: 19.1.1 react-dom: 19.1.1(react@19.1.1) @@ -17793,9 +17834,9 @@ snapshots: - react-native - typescript - '@solana/wallet-adapter-react@0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana/wallet-adapter-react@0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: - '@solana-mobile/wallet-adapter-mobile': 2.2.5(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana-mobile/wallet-adapter-mobile': 2.2.5(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-standard-wallet-adapter-react': 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(react@19.1.1) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -17874,11 +17915,11 @@ snapshots: - typescript - utf-8-validate - '@solana/wallet-adapter-trezor@0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/wallet-adapter-trezor@0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@trezor/connect-web': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/connect-web': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) buffer: 6.0.3 transitivePeerDependencies: - '@solana/sysvars' @@ -17908,11 +17949,11 @@ snapshots: '@solana/wallet-standard-util': 1.1.2 '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-walletconnect@0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@solana/wallet-adapter-walletconnect@0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/solana-adapter': 0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/solana-adapter': 0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -17941,7 +17982,7 @@ snapshots: - utf-8-validate - zod - '@solana/wallet-adapter-wallets@0.19.37(k66plh6iifxyw5d3zjvlhcznga)': + '@solana/wallet-adapter-wallets@0.19.37(g72kdmcu56a5ty4czgn6xpkdlu)': dependencies: '@solana/wallet-adapter-alpha': 0.1.14(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-avana': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) @@ -17974,10 +18015,10 @@ snapshots: '@solana/wallet-adapter-tokenary': 0.1.16(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-tokenpocket': 0.4.23(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-torus': 0.11.32(@babel/runtime@7.28.4)(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-trust': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-unsafe-burner': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@solana/wallet-adapter-walletconnect': 0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@solana/wallet-adapter-walletconnect': 0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@solana/wallet-adapter-xdefi': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) transitivePeerDependencies: @@ -18431,9 +18472,9 @@ snapshots: - typescript - utf-8-validate - '@trezor/analytics@1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': + '@trezor/analytics@1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': dependencies: - '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/utils': 9.4.3(tslib@2.8.1) tslib: 2.8.1 transitivePeerDependencies: @@ -18447,11 +18488,11 @@ snapshots: '@trezor/utxo-lib': 2.4.4(tslib@2.8.1) tslib: 2.8.1 - '@trezor/blockchain-link-utils@1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10)': + '@trezor/blockchain-link-utils@1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10)': dependencies: '@mobily/ts-belt': 3.13.1 '@stellar/stellar-sdk': 13.3.0 - '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/protobuf': 1.4.4(tslib@2.8.1) '@trezor/utils': 9.4.4(tslib@2.8.1) tslib: 2.8.1 @@ -18464,18 +18505,18 @@ snapshots: - react-native - utf-8-validate - '@trezor/blockchain-link@2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/blockchain-link@2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: - '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@stellar/stellar-sdk': 13.3.0 '@trezor/blockchain-link-types': 1.4.4(tslib@2.8.1) - '@trezor/blockchain-link-utils': 1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10) - '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/blockchain-link-utils': 1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10) + '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/utils': 9.4.4(tslib@2.8.1) '@trezor/utxo-lib': 2.4.4(tslib@2.8.1) '@trezor/websocket-client': 1.2.4(bufferutil@4.0.9)(tslib@2.8.1)(utf-8-validate@5.0.10) @@ -18498,18 +18539,18 @@ snapshots: - utf-8-validate - ws - '@trezor/connect-analytics@1.3.6(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': + '@trezor/connect-analytics@1.3.6(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': dependencies: - '@trezor/analytics': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/analytics': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) tslib: 2.8.1 transitivePeerDependencies: - expo-constants - expo-localization - react-native - '@trezor/connect-common@0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': + '@trezor/connect-common@0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': dependencies: - '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/type-utils': 1.1.9 '@trezor/utils': 9.4.4(tslib@2.8.1) tslib: 2.8.1 @@ -18518,10 +18559,10 @@ snapshots: - expo-localization - react-native - '@trezor/connect-web@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/connect-web@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: - '@trezor/connect': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@trezor/connect-common': 0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/connect': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/connect-common': 0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/utils': 9.4.4(tslib@2.8.1) '@trezor/websocket-client': 1.2.4(bufferutil@4.0.9)(tslib@2.8.1)(utf-8-validate@5.0.10) tslib: 2.8.1 @@ -18539,7 +18580,7 @@ snapshots: - utf-8-validate - ws - '@trezor/connect@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/connect@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@ethereumjs/common': 10.0.0 '@ethereumjs/tx': 10.0.0 @@ -18547,19 +18588,19 @@ snapshots: '@mobily/ts-belt': 3.13.1 '@noble/hashes': 1.8.0 '@scure/bip39': 1.6.0 - '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/system': 0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@trezor/blockchain-link': 2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/system': 0.7.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/blockchain-link': 2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@trezor/blockchain-link-types': 1.4.4(tslib@2.8.1) - '@trezor/blockchain-link-utils': 1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10) - '@trezor/connect-analytics': 1.3.6(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) - '@trezor/connect-common': 0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/blockchain-link-utils': 1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10) + '@trezor/connect-analytics': 1.3.6(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/connect-common': 0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/crypto-utils': 1.1.5(tslib@2.8.1) '@trezor/device-utils': 1.1.4 - '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/protobuf': 1.4.4(tslib@2.8.1) '@trezor/protocol': 1.2.10(tslib@2.8.1) '@trezor/schema-utils': 1.3.4(tslib@2.8.1) @@ -18594,12 +18635,12 @@ snapshots: '@trezor/device-utils@1.1.4': {} - '@trezor/env-utils@1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': + '@trezor/env-utils@1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': dependencies: tslib: 2.8.1 ua-parser-js: 2.0.6 optionalDependencies: - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) '@trezor/protobuf@1.4.4(tslib@2.8.1)': dependencies: @@ -18705,7 +18746,7 @@ snapshots: '@turnkey/api-key-stamper': 0.4.7 '@turnkey/encoding': 0.5.0 - '@turnkey/sdk-browser@5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@turnkey/sdk-browser@5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@turnkey/api-key-stamper': 0.4.7 '@turnkey/crypto': 2.5.0 @@ -18714,7 +18755,7 @@ snapshots: '@turnkey/iframe-stamper': 2.5.0 '@turnkey/indexed-db-stamper': 1.1.1 '@turnkey/sdk-types': 0.3.0 - '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@turnkey/webauthn-stamper': 0.5.1 bs58check: 4.0.0 buffer: 6.0.3 @@ -18727,11 +18768,11 @@ snapshots: - utf-8-validate - zod - '@turnkey/sdk-server@4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@turnkey/sdk-server@4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@turnkey/api-key-stamper': 0.4.7 '@turnkey/http': 3.10.0(encoding@0.1.13) - '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/wallet-stamper': 1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) buffer: 6.0.3 cross-fetch: 3.2.0(encoding@0.1.13) transitivePeerDependencies: @@ -18743,12 +18784,12 @@ snapshots: '@turnkey/sdk-types@0.3.0': {} - '@turnkey/solana@1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@turnkey/solana@1.0.42(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@turnkey/http': 3.10.0(encoding@0.1.13) - '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - encoding @@ -18756,16 +18797,16 @@ snapshots: - utf-8-validate - zod - '@turnkey/viem@0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)': + '@turnkey/viem@0.13.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: '@noble/curves': 1.8.0 '@openzeppelin/contracts': 4.9.6 '@turnkey/api-key-stamper': 0.4.7 '@turnkey/http': 3.10.0(encoding@0.1.13) - '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@turnkey/sdk-browser': 5.8.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@turnkey/sdk-server': 4.7.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) cross-fetch: 4.1.0(encoding@0.1.13) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - encoding @@ -18773,12 +18814,12 @@ snapshots: - utf-8-validate - zod - '@turnkey/wallet-stamper@1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@turnkey/wallet-stamper@1.0.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@turnkey/crypto': 2.5.0 '@turnkey/encoding': 0.5.0 optionalDependencies: - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - bufferutil - typescript @@ -19274,7 +19315,7 @@ snapshots: '@vue/shared@3.5.22': {} - '@wagmi/connectors@6.1.0(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76))(zod@3.25.76)': + '@wagmi/connectors@6.1.0(5wnggatnpg3gomvuzxtzqousqe)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) @@ -19283,9 +19324,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) - '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)) + porto: 0.2.19(2xcn7d5aunq6wiuiuf55mtzjz4) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 @@ -19321,7 +19362,7 @@ snapshots: - wagmi - zod - '@wagmi/connectors@6.1.0(ck72wdzxjpfzfgbpsvtuebcwoi)': + '@wagmi/connectors@6.1.0(6cluku43u2oelf2ch5n5aldz5e)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) @@ -19330,9 +19371,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(q4cw5yhvj7zbif42fx5kul3uoi) + porto: 0.2.19(ujys5btvxfmmll34c522tgip2a) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 @@ -19368,7 +19409,7 @@ snapshots: - wagmi - zod - '@wagmi/connectors@6.1.0(qnfautsezg4qphd44hbshkqvfi)': + '@wagmi/connectors@6.1.0(bsf4qoxz566z5cp5uprqbvc7kq)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@4.4.3) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@4.4.3) @@ -19377,9 +19418,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(wbrvrvfq6sbyt2murnqn7zt4wm) + porto: 0.2.19(hpt6vxfggamvxcrtvr7rawnaru) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) optionalDependencies: typescript: 5.8.3 @@ -19494,21 +19535,21 @@ snapshots: dependencies: '@wallet-standard/base': 1.1.0 - '@walletconnect/core@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 events: 3.3.0 lodash.isequal: 4.5.0 @@ -19538,21 +19579,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19582,21 +19623,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19626,21 +19667,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19670,21 +19711,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0 - '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19714,21 +19755,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19758,21 +19799,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19802,21 +19843,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1 - '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19846,21 +19887,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@4.4.3) + '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.39.3 events: 3.3.0 @@ -19890,21 +19931,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.39.3 events: 3.3.0 @@ -19938,18 +19979,18 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -19979,18 +20020,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20020,18 +20061,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.1(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1 - '@walletconnect/universal-provider': 2.21.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20061,18 +20102,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/ethereum-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20149,13 +20190,13 @@ snapshots: - bufferutil - utf-8-validate - '@walletconnect/keyvaluestorage@1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/keyvaluestorage@1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/safe-json': 1.0.2 idb-keyval: 6.2.2 unstorage: 1.17.1(idb-keyval@6.2.2)(ioredis@5.11.1) optionalDependencies: - '@react-native-async-storage/async-storage': 1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@react-native-async-storage/async-storage': 1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -20176,13 +20217,13 @@ snapshots: - ioredis - uploadthing - '@walletconnect/keyvaluestorage@1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/keyvaluestorage@1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/safe-json': 1.0.2 idb-keyval: 6.2.2 unstorage: 1.17.1(idb-keyval@6.2.2)(ioredis@5.11.1) optionalDependencies: - '@react-native-async-storage/async-storage': 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@react-native-async-storage/async-storage': 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -20208,15 +20249,15 @@ snapshots: '@walletconnect/safe-json': 1.0.2 pino: 7.11.0 - '@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou)': + '@walletconnect/react-native-compat@2.23.0(o2cbduf7egsa2inysox7pg5zyu)': dependencies: - '@react-native-async-storage/async-storage': 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) - '@react-native-community/netinfo': 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@react-native-async-storage/async-storage': 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@react-native-community/netinfo': 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) events: 3.3.0 fast-text-encoding: 1.0.6 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-get-random-values: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) - react-native-url-polyfill: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native-get-random-values: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + react-native-url-polyfill: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) '@walletconnect/relay-api@1.0.11': dependencies: @@ -20234,16 +20275,16 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/sign-client@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20270,16 +20311,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20306,16 +20347,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20342,16 +20383,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20378,16 +20419,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0 - '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20414,16 +20455,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20450,16 +20491,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20486,16 +20527,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1 - '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20522,16 +20563,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@4.4.3) + '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20558,16 +20599,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20594,13 +20635,13 @@ snapshots: - utf-8-validate - zod - '@walletconnect/solana-adapter@0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/solana-adapter@0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 6.0.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20634,12 +20675,12 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/types@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20663,12 +20704,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20692,12 +20733,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.0': + '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20721,12 +20762,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20750,12 +20791,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20779,12 +20820,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.1': + '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20808,12 +20849,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20837,12 +20878,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20866,12 +20907,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20895,12 +20936,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20924,18 +20965,18 @@ snapshots: - ioredis - uploadthing - '@walletconnect/universal-provider@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 lodash: 4.17.21 transitivePeerDependencies: @@ -20964,18 +21005,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21004,18 +21045,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21044,18 +21085,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21084,18 +21125,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.0 - '@walletconnect/utils': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21124,18 +21165,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21164,18 +21205,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21204,18 +21245,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1 - '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21244,18 +21285,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) es-toolkit: 1.39.3 events: 3.3.0 transitivePeerDependencies: @@ -21284,18 +21325,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.39.3 events: 3.3.0 transitivePeerDependencies: @@ -21324,25 +21365,25 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 detect-browser: 5.3.0 elliptic: 6.6.1 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21368,18 +21409,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21387,7 +21428,7 @@ snapshots: elliptic: 6.6.1 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21413,25 +21454,25 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21457,25 +21498,25 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21501,18 +21542,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0 + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21545,25 +21586,25 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21589,25 +21630,25 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.0 - viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -21633,18 +21674,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1 + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21677,7 +21718,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@4.4.3)': + '@walletconnect/utils@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76)': dependencies: '@msgpack/msgpack': 3.1.2 '@noble/ciphers': 1.3.0 @@ -21685,18 +21726,18 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 blakejs: 1.2.1 bs58: 6.0.0 detect-browser: 5.3.0 - ox: 0.9.3(typescript@5.8.3)(zod@4.4.3) + ox: 0.9.3(typescript@5.8.3)(zod@3.25.76) uint8arrays: 3.1.1 transitivePeerDependencies: - '@azure/app-configuration' @@ -21721,7 +21762,7 @@ snapshots: - uploadthing - zod - '@walletconnect/utils@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@msgpack/msgpack': 3.1.2 '@noble/ciphers': 1.3.0 @@ -21729,12 +21770,12 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 blakejs: 1.2.1 @@ -21742,7 +21783,7 @@ snapshots: detect-browser: 5.3.0 query-string: 7.1.3 uint8arrays: 3.1.1 - viem: 2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -26442,7 +26483,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.7.1(typescript@5.8.3)(zod@3.25.76): + ox@0.7.1(typescript@5.8.3)(zod@4.4.3): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -26450,7 +26491,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.1(typescript@5.8.3)(zod@3.25.76) + abitype: 1.1.1(typescript@5.8.3)(zod@4.4.3) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 @@ -26472,7 +26513,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.9.3(typescript@5.8.3)(zod@4.4.3): + ox@0.9.3(typescript@5.8.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -26480,7 +26521,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.1.1(typescript@5.8.3)(zod@4.4.3) + abitype: 1.1.1(typescript@5.8.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 @@ -26744,7 +26785,7 @@ snapshots: pony-cause@2.1.11: {} - porto@0.2.19(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)): + porto@0.2.19(2xcn7d5aunq6wiuiuf55mtzjz4): dependencies: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) hono: 4.10.3 @@ -26758,47 +26799,47 @@ snapshots: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) transitivePeerDependencies: - '@types/react' - immer - use-sync-external-store - porto@0.2.19(q4cw5yhvj7zbif42fx5kul3uoi): + porto@0.2.19(hpt6vxfggamvxcrtvr7rawnaru): dependencies: - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) hono: 4.10.3 idb-keyval: 6.2.2 mipd: 0.0.7(typescript@5.8.3) ox: 0.9.12(typescript@5.8.3)(zod@4.4.3) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) zod: 4.4.3 zustand: 5.0.8(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(use-sync-external-store@1.4.0(react@19.1.1)) optionalDependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) transitivePeerDependencies: - '@types/react' - immer - use-sync-external-store - porto@0.2.19(wbrvrvfq6sbyt2murnqn7zt4wm): + porto@0.2.19(ujys5btvxfmmll34c522tgip2a): dependencies: - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) hono: 4.10.3 idb-keyval: 6.2.2 mipd: 0.0.7(typescript@5.8.3) ox: 0.9.12(typescript@5.8.3)(zod@4.4.3) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) zod: 4.4.3 zustand: 5.0.8(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(use-sync-external-store@1.4.0(react@19.1.1)) optionalDependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) transitivePeerDependencies: - '@types/react' - immer @@ -27092,7 +27133,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 - react-i18next@13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): + react-i18next@13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): dependencies: '@babel/runtime': 7.28.4 html-parse-stringify: 3.0.1 @@ -27100,7 +27141,7 @@ snapshots: react: 19.1.1 optionalDependencies: react-dom: 19.1.1(react@19.1.1) - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) react-icons@5.6.0(react@19.1.1): dependencies: @@ -27138,39 +27179,39 @@ snapshots: dependencies: p-defer: 3.0.0 - react-native-get-random-values@2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): + react-native-get-random-values@2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): dependencies: fast-base64-decode: 1.0.0 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-safe-area-context@5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): + react-native-safe-area-context@5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): dependencies: react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): + react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) warn-once: 0.1.1 - react-native-url-polyfill@2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): + react-native-url-polyfill@2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): dependencies: - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) whatwg-url-without-unicode: 8.0.0-3 - react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10): + react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10): dependencies: '@jest/create-cache-key-function': 29.7.0 '@react-native/assets-registry': 0.82.0 '@react-native/codegen': 0.82.0(@babel/core@7.28.5) - '@react-native/community-cli-plugin': 0.82.0(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@react-native/community-cli-plugin': 0.82.0(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@react-native/gradle-plugin': 0.82.0 '@react-native/js-polyfills': 0.82.0 '@react-native/normalize-colors': 0.82.0 - '@react-native/virtualized-lists': 0.82.0(@types/react@19.2.2)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@react-native/virtualized-lists': 0.82.0(@types/react@19.2.2)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -27249,10 +27290,10 @@ snapshots: reactotron-core-contract@0.3.2: {} - reactotron-react-native@5.1.18(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): + reactotron-react-native@5.1.18(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): dependencies: mitt: 3.0.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) reactotron-core-client: 2.9.9 readable-stream@2.3.8: @@ -28511,15 +28552,15 @@ snapshots: - utf-8-validate - zod - viem@2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76): + viem@2.29.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3): dependencies: '@noble/curves': 1.8.2 '@noble/hashes': 1.7.2 '@scure/bip32': 1.6.2 '@scure/bip39': 1.5.4 - abitype: 1.0.8(typescript@5.8.3)(zod@3.25.76) + abitype: 1.0.8(typescript@5.8.3)(zod@4.4.3) isows: 1.0.6(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - ox: 0.6.9(typescript@5.8.3)(zod@3.25.76) + ox: 0.6.9(typescript@5.8.3)(zod@4.4.3) ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -28528,15 +28569,15 @@ snapshots: - utf-8-validate - zod - viem@2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76): + viem@2.31.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.8.3)(zod@3.25.76) + abitype: 1.0.8(typescript@5.8.3)(zod@4.4.3) isows: 1.0.7(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - ox: 0.7.1(typescript@5.8.3)(zod@3.25.76) + ox: 0.7.1(typescript@5.8.3)(zod@4.4.3) ws: 8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -28690,14 +28731,14 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): + wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): dependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) - '@wagmi/connectors': 6.1.0(ck72wdzxjpfzfgbpsvtuebcwoi) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@wagmi/connectors': 6.1.0(bsf4qoxz566z5cp5uprqbvc7kq) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) react: 19.1.1 use-sync-external-store: 1.4.0(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -28729,14 +28770,14 @@ snapshots: - utf-8-validate - zod - wagmi@2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): + wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): dependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) - '@wagmi/connectors': 6.1.0(qnfautsezg4qphd44hbshkqvfi) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) + '@wagmi/connectors': 6.1.0(5wnggatnpg3gomvuzxtzqousqe) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) react: 19.1.1 use-sync-external-store: 1.4.0(react@19.1.1) - viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -28768,10 +28809,10 @@ snapshots: - utf-8-validate - zod - wagmi@2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): + wagmi@2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): dependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) - '@wagmi/connectors': 6.1.0(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)))(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.18.2(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76))(zod@3.25.76) + '@wagmi/connectors': 6.1.0(6cluku43u2oelf2ch5n5aldz5e) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) react: 19.1.1 use-sync-external-store: 1.4.0(react@19.1.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d6b46dee..692b9455 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - 'mobile' - 'shared' - 'protocol' + - 'verifier' - 'contracts/ethereum' - 'contracts/ethereum/subgraph' - 'contracts/solana/cryptopets' diff --git a/protocol/src/index.ts b/protocol/src/index.ts index e757893c..1fbb9540 100644 --- a/protocol/src/index.ts +++ b/protocol/src/index.ts @@ -22,4 +22,5 @@ export * from './progression'; export * from './randomness'; export * from './receipt'; export * from './ruleset'; +export * from './signature'; export * from './snapshot'; diff --git a/protocol/src/receipt/index.ts b/protocol/src/receipt/index.ts index 25661de2..9cac1451 100644 --- a/protocol/src/receipt/index.ts +++ b/protocol/src/receipt/index.ts @@ -20,3 +20,11 @@ export { type ReceiptVerification, verifyReceiptConsistency, } from './verify'; +export { + receiptFromWire, + type WireBattleReceipt, + type WireBattleSnapshot, + type WirePetProgression, + type WirePetSnapshot, + type WireProgressionDelta, +} from './wire'; diff --git a/protocol/src/receipt/wire.ts b/protocol/src/receipt/wire.ts new file mode 100644 index 00000000..fa303569 --- /dev/null +++ b/protocol/src/receipt/wire.ts @@ -0,0 +1,78 @@ +import type { PetProgression } from '../progression/progression'; +import type { BattleSnapshot, PetSnapshot } from '../snapshot/types'; + +import type { BattleReceipt } from './types'; + +/** + * JSON wire form of a `BattleReceipt`: identical shape, except the bigint fields (pet id, + * dna, last-opponent id, source version) travel as decimal strings, since JSON has no + * bigint type. This is exactly what the backend's public receipt endpoints actually serve — + * `backend/src/features/battle-worker/sign.worker.ts` stores the receipt through a + * `JSON.stringify(receipt, bigint -> string)` replacer, and the read/corpus routes hand that + * stored payload back unchanged. + * + * `receiptFromWire` converts only; it does not validate. `assertBattleReceipt` is what makes + * a converted receipt trustworthy — a field that fails to convert cleanly (a non-numeric + * string handed to `BigInt(...)`) throws before that even runs, and everything else + * (ranges, hash consistency, chain-link shape) is `assertBattleReceipt`'s job, not this one's. + */ +export type WireBattleReceipt = Omit & { + snapshot: WireBattleSnapshot; + progression: WireProgressionDelta; +}; + +export type WireBattleSnapshot = Omit & { + attacker: WirePetSnapshot; + defender: WirePetSnapshot; +}; + +export type WirePetSnapshot = Omit & { + petId: string; + dna: string; + lastOpponentId: string; + sourceVersion: string; +}; + +export interface WireProgressionDelta { + attacker: WirePetProgression; + defender: WirePetProgression; +} + +export type WirePetProgression = Omit & { + petId: string; + lastOpponentId: string; +}; + +/** Converts a JSON-wire receipt (bigints as decimal strings) into a typed `BattleReceipt`. */ +export function receiptFromWire(wire: WireBattleReceipt): BattleReceipt { + return { + ...wire, + snapshot: { + ...wire.snapshot, + attacker: petSnapshotFromWire(wire.snapshot.attacker), + defender: petSnapshotFromWire(wire.snapshot.defender), + }, + progression: { + attacker: petProgressionFromWire(wire.progression.attacker), + defender: petProgressionFromWire(wire.progression.defender), + }, + }; +} + +function petSnapshotFromWire(pet: WirePetSnapshot): PetSnapshot { + return { + ...pet, + petId: BigInt(pet.petId), + dna: BigInt(pet.dna), + lastOpponentId: BigInt(pet.lastOpponentId), + sourceVersion: BigInt(pet.sourceVersion), + }; +} + +function petProgressionFromWire(pet: WirePetProgression): PetProgression { + return { + ...pet, + petId: BigInt(pet.petId), + lastOpponentId: BigInt(pet.lastOpponentId), + }; +} diff --git a/protocol/src/signature/address.ts b/protocol/src/signature/address.ts new file mode 100644 index 00000000..7b223999 --- /dev/null +++ b/protocol/src/signature/address.ts @@ -0,0 +1,51 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; + +import { bytesToHex, hexToBytes, type Hex } from '../encoding/bytes'; +import { keccak256 } from '../encoding/hash'; + +/** + * Recovers the Ethereum-style address that produced a raw secp256k1 ECDSA signature over + * `digest`. + * + * This exists so the standalone verifier (§H) can check the operator's signature over a + * receipt without depending on `ethers` or any other PolyForm-licensed package — the + * verifier's own dependency budget is `@cryptopets/protocol` and nothing else. The signature + * format matches what `backend/src/features/battle-signer` produces: 65 bytes, `r (32) || + * s (32) || v (1)` with `v` in `{27, 28}` (legacy, unprefixed — no EIP-155 chain id encoded + * into `v`, since this signs a receipt/commitment digest directly, never a transaction). + * + * `digest` must be the exact 32-byte value that was signed — this function does not hash + * or prefix its input in any way (there is deliberately no EIP-191 `personal_sign` prefix + * anywhere in this protocol's signing path; see `signer.local.ts`'s own comment for why). + */ +export function recoverAddress(digest: Hex, signature: Hex): Hex { + const digestBytes = hexToBytes(digest); + if (digestBytes.length !== 32) { + throw new Error(`expected a 32-byte digest, got ${digestBytes.length} bytes`); + } + const sigBytes = hexToBytes(signature); + if (sigBytes.length !== 65) { + throw new Error(`expected a 65-byte r||s||v signature, got ${sigBytes.length} bytes`); + } + + const r = sigBytes.slice(0, 32); + const s = sigBytes.slice(32, 64); + const v = sigBytes[64] as number; + const recovery = v >= 27 ? v - 27 : v; + if (recovery !== 0 && recovery !== 1) { + throw new Error(`signature recovery byte ${v} does not resolve to 0 or 1`); + } + + // noble's "recovered" wire format is recovery-byte-first, unlike Ethereum's + // r||s||v (recovery byte last) — reordered here rather than pushed onto every caller. + const recoveredFormat = new Uint8Array(65); + recoveredFormat[0] = recovery; + recoveredFormat.set(r, 1); + recoveredFormat.set(s, 33); + + const parsedSignature = secp256k1.Signature.fromBytes(recoveredFormat, 'recovered'); + const point = parsedSignature.recoverPublicKey(digestBytes); + const uncompressedPubkey = point.toBytes(false); + const addressBytes = keccak256(uncompressedPubkey.slice(1)).slice(-20); + return bytesToHex(addressBytes); +} diff --git a/protocol/src/signature/index.ts b/protocol/src/signature/index.ts new file mode 100644 index 00000000..8d59fb0c --- /dev/null +++ b/protocol/src/signature/index.ts @@ -0,0 +1 @@ +export { recoverAddress } from './address'; diff --git a/protocol/tests/receipt/wire.test.ts b/protocol/tests/receipt/wire.test.ts new file mode 100644 index 00000000..5a7a5719 --- /dev/null +++ b/protocol/tests/receipt/wire.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; + +import { simulate } from '../../src/combat'; +import type { Hex } from '../../src/encoding/bytes'; +import { computeProgression } from '../../src/progression'; +import { deriveBattleSeed, QUICKNET, roundTime } from '../../src/randomness'; +import { + assertBattleReceipt, + type BattleReceipt, + hashBattleReceipt, + hashCombatLog, + receiptFromWire, + type WireBattleReceipt, +} from '../../src/receipt'; +import { hashRuleset, SOURCE_DEFAULT_RULESET } from '../../src/ruleset'; +import { type BattleSnapshot, hashBattleSnapshot } from '../../src/snapshot'; + +/** Real quicknet round 1000 (tests/fixtures/drand.json), so beacon checks are genuine. */ +const BEACON = { + chainHash: QUICKNET.chainHash, + round: 1000, + signature: + '0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39' as Hex, + randomness: '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd' as Hex, +}; +const PUBLISHED_AT = roundTime(QUICKNET, BEACON.round); +const DOMAIN = { chainId: 'eip155:84532' as const, deploymentId: 'base-sepolia-live' }; +const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); + +const SNAPSHOT: BattleSnapshot = { + domain: DOMAIN, + attacker: { + petId: 1n, + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: 1234567890123456n, + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: 0n, + streak: 0, + readyAt: PUBLISHED_AT - 100, + sourceVersion: BigInt(PUBLISHED_AT - 50), + }, + defender: { + petId: 2n, + owner: '0x2222222222222222222222222222222222222222', + dna: 6543210987654321n, + rarity: 2, + level: 11, + skill: 7, + xp: 45, + lastOpponentId: 1n, + streak: 2, + readyAt: PUBLISHED_AT - 100, + sourceVersion: BigInt(PUBLISHED_AT - 50), + }, + takenAt: PUBLISHED_AT - 6, +}; + +function build(): BattleReceipt { + const seed = deriveBattleSeed({ + domain: DOMAIN, + drandRandomness: BEACON.randomness, + battleId: 'btl_0001', + snapshotHash: hashBattleSnapshot(SNAPSHOT), + rulesetHash: RULESET_HASH, + }); + const outcome = simulate( + SNAPSHOT.attacker.dna, + SNAPSHOT.attacker.rarity, + SNAPSHOT.attacker.level, + SNAPSHOT.attacker.skill, + SNAPSHOT.defender.dna, + SNAPSHOT.defender.rarity, + SNAPSHOT.defender.level, + SNAPSHOT.defender.skill, + seed.value, + SOURCE_DEFAULT_RULESET.skillConfig, + ); + return { + domain: DOMAIN, + battleId: 'btl_0001', + intentHash: `0x${'11'.repeat(32)}`, + commitmentHash: `0x${'22'.repeat(32)}`, + defenseAuthorizationHash: `0x${'33'.repeat(32)}`, + snapshot: SNAPSHOT, + beacon: BEACON, + seed: seed.hex, + rulesetVersion: SOURCE_DEFAULT_RULESET.version, + rulesetHash: RULESET_HASH, + result: { + attackerWon: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + }, + combatLogHash: hashCombatLog(outcome), + progression: computeProgression(SNAPSHOT, outcome.result.firstWins), + sequence: 1, + previousReceiptHash: null, + attackerPreviousReceiptHash: null, + defenderPreviousReceiptHash: null, + createdAt: PUBLISHED_AT + 1, + signingKeyId: 'battle-signer-2026-07', + }; +} + +/** The exact replacer `sign.worker.ts` stores receipts through: bigint -> decimal string. */ +function toWireJson(receipt: BattleReceipt): unknown { + return JSON.parse(JSON.stringify(receipt, (_key, value) => (typeof value === 'bigint' ? value.toString() : value))); +} + +describe('receiptFromWire', () => { + const receipt = build(); + + it('round-trips a receipt through the exact JSON shape the backend serves', () => { + const wire = toWireJson(receipt) as WireBattleReceipt; + const restored = receiptFromWire(wire); + + expect(restored).toEqual(receipt); + expect(hashBattleReceipt(restored)).toBe(hashBattleReceipt(receipt)); + expect(() => assertBattleReceipt(restored)).not.toThrow(); + }); + + it('converts every bigint field independently, not just petId', () => { + const wire = toWireJson(receipt) as WireBattleReceipt; + expect(wire.snapshot.attacker.dna).toBe('1234567890123456'); + expect(wire.snapshot.defender.lastOpponentId).toBe('1'); + expect(wire.snapshot.attacker.sourceVersion).toBe(String(receipt.snapshot.attacker.sourceVersion)); + expect(wire.progression.defender.lastOpponentId).toBe(String(receipt.progression.defender.lastOpponentId)); + + const restored = receiptFromWire(wire); + expect(restored.snapshot.attacker.dna).toBe(1234567890123456n); + expect(typeof restored.progression.attacker.petId).toBe('bigint'); + }); + + it('throws on a bigint field that did not survive the wire cleanly', () => { + const wire = toWireJson(receipt) as WireBattleReceipt; + wire.snapshot.attacker.dna = 'not-a-number'; + expect(() => receiptFromWire(wire)).toThrow(); + }); +}); diff --git a/protocol/tests/signature/address.test.ts b/protocol/tests/signature/address.test.ts new file mode 100644 index 00000000..51179b6c --- /dev/null +++ b/protocol/tests/signature/address.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import type { Hex } from '../../src/encoding/bytes'; +import { recoverAddress } from '../../src/signature/address'; + +/** + * Fixture generated once with `ethers.SigningKey(privateKey).sign(digest)` for private key + * `0x1111...1111` (32 bytes of `0x11`) over `keccak256(utf8("cryptopets-protocol-signature-fixture"))`. + * Committed as a literal so this test needs no `ethers` dependency at all — the whole point + * of `recoverAddress` is to not need one. + */ +const DIGEST: Hex = '0x51280f91d621920c44441165ae6e38817dc118a90bcf6427d818a8d85239bf5e'; +const SIGNATURE: Hex = + '0xfb79273ae8cb0676caff20fc405cf3fbf42d7d45df727755a6d87934343373803efc722962a89bccbc8742768428c4f554c335230b46516aeb4b550626c5314b1b'; +const EXPECTED_ADDRESS: Hex = '0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a'; + +describe('recoverAddress', () => { + it('recovers the signing address from a real ethers-produced signature', () => { + expect(recoverAddress(DIGEST, SIGNATURE)).toBe(EXPECTED_ADDRESS); + }); + + it('recovers a different address for a different digest under the same signature', () => { + const otherDigest: Hex = `0x${'22'.repeat(32)}`; + expect(recoverAddress(otherDigest, SIGNATURE)).not.toBe(EXPECTED_ADDRESS); + }); + + it('rejects a digest that is not 32 bytes', () => { + expect(() => recoverAddress('0x1234', SIGNATURE)).toThrow(/32-byte digest/); + }); + + it('rejects a signature that is not 65 bytes', () => { + expect(() => recoverAddress(DIGEST, '0x1234')).toThrow(/65-byte/); + }); + + it('rejects a signature whose recovery byte is out of range', () => { + const bytes = `${SIGNATURE.slice(0, -2)}ff` as Hex; + expect(() => recoverAddress(DIGEST, bytes)).toThrow(/recovery byte/); + }); + + it('accepts a recovery byte already in {0, 1} form, not only {27, 28}', () => { + // The fixture's v byte is 0x1b (27) => recovery=0; the {0,1} spelling of the same + // recovery bit must recover the same address. + const zeroOneForm = `${SIGNATURE.slice(0, -2)}00` as Hex; + expect(recoverAddress(DIGEST, zeroOneForm)).toBe(recoverAddress(DIGEST, SIGNATURE)); + }); +}); diff --git a/verifier/LICENSE b/verifier/LICENSE new file mode 100644 index 00000000..89dab1bc --- /dev/null +++ b/verifier/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 RadCrew + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/verifier/README.md b/verifier/README.md new file mode 100644 index 00000000..75c8e520 --- /dev/null +++ b/verifier/README.md @@ -0,0 +1,77 @@ +# @cryptopets/verifier + +The standalone public verifier for CryptoPets backend-authoritative battle receipts (§H of +[docs/plan-backend-battle-architecture.md](../docs/plan-backend-battle-architecture.md)). + +Sequencing: [docs/plan-backend-battle-steps.md](../docs/plan-backend-battle-steps.md), Group F. + +## Why this exists, and why it is MIT + +A backend that decides battle outcomes has to be checkable by someone other than the backend +itself. §H's argument is that anyone can take a signed receipt, recompute the fight from its own +published inputs, and compare — but that is only real if outsiders can actually run the checking +code. A verifier that requires a PolyForm Noncommercial license is not a verifier, it is a claim. +So this package is **MIT**, and it depends on nothing but `@cryptopets/protocol` (also MIT). No +backend access, no database — every check runs against a receipt's own contents plus whatever +public inputs the caller supplies (a signing-key list, and — once Step 31 lands — a live drand +endpoint). + +## What is checked so far + +This is Step 30: the two checks that need nothing but the receipts themselves. + +- **Operator signature** (`checkOperatorSignature`). Recomputes the receipt's own hash, recovers + the address that produced the stored ECDSA signature over it (`recoverAddress`, pure + `@noble/curves` secp256k1 recovery — no `ethers` dependency), and checks it against a + caller-supplied trusted key list. A receipt whose signing key is not in that list, or whose + `createdAt` falls outside the key's published validity window, fails closed. +- **Hash-chain continuity** (`checkChainContinuity`). Wraps `verifyReceiptChain` from + `@cryptopets/protocol`: every receipt in a run links to its predecessor's real hash, sequence + numbers are consecutive, and nothing is out of order. + +**Not yet covered** (Step 31): the drand BLS beacon signature, seed derivation, replaying the +actual fight from the combat log, and recomputing the progression delta. `verifyReceiptConsistency` +in `@cryptopets/protocol` already implements the beacon and progression halves of that; Step 31 +wires those in here alongside the combat replay itself and turns everything into the +per-check pass/fail CLI output §H describes. + +## Usage + +```bash +# Programmatic +pnpm --filter @cryptopets/verifier exec tsx -e " + import { loadReceipts, loadSigningKeys, verifyReceipts } from './src/index.ts'; + const envelopes = await loadReceipts('./some-receipt.json'); + const keys = await loadSigningKeys('https://api.example.com/api/battle/signing-keys'); + console.log(verifyReceipts(envelopes, keys)); +" + +# CLI (dev, via tsx — see the note below on packaging) +pnpm --filter @cryptopets/verifier cli -- ./some-receipt.json --keys ./trusted-keys.json +pnpm --filter @cryptopets/verifier cli -- https://api.example.com/api/receipts?signingKeyId=battle-signer-2026-07 --keys https://api.example.com/api/battle/signing-keys +``` + +`loadReceipts` accepts a local file path or an `http(s)` URL, and any of the shapes +`backend/API.md` actually serves: a single receipt (`GET /api/battle/:battleId/receipt`), a +corpus page (`GET /api/receipts/...`), or a bare array. `loadSigningKeys` accepts +`GET /api/battle/signing-keys`'s `{ keys: [...] }` shape, or a bare array for a hand-written +trust file. + +Omitting `--keys` does not skip the operator-signature check — it means no key is trusted, so +every receipt fails that check rather than silently passing one nobody actually verified. + +## Consumption + +Raw TypeScript, no build step, same as `@cryptopets/protocol` — this package is a workspace +dependent of `protocol`, not the other way around. There is deliberately no `bin` entry or +publish-ready build yet: packaging this as an installable CLI (`npx @cryptopets/verifier ...`) +is later work, once there is an actual third party to hand it to. For now, run it from within +this workspace via `tsx`. + +## Commands + +```bash +pnpm --filter @cryptopets/verifier test # vitest +pnpm --filter @cryptopets/verifier lint # eslint +pnpm --filter @cryptopets/verifier typecheck # tsc --noEmit +``` diff --git a/verifier/eslint.config.js b/verifier/eslint.config.js new file mode 100644 index 00000000..8d759462 --- /dev/null +++ b/verifier/eslint.config.js @@ -0,0 +1,39 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import importPlugin from 'eslint-plugin-import'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['node_modules/', 'coverage/'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts'], + languageOptions: { + ecmaVersion: 'latest', + // A Node CLI, not a protocol module: unlike `protocol`, this package does real + // I/O (reads files, fetches URLs) and reads the clock freely, so none of + // `protocol`'s determinism restrictions apply here. + globals: { ...globals.node }, + }, + plugins: { + import: importPlugin, + }, + rules: { + 'import/no-unresolved': 'off', + 'import/no-duplicates': 'error', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'error', + 'prefer-const': 'error', + 'semi': ['error', 'always'], + 'arrow-spacing': ['error', { before: true, after: true }], + }, + }, + { + files: ['tests/**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, +); diff --git a/verifier/package.json b/verifier/package.json new file mode 100644 index 00000000..e9a4a8c7 --- /dev/null +++ b/verifier/package.json @@ -0,0 +1,38 @@ +{ + "name": "@cryptopets/verifier", + "version": "0.0.1", + "description": "Standalone MIT verifier for CryptoPets backend-authoritative battle receipts (docs/plan-backend-battle-architecture.md §H). No backend access, no database: every check runs against a receipt's own published contents plus public inputs (drand, a signing-key list) the caller supplies.", + "license": "MIT", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "cli": "tsx src/cli.ts", + "lint": "pnpm exec eslint .", + "lint:fix": "pnpm exec eslint . --fix", + "typecheck": "pnpm exec tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@cryptopets/protocol": "workspace:*" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@types/node": "^22.18.6", + "@vitest/coverage-v8": "^4.1.8", + "eslint": "^9.36.0", + "eslint-plugin-import": "^2.31.0", + "globals": "^16.4.0", + "tsx": "^4.20.6", + "typescript": "~5.8.3", + "typescript-eslint": "^8.44.0", + "vitest": "^4.1.8" + } +} diff --git a/verifier/src/checks/chainContinuity.ts b/verifier/src/checks/chainContinuity.ts new file mode 100644 index 00000000..99e918c5 --- /dev/null +++ b/verifier/src/checks/chainContinuity.ts @@ -0,0 +1,24 @@ +import { type BattleReceipt, type Hex, verifyReceiptChain } from '@cryptopets/protocol'; + +import type { CheckResult } from './types'; + +/** + * Checks hash-chain continuity across a run of receipts under one signing key (§G, §H item 1): + * every receipt links to its predecessor's real hash, sequence numbers are consecutive, and no + * battle id or timestamp is out of place. + * + * Like the operator-signature check, this needs nothing beyond the receipts themselves — + * `verifyReceiptChain` does the actual walk (`protocol/src/receipt/chain.ts`); this just adapts + * its result into the same `CheckResult` shape every other check reports. + * + * `expectedAnchor` is `null` to assert the run starts at the key's very first receipt, a hash to + * continue an earlier window, or omitted to check only the run's own internal continuity. + */ +export function checkChainContinuity(receipts: readonly BattleReceipt[], expectedAnchor?: Hex | null): CheckResult { + const check = 'chain-continuity'; + const result = verifyReceiptChain(receipts, expectedAnchor); + if (result.ok) { + return { check, ok: true }; + } + return { check, ok: false, detail: `receipt at index ${result.index} (battleId ${receipts[result.index]?.battleId}): ${result.reason}` }; +} diff --git a/verifier/src/checks/index.ts b/verifier/src/checks/index.ts new file mode 100644 index 00000000..18f5df10 --- /dev/null +++ b/verifier/src/checks/index.ts @@ -0,0 +1,3 @@ +export { checkChainContinuity } from './chainContinuity'; +export { checkOperatorSignature } from './operatorSignature'; +export type { CheckResult } from './types'; diff --git a/verifier/src/checks/operatorSignature.ts b/verifier/src/checks/operatorSignature.ts new file mode 100644 index 00000000..eb74d213 --- /dev/null +++ b/verifier/src/checks/operatorSignature.ts @@ -0,0 +1,73 @@ +import { type BattleReceipt, hashBattleReceipt, type Hex, recoverAddress } from '@cryptopets/protocol'; + +import type { SignedReceiptEnvelope, TrustedSigningKey } from '../io/types'; +import type { CheckResult } from './types'; + +/** + * Checks that a receipt's operator signature actually verifies against a trusted, published + * signing key (§A's "operator signature -> verify against a published key" row, §H item 1). + * + * This needs nothing beyond the receipt itself and the caller-supplied trusted key list: no + * drand round, no combat replay, no backend access. It is the cheapest real check there is, + * which is why it runs first. + * + * Deliberately not covered here: the drand BLS signature (needs the beacon), the combat + * replay (needs the ruleset and the log), and the progression recomputation (needs the + * ruleset's level cap) — all of those are `verifyReceiptConsistency` / the full-replay checks + * this package adds next, not this one. + */ +export function checkOperatorSignature( + envelope: SignedReceiptEnvelope, + receipt: BattleReceipt, + trustedKeys: readonly TrustedSigningKey[], +): CheckResult { + const check = 'operator-signature'; + + if (receipt.signingKeyId !== envelope.signingKeyId) { + return { + check, + ok: false, + detail: `envelope names signing key ${envelope.signingKeyId}, but the receipt payload names ${receipt.signingKeyId}`, + }; + } + + const key = trustedKeys.find((candidate) => candidate.keyId === envelope.signingKeyId); + if (!key) { + return { check, ok: false, detail: `signing key ${envelope.signingKeyId} is not in the trusted key list` }; + } + if (key.notBefore !== undefined && receipt.createdAt < key.notBefore) { + return { + check, + ok: false, + detail: `receipt created at ${receipt.createdAt}, before key ${key.keyId} became valid at ${key.notBefore}`, + }; + } + if (key.notAfter != null && receipt.createdAt > key.notAfter) { + return { + check, + ok: false, + detail: `receipt created at ${receipt.createdAt}, after key ${key.keyId} retired at ${key.notAfter}`, + }; + } + + const digest = hashBattleReceipt(receipt); + if (digest.toLowerCase() !== envelope.receiptHash.toLowerCase()) { + return { + check, + ok: false, + detail: `envelope's receiptHash ${envelope.receiptHash} does not match the recomputed digest ${digest}`, + }; + } + + let recovered: Hex; + try { + recovered = recoverAddress(digest, envelope.signature as Hex); + } catch (error) { + return { check, ok: false, detail: (error as Error).message }; + } + if (recovered.toLowerCase() !== key.address.toLowerCase()) { + return { check, ok: false, detail: `signature recovers to ${recovered}, not ${key.address}` }; + } + + return { check, ok: true }; +} diff --git a/verifier/src/checks/types.ts b/verifier/src/checks/types.ts new file mode 100644 index 00000000..c335028e --- /dev/null +++ b/verifier/src/checks/types.ts @@ -0,0 +1,6 @@ +/** One check's outcome: which check, whether it passed, and detail on failure. */ +export interface CheckResult { + check: string; + ok: boolean; + detail?: string; +} diff --git a/verifier/src/cli.ts b/verifier/src/cli.ts new file mode 100644 index 00000000..d37ec459 --- /dev/null +++ b/verifier/src/cli.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { loadReceipts, loadSigningKeys } from './io'; +import { verifyReceipts } from './verify'; + +/** + * `cryptopets-verify [--keys ]` + * + * Prints one pass/fail line per check and exits non-zero if any check failed. Omitting + * `--keys` is not "skip the signature check" — it means no key is trusted, so every + * receipt's operator-signature check fails closed rather than silently passing. + */ +async function main(): Promise { + const args = process.argv.slice(2); + const receiptSource = args[0]; + if (!receiptSource) { + console.error('usage: cryptopets-verify [--keys ]'); + process.exitCode = 1; + return; + } + const keysFlagIndex = args.indexOf('--keys'); + const keysSource = keysFlagIndex >= 0 ? args[keysFlagIndex + 1] : undefined; + + const envelopes = await loadReceipts(receiptSource); + const trustedKeys = keysSource ? await loadSigningKeys(keysSource) : []; + + const report = verifyReceipts(envelopes, trustedKeys); + for (const result of report.results) { + const status = result.ok ? 'PASS' : 'FAIL'; + console.log(`[${status}] ${result.check}${result.detail ? `: ${result.detail}` : ''}`); + } + process.exitCode = report.ok ? 0 : 1; +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/verifier/src/index.ts b/verifier/src/index.ts new file mode 100644 index 00000000..bd410a76 --- /dev/null +++ b/verifier/src/index.ts @@ -0,0 +1,14 @@ +/** + * Standalone MIT verifier for CryptoPets backend-authoritative battle receipts + * (docs/plan-backend-battle-architecture.md §H). Depends only on `@cryptopets/protocol`: + * no backend access, no database. + * + * See the package README for what is and is not covered yet — checks land in the order + * set by docs/plan-backend-battle-steps.md (Step 30: operator signature and hash-chain + * continuity; Step 31 adds the drand beacon, seed derivation, combat replay, and + * progression checks). + */ + +export * from './checks'; +export * from './io'; +export * from './verify'; diff --git a/verifier/src/io/index.ts b/verifier/src/io/index.ts new file mode 100644 index 00000000..bf84f506 --- /dev/null +++ b/verifier/src/io/index.ts @@ -0,0 +1,4 @@ +export { loadReceipts } from './loadReceipts'; +export { loadSigningKeys } from './loadSigningKeys'; +export { readJsonFrom } from './source'; +export type { SignedReceiptEnvelope, TrustedSigningKey } from './types'; diff --git a/verifier/src/io/loadReceipts.ts b/verifier/src/io/loadReceipts.ts new file mode 100644 index 00000000..552990ba --- /dev/null +++ b/verifier/src/io/loadReceipts.ts @@ -0,0 +1,52 @@ +import type { WireBattleReceipt } from '@cryptopets/protocol'; + +import { readJsonFrom } from './source'; +import type { SignedReceiptEnvelope } from './types'; +import { firstString, isRecord, requireString } from './util'; + +/** + * Loads one or more signed receipts from a local file path or an `http(s)` URL. + * + * Accepts every shape the backend actually serves (`backend/API.md`): a single receipt + * (`GET /api/battle/:battleId/receipt`, `{ hash, signature, signingKeyId, payload }`), a + * corpus page (`GET /api/receipts/...`, `{ receipts: [...], nextCursor }`), or a bare + * array of receipt entries — including one saved to a local file by hand. + */ +export async function loadReceipts(source: string): Promise { + const json = await readJsonFrom(source); + return normalizeReceipts(json); +} + +function normalizeReceipts(json: unknown): SignedReceiptEnvelope[] { + if (Array.isArray(json)) { + return json.map(normalizeOne); + } + if (isRecord(json) && Array.isArray(json.receipts)) { + return json.receipts.map(normalizeOne); + } + if (isRecord(json)) { + return [normalizeOne(json)]; + } + throw new Error('receipt source did not contain a receipt, a receipt array, or a corpus page'); +} + +function normalizeOne(value: unknown): SignedReceiptEnvelope { + if (!isRecord(value)) { + throw new Error('a receipt entry must be an object'); + } + // The single-receipt endpoint spells this field `hash`; the corpus routes spell it + // `receiptHash`. Both are accepted rather than picking one and forcing callers to + // reshape whichever endpoint they used. + const receiptHash = firstString(value, ['receiptHash', 'hash']); + const signature = requireString(value, 'signature'); + const signingKeyId = requireString(value, 'signingKeyId'); + if (!('payload' in value)) { + throw new Error(`receipt ${receiptHash} is missing its payload`); + } + return { + receiptHash, + signature, + signingKeyId, + payload: value.payload as WireBattleReceipt, + }; +} diff --git a/verifier/src/io/loadSigningKeys.ts b/verifier/src/io/loadSigningKeys.ts new file mode 100644 index 00000000..3c7088c6 --- /dev/null +++ b/verifier/src/io/loadSigningKeys.ts @@ -0,0 +1,32 @@ +import { readJsonFrom } from './source'; +import type { TrustedSigningKey } from './types'; +import { isRecord, requireString } from './util'; + +/** + * Loads the trusted signing-key list from a local file or the `GET /api/battle/signing-keys` + * URL (`{ keys: [...] }`) — or a bare array, for a hand-written trust file. + */ +export async function loadSigningKeys(source: string): Promise { + const json = await readJsonFrom(source); + const list = Array.isArray(json) ? json : isRecord(json) && Array.isArray(json.keys) ? json.keys : undefined; + if (!list) { + throw new Error('signing-key source did not contain a key array or a { keys: [...] } object'); + } + return list.map(normalizeKey); +} + +function normalizeKey(value: unknown): TrustedSigningKey { + if (!isRecord(value)) { + throw new Error('a signing-key entry must be an object'); + } + const keyId = requireString(value, 'keyId'); + const address = requireString(value, 'address'); + const key: TrustedSigningKey = { keyId, address }; + if (typeof value.notBefore === 'number') { + key.notBefore = value.notBefore; + } + if (typeof value.notAfter === 'number' || value.notAfter === null) { + key.notAfter = value.notAfter; + } + return key; +} diff --git a/verifier/src/io/source.ts b/verifier/src/io/source.ts new file mode 100644 index 00000000..e62849f7 --- /dev/null +++ b/verifier/src/io/source.ts @@ -0,0 +1,21 @@ +import { readFile } from 'node:fs/promises'; + +/** + * Reads and JSON-parses `source`, which may be a local file path or an `http(s)` URL. + * + * This is the entire network/filesystem surface this package touches. No backend + * access and no database means the verifier only ever consumes whatever a receipt file + * or a public endpoint hands back — the same public inputs any other outsider running + * this same check would have (§H). + */ +export async function readJsonFrom(source: string): Promise { + if (source.startsWith('http://') || source.startsWith('https://')) { + const response = await fetch(source); + if (!response.ok) { + throw new Error(`fetching ${source} failed: ${response.status} ${response.statusText}`); + } + return response.json(); + } + const text = await readFile(source, 'utf8'); + return JSON.parse(text); +} diff --git a/verifier/src/io/types.ts b/verifier/src/io/types.ts new file mode 100644 index 00000000..4fd0383a --- /dev/null +++ b/verifier/src/io/types.ts @@ -0,0 +1,32 @@ +import type { WireBattleReceipt } from '@cryptopets/protocol'; + +/** + * One signed receipt, exactly as the backend's read/corpus endpoints serve it + * (`backend/API.md`'s `SignedArtifact` / `ReceiptSummary` shapes): the operator's ECDSA + * signature and the signing key it claims, alongside the receipt payload itself. + * + * The signature and `receiptHash` live outside `BattleReceipt` on purpose — the protocol + * package defines what a receipt *is*, not how an operator's signature over one is + * transported, so that stays a wire-layer concern here rather than a protocol-schema one. + */ +export interface SignedReceiptEnvelope { + receiptHash: string; + signature: string; + signingKeyId: string; + payload: WireBattleReceipt; +} + +/** + * One entry the verifier is willing to trust as having produced a given signature, as + * published by `GET /api/battle/signing-keys` (§G) — or supplied by hand for fully + * offline verification. `notBefore`/`notAfter` are optional because a hand-written key + * file may simply omit a validity window; when present, `checkOperatorSignature` holds + * the receipt's own `createdAt` to it. + */ +export interface TrustedSigningKey { + keyId: string; + /** EVM address form; compared case-insensitively. */ + address: string; + notBefore?: number; + notAfter?: number | null; +} diff --git a/verifier/src/io/util.ts b/verifier/src/io/util.ts new file mode 100644 index 00000000..ee02a5b3 --- /dev/null +++ b/verifier/src/io/util.ts @@ -0,0 +1,24 @@ +/** Narrows an untrusted JSON value to a plain object before indexing into it. */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Reads a required string field, throwing a message that names the field and the culprit. */ +export function requireString(value: Record, field: string): string { + const raw = value[field]; + if (typeof raw !== 'string') { + throw new Error(`expected a string field "${field}", got ${JSON.stringify(raw)}`); + } + return raw; +} + +/** Reads the first field present as a string, for wire shapes that spell one field two ways. */ +export function firstString(value: Record, fields: readonly string[]): string { + for (const field of fields) { + const raw = value[field]; + if (typeof raw === 'string') { + return raw; + } + } + throw new Error(`expected one of ${fields.join('/')} to be a string field`); +} diff --git a/verifier/src/verify.ts b/verifier/src/verify.ts new file mode 100644 index 00000000..b9e51438 --- /dev/null +++ b/verifier/src/verify.ts @@ -0,0 +1,51 @@ +import { assertBattleReceipt, type BattleReceipt, receiptFromWire } from '@cryptopets/protocol'; + +import { checkChainContinuity, checkOperatorSignature, type CheckResult } from './checks'; +import type { SignedReceiptEnvelope, TrustedSigningKey } from './io'; + +export interface VerifyReceiptsReport { + results: CheckResult[]; + ok: boolean; +} + +/** + * Runs every check this step covers over a set of signed receipt envelopes: the operator + * signature per receipt, then hash-chain continuity across the whole run (§H item 1). Both + * need nothing beyond the receipts themselves and a trusted key list — no drand round, no + * combat replay, no backend access. + * + * A receipt that fails to parse, or fails its own internal consistency check + * (`assertBattleReceipt` — malformed hashes, a seed that does not follow from its own + * inputs, and so on), is reported as a `malformed-receipt` failure and excluded from the + * chain-continuity walk, since that walk assumes every receipt in the run is at least + * well-formed to begin with. + */ +export function verifyReceipts( + envelopes: readonly SignedReceiptEnvelope[], + trustedKeys: readonly TrustedSigningKey[], +): VerifyReceiptsReport { + const results: CheckResult[] = []; + const receipts: BattleReceipt[] = []; + + for (const envelope of envelopes) { + let receipt: BattleReceipt; + try { + receipt = assertBattleReceipt(receiptFromWire(envelope.payload)); + } catch (error) { + results.push({ + check: 'malformed-receipt', + ok: false, + detail: `${envelope.receiptHash}: ${(error as Error).message}`, + }); + continue; + } + receipts.push(receipt); + results.push(checkOperatorSignature(envelope, receipt, trustedKeys)); + } + + if (receipts.length > 0) { + results.push(checkChainContinuity(receipts)); + } + + return { results, ok: results.every((result) => result.ok) }; +} diff --git a/verifier/tests/checks/chainContinuity.test.ts b/verifier/tests/checks/chainContinuity.test.ts new file mode 100644 index 00000000..4bbc5450 --- /dev/null +++ b/verifier/tests/checks/chainContinuity.test.ts @@ -0,0 +1,58 @@ +import { hashBattleReceipt } from '@cryptopets/protocol'; +import { describe, expect, it } from 'vitest'; + +import { checkChainContinuity } from '../../src/checks/chainContinuity'; +import { buildReceipt } from '../fixtures/signedReceipt'; + +describe('checkChainContinuity', () => { + it('passes an unbroken run', () => { + const first = buildReceipt({ battleId: 'btl_0001' }); + const second = buildReceipt({ + battleId: 'btl_0002', + sequence: 2, + previousReceiptHash: hashBattleReceipt(first), + createdAt: first.createdAt + 1, + }); + expect(checkChainContinuity([first, second])).toEqual({ check: 'chain-continuity', ok: true }); + }); + + it('fails and names the offending index and battle id on a broken link', () => { + const first = buildReceipt({ battleId: 'btl_0001' }); + const third = buildReceipt({ + battleId: 'btl_0003', + sequence: 3, + previousReceiptHash: `0x${'99'.repeat(32)}`, + createdAt: first.createdAt + 2, + }); + const result = checkChainContinuity([first, third]); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/index 1/); + expect(result.detail).toMatch(/btl_0003/); + expect(result.detail).toMatch(/broken-link/); + }); + + it('fails on a sequence gap, which is what a withheld receipt looks like', () => { + const first = buildReceipt({ battleId: 'btl_0001' }); + const skipped = buildReceipt({ + battleId: 'btl_0003', + sequence: 3, + previousReceiptHash: hashBattleReceipt(first), + createdAt: first.createdAt + 1, + }); + const result = checkChainContinuity([first, skipped]); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/sequence-not-consecutive/); + }); + + it('rejects the wrong anchor when one is asserted, and passes when none is given', () => { + const first = buildReceipt({ battleId: 'btl_0001' }); + const second = buildReceipt({ + battleId: 'btl_0002', + sequence: 2, + previousReceiptHash: hashBattleReceipt(first), + createdAt: first.createdAt + 1, + }); + expect(checkChainContinuity([second], `0x${'99'.repeat(32)}`).ok).toBe(false); + expect(checkChainContinuity([second]).ok).toBe(true); + }); +}); diff --git a/verifier/tests/checks/operatorSignature.test.ts b/verifier/tests/checks/operatorSignature.test.ts new file mode 100644 index 00000000..b2108628 --- /dev/null +++ b/verifier/tests/checks/operatorSignature.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { checkOperatorSignature } from '../../src/checks/operatorSignature'; +import { buildSignedReceipt, TEST_SIGNING_KEY_ID, testSigningAddress } from '../fixtures/signedReceipt'; + +describe('checkOperatorSignature', () => { + it('passes a receipt genuinely signed by a trusted key', () => { + const { receipt, envelope, trustedKey } = buildSignedReceipt(); + expect(checkOperatorSignature(envelope, receipt, [trustedKey])).toEqual({ + check: 'operator-signature', + ok: true, + }); + }); + + it('fails when the signing key is not in the trusted list at all', () => { + const { receipt, envelope } = buildSignedReceipt(); + const result = checkOperatorSignature(envelope, receipt, []); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/not in the trusted key list/); + }); + + it('fails when the envelope and payload disagree about which key signed', () => { + const { receipt, envelope, trustedKey } = buildSignedReceipt(); + const mismatched = { ...envelope, signingKeyId: 'some-other-key' }; + const result = checkOperatorSignature(mismatched, receipt, [trustedKey, { ...trustedKey, keyId: 'some-other-key' }]); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/envelope names signing key/); + }); + + it('fails when the receiptHash does not match the recomputed digest', () => { + const { receipt, envelope, trustedKey } = buildSignedReceipt(); + const tampered = { ...envelope, receiptHash: `0x${'ff'.repeat(32)}` }; + const result = checkOperatorSignature(tampered, receipt, [trustedKey]); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/does not match the recomputed digest/); + }); + + it('fails when the signature does not recover to the trusted address', () => { + const { receipt, envelope, trustedKey } = buildSignedReceipt(); + const wrongAddress = { ...trustedKey, address: '0x1111111111111111111111111111111111111111' }; + const result = checkOperatorSignature(envelope, receipt, [wrongAddress]); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/signature recovers to/); + }); + + it('fails a malformed signature rather than throwing', () => { + const { receipt, envelope, trustedKey } = buildSignedReceipt(); + const malformed = { ...envelope, signature: '0x1234' }; + expect(() => checkOperatorSignature(malformed, receipt, [trustedKey])).not.toThrow(); + expect(checkOperatorSignature(malformed, receipt, [trustedKey]).ok).toBe(false); + }); + + it('fails when the receipt was created before the key became valid', () => { + const { receipt, envelope, trustedKey } = buildSignedReceipt(); + const notYetValid = { ...trustedKey, notBefore: receipt.createdAt + 1 }; + const result = checkOperatorSignature(envelope, receipt, [notYetValid]); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/before key .* became valid/); + }); + + it('fails when the receipt was created after the key retired', () => { + const { receipt, envelope, trustedKey } = buildSignedReceipt(); + const retired = { ...trustedKey, notAfter: receipt.createdAt - 1 }; + const result = checkOperatorSignature(envelope, receipt, [retired]); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/after key .* retired/); + }); + + it('passes when the receipt falls inside an explicit validity window', () => { + const { receipt, envelope, trustedKey } = buildSignedReceipt(); + const windowed = { ...trustedKey, notBefore: receipt.createdAt - 10, notAfter: receipt.createdAt + 10 }; + expect(checkOperatorSignature(envelope, receipt, [windowed]).ok).toBe(true); + }); + + it('matches keys by keyId regardless of address casing', () => { + const { receipt, envelope, trustedKey } = buildSignedReceipt(); + const upper = { ...trustedKey, address: trustedKey.address.toUpperCase() }; + expect(checkOperatorSignature(envelope, receipt, [upper]).ok).toBe(true); + }); + + it('derives the address the same way the fixture computed it', () => { + // Sanity check on the fixture itself, not the check under test: if this ever + // disagrees, every other assertion here would be passing for the wrong reason. + const { trustedKey } = buildSignedReceipt(); + expect(trustedKey.keyId).toBe(TEST_SIGNING_KEY_ID); + expect(trustedKey.address).toBe(testSigningAddress()); + }); +}); diff --git a/verifier/tests/fixtures/signedReceipt.ts b/verifier/tests/fixtures/signedReceipt.ts new file mode 100644 index 00000000..a46c7c30 --- /dev/null +++ b/verifier/tests/fixtures/signedReceipt.ts @@ -0,0 +1,190 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { keccak_256 } from '@noble/hashes/sha3'; + +import { + type BattleReceipt, + type BattleSnapshot, + computeProgression, + deriveBattleSeed, + hashBattleReceipt, + hashBattleSnapshot, + hashCombatLog, + hashRuleset, + type Hex, + QUICKNET, + roundTime, + simulate, + SOURCE_DEFAULT_RULESET, +} from '@cryptopets/protocol'; + +import type { SignedReceiptEnvelope, TrustedSigningKey } from '../../src/io/types'; + +/** + * Builds a real, internally-consistent `BattleReceipt` plus a real secp256k1 signature over + * it — via `@noble/curves` directly, not `ethers` — so tests exercise the same math + * `recoverAddress` implements rather than a fixture the checks were written to fit. + * + * The private key is a fixed, throwaway test constant; deterministic ECDSA (RFC6979) means + * the same key + digest always produces the same signature, so this fixture is stable + * across runs without needing `Math.random`. + */ + +const BEACON = { + chainHash: QUICKNET.chainHash, + round: 1000, + signature: + '0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39' as Hex, + randomness: '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd' as Hex, +}; +const PUBLISHED_AT = roundTime(QUICKNET, BEACON.round); +const DOMAIN = { chainId: 'eip155:84532' as const, deploymentId: 'base-sepolia-live' }; +const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); + +const SNAPSHOT: BattleSnapshot = { + domain: DOMAIN, + attacker: { + petId: 1n, + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: 1234567890123456n, + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: 0n, + streak: 0, + readyAt: PUBLISHED_AT - 100, + sourceVersion: BigInt(PUBLISHED_AT - 50), + }, + defender: { + petId: 2n, + owner: '0x2222222222222222222222222222222222222222', + dna: 6543210987654321n, + rarity: 2, + level: 11, + skill: 7, + xp: 45, + lastOpponentId: 1n, + streak: 2, + readyAt: PUBLISHED_AT - 100, + sourceVersion: BigInt(PUBLISHED_AT - 50), + }, + takenAt: PUBLISHED_AT - 6, +}; + +export const TEST_PRIVATE_KEY = `0x${'11'.repeat(32)}` as Hex; +export const TEST_SIGNING_KEY_ID = 'battle-signer-2026-07'; + +function hexToBytes(hex: string): Uint8Array { + const clean = hex.slice(2); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function bytesToHex(bytes: Uint8Array): Hex { + return `0x${Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join('')}` as Hex; +} + +/** The EVM address for `TEST_PRIVATE_KEY`, computed the same way `recoverAddress` verifies it. */ +export function testSigningAddress(): Hex { + const publicKey = secp256k1.getPublicKey(hexToBytes(TEST_PRIVATE_KEY), false); + return bytesToHex(keccak_256(publicKey.slice(1)).slice(-20)); +} + +/** + * Signs `digest` with `TEST_PRIVATE_KEY`, producing the same r||s||v(27/28) wire format the + * backend's signer produces. + */ +export function signWithTestKey(digest: Hex): Hex { + const signature = secp256k1.sign(hexToBytes(digest), hexToBytes(TEST_PRIVATE_KEY)); + const recovered = signature.toBytes('recovered'); // [recovery, r(32), s(32)] + const out = new Uint8Array(65); + out.set(recovered.slice(1, 65), 0); // r || s + out[64] = signature.recovery + 27; + return bytesToHex(out); +} + +export interface ReceiptOverrides { + battleId?: string; + sequence?: number; + previousReceiptHash?: Hex | null; + createdAt?: number; + signingKeyId?: string; +} + +/** Builds one valid, internally-consistent receipt. Each call re-simulates independently. */ +export function buildReceipt(overrides: ReceiptOverrides = {}): BattleReceipt { + const battleId = overrides.battleId ?? 'btl_0001'; + const seed = deriveBattleSeed({ + domain: DOMAIN, + drandRandomness: BEACON.randomness, + battleId, + snapshotHash: hashBattleSnapshot(SNAPSHOT), + rulesetHash: RULESET_HASH, + }); + const outcome = simulate( + SNAPSHOT.attacker.dna, + SNAPSHOT.attacker.rarity, + SNAPSHOT.attacker.level, + SNAPSHOT.attacker.skill, + SNAPSHOT.defender.dna, + SNAPSHOT.defender.rarity, + SNAPSHOT.defender.level, + SNAPSHOT.defender.skill, + seed.value, + SOURCE_DEFAULT_RULESET.skillConfig, + ); + return { + domain: DOMAIN, + battleId, + intentHash: `0x${'11'.repeat(32)}`, + commitmentHash: `0x${'22'.repeat(32)}`, + defenseAuthorizationHash: `0x${'33'.repeat(32)}`, + snapshot: SNAPSHOT, + beacon: BEACON, + seed: seed.hex, + rulesetVersion: SOURCE_DEFAULT_RULESET.version, + rulesetHash: RULESET_HASH, + result: { + attackerWon: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + }, + combatLogHash: hashCombatLog(outcome), + progression: computeProgression(SNAPSHOT, outcome.result.firstWins), + sequence: overrides.sequence ?? 1, + previousReceiptHash: overrides.previousReceiptHash ?? null, + attackerPreviousReceiptHash: null, + defenderPreviousReceiptHash: null, + createdAt: overrides.createdAt ?? PUBLISHED_AT + 1, + signingKeyId: overrides.signingKeyId ?? TEST_SIGNING_KEY_ID, + }; +} + +/** The exact replacer `sign.worker.ts` stores receipts through: bigint -> decimal string. */ +export function toWireJson(receipt: BattleReceipt): unknown { + return JSON.parse(JSON.stringify(receipt, (_key, value) => (typeof value === 'bigint' ? value.toString() : value))); +} + +/** A receipt plus its signed envelope and the matching trusted-key entry, ready to verify. */ +export function buildSignedReceipt(overrides: ReceiptOverrides = {}): { + receipt: BattleReceipt; + envelope: SignedReceiptEnvelope; + trustedKey: TrustedSigningKey; +} { + const receipt = buildReceipt(overrides); + const receiptHash = hashBattleReceipt(receipt); + const signature = signWithTestKey(receiptHash); + return { + receipt, + envelope: { + receiptHash, + signature, + signingKeyId: receipt.signingKeyId, + payload: toWireJson(receipt) as SignedReceiptEnvelope['payload'], + }, + trustedKey: { keyId: receipt.signingKeyId, address: testSigningAddress() }, + }; +} diff --git a/verifier/tests/io/loadReceipts.test.ts b/verifier/tests/io/loadReceipts.test.ts new file mode 100644 index 00000000..275f12b6 --- /dev/null +++ b/verifier/tests/io/loadReceipts.test.ts @@ -0,0 +1,127 @@ +import { createServer, type Server } from 'node:http'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { loadReceipts } from '../../src/io/loadReceipts'; +import { buildSignedReceipt } from '../fixtures/signedReceipt'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'verifier-loadreceipts-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('loadReceipts from a local file', () => { + it('loads a single receipt in the "single-receipt endpoint" shape ({ hash, ... })', async () => { + const { envelope } = buildSignedReceipt(); + const file = join(dir, 'receipt.json'); + await writeFile( + file, + JSON.stringify({ + hash: envelope.receiptHash, + signature: envelope.signature, + signingKeyId: envelope.signingKeyId, + payload: envelope.payload, + }), + ); + + const loaded = await loadReceipts(file); + expect(loaded).toEqual([envelope]); + }); + + it('loads a corpus page shape ({ receipts: [...], nextCursor })', async () => { + const one = buildSignedReceipt({ battleId: 'btl_0001' }); + const two = buildSignedReceipt({ battleId: 'btl_0002' }); + const file = join(dir, 'corpus.json'); + await writeFile( + file, + JSON.stringify({ + receipts: [one.envelope, two.envelope], + nextCursor: null, + }), + ); + + const loaded = await loadReceipts(file); + expect(loaded).toHaveLength(2); + expect(loaded[0]?.receiptHash).toBe(one.envelope.receiptHash); + expect(loaded[1]?.receiptHash).toBe(two.envelope.receiptHash); + }); + + it('loads a bare array of receipt entries', async () => { + const one = buildSignedReceipt({ battleId: 'btl_0001' }); + const file = join(dir, 'array.json'); + await writeFile(file, JSON.stringify([one.envelope])); + + const loaded = await loadReceipts(file); + expect(loaded).toEqual([one.envelope]); + }); + + it('throws a clear error when the payload field is missing', async () => { + const file = join(dir, 'no-payload.json'); + await writeFile(file, JSON.stringify({ receiptHash: '0xabc', signature: '0xdef', signingKeyId: 'k' })); + await expect(loadReceipts(file)).rejects.toThrow(/missing its payload/); + }); + + it('throws when the receipt hash is spelled neither "hash" nor "receiptHash"', async () => { + const file = join(dir, 'no-hash.json'); + await writeFile(file, JSON.stringify({ signature: '0xdef', signingKeyId: 'k', payload: {} })); + await expect(loadReceipts(file)).rejects.toThrow(/hash/); + }); +}); + +describe('loadReceipts from an http(s) URL', () => { + let server: Server; + let baseUrl: string; + + beforeEach(async () => { + server = createServer((_req, res) => { + const { envelope } = buildSignedReceipt(); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + hash: envelope.receiptHash, + signature: envelope.signature, + signingKeyId: envelope.signingKeyId, + payload: envelope.payload, + }), + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('expected a bound TCP address'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('fetches and parses a receipt served over http', async () => { + const loaded = await loadReceipts(baseUrl); + expect(loaded).toHaveLength(1); + expect(loaded[0]?.signingKeyId).toBe('battle-signer-2026-07'); + }); + + it('throws with the status code when the server responds with an error', async () => { + server.close(); + server = createServer((_req, res) => { + res.writeHead(404); + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('expected a bound TCP address'); + } + await expect(loadReceipts(`http://127.0.0.1:${address.port}`)).rejects.toThrow(/404/); + }); +}); diff --git a/verifier/tests/io/loadSigningKeys.test.ts b/verifier/tests/io/loadSigningKeys.test.ts new file mode 100644 index 00000000..1d3de829 --- /dev/null +++ b/verifier/tests/io/loadSigningKeys.test.ts @@ -0,0 +1,58 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { loadSigningKeys } from '../../src/io/loadSigningKeys'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'verifier-loadkeys-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('loadSigningKeys', () => { + it('loads the { keys: [...] } shape GET /api/battle/signing-keys serves', async () => { + const file = join(dir, 'keys.json'); + await writeFile( + file, + JSON.stringify({ + keys: [ + { keyId: 'k1', address: '0x1111111111111111111111111111111111111111', notBefore: 100, notAfter: null }, + { keyId: 'k2', address: '0x2222222222222222222222222222222222222222', notBefore: 200 }, + ], + }), + ); + + const keys = await loadSigningKeys(file); + expect(keys).toEqual([ + { keyId: 'k1', address: '0x1111111111111111111111111111111111111111', notBefore: 100, notAfter: null }, + { keyId: 'k2', address: '0x2222222222222222222222222222222222222222', notBefore: 200 }, + ]); + }); + + it('loads a bare array, for a hand-written trust file', async () => { + const file = join(dir, 'keys-array.json'); + await writeFile(file, JSON.stringify([{ keyId: 'k1', address: '0x1111111111111111111111111111111111111111' }])); + + const keys = await loadSigningKeys(file); + expect(keys).toEqual([{ keyId: 'k1', address: '0x1111111111111111111111111111111111111111' }]); + }); + + it('throws when the source is neither a key array nor a { keys: [...] } object', async () => { + const file = join(dir, 'bad.json'); + await writeFile(file, JSON.stringify({ notKeys: [] })); + await expect(loadSigningKeys(file)).rejects.toThrow(/did not contain a key array/); + }); + + it('throws when a key entry is missing its address', async () => { + const file = join(dir, 'missing-address.json'); + await writeFile(file, JSON.stringify({ keys: [{ keyId: 'k1' }] })); + await expect(loadSigningKeys(file)).rejects.toThrow(/address/); + }); +}); diff --git a/verifier/tests/verify.test.ts b/verifier/tests/verify.test.ts new file mode 100644 index 00000000..b606df8c --- /dev/null +++ b/verifier/tests/verify.test.ts @@ -0,0 +1,59 @@ +import { hashBattleReceipt } from '@cryptopets/protocol'; +import { describe, expect, it } from 'vitest'; + +import { verifyReceipts } from '../src/verify'; +import { buildSignedReceipt } from './fixtures/signedReceipt'; + +describe('verifyReceipts', () => { + it('passes a single well-formed, correctly-signed receipt', () => { + const { envelope, trustedKey } = buildSignedReceipt(); + const report = verifyReceipts([envelope], [trustedKey]); + expect(report.ok).toBe(true); + expect(report.results.map((r) => r.check)).toEqual(['operator-signature', 'chain-continuity']); + }); + + it('reports both an operator-signature failure and continuity for an unbroken but untrusted run', () => { + const first = buildSignedReceipt({ battleId: 'btl_0001' }); + const second = buildSignedReceipt({ + battleId: 'btl_0002', + sequence: 2, + previousReceiptHash: hashBattleReceipt(first.receipt), + createdAt: first.receipt.createdAt + 1, + }); + // No trusted keys supplied: both signatures fail closed, but the chain itself is intact. + const report = verifyReceipts([first.envelope, second.envelope], []); + expect(report.ok).toBe(false); + const signatureFailures = report.results.filter((r) => r.check === 'operator-signature'); + expect(signatureFailures).toHaveLength(2); + expect(signatureFailures.every((r) => !r.ok)).toBe(true); + expect(report.results.find((r) => r.check === 'chain-continuity')).toEqual({ + check: 'chain-continuity', + ok: true, + }); + }); + + it('reports a malformed-receipt failure and excludes it from the chain walk, without throwing', () => { + const { envelope, trustedKey } = buildSignedReceipt(); + // A seed that does not follow from the receipt's own inputs: `assertBattleReceipt` + // rejects this (`receipt.test.ts` pins the same check in `protocol`), so this + // receipt never becomes a typed `BattleReceipt` at all. + const malformed = { + ...envelope, + payload: { ...(envelope.payload as Record), seed: `0x${'99'.repeat(32)}` } as never, + }; + + const report = verifyReceipts([malformed], [trustedKey]); + expect(report.ok).toBe(false); + expect(report.results).toEqual([ + { + check: 'malformed-receipt', + ok: false, + detail: expect.stringContaining(malformed.receiptHash), + }, + ]); + }); + + it('passes an empty input with no results at all', () => { + expect(verifyReceipts([], [])).toEqual({ results: [], ok: true }); + }); +}); diff --git a/verifier/tsconfig.json b/verifier/tsconfig.json new file mode 100644 index 00000000..4d7d1d0d --- /dev/null +++ b/verifier/tsconfig.json @@ -0,0 +1,27 @@ +{ + // Typecheck-only config (`pnpm typecheck`), same shape as `protocol`'s. Consumed as raw + // TypeScript for now (see the package README for why): no build step in this step, no + // "bin" wiring yet either — that is packaging work for whenever this actually ships to + // someone outside the monorepo, not part of the scaffold. + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["vitest/globals", "node"], + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/verifier/vitest.config.ts b/verifier/vitest.config.ts new file mode 100644 index 00000000..d06b9eaf --- /dev/null +++ b/verifier/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, coverageConfigDefaults } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.{test,spec}.ts'], + coverage: { + provider: 'v8', + reportsDirectory: './coverage', + reporter: ['text', 'html', 'lcov', 'json', 'json-summary'], + include: ['src/**/*.ts'], + exclude: [...coverageConfigDefaults.exclude, 'src/**/index.ts'], + }, + }, +}); From 39c1c0ebcd4034f2d71fc4eb0ba59f9df41f696a Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 14:44:40 -0400 Subject: [PATCH 32/76] feat(verifier): verify beacon, seed, combat replay, and progression --- CLAUDE.md | 2 +- protocol/src/receipt/index.ts | 2 + protocol/src/receipt/verify.ts | 58 +++++-- protocol/tests/receipt/receipt.test.ts | 55 +++++++ verifier/README.md | 82 +++++++--- verifier/src/checks/beaconSignature.ts | 29 ++++ verifier/src/checks/combatReplay.ts | 63 ++++++++ verifier/src/checks/index.ts | 4 + verifier/src/checks/progression.ts | 32 ++++ verifier/src/checks/seedDerivation.ts | 35 ++++ verifier/src/cli.ts | 45 ++++-- verifier/src/io/index.ts | 1 + verifier/src/io/loadRulesets.ts | 54 +++++++ verifier/src/verify.ts | 129 +++++++++++---- verifier/tests/checks/beaconSignature.test.ts | 32 ++++ verifier/tests/checks/combatReplay.test.ts | 74 +++++++++ verifier/tests/checks/progression.test.ts | 67 ++++++++ verifier/tests/checks/seedDerivation.test.ts | 48 ++++++ verifier/tests/fixtures/signedReceipt.ts | 84 ++++++++-- verifier/tests/io/loadRulesets.test.ts | 91 +++++++++++ verifier/tests/verify.test.ts | 150 ++++++++++++++---- 21 files changed, 1009 insertions(+), 128 deletions(-) create mode 100644 verifier/src/checks/beaconSignature.ts create mode 100644 verifier/src/checks/combatReplay.ts create mode 100644 verifier/src/checks/progression.ts create mode 100644 verifier/src/checks/seedDerivation.ts create mode 100644 verifier/src/io/loadRulesets.ts create mode 100644 verifier/tests/checks/beaconSignature.test.ts create mode 100644 verifier/tests/checks/combatReplay.test.ts create mode 100644 verifier/tests/checks/progression.test.ts create mode 100644 verifier/tests/checks/seedDerivation.test.ts create mode 100644 verifier/tests/io/loadRulesets.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 52629cd9..5a6fc030 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ pnpm build # compile contracts + build backend + frontend + we | `contracts/solana/cryptopets` | Rust, Anchor | Solana programs | | `shared` (`@shared/core`) | TypeScript | Common utils/types/hooks, consumed as raw TS (no build step), shared by frontend + mobile | | `protocol` (`@cryptopets/protocol`) | TypeScript | MIT, dependency-free battle protocol: the TS combat engine plus (in progress) canonical encodings, hashes, and drand seed derivation. Consumed as raw TS by `shared`/`backend` and by `verifier` | -| `verifier` (`@cryptopets/verifier`) | TypeScript | MIT, standalone public receipt verifier (§H). Depends only on `protocol`; no backend access, no database. So far: operator-signature and hash-chain-continuity checks (Step 30); drand/seed/replay/progression checks land in Step 31 | +| `verifier` (`@cryptopets/verifier`) | TypeScript | MIT, standalone public receipt verifier (§H). Depends only on `protocol`; no backend access, no database. Checks seed derivation, operator signature, drand BLS beacon, combat replay, progression, and hash-chain continuity, reporting each independently | | `proto` | Protobuf/Buf | gRPC contract (`GameDataService`) between `indexer-go` and `backend` | ### Data flow diff --git a/protocol/src/receipt/index.ts b/protocol/src/receipt/index.ts index 9cac1451..8d337cac 100644 --- a/protocol/src/receipt/index.ts +++ b/protocol/src/receipt/index.ts @@ -18,7 +18,9 @@ export { type ReceiptCheck, type ReceiptCheckFailure, type ReceiptVerification, + verifyReceiptBeacon, verifyReceiptConsistency, + verifyReceiptProgression, } from './verify'; export { receiptFromWire, diff --git a/protocol/src/receipt/verify.ts b/protocol/src/receipt/verify.ts index 29f7a4e0..46321f70 100644 --- a/protocol/src/receipt/verify.ts +++ b/protocol/src/receipt/verify.ts @@ -11,10 +11,14 @@ import { assertBattleReceipt, type BattleReceipt } from './types'; * signature hash, seed follows from the inputs, times are ordered). This adds the two * expensive ones: the BLS signature, and recomputing the progression delta. * - * What is *not* here: replaying the fight itself. That needs the combat log, which the - * receipt only references, so it belongs to the standalone verifier where the log is - * fetched alongside. Progression is checkable here because the snapshot carries the - * streak state it depends on. + * What is *not* here: replaying the fight itself. That needs the ruleset's skill config, + * which this module deliberately does not take, so it belongs to the standalone verifier + * where the published bundle is resolved alongside. Progression is checkable here because + * the snapshot carries the streak state it depends on. + * + * Each half is also exported on its own, because their preconditions differ: the beacon + * check needs nothing but the receipt, while progression needs the level cap from the + * ruleset the receipt names. */ export type ReceiptCheck = 'beacon-signature' | 'progression'; @@ -38,27 +42,51 @@ export type ReceiptVerification = { ok: true } | { ok: false; failures: ReceiptC * produces a progression mismatch, which is the correct outcome rather than a false pass. */ export function verifyReceiptConsistency(receipt: BattleReceipt, params: ProgressionParams): ReceiptVerification { - const checked = assertBattleReceipt(receipt); - const failures: ReceiptCheckFailure[] = []; + const failures = [...beaconFailures(receipt), ...progressionFailures(receipt, params)]; + return failures.length === 0 ? { ok: true } : { ok: false, failures }; +} + +/** + * The beacon half on its own. + * + * Split out because it is the only expensive check that needs nothing from the ruleset: a + * verifier that could not obtain the bundle a receipt names can still confirm the + * randomness was real, and reporting "we could not check the beacon" in that case would be + * a worse answer than the one available. + */ +export function verifyReceiptBeacon(receipt: BattleReceipt): ReceiptVerification { + const failures = beaconFailures(receipt); + return failures.length === 0 ? { ok: true } : { ok: false, failures }; +} + +/** The progression half on its own. Needs the level cap from the ruleset the receipt names. */ +export function verifyReceiptProgression(receipt: BattleReceipt, params: ProgressionParams): ReceiptVerification { + const failures = progressionFailures(receipt, params); + return failures.length === 0 ? { ok: true } : { ok: false, failures }; +} +function beaconFailures(receipt: BattleReceipt): ReceiptCheckFailure[] { + const checked = assertBattleReceipt(receipt); const chain = resolveDrandChain(checked.beacon.chainHash); - if (!verifyBeacon(chain, { round: checked.beacon.round, signature: checked.beacon.signature })) { - failures.push({ + if (verifyBeacon(chain, { round: checked.beacon.round, signature: checked.beacon.signature })) { + return []; + } + return [ + { check: 'beacon-signature', detail: `drand round ${checked.beacon.round} does not verify against chain ${chain.chainHash}`, - }); - } + }, + ]; +} +function progressionFailures(receipt: BattleReceipt, params: ProgressionParams): ReceiptCheckFailure[] { + const checked = assertBattleReceipt(receipt); const recomputed = computeProgression(checked.snapshot, checked.result.attackerWon, params); const mismatches = [ ...compareProgression('attacker', recomputed.attacker, checked.progression.attacker), ...compareProgression('defender', recomputed.defender, checked.progression.defender), ]; - if (mismatches.length > 0) { - failures.push({ check: 'progression', detail: mismatches.join('; ') }); - } - - return failures.length === 0 ? { ok: true } : { ok: false, failures }; + return mismatches.length === 0 ? [] : [{ check: 'progression', detail: mismatches.join('; ') }]; } const PROGRESSION_FIELDS = [ diff --git a/protocol/tests/receipt/receipt.test.ts b/protocol/tests/receipt/receipt.test.ts index 468438be..2e62347e 100644 --- a/protocol/tests/receipt/receipt.test.ts +++ b/protocol/tests/receipt/receipt.test.ts @@ -12,8 +12,10 @@ import { hashCombatLog, petPreviousReceiptHash, verifyPetReceiptChain, + verifyReceiptBeacon, verifyReceiptChain, verifyReceiptConsistency, + verifyReceiptProgression, } from '../../src/receipt'; import { hashRuleset, SOURCE_DEFAULT_RULESET } from '../../src/ruleset'; import { type BattleSnapshot, hashBattleSnapshot } from '../../src/snapshot'; @@ -269,6 +271,59 @@ describe('verifyReceiptConsistency', () => { }); }); +describe('the halves on their own', () => { + // The split exists so a verifier that could not obtain the named ruleset bundle can + // still check the beacon, instead of reporting the whole receipt as unverifiable. + const forgedBeacon = build({ + beacon: { + ...BEACON, + signature: + '0x971cbe88adc436f6411fd26d51887ede7ba144264cd05edec6645b5e170a7702d16082947a85d89c89cb47cd8eb7d817', + randomness: '0x36ecd957580ee415f951370e2a5e13273be97de9072418aaf14d38242979e3c1', + }, + }); + + it('verifyReceiptBeacon checks the beacon without needing any ruleset parameters', () => { + expect(verifyReceiptBeacon(VALID)).toEqual({ ok: true }); + const result = verifyReceiptBeacon(forgedBeacon); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failures.map((f) => f.check)).toEqual(['beacon-signature']); + } + }); + + it('verifyReceiptProgression checks progression without touching the beacon', () => { + // The beacon here is forged, and this half must not notice or care. + expect(verifyReceiptProgression(forgedBeacon, { maxLevel: 100 })).toEqual({ ok: true }); + + const inflated = build({ + progression: { ...VALID.progression, attacker: { ...VALID.progression.attacker, xp: 9999 } }, + }); + const result = verifyReceiptProgression(inflated, { maxLevel: 100 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failures.map((f) => f.check)).toEqual(['progression']); + } + }); + + it('together they report exactly what the composed function reports', () => { + const broken = build({ + beacon: forgedBeacon.beacon, + progression: { ...VALID.progression, defender: { ...VALID.progression.defender, xp: 1 } }, + }); + const composed = verifyReceiptConsistency(broken, { maxLevel: 100 }); + const beacon = verifyReceiptBeacon(broken); + const progression = verifyReceiptProgression(broken, { maxLevel: 100 }); + expect(composed).toEqual({ + ok: false, + failures: [ + ...(beacon.ok ? [] : beacon.failures), + ...(progression.ok ? [] : progression.failures), + ], + }); + }); +}); + describe('global receipt chain', () => { const first = build({}, 'btl_0001'); const second = build( diff --git a/verifier/README.md b/verifier/README.md index 75c8e520..601f9628 100644 --- a/verifier/README.md +++ b/verifier/README.md @@ -16,49 +16,85 @@ backend access, no database — every check runs against a receipt's own content public inputs the caller supplies (a signing-key list, and — once Step 31 lands — a live drand endpoint). -## What is checked so far +## What is checked -This is Step 30: the two checks that need nothing but the receipts themselves. +Every check §H item 1 calls for, reported individually. `verifyReceipts` runs them all and never +stops at the first failure — "the beacon is forged" and "the XP is wrong" are different +accusations, and a verifier that stopped early would make the second invisible. +- **Seed derivation** (`checkSeedDerivation`). Recomputes the seed from the receipt's own domain, + drand randomness, battle id, snapshot, and ruleset hash. This is what stops a favourable seed + being stapled onto a genuine beacon and a genuine snapshot. - **Operator signature** (`checkOperatorSignature`). Recomputes the receipt's own hash, recovers the address that produced the stored ECDSA signature over it (`recoverAddress`, pure `@noble/curves` secp256k1 recovery — no `ethers` dependency), and checks it against a caller-supplied trusted key list. A receipt whose signing key is not in that list, or whose `createdAt` falls outside the key's published validity window, fails closed. -- **Hash-chain continuity** (`checkChainContinuity`). Wraps `verifyReceiptChain` from - `@cryptopets/protocol`: every receipt in a run links to its predecessor's real hash, sequence - numbers are consecutive, and nothing is out of order. - -**Not yet covered** (Step 31): the drand BLS beacon signature, seed derivation, replaying the -actual fight from the combat log, and recomputing the progression delta. `verifyReceiptConsistency` -in `@cryptopets/protocol` already implements the beacon and progression halves of that; Step 31 -wires those in here alongside the combat replay itself and turns everything into the -per-check pass/fail CLI output §H describes. +- **Drand beacon** (`checkBeaconSignature`). Verifies the BLS12-381 signature against drand's + public key, with the round number as the signed message. This is the check that makes + commit-before-reveal mean anything: every cheaper check passes just as happily for randomness we + invented, because we would have hashed our own invention consistently. Needs no ruleset, so it + runs even when the named bundle could not be obtained. +- **Combat replay** (`checkCombatReplay`). Re-runs the fight from the frozen snapshot, the seed, + and the named ruleset, then compares the winner, round count, winner HP, *and* the recomputed + combat-log hash. Checking the log hash too means the log the operator serves separately + (`GET /api/battle/:battleId/combat-log`) is pinned transitively. +- **Progression** (`checkProgression`). Recomputes the XP and level change and compares it. This + is only a pure function of the receipt because the snapshot freezes `lastOpponentId` and + `streak`, the same-opponent decay state XP depends on. +- **Hash-chain continuity** (`checkChainContinuity`). Every receipt in a run links to its + predecessor's real hash, sequence numbers are consecutive, and nothing is out of order. + +Two situations fail closed rather than being skipped quietly. A receipt that will not parse, or +fails its own internal consistency, is reported as `malformed-receipt` and left out of the chain +walk. A receipt naming a ruleset bundle the caller did not supply is reported as +`ruleset-unavailable`, and its replay and progression checks do not run — reporting those as passed +would be a lie, and omitting them silently would read as a clean bill of health. + +**Not yet covered** (Step 32): fetching and pinning content-addressed ruleset artifacts +automatically, and a CI job running this over a committed corpus fixture. Merkle inclusion proofs +arrive with the batch registry (Group G). ## Usage ```bash -# Programmatic -pnpm --filter @cryptopets/verifier exec tsx -e " - import { loadReceipts, loadSigningKeys, verifyReceipts } from './src/index.ts'; - const envelopes = await loadReceipts('./some-receipt.json'); - const keys = await loadSigningKeys('https://api.example.com/api/battle/signing-keys'); - console.log(verifyReceipts(envelopes, keys)); -" - # CLI (dev, via tsx — see the note below on packaging) pnpm --filter @cryptopets/verifier cli -- ./some-receipt.json --keys ./trusted-keys.json -pnpm --filter @cryptopets/verifier cli -- https://api.example.com/api/receipts?signingKeyId=battle-signer-2026-07 --keys https://api.example.com/api/battle/signing-keys +pnpm --filter @cryptopets/verifier cli -- \ + 'https://api.example.com/api/receipts?signingKeyId=battle-signer-2026-07' \ + --keys https://api.example.com/api/battle/signing-keys \ + --rulesets https://api.example.com/api/battle/rulesets/0xabc... ``` +Output is one line per check, and the process exits non-zero if any failed: + +```text +[PASS] seed-derivation +[PASS] operator-signature +[FAIL] beacon-signature: drand round 1000 does not verify against chain 0x52db9ba7... +[PASS] combat-replay +[PASS] progression +[PASS] chain-continuity +``` + +Programmatically, `verifyReceipts(envelopes, trustedKeys, { rulesets })` returns the same results +as `{ results, ok }`. + `loadReceipts` accepts a local file path or an `http(s)` URL, and any of the shapes `backend/API.md` actually serves: a single receipt (`GET /api/battle/:battleId/receipt`), a corpus page (`GET /api/receipts/...`), or a bare array. `loadSigningKeys` accepts `GET /api/battle/signing-keys`'s `{ keys: [...] }` shape, or a bare array for a hand-written -trust file. +trust file. `loadRulesets` accepts `GET /api/battle/rulesets/:rulesetHash`'s `{ ..., bundle }` +shape, a bare bundle, or an array of either — always keyed by the hash recomputed from the +bundle's own contents, never one the source claimed. + +Both flags default to the safe answer rather than the convenient one: -Omitting `--keys` does not skip the operator-signature check — it means no key is trusted, so -every receipt fails that check rather than silently passing one nobody actually verified. +- Omitting `--keys` does not skip the operator-signature check. It means no key is trusted, so + every receipt fails that check rather than silently passing one nobody actually verified. +- Omitting `--rulesets` falls back to this build's source-default ruleset only. A battle fought + under tuned `GameConfig` values then reports `ruleset-unavailable` instead of being replayed + against the wrong numbers. ## Consumption diff --git a/verifier/src/checks/beaconSignature.ts b/verifier/src/checks/beaconSignature.ts new file mode 100644 index 00000000..62964d2e --- /dev/null +++ b/verifier/src/checks/beaconSignature.ts @@ -0,0 +1,29 @@ +import { type BattleReceipt, verifyReceiptBeacon } from '@cryptopets/protocol'; + +import type { CheckResult } from './types'; + +/** + * Checks the drand BLS signature the receipt carries (§E, §H item 1). + * + * This is the check that makes commit-before-reveal mean anything. Everything cheaper — + * that the randomness is the hash of the signature, that the seed follows from that + * randomness — passes just as happily for a signature we invented, because we would have + * hashed our own invention consistently. Only verifying against drand's public key, over + * the round number as the signed message, establishes that the randomness existed + * independently of us and could not have been known when the battle was committed. + * + * Needs no ruleset: this runs even when the bundle a receipt names could not be obtained. + */ +export function checkBeaconSignature(receipt: BattleReceipt): CheckResult { + const check = 'beacon-signature'; + let result: ReturnType; + try { + result = verifyReceiptBeacon(receipt); + } catch (error) { + return { check, ok: false, detail: (error as Error).message }; + } + if (result.ok) { + return { check, ok: true }; + } + return { check, ok: false, detail: result.failures.map((failure) => failure.detail).join('; ') }; +} diff --git a/verifier/src/checks/combatReplay.ts b/verifier/src/checks/combatReplay.ts new file mode 100644 index 00000000..4123f6c4 --- /dev/null +++ b/verifier/src/checks/combatReplay.ts @@ -0,0 +1,63 @@ +import { type BattleReceipt, hashCombatLog, type Ruleset, simulate } from '@cryptopets/protocol'; + +import type { CheckResult } from './types'; + +/** + * Re-runs the fight and compares it to what the receipt claims (§F, §H item 1). + * + * This is the check the whole design exists for. Every input the engine consumes is in the + * receipt — both pets frozen at acceptance, the seed, and the ruleset it names — so a + * stranger can run the same simulation and get the same answer, or not. Nothing here asks + * the operator for anything. + * + * Both the summary result and the blow-by-blow log are compared. The summary alone would + * miss a receipt whose winner and round count are honest but whose published log tells a + * different story about how it got there, so the recomputed log's hash is checked against + * `combatLogHash` as well. That also means the log served separately by the operator + * (`GET /api/battle/:battleId/combat-log`, §G) is pinned transitively: anything hashing to + * `combatLogHash` is, by collision resistance, the log this replay produced. + * + * The `ruleset` must be the bundle the receipt names — the caller resolves it by hash, so + * a mismatched bundle is not something this function can be handed. + */ +export function checkCombatReplay(receipt: BattleReceipt, ruleset: Ruleset): CheckResult { + const check = 'combat-replay'; + const { attacker, defender } = receipt.snapshot; + + let outcome: ReturnType; + try { + outcome = simulate( + attacker.dna, + attacker.rarity, + attacker.level, + attacker.skill, + defender.dna, + defender.rarity, + defender.level, + defender.skill, + BigInt(receipt.seed), + ruleset.skillConfig, + ); + } catch (error) { + return { check, ok: false, detail: `replay could not run: ${(error as Error).message}` }; + } + + const mismatches: string[] = []; + if (outcome.result.firstWins !== receipt.result.attackerWon) { + mismatches.push(`attackerWon: replay=${outcome.result.firstWins} receipt=${receipt.result.attackerWon}`); + } + if (outcome.result.rounds !== receipt.result.rounds) { + mismatches.push(`rounds: replay=${outcome.result.rounds} receipt=${receipt.result.rounds}`); + } + if (outcome.result.winnerHpRemaining !== receipt.result.winnerHpRemaining) { + mismatches.push( + `winnerHpRemaining: replay=${outcome.result.winnerHpRemaining} receipt=${receipt.result.winnerHpRemaining}`, + ); + } + const replayedLogHash = hashCombatLog(outcome); + if (replayedLogHash.toLowerCase() !== receipt.combatLogHash.toLowerCase()) { + mismatches.push(`combatLogHash: replay=${replayedLogHash} receipt=${receipt.combatLogHash}`); + } + + return mismatches.length === 0 ? { check, ok: true } : { check, ok: false, detail: mismatches.join('; ') }; +} diff --git a/verifier/src/checks/index.ts b/verifier/src/checks/index.ts index 18f5df10..c2ce7596 100644 --- a/verifier/src/checks/index.ts +++ b/verifier/src/checks/index.ts @@ -1,3 +1,7 @@ +export { checkBeaconSignature } from './beaconSignature'; export { checkChainContinuity } from './chainContinuity'; +export { checkCombatReplay } from './combatReplay'; export { checkOperatorSignature } from './operatorSignature'; +export { checkProgression } from './progression'; +export { checkSeedDerivation } from './seedDerivation'; export type { CheckResult } from './types'; diff --git a/verifier/src/checks/progression.ts b/verifier/src/checks/progression.ts new file mode 100644 index 00000000..030f937b --- /dev/null +++ b/verifier/src/checks/progression.ts @@ -0,0 +1,32 @@ +import { type BattleReceipt, type Ruleset, verifyReceiptProgression } from '@cryptopets/protocol'; + +import type { CheckResult } from './types'; + +/** + * Recomputes the XP and level change the battle caused and compares it to what the receipt + * claims (§F, §H item 1). + * + * This is what makes off-chain progression auditable at all. A pet's level is no longer + * verifiable against the chain, so the honest version of "this pet is level 12" is "replay + * the receipts that got it there and see". That is only a pure function of the receipt + * because the snapshot freezes `lastOpponentId` and `streak`, the same-opponent decay state + * XP depends on — without those a third party could only recompute this with access to our + * tables, which is not replay. + * + * The level cap comes from the ruleset the receipt names, never from this build's defaults: + * passing the wrong cap produces a mismatch, which is the correct outcome rather than a + * false pass. + */ +export function checkProgression(receipt: BattleReceipt, ruleset: Ruleset): CheckResult { + const check = 'progression'; + let result: ReturnType; + try { + result = verifyReceiptProgression(receipt, { maxLevel: ruleset.maxLevel }); + } catch (error) { + return { check, ok: false, detail: (error as Error).message }; + } + if (result.ok) { + return { check, ok: true }; + } + return { check, ok: false, detail: result.failures.map((failure) => failure.detail).join('; ') }; +} diff --git a/verifier/src/checks/seedDerivation.ts b/verifier/src/checks/seedDerivation.ts new file mode 100644 index 00000000..e7cb973d --- /dev/null +++ b/verifier/src/checks/seedDerivation.ts @@ -0,0 +1,35 @@ +import { type BattleReceipt, deriveBattleSeed, hashBattleSnapshot } from '@cryptopets/protocol'; + +import type { CheckResult } from './types'; + +/** + * Checks that the seed the fight ran on really follows from the receipt's own inputs + * (§E, §H item 1): the domain, the drand randomness, the battle id, the snapshot, and the + * ruleset hash. + * + * This is what stops a favourable seed being stapled onto a genuine beacon and a genuine + * snapshot. `assertBattleReceipt` makes the same check internally — a receipt that fails + * here also fails to hash at all — but it is reported as its own named check because + * "the seed was chosen, not derived" and "this JSON is malformed" are very different + * accusations, and collapsing them into one line would lose that. + */ +export function checkSeedDerivation(receipt: BattleReceipt): CheckResult { + const check = 'seed-derivation'; + let expected: string; + try { + expected = deriveBattleSeed({ + domain: receipt.domain, + drandRandomness: receipt.beacon.randomness, + battleId: receipt.battleId, + snapshotHash: hashBattleSnapshot(receipt.snapshot), + rulesetHash: receipt.rulesetHash, + }).hex; + } catch (error) { + return { check, ok: false, detail: `could not derive the seed: ${(error as Error).message}` }; + } + + if (receipt.seed.toLowerCase() !== expected) { + return { check, ok: false, detail: `receipt claims seed ${receipt.seed}, but its own inputs derive ${expected}` }; + } + return { check, ok: true }; +} diff --git a/verifier/src/cli.ts b/verifier/src/cli.ts index d37ec459..c4df59f2 100644 --- a/verifier/src/cli.ts +++ b/verifier/src/cli.ts @@ -1,36 +1,57 @@ #!/usr/bin/env node -import { loadReceipts, loadSigningKeys } from './io'; +import { builtInRulesets, loadReceipts, loadRulesets, loadSigningKeys } from './io'; import { verifyReceipts } from './verify'; /** - * `cryptopets-verify [--keys ]` + * `cryptopets-verify [--keys ] [--rulesets ]` * - * Prints one pass/fail line per check and exits non-zero if any check failed. Omitting - * `--keys` is not "skip the signature check" — it means no key is trusted, so every - * receipt's operator-signature check fails closed rather than silently passing. + * Prints one pass/fail line per check and exits non-zero if any check failed. + * + * Both flags default to the safe answer rather than the convenient one. Omitting `--keys` + * is not "skip the signature check": it means no key is trusted, so every receipt's + * operator-signature check fails. Omitting `--rulesets` falls back to this build's + * source-default ruleset only, so a battle fought under tuned `GameConfig` values reports + * `ruleset-unavailable` instead of being replayed against the wrong numbers. */ async function main(): Promise { const args = process.argv.slice(2); const receiptSource = args[0]; - if (!receiptSource) { - console.error('usage: cryptopets-verify [--keys ]'); + if (!receiptSource || receiptSource.startsWith('--')) { + console.error('usage: cryptopets-verify [--keys ] [--rulesets ]'); process.exitCode = 1; return; } - const keysFlagIndex = args.indexOf('--keys'); - const keysSource = keysFlagIndex >= 0 ? args[keysFlagIndex + 1] : undefined; + + const keysSource = flagValue(args, '--keys'); + const rulesetsSource = flagValue(args, '--rulesets'); const envelopes = await loadReceipts(receiptSource); const trustedKeys = keysSource ? await loadSigningKeys(keysSource) : []; - const report = verifyReceipts(envelopes, trustedKeys); + const rulesets = builtInRulesets(); + if (rulesetsSource) { + for (const [hash, ruleset] of await loadRulesets(rulesetsSource)) { + rulesets.set(hash, ruleset); + } + } + + const report = verifyReceipts(envelopes, trustedKeys, { rulesets }); for (const result of report.results) { - const status = result.ok ? 'PASS' : 'FAIL'; - console.log(`[${status}] ${result.check}${result.detail ? `: ${result.detail}` : ''}`); + console.log(`[${result.ok ? 'PASS' : 'FAIL'}] ${result.check}${result.detail ? `: ${result.detail}` : ''}`); } process.exitCode = report.ok ? 0 : 1; } +function flagValue(args: readonly string[], flag: string): string | undefined { + const index = args.indexOf(flag); + if (index < 0) return undefined; + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${flag} needs a file path or URL`); + } + return value; +} + main().catch((error: unknown) => { console.error(error); process.exitCode = 1; diff --git a/verifier/src/io/index.ts b/verifier/src/io/index.ts index bf84f506..f0e25915 100644 --- a/verifier/src/io/index.ts +++ b/verifier/src/io/index.ts @@ -1,4 +1,5 @@ export { loadReceipts } from './loadReceipts'; +export { builtInRulesets, loadRulesets, type RulesetRegistry } from './loadRulesets'; export { loadSigningKeys } from './loadSigningKeys'; export { readJsonFrom } from './source'; export type { SignedReceiptEnvelope, TrustedSigningKey } from './types'; diff --git a/verifier/src/io/loadRulesets.ts b/verifier/src/io/loadRulesets.ts new file mode 100644 index 00000000..ae72c76e --- /dev/null +++ b/verifier/src/io/loadRulesets.ts @@ -0,0 +1,54 @@ +import { hashRuleset, parseRulesetBundle, type Ruleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; + +import { readJsonFrom } from './source'; +import { isRecord } from './util'; + +/** + * Published ruleset bundles, keyed by lowercase `rulesetHash` (§H item 2). + * + * The hash is the identity, so where a bundle came from does not matter: every entry here + * is keyed by the hash recomputed from its own contents, never by a hash the source + * claimed. A bundle fetched from a hostile mirror either hashes to what the receipt names + * or it does not get used. + */ +export type RulesetRegistry = ReadonlyMap; + +/** + * The ruleset this build implements with source defaults, keyed by its own hash. + * + * Enough on its own to replay any battle fought under source defaults — which is every + * local-development battle and anything the golden vectors are anchored to. A deployment + * that tuned `GameConfig` produces a different hash, and those bundles must be supplied + * with `loadRulesets`. + */ +export function builtInRulesets(): Map { + return new Map([[hashRuleset(SOURCE_DEFAULT_RULESET).toLowerCase(), SOURCE_DEFAULT_RULESET]]); +} + +/** + * Loads published bundles from a local file or an `http(s)` URL. + * + * Accepts what `backend/API.md` serves from `GET /api/battle/rulesets/:rulesetHash` + * (`{ ..., bundle: {...} }`), a bare bundle object saved to a file by hand, or an array of + * either. + */ +export async function loadRulesets(source: string): Promise> { + const json = await readJsonFrom(source); + const entries = Array.isArray(json) ? json : [json]; + const registry = new Map(); + for (const entry of entries) { + const ruleset = parseOne(entry); + registry.set(hashRuleset(ruleset).toLowerCase(), ruleset); + } + return registry; +} + +function parseOne(entry: unknown): Ruleset { + if (!isRecord(entry)) { + throw new Error('a ruleset entry must be an object'); + } + // The bundle is transport-only JSON; `parseRulesetBundle` takes a string, and going + // back through `JSON.stringify` is what the backend's own compute worker does too. + const bundle = isRecord(entry.bundle) ? entry.bundle : entry; + return parseRulesetBundle(JSON.stringify(bundle)); +} diff --git a/verifier/src/verify.ts b/verifier/src/verify.ts index b9e51438..406932e6 100644 --- a/verifier/src/verify.ts +++ b/verifier/src/verify.ts @@ -1,7 +1,24 @@ -import { assertBattleReceipt, type BattleReceipt, receiptFromWire } from '@cryptopets/protocol'; +import { assertBattleReceipt, type BattleReceipt, receiptFromWire, type Ruleset } from '@cryptopets/protocol'; -import { checkChainContinuity, checkOperatorSignature, type CheckResult } from './checks'; -import type { SignedReceiptEnvelope, TrustedSigningKey } from './io'; +import { + checkBeaconSignature, + checkChainContinuity, + checkCombatReplay, + checkOperatorSignature, + checkProgression, + checkSeedDerivation, + type CheckResult, +} from './checks'; +import { builtInRulesets, type RulesetRegistry, type SignedReceiptEnvelope, type TrustedSigningKey } from './io'; + +export interface VerifyOptions { + /** + * Published ruleset bundles, keyed by lowercase `rulesetHash`. Defaults to this build's + * source-default ruleset alone, which covers every battle fought under untuned + * `GameConfig` values. + */ + rulesets?: RulesetRegistry; +} export interface VerifyReceiptsReport { results: CheckResult[]; @@ -9,43 +26,99 @@ export interface VerifyReceiptsReport { } /** - * Runs every check this step covers over a set of signed receipt envelopes: the operator - * signature per receipt, then hash-chain continuity across the whole run (§H item 1). Both - * need nothing beyond the receipts themselves and a trusted key list — no drand round, no - * combat replay, no backend access. + * Runs every check §H item 1 calls for over a set of signed receipt envelopes: operator + * signature, drand BLS beacon, seed derivation, combat replay, progression, and hash-chain + * continuity across the run. + * + * Nothing here contacts the operator. Every input is either in the receipt itself or was + * supplied by the caller (a trusted key list, published ruleset bundles), which is the + * whole point — an answer that depended on the backend telling the truth would not be + * verification. + * + * Every check is reported, not just the first failure. "The beacon is forged" and "the XP + * is wrong" are different accusations, and a verifier that stopped at the first one would + * make the second invisible. + * + * Two things fail closed rather than being skipped quietly: * - * A receipt that fails to parse, or fails its own internal consistency check - * (`assertBattleReceipt` — malformed hashes, a seed that does not follow from its own - * inputs, and so on), is reported as a `malformed-receipt` failure and excluded from the - * chain-continuity walk, since that walk assumes every receipt in the run is at least - * well-formed to begin with. + * - A receipt that will not parse, or fails its own internal consistency + * (`assertBattleReceipt`), is reported as `malformed-receipt`. Only the checks that do + * not need a hashable receipt still run on it, and it is left out of the chain walk, + * which assumes well-formed members. + * - A receipt naming a ruleset bundle the caller did not supply is reported as + * `ruleset-unavailable`, and its replay and progression checks do not run. Reporting + * those as passed would be a lie; silently omitting them would read as a clean bill of + * health. */ export function verifyReceipts( envelopes: readonly SignedReceiptEnvelope[], trustedKeys: readonly TrustedSigningKey[], + options: VerifyOptions = {}, ): VerifyReceiptsReport { + const rulesets = options.rulesets ?? builtInRulesets(); const results: CheckResult[] = []; - const receipts: BattleReceipt[] = []; + const wellFormed: BattleReceipt[] = []; for (const envelope of envelopes) { - let receipt: BattleReceipt; - try { - receipt = assertBattleReceipt(receiptFromWire(envelope.payload)); - } catch (error) { - results.push({ - check: 'malformed-receipt', - ok: false, - detail: `${envelope.receiptHash}: ${(error as Error).message}`, - }); - continue; - } - receipts.push(receipt); - results.push(checkOperatorSignature(envelope, receipt, trustedKeys)); + results.push(...verifyOne(envelope, trustedKeys, rulesets, wellFormed)); } - if (receipts.length > 0) { - results.push(checkChainContinuity(receipts)); + if (wellFormed.length > 0) { + results.push(checkChainContinuity(wellFormed)); } return { results, ok: results.every((result) => result.ok) }; } + +function verifyOne( + envelope: SignedReceiptEnvelope, + trustedKeys: readonly TrustedSigningKey[], + rulesets: RulesetRegistry, + wellFormed: BattleReceipt[], +): CheckResult[] { + let converted: BattleReceipt; + try { + converted = receiptFromWire(envelope.payload); + } catch (error) { + // Not even structurally a receipt: nothing further can run against it. + return [{ check: 'malformed-receipt', ok: false, detail: `${envelope.receiptHash}: ${(error as Error).message}` }]; + } + + // Runs before the well-formedness gate on purpose. A chosen seed is the specific thing + // `assertBattleReceipt` would reject, and reporting only "malformed" there would bury + // the actual accusation under a shape complaint. + const results: CheckResult[] = [checkSeedDerivation(converted)]; + + let receipt: BattleReceipt; + try { + receipt = assertBattleReceipt(converted); + } catch (error) { + results.push({ + check: 'malformed-receipt', + ok: false, + detail: `${envelope.receiptHash}: ${(error as Error).message}`, + }); + return results; + } + wellFormed.push(receipt); + + results.push(checkOperatorSignature(envelope, receipt, trustedKeys)); + results.push(checkBeaconSignature(receipt)); + + const ruleset = resolveRuleset(receipt, rulesets); + if (!ruleset) { + results.push({ + check: 'ruleset-unavailable', + ok: false, + detail: `no published bundle supplied for rulesetHash ${receipt.rulesetHash}; combat replay and progression could not be checked`, + }); + return results; + } + results.push(checkCombatReplay(receipt, ruleset)); + results.push(checkProgression(receipt, ruleset)); + return results; +} + +function resolveRuleset(receipt: BattleReceipt, rulesets: RulesetRegistry): Ruleset | undefined { + return rulesets.get(receipt.rulesetHash.toLowerCase()); +} diff --git a/verifier/tests/checks/beaconSignature.test.ts b/verifier/tests/checks/beaconSignature.test.ts new file mode 100644 index 00000000..183f89b0 --- /dev/null +++ b/verifier/tests/checks/beaconSignature.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { checkBeaconSignature } from '../../src/checks/beaconSignature'; +import { buildReceipt, FORGED_BEACON } from '../fixtures/signedReceipt'; + +describe('checkBeaconSignature', () => { + it('passes a receipt carrying a genuine drand round', () => { + expect(checkBeaconSignature(buildReceipt())).toEqual({ check: 'beacon-signature', ok: true }); + }); + + it('catches a real signature presented as a different round', () => { + // Everything cheaper passes here: the randomness really is the hash of the shipped + // signature, and the seed really does derive from that randomness. Only the BLS + // check notices, because the round number is the message being signed. + const result = checkBeaconSignature(buildReceipt({ beacon: FORGED_BEACON })); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/does not verify against chain/); + }); + + it('needs no ruleset, so it still runs for a receipt naming an unavailable bundle', () => { + const receipt = buildReceipt({ rulesetHash: `0x${'77'.repeat(32)}` }); + expect(checkBeaconSignature(receipt).ok).toBe(true); + }); + + it('fails rather than throwing on an unpinned drand chain', () => { + const receipt = buildReceipt(); + const unpinned = { ...receipt, beacon: { ...receipt.beacon, chainHash: `0x${'99'.repeat(32)}` as const } }; + const result = checkBeaconSignature(unpinned); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/is not pinned/); + }); +}); diff --git a/verifier/tests/checks/combatReplay.test.ts b/verifier/tests/checks/combatReplay.test.ts new file mode 100644 index 00000000..2bccad62 --- /dev/null +++ b/verifier/tests/checks/combatReplay.test.ts @@ -0,0 +1,74 @@ +import { SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; +import { describe, expect, it } from 'vitest'; + +import { checkCombatReplay } from '../../src/checks/combatReplay'; +import { buildReceipt } from '../fixtures/signedReceipt'; + +describe('checkCombatReplay', () => { + it('passes when the fight reproduces from the receipt own inputs', () => { + expect(checkCombatReplay(buildReceipt(), SOURCE_DEFAULT_RULESET)).toEqual({ + check: 'combat-replay', + ok: true, + }); + }); + + it('is deterministic: the same receipt replays the same way every time', () => { + const receipt = buildReceipt(); + expect(checkCombatReplay(receipt, SOURCE_DEFAULT_RULESET)).toEqual( + checkCombatReplay(receipt, SOURCE_DEFAULT_RULESET), + ); + }); + + it('catches a flipped winner', () => { + const honest = buildReceipt(); + const flipped = buildReceipt({ + patch: { result: { ...honest.result, attackerWon: !honest.result.attackerWon } }, + }); + const result = checkCombatReplay(flipped, SOURCE_DEFAULT_RULESET); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/attackerWon: replay=(true|false) receipt=(true|false)/); + }); + + it('catches an altered round count and winner HP together', () => { + const honest = buildReceipt(); + const altered = buildReceipt({ + patch: { + result: { + attackerWon: honest.result.attackerWon, + rounds: honest.result.rounds + 1, + winnerHpRemaining: honest.result.winnerHpRemaining + 1, + }, + }, + }); + const result = checkCombatReplay(altered, SOURCE_DEFAULT_RULESET); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/rounds:/); + expect(result.detail).toMatch(/winnerHpRemaining:/); + }); + + it('catches a log that differs even when the summary result is honest', () => { + // The reason the log hash is checked at all: winner and round count can be true + // while the blow-by-blow tells a different story about how it got there. + const tampered = buildReceipt({ patch: { combatLogHash: `0x${'cc'.repeat(32)}` } }); + const result = checkCombatReplay(tampered, SOURCE_DEFAULT_RULESET); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/combatLogHash/); + }); + + it('fails against the wrong ruleset rather than producing a false pass', () => { + // Replaying with balance values the battle was not fought under must not quietly + // agree. This is why the bundle is resolved by hash, never assumed. + // + // `bloodlustBps` specifically because the fixture defender has the Bloodlust + // archetype (skill 7) and its heal applies on every physical hit. Most other + // fields leave this particular fight identical — the attacker's Fury (skill 4) + // never triggers here, for instance — which would make the test pass for the + // wrong reason. + const receipt = buildReceipt(); + const tweaked = { + ...SOURCE_DEFAULT_RULESET, + skillConfig: { ...SOURCE_DEFAULT_RULESET.skillConfig, bloodlustBps: 5000 }, + }; + expect(checkCombatReplay(receipt, tweaked).ok).toBe(false); + }); +}); diff --git a/verifier/tests/checks/progression.test.ts b/verifier/tests/checks/progression.test.ts new file mode 100644 index 00000000..fb04cc13 --- /dev/null +++ b/verifier/tests/checks/progression.test.ts @@ -0,0 +1,67 @@ +import { SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; +import { describe, expect, it } from 'vitest'; + +import { checkProgression } from '../../src/checks/progression'; +import { buildReceipt, FORGED_BEACON } from '../fixtures/signedReceipt'; + +describe('checkProgression', () => { + it('passes a receipt whose XP and level change reproduces', () => { + expect(checkProgression(buildReceipt(), SOURCE_DEFAULT_RULESET)).toEqual({ + check: 'progression', + ok: true, + }); + }); + + it('catches inflated XP', () => { + const honest = buildReceipt(); + const inflated = buildReceipt({ + patch: { + progression: { + ...honest.progression, + attacker: { ...honest.progression.attacker, xp: 9999, xpAwarded: 9999 }, + }, + }, + }); + const result = checkProgression(inflated, SOURCE_DEFAULT_RULESET); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/attacker\.xp/); + }); + + it('catches a fabricated level-up', () => { + const honest = buildReceipt(); + const promoted = buildReceipt({ + patch: { + progression: { + ...honest.progression, + defender: { ...honest.progression.defender, level: 99, leveledUp: true }, + }, + }, + }); + const result = checkProgression(promoted, SOURCE_DEFAULT_RULESET); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/defender\.level/); + }); + + it('catches tampering with the same-opponent decay state the award depends on', () => { + const honest = buildReceipt(); + const tampered = buildReceipt({ + patch: { + progression: { + ...honest.progression, + defender: { ...honest.progression.defender, streak: 0 }, + }, + }, + }); + expect(checkProgression(tampered, SOURCE_DEFAULT_RULESET).ok).toBe(false); + }); + + it('fails against the wrong level cap rather than producing a false pass', () => { + // Parameters that do not match the named ruleset must not quietly agree. + const receipt = buildReceipt(); + expect(checkProgression(receipt, { ...SOURCE_DEFAULT_RULESET, maxLevel: 1 }).ok).toBe(false); + }); + + it('does not care about the beacon, which is the other check job', () => { + expect(checkProgression(buildReceipt({ beacon: FORGED_BEACON }), SOURCE_DEFAULT_RULESET).ok).toBe(true); + }); +}); diff --git a/verifier/tests/checks/seedDerivation.test.ts b/verifier/tests/checks/seedDerivation.test.ts new file mode 100644 index 00000000..e22287d2 --- /dev/null +++ b/verifier/tests/checks/seedDerivation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { checkSeedDerivation } from '../../src/checks/seedDerivation'; +import { buildReceipt, FORGED_BEACON } from '../fixtures/signedReceipt'; + +describe('checkSeedDerivation', () => { + it('passes a seed that follows from the receipt own inputs', () => { + expect(checkSeedDerivation(buildReceipt())).toEqual({ check: 'seed-derivation', ok: true }); + }); + + it('fails a seed that was chosen rather than derived', () => { + // The attack this check exists to stop: a favourable seed stapled onto a genuine + // beacon and a genuine snapshot. + const receipt = buildReceipt({ patch: { seed: `0x${'99'.repeat(32)}` } }); + const result = checkSeedDerivation(receipt); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/its own inputs derive 0x[0-9a-f]{64}/); + }); + + it('binds the seed to the battle id', () => { + const first = buildReceipt({ battleId: 'btl_0001' }); + const second = buildReceipt({ battleId: 'btl_0002' }); + expect(first.seed).not.toBe(second.seed); + expect(checkSeedDerivation({ ...first, seed: second.seed }).ok).toBe(false); + }); + + it('binds the seed to the ruleset hash', () => { + const first = buildReceipt(); + const other = buildReceipt({ rulesetHash: `0x${'77'.repeat(32)}` }); + expect(first.seed).not.toBe(other.seed); + expect(checkSeedDerivation({ ...first, seed: other.seed }).ok).toBe(false); + }); + + it('binds the seed to the beacon randomness', () => { + const honest = buildReceipt(); + const forged = buildReceipt({ beacon: FORGED_BEACON }); + expect(honest.seed).not.toBe(forged.seed); + expect(checkSeedDerivation({ ...honest, seed: forged.seed }).ok).toBe(false); + }); + + it('fails rather than throwing when the snapshot cannot be hashed', () => { + const receipt = buildReceipt(); + const broken = { ...receipt, snapshot: { ...receipt.snapshot, takenAt: -1 } }; + const result = checkSeedDerivation(broken); + expect(result.ok).toBe(false); + expect(result.detail).toMatch(/could not derive the seed/); + }); +}); diff --git a/verifier/tests/fixtures/signedReceipt.ts b/verifier/tests/fixtures/signedReceipt.ts index a46c7c30..c147c649 100644 --- a/verifier/tests/fixtures/signedReceipt.ts +++ b/verifier/tests/fixtures/signedReceipt.ts @@ -29,18 +29,35 @@ import type { SignedReceiptEnvelope, TrustedSigningKey } from '../../src/io/type * across runs without needing `Math.random`. */ -const BEACON = { +/** Real quicknet round 1000, so the BLS check runs against genuine drand output. */ +export const BEACON = { chainHash: QUICKNET.chainHash, round: 1000, signature: '0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39' as Hex, randomness: '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd' as Hex, }; + +/** + * Round 21000000's real signature, presented as round 1000. + * + * Well-formed, and the randomness really is its hash, so every cheap check passes and the + * seed derives consistently from it. Only the BLS verification catches it, because the + * round number is the message being signed — which is exactly the attack the beacon check + * exists to stop. + */ +export const FORGED_BEACON = { + ...BEACON, + signature: + '0x971cbe88adc436f6411fd26d51887ede7ba144264cd05edec6645b5e170a7702d16082947a85d89c89cb47cd8eb7d817' as Hex, + randomness: '0x36ecd957580ee415f951370e2a5e13273be97de9072418aaf14d38242979e3c1' as Hex, +}; + const PUBLISHED_AT = roundTime(QUICKNET, BEACON.round); const DOMAIN = { chainId: 'eip155:84532' as const, deploymentId: 'base-sepolia-live' }; -const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); +export const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); -const SNAPSHOT: BattleSnapshot = { +export const SNAPSHOT: BattleSnapshot = { domain: DOMAIN, attacker: { petId: 1n, @@ -112,17 +129,36 @@ export interface ReceiptOverrides { previousReceiptHash?: Hex | null; createdAt?: number; signingKeyId?: string; + /** Swapped wholesale; the seed is re-derived from whichever beacon is supplied. */ + beacon?: BattleReceipt['beacon']; + /** + * Names a different ruleset. Re-derives the seed too, since the seed binds the ruleset + * hash — patching it afterwards would produce a receipt that fails seed derivation + * rather than one that coherently names a bundle the verifier does not hold. + */ + rulesetHash?: Hex; + /** Applied after the honest values, so a test can state exactly what it tampered with. */ + patch?: Partial; } -/** Builds one valid, internally-consistent receipt. Each call re-simulates independently. */ +/** + * Builds one valid, internally-consistent receipt. Each call re-simulates independently. + * + * The seed always derives from whichever beacon is in play, because it has to: a receipt + * whose seed does not follow from its own inputs cannot be hashed at all, so there is no + * way to build one with a beacon it was not seeded from. Tests that want that specific + * tampering use `patch` to set the seed after the fact. + */ export function buildReceipt(overrides: ReceiptOverrides = {}): BattleReceipt { const battleId = overrides.battleId ?? 'btl_0001'; + const beacon = overrides.beacon ?? BEACON; + const rulesetHash = overrides.rulesetHash ?? RULESET_HASH; const seed = deriveBattleSeed({ domain: DOMAIN, - drandRandomness: BEACON.randomness, + drandRandomness: beacon.randomness, battleId, snapshotHash: hashBattleSnapshot(SNAPSHOT), - rulesetHash: RULESET_HASH, + rulesetHash, }); const outcome = simulate( SNAPSHOT.attacker.dna, @@ -143,10 +179,10 @@ export function buildReceipt(overrides: ReceiptOverrides = {}): BattleReceipt { commitmentHash: `0x${'22'.repeat(32)}`, defenseAuthorizationHash: `0x${'33'.repeat(32)}`, snapshot: SNAPSHOT, - beacon: BEACON, + beacon, seed: seed.hex, rulesetVersion: SOURCE_DEFAULT_RULESET.version, - rulesetHash: RULESET_HASH, + rulesetHash, result: { attackerWon: outcome.result.firstWins, rounds: outcome.result.rounds, @@ -160,6 +196,7 @@ export function buildReceipt(overrides: ReceiptOverrides = {}): BattleReceipt { defenderPreviousReceiptHash: null, createdAt: overrides.createdAt ?? PUBLISHED_AT + 1, signingKeyId: overrides.signingKeyId ?? TEST_SIGNING_KEY_ID, + ...overrides.patch, }; } @@ -175,16 +212,31 @@ export function buildSignedReceipt(overrides: ReceiptOverrides = {}): { trustedKey: TrustedSigningKey; } { const receipt = buildReceipt(overrides); - const receiptHash = hashBattleReceipt(receipt); - const signature = signWithTestKey(receiptHash); return { receipt, - envelope: { - receiptHash, - signature, - signingKeyId: receipt.signingKeyId, - payload: toWireJson(receipt) as SignedReceiptEnvelope['payload'], - }, + envelope: envelopeFor(receipt), trustedKey: { keyId: receipt.signingKeyId, address: testSigningAddress() }, }; } + +/** The trusted-key entry matching `signWithTestKey`. */ +export function testTrustedKey(keyId = TEST_SIGNING_KEY_ID): TrustedSigningKey { + return { keyId, address: testSigningAddress() }; +} + +/** + * Wraps a receipt in a signed envelope. + * + * `hashBattleReceipt` asserts, so a deliberately-broken receipt cannot be hashed. Those + * tests pass `receiptHash` explicitly: the envelope still has to carry *some* hash, and + * what it carries is beside the point when the receipt itself is what is under test. + */ +export function envelopeFor(receipt: BattleReceipt, receiptHash?: Hex): SignedReceiptEnvelope { + const hash = receiptHash ?? hashBattleReceipt(receipt); + return { + receiptHash: hash, + signature: signWithTestKey(hash), + signingKeyId: receipt.signingKeyId, + payload: toWireJson(receipt) as SignedReceiptEnvelope['payload'], + }; +} diff --git a/verifier/tests/io/loadRulesets.test.ts b/verifier/tests/io/loadRulesets.test.ts new file mode 100644 index 00000000..b3a83ef6 --- /dev/null +++ b/verifier/tests/io/loadRulesets.test.ts @@ -0,0 +1,91 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { hashRuleset, publishRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { builtInRulesets, loadRulesets } from '../../src/io/loadRulesets'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'verifier-loadrulesets-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('builtInRulesets', () => { + it('holds this build source-default ruleset, keyed by its own hash', () => { + const registry = builtInRulesets(); + expect(registry.get(hashRuleset(SOURCE_DEFAULT_RULESET).toLowerCase())).toEqual(SOURCE_DEFAULT_RULESET); + }); + + it('returns a fresh map each call, so a caller merging into it cannot leak across runs', () => { + const first = builtInRulesets(); + first.set('0xdeadbeef', SOURCE_DEFAULT_RULESET); + expect(builtInRulesets().has('0xdeadbeef')).toBe(false); + }); +}); + +describe('loadRulesets', () => { + it('loads the { ..., bundle } shape GET /api/battle/rulesets/:hash serves', async () => { + const { hash, json } = publishRuleset(SOURCE_DEFAULT_RULESET); + const file = join(dir, 'ruleset.json'); + await writeFile(file, JSON.stringify({ rulesetHash: hash, version: 1, bundle: JSON.parse(json) })); + + const registry = await loadRulesets(file); + expect(registry.get(hash.toLowerCase())).toEqual(SOURCE_DEFAULT_RULESET); + }); + + it('loads a bare bundle object saved to a file by hand', async () => { + const { hash, json } = publishRuleset(SOURCE_DEFAULT_RULESET); + const file = join(dir, 'bare.json'); + await writeFile(file, json); + + const registry = await loadRulesets(file); + expect(registry.get(hash.toLowerCase())).toEqual(SOURCE_DEFAULT_RULESET); + }); + + it('loads an array of bundles', async () => { + const tweaked = { ...SOURCE_DEFAULT_RULESET, version: 2 }; + const file = join(dir, 'many.json'); + await writeFile( + file, + JSON.stringify([JSON.parse(publishRuleset(SOURCE_DEFAULT_RULESET).json), JSON.parse(publishRuleset(tweaked).json)]), + ); + + const registry = await loadRulesets(file); + expect(registry.size).toBe(2); + expect(registry.get(hashRuleset(tweaked).toLowerCase())).toEqual(tweaked); + }); + + it('keys by the hash recomputed from the contents, not by one the source claimed', async () => { + // The whole point of content addressing: a source cannot make a bundle answer to a + // hash it does not actually have. + const { json } = publishRuleset(SOURCE_DEFAULT_RULESET); + const file = join(dir, 'lying.json'); + await writeFile(file, JSON.stringify({ rulesetHash: `0x${'99'.repeat(32)}`, bundle: JSON.parse(json) })); + + const registry = await loadRulesets(file); + expect(registry.has(`0x${'99'.repeat(32)}`)).toBe(false); + expect(registry.has(hashRuleset(SOURCE_DEFAULT_RULESET).toLowerCase())).toBe(true); + }); + + it('rejects a bundle carrying unknown keys', async () => { + // Two documents answering to one hash would leave a reader unable to tell which + // the battle used. + const { json } = publishRuleset(SOURCE_DEFAULT_RULESET); + const file = join(dir, 'extra.json'); + await writeFile(file, JSON.stringify({ ...JSON.parse(json), surprise: 1 })); + await expect(loadRulesets(file)).rejects.toThrow(/unexpected keys/); + }); + + it('rejects a non-object entry', async () => { + const file = join(dir, 'bad.json'); + await writeFile(file, JSON.stringify(['not-an-object'])); + await expect(loadRulesets(file)).rejects.toThrow(/must be an object/); + }); +}); diff --git a/verifier/tests/verify.test.ts b/verifier/tests/verify.test.ts index b606df8c..c3aca55e 100644 --- a/verifier/tests/verify.test.ts +++ b/verifier/tests/verify.test.ts @@ -1,18 +1,125 @@ -import { hashBattleReceipt } from '@cryptopets/protocol'; +import { hashBattleReceipt, hashRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; import { describe, expect, it } from 'vitest'; +import { builtInRulesets } from '../src/io/loadRulesets'; import { verifyReceipts } from '../src/verify'; -import { buildSignedReceipt } from './fixtures/signedReceipt'; +import { buildReceipt, buildSignedReceipt, envelopeFor, FORGED_BEACON, testTrustedKey } from './fixtures/signedReceipt'; + +/** Every check a single well-formed receipt is expected to produce, in pipeline order. */ +const SINGLE_RECEIPT_CHECKS = [ + 'seed-derivation', + 'operator-signature', + 'beacon-signature', + 'combat-replay', + 'progression', + 'chain-continuity', +]; + +function checksByName(results: { check: string; ok: boolean; detail?: string }[]) { + return new Map(results.map((result) => [result.check, result])); +} describe('verifyReceipts', () => { - it('passes a single well-formed, correctly-signed receipt', () => { + it('passes every check for a single well-formed, correctly-signed receipt', () => { const { envelope, trustedKey } = buildSignedReceipt(); const report = verifyReceipts([envelope], [trustedKey]); + expect(report.results.map((r) => r.check)).toEqual(SINGLE_RECEIPT_CHECKS); expect(report.ok).toBe(true); - expect(report.results.map((r) => r.check)).toEqual(['operator-signature', 'chain-continuity']); }); - it('reports both an operator-signature failure and continuity for an unbroken but untrusted run', () => { + it('reports every failure rather than stopping at the first', () => { + // A forged beacon and an inflated progression at once: both must be named. + const receipt = buildReceipt({ + beacon: FORGED_BEACON, + patch: { + progression: { + ...buildReceipt({ beacon: FORGED_BEACON }).progression, + attacker: { ...buildReceipt({ beacon: FORGED_BEACON }).progression.attacker, xp: 9999 }, + }, + }, + }); + const report = verifyReceipts([envelopeFor(receipt)], [testTrustedKey()]); + const byName = checksByName(report.results); + + expect(report.ok).toBe(false); + expect(byName.get('beacon-signature')?.ok).toBe(false); + expect(byName.get('progression')?.ok).toBe(false); + // The seed still derives honestly from the forged beacon, and the fight itself was + // run on that seed — so those two checks pass, which is the accurate answer. + expect(byName.get('seed-derivation')?.ok).toBe(true); + expect(byName.get('combat-replay')?.ok).toBe(true); + }); + + it('names a chosen seed as its own failure, not merely a malformed receipt', () => { + const receipt = buildReceipt({ patch: { seed: `0x${'99'.repeat(32)}` } }); + // The receipt cannot be hashed, so the envelope carries a stand-in hash. + const envelope = envelopeFor(receipt, `0x${'aa'.repeat(32)}`); + + const report = verifyReceipts([envelope], [testTrustedKey()]); + const byName = checksByName(report.results); + + expect(report.ok).toBe(false); + expect(byName.get('seed-derivation')?.ok).toBe(false); + expect(byName.get('seed-derivation')?.detail).toMatch(/its own inputs derive/); + // It is also malformed, and saying so is honest; the point is that the specific + // accusation is not buried underneath the shape complaint. + expect(byName.get('malformed-receipt')?.ok).toBe(false); + }); + + it('excludes a malformed receipt from the chain walk instead of crashing it', () => { + const good = buildSignedReceipt({ battleId: 'btl_0001' }); + const broken = envelopeFor(buildReceipt({ battleId: 'btl_0002', patch: { seed: `0x${'99'.repeat(32)}` } }), `0x${'aa'.repeat(32)}`); + + const report = verifyReceipts([good.envelope, broken], [good.trustedKey]); + // One well-formed member left, so continuity still runs and still passes. + expect(checksByName(report.results).get('chain-continuity')?.ok).toBe(true); + expect(report.ok).toBe(false); + }); + + it('fails a receipt whose fight result does not match a replay of its own inputs', () => { + const honest = buildReceipt(); + const receipt = buildReceipt({ + patch: { result: { ...honest.result, rounds: honest.result.rounds + 1 } }, + }); + const report = verifyReceipts([envelopeFor(receipt)], [testTrustedKey()]); + const replay = checksByName(report.results).get('combat-replay'); + + expect(replay?.ok).toBe(false); + expect(replay?.detail).toMatch(/rounds: replay=\d+ receipt=\d+/); + }); + + it('fails a receipt whose combat log hash does not match the replayed log', () => { + const receipt = buildReceipt({ patch: { combatLogHash: `0x${'cc'.repeat(32)}` } }); + const report = verifyReceipts([envelopeFor(receipt)], [testTrustedKey()]); + const replay = checksByName(report.results).get('combat-replay'); + + expect(replay?.ok).toBe(false); + expect(replay?.detail).toMatch(/combatLogHash/); + }); + + it('fails closed when the named ruleset bundle was not supplied', () => { + // A receipt naming a ruleset this build does not have: replay and progression + // cannot run, and reporting them as passed would be a lie. + const receipt = buildReceipt({ rulesetHash: `0x${'77'.repeat(32)}` }); + const report = verifyReceipts([envelopeFor(receipt)], [testTrustedKey()], { + rulesets: new Map(), + }); + const byName = checksByName(report.results); + + expect(report.ok).toBe(false); + expect(byName.get('ruleset-unavailable')?.ok).toBe(false); + expect(byName.has('combat-replay')).toBe(false); + expect(byName.has('progression')).toBe(false); + }); + + it('uses the built-in source-default ruleset when none is supplied', () => { + const { envelope, trustedKey } = buildSignedReceipt(); + expect(envelope.payload.rulesetHash.toLowerCase()).toBe(hashRuleset(SOURCE_DEFAULT_RULESET).toLowerCase()); + expect(builtInRulesets().has(envelope.payload.rulesetHash.toLowerCase())).toBe(true); + expect(verifyReceipts([envelope], [trustedKey]).ok).toBe(true); + }); + + it('reports untrusted signatures per receipt while still walking the chain', () => { const first = buildSignedReceipt({ battleId: 'btl_0001' }); const second = buildSignedReceipt({ battleId: 'btl_0002', @@ -20,37 +127,14 @@ describe('verifyReceipts', () => { previousReceiptHash: hashBattleReceipt(first.receipt), createdAt: first.receipt.createdAt + 1, }); - // No trusted keys supplied: both signatures fail closed, but the chain itself is intact. + // No trusted keys: both signatures fail closed, but the chain itself is intact. const report = verifyReceipts([first.envelope, second.envelope], []); - expect(report.ok).toBe(false); - const signatureFailures = report.results.filter((r) => r.check === 'operator-signature'); - expect(signatureFailures).toHaveLength(2); - expect(signatureFailures.every((r) => !r.ok)).toBe(true); - expect(report.results.find((r) => r.check === 'chain-continuity')).toEqual({ - check: 'chain-continuity', - ok: true, - }); - }); + const signatureResults = report.results.filter((r) => r.check === 'operator-signature'); - it('reports a malformed-receipt failure and excludes it from the chain walk, without throwing', () => { - const { envelope, trustedKey } = buildSignedReceipt(); - // A seed that does not follow from the receipt's own inputs: `assertBattleReceipt` - // rejects this (`receipt.test.ts` pins the same check in `protocol`), so this - // receipt never becomes a typed `BattleReceipt` at all. - const malformed = { - ...envelope, - payload: { ...(envelope.payload as Record), seed: `0x${'99'.repeat(32)}` } as never, - }; - - const report = verifyReceipts([malformed], [trustedKey]); expect(report.ok).toBe(false); - expect(report.results).toEqual([ - { - check: 'malformed-receipt', - ok: false, - detail: expect.stringContaining(malformed.receiptHash), - }, - ]); + expect(signatureResults).toHaveLength(2); + expect(signatureResults.every((r) => !r.ok)).toBe(true); + expect(checksByName(report.results).get('chain-continuity')?.ok).toBe(true); }); it('passes an empty input with no results at all', () => { From dcecc8a4a067e1b8a363988ba29e9127e8f0aad3 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 14:56:12 -0400 Subject: [PATCH 33/76] feat(verifier): pin ruleset artifacts and run receipt verification in CI --- .github/workflows/verifier.yml | 59 ++++ verifier/README.md | 56 +++- verifier/fixtures/corpus-tampered.json | 284 ++++++++++++++++++ verifier/fixtures/corpus.json | 284 ++++++++++++++++++ verifier/fixtures/signing-keys.json | 8 + verifier/package.json | 1 + ...5965c8ec6ac12a7206fdcf5248d57a63a2ed8.json | 17 ++ verifier/scripts/gen-corpus.ts | 50 +++ verifier/src/checks/types.ts | 9 + verifier/src/cli.ts | 16 +- verifier/src/index.ts | 1 + verifier/src/ruleset.ts | 59 ++++ verifier/src/verify.ts | 46 +-- verifier/tests/corpus.test.ts | 94 ++++++ verifier/tests/fixtures/corpus.ts | 97 ++++++ verifier/tests/fixtures/signedReceipt.ts | 6 +- verifier/tests/ruleset.test.ts | 41 +++ verifier/tsconfig.json | 2 +- 18 files changed, 1093 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/verifier.yml create mode 100644 verifier/fixtures/corpus-tampered.json create mode 100644 verifier/fixtures/corpus.json create mode 100644 verifier/fixtures/signing-keys.json create mode 100644 verifier/rulesets/0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8.json create mode 100644 verifier/scripts/gen-corpus.ts create mode 100644 verifier/src/ruleset.ts create mode 100644 verifier/tests/corpus.test.ts create mode 100644 verifier/tests/fixtures/corpus.ts create mode 100644 verifier/tests/ruleset.test.ts diff --git a/.github/workflows/verifier.yml b/.github/workflows/verifier.yml new file mode 100644 index 00000000..6f465d39 --- /dev/null +++ b/.github/workflows/verifier.yml @@ -0,0 +1,59 @@ +name: Verifier + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: verifier-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The verifier's suite builds real receipts through @cryptopets/protocol — real + # signatures, real drand fixtures, real combat simulation — so a protocol regression + # surfaces here too, without needing a separate step for it. + - name: Verifier tests + run: pnpm --filter @cryptopets/verifier test + + - name: Verifier lint + if: always() + run: pnpm --filter @cryptopets/verifier lint + + # The committed corpus, run through the actual CLI rather than the library, so the + # thing a third party would run is the thing CI proves still works. No network + # access: the ruleset these battles were fought under is pinned in the checkout. + - name: Honest corpus must verify + if: always() + run: pnpm --filter @cryptopets/verifier cli -- fixtures/corpus.json --keys fixtures/signing-keys.json + + # The half that stops this from being theatre. "The honest corpus verifies" is also + # true of a verifier that has quietly degraded into always passing; only this step + # notices that. + - name: Tampered corpus must be rejected + if: always() + run: | + if pnpm --filter @cryptopets/verifier cli -- fixtures/corpus-tampered.json --keys fixtures/signing-keys.json; then + echo "::error::The tampered corpus verified successfully. The verifier is not actually checking anything." + exit 1 + fi + echo "Tampered corpus rejected, as expected." diff --git a/verifier/README.md b/verifier/README.md index 601f9628..a2761022 100644 --- a/verifier/README.md +++ b/verifier/README.md @@ -51,9 +51,39 @@ walk. A receipt naming a ruleset bundle the caller did not supply is reported as `ruleset-unavailable`, and its replay and progression checks do not run — reporting those as passed would be a lie, and omitting them silently would read as a clean bill of health. -**Not yet covered** (Step 32): fetching and pinning content-addressed ruleset artifacts -automatically, and a CI job running this over a committed corpus fixture. Merkle inclusion proofs -arrive with the batch registry (Group G). +**Not yet covered**: Merkle inclusion proofs, which arrive with the batch registry (Group G). + +## Pinned ruleset artifacts + +`rulesets/.json` holds the published bundles, committed as plain JSON, one file per +ruleset, each named for its own hash. + +Content addressing already makes a bundle's *integrity* independent of where it came from — a +receipt names a `rulesetHash`, and a bundle either hashes to it or does not get used. What it does +not give you is *availability*. If the only copy of the rules a 2026 battle was fought under lives +on an endpoint the operator runs, then replaying that battle in 2030 needs the operator to still be +serving it, and "you can check our homework, as long as we hand you the textbook" is a weaker claim +than §H is making. Pinning them here means a checkout is enough. + +The filename is checked against the hash recomputed from the file's contents at load, so a +corrupted or mislabelled artifact fails loudly rather than quietly answering to a hash it does not +have. This includes the ruleset the current build implements: `ENGINE_VERSION` bumps eventually, +and when it does, today's ruleset becomes a historical one whose only durable copy is that file. + +## Committed corpus + +`fixtures/` holds a regression corpus that CI (`.github/workflows/verifier.yml`) runs on every PR: + +- `corpus.json` — three linked receipts under one signing key. Must verify. +- `corpus-tampered.json` — the same chain with one receipt's beacon and fight result altered. Must + **fail**. A corpus that only ever proves the verifier passes would be satisfied just as well by a + verifier that had degraded into always passing, which is the regression actually worth guarding. +- `signing-keys.json` — the key those signatures recover to. + +Regenerate with `pnpm --filter @cryptopets/verifier corpus`. Everything in the generator is +deterministic (fixed test key, RFC6979 deterministic ECDSA, a real but fixed drand round, a fixed +snapshot), so regenerating produces no diff unless something that matters changed — +`tests/corpus.test.ts` asserts exactly that. ## Usage @@ -69,14 +99,18 @@ pnpm --filter @cryptopets/verifier cli -- \ Output is one line per check, and the process exits non-zero if any failed: ```text -[PASS] seed-derivation -[PASS] operator-signature -[FAIL] beacon-signature: drand round 1000 does not verify against chain 0x52db9ba7... -[PASS] combat-replay -[PASS] progression -[PASS] chain-continuity +[PASS] btl_0001 seed-derivation +[PASS] btl_0001 operator-signature +[FAIL] btl_0001 beacon-signature: drand round 1000 does not verify against chain 0x52db9ba7... +[PASS] btl_0001 combat-replay +[PASS] btl_0001 progression +[FAIL] chain-continuity: receipt at index 2 (battleId btl_0003): broken-link ``` +Each line names the receipt it is about, so a corpus of hundreds stays attributable. +`chain-continuity` is the one check about a *run* rather than a single receipt, so it names the +offending position in its detail instead. + Programmatically, `verifyReceipts(envelopes, trustedKeys, { rulesets })` returns the same results as `{ results, ok }`. @@ -92,8 +126,8 @@ Both flags default to the safe answer rather than the convenient one: - Omitting `--keys` does not skip the operator-signature check. It means no key is trusted, so every receipt fails that check rather than silently passing one nobody actually verified. -- Omitting `--rulesets` falls back to this build's source-default ruleset only. A battle fought - under tuned `GameConfig` values then reports `ruleset-unavailable` instead of being replayed +- Omitting `--rulesets` falls back to the bundles pinned into this package (see below). A battle + fought under a ruleset nobody pinned then reports `ruleset-unavailable` instead of being replayed against the wrong numbers. ## Consumption diff --git a/verifier/fixtures/corpus-tampered.json b/verifier/fixtures/corpus-tampered.json new file mode 100644 index 00000000..3542858f --- /dev/null +++ b/verifier/fixtures/corpus-tampered.json @@ -0,0 +1,284 @@ +{ + "receipts": [ + { + "receiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", + "signature": "0xb4b8bbc088c6abe5c8e7e3a5a3e8131de91c80bb305784096df71da3f3ea22b442b633b06f2026970b832bacefe8edc4f7d2ebae591f56435653c0e8c7d720391b", + "signingKeyId": "battle-signer-2026-07", + "payload": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "battleId": "btl_0001", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" + }, + "seed": "0x3a43529c116460f0bfc4c23ea9a07a13379f301907411f269fcfbf305c523ee9", + "rulesetVersion": 1, + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "result": { + "attackerWon": true, + "rounds": 6, + "winnerHpRemaining": 163 + }, + "combatLogHash": "0x9b29c8103de014db6af3ce262da082bb7084a3b971b8eded1ec02cbe6103e491", + "progression": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 0, + "xpAwarded": 110, + "lastOpponentId": "2", + "streak": 0, + "level": 10, + "xp": 230, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 3, + "xpAwarded": 2, + "lastOpponentId": "1", + "streak": 3, + "level": 11, + "xp": 47, + "leveledUp": false + } + }, + "sequence": 1, + "previousReceiptHash": null, + "attackerPreviousReceiptHash": null, + "defenderPreviousReceiptHash": null, + "createdAt": 1692806368, + "signingKeyId": "battle-signer-2026-07" + } + }, + { + "receiptHash": "0x708856ad23500d0c3b16f04edccd21dbe84533f41fdb3dba177bcce1c43d3d5c", + "signature": "0xa1e69c666d9a55973ea9e441677a5db335297a6d17856f38c5050383edec759e7e13500b3686303d89b9ef4774e842e806bcec268cf8947d367d85a652ec96121b", + "signingKeyId": "battle-signer-2026-07", + "payload": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "battleId": "btl_0002", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0x971cbe88adc436f6411fd26d51887ede7ba144264cd05edec6645b5e170a7702d16082947a85d89c89cb47cd8eb7d817", + "randomness": "0x36ecd957580ee415f951370e2a5e13273be97de9072418aaf14d38242979e3c1" + }, + "seed": "0x0ca434dfbcae5a627c36579aef46a1c08bd73101308afe21fb62fc93d99b6a24", + "rulesetVersion": 1, + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "result": { + "attackerWon": true, + "rounds": 5, + "winnerHpRemaining": 217 + }, + "combatLogHash": "0x7af42056e7a4fd4ab1c33e6dad3f6b88b4e2dce9e04ee3faccd2e5895aed2fe2", + "progression": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 0, + "xpAwarded": 110, + "lastOpponentId": "2", + "streak": 0, + "level": 10, + "xp": 230, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 3, + "xpAwarded": 2, + "lastOpponentId": "1", + "streak": 3, + "level": 11, + "xp": 47, + "leveledUp": false + } + }, + "sequence": 2, + "previousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", + "attackerPreviousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", + "defenderPreviousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", + "createdAt": 1692806369, + "signingKeyId": "battle-signer-2026-07" + } + }, + { + "receiptHash": "0xb6706876dca8338897b8e55665072ca7d5069e52ef5bada6756e3e3fac67b385", + "signature": "0xbc71375a7af530b9fcb324133b45088fc73db95884f51d337ff76f18eb826d7330411780d1f8b19d7247b6937f15046aee72b7419619813d2b8440751f183a571b", + "signingKeyId": "battle-signer-2026-07", + "payload": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "battleId": "btl_0003", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" + }, + "seed": "0xbedd2dc5371c75eb1502e3657d2c9febc81c899740e919259e15d9a851cbb844", + "rulesetVersion": 1, + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "result": { + "attackerWon": true, + "rounds": 5, + "winnerHpRemaining": 217 + }, + "combatLogHash": "0xe5e54f05aa6be342cfb124b047f0987acfe75651be82c374b8b5155a3f354671", + "progression": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 0, + "xpAwarded": 110, + "lastOpponentId": "2", + "streak": 0, + "level": 10, + "xp": 230, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 3, + "xpAwarded": 2, + "lastOpponentId": "1", + "streak": 3, + "level": 11, + "xp": 47, + "leveledUp": false + } + }, + "sequence": 3, + "previousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", + "attackerPreviousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", + "defenderPreviousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", + "createdAt": 1692806370, + "signingKeyId": "battle-signer-2026-07" + } + } + ], + "nextCursor": null +} diff --git a/verifier/fixtures/corpus.json b/verifier/fixtures/corpus.json new file mode 100644 index 00000000..62dda64a --- /dev/null +++ b/verifier/fixtures/corpus.json @@ -0,0 +1,284 @@ +{ + "receipts": [ + { + "receiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", + "signature": "0xb4b8bbc088c6abe5c8e7e3a5a3e8131de91c80bb305784096df71da3f3ea22b442b633b06f2026970b832bacefe8edc4f7d2ebae591f56435653c0e8c7d720391b", + "signingKeyId": "battle-signer-2026-07", + "payload": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "battleId": "btl_0001", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" + }, + "seed": "0x3a43529c116460f0bfc4c23ea9a07a13379f301907411f269fcfbf305c523ee9", + "rulesetVersion": 1, + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "result": { + "attackerWon": true, + "rounds": 6, + "winnerHpRemaining": 163 + }, + "combatLogHash": "0x9b29c8103de014db6af3ce262da082bb7084a3b971b8eded1ec02cbe6103e491", + "progression": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 0, + "xpAwarded": 110, + "lastOpponentId": "2", + "streak": 0, + "level": 10, + "xp": 230, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 3, + "xpAwarded": 2, + "lastOpponentId": "1", + "streak": 3, + "level": 11, + "xp": 47, + "leveledUp": false + } + }, + "sequence": 1, + "previousReceiptHash": null, + "attackerPreviousReceiptHash": null, + "defenderPreviousReceiptHash": null, + "createdAt": 1692806368, + "signingKeyId": "battle-signer-2026-07" + } + }, + { + "receiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", + "signature": "0xfae9832c1596e1a6c0d6eb602f6632688a3906fd3df8f4ddf19e82228eeb45564f50036db14bf9a407900b05924ed47484c4f9db15a988c75a56cbeb5ef633611b", + "signingKeyId": "battle-signer-2026-07", + "payload": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "battleId": "btl_0002", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" + }, + "seed": "0xfee7c217ea4a80e130ba2cd6a3b3ff1472176f76ffcf32fbf36ab1ef6d3cc941", + "rulesetVersion": 1, + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "result": { + "attackerWon": true, + "rounds": 4, + "winnerHpRemaining": 235 + }, + "combatLogHash": "0xc5acdcf5dee811d8d109727c3867a6db28f102046c29031eb04adb42e19b4449", + "progression": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 0, + "xpAwarded": 110, + "lastOpponentId": "2", + "streak": 0, + "level": 10, + "xp": 230, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 3, + "xpAwarded": 2, + "lastOpponentId": "1", + "streak": 3, + "level": 11, + "xp": 47, + "leveledUp": false + } + }, + "sequence": 2, + "previousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", + "attackerPreviousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", + "defenderPreviousReceiptHash": "0x5a7e76a7d8b5676b575d7afcd836f461aacc303152a2932e02afba38cb5cf245", + "createdAt": 1692806369, + "signingKeyId": "battle-signer-2026-07" + } + }, + { + "receiptHash": "0xb6706876dca8338897b8e55665072ca7d5069e52ef5bada6756e3e3fac67b385", + "signature": "0xbc71375a7af530b9fcb324133b45088fc73db95884f51d337ff76f18eb826d7330411780d1f8b19d7247b6937f15046aee72b7419619813d2b8440751f183a571b", + "signingKeyId": "battle-signer-2026-07", + "payload": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "battleId": "btl_0003", + "intentHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "commitmentHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "defenseAuthorizationHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "snapshot": { + "domain": { + "chainId": "eip155:84532", + "deploymentId": "base-sepolia-live" + }, + "attacker": { + "petId": "1", + "owner": "0xabcdef0123456789abcdef0123456789abcdef01", + "dna": "1234567890123456", + "rarity": 3, + "level": 10, + "skill": 4, + "xp": 120, + "lastOpponentId": "0", + "streak": 0, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "defender": { + "petId": "2", + "owner": "0x2222222222222222222222222222222222222222", + "dna": "6543210987654321", + "rarity": 2, + "level": 11, + "skill": 7, + "xp": 45, + "lastOpponentId": "1", + "streak": 2, + "readyAt": 1692806267, + "sourceVersion": "1692806317" + }, + "takenAt": 1692806361 + }, + "beacon": { + "chainHash": "0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + "round": 1000, + "signature": "0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39", + "randomness": "0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" + }, + "seed": "0xbedd2dc5371c75eb1502e3657d2c9febc81c899740e919259e15d9a851cbb844", + "rulesetVersion": 1, + "rulesetHash": "0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8", + "result": { + "attackerWon": true, + "rounds": 5, + "winnerHpRemaining": 217 + }, + "combatLogHash": "0xe5e54f05aa6be342cfb124b047f0987acfe75651be82c374b8b5155a3f354671", + "progression": { + "attacker": { + "petId": "1", + "won": true, + "decayShift": 0, + "xpAwarded": 110, + "lastOpponentId": "2", + "streak": 0, + "level": 10, + "xp": 230, + "leveledUp": false + }, + "defender": { + "petId": "2", + "won": false, + "decayShift": 3, + "xpAwarded": 2, + "lastOpponentId": "1", + "streak": 3, + "level": 11, + "xp": 47, + "leveledUp": false + } + }, + "sequence": 3, + "previousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", + "attackerPreviousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", + "defenderPreviousReceiptHash": "0x4bdb4bc231f1e41c3205c1d1da88a1c94c3fed2ad38f91fdcc019df85fdab12c", + "createdAt": 1692806370, + "signingKeyId": "battle-signer-2026-07" + } + } + ], + "nextCursor": null +} diff --git a/verifier/fixtures/signing-keys.json b/verifier/fixtures/signing-keys.json new file mode 100644 index 00000000..4a97ecd2 --- /dev/null +++ b/verifier/fixtures/signing-keys.json @@ -0,0 +1,8 @@ +{ + "keys": [ + { + "keyId": "battle-signer-2026-07", + "address": "0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a" + } + ] +} diff --git a/verifier/package.json b/verifier/package.json index e9a4a8c7..f04940cc 100644 --- a/verifier/package.json +++ b/verifier/package.json @@ -11,6 +11,7 @@ }, "scripts": { "cli": "tsx src/cli.ts", + "corpus": "tsx scripts/gen-corpus.ts", "lint": "pnpm exec eslint .", "lint:fix": "pnpm exec eslint . --fix", "typecheck": "pnpm exec tsc --noEmit", diff --git a/verifier/rulesets/0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8.json b/verifier/rulesets/0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8.json new file mode 100644 index 00000000..ce8606a9 --- /dev/null +++ b/verifier/rulesets/0x6175c04e45eac5c8f3f5d319f825965c8ec6ac12a7206fdcf5248d57a63a2ed8.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "engineId": "cryptopets-combat-ts", + "engineVersion": 1, + "maxRounds": 30, + "maxLevel": 100, + "skillConfig": { + "tankHpMult": 120, + "shellDefMult": 125, + "swiftCritBonus": 50, + "cunningCritCap": 4000, + "furyDmgMult": 130, + "furyHpThreshold": 3000, + "sageMdefMult": 125, + "bloodlustBps": 150 + } +} diff --git a/verifier/scripts/gen-corpus.ts b/verifier/scripts/gen-corpus.ts new file mode 100644 index 00000000..94b9f3a8 --- /dev/null +++ b/verifier/scripts/gen-corpus.ts @@ -0,0 +1,50 @@ +/** + * Regenerates this package's committed artifacts: + * + * - `rulesets/.json`, the pinned ruleset bundles (§H item 2) + * - `fixtures/corpus.json`, a chain of valid signed receipts + * - `fixtures/signing-keys.json`, the trusted key that signed them + * - `fixtures/corpus-tampered.json`, the same chain with one receipt altered + * + * Run with `pnpm --filter @cryptopets/verifier corpus`. + * + * Everything here is deterministic — a fixed test key, RFC6979 deterministic ECDSA, a real + * but fixed drand round, and a fixed snapshot — so regenerating produces no diff unless + * something that actually matters changed. `tests/corpus.test.ts` asserts exactly that, + * which is what makes a diff here meaningful rather than noise. + * + * The receipt-building helpers are imported from `tests/fixtures/` on purpose: there + * should be exactly one definition of what a valid signed receipt looks like, and + * duplicating it into a script is how the committed corpus and the unit tests would + * quietly drift apart. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { publishRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; + +import { buildCorpus, buildTamperedCorpus, corpusSigningKeys } from '../tests/fixtures/corpus'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const RULESETS_DIR = join(HERE, '../rulesets'); +const FIXTURES_DIR = join(HERE, '../fixtures'); + +function writeJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + console.log(`wrote ${path}`); +} + +mkdirSync(RULESETS_DIR, { recursive: true }); +mkdirSync(FIXTURES_DIR, { recursive: true }); + +// The ruleset this build implements, pinned so a battle fought under it stays replayable +// after ENGINE_VERSION moves on. `serializeRuleset` already emits a trailing newline. +const { hash, json } = publishRuleset(SOURCE_DEFAULT_RULESET); +const rulesetPath = join(RULESETS_DIR, `${hash.toLowerCase()}.json`); +writeFileSync(rulesetPath, json, 'utf8'); +console.log(`wrote ${rulesetPath}`); + +writeJson(join(FIXTURES_DIR, 'corpus.json'), buildCorpus()); +writeJson(join(FIXTURES_DIR, 'corpus-tampered.json'), buildTamperedCorpus()); +writeJson(join(FIXTURES_DIR, 'signing-keys.json'), corpusSigningKeys()); diff --git a/verifier/src/checks/types.ts b/verifier/src/checks/types.ts index c335028e..e45b8a7d 100644 --- a/verifier/src/checks/types.ts +++ b/verifier/src/checks/types.ts @@ -3,4 +3,13 @@ export interface CheckResult { check: string; ok: boolean; detail?: string; + /** + * Which receipt this result is about, as its `battleId` (or its hash, when the receipt + * was too malformed to have a readable id). + * + * Absent for checks that are about a *run* rather than a single receipt — + * `chain-continuity` is the only one — which name the offending position in their + * detail instead. + */ + subject?: string; } diff --git a/verifier/src/cli.ts b/verifier/src/cli.ts index c4df59f2..61a2bc9c 100644 --- a/verifier/src/cli.ts +++ b/verifier/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node -import { builtInRulesets, loadReceipts, loadRulesets, loadSigningKeys } from './io'; +import { loadReceipts, loadRulesets, loadSigningKeys } from './io'; +import { pinnedRulesets } from './ruleset'; import { verifyReceipts } from './verify'; /** @@ -9,12 +10,14 @@ import { verifyReceipts } from './verify'; * * Both flags default to the safe answer rather than the convenient one. Omitting `--keys` * is not "skip the signature check": it means no key is trusted, so every receipt's - * operator-signature check fails. Omitting `--rulesets` falls back to this build's - * source-default ruleset only, so a battle fought under tuned `GameConfig` values reports + * operator-signature check fails. Omitting `--rulesets` falls back to the bundles pinned + * into this package, so a battle fought under a ruleset nobody pinned reports * `ruleset-unavailable` instead of being replayed against the wrong numbers. */ async function main(): Promise { - const args = process.argv.slice(2); + // `pnpm run cli -- foo.json` forwards the `--` itself rather than consuming it, so a + // bare separator is dropped here instead of being mistaken for the receipt source. + const args = process.argv.slice(2).filter((arg) => arg !== '--'); const receiptSource = args[0]; if (!receiptSource || receiptSource.startsWith('--')) { console.error('usage: cryptopets-verify [--keys ] [--rulesets ]'); @@ -28,7 +31,7 @@ async function main(): Promise { const envelopes = await loadReceipts(receiptSource); const trustedKeys = keysSource ? await loadSigningKeys(keysSource) : []; - const rulesets = builtInRulesets(); + const rulesets = pinnedRulesets(); if (rulesetsSource) { for (const [hash, ruleset] of await loadRulesets(rulesetsSource)) { rulesets.set(hash, ruleset); @@ -37,7 +40,8 @@ async function main(): Promise { const report = verifyReceipts(envelopes, trustedKeys, { rulesets }); for (const result of report.results) { - console.log(`[${result.ok ? 'PASS' : 'FAIL'}] ${result.check}${result.detail ? `: ${result.detail}` : ''}`); + const subject = result.subject ? `${result.subject} ` : ''; + console.log(`[${result.ok ? 'PASS' : 'FAIL'}] ${subject}${result.check}${result.detail ? `: ${result.detail}` : ''}`); } process.exitCode = report.ok ? 0 : 1; } diff --git a/verifier/src/index.ts b/verifier/src/index.ts index bd410a76..ca7e5690 100644 --- a/verifier/src/index.ts +++ b/verifier/src/index.ts @@ -11,4 +11,5 @@ export * from './checks'; export * from './io'; +export * from './ruleset'; export * from './verify'; diff --git a/verifier/src/ruleset.ts b/verifier/src/ruleset.ts new file mode 100644 index 00000000..3da9f27c --- /dev/null +++ b/verifier/src/ruleset.ts @@ -0,0 +1,59 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { hashRuleset, parseRulesetBundle, type Ruleset } from '@cryptopets/protocol'; + +/** + * Ruleset artifacts pinned into this package (§H item 2). + * + * Content addressing already makes a bundle's *integrity* independent of where it came + * from: a receipt names a `rulesetHash`, and a bundle either hashes to it or does not get + * used. What content addressing does not give you is *availability*. If the only copy of + * the rules a 2026 battle was fought under lives on an endpoint we operate, then replaying + * that battle in 2030 needs us to still be serving it, and "you can check our homework, as + * long as we hand you the textbook" is a weaker claim than §H is making. + * + * So the bundles are committed here as plain JSON, one file per ruleset, named for its own + * hash. Anyone with a checkout can replay a historical battle with no network access at + * all. This includes the ruleset the current build implements: `ENGINE_VERSION` bumps + * eventually, and when it does, today's ruleset becomes a historical one whose only + * durable copy is this file. + * + * The filename is not decoration. It is checked against the hash recomputed from the + * file's own contents, so a corrupted or mislabelled artifact fails loudly at load rather + * than quietly answering to a hash it does not have. + */ + +const PINNED_DIR = join(dirname(fileURLToPath(import.meta.url)), '../rulesets'); + +/** Absolute path to the pinned-artifact directory. Exported so tests can enumerate it. */ +export const PINNED_RULESETS_DIR = PINNED_DIR; + +/** + * Every pinned bundle, keyed by lowercase `rulesetHash`. + * + * Throws if any artifact does not hash to its own filename. That is a repository + * integrity problem, not a verification result, and treating it as the latter would mean + * reporting "this battle could not be replayed" when the truth is "our copy of the rules + * is corrupt". + */ +export function pinnedRulesets(): Map { + const registry = new Map(); + for (const filename of readdirSync(PINNED_DIR)) { + if (!filename.endsWith('.json')) continue; + const ruleset = parseRulesetBundle(readFileSync(join(PINNED_DIR, filename), 'utf8')); + const actual = hashRuleset(ruleset).toLowerCase(); + const claimed = filename.slice(0, -'.json'.length).toLowerCase(); + if (actual !== claimed) { + throw new Error(`pinned ruleset ${filename} hashes to ${actual}, not to the hash it is named for`); + } + registry.set(actual, ruleset); + } + return registry; +} + +/** The filename a bundle must be pinned under. */ +export function pinnedRulesetFilename(ruleset: Ruleset): string { + return `${hashRuleset(ruleset).toLowerCase()}.json`; +} diff --git a/verifier/src/verify.ts b/verifier/src/verify.ts index 406932e6..c552792a 100644 --- a/verifier/src/verify.ts +++ b/verifier/src/verify.ts @@ -63,6 +63,7 @@ export function verifyReceipts( results.push(...verifyOne(envelope, trustedKeys, rulesets, wellFormed)); } + if (wellFormed.length > 0) { results.push(checkChainContinuity(wellFormed)); } @@ -80,42 +81,53 @@ function verifyOne( try { converted = receiptFromWire(envelope.payload); } catch (error) { - // Not even structurally a receipt: nothing further can run against it. - return [{ check: 'malformed-receipt', ok: false, detail: `${envelope.receiptHash}: ${(error as Error).message}` }]; + // Not even structurally a receipt: nothing further can run against it, and there is + // no readable battle id to attribute it to either. + return [ + { + check: 'malformed-receipt', + ok: false, + detail: (error as Error).message, + subject: envelope.receiptHash, + }, + ]; } + // Every result for this receipt is attributed to it, so a corpus of hundreds does not + // print an anonymous wall of check names. + const subject = typeof converted.battleId === 'string' ? converted.battleId : envelope.receiptHash; + const about = (result: CheckResult): CheckResult => ({ ...result, subject }); + // Runs before the well-formedness gate on purpose. A chosen seed is the specific thing // `assertBattleReceipt` would reject, and reporting only "malformed" there would bury // the actual accusation under a shape complaint. - const results: CheckResult[] = [checkSeedDerivation(converted)]; + const results: CheckResult[] = [about(checkSeedDerivation(converted))]; let receipt: BattleReceipt; try { receipt = assertBattleReceipt(converted); } catch (error) { - results.push({ - check: 'malformed-receipt', - ok: false, - detail: `${envelope.receiptHash}: ${(error as Error).message}`, - }); + results.push(about({ check: 'malformed-receipt', ok: false, detail: (error as Error).message })); return results; } wellFormed.push(receipt); - results.push(checkOperatorSignature(envelope, receipt, trustedKeys)); - results.push(checkBeaconSignature(receipt)); + results.push(about(checkOperatorSignature(envelope, receipt, trustedKeys))); + results.push(about(checkBeaconSignature(receipt))); const ruleset = resolveRuleset(receipt, rulesets); if (!ruleset) { - results.push({ - check: 'ruleset-unavailable', - ok: false, - detail: `no published bundle supplied for rulesetHash ${receipt.rulesetHash}; combat replay and progression could not be checked`, - }); + results.push( + about({ + check: 'ruleset-unavailable', + ok: false, + detail: `no published bundle for rulesetHash ${receipt.rulesetHash}; combat replay and progression could not be checked`, + }), + ); return results; } - results.push(checkCombatReplay(receipt, ruleset)); - results.push(checkProgression(receipt, ruleset)); + results.push(about(checkCombatReplay(receipt, ruleset))); + results.push(about(checkProgression(receipt, ruleset))); return results; } diff --git a/verifier/tests/corpus.test.ts b/verifier/tests/corpus.test.ts new file mode 100644 index 00000000..09be16c6 --- /dev/null +++ b/verifier/tests/corpus.test.ts @@ -0,0 +1,94 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { loadReceipts, loadSigningKeys } from '../src/io'; +import { pinnedRulesets } from '../src/ruleset'; +import { verifyReceipts } from '../src/verify'; + +import { buildCorpus, buildTamperedCorpus, corpusSigningKeys } from './fixtures/corpus'; + +/** + * The committed-corpus regression guard, and the same thing CI runs on every PR. + * + * Two assertions, and the second matters as much as the first: a verifier that had + * degraded into always passing would sail through "the honest corpus verifies" and be + * caught only by "the tampered corpus does not". + */ + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '../fixtures'); + +function readFixture(name: string): string { + return readFileSync(join(FIXTURES, name), 'utf8'); +} + +describe('the committed corpus is in sync with its generator', () => { + // Everything in the generator is deterministic, so a diff here means something that + // actually matters changed, not that the fixture drifted on its own. + it.each([ + ['corpus.json', () => buildCorpus()], + ['corpus-tampered.json', () => buildTamperedCorpus()], + ['signing-keys.json', () => corpusSigningKeys()], + ])('%s matches a fresh generation', (name, build) => { + expect(JSON.parse(readFixture(name))).toEqual(JSON.parse(JSON.stringify(build()))); + }); +}); + +describe('the honest corpus verifies end to end', () => { + it('passes every check, loaded exactly as the CLI loads it', async () => { + const envelopes = await loadReceipts(join(FIXTURES, 'corpus.json')); + const keys = await loadSigningKeys(join(FIXTURES, 'signing-keys.json')); + + const report = verifyReceipts(envelopes, keys, { rulesets: pinnedRulesets() }); + + expect(report.results.filter((result) => !result.ok)).toEqual([]); + expect(report.ok).toBe(true); + }); + + it('replays against a pinned bundle, with no network access and no --rulesets flag', async () => { + const envelopes = await loadReceipts(join(FIXTURES, 'corpus.json')); + const pinned = pinnedRulesets(); + // The point of pinning: the rules these battles were fought under are in the + // checkout, so this works with the operator entirely absent. + expect(pinned.has(envelopes[0]!.payload.rulesetHash.toLowerCase())).toBe(true); + }); + + it('covers more than one receipt, so the continuity walk is actually exercised', async () => { + const envelopes = await loadReceipts(join(FIXTURES, 'corpus.json')); + expect(envelopes.length).toBeGreaterThan(1); + }); +}); + +describe('the tampered corpus is rejected', () => { + it('fails, and names every reason rather than only the first', async () => { + const envelopes = await loadReceipts(join(FIXTURES, 'corpus-tampered.json')); + const keys = await loadSigningKeys(join(FIXTURES, 'signing-keys.json')); + + const report = verifyReceipts(envelopes, keys, { rulesets: pinnedRulesets() }); + const failed = report.results.filter((result) => !result.ok); + + expect(report.ok).toBe(false); + // The altered receipt fails its own checks... + expect(failed).toContainEqual( + expect.objectContaining({ check: 'combat-replay', subject: 'btl_0002', ok: false }), + ); + expect(failed).toContainEqual( + expect.objectContaining({ check: 'beacon-signature', subject: 'btl_0002', ok: false }), + ); + // ...and the break propagates down the chain, which is what the chain is for. + expect(failed).toContainEqual(expect.objectContaining({ check: 'chain-continuity', ok: false })); + }); + + it('leaves the untampered receipts passing, so failures stay attributable', async () => { + const envelopes = await loadReceipts(join(FIXTURES, 'corpus-tampered.json')); + const keys = await loadSigningKeys(join(FIXTURES, 'signing-keys.json')); + + const report = verifyReceipts(envelopes, keys, { rulesets: pinnedRulesets() }); + const firstReceipt = report.results.filter((result) => result.subject === 'btl_0001'); + + expect(firstReceipt.length).toBeGreaterThan(0); + expect(firstReceipt.every((result) => result.ok)).toBe(true); + }); +}); diff --git a/verifier/tests/fixtures/corpus.ts b/verifier/tests/fixtures/corpus.ts new file mode 100644 index 00000000..c6fd3865 --- /dev/null +++ b/verifier/tests/fixtures/corpus.ts @@ -0,0 +1,97 @@ +import { type BattleReceipt, hashBattleReceipt, type Hex } from '@cryptopets/protocol'; + +import type { SignedReceiptEnvelope, TrustedSigningKey } from '../../src/io/types'; + +import { buildReceipt, envelopeFor, FORGED_BEACON, testTrustedKey } from './signedReceipt'; + +/** + * The committed regression corpus (§H item 3's export shape, used here as a fixture). + * + * Three receipts under one signing key, properly linked on all three chains: the global + * one and both pets' own. That exercises every check the verifier makes, including the + * multi-receipt continuity walk a single receipt cannot. + * + * Shaped exactly like a corpus page from `GET /api/receipts?signingKeyId=...`, so the + * fixture doubles as a worked example of that endpoint's output and `loadReceipts` reads + * it with no special casing. + */ + +export interface CorpusPage { + receipts: SignedReceiptEnvelope[]; + nextCursor: string | null; +} + +const CORPUS_SIZE = 3; + +/** + * Builds the linked chain of receipts. + * + * `tamperAt` rebuilds one position with a broken receipt while leaving its links intact, + * so the resulting corpus is broken in the way a real tampered corpus would be: the + * altered receipt fails its own checks, *and* every later receipt's link no longer + * matches, because that is precisely what the chain is for. + */ +function buildChain(tamperAt?: number): BattleReceipt[] { + const receipts: BattleReceipt[] = []; + let previousReceiptHash: Hex | null = null; + let createdAt: number | undefined; + + for (let index = 0; index < CORPUS_SIZE; index++) { + const links = { + battleId: `btl_${String(index + 1).padStart(4, '0')}`, + sequence: index + 1, + previousReceiptHash, + // Both pets fight in every battle here, so each one's own chain advances in + // lockstep with the global one. + attackerPreviousReceiptHash: previousReceiptHash, + defenderPreviousReceiptHash: previousReceiptHash, + ...(createdAt === undefined ? {} : { createdAt }), + }; + + const receipt = index === tamperAt ? tamper(links) : buildReceipt(links); + receipts.push(receipt); + // Deliberately the *honest* hash: a chain built on the tampered receipt's own hash + // would be internally consistent again, which is the opposite of the fixture's job. + previousReceiptHash = hashBattleReceipt(buildReceipt(links)); + createdAt = receipt.createdAt + 1; + } + + return receipts; +} + +/** + * One receipt, altered two ways at once. + * + * Layered on purpose: the round count no longer matches a replay, and the beacon is a real + * signature from a different round, so the BLS check fails too. Both must be reported, not + * just whichever is found first. + */ +function tamper(links: Parameters[0]): BattleReceipt { + const honest = buildReceipt({ ...links, beacon: FORGED_BEACON }); + return buildReceipt({ + ...links, + beacon: FORGED_BEACON, + patch: { result: { ...honest.result, rounds: honest.result.rounds + 1 } }, + }); +} + +/** A valid, fully verifiable chain. This is what CI asserts still passes. */ +export function buildCorpus(): CorpusPage { + return { receipts: buildChain().map((receipt) => envelopeFor(receipt)), nextCursor: null }; +} + +/** + * The same chain with the middle receipt broken. + * + * CI asserts this one *fails*. A corpus that only ever proves the verifier passes would be + * satisfied just as well by a verifier that had degraded into always passing, which is the + * exact regression worth guarding against. + */ +export function buildTamperedCorpus(): CorpusPage { + return { receipts: buildChain(1).map((receipt) => envelopeFor(receipt)), nextCursor: null }; +} + +/** The trusted key list matching the corpus signatures. */ +export function corpusSigningKeys(): { keys: TrustedSigningKey[] } { + return { keys: [testTrustedKey()] }; +} diff --git a/verifier/tests/fixtures/signedReceipt.ts b/verifier/tests/fixtures/signedReceipt.ts index c147c649..92734a99 100644 --- a/verifier/tests/fixtures/signedReceipt.ts +++ b/verifier/tests/fixtures/signedReceipt.ts @@ -127,6 +127,8 @@ export interface ReceiptOverrides { battleId?: string; sequence?: number; previousReceiptHash?: Hex | null; + attackerPreviousReceiptHash?: Hex | null; + defenderPreviousReceiptHash?: Hex | null; createdAt?: number; signingKeyId?: string; /** Swapped wholesale; the seed is re-derived from whichever beacon is supplied. */ @@ -192,8 +194,8 @@ export function buildReceipt(overrides: ReceiptOverrides = {}): BattleReceipt { progression: computeProgression(SNAPSHOT, outcome.result.firstWins), sequence: overrides.sequence ?? 1, previousReceiptHash: overrides.previousReceiptHash ?? null, - attackerPreviousReceiptHash: null, - defenderPreviousReceiptHash: null, + attackerPreviousReceiptHash: overrides.attackerPreviousReceiptHash ?? null, + defenderPreviousReceiptHash: overrides.defenderPreviousReceiptHash ?? null, createdAt: overrides.createdAt ?? PUBLISHED_AT + 1, signingKeyId: overrides.signingKeyId ?? TEST_SIGNING_KEY_ID, ...overrides.patch, diff --git a/verifier/tests/ruleset.test.ts b/verifier/tests/ruleset.test.ts new file mode 100644 index 00000000..68e7d714 --- /dev/null +++ b/verifier/tests/ruleset.test.ts @@ -0,0 +1,41 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { hashRuleset, parseRulesetBundle, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; +import { describe, expect, it } from 'vitest'; + +import { PINNED_RULESETS_DIR, pinnedRulesetFilename, pinnedRulesets } from '../src/ruleset'; + +describe('pinned ruleset artifacts', () => { + it('every artifact hashes to the filename it is pinned under', () => { + // The invariant that makes the filename trustworthy rather than decorative. + const files = readdirSync(PINNED_RULESETS_DIR).filter((name) => name.endsWith('.json')); + expect(files.length).toBeGreaterThan(0); + + for (const filename of files) { + const ruleset = parseRulesetBundle(readFileSync(join(PINNED_RULESETS_DIR, filename), 'utf8')); + expect(pinnedRulesetFilename(ruleset)).toBe(filename.toLowerCase()); + } + }); + + it('pins the ruleset this build implements, so today battles stay replayable later', () => { + // ENGINE_VERSION moves on eventually. When it does, this file is the only durable + // copy of the rules today's battles were fought under. + const registry = pinnedRulesets(); + expect(registry.get(hashRuleset(SOURCE_DEFAULT_RULESET).toLowerCase())).toEqual(SOURCE_DEFAULT_RULESET); + }); + + it('keys every entry by the hash recomputed from the file contents', () => { + for (const [hash, ruleset] of pinnedRulesets()) { + expect(hash).toBe(hashRuleset(ruleset).toLowerCase()); + } + }); + + it('returns a fresh map each call, so a caller merging into it cannot leak across runs', () => { + const first = pinnedRulesets(); + const size = first.size; + first.set('0xdeadbeef', SOURCE_DEFAULT_RULESET); + expect(pinnedRulesets().size).toBe(size); + expect(pinnedRulesets().has('0xdeadbeef')).toBe(false); + }); +}); diff --git a/verifier/tsconfig.json b/verifier/tsconfig.json index 4d7d1d0d..a52f083a 100644 --- a/verifier/tsconfig.json +++ b/verifier/tsconfig.json @@ -23,5 +23,5 @@ "verbatimModuleSyntax": true, "isolatedModules": true }, - "include": ["src/**/*.ts", "tests/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"] } From cf1f730972afa62623b74ec9fbcab4fa0b0aa340 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 15:15:10 -0400 Subject: [PATCH 34/76] feat(frontend): submit signed battle intents and persist the signed commitment --- backend/API.md | 1 + backend/src/features/battle-ledger/index.ts | 3 + .../battle-ledger/reads.controller.ts | 6 + .../features/battle-ledger/reads.service.ts | 36 ++- backend/src/routes/battle.ts | 4 + .../battle-ledger/config.service.test.ts | 64 +++++ frontend/src/config.ts | 13 + shared/src/hooks/index.ts | 19 ++ shared/src/hooks/useBackendBattle.ts | 123 +++++++++ shared/src/hooks/useBattleConfig.ts | 38 +++ shared/src/hooks/useBattleRoomSocket.ts | 121 ++++++++ shared/src/hooks/useSubmitBattleIntent.ts | 206 ++++++++++++++ shared/src/utils/battleEvidence.ts | 123 +++++++++ shared/src/utils/index.ts | 1 + shared/tests/hooks/useBackendBattle.test.tsx | 172 ++++++++++++ .../tests/hooks/useBattleRoomSocket.test.tsx | 213 ++++++++++++++ .../hooks/useSubmitBattleIntent.test.tsx | 260 ++++++++++++++++++ shared/tests/utils/battleEvidence.test.ts | 122 ++++++++ 18 files changed, 1524 insertions(+), 1 deletion(-) create mode 100644 backend/tests/features/battle-ledger/config.service.test.ts create mode 100644 shared/src/hooks/useBackendBattle.ts create mode 100644 shared/src/hooks/useBattleConfig.ts create mode 100644 shared/src/hooks/useBattleRoomSocket.ts create mode 100644 shared/src/hooks/useSubmitBattleIntent.ts create mode 100644 shared/src/utils/battleEvidence.ts create mode 100644 shared/tests/hooks/useBackendBattle.test.tsx create mode 100644 shared/tests/hooks/useBattleRoomSocket.test.tsx create mode 100644 shared/tests/hooks/useSubmitBattleIntent.test.tsx create mode 100644 shared/tests/utils/battleEvidence.test.ts diff --git a/backend/API.md b/backend/API.md index 25361ebc..9cebe0ed 100644 --- a/backend/API.md +++ b/backend/API.md @@ -253,6 +253,7 @@ to check independently. | 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. | +| 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. | | GET | `/api/battle/:battleId/receipt` | none | The signed receipt, once signing completes. | diff --git a/backend/src/features/battle-ledger/index.ts b/backend/src/features/battle-ledger/index.ts index 1f497da9..2206c2d0 100644 --- a/backend/src/features/battle-ledger/index.ts +++ b/backend/src/features/battle-ledger/index.ts @@ -19,6 +19,7 @@ export { deleteDefenseAuthorizations, postDefenseAuthorization } from './consent export { getBattleCombatLog, getBattleCommitment, + getBattleConfigHandler, getBattleReceipt, getBattleStateHandler, getRulesetByHash, @@ -27,6 +28,8 @@ export { postVerifyReceipt, } from './reads.controller'; export { + type BattleConfig, + getBattleConfig, type BattleStateSummary, type CombatLogResponse, getBattleStateSummary, diff --git a/backend/src/features/battle-ledger/reads.controller.ts b/backend/src/features/battle-ledger/reads.controller.ts index fb75ee51..1f40dc41 100644 --- a/backend/src/features/battle-ledger/reads.controller.ts +++ b/backend/src/features/battle-ledger/reads.controller.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express'; import { + getBattleConfig, getBattleStateSummary, getCombatLog, getRuleset, @@ -11,6 +12,11 @@ import { verifyReceiptSignature, } from './reads.service'; +/** The deployment, chains, and active ruleset a client needs before it can sign an intent. */ +export function getBattleConfigHandler(_req: Request, res: Response): void { + res.status(200).json(getBattleConfig()); +} + export async function getBattleStateHandler(req: Request, res: Response): Promise { const summary = await getBattleStateSummary(req.params.battleId as string); if (!summary) { diff --git a/backend/src/features/battle-ledger/reads.service.ts b/backend/src/features/battle-ledger/reads.service.ts index 4ed68812..9976f979 100644 --- a/backend/src/features/battle-ledger/reads.service.ts +++ b/backend/src/features/battle-ledger/reads.service.ts @@ -1,9 +1,11 @@ -import type { Hex } from '@cryptopets/protocol'; +import { hashRuleset, SOURCE_DEFAULT_RULESET, type Hex } from '@cryptopets/protocol'; import { ethers } from 'ethers'; import { prisma } from '@config/prisma'; import { listSigningKeys } from '@features/battle-signer'; +import { servedChainIds, servedDeploymentId } from './domain'; + /** * Public, authoritative reads for a battle in flight or settled (§J). * @@ -21,6 +23,38 @@ import { listSigningKeys } from '@features/battle-signer'; * design is supposed to let them do. */ +export interface BattleConfig { + /** The deployment an intent must name, or it is refused as `wrong-deployment`. */ + deploymentId: string; + /** Chain ids this process serves battles for. */ + chainIds: string[]; + /** The ruleset a defence authorization must be bound to for a battle to be accepted. */ + ruleset: { hash: string; version: number }; +} + +/** + * The parameters a client needs before it can build a signable intent (§D). + * + * These are not derivable client-side. `deploymentId` is this process's own + * identity, and the active ruleset is whichever bundle the accept path actually + * commits battles under — currently the source defaults, later whatever + * `GameConfig` is tuned to. A client that guessed either would produce intents + * refused as `wrong-deployment`, or authorizations refused as `ruleset-mismatch`, + * and the failure would surface a signature step too late to be obvious. + * + * Served unauthenticated for the same reason as the other reads here: none of it + * is secret, and needing a login to find out which rules are in force would make + * a third-party client harder to write than it has any reason to be. + */ +export function getBattleConfig(): BattleConfig { + const ruleset = SOURCE_DEFAULT_RULESET; + return { + deploymentId: servedDeploymentId(), + chainIds: servedChainIds(), + ruleset: { hash: hashRuleset(ruleset), version: ruleset.version }, + }; +} + export interface BattleStateSummary { battleId: string; chainId: string; diff --git a/backend/src/routes/battle.ts b/backend/src/routes/battle.ts index 0f920af9..73f05a50 100644 --- a/backend/src/routes/battle.ts +++ b/backend/src/routes/battle.ts @@ -4,6 +4,7 @@ import { deleteDefenseAuthorizations, getBattleCombatLog, getBattleCommitment, + getBattleConfigHandler, getBattleReceipt, getBattleStateHandler, getRulesetByHash, @@ -40,6 +41,9 @@ router.delete('/authorizations', verifyToken, deleteDefenseAuthorizations); // public on chain or is itself a signed artifact anyone is meant to check, so gating // these behind a JWT would stop a spectator with a room link from doing the one thing // this design exists to let them do. +// Declared before `/:battleId`, or that route would happily match "config" as a battle id. +// Same reason the other fixed paths below sit above it. +router.get('/config', getBattleConfigHandler); router.get('/signing-keys', getSigningKeys); router.get('/rulesets', getRulesets); router.get('/rulesets/:rulesetHash', getRulesetByHash); diff --git a/backend/tests/features/battle-ledger/config.service.test.ts b/backend/tests/features/battle-ledger/config.service.test.ts new file mode 100644 index 00000000..8b8e7740 --- /dev/null +++ b/backend/tests/features/battle-ledger/config.service.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { hashRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; + +const battleEnv = vi.hoisted(() => ({ + deploymentId: 'base-sepolia-live', + chainIds: ['eip155:84532', 'solana:devnet'], +})); + +vi.mock('@config/env', () => ({ env: { battle: battleEnv } })); +vi.mock('@config/prisma', () => ({ + prisma: { + battleLedger: { findUnique: vi.fn() }, + battleCommitment: { findUnique: vi.fn() }, + battleReceipt: { findUnique: vi.fn() }, + battleRuleset: { findMany: vi.fn(), findUnique: vi.fn() }, + }, +})); +vi.mock('@features/battle-signer', () => ({ listSigningKeys: vi.fn() })); + +import { getBattleConfig } from '@features/battle-ledger'; + +beforeEach(() => { + battleEnv.deploymentId = 'base-sepolia-live'; + battleEnv.chainIds = ['eip155:84532', 'solana:devnet']; +}); + +describe('getBattleConfig', () => { + it('serves the deployment a client must name in an intent', () => { + // A client that guessed this would have its intent refused as `wrong-deployment`, + // after the wallet prompt rather than before it. + expect(getBattleConfig().deploymentId).toBe('base-sepolia-live'); + }); + + it('serves every chain this process accepts intents for', () => { + expect(getBattleConfig().chainIds).toEqual(['eip155:84532', 'solana:devnet']); + }); + + it('serves the ruleset the accept path actually commits battles under', () => { + // Not "some published ruleset" — the one `acceptBattle` binds a battle to, since a + // defence authorization bound to any other is refused as `ruleset-mismatch`. + expect(getBattleConfig().ruleset).toEqual({ + hash: hashRuleset(SOURCE_DEFAULT_RULESET), + version: SOURCE_DEFAULT_RULESET.version, + }); + }); + + it('reflects a reconfigured deployment rather than a cached first read', () => { + battleEnv.deploymentId = 'base-mainnet-live'; + battleEnv.chainIds = ['eip155:8453']; + + expect(getBattleConfig()).toMatchObject({ + deploymentId: 'base-mainnet-live', + chainIds: ['eip155:8453'], + }); + }); + + it('rejects a chain id the protocol does not recognise', () => { + // Served config is what clients build signable objects from, so a malformed chain + // id must fail here rather than become an unsignable intent. + battleEnv.chainIds = ['not-a-chain-id']; + expect(() => getBattleConfig()).toThrow(); + }); +}); diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 6cc81fc4..7a4ba340 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -2,6 +2,19 @@ import { setTokenSuccessCallback, setStorageAdapter, type StorageAdapter } from export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; +/** + * Per-room notification channel for backend-authoritative battles + * (`docs/plan-backend-battle-architecture.md` §J). + * + * Lives here rather than in `petsContractParams` because it is chain-neutral: backend + * battles run on both EVM and Solana, while that module is the EVM contract config and + * carries the separate, legacy `/ws/live-battle` endpoint for the on-chain settle flow. + * + * A client that cannot reach this still converges on the same state by polling the read + * APIs, so an unreachable socket costs latency, never correctness. + */ +export const BATTLE_ROOM_WS_URL = `${API_URL.replace(/^http/, 'ws')}/ws/battle-room`; + const AUTH_TOKEN_KEY = 'authToken'; // localStorage-backed token persistence — the single source of truth for the key. diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index c5d58461..5173fc69 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -68,5 +68,24 @@ export { } from './useBattleDialogue'; export { useBattleTaunts, type GenerateTauntsVars } from './useBattleTaunts'; export { useCreateBattleRoom, type CreateRoomVars } from './useCreateBattleRoom'; +// Backend-authoritative battles (docs/plan-backend-battle-architecture.md §D, §E, §J). +export { BATTLE_CONFIG_QUERY_KEY, useBattleConfig, type BattleConfig } from './useBattleConfig'; +export { + useSubmitBattleIntent, + type AcceptedBattle, + type SubmitBattleIntentVars, +} from './useSubmitBattleIntent'; +export { + battleStateQueryKey, + useBackendBattle, + useStoredBattleEvidence, + type BattleStateSummary, + type UseBackendBattleOptions, +} from './useBackendBattle'; +export { + useBattleRoomSocket, + type BattleRoomNotification, + type UseBattleRoomSocketOptions, +} from './useBattleRoomSocket'; export { usePetError, type PetError } from './usePetError'; export { useTxError, type TxError } from './useTxError'; diff --git a/shared/src/hooks/useBackendBattle.ts b/shared/src/hooks/useBackendBattle.ts new file mode 100644 index 00000000..d45e25a9 --- /dev/null +++ b/shared/src/hooks/useBackendBattle.ts @@ -0,0 +1,123 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +import { useApiClient } from '../contexts/ApiClientContext'; +import { readBattleEvidence, type BattleEvidence } from '../utils/battleEvidence'; + +import { useBattleRoomSocket } from './useBattleRoomSocket'; + +/** + * The authoritative view of one backend-resolved battle (§J). + * + * `GET /api/battle/:battleId` is the source of truth, and the room socket is only a hint + * that it is worth asking again. That ordering is the whole design: a client that missed + * every notification, or was never connected, converges on exactly the same state by + * polling this endpoint. The socket makes that faster, never more authoritative — so a + * dropped connection degrades to slower updates, not to wrong ones. + * + * The refetch on reconnect matters as much as the one on notification. A socket that was + * down for ten seconds missed whatever happened in those ten seconds, and reconnecting + * without re-reading would leave the client confidently stale. + */ + +export interface BattleStateSummary { + battleId: string; + chainId: string; + deploymentId: string; + state: string; + failureReason: string | null; + attackerPetId: string; + attackerOwner: string; + defenderPetId: string; + defenderOwner: string; + rulesetHash: string; + createdAt: string; + updatedAt: string; +} + +/** States the pipeline never leaves, so there is nothing further to wait for. */ +const TERMINAL_STATES = new Set([ + 'signed', + 'batched', + 'rejected', + 'forfeited', + 'verification_failed', + 'signing_failed', +]); + +export function battleStateQueryKey(battleId: string | null | undefined) { + return ['battle', 'state', battleId] as const; +} + +export interface UseBackendBattleOptions { + /** Room to subscribe to for change notifications. Without one, polling is the only signal. */ + roomId?: string | null; + /** Base URL of the battle-room socket, e.g. `wss://api.example.com/ws/battle-room`. */ + roomSocketUrl?: string | undefined; + /** + * Fallback poll interval while the battle is still in flight, in milliseconds. + * + * Deliberately not disabled when a socket is connected. The socket is a notification + * channel with no delivery guarantee, and a battle that silently stopped updating + * because one message was lost is a worse failure than a request every few seconds. + */ + pollIntervalMs?: number; +} + +const DEFAULT_POLL_INTERVAL_MS = 5000; + +export function useBackendBattle(battleId: string | null | undefined, options: UseBackendBattleOptions = {}) { + const apiClient = useApiClient(); + const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + + const query = useQuery({ + queryKey: battleStateQueryKey(battleId), + enabled: Boolean(battleId), + queryFn: async (): Promise => { + const { data } = await apiClient.get(`/api/battle/${battleId}`); + return data; + }, + // Stops once the battle reaches a state it will never leave, rather than polling a + // finished battle forever. + refetchInterval: (q) => (isSettled(q.state.data) ? false : pollIntervalMs), + }); + + const { refetch } = query; + const { connected } = useBattleRoomSocket({ + url: options.roomSocketUrl, + roomId: options.roomId ?? null, + // A notification and a reconnect mean the same thing here: go ask the authoritative + // endpoint. Neither carries battle content to trust. + onNotification: () => void refetch(), + onReconnect: () => void refetch(), + }); + + return { + ...query, + /** Whether the notification channel is currently live. Presentation only. */ + socketConnected: connected, + isSettled: isSettled(query.data), + }; +} + +function isSettled(summary: BattleStateSummary | undefined): boolean { + return summary ? TERMINAL_STATES.has(summary.state) : false; +} + +/** + * The player's own stored commitment for this battle, if this client is the one that + * accepted it. + * + * Read from local storage rather than refetched, because the point of holding it is that it + * does not depend on us continuing to serve it. Returns null on any other client, which is + * correct: a spectator was never promised anything. + */ +export function useStoredBattleEvidence(battleId: string | null | undefined): BattleEvidence | null { + const [evidence, setEvidence] = useState(null); + + useEffect(() => { + setEvidence(battleId ? readBattleEvidence(battleId) : null); + }, [battleId]); + + return evidence; +} diff --git a/shared/src/hooks/useBattleConfig.ts b/shared/src/hooks/useBattleConfig.ts new file mode 100644 index 00000000..93d4268b --- /dev/null +++ b/shared/src/hooks/useBattleConfig.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useApiClient } from '../contexts/ApiClientContext'; + +/** + * The deployment, chains, and active ruleset a client needs before it can build a signable + * intent (`GET /api/battle/config`). + * + * None of this is derivable client-side, and guessing it fails late: a wrong `deploymentId` + * is refused as `wrong-deployment` and a wrong `rulesetHash` produces a defence + * authorization nobody's battle matches — both *after* the wallet prompt, which is the worst + * possible moment to discover a configuration mistake. + * + * Cached for the session. These values change only when the deployment is redeployed or the + * ruleset is retuned, neither of which happens mid-battle, and refetching per battle would + * put a network round trip in front of every wallet prompt for no benefit. + */ + +export interface BattleConfig { + deploymentId: string; + chainIds: string[]; + ruleset: { hash: string; version: number }; +} + +export const BATTLE_CONFIG_QUERY_KEY = ['battle', 'config'] as const; + +export function useBattleConfig() { + const apiClient = useApiClient(); + + return useQuery({ + queryKey: BATTLE_CONFIG_QUERY_KEY, + queryFn: async (): Promise => { + const { data } = await apiClient.get('/api/battle/config'); + return data; + }, + staleTime: Infinity, + }); +} diff --git a/shared/src/hooks/useBattleRoomSocket.ts b/shared/src/hooks/useBattleRoomSocket.ts new file mode 100644 index 00000000..cf2d06cc --- /dev/null +++ b/shared/src/hooks/useBattleRoomSocket.ts @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Subscribes to the per-room battle notification channel (`/ws/battle-room`, §J). + * + * Notification only, by construction: the messages carry `{ battleId, state }` and nothing + * else, so there is no battle content here that could be trusted over the read APIs even by + * mistake. Every message means one thing — go re-fetch. + * + * Reconnects with backoff, and reports each successful reconnect separately from each + * message. A caller needs both: a reconnect means "you may have missed something while the + * socket was down", which is exactly as much a reason to re-read as a message is. + */ + +export interface BattleRoomNotification { + type: 'battle-updated'; + battleId: string; + state: string; +} + +export interface UseBattleRoomSocketOptions { + /** Socket endpoint. Undefined disables the subscription entirely. */ + url: string | undefined; + /** Room to join. Null disables the subscription. */ + roomId: string | null; + onNotification?: (message: BattleRoomNotification) => void; + /** Fired after a reconnect, never on the first connect. */ + onReconnect?: () => void; +} + +const INITIAL_RETRY_MS = 1000; +const MAX_RETRY_MS = 30_000; + +export function useBattleRoomSocket(options: UseBattleRoomSocketOptions): { connected: boolean } { + const { url, roomId } = options; + const [connected, setConnected] = useState(false); + + // Held in refs so a caller passing inline closures does not tear down and rebuild the + // socket on every render. The connection's lifetime should depend on the url and room, + // and nothing else. + const onNotification = useRef(options.onNotification); + const onReconnect = useRef(options.onReconnect); + onNotification.current = options.onNotification; + onReconnect.current = options.onReconnect; + + useEffect(() => { + setConnected(false); + if (!url || !roomId) return; + + let disposed = false; + let socket: WebSocket | null = null; + let retryTimer: ReturnType | null = null; + let retryDelay = INITIAL_RETRY_MS; + let hasConnectedBefore = false; + + const connect = (): void => { + if (disposed) return; + + socket = new WebSocket(`${url}?roomId=${encodeURIComponent(roomId)}`); + + socket.onopen = () => { + if (disposed) return; + setConnected(true); + retryDelay = INITIAL_RETRY_MS; + if (hasConnectedBefore) { + // Whatever happened while this was down was never delivered, so the + // caller has to re-read rather than assume continuity. + onReconnect.current?.(); + } + hasConnectedBefore = true; + }; + + socket.onmessage = (event) => { + if (disposed) return; + try { + const message = JSON.parse(event.data as string) as BattleRoomNotification; + if (message?.type === 'battle-updated' && typeof message.battleId === 'string') { + onNotification.current?.(message); + } + } catch { + // A malformed frame is not worth surfacing: the read APIs are still the + // truth, and polling will pick up whatever this would have announced. + } + }; + + socket.onclose = () => { + if (disposed) return; + setConnected(false); + retryTimer = setTimeout(connect, retryDelay); + // Backoff, capped: a backend that is down for an hour should not be met with + // a reconnect every second, but a client should still recover on its own. + retryDelay = Math.min(retryDelay * 2, MAX_RETRY_MS); + }; + + socket.onerror = () => { + // `onclose` always follows, and that is where reconnection is handled. Doing + // it here too would open two sockets for one failure. + socket?.close(); + }; + }; + + connect(); + + return () => { + disposed = true; + if (retryTimer) clearTimeout(retryTimer); + // Detached first: a close triggered by this cleanup must not schedule a retry + // for a subscription that is going away. + if (socket) { + socket.onopen = null; + socket.onmessage = null; + socket.onclose = null; + socket.onerror = null; + socket.close(); + } + setConnected(false); + }; + }, [url, roomId]); + + return { connected }; +} diff --git a/shared/src/hooks/useSubmitBattleIntent.ts b/shared/src/hooks/useSubmitBattleIntent.ts new file mode 100644 index 00000000..a80693d6 --- /dev/null +++ b/shared/src/hooks/useSubmitBattleIntent.ts @@ -0,0 +1,206 @@ +import { + battleIntentSolanaMessageBytes, + battleIntentTypedData, + type BattleIntent, + type ChainId, +} from '@cryptopets/protocol'; +import { useCallback, useState } from 'react'; +import { useSignTypedData } from 'wagmi'; + +import { getSolanaAuthSigner } from '../auth/solanaAuthStore'; +import { useApiClient } from '../contexts/ApiClientContext'; +import { saveBattleEvidence, type BattleEvidence } from '../utils/battleEvidence'; +import { normalizeSolanaSignatureToBase58 } from '../utils/solana/signatureAuthCodec'; + +import { useActiveChain } from './useActiveChain'; +import { useBattleConfig } from './useBattleConfig'; + +/** + * Submits a wallet-signed battle intent and captures the signed commitment that comes back + * (§D, §E). + * + * Two round trips, in this order, because the protocol needs them separate: + * + * 1. `POST /api/battle/intents` — the wallet signature here is what *authorizes* the battle. + * The JWT only says who is calling; §D deliberately does not let a session token start a + * fight on a pet's behalf. + * 2. `POST /api/battle/intents/:intentHash/accept` — freezes both pets, commits to a future + * drand round, and returns the signed commitment synchronously. This is the only time that + * commitment is handed over as part of a write, so it is persisted immediately. + * + * What is signed is never an opaque digest. EVM wallets get EIP-712 typed data and Solana + * wallets get a labelled text message, both built by `@cryptopets/protocol`, so the prompt + * names which pet is fighting whom, under which ruleset, until when. + */ + +export interface SubmitBattleIntentVars { + attackerPetId: string; + defenderOwner: string; + defenderPetId: string; + /** Links the battle to a shareable room, so spectators get pushed state changes (§J). */ + roomId?: string; + /** Optional challenge this intent answers. */ + challengeId?: string; +} + +export interface AcceptedBattle { + battleId: string; + commitmentHash: string; + signature: string; + signingKeyId: string; + commitment: unknown; +} + +/** How long a signed intent stays submittable. Long enough to sign, short enough to bound replay. */ +const INTENT_TTL_SECONDS = 300; + +interface SubmitIntentResponse { + intentHash: string; +} + +export function useSubmitBattleIntent() { + const apiClient = useApiClient(); + const activeChain = useActiveChain(); + const { data: config } = useBattleConfig(); + const { signTypedDataAsync } = useSignTypedData(); + + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + const submit = useCallback( + async (vars: SubmitBattleIntentVars): Promise => { + if (activeChain.kind === 'none') { + setError(new Error('connect a wallet before starting a battle')); + return null; + } + if (!config) { + // Without the served deployment and ruleset an intent would be built against + // guesses, and refused after the wallet prompt rather than before it. + setError(new Error('battle configuration is not loaded yet')); + return null; + } + + setIsPending(true); + setError(null); + try { + const chainId = chainIdFor(activeChain.kind, config.chainIds); + const intent: BattleIntent = { + domain: { chainId, deploymentId: config.deploymentId }, + attackerOwner: activeChain.address, + attackerPetId: BigInt(vars.attackerPetId), + defenderOwner: vars.defenderOwner, + defenderPetId: BigInt(vars.defenderPetId), + challengeId: vars.challengeId ?? null, + clientNonce: newClientNonce(), + rulesetHash: config.ruleset.hash as `0x${string}`, + expiresAt: Math.floor(Date.now() / 1000) + INTENT_TTL_SECONDS, + }; + + const { signature, signatureFormat } = + activeChain.kind === 'evm' + ? { signature: await signEvmIntent(intent, signTypedDataAsync), signatureFormat: 'eip712' as const } + : { signature: await signSolanaIntent(intent), signatureFormat: 'solana-message' as const }; + + const { data: submitted } = await apiClient.post('/api/battle/intents', { + intent: toWire(intent), + signature, + signatureFormat, + }); + + const { data: accepted } = await apiClient.post( + `/api/battle/intents/${submitted.intentHash}/accept`, + vars.roomId ? { roomId: vars.roomId } : {}, + ); + + // Written before this function returns, so a reload one second later still + // finds the player's own proof of what they were promised. + const evidence: BattleEvidence = { ...accepted, storedAt: Date.now() }; + saveBattleEvidence(evidence); + + return accepted; + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + return null; + } finally { + setIsPending(false); + } + }, + [activeChain, apiClient, config, signTypedDataAsync], + ); + + return { submit, isPending, error }; +} + +/** Picks the served chain id matching the connected wallet's family. */ +function chainIdFor(kind: 'evm' | 'solana', servedChainIds: string[]): ChainId { + const prefix = kind === 'evm' ? 'eip155:' : 'solana:'; + const match = servedChainIds.find((candidate) => candidate.startsWith(prefix)); + if (!match) { + throw new Error(`this deployment serves no ${kind} chain (has ${servedChainIds.join(', ') || 'none'})`); + } + return match as ChainId; +} + +async function signEvmIntent( + intent: BattleIntent, + signTypedDataAsync: ReturnType['signTypedDataAsync'], +): Promise { + const typed = battleIntentTypedData(intent); + return signTypedDataAsync({ + domain: typed.domain, + types: typed.types, + primaryType: typed.primaryType, + // The protocol types the account fields as plain `string`, because the same intent + // shape carries base58 Solana addresses. wagmi wants the EIP-712 `address` fields as + // `0x${string}`. Narrowing rather than widening the protocol type: this branch only + // runs for EVM intents, and `battleIntentTypedData` itself throws for any other + // chain family, so these really are 0x addresses by the time execution gets here. + message: typed.message as typeof typed.message & { + attackerOwner: `0x${string}`; + defenderOwner: `0x${string}`; + rulesetHash: `0x${string}`; + }, + }); +} + +async function signSolanaIntent(intent: BattleIntent): Promise { + const signer = getSolanaAuthSigner(); + if (!signer) { + throw new Error('no Solana signer is connected'); + } + const signed = await signer.signMessage(battleIntentSolanaMessageBytes(intent)); + // Base58, matching what the auth flow already sends. The backend accepts hex and base64 + // too, but sending one form everywhere keeps the wire predictable. + return normalizeSolanaSignatureToBase58(signed); +} + +/** Serializes an intent for the wire: bigints become decimal strings, as JSON requires. */ +function toWire(intent: BattleIntent) { + return { + chainId: intent.domain.chainId, + deploymentId: intent.domain.deploymentId, + attackerOwner: intent.attackerOwner, + attackerPetId: intent.attackerPetId.toString(), + defenderOwner: intent.defenderOwner, + defenderPetId: intent.defenderPetId.toString(), + challengeId: intent.challengeId, + clientNonce: intent.clientNonce, + rulesetHash: intent.rulesetHash, + expiresAt: intent.expiresAt, + }; +} + +/** + * A per-submission nonce, which is what makes one signature un-replayable (threat T7). + * + * `crypto.randomUUID` where available; a random-plus-time fallback otherwise, since older + * WebViews and some React Native runtimes lack it. Uniqueness is enforced server-side by a + * unique constraint either way, so a collision is refused rather than accepted twice. + */ +function newClientNonce(): string { + const cryptoApi = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto; + if (typeof cryptoApi?.randomUUID === 'function') { + return cryptoApi.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; +} diff --git a/shared/src/utils/battleEvidence.ts b/shared/src/utils/battleEvidence.ts new file mode 100644 index 00000000..f66bb5ae --- /dev/null +++ b/shared/src/utils/battleEvidence.ts @@ -0,0 +1,123 @@ +/** + * Local persistence for the player's own commit-before-reveal evidence (§E, §J). + * + * The signed commitment is handed over exactly once, in the response to + * `POST /api/battle/intents/:intentHash/accept`, and it is the player's proof that the + * drand round was chosen *before* the randomness existed. `GET /api/battle/:battleId/commitment` + * will re-serve it, but that is us serving it — the whole point of holding a copy is that the + * player's evidence does not depend on us continuing to hand it over. So it is written to + * local storage the moment it arrives, and survives a reload. + * + * Deliberately not in React state or a query cache: both are lost on refresh, which is exactly + * when a player would want the receipt of what they were promised. + */ + +/** The subset of the Web Storage API this module needs. */ +export interface EvidenceStore { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** One battle's evidence, exactly as the accept response delivered it. */ +export interface BattleEvidence { + battleId: string; + commitmentHash: string; + signature: string; + signingKeyId: string; + /** The canonical commitment object, as signed. Kept verbatim so it can be re-hashed. */ + commitment: unknown; + /** Unix milliseconds this client stored it. Local bookkeeping, never protocol input. */ + storedAt: number; +} + +const KEY_PREFIX = 'cryptopets.battle-evidence.'; +const INDEX_KEY = 'cryptopets.battle-evidence.index'; + +/** + * Falls back to a no-op store when Web Storage is unavailable. + * + * React Native has no `localStorage`, and a browser in private mode can have one that throws + * on write. Neither should take down a battle: losing the local copy costs the player a + * convenience, while an exception here would cost them the fight. + */ +function defaultStore(): EvidenceStore { + try { + const candidate = (globalThis as { localStorage?: EvidenceStore }).localStorage; + if (candidate) { + // Touching it is what actually proves it works — Safari in private mode exposes + // the object and throws only on write. + const probe = `${KEY_PREFIX}probe`; + candidate.setItem(probe, '1'); + candidate.removeItem(probe); + return candidate; + } + } catch { + // Fall through to the no-op store. + } + return NO_OP_STORE; +} + +const NO_OP_STORE: EvidenceStore = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, +}; + +let activeStore: EvidenceStore | null = null; + +/** Overrides the store. Mobile passes its own; tests pass an in-memory one. */ +export function setEvidenceStore(store: EvidenceStore | null): void { + activeStore = store; +} + +function store(): EvidenceStore { + return activeStore ?? defaultStore(); +} + +/** Persists one battle's evidence, replacing any earlier copy for the same battle. */ +export function saveBattleEvidence(evidence: BattleEvidence): void { + try { + store().setItem(`${KEY_PREFIX}${evidence.battleId}`, JSON.stringify(evidence)); + const index = new Set(listBattleEvidenceIds()); + index.add(evidence.battleId); + store().setItem(INDEX_KEY, JSON.stringify([...index])); + } catch { + // A full or unavailable store must not break the battle it is trying to record. + } +} + +/** Reads one battle's evidence, or null when it was never stored or is unreadable. */ +export function readBattleEvidence(battleId: string): BattleEvidence | null { + try { + const raw = store().getItem(`${KEY_PREFIX}${battleId}`); + if (!raw) return null; + const parsed = JSON.parse(raw) as BattleEvidence; + // A stored blob that lost its identifying fields is not evidence of anything. + return typeof parsed?.battleId === 'string' && typeof parsed.commitmentHash === 'string' ? parsed : null; + } catch { + return null; + } +} + +/** Every battle id this client holds evidence for, newest last. */ +export function listBattleEvidenceIds(): string[] { + try { + const raw = store().getItem(INDEX_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []; + } catch { + return []; + } +} + +/** Drops one battle's evidence and its index entry. */ +export function forgetBattleEvidence(battleId: string): void { + try { + store().removeItem(`${KEY_PREFIX}${battleId}`); + store().setItem(INDEX_KEY, JSON.stringify(listBattleEvidenceIds().filter((id) => id !== battleId))); + } catch { + // Nothing to do: the copy is either already gone or unreachable. + } +} diff --git a/shared/src/utils/index.ts b/shared/src/utils/index.ts index 49e3a628..5be354e6 100644 --- a/shared/src/utils/index.ts +++ b/shared/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './battleEvidence'; export * from './common'; export * from './ethereum'; export * from './solana'; diff --git a/shared/tests/hooks/useBackendBattle.test.tsx b/shared/tests/hooks/useBackendBattle.test.tsx new file mode 100644 index 00000000..735b7d72 --- /dev/null +++ b/shared/tests/hooks/useBackendBattle.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const get = vi.hoisted(() => vi.fn()); +const socket = vi.hoisted(() => ({ + onNotification: undefined as undefined | (() => void), + onReconnect: undefined as undefined | (() => void), + connected: false, +})); + +vi.mock('../../src/contexts/ApiClientContext', () => ({ useApiClient: () => ({ get, post: vi.fn() }) })); +vi.mock('../../src/hooks/useBattleRoomSocket', () => ({ + useBattleRoomSocket: (options: { onNotification?: () => void; onReconnect?: () => void }) => { + socket.onNotification = options.onNotification; + socket.onReconnect = options.onReconnect; + return { connected: socket.connected }; + }, +})); + +import { setEvidenceStore, saveBattleEvidence, type EvidenceStore } from '../../src/utils/battleEvidence'; +import { useBackendBattle, useStoredBattleEvidence } from '../../src/hooks/useBackendBattle'; + +function summary(state: string) { + return { + battleId: 'btl_0001', + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + state, + failureReason: null, + attackerPetId: '1', + attackerOwner: '0xabc', + defenderPetId: '2', + defenderOwner: '0xdef', + rulesetHash: `0x${'11'.repeat(32)}`, + createdAt: '2026-07-26T00:00:00.000Z', + updatedAt: '2026-07-26T00:00:01.000Z', + }; +} + +function memoryStore(): EvidenceStore { + const map = new Map(); + return { + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => void map.set(key, value), + removeItem: (key) => void map.delete(key), + }; +} + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return React.createElement(QueryClientProvider, { client }, children); +} + +beforeEach(() => { + vi.clearAllMocks(); + socket.connected = false; + setEvidenceStore(memoryStore()); + get.mockResolvedValue({ data: summary('committed') }); +}); + +afterEach(() => setEvidenceStore(null)); + +describe('reading the authoritative endpoint', () => { + it('fetches the battle state', async () => { + const { result } = renderHook(() => useBackendBattle('btl_0001'), { wrapper }); + + await waitFor(() => expect(result.current.data).toEqual(summary('committed'))); + expect(get).toHaveBeenCalledWith('/api/battle/btl_0001'); + }); + + it('does not fetch without a battle id', () => { + renderHook(() => useBackendBattle(null), { wrapper }); + expect(get).not.toHaveBeenCalled(); + }); + + it('reports a terminal state as settled', async () => { + get.mockResolvedValue({ data: summary('signed') }); + const { result } = renderHook(() => useBackendBattle('btl_0001'), { wrapper }); + + await waitFor(() => expect(result.current.isSettled).toBe(true)); + }); + + it('treats a failure state as settled too, so a failed battle stops being polled', async () => { + get.mockResolvedValue({ data: summary('verification_failed') }); + const { result } = renderHook(() => useBackendBattle('btl_0001'), { wrapper }); + + await waitFor(() => expect(result.current.isSettled).toBe(true)); + }); + + it('does not treat an in-flight state as settled', async () => { + const { result } = renderHook(() => useBackendBattle('btl_0001'), { wrapper }); + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.isSettled).toBe(false); + }); +}); + +describe('the socket is a hint, never the truth', () => { + it('re-reads the authoritative endpoint when a notification arrives', async () => { + const { result } = renderHook(() => useBackendBattle('btl_0001', { roomId: 'room_1', roomSocketUrl: 'ws://x' }), { + wrapper, + }); + await waitFor(() => expect(result.current.data).toBeDefined()); + const before = get.mock.calls.length; + + get.mockResolvedValue({ data: summary('signed') }); + await act(async () => void socket.onNotification?.()); + + await waitFor(() => expect(get.mock.calls.length).toBeGreaterThan(before)); + await waitFor(() => expect(result.current.data?.state).toBe('signed')); + }); + + it('re-reads after a reconnect, since anything during the outage was never delivered', async () => { + const { result } = renderHook(() => useBackendBattle('btl_0001', { roomId: 'room_1', roomSocketUrl: 'ws://x' }), { + wrapper, + }); + await waitFor(() => expect(result.current.data).toBeDefined()); + const before = get.mock.calls.length; + + get.mockResolvedValue({ data: summary('signed') }); + await act(async () => void socket.onReconnect?.()); + + await waitFor(() => expect(get.mock.calls.length).toBeGreaterThan(before)); + await waitFor(() => expect(result.current.data?.state).toBe('signed')); + }); + + it('surfaces socket connectivity without letting it gate the data', async () => { + socket.connected = true; + const { result } = renderHook(() => useBackendBattle('btl_0001', { roomId: 'room_1', roomSocketUrl: 'ws://x' }), { + wrapper, + }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.socketConnected).toBe(true); + }); + + it('still reads the endpoint with no room at all', async () => { + // A client that never subscribed converges on the same state by polling; the socket + // only makes that faster. + const { result } = renderHook(() => useBackendBattle('btl_0001'), { wrapper }); + await waitFor(() => expect(result.current.data).toEqual(summary('committed'))); + }); +}); + +describe('useStoredBattleEvidence', () => { + it('returns the evidence this client stored for the battle', async () => { + saveBattleEvidence({ + battleId: 'btl_0001', + commitmentHash: `0x${'11'.repeat(32)}`, + signature: `0x${'22'.repeat(65)}`, + signingKeyId: 'battle-signer-2026-07', + commitment: { drandRound: 1000 }, + storedAt: 1, + }); + + const { result } = renderHook(() => useStoredBattleEvidence('btl_0001')); + await waitFor(() => expect(result.current?.commitmentHash).toBe(`0x${'11'.repeat(32)}`)); + }); + + it('returns null on a client that never accepted this battle', async () => { + // A spectator was never promised anything, so having nothing stored is correct. + const { result } = renderHook(() => useStoredBattleEvidence('btl_0001')); + await waitFor(() => expect(result.current).toBeNull()); + }); + + it('returns null without a battle id', async () => { + const { result } = renderHook(() => useStoredBattleEvidence(null)); + await waitFor(() => expect(result.current).toBeNull()); + }); +}); diff --git a/shared/tests/hooks/useBattleRoomSocket.test.tsx b/shared/tests/hooks/useBattleRoomSocket.test.tsx new file mode 100644 index 00000000..c833401b --- /dev/null +++ b/shared/tests/hooks/useBattleRoomSocket.test.tsx @@ -0,0 +1,213 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useBattleRoomSocket } from '../../src/hooks/useBattleRoomSocket'; + +/** + * A controllable WebSocket stand-in. Real sockets cannot be driven deterministically, and + * the behaviour under test here is precisely the timing: what fires on open, on message, and + * on a reconnect after an unexpected close. + */ +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + closed = false; + + constructor(readonly url: string) { + FakeWebSocket.instances.push(this); + } + + close(): void { + this.closed = true; + } + + /** Drives the handlers the hook installed. */ + open(): void { + this.onopen?.(); + } + emit(payload: unknown): void { + this.onmessage?.({ data: JSON.stringify(payload) }); + } + emitRaw(data: string): void { + this.onmessage?.({ data }); + } + drop(): void { + this.onclose?.(); + } +} + +const NOTIFICATION = { type: 'battle-updated', battleId: 'btl_0001', state: 'signed' }; + +beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('subscription lifetime', () => { + it('connects to the room it was given', () => { + renderHook(() => useBattleRoomSocket({ url: 'ws://x/ws/battle-room', roomId: 'room_1' })); + + expect(FakeWebSocket.instances).toHaveLength(1); + expect(FakeWebSocket.instances[0]!.url).toBe('ws://x/ws/battle-room?roomId=room_1'); + }); + + it('encodes a room id that would otherwise break the query string', () => { + renderHook(() => useBattleRoomSocket({ url: 'ws://x/ws/battle-room', roomId: 'a&b=c' })); + expect(FakeWebSocket.instances[0]!.url).toBe('ws://x/ws/battle-room?roomId=a%26b%3Dc'); + }); + + it('does not connect without a url or a room', () => { + renderHook(() => useBattleRoomSocket({ url: undefined, roomId: 'room_1' })); + renderHook(() => useBattleRoomSocket({ url: 'ws://x/ws/battle-room', roomId: null })); + expect(FakeWebSocket.instances).toHaveLength(0); + }); + + it('reports connection state', () => { + const { result } = renderHook(() => useBattleRoomSocket({ url: 'ws://x', roomId: 'room_1' })); + expect(result.current.connected).toBe(false); + + act(() => FakeWebSocket.instances[0]!.open()); + expect(result.current.connected).toBe(true); + + act(() => FakeWebSocket.instances[0]!.drop()); + expect(result.current.connected).toBe(false); + }); + + it('closes the socket on unmount and does not reconnect afterwards', () => { + const { unmount } = renderHook(() => useBattleRoomSocket({ url: 'ws://x', roomId: 'room_1' })); + act(() => FakeWebSocket.instances[0]!.open()); + + unmount(); + + expect(FakeWebSocket.instances[0]!.closed).toBe(true); + act(() => void vi.advanceTimersByTime(60_000)); + // A close caused by teardown must not schedule a retry for a subscription that is + // going away, or every navigation would leak a socket. + expect(FakeWebSocket.instances).toHaveLength(1); + }); +}); + +describe('notifications', () => { + it('forwards a well-formed notification', () => { + const onNotification = vi.fn(); + renderHook(() => useBattleRoomSocket({ url: 'ws://x', roomId: 'room_1', onNotification })); + + act(() => FakeWebSocket.instances[0]!.emit(NOTIFICATION)); + + expect(onNotification).toHaveBeenCalledWith(NOTIFICATION); + }); + + it('ignores malformed frames rather than throwing', () => { + const onNotification = vi.fn(); + renderHook(() => useBattleRoomSocket({ url: 'ws://x', roomId: 'room_1', onNotification })); + + act(() => FakeWebSocket.instances[0]!.emitRaw('not json')); + act(() => FakeWebSocket.instances[0]!.emit({ type: 'something-else' })); + + expect(onNotification).not.toHaveBeenCalled(); + }); + + it('does not rebuild the socket when the callback identity changes', () => { + // A caller passing an inline closure would otherwise tear down and reconnect on + // every render, which is both wasteful and a source of missed messages. + const { rerender } = renderHook( + ({ cb }: { cb: () => void }) => useBattleRoomSocket({ url: 'ws://x', roomId: 'room_1', onNotification: cb }), + { initialProps: { cb: () => undefined } }, + ); + + rerender({ cb: () => undefined }); + rerender({ cb: () => undefined }); + + expect(FakeWebSocket.instances).toHaveLength(1); + }); + + it('routes messages to the latest callback, not the one captured at connect time', () => { + const first = vi.fn(); + const second = vi.fn(); + const { rerender } = renderHook( + ({ cb }: { cb: () => void }) => useBattleRoomSocket({ url: 'ws://x', roomId: 'room_1', onNotification: cb }), + { initialProps: { cb: first } }, + ); + + rerender({ cb: second }); + act(() => FakeWebSocket.instances[0]!.emit(NOTIFICATION)); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith(NOTIFICATION); + }); +}); + +describe('reconnection', () => { + it('reconnects after an unexpected close', () => { + renderHook(() => useBattleRoomSocket({ url: 'ws://x', roomId: 'room_1' })); + act(() => FakeWebSocket.instances[0]!.open()); + + act(() => FakeWebSocket.instances[0]!.drop()); + act(() => void vi.advanceTimersByTime(1000)); + + expect(FakeWebSocket.instances).toHaveLength(2); + }); + + it('fires onReconnect only on a later connect, never the first', () => { + const onReconnect = vi.fn(); + renderHook(() => useBattleRoomSocket({ url: 'ws://x', roomId: 'room_1', onReconnect })); + + act(() => FakeWebSocket.instances[0]!.open()); + expect(onReconnect).not.toHaveBeenCalled(); + + act(() => FakeWebSocket.instances[0]!.drop()); + act(() => void vi.advanceTimersByTime(1000)); + act(() => FakeWebSocket.instances[1]!.open()); + + // Whatever happened while the socket was down was never delivered, so the caller + // has to be told to re-read. + expect(onReconnect).toHaveBeenCalledTimes(1); + }); + + it('backs off, then resets the delay after a successful connect', () => { + renderHook(() => useBattleRoomSocket({ url: 'ws://x', roomId: 'room_1' })); + act(() => FakeWebSocket.instances[0]!.open()); + + // 1s, then 2s, then 4s. + act(() => FakeWebSocket.instances[0]!.drop()); + act(() => void vi.advanceTimersByTime(999)); + expect(FakeWebSocket.instances).toHaveLength(1); + act(() => void vi.advanceTimersByTime(1)); + expect(FakeWebSocket.instances).toHaveLength(2); + + act(() => FakeWebSocket.instances[1]!.drop()); + act(() => void vi.advanceTimersByTime(1999)); + expect(FakeWebSocket.instances).toHaveLength(2); + act(() => void vi.advanceTimersByTime(1)); + expect(FakeWebSocket.instances).toHaveLength(3); + + // A connect that succeeds puts the next outage back at one second. + act(() => FakeWebSocket.instances[2]!.open()); + act(() => FakeWebSocket.instances[2]!.drop()); + act(() => void vi.advanceTimersByTime(1000)); + expect(FakeWebSocket.instances).toHaveLength(4); + }); + + it('caps the backoff so a long outage still recovers on its own', () => { + renderHook(() => useBattleRoomSocket({ url: 'ws://x', roomId: 'room_1' })); + + for (let attempt = 0; attempt < 12; attempt++) { + act(() => FakeWebSocket.instances.at(-1)!.drop()); + act(() => void vi.advanceTimersByTime(30_000)); + } + + // Without a cap the delay would have grown past an hour by now. + expect(FakeWebSocket.instances.length).toBe(13); + }); +}); diff --git a/shared/tests/hooks/useSubmitBattleIntent.test.tsx b/shared/tests/hooks/useSubmitBattleIntent.test.tsx new file mode 100644 index 00000000..8f5c20da --- /dev/null +++ b/shared/tests/hooks/useSubmitBattleIntent.test.tsx @@ -0,0 +1,260 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + hashRuleset, + SOURCE_DEFAULT_RULESET, + battleIntentSolanaMessage, + battleIntentTypedData, +} from '@cryptopets/protocol'; + +const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); +const CONFIG = { + deploymentId: 'base-sepolia-live', + chainIds: ['eip155:84532', 'solana:devnet'], + ruleset: { hash: RULESET_HASH, version: SOURCE_DEFAULT_RULESET.version }, +}; + +const chain = vi.hoisted(() => ({ + current: { kind: 'evm' as 'evm' | 'solana' | 'none', address: '0xabcdef0123456789abcdef0123456789abcdef01' }, +})); +const signTypedDataAsync = vi.hoisted(() => vi.fn()); +const solanaSigner = vi.hoisted(() => ({ + current: null as null | { getAddress: () => string; signMessage: (m: Uint8Array) => Promise }, +})); +const post = vi.hoisted(() => vi.fn()); +const configQuery = vi.hoisted(() => ({ current: undefined as unknown })); + +vi.mock('../../src/hooks/useActiveChain', () => ({ useActiveChain: () => chain.current })); +vi.mock('wagmi', () => ({ useSignTypedData: () => ({ signTypedDataAsync }) })); +vi.mock('../../src/auth/solanaAuthStore', () => ({ getSolanaAuthSigner: () => solanaSigner.current })); +vi.mock('../../src/contexts/ApiClientContext', () => ({ useApiClient: () => ({ post, get: vi.fn() }) })); +vi.mock('../../src/hooks/useBattleConfig', () => ({ useBattleConfig: () => ({ data: configQuery.current }) })); + +import { setEvidenceStore, readBattleEvidence, type EvidenceStore } from '../../src/utils/battleEvidence'; +import { useSubmitBattleIntent } from '../../src/hooks/useSubmitBattleIntent'; + +const ACCEPTED = { + battleId: 'btl_0001', + commitmentHash: `0x${'11'.repeat(32)}`, + signature: `0x${'22'.repeat(65)}`, + signingKeyId: 'battle-signer-2026-07', + commitment: { drandRound: 1000 }, +}; + +function memoryStore(): EvidenceStore { + const map = new Map(); + return { + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => void map.set(key, value), + removeItem: (key) => void map.delete(key), + }; +} + +const VARS = { attackerPetId: '1', defenderOwner: '0x2222222222222222222222222222222222222222', defenderPetId: '2' }; + +beforeEach(() => { + vi.clearAllMocks(); + setEvidenceStore(memoryStore()); + chain.current = { kind: 'evm', address: '0xabcdef0123456789abcdef0123456789abcdef01' }; + configQuery.current = CONFIG; + solanaSigner.current = null; + signTypedDataAsync.mockResolvedValue(`0x${'33'.repeat(65)}`); + post.mockImplementation((url: string) => + url.endsWith('/accept') + ? Promise.resolve({ data: ACCEPTED }) + : Promise.resolve({ data: { intentHash: `0x${'44'.repeat(32)}` } }), + ); +}); + +afterEach(() => setEvidenceStore(null)); + +describe('the EVM path', () => { + it('signs EIP-712 typed data, submits, accepts, and returns the commitment', async () => { + const { result } = renderHook(() => useSubmitBattleIntent()); + + let accepted: unknown; + await act(async () => { + accepted = await result.current.submit(VARS); + }); + + expect(accepted).toEqual(ACCEPTED); + expect(post).toHaveBeenNthCalledWith(1, '/api/battle/intents', expect.objectContaining({ signatureFormat: 'eip712' })); + expect(post).toHaveBeenNthCalledWith(2, `/api/battle/intents/0x${'44'.repeat(32)}/accept`, {}); + }); + + it('signs the exact typed data the protocol defines, not a hand-rolled copy', async () => { + const { result } = renderHook(() => useSubmitBattleIntent()); + await act(async () => void (await result.current.submit(VARS))); + + const wire = post.mock.calls[0]![1].intent; + // Rebuilding the intent from the wire payload and re-deriving the typed data must + // reproduce exactly what the wallet was asked to sign, or the backend's own + // verification recovers a different address. + const expected = battleIntentTypedData({ + domain: { chainId: wire.chainId, deploymentId: wire.deploymentId }, + attackerOwner: wire.attackerOwner, + attackerPetId: BigInt(wire.attackerPetId), + defenderOwner: wire.defenderOwner, + defenderPetId: BigInt(wire.defenderPetId), + challengeId: wire.challengeId, + clientNonce: wire.clientNonce, + rulesetHash: wire.rulesetHash, + expiresAt: wire.expiresAt, + }); + expect(signTypedDataAsync).toHaveBeenCalledWith({ + domain: expected.domain, + types: expected.types, + primaryType: 'BattleIntent', + message: expected.message, + }); + }); + + it('names the served deployment and ruleset rather than guessing them', async () => { + const { result } = renderHook(() => useSubmitBattleIntent()); + await act(async () => void (await result.current.submit(VARS))); + + const wire = post.mock.calls[0]![1].intent; + expect(wire.deploymentId).toBe(CONFIG.deploymentId); + expect(wire.rulesetHash).toBe(RULESET_HASH); + expect(wire.chainId).toBe('eip155:84532'); + }); + + it('serializes pet ids as decimal strings, since JSON has no bigint', async () => { + const { result } = renderHook(() => useSubmitBattleIntent()); + await act(async () => void (await result.current.submit(VARS))); + + const wire = post.mock.calls[0]![1].intent; + expect(wire.attackerPetId).toBe('1'); + expect(wire.defenderPetId).toBe('2'); + }); + + it('uses a fresh nonce per submission, so one signature cannot be replayed', async () => { + const { result } = renderHook(() => useSubmitBattleIntent()); + await act(async () => void (await result.current.submit(VARS))); + await act(async () => void (await result.current.submit(VARS))); + + const first = post.mock.calls[0]![1].intent.clientNonce; + const second = post.mock.calls[2]![1].intent.clientNonce; + expect(first).not.toBe(second); + }); + + it('passes the room through so spectators get pushed state changes', async () => { + const { result } = renderHook(() => useSubmitBattleIntent()); + await act(async () => void (await result.current.submit({ ...VARS, roomId: 'room_1' }))); + + expect(post).toHaveBeenNthCalledWith(2, expect.stringContaining('/accept'), { roomId: 'room_1' }); + }); +}); + +describe('persisting the evidence', () => { + it('stores the signed commitment before returning, so a reload keeps it', async () => { + const { result } = renderHook(() => useSubmitBattleIntent()); + await act(async () => void (await result.current.submit(VARS))); + + const stored = readBattleEvidence('btl_0001'); + expect(stored).toMatchObject({ + battleId: ACCEPTED.battleId, + commitmentHash: ACCEPTED.commitmentHash, + signature: ACCEPTED.signature, + signingKeyId: ACCEPTED.signingKeyId, + commitment: ACCEPTED.commitment, + }); + }); + + it('stores nothing when accept fails', async () => { + post.mockImplementation((url: string) => + url.endsWith('/accept') + ? Promise.reject(new Error('daily-cap-reached')) + : Promise.resolve({ data: { intentHash: `0x${'44'.repeat(32)}` } }), + ); + const { result } = renderHook(() => useSubmitBattleIntent()); + + let accepted: unknown; + await act(async () => { + accepted = await result.current.submit(VARS); + }); + + expect(accepted).toBeNull(); + expect(readBattleEvidence('btl_0001')).toBeNull(); + expect(result.current.error?.message).toContain('daily-cap-reached'); + }); +}); + +describe('the Solana path', () => { + it('signs the labelled message and sends it base58', async () => { + const signMessage = vi.fn().mockResolvedValue(new Uint8Array(64).fill(7)); + chain.current = { kind: 'solana', address: 'So11111111111111111111111111111111111111112' }; + solanaSigner.current = { getAddress: () => 'So11111111111111111111111111111111111111112', signMessage }; + + const { result } = renderHook(() => useSubmitBattleIntent()); + await act(async () => void (await result.current.submit(VARS))); + + const wire = post.mock.calls[0]![1]; + expect(wire.signatureFormat).toBe('solana-message'); + expect(wire.intent.chainId).toBe('solana:devnet'); + + // What the wallet was handed must be the protocol's labelled text, byte for byte. + const expectedMessage = battleIntentSolanaMessage({ + domain: { chainId: wire.intent.chainId, deploymentId: wire.intent.deploymentId }, + attackerOwner: wire.intent.attackerOwner, + attackerPetId: BigInt(wire.intent.attackerPetId), + defenderOwner: wire.intent.defenderOwner, + defenderPetId: BigInt(wire.intent.defenderPetId), + challengeId: wire.intent.challengeId, + clientNonce: wire.intent.clientNonce, + rulesetHash: wire.intent.rulesetHash, + expiresAt: wire.intent.expiresAt, + }); + expect(new TextDecoder().decode(signMessage.mock.calls[0]![0])).toBe(expectedMessage); + // Base58 of 64 bytes is never 0x-hex, so this also proves it was not sent raw. + expect(wire.signature.startsWith('0x')).toBe(false); + }); + + it('fails cleanly when no Solana signer is connected', async () => { + chain.current = { kind: 'solana', address: 'So11111111111111111111111111111111111111112' }; + solanaSigner.current = null; + + const { result } = renderHook(() => useSubmitBattleIntent()); + await act(async () => void (await result.current.submit(VARS))); + + expect(post).not.toHaveBeenCalled(); + expect(result.current.error?.message).toMatch(/no Solana signer/); + }); +}); + +describe('refusing to sign against guesses', () => { + it('does nothing without a connected wallet', async () => { + chain.current = { kind: 'none' } as never; + const { result } = renderHook(() => useSubmitBattleIntent()); + + await act(async () => void (await result.current.submit(VARS))); + + expect(signTypedDataAsync).not.toHaveBeenCalled(); + expect(result.current.error?.message).toMatch(/connect a wallet/); + }); + + it('does not prompt the wallet before the served config has loaded', async () => { + // Signing against a guessed deployment would be refused server-side *after* the + // prompt, which is the worst moment to discover it. + configQuery.current = undefined; + const { result } = renderHook(() => useSubmitBattleIntent()); + + await act(async () => void (await result.current.submit(VARS))); + + expect(signTypedDataAsync).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + expect(result.current.error?.message).toMatch(/configuration is not loaded/); + }); + + it('refuses when the deployment serves no chain of the connected family', async () => { + configQuery.current = { ...CONFIG, chainIds: ['solana:devnet'] }; + const { result } = renderHook(() => useSubmitBattleIntent()); + + await act(async () => void (await result.current.submit(VARS))); + + expect(signTypedDataAsync).not.toHaveBeenCalled(); + expect(result.current.error?.message).toMatch(/serves no evm chain/); + }); +}); diff --git a/shared/tests/utils/battleEvidence.test.ts b/shared/tests/utils/battleEvidence.test.ts new file mode 100644 index 00000000..04cdbd0e --- /dev/null +++ b/shared/tests/utils/battleEvidence.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + forgetBattleEvidence, + listBattleEvidenceIds, + readBattleEvidence, + saveBattleEvidence, + setEvidenceStore, + type BattleEvidence, + type EvidenceStore, +} from '../../src/utils/battleEvidence'; + +function memoryStore(): EvidenceStore & { map: Map } { + const map = new Map(); + return { + map, + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => void map.set(key, value), + removeItem: (key) => void map.delete(key), + }; +} + +function evidence(battleId: string): BattleEvidence { + return { + battleId, + commitmentHash: `0x${'11'.repeat(32)}`, + signature: `0x${'22'.repeat(65)}`, + signingKeyId: 'battle-signer-2026-07', + commitment: { drandRound: 1000, deploymentId: 'base-sepolia-live' }, + storedAt: 1_770_000_000_000, + }; +} + +let store: ReturnType; + +beforeEach(() => { + store = memoryStore(); + setEvidenceStore(store); +}); + +afterEach(() => { + setEvidenceStore(null); +}); + +describe('saving and reading', () => { + it('round-trips a battle evidence record verbatim', () => { + saveBattleEvidence(evidence('btl_0001')); + expect(readBattleEvidence('btl_0001')).toEqual(evidence('btl_0001')); + }); + + it('preserves the commitment object exactly, so it can be re-hashed later', () => { + // The stored commitment is the thing a verifier re-hashes; a lossy round trip + // would leave the player holding evidence that no longer checks out. + const original = evidence('btl_0001'); + saveBattleEvidence(original); + expect(readBattleEvidence('btl_0001')?.commitment).toEqual(original.commitment); + }); + + it('returns null for a battle it never stored', () => { + expect(readBattleEvidence('btl_missing')).toBeNull(); + }); + + it('replaces an earlier copy for the same battle rather than duplicating it', () => { + saveBattleEvidence(evidence('btl_0001')); + saveBattleEvidence({ ...evidence('btl_0001'), signingKeyId: 'battle-signer-2026-08' }); + + expect(readBattleEvidence('btl_0001')?.signingKeyId).toBe('battle-signer-2026-08'); + expect(listBattleEvidenceIds()).toEqual(['btl_0001']); + }); + + it('tracks every stored battle in the index', () => { + saveBattleEvidence(evidence('btl_0001')); + saveBattleEvidence(evidence('btl_0002')); + expect(listBattleEvidenceIds()).toEqual(['btl_0001', 'btl_0002']); + }); + + it('forgets one battle without disturbing the others', () => { + saveBattleEvidence(evidence('btl_0001')); + saveBattleEvidence(evidence('btl_0002')); + + forgetBattleEvidence('btl_0001'); + + expect(readBattleEvidence('btl_0001')).toBeNull(); + expect(readBattleEvidence('btl_0002')).not.toBeNull(); + expect(listBattleEvidenceIds()).toEqual(['btl_0002']); + }); +}); + +describe('surviving a hostile store', () => { + it('reads null rather than throwing on a corrupted blob', () => { + store.map.set('cryptopets.battle-evidence.btl_0001', 'not json'); + expect(readBattleEvidence('btl_0001')).toBeNull(); + }); + + it('rejects a blob that lost its identifying fields', () => { + // Present but unusable is not evidence of anything, and returning it would let a + // caller believe it holds a commitment it cannot check. + store.map.set('cryptopets.battle-evidence.btl_0001', JSON.stringify({ storedAt: 1 })); + expect(readBattleEvidence('btl_0001')).toBeNull(); + }); + + it('returns an empty list when the index is corrupted', () => { + store.map.set('cryptopets.battle-evidence.index', '{"not":"an array"}'); + expect(listBattleEvidenceIds()).toEqual([]); + }); + + it('does not throw when the store refuses writes', () => { + // A full quota, or Safari private mode, must cost the player a convenience and + // never the battle itself. + setEvidenceStore({ + getItem: () => null, + setItem: () => { + throw new Error('QuotaExceededError'); + }, + removeItem: () => undefined, + }); + + expect(() => saveBattleEvidence(evidence('btl_0001'))).not.toThrow(); + expect(() => forgetBattleEvidence('btl_0001')).not.toThrow(); + expect(readBattleEvidence('btl_0001')).toBeNull(); + }); +}); From ed1f06737414e95f4d0ec9dff5622f44e53d5373 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 15:39:36 -0400 Subject: [PATCH 35/76] feat(frontend): verify battle receipts client-side before replaying the fight --- pnpm-lock.yaml | 951 +++++++++--------- shared/package.json | 1 + shared/src/hooks/index.ts | 5 + shared/src/hooks/useVerifiedBattleReceipt.ts | 160 +++ .../hooks/useVerifiedBattleReceipt.test.tsx | 342 +++++++ verifier/README.md | 29 + verifier/package.json | 3 +- verifier/src/checks/index.ts | 12 + 8 files changed, 1029 insertions(+), 474 deletions(-) create mode 100644 shared/src/hooks/useVerifiedBattleReceipt.ts create mode 100644 shared/tests/hooks/useVerifiedBattleReceipt.test.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fd4cff7..f072cc09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,28 +238,28 @@ importers: dependencies: '@dynamic-labs/ethereum': specifier: ^4.37.1 - version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@dynamic-labs/sdk-react-core': specifier: ^4.37.1 - version: 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) + version: 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) '@dynamic-labs/solana': specifier: ^4.37.1 - version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@dynamic-labs/wagmi-connector': specifier: ^4.37.1 - version: 4.40.1(eztakswpwypzfani4hwnwntxkq) + version: 4.40.1(lupvgyugmbc5ztyp7prdwbwueq) '@shared/core': specifier: workspace:* version: link:../shared '@solana/wallet-adapter-react': specifier: ^0.15.35 - version: 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + version: 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-adapter-react-ui': specifier: ^0.9.35 - version: 0.9.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + version: 0.9.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-adapter-wallets': specifier: ^0.19.32 - version: 0.19.37(g72kdmcu56a5ty4czgn6xpkdlu) + version: 0.19.37(wcwzcvkiean7xoqtynzwkhqyla) '@solana/web3.js': specifier: ^1.95.2 version: 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -292,7 +292,7 @@ importers: version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) wagmi: specifier: ^2.17.1 - version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -383,19 +383,19 @@ importers: version: 0.32.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@react-native-async-storage/async-storage': specifier: ^2.2.0 - version: 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + version: 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) '@react-native-community/netinfo': specifier: ^11.4.1 - version: 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + version: 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) '@reown/appkit-react-native': specifier: ^2.0.1 - version: 2.0.1(oamxhoebs4lkohisorpqzkfdmy) + version: 2.0.1(htg2tpf3zzmsrhrwiskzh3ey3i) '@reown/appkit-solana-react-native': specifier: ^2.0.1 - version: 2.0.1(@walletconnect/react-native-compat@2.23.0(o2cbduf7egsa2inysox7pg5zyu))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) + version: 2.0.1(@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10) '@reown/appkit-wagmi-react-native': specifier: ^2.0.1 - version: 2.0.1(djaefxiucauy2vu2b3pakp2lue) + version: 2.0.1(gbsuv35t73ppduqv7v3dwovljy) '@shared/core': specifier: workspace:* version: link:../shared @@ -407,7 +407,7 @@ importers: version: 5.90.5(react@19.1.1) '@walletconnect/react-native-compat': specifier: ^2.23.0 - version: 2.23.0(o2cbduf7egsa2inysox7pg5zyu) + version: 2.23.0(lh5jzsrjqwxruiai4runjz3fou) bs58: specifier: ^6.0.0 version: 6.0.0 @@ -416,25 +416,25 @@ importers: version: 19.1.1 react-native: specifier: 0.82.0 - version: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + version: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) react-native-dotenv: specifier: ^3.4.11 version: 3.4.11(@babel/runtime@7.28.4) react-native-get-random-values: specifier: ^2.0.0 - version: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + version: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) react-native-safe-area-context: specifier: ^5.5.2 - version: 5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + version: 5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) react-native-svg: specifier: ^15.14.0 - version: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + version: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) viem: specifier: ~2.38.3 version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) wagmi: specifier: ^2.18.2 - version: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) devDependencies: '@babel/core': specifier: ^7.25.2 @@ -459,7 +459,7 @@ importers: version: 0.82.0(eslint@8.57.1)(jest@29.7.0(@types/node@22.18.12))(prettier@2.8.8)(typescript@5.8.3) '@react-native/metro-config': specifier: 0.82.0 - version: 0.82.0(@babel/core@7.28.5) + version: 0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@react-native/typescript-config': specifier: 0.82.0 version: 0.82.0 @@ -486,7 +486,7 @@ importers: version: 19.1.1(react@19.1.1) reactotron-react-native: specifier: ^5.0.0 - version: 5.1.18(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + version: 5.1.18(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) typescript: specifier: ^5.8.3 version: 5.8.3 @@ -539,6 +539,9 @@ importers: '@cryptopets/protocol': specifier: workspace:* version: link:../protocol + '@cryptopets/verifier': + specifier: workspace:* + version: link:../verifier '@solana/web3.js': specifier: ^1.95.0 version: 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -562,7 +565,7 @@ importers: version: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) wagmi: specifier: ^2.0.0 - version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -12862,7 +12865,7 @@ snapshots: - react - react-dom - '@dynamic-labs/ethereum@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': + '@dynamic-labs/ethereum@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: '@coinbase/wallet-sdk': 4.3.7(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@dynamic-labs-connectors/base-account-evm': 4.4.2(@dynamic-labs/ethereum-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)))(@dynamic-labs/wallet-connector-core@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) @@ -12877,7 +12880,7 @@ snapshots: '@dynamic-labs/wallet-book': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@metamask/sdk': 0.33.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10) - '@walletconnect/ethereum-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/ethereum-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) buffer: 6.0.3 eventemitter3: 5.0.1 viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) @@ -12925,11 +12928,11 @@ snapshots: react-dom: 19.1.1(react@19.1.1) sharp: 0.33.5 - '@dynamic-labs/locale@4.40.1(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': + '@dynamic-labs/locale@4.40.1(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 i18next: 23.4.6 - react-i18next: 13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + react-i18next: 13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) transitivePeerDependencies: - react - react-dom @@ -12970,12 +12973,12 @@ snapshots: '@dynamic-labs/sdk-api-core@0.0.813': {} - '@dynamic-labs/sdk-react-core@4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10)': + '@dynamic-labs/sdk-react-core@4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10)': dependencies: '@dynamic-labs-sdk/client': 0.1.0-alpha.23(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/iconic': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@dynamic-labs/locale': 4.40.1(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@dynamic-labs/locale': 4.40.1(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/multi-wallet': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@dynamic-labs/rpc-providers': 4.40.1 @@ -12996,7 +12999,7 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) react-focus-lock: 2.13.6(@types/react@19.2.2)(react@19.1.1) - react-i18next: 13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + react-i18next: 13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) react-international-phone: 4.5.0(react@19.1.1) yup: 0.32.11 transitivePeerDependencies: @@ -13027,7 +13030,7 @@ snapshots: - typescript - utf-8-validate - '@dynamic-labs/solana@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': + '@dynamic-labs/solana@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/embedded-wallet-solana': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) @@ -13039,16 +13042,16 @@ snapshots: '@dynamic-labs/utils': 4.40.1 '@dynamic-labs/waas-svm': 4.40.1(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/wallet-book': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@dynamic-labs/wallet-connect': 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@dynamic-labs/wallet-connect': 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@solana/web3.js': 1.98.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@wallet-standard/app': 1.0.1 '@wallet-standard/base': 1.0.1 '@wallet-standard/experimental-features': 0.1.1 '@wallet-standard/features': 1.0.3 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 5.0.0 eventemitter3: 5.0.1 tweetnacl: 1.0.3 @@ -13199,20 +13202,20 @@ snapshots: - utf-8-validate - viem - '@dynamic-labs/wagmi-connector@4.40.1(eztakswpwypzfani4hwnwntxkq)': + '@dynamic-labs/wagmi-connector@4.40.1(lupvgyugmbc5ztyp7prdwbwueq)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/ethereum-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) '@dynamic-labs/logger': 4.40.1 '@dynamic-labs/rpc-providers': 4.40.1 - '@dynamic-labs/sdk-react-core': 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) + '@dynamic-labs/sdk-react-core': 4.40.1(@types/react@19.2.2)(bufferutil@4.0.9)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(utf-8-validate@5.0.10) '@dynamic-labs/types': 4.40.1 '@dynamic-labs/wallet-connector-core': 4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) eventemitter3: 5.0.4 react: 19.1.1 viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) '@dynamic-labs/wallet-book@4.40.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: @@ -13226,11 +13229,11 @@ snapshots: util: 0.12.5 zod: 4.0.5 - '@dynamic-labs/wallet-connect@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@dynamic-labs/wallet-connect@4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@dynamic-labs/assert-package-version': 4.40.1 '@dynamic-labs/logger': 4.40.1 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15467,16 +15470,16 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 - '@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': + '@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': dependencies: merge-options: 3.0.4 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) optional: true - '@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': + '@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': dependencies: merge-options: 3.0.4 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) '@react-native-community/cli-clean@20.0.0': dependencies: @@ -15607,9 +15610,9 @@ snapshots: - typescript - utf-8-validate - '@react-native-community/netinfo@11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': + '@react-native-community/netinfo@11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))': dependencies: - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) '@react-native/assets-registry@0.82.0': {} @@ -15681,7 +15684,7 @@ snapshots: nullthrows: 1.1.1 yargs: 17.7.2 - '@react-native/community-cli-plugin@0.82.0(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + '@react-native/community-cli-plugin@0.82.0(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(utf-8-validate@5.0.10)': dependencies: '@react-native/dev-middleware': 0.82.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) debug: 4.4.3(supports-color@8.1.1) @@ -15692,7 +15695,7 @@ snapshots: semver: 7.7.3 optionalDependencies: '@react-native-community/cli': 20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@react-native/metro-config': 0.82.0(@babel/core@7.28.5) + '@react-native/metro-config': 0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - supports-color @@ -15760,7 +15763,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.82.0(@babel/core@7.28.5)': + '@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10)': dependencies: '@react-native/js-polyfills': 0.82.0 '@react-native/metro-babel-transformer': 0.82.0(@babel/core@7.28.5) @@ -15768,27 +15771,29 @@ snapshots: metro-runtime: 0.83.3 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.82.0': {} '@react-native/typescript-config@0.82.0': {} - '@react-native/virtualized-lists@0.82.0(@types/react@19.2.2)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': + '@react-native/virtualized-lists@0.82.0(@types/react@19.2.2)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) optionalDependencies: '@types/react': 19.2.2 - '@reown/appkit-common-react-native@2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': + '@reown/appkit-common-react-native@2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': dependencies: bignumber.js: 9.1.2 dayjs: 1.11.10 react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) '@reown/appkit-common@1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.22.4)': dependencies: @@ -15845,11 +15850,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-controllers@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: @@ -15880,11 +15885,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: @@ -15915,11 +15920,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: @@ -15950,11 +15955,11 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-controllers@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: @@ -15985,24 +15990,24 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-core-react-native@2.0.1(@types/react@19.2.2)(@walletconnect/react-native-compat@2.23.0(o2cbduf7egsa2inysox7pg5zyu))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': + '@reown/appkit-core-react-native@2.0.1(@types/react@19.2.2)(@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': dependencies: - '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@walletconnect/react-native-compat': 2.23.0(o2cbduf7egsa2inysox7pg5zyu) + '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@walletconnect/react-native-compat': 2.23.0(lh5jzsrjqwxruiai4runjz3fou) countries-and-timezones: 3.7.2 derive-valtio: 0.2.0(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1)) react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: - '@types/react' - '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) lit: 3.3.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: @@ -16033,12 +16038,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) lit: 3.3.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: @@ -16069,12 +16074,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-pay@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) lit: 3.3.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: @@ -16113,18 +16118,18 @@ snapshots: dependencies: buffer: 6.0.3 - '@reown/appkit-react-native@2.0.1(oamxhoebs4lkohisorpqzkfdmy)': + '@reown/appkit-react-native@2.0.1(htg2tpf3zzmsrhrwiskzh3ey3i)': dependencies: - '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@reown/appkit-core-react-native': 2.0.1(@types/react@19.2.2)(@walletconnect/react-native-compat@2.23.0(o2cbduf7egsa2inysox7pg5zyu))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@reown/appkit-ui-react-native': 2.0.1(react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@walletconnect/react-native-compat': 2.23.0(o2cbduf7egsa2inysox7pg5zyu) - '@walletconnect/universal-provider': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) + '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@reown/appkit-core-react-native': 2.0.1(@types/react@19.2.2)(@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@reown/appkit-ui-react-native': 2.0.1(react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@walletconnect/react-native-compat': 2.23.0(lh5jzsrjqwxruiai4runjz3fou) + '@walletconnect/universal-provider': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-safe-area-context: 5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - react-native-svg: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native-safe-area-context: 5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + react-native-svg: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) transitivePeerDependencies: - '@azure/app-configuration' @@ -16153,12 +16158,12 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-scaffold-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-scaffold-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.1.0 transitivePeerDependencies: @@ -16190,12 +16195,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -16227,12 +16232,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -16264,12 +16269,12 @@ snapshots: - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -16301,15 +16306,15 @@ snapshots: - valtio - zod - '@reown/appkit-solana-react-native@2.0.1(@walletconnect/react-native-compat@2.23.0(o2cbduf7egsa2inysox7pg5zyu))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)': + '@reown/appkit-solana-react-native@2.0.1(@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)': dependencies: - '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) '@solana/spl-token': 0.4.13(@solana/web3.js@1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) '@solana/web3.js': 1.98.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/react-native-compat': 2.23.0(o2cbduf7egsa2inysox7pg5zyu) + '@walletconnect/react-native-compat': 2.23.0(lh5jzsrjqwxruiai4runjz3fou) bs58: 6.0.0 react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) tweetnacl: 1.0.3 transitivePeerDependencies: - bufferutil @@ -16318,19 +16323,19 @@ snapshots: - typescript - utf-8-validate - '@reown/appkit-ui-react-native@2.0.1(react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': + '@reown/appkit-ui-react-native@2.0.1(react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)': dependencies: - '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) polished: 4.3.1 qrcode: 1.5.3 react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-svg: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native-svg: 15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@reown/appkit-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-ui@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.1.0 qrcode: 1.5.3 @@ -16362,10 +16367,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -16397,10 +16402,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -16432,10 +16437,10 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit-ui@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 qrcode: 1.5.3 @@ -16467,14 +16472,14 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-utils@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.2 '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: @@ -16505,14 +16510,14 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': + '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: @@ -16543,14 +16548,14 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: @@ -16581,14 +16586,14 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': + '@reown/appkit-utils@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) transitivePeerDependencies: @@ -16619,17 +16624,17 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-wagmi-react-native@2.0.1(djaefxiucauy2vu2b3pakp2lue)': + '@reown/appkit-wagmi-react-native@2.0.1(gbsuv35t73ppduqv7v3dwovljy)': dependencies: - '@react-native-community/netinfo': 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) - '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) - '@reown/appkit-react-native': 2.0.1(oamxhoebs4lkohisorpqzkfdmy) - '@walletconnect/react-native-compat': 2.23.0(o2cbduf7egsa2inysox7pg5zyu) + '@react-native-community/netinfo': 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@reown/appkit-common-react-native': 2.0.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@reown/appkit-react-native': 2.0.1(htg2tpf3zzmsrhrwiskzh3ey3i) + '@walletconnect/react-native-compat': 2.23.0(lh5jzsrjqwxruiai4runjz3fou) react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-get-random-values: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native-get-random-values: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16682,17 +16687,17 @@ snapshots: - typescript - utf-8-validate - '@reown/appkit@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit@1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@reown/appkit-common': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.2 - '@reown/appkit-scaffold-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) - '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-scaffold-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-ui': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) @@ -16724,18 +16729,18 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@4.4.3) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) @@ -16767,18 +16772,18 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) @@ -16810,18 +16815,18 @@ snapshots: - utf-8-validate - zod - '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@reown/appkit@1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-pay': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@reown/appkit-polyfills': 1.7.8 - '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) - '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@2.1.8(@types/react@19.2.2)(react@19.1.1))(zod@3.25.76) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 valtio: 2.1.8(@types/react@19.2.2)(react@19.1.1) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) @@ -17050,9 +17055,9 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} - '@solana-mobile/mobile-wallet-adapter-protocol-web3js@2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana-mobile/mobile-wallet-adapter-protocol-web3js@2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: - '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) bs58: 5.0.0 js-base64: 3.7.8 @@ -17063,14 +17068,14 @@ snapshots: - react-native - typescript - '@solana-mobile/mobile-wallet-adapter-protocol@2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana-mobile/mobile-wallet-adapter-protocol@2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: '@solana/codecs-strings': 4.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/wallet-standard': 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(react@19.1.1) '@solana/wallet-standard-util': 1.1.2 '@wallet-standard/core': 1.1.1 js-base64: 3.7.8 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) transitivePeerDependencies: - '@solana/wallet-adapter-base' - '@solana/web3.js' @@ -17079,25 +17084,25 @@ snapshots: - react - typescript - '@solana-mobile/wallet-adapter-mobile@2.2.5(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana-mobile/wallet-adapter-mobile@2.2.5(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: - '@solana-mobile/mobile-wallet-adapter-protocol-web3js': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) - '@solana-mobile/wallet-standard-mobile': 0.4.3(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana-mobile/mobile-wallet-adapter-protocol-web3js': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana-mobile/wallet-standard-mobile': 0.4.3(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-standard-features': 1.3.0 '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) js-base64: 3.7.8 optionalDependencies: - '@react-native-async-storage/async-storage': 1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@react-native-async-storage/async-storage': 1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) transitivePeerDependencies: - fastestsmallesttextencoderdecoder - react - react-native - typescript - '@solana-mobile/wallet-standard-mobile@0.4.3(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana-mobile/wallet-standard-mobile@0.4.3(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: - '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.5(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-standard-chains': 1.1.1 '@solana/wallet-standard-features': 1.3.0 '@wallet-standard/base': 1.1.0 @@ -17684,9 +17689,9 @@ snapshots: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-base-ui@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana/wallet-adapter-base-ui@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: - '@solana/wallet-adapter-react': 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana/wallet-adapter-react': 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) react: 19.1.1 transitivePeerDependencies: @@ -17820,11 +17825,11 @@ snapshots: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-react-ui@0.9.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana/wallet-adapter-react-ui@0.9.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@solana/wallet-adapter-base-ui': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) - '@solana/wallet-adapter-react': 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana/wallet-adapter-base-ui': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana/wallet-adapter-react': 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) react: 19.1.1 react-dom: 19.1.1(react@19.1.1) @@ -17834,9 +17839,9 @@ snapshots: - react-native - typescript - '@solana/wallet-adapter-react@0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': + '@solana/wallet-adapter-react@0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3)': dependencies: - '@solana-mobile/wallet-adapter-mobile': 2.2.5(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) + '@solana-mobile/wallet-adapter-mobile': 2.2.5(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-standard-wallet-adapter-react': 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(react@19.1.1) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -17915,11 +17920,11 @@ snapshots: - typescript - utf-8-validate - '@solana/wallet-adapter-trezor@0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/wallet-adapter-trezor@0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@trezor/connect-web': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/connect-web': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) buffer: 6.0.3 transitivePeerDependencies: - '@solana/sysvars' @@ -17949,11 +17954,11 @@ snapshots: '@solana/wallet-standard-util': 1.1.2 '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-walletconnect@0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@solana/wallet-adapter-walletconnect@0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/solana-adapter': 0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/solana-adapter': 0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -17982,7 +17987,7 @@ snapshots: - utf-8-validate - zod - '@solana/wallet-adapter-wallets@0.19.37(g72kdmcu56a5ty4czgn6xpkdlu)': + '@solana/wallet-adapter-wallets@0.19.37(wcwzcvkiean7xoqtynzwkhqyla)': dependencies: '@solana/wallet-adapter-alpha': 0.1.14(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-avana': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) @@ -18015,10 +18020,10 @@ snapshots: '@solana/wallet-adapter-tokenary': 0.1.16(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-tokenpocket': 0.4.23(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-torus': 0.11.32(@babel/runtime@7.28.4)(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/wallet-adapter-trezor': 0.1.6(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-trust': 0.1.17(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-unsafe-burner': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) - '@solana/wallet-adapter-walletconnect': 0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@solana/wallet-adapter-walletconnect': 0.1.21(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@solana/wallet-adapter-xdefi': 0.1.11(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) transitivePeerDependencies: @@ -18472,9 +18477,9 @@ snapshots: - typescript - utf-8-validate - '@trezor/analytics@1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': + '@trezor/analytics@1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': dependencies: - '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/utils': 9.4.3(tslib@2.8.1) tslib: 2.8.1 transitivePeerDependencies: @@ -18488,11 +18493,11 @@ snapshots: '@trezor/utxo-lib': 2.4.4(tslib@2.8.1) tslib: 2.8.1 - '@trezor/blockchain-link-utils@1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10)': + '@trezor/blockchain-link-utils@1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10)': dependencies: '@mobily/ts-belt': 3.13.1 '@stellar/stellar-sdk': 13.3.0 - '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/protobuf': 1.4.4(tslib@2.8.1) '@trezor/utils': 9.4.4(tslib@2.8.1) tslib: 2.8.1 @@ -18505,7 +18510,7 @@ snapshots: - react-native - utf-8-validate - '@trezor/blockchain-link@2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/blockchain-link@2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) '@solana-program/stake': 0.2.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) @@ -18515,8 +18520,8 @@ snapshots: '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@stellar/stellar-sdk': 13.3.0 '@trezor/blockchain-link-types': 1.4.4(tslib@2.8.1) - '@trezor/blockchain-link-utils': 1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10) - '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/blockchain-link-utils': 1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10) + '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/utils': 9.4.4(tslib@2.8.1) '@trezor/utxo-lib': 2.4.4(tslib@2.8.1) '@trezor/websocket-client': 1.2.4(bufferutil@4.0.9)(tslib@2.8.1)(utf-8-validate@5.0.10) @@ -18539,18 +18544,18 @@ snapshots: - utf-8-validate - ws - '@trezor/connect-analytics@1.3.6(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': + '@trezor/connect-analytics@1.3.6(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': dependencies: - '@trezor/analytics': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/analytics': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) tslib: 2.8.1 transitivePeerDependencies: - expo-constants - expo-localization - react-native - '@trezor/connect-common@0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': + '@trezor/connect-common@0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': dependencies: - '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/type-utils': 1.1.9 '@trezor/utils': 9.4.4(tslib@2.8.1) tslib: 2.8.1 @@ -18559,10 +18564,10 @@ snapshots: - expo-localization - react-native - '@trezor/connect-web@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/connect-web@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: - '@trezor/connect': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@trezor/connect-common': 0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/connect': 9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/connect-common': 0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/utils': 9.4.4(tslib@2.8.1) '@trezor/websocket-client': 1.2.4(bufferutil@4.0.9)(tslib@2.8.1)(utf-8-validate@5.0.10) tslib: 2.8.1 @@ -18580,7 +18585,7 @@ snapshots: - utf-8-validate - ws - '@trezor/connect@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@trezor/connect@9.6.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@ethereumjs/common': 10.0.0 '@ethereumjs/tx': 10.0.0 @@ -18593,14 +18598,14 @@ snapshots: '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@trezor/blockchain-link': 2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@trezor/blockchain-link': 2.5.4(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@trezor/blockchain-link-types': 1.4.4(tslib@2.8.1) - '@trezor/blockchain-link-utils': 1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10) - '@trezor/connect-analytics': 1.3.6(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) - '@trezor/connect-common': 0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/blockchain-link-utils': 1.4.4(bufferutil@4.0.9)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)(utf-8-validate@5.0.10) + '@trezor/connect-analytics': 1.3.6(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/connect-common': 0.4.4(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/crypto-utils': 1.1.5(tslib@2.8.1) '@trezor/device-utils': 1.1.4 - '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) + '@trezor/env-utils': 1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1) '@trezor/protobuf': 1.4.4(tslib@2.8.1) '@trezor/protocol': 1.2.10(tslib@2.8.1) '@trezor/schema-utils': 1.3.4(tslib@2.8.1) @@ -18635,12 +18640,12 @@ snapshots: '@trezor/device-utils@1.1.4': {} - '@trezor/env-utils@1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': + '@trezor/env-utils@1.4.3(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(tslib@2.8.1)': dependencies: tslib: 2.8.1 ua-parser-js: 2.0.6 optionalDependencies: - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) '@trezor/protobuf@1.4.4(tslib@2.8.1)': dependencies: @@ -19315,7 +19320,7 @@ snapshots: '@vue/shared@3.5.22': {} - '@wagmi/connectors@6.1.0(5wnggatnpg3gomvuzxtzqousqe)': + '@wagmi/connectors@6.1.0(2orsghzlwewxwohejkwolumw4e)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) @@ -19324,9 +19329,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(2xcn7d5aunq6wiuiuf55mtzjz4) + porto: 0.2.19(6oek35uj62dxa7liwvqvv47ara) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 @@ -19362,7 +19367,7 @@ snapshots: - wagmi - zod - '@wagmi/connectors@6.1.0(6cluku43u2oelf2ch5n5aldz5e)': + '@wagmi/connectors@6.1.0(bgpzjh5q7yrj4ocuf4x4nrqhlm)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) @@ -19371,9 +19376,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(ujys5btvxfmmll34c522tgip2a) + porto: 0.2.19(gvhepirkfl6ucqngccm4za6i6m) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: typescript: 5.8.3 @@ -19409,7 +19414,7 @@ snapshots: - wagmi - zod - '@wagmi/connectors@6.1.0(bsf4qoxz566z5cp5uprqbvc7kq)': + '@wagmi/connectors@6.1.0(g3hyk7kpi5chrxeuitid5ge5f4)': dependencies: '@base-org/account': 1.1.1(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@4.4.3) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.2)(bufferutil@4.0.9)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@4.4.3) @@ -19418,9 +19423,9 @@ snapshots: '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) - '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/ethereum-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.19(hpt6vxfggamvxcrtvr7rawnaru) + porto: 0.2.19(dryu7ql2ha2chpe6amo3r4teni) viem: 2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) optionalDependencies: typescript: 5.8.3 @@ -19535,21 +19540,21 @@ snapshots: dependencies: '@wallet-standard/base': 1.1.0 - '@walletconnect/core@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 events: 3.3.0 lodash.isequal: 4.5.0 @@ -19579,21 +19584,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19623,21 +19628,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19667,21 +19672,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19711,21 +19716,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19755,21 +19760,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19799,21 +19804,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19843,21 +19848,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.33.0 events: 3.3.0 @@ -19887,21 +19892,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/core@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) + '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.39.3 events: 3.3.0 @@ -19931,21 +19936,21 @@ snapshots: - utf-8-validate - zod - '@walletconnect/core@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/core@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/window-getters': 1.0.1 es-toolkit: 1.39.3 events: 3.3.0 @@ -19979,18 +19984,18 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20020,18 +20025,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20061,18 +20066,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/ethereum-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20102,18 +20107,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/ethereum-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/ethereum-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit': 1.7.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/universal-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/universal-provider': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20190,13 +20195,13 @@ snapshots: - bufferutil - utf-8-validate - '@walletconnect/keyvaluestorage@1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/keyvaluestorage@1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/safe-json': 1.0.2 idb-keyval: 6.2.2 unstorage: 1.17.1(idb-keyval@6.2.2)(ioredis@5.11.1) optionalDependencies: - '@react-native-async-storage/async-storage': 1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@react-native-async-storage/async-storage': 1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -20217,13 +20222,13 @@ snapshots: - ioredis - uploadthing - '@walletconnect/keyvaluestorage@1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/keyvaluestorage@1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/safe-json': 1.0.2 idb-keyval: 6.2.2 unstorage: 1.17.1(idb-keyval@6.2.2)(ioredis@5.11.1) optionalDependencies: - '@react-native-async-storage/async-storage': 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@react-native-async-storage/async-storage': 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -20249,15 +20254,15 @@ snapshots: '@walletconnect/safe-json': 1.0.2 pino: 7.11.0 - '@walletconnect/react-native-compat@2.23.0(o2cbduf7egsa2inysox7pg5zyu)': + '@walletconnect/react-native-compat@2.23.0(lh5jzsrjqwxruiai4runjz3fou)': dependencies: - '@react-native-async-storage/async-storage': 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) - '@react-native-community/netinfo': 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@react-native-async-storage/async-storage': 2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + '@react-native-community/netinfo': 11.4.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) events: 3.3.0 fast-text-encoding: 1.0.6 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-get-random-values: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) - react-native-url-polyfill: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native-get-random-values: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) + react-native-url-polyfill: 2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)) '@walletconnect/relay-api@1.0.11': dependencies: @@ -20275,16 +20280,16 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/sign-client@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20311,16 +20316,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20347,16 +20352,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20383,16 +20388,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20419,16 +20424,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20455,16 +20460,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20491,16 +20496,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20527,16 +20532,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20563,16 +20568,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/sign-client@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@walletconnect/core': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/core': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) + '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20599,16 +20604,16 @@ snapshots: - utf-8-validate - zod - '@walletconnect/sign-client@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/sign-client@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@walletconnect/core': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/core': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-utils': 1.0.8 '@walletconnect/logger': 2.1.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20635,13 +20640,13 @@ snapshots: - utf-8-validate - zod - '@walletconnect/solana-adapter@0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/solana-adapter@0.0.8(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: - '@reown/appkit': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@reown/appkit': 1.7.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/universal-provider': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/universal-provider': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) bs58: 6.0.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -20675,12 +20680,12 @@ snapshots: dependencies: tslib: 1.14.1 - '@walletconnect/types@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20704,12 +20709,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20733,12 +20738,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))': + '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20762,12 +20767,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20791,12 +20796,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20820,12 +20825,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))': + '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20849,12 +20854,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20878,12 +20883,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20907,12 +20912,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20936,12 +20941,12 @@ snapshots: - ioredis - uploadthing - '@walletconnect/types@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': + '@walletconnect/types@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/heartbeat': 1.2.2 '@walletconnect/jsonrpc-types': 1.0.4 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 events: 3.3.0 transitivePeerDependencies: @@ -20965,18 +20970,18 @@ snapshots: - ioredis - uploadthing - '@walletconnect/universal-provider@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) events: 3.3.0 lodash: 4.17.21 transitivePeerDependencies: @@ -21005,18 +21010,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21045,18 +21050,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21085,18 +21090,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21125,18 +21130,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21165,18 +21170,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21205,18 +21210,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21245,18 +21250,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) es-toolkit: 1.33.0 events: 3.3.0 transitivePeerDependencies: @@ -21285,18 +21290,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/universal-provider@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) - '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) + '@walletconnect/sign-client': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76) es-toolkit: 1.39.3 events: 3.3.0 transitivePeerDependencies: @@ -21325,18 +21330,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/universal-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/universal-provider@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@walletconnect/events': 1.0.1 '@walletconnect/jsonrpc-http-connection': 1.0.8(encoding@0.1.13) '@walletconnect/jsonrpc-provider': 1.0.14 '@walletconnect/jsonrpc-types': 1.0.4 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/logger': 2.1.2 - '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) - '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/sign-client': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/utils': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3) es-toolkit: 1.39.3 events: 3.3.0 transitivePeerDependencies: @@ -21365,18 +21370,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.19.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 detect-browser: 5.3.0 @@ -21409,18 +21414,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.19.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21454,18 +21459,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21498,18 +21503,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21542,18 +21547,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.0(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21586,18 +21591,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21630,18 +21635,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21674,18 +21679,18 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@walletconnect/utils@2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/ciphers': 1.2.1 '@noble/curves': 1.8.1 '@noble/hashes': 1.7.1 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 bs58: 6.0.0 @@ -21718,7 +21723,7 @@ snapshots: - utf-8-validate - zod - '@walletconnect/utils@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76)': + '@walletconnect/utils@2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1)(typescript@5.8.3)(zod@3.25.76)': dependencies: '@msgpack/msgpack': 3.1.2 '@noble/ciphers': 1.3.0 @@ -21726,12 +21731,12 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.10(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 blakejs: 1.2.1 @@ -21762,7 +21767,7 @@ snapshots: - uploadthing - zod - '@walletconnect/utils@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@walletconnect/utils@2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(ioredis@5.11.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: '@msgpack/msgpack': 3.1.2 '@noble/ciphers': 1.3.0 @@ -21770,12 +21775,12 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@walletconnect/jsonrpc-utils': 1.0.8 - '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/keyvaluestorage': 1.1.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/relay-api': 1.0.11 '@walletconnect/relay-auth': 1.1.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) + '@walletconnect/types': 2.21.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(ioredis@5.11.1) '@walletconnect/window-getters': 1.0.1 '@walletconnect/window-metadata': 1.0.1 blakejs: 1.2.1 @@ -26785,7 +26790,7 @@ snapshots: pony-cause@2.1.11: {} - porto@0.2.19(2xcn7d5aunq6wiuiuf55mtzjz4): + porto@0.2.19(6oek35uj62dxa7liwvqvv47ara): dependencies: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) hono: 4.10.3 @@ -26799,13 +26804,13 @@ snapshots: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) transitivePeerDependencies: - '@types/react' - immer - use-sync-external-store - porto@0.2.19(hpt6vxfggamvxcrtvr7rawnaru): + porto@0.2.19(dryu7ql2ha2chpe6amo3r4teni): dependencies: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) hono: 4.10.3 @@ -26819,13 +26824,13 @@ snapshots: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) + wagmi: 2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) transitivePeerDependencies: - '@types/react' - immer - use-sync-external-store - porto@0.2.19(ujys5btvxfmmll34c522tgip2a): + porto@0.2.19(gvhepirkfl6ucqngccm4za6i6m): dependencies: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) hono: 4.10.3 @@ -26839,7 +26844,7 @@ snapshots: '@tanstack/react-query': 5.90.5(react@19.1.1) react: 19.1.1 typescript: 5.8.3 - wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + wagmi: 2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) transitivePeerDependencies: - '@types/react' - immer @@ -27133,7 +27138,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 - react-i18next@13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): + react-i18next@13.5.0(i18next@23.4.6)(react-dom@19.1.1(react@19.1.1))(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): dependencies: '@babel/runtime': 7.28.4 html-parse-stringify: 3.0.1 @@ -27141,7 +27146,7 @@ snapshots: react: 19.1.1 optionalDependencies: react-dom: 19.1.1(react@19.1.1) - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) react-icons@5.6.0(react@19.1.1): dependencies: @@ -27179,39 +27184,39 @@ snapshots: dependencies: p-defer: 3.0.0 - react-native-get-random-values@2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): + react-native-get-random-values@2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): dependencies: fast-base64-decode: 1.0.0 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-safe-area-context@5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): + react-native-safe-area-context@5.6.1(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): dependencies: react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) - react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): + react-native-svg@15.14.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.1.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) warn-once: 0.1.1 - react-native-url-polyfill@2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): + react-native-url-polyfill@2.0.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): dependencies: - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) whatwg-url-without-unicode: 8.0.0-3 - react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10): + react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10): dependencies: '@jest/create-cache-key-function': 29.7.0 '@react-native/assets-registry': 0.82.0 '@react-native/codegen': 0.82.0(@babel/core@7.28.5) - '@react-native/community-cli-plugin': 0.82.0(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@react-native/community-cli-plugin': 0.82.0(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@react-native/gradle-plugin': 0.82.0 '@react-native/js-polyfills': 0.82.0 '@react-native/normalize-colors': 0.82.0 - '@react-native/virtualized-lists': 0.82.0(@types/react@19.2.2)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) + '@react-native/virtualized-lists': 0.82.0(@types/react@19.2.2)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -27290,10 +27295,10 @@ snapshots: reactotron-core-contract@0.3.2: {} - reactotron-react-native@5.1.18(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): + reactotron-react-native@5.1.18(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)): dependencies: mitt: 3.0.1 - react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) + react-native: 0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10) reactotron-core-client: 2.9.9 readable-stream@2.3.8: @@ -28731,10 +28736,10 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): + wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3): dependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) - '@wagmi/connectors': 6.1.0(bsf4qoxz566z5cp5uprqbvc7kq) + '@wagmi/connectors': 6.1.0(g3hyk7kpi5chrxeuitid5ge5f4) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3)) react: 19.1.1 use-sync-external-store: 1.4.0(react@19.1.1) @@ -28770,10 +28775,10 @@ snapshots: - utf-8-validate - zod - wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): + wagmi@2.18.2(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): dependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) - '@wagmi/connectors': 6.1.0(5wnggatnpg3gomvuzxtzqousqe) + '@wagmi/connectors': 6.1.0(2orsghzlwewxwohejkwolumw4e) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) react: 19.1.1 use-sync-external-store: 1.4.0(react@19.1.1) @@ -28809,10 +28814,10 @@ snapshots: - utf-8-validate - zod - wagmi@2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): + wagmi@2.18.2(@react-native-async-storage/async-storage@2.2.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.90.5)(@tanstack/react-query@5.90.5(react@19.1.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(immer@10.0.2)(ioredis@5.11.1)(react@19.1.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): dependencies: '@tanstack/react-query': 5.90.5(react@19.1.1) - '@wagmi/connectors': 6.1.0(6cluku43u2oelf2ch5n5aldz5e) + '@wagmi/connectors': 6.1.0(bgpzjh5q7yrj4ocuf4x4nrqhlm) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.5)(@types/react@19.2.2)(immer@10.0.2)(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)) react: 19.1.1 use-sync-external-store: 1.4.0(react@19.1.1) diff --git a/shared/package.json b/shared/package.json index 97025088..5f60e506 100644 --- a/shared/package.json +++ b/shared/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "@cryptopets/protocol": "workspace:*", + "@cryptopets/verifier": "workspace:*", "@switchboard-xyz/on-demand": "^3.10.2", "bs58": "^6.0.0", "buffer": "^6.0.3" diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index 5173fc69..c33ef3a0 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -87,5 +87,10 @@ export { type BattleRoomNotification, type UseBattleRoomSocketOptions, } from './useBattleRoomSocket'; +export { + useVerifiedBattleReceipt, + verifiedReceiptQueryKey, + type VerifiedBattleReceipt, +} from './useVerifiedBattleReceipt'; export { usePetError, type PetError } from './usePetError'; export { useTxError, type TxError } from './useTxError'; diff --git a/shared/src/hooks/useVerifiedBattleReceipt.ts b/shared/src/hooks/useVerifiedBattleReceipt.ts new file mode 100644 index 00000000..29c4e113 --- /dev/null +++ b/shared/src/hooks/useVerifiedBattleReceipt.ts @@ -0,0 +1,160 @@ +import { + assertBattleReceipt, + loadRulesetBundle, + receiptFromWire, + simulate, + type BattleReceipt, + type Hex, + type Ruleset, + type SimOutcome, + type WireBattleReceipt, +} from '@cryptopets/protocol'; +import { + checkBeaconSignature, + checkCombatReplay, + checkOperatorSignature, + checkProgression, + checkSeedDerivation, + type CheckResult, +} from '@cryptopets/verifier/checks'; +import { useQuery } from '@tanstack/react-query'; + +import { useApiClient } from '../contexts/ApiClientContext'; + +/** + * Verifies a signed battle receipt in the browser, then replays the fight from it (§H, §J). + * + * The order is the point. Everything animated here comes from *this client's own* + * simulation of the receipt's inputs, not from a log the backend handed over — so what the + * player watches is what the receipt commits to, or nothing is shown at all. The served + * combat-log endpoint is deliberately not fetched for this: the log is regenerable from the + * snapshot, the seed, and the ruleset, and regenerating it removes a trust dependency + * instead of adding a round trip. + * + * The checks are imported from `@cryptopets/verifier`, the same code the standalone CLI + * runs, rather than reimplemented against the same spec. A browser that verified receipts + * its own way would eventually disagree with the public verifier, and §H's whole argument + * is that it cannot. + * + * What is checked, all of it locally: + * + * - **operator signature** — the receipt hashes to what it claims and that hash was signed + * by a key in the published list. + * - **drand beacon** — the BLS signature verifies against the pinned quicknet key. This is + * what makes commit-before-reveal mean anything; every cheaper check passes equally well + * for randomness the operator invented. + * - **seed derivation** — the seed follows from the receipt's own inputs, so a favourable + * one cannot be stapled onto a genuine beacon. + * - **combat replay** — re-running the fight reproduces the winner, rounds, winner HP, and + * the combat-log hash. + * - **progression** — the XP and level change recomputes. + * + * Hash-chain continuity across a *run* of receipts is not checked here: a client holding one + * receipt has nothing to link it against. That is the standalone verifier's job over the + * public corpus, and pretending to do it from a single receipt would be theatre. + */ + +export interface VerifiedBattleReceipt { + receipt: BattleReceipt; + checks: CheckResult[]; + /** True only when every check passed. Anything else means do not animate. */ + verified: boolean; + /** This client's own replay. The animation source, and only present when verified. */ + outcome: SimOutcome | null; +} + +interface SignedArtifactResponse { + hash: string; + signature: string; + signingKeyId: string; + payload: WireBattleReceipt; +} + +interface SigningKeysResponse { + keys: { keyId: string; address: string; notBefore?: number; notAfter?: number | null }[]; +} + +interface RulesetResponse { + rulesetHash: string; + bundle: unknown; +} + +export function verifiedReceiptQueryKey(battleId: string | null | undefined) { + return ['battle', 'verified-receipt', battleId] as const; +} + +export function useVerifiedBattleReceipt(battleId: string | null | undefined) { + const apiClient = useApiClient(); + + return useQuery({ + queryKey: verifiedReceiptQueryKey(battleId), + enabled: Boolean(battleId), + // A signed receipt is immutable once issued, so re-verifying it on a refocus would + // burn a BLS verification to reach the same answer. + staleTime: Infinity, + queryFn: async (): Promise => { + const [{ data: artifact }, { data: keys }] = await Promise.all([ + apiClient.get(`/api/battle/${battleId}/receipt`), + apiClient.get('/api/battle/signing-keys'), + ]); + + const receipt = assertBattleReceipt(receiptFromWire(artifact.payload)); + const envelope = { + receiptHash: artifact.hash, + signature: artifact.signature, + signingKeyId: artifact.signingKeyId, + payload: artifact.payload, + }; + + const ruleset = await fetchRuleset(apiClient, receipt.rulesetHash); + + const checks = [ + checkOperatorSignature(envelope, receipt, keys.keys), + checkBeaconSignature(receipt), + checkSeedDerivation(receipt), + checkCombatReplay(receipt, ruleset), + checkProgression(receipt, ruleset), + ]; + const verified = checks.every((check) => check.ok); + + return { receipt, checks, verified, outcome: verified ? replay(receipt, ruleset) : null }; + }, + }); +} + +/** + * Fetches the bundle the receipt names and confirms it is that bundle. + * + * `loadRulesetBundle` recomputes the hash and throws on a mismatch, so the rules replayed + * against are the ones the receipt committed to, whatever the endpoint chose to serve. + */ +async function fetchRuleset( + apiClient: ReturnType, + rulesetHash: Hex, +): Promise { + const { data } = await apiClient.get(`/api/battle/rulesets/${rulesetHash}`); + return loadRulesetBundle(JSON.stringify(data.bundle), rulesetHash); +} + +/** + * Re-runs the fight the receipt describes. + * + * Identical inputs to `checkCombatReplay`, which has already confirmed this reproduces the + * receipt's result and combat-log hash — so this is the verified fight, not a second opinion + * about it. + */ +function replay(receipt: BattleReceipt, ruleset: Ruleset): SimOutcome { + const { attacker, defender } = receipt.snapshot; + return simulate( + attacker.dna, + attacker.rarity, + attacker.level, + attacker.skill, + defender.dna, + defender.rarity, + defender.level, + defender.skill, + BigInt(receipt.seed), + ruleset.skillConfig, + ); +} diff --git a/shared/tests/hooks/useVerifiedBattleReceipt.test.tsx b/shared/tests/hooks/useVerifiedBattleReceipt.test.tsx new file mode 100644 index 00000000..4ae03449 --- /dev/null +++ b/shared/tests/hooks/useVerifiedBattleReceipt.test.tsx @@ -0,0 +1,342 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + computeProgression, + deriveBattleSeed, + hashBattleReceipt, + hashBattleSnapshot, + hashCombatLog, + hashRuleset, + publishRuleset, + QUICKNET, + roundTime, + simulate, + SOURCE_DEFAULT_RULESET, + type BattleReceipt, + type BattleSnapshot, + type Hex, +} from '@cryptopets/protocol'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { keccak_256 } from '@noble/hashes/sha3'; + +const get = vi.hoisted(() => vi.fn()); +vi.mock('../../src/contexts/ApiClientContext', () => ({ useApiClient: () => ({ get, post: vi.fn() }) })); + +import { useVerifiedBattleReceipt } from '../../src/hooks/useVerifiedBattleReceipt'; + +/** + * Real signatures, a real drand round, and the real combat engine throughout. The point of + * this hook is that it refuses a receipt that does not actually check out, and a test with + * mocked checks would prove only that the mocks were called. + */ + +const BEACON = { + chainHash: QUICKNET.chainHash, + round: 1000, + signature: + '0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39' as Hex, + randomness: '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd' as Hex, +}; +/** Round 21000000's real signature presented as round 1000: only the BLS check catches it. */ +const FORGED_BEACON = { + ...BEACON, + signature: + '0x971cbe88adc436f6411fd26d51887ede7ba144264cd05edec6645b5e170a7702d16082947a85d89c89cb47cd8eb7d817' as Hex, + randomness: '0x36ecd957580ee415f951370e2a5e13273be97de9072418aaf14d38242979e3c1' as Hex, +}; + +const PUBLISHED_AT = roundTime(QUICKNET, BEACON.round); +const DOMAIN = { chainId: 'eip155:84532' as const, deploymentId: 'base-sepolia-live' }; +const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); +const PRIVATE_KEY = `0x${'11'.repeat(32)}`; + +const SNAPSHOT: BattleSnapshot = { + domain: DOMAIN, + attacker: { + petId: 1n, + owner: '0xabcdef0123456789abcdef0123456789abcdef01', + dna: 1234567890123456n, + rarity: 3, + level: 10, + skill: 4, + xp: 120, + lastOpponentId: 0n, + streak: 0, + readyAt: PUBLISHED_AT - 100, + sourceVersion: BigInt(PUBLISHED_AT - 50), + }, + defender: { + petId: 2n, + owner: '0x2222222222222222222222222222222222222222', + dna: 6543210987654321n, + rarity: 2, + level: 11, + skill: 7, + xp: 45, + lastOpponentId: 1n, + streak: 2, + readyAt: PUBLISHED_AT - 100, + sourceVersion: BigInt(PUBLISHED_AT - 50), + }, + takenAt: PUBLISHED_AT - 6, +}; + +function hexToBytes(hex: string): Uint8Array { + const clean = hex.slice(2); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16); + return out; +} +function bytesToHex(bytes: Uint8Array): Hex { + return `0x${Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('')}` as Hex; +} + +const SIGNING_ADDRESS = bytesToHex( + keccak_256(secp256k1.getPublicKey(hexToBytes(PRIVATE_KEY), false).slice(1)).slice(-20), +); + +function signWithTestKey(digest: Hex): Hex { + const sig = secp256k1.sign(hexToBytes(digest), hexToBytes(PRIVATE_KEY)); + const recovered = sig.toBytes('recovered'); // [recovery, r, s] + const out = new Uint8Array(65); + out.set(recovered.slice(1, 65), 0); + out[64] = sig.recovery + 27; + return bytesToHex(out); +} + +function buildReceipt(overrides: Partial = {}, beacon = BEACON): BattleReceipt { + const seed = deriveBattleSeed({ + domain: DOMAIN, + drandRandomness: beacon.randomness, + battleId: 'btl_0001', + snapshotHash: hashBattleSnapshot(SNAPSHOT), + rulesetHash: RULESET_HASH, + }); + const outcome = simulate( + SNAPSHOT.attacker.dna, SNAPSHOT.attacker.rarity, SNAPSHOT.attacker.level, SNAPSHOT.attacker.skill, + SNAPSHOT.defender.dna, SNAPSHOT.defender.rarity, SNAPSHOT.defender.level, SNAPSHOT.defender.skill, + seed.value, SOURCE_DEFAULT_RULESET.skillConfig, + ); + return { + domain: DOMAIN, + battleId: 'btl_0001', + intentHash: `0x${'11'.repeat(32)}`, + commitmentHash: `0x${'22'.repeat(32)}`, + defenseAuthorizationHash: `0x${'33'.repeat(32)}`, + snapshot: SNAPSHOT, + beacon, + seed: seed.hex, + rulesetVersion: SOURCE_DEFAULT_RULESET.version, + rulesetHash: RULESET_HASH, + result: { + attackerWon: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + }, + combatLogHash: hashCombatLog(outcome), + progression: computeProgression(SNAPSHOT, outcome.result.firstWins), + sequence: 1, + previousReceiptHash: null, + attackerPreviousReceiptHash: null, + defenderPreviousReceiptHash: null, + createdAt: PUBLISHED_AT + 1, + signingKeyId: 'battle-signer-2026-07', + ...overrides, + }; +} + +function toWire(receipt: BattleReceipt): unknown { + return JSON.parse(JSON.stringify(receipt, (_k, v) => (typeof v === 'bigint' ? v.toString() : v))); +} + +/** Wires the three endpoints the hook reads. */ +function serve(receipt: BattleReceipt, options: { address?: string } = {}) { + const hash = hashBattleReceipt(receipt); + get.mockImplementation((url: string) => { + if (url.endsWith('/receipt')) { + return Promise.resolve({ + data: { hash, signature: signWithTestKey(hash), signingKeyId: receipt.signingKeyId, payload: toWire(receipt) }, + }); + } + if (url.endsWith('/signing-keys')) { + return Promise.resolve({ + data: { keys: [{ keyId: 'battle-signer-2026-07', address: options.address ?? SIGNING_ADDRESS }] }, + }); + } + if (url.includes('/rulesets/')) { + return Promise.resolve({ + data: { rulesetHash: RULESET_HASH, bundle: JSON.parse(publishRuleset(SOURCE_DEFAULT_RULESET).json) }, + }); + } + return Promise.reject(new Error(`unexpected request: ${url}`)); + }); +} + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return React.createElement(QueryClientProvider, { client }, children); +} + +function renderVerified() { + return renderHook(() => useVerifiedBattleReceipt('btl_0001'), { wrapper }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('an honest receipt', () => { + it('passes every check and yields a replayed outcome to animate', async () => { + serve(buildReceipt()); + const { result } = renderVerified(); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data!.checks.filter((c) => !c.ok)).toEqual([]); + expect(result.current.data!.verified).toBe(true); + expect(result.current.data!.outcome).not.toBeNull(); + }); + + it('animates the client own replay, matching the hash the receipt committed to', async () => { + // The property that makes this safe to show: the log on screen is the one the + // receipt names, because this client produced it and the hashes agree. + serve(buildReceipt()); + const { result } = renderVerified(); + + await waitFor(() => expect(result.current.data?.outcome).toBeDefined()); + const outcome = result.current.data!.outcome!; + expect(hashCombatLog(outcome)).toBe(result.current.data!.receipt.combatLogHash); + expect(outcome.log.length).toBeGreaterThan(0); + }); + + it('never fetches the served combat log, since it can regenerate it', async () => { + serve(buildReceipt()); + const { result } = renderVerified(); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(get.mock.calls.map((c) => c[0]).some((url: string) => url.includes('/combat-log'))).toBe(false); + }); + + it('runs all five checks', async () => { + serve(buildReceipt()); + const { result } = renderVerified(); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data!.checks.map((c) => c.check).sort()).toEqual([ + 'beacon-signature', + 'combat-replay', + 'operator-signature', + 'progression', + 'seed-derivation', + ]); + }); +}); + +describe('refusing to animate what does not check out', () => { + it('rejects a forged beacon, even though everything cheaper about it is consistent', async () => { + serve(buildReceipt({}, FORGED_BEACON)); + const { result } = renderVerified(); + + await waitFor(() => expect(result.current.data).toBeDefined()); + const beacon = result.current.data!.checks.find((c) => c.check === 'beacon-signature'); + expect(beacon?.ok).toBe(false); + expect(result.current.data!.verified).toBe(false); + // Nothing to animate: showing a fight whose randomness cannot be trusted would be + // exactly the claim this design refuses to make. + expect(result.current.data!.outcome).toBeNull(); + }); + + it('rejects a receipt signed by a key nobody published', async () => { + serve(buildReceipt(), { address: '0x1111111111111111111111111111111111111111' }); + const { result } = renderVerified(); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data!.checks.find((c) => c.check === 'operator-signature')?.ok).toBe(false); + expect(result.current.data!.verified).toBe(false); + expect(result.current.data!.outcome).toBeNull(); + }); + + it('rejects a tampered fight result', async () => { + const honest = buildReceipt(); + serve(buildReceipt({ result: { ...honest.result, rounds: honest.result.rounds + 1 } })); + const { result } = renderVerified(); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data!.checks.find((c) => c.check === 'combat-replay')?.ok).toBe(false); + expect(result.current.data!.outcome).toBeNull(); + }); + + it('rejects an inflated progression delta', async () => { + const honest = buildReceipt(); + serve( + buildReceipt({ + progression: { ...honest.progression, attacker: { ...honest.progression.attacker, xp: 9999 } }, + }), + ); + const { result } = renderVerified(); + + await waitFor(() => expect(result.current.data).toBeDefined()); + expect(result.current.data!.checks.find((c) => c.check === 'progression')?.ok).toBe(false); + }); + + it('reports every failure, not just the first', async () => { + const honest = buildReceipt({}, FORGED_BEACON); + serve( + buildReceipt( + { progression: { ...honest.progression, defender: { ...honest.progression.defender, xp: 1 } } }, + FORGED_BEACON, + ), + ); + const { result } = renderVerified(); + + await waitFor(() => expect(result.current.data).toBeDefined()); + const failed = result.current.data!.checks.filter((c) => !c.ok).map((c) => c.check); + expect(failed).toContain('beacon-signature'); + expect(failed).toContain('progression'); + }); +}); + +describe('the ruleset it replays against', () => { + it('refuses a bundle that is not the one the receipt named', async () => { + // Content addressing is the safeguard: a substituted bundle cannot answer to the + // hash the receipt committed to, so replay fails rather than using the wrong rules. + serve(buildReceipt()); + get.mockImplementation((url: string) => { + if (url.includes('/rulesets/')) { + return Promise.resolve({ + data: { bundle: JSON.parse(publishRuleset({ ...SOURCE_DEFAULT_RULESET, version: 99 }).json) }, + }); + } + const receipt = buildReceipt(); + const hash = hashBattleReceipt(receipt); + if (url.endsWith('/receipt')) { + return Promise.resolve({ + data: { hash, signature: signWithTestKey(hash), signingKeyId: receipt.signingKeyId, payload: toWire(receipt) }, + }); + } + return Promise.resolve({ data: { keys: [{ keyId: 'battle-signer-2026-07', address: SIGNING_ADDRESS }] } }); + }); + + const { result } = renderVerified(); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(String(result.current.error)).toMatch(/ruleset hash mismatch/); + }); +}); + +describe('query behaviour', () => { + it('does not run without a battle id', () => { + renderHook(() => useVerifiedBattleReceipt(null), { wrapper }); + expect(get).not.toHaveBeenCalled(); + }); + + it('surfaces a missing receipt as an error rather than a silent pass', async () => { + get.mockRejectedValue(new Error('receipt-not-found')); + const { result } = renderVerified(); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/verifier/README.md b/verifier/README.md index a2761022..1ef3f534 100644 --- a/verifier/README.md +++ b/verifier/README.md @@ -53,6 +53,35 @@ would be a lie, and omitting them silently would read as a clean bill of health. **Not yet covered**: Merkle inclusion proofs, which arrive with the batch registry (Group G). +## Running in a browser + +`@cryptopets/verifier/checks` is a browser-safe subpath exporting the checks alone. Every +module behind it is pure — `@cryptopets/protocol` and type-only imports, no `node:fs`, no +network — while the package root pulls in the loaders and the pinned-artifact reader, which +are Node-only. + +The frontend uses this so the browser runs the *same* verification code the CLI runs +(`shared/src/hooks/useVerifiedBattleReceipt.ts`). A client that reimplemented the checks to +avoid the dependency is how the browser's answer and the CLI's answer start disagreeing, and +§H's argument only holds while they cannot. + +### What it costs a bundle + +Measured with esbuild (minified, `platform: browser`, `target: es2022`), the same way +`protocol/README.md` measured the BLS cost: + +| Entry point | Minified | Minified + gzip | +|---|---|---| +| Combat replay only (`simulate` + `hashCombatLog`) | 16.6 kB | 6.5 kB | +| Replay + operator-signature, seed, and progression checks | 65.5 kB | 24.1 kB | +| Full verification, adding the drand BLS beacon check | 103.0 kB | 38.2 kB | + +So verifying a receipt rather than merely replaying it costs about **32 kB gzipped**, of +which the BLS beacon check is **14 kB**. Against a frontend bundle already over 2 MB +gzipped that is under 2%, and it is the only thing separating "we watched the fight the +receipt commits to" from "we watched what the server sent". Re-measure if `@noble/curves` +is upgraded. + ## Pinned ruleset artifacts `rulesets/.json` holds the published bundles, committed as plain JSON, one file per diff --git a/verifier/package.json b/verifier/package.json index f04940cc..c20f119b 100644 --- a/verifier/package.json +++ b/verifier/package.json @@ -7,7 +7,8 @@ "main": "src/index.ts", "types": "src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./checks": "./src/checks/index.ts" }, "scripts": { "cli": "tsx src/cli.ts", diff --git a/verifier/src/checks/index.ts b/verifier/src/checks/index.ts index c2ce7596..b0b597b2 100644 --- a/verifier/src/checks/index.ts +++ b/verifier/src/checks/index.ts @@ -1,3 +1,15 @@ +/** + * The checks themselves, exported as `@cryptopets/verifier/checks`. + * + * This subpath exists so a browser can run the *same* verification code the CLI runs. Every + * module here is pure — `@cryptopets/protocol` and type-only imports, no `node:fs`, no + * network — while the package root pulls in the loaders and the pinned-artifact reader, + * which are Node-only and would break a browser bundle. + * + * Keep it that way. A client that reimplemented these checks to avoid the dependency is + * exactly how the browser's answer and the CLI's answer start disagreeing, and §H's argument + * only holds while they cannot. + */ export { checkBeaconSignature } from './beaconSignature'; export { checkChainContinuity } from './chainContinuity'; export { checkCombatReplay } from './combatReplay'; From 2c225c83bdc1939d642fabc82ac9cc8803ac2d45 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 15:53:44 -0400 Subject: [PATCH 36/76] feat(backend): shadow-compute settled on-chain battles against the backend engine --- backend/API.md | 1 + backend/env.example | 9 + .../migration.sql | 22 ++ backend/prisma/schema.prisma | 46 ++++ backend/src/config/env.ts | 6 + backend/src/features/battle-shadow/compare.ts | 121 ++++++++++ backend/src/features/battle-shadow/index.ts | 23 ++ backend/src/features/battle-shadow/metrics.ts | 75 ++++++ .../features/battle-shadow/shadow.service.ts | 221 ++++++++++++++++++ backend/src/features/settle-keeper/index.ts | 32 ++- backend/src/features/settle-keeper/keeper.ts | 53 +++++ .../features/battle-shadow/compare.test.ts | 77 ++++++ .../features/battle-shadow/metrics.test.ts | 95 ++++++++ .../battle-shadow/shadow.service.test.ts | 180 ++++++++++++++ 14 files changed, 958 insertions(+), 3 deletions(-) create mode 100644 backend/prisma/migrations/20260726130000_add_battle_shadow_run/migration.sql create mode 100644 backend/src/features/battle-shadow/compare.ts create mode 100644 backend/src/features/battle-shadow/index.ts create mode 100644 backend/src/features/battle-shadow/metrics.ts create mode 100644 backend/src/features/battle-shadow/shadow.service.ts create mode 100644 backend/tests/features/battle-shadow/compare.test.ts create mode 100644 backend/tests/features/battle-shadow/metrics.test.ts create mode 100644 backend/tests/features/battle-shadow/shadow.service.test.ts diff --git a/backend/API.md b/backend/API.md index 9cebe0ed..9bcf1174 100644 --- a/backend/API.md +++ b/backend/API.md @@ -329,6 +329,7 @@ so historical-key durability is not yet backed by persistent storage. | `KEEPER_RPC_URL` / `KEEPER_PRIVATE_KEY` / `KEEPER_CHAIN_ID` / `KEEPER_GAME_LOGIC_ADDRESS` | Required once enabled; keeper logs and no-ops if any are missing rather than crashing the server. | | `KEEPER_BACKFILL_BLOCKS` | How far back to scan on boot for requests never settled (default 5000). | | `KEEPER_MOCK_REVEAL` | Local dev only: keeper also acts as the Entropy provider (`MockEntropy.mockReveal`). Only takes effect when `KEEPER_CHAIN_ID=31337`. | +| `KEEPER_SHADOW_ENABLED` | Shadow mode (§L Phase 2): recompute settled on-chain battles through the backend engine and indexer-go and record whether they matched `BattleResolved`. Observation only. Off by default. | > **Migration prerequisite:** the v2 `pet_roster` / `battle_history` columns ship > in `prisma/schema.prisma`; run `pnpm prisma:migrate` then `pnpm prisma:generate` diff --git a/backend/env.example b/backend/env.example index 3922bc68..72b5b65c 100644 --- a/backend/env.example +++ b/backend/env.example @@ -111,6 +111,15 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # enable against anything else, so this can't accidentally run on a real network. # KEEPER_MOCK_REVEAL=true +# Shadow mode (docs/plan-backend-battle-architecture.md §L Phase 2): recompute every +# settled on-chain battle through the backend engine and indexer-go, and record whether +# they agreed with BattleResolved. Observation only — it settles nothing, blocks nothing, +# and writes to no table the live path reads, so the on-chain flow behaves identically +# whether this is on or off. Needs KEEPER_GAME_CONFIG_ADDRESS (the skill config comes from +# GameConfig) and, for the second opinion, INDEXER_GRPC_ADDR. +# Off by default: it writes a row and makes a gRPC call per battle. +# KEEPER_SHADOW_ENABLED=true + # --- Solana settle keeper (commit_battle settlement) --- # Settles commit_battle requests from this wallet once Switchboard On-Demand reveals # their randomness, so the player only signs the commit transaction (see diff --git a/backend/prisma/migrations/20260726130000_add_battle_shadow_run/migration.sql b/backend/prisma/migrations/20260726130000_add_battle_shadow_run/migration.sql new file mode 100644 index 00000000..d577c7a5 --- /dev/null +++ b/backend/prisma/migrations/20260726130000_add_battle_shadow_run/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "battle_shadow_run" ( + "chain_id" TEXT NOT NULL, + "request_id" TEXT NOT NULL, + "seed" TEXT NOT NULL, + "attacker_pet_id" TEXT NOT NULL, + "defender_pet_id" TEXT NOT NULL, + "inputs" JSONB NOT NULL, + "predicted" JSONB NOT NULL, + "go_verdict" JSONB, + "observed" JSONB, + "mismatches" JSONB, + "status" TEXT NOT NULL DEFAULT 'pending', + "predicted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "observed_at" TIMESTAMP(3), + + CONSTRAINT "battle_shadow_run_pkey" PRIMARY KEY ("chain_id","request_id") +); + +-- CreateIndex +CREATE INDEX "battle_shadow_run_status_predicted_at_idx" ON "battle_shadow_run"("status", "predicted_at"); + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8d3d01a4..c18dda38 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -523,3 +523,49 @@ model BattleOutbox { @@index([topic, processedAt]) @@map("battle_outbox") } + +/// One shadow run: what the backend engine predicted for an on-chain battle, and what +/// the chain actually did (§L Phase 2). +/// +/// Written in two stages, because the inputs and the answer are available at different +/// moments. `GameLogic.settleBattle` deletes its request-time snapshot, so the frozen sim +/// inputs only exist between entropy revealing and the settle transaction landing — the +/// prediction is recorded in that window, and the observation is filled in when +/// `BattleResolved` arrives. +/// +/// Deliberately no relation to `battle_history`: shadow mode observes the on-chain path +/// without participating in it, and a foreign key would make a shadow write able to fail +/// a real settle. +model BattleShadowRun { + chainId String @map("chain_id") + /// Pyth Entropy sequence number, which is GameLogic's battle request id. + requestId String @map("request_id") + + /// Frozen sim inputs and the revealed seed, exactly as predicted from. + seed String + attackerPetId String @map("attacker_pet_id") + defenderPetId String @map("defender_pet_id") + inputs Json + + /// The TypeScript engine's outcome, computed from `inputs` and `seed`. + predicted Json + /// indexer-go's independent recomputation, plus whether it could be reached at all. + /// Null means the verifier was not configured or did not answer; that is recorded as + /// its own status rather than being mistaken for agreement. + goVerdict Json? @map("go_verdict") + + /// The chain's own answer, from the `BattleResolved` event. Null until it lands. + observed Json? + /// Field-level differences, empty when everything matched. + mismatches Json? + + /// 'pending' | 'agreed' | 'mismatch' | 'engine-disagreement' + status String @default("pending") + + predictedAt DateTime @default(now()) @map("predicted_at") + observedAt DateTime? @map("observed_at") + + @@id([chainId, requestId]) + @@index([status, predictedAt]) + @@map("battle_shadow_run") +} diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 22c18559..092f2a03 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -109,6 +109,12 @@ export const env = { mockReveal: process.env.KEEPER_MOCK_REVEAL?.trim().toLowerCase() === 'true' && Number(process.env.KEEPER_CHAIN_ID) === 31337, + /** Shadow mode (docs/plan-backend-battle-architecture.md §L Phase 2): recompute + * every settled on-chain battle through the backend engine and record whether it + * agreed. Observation only — it settles nothing and blocks nothing. Off by + * default because it writes a row and calls indexer-go per battle, and the + * on-chain path has to behave identically whether it is on or not. */ + shadowEnabled: process.env.KEEPER_SHADOW_ENABLED?.trim().toLowerCase() === 'true', }, /** diff --git a/backend/src/features/battle-shadow/compare.ts b/backend/src/features/battle-shadow/compare.ts new file mode 100644 index 00000000..6fea370e --- /dev/null +++ b/backend/src/features/battle-shadow/compare.ts @@ -0,0 +1,121 @@ +/** + * Comparing what the backend engine predicted against what the chain actually did + * (§L Phase 2). + * + * ## What is compared, and what deliberately is not + * + * Only the fight outcome: `firstWins`, `rounds`, `winnerHpRemaining`, and which pet id + * won. Every one of those is a pure function of the request-time snapshot and the revealed + * seed, both of which are captured before `settleBattle` runs, so a disagreement is a real + * engine disagreement and nothing else. That is what makes "zero deterministic mismatch" + * a meaningful stop condition rather than a noise threshold. + * + * `xpWin` and `xpLoss` are excluded on purpose, even though `BattleResolved` carries them. + * They depend on each pet's `lastOpponentId` and `sameOpponentStreak`, which + * `settleBattle` reads *and mutates* through `recordBattleOpponent` at settle time — not + * from the frozen snapshot. Shadow mode observes at reveal, so any other battle settling + * for the same pet in between would move the decay shift and produce a mismatch that means + * nothing about the engine. Including them would trade a clean signal for a noisy one. + * + * The XP formula is not going unchecked as a result: `contracts/test-vectors/xp.json` is + * run against all four ports. What shadow mode adds is confirmation that the *simulator* + * reproduces real chain outcomes on real inputs, which vectors cannot do — so this is the + * gap it is aimed at. + */ + +/** The fight outcome, as any of the three engines states it. */ +export interface FightOutcome { + firstWins: boolean; + rounds: number; + winnerHpRemaining: number; +} + +/** What the chain reported, decoded from `BattleResolved`. */ +export interface ObservedOutcome extends FightOutcome { + winnerPetId: string; + loserPetId: string; +} + +/** What an engine predicted, plus which pet that makes the winner. */ +export interface PredictedOutcome extends FightOutcome { + winnerPetId: string; + loserPetId: string; +} + +export type ShadowStatus = 'pending' | 'agreed' | 'mismatch' | 'engine-disagreement'; + +export interface ComparisonResult { + status: Exclude; + mismatches: string[]; +} + +/** + * Compares the TypeScript prediction, the Go verifier's recomputation, and the chain. + * + * Three distinct outcomes, because they mean different things to whoever reads the log: + * + * - `agreed` — everything that could be checked matched. + * - `mismatch` — the backend engine and the chain disagree. This is the one that blocks + * the phase gate. + * - `engine-disagreement` — the two backend engines disagree with each other. Reported + * separately because it points at the ports having drifted, not at the chain, and the + * fix is a different one. + * + * A Go verdict of `null` is not agreement. It means the check did not run, and is recorded + * as such rather than folded into a pass — the same fail-closed reasoning the verify worker + * uses. + */ +export function compareShadowRun( + predicted: PredictedOutcome, + observed: ObservedOutcome, + goOutcome: FightOutcome | null, +): ComparisonResult { + const mismatches = diffAgainstChain(predicted, observed); + const engineMismatches = goOutcome ? diffEngines(predicted, goOutcome) : []; + + if (mismatches.length > 0) { + return { status: 'mismatch', mismatches: [...mismatches, ...engineMismatches] }; + } + if (engineMismatches.length > 0) { + return { status: 'engine-disagreement', mismatches: engineMismatches }; + } + return { status: 'agreed', mismatches: [] }; +} + +function diffAgainstChain(predicted: PredictedOutcome, observed: ObservedOutcome): string[] { + const mismatches: string[] = []; + if (predicted.firstWins !== observed.firstWins) { + mismatches.push(`firstWins: engine=${predicted.firstWins} chain=${observed.firstWins}`); + } + if (predicted.rounds !== observed.rounds) { + mismatches.push(`rounds: engine=${predicted.rounds} chain=${observed.rounds}`); + } + if (predicted.winnerHpRemaining !== observed.winnerHpRemaining) { + mismatches.push( + `winnerHpRemaining: engine=${predicted.winnerHpRemaining} chain=${observed.winnerHpRemaining}`, + ); + } + // Checked separately from `firstWins` rather than derived from it: the two agreeing is + // what proves the engine and the chain also agree on which pet was in which slot. + if (predicted.winnerPetId !== observed.winnerPetId) { + mismatches.push(`winnerPetId: engine=${predicted.winnerPetId} chain=${observed.winnerPetId}`); + } + if (predicted.loserPetId !== observed.loserPetId) { + mismatches.push(`loserPetId: engine=${predicted.loserPetId} chain=${observed.loserPetId}`); + } + return mismatches; +} + +function diffEngines(predicted: FightOutcome, go: FightOutcome): string[] { + const mismatches: string[] = []; + if (predicted.firstWins !== go.firstWins) { + mismatches.push(`go.firstWins: ts=${predicted.firstWins} go=${go.firstWins}`); + } + if (predicted.rounds !== go.rounds) { + mismatches.push(`go.rounds: ts=${predicted.rounds} go=${go.rounds}`); + } + if (predicted.winnerHpRemaining !== go.winnerHpRemaining) { + mismatches.push(`go.winnerHpRemaining: ts=${predicted.winnerHpRemaining} go=${go.winnerHpRemaining}`); + } + return mismatches; +} diff --git a/backend/src/features/battle-shadow/index.ts b/backend/src/features/battle-shadow/index.ts new file mode 100644 index 00000000..7456b4a1 --- /dev/null +++ b/backend/src/features/battle-shadow/index.ts @@ -0,0 +1,23 @@ +export { + compareShadowRun, + type ComparisonResult, + type FightOutcome, + type ObservedOutcome, + type PredictedOutcome, + type ShadowStatus, +} from './compare'; +export { + recordShadowOutcome, + resetShadowCounters, + shadowCounters, + shadowSummary, + type ShadowCounters, + type ShadowSummary, +} from './metrics'; +export { + observeOnSettle, + predictOnReveal, + type ObserveRequest, + type PredictRequest, + type ShadowInputs, +} from './shadow.service'; diff --git a/backend/src/features/battle-shadow/metrics.ts b/backend/src/features/battle-shadow/metrics.ts new file mode 100644 index 00000000..bcf3defb --- /dev/null +++ b/backend/src/features/battle-shadow/metrics.ts @@ -0,0 +1,75 @@ +import { prisma } from '@config/prisma'; + +/** + * Shadow-mode counters, and the query behind the phase gate. + * + * The in-process counters are for a liveness check on a running instance. They are not the + * stop condition: §L Phase 2's gate is "zero deterministic mismatch over the agreed + * observation window", and a window measured in days outlives any process, so the real + * answer is `shadowSummary`, which reads the durable rows. + */ + +export interface ShadowCounters { + agreed: number; + mismatch: number; + engineDisagreement: number; +} + +const counters: ShadowCounters = { agreed: 0, mismatch: 0, engineDisagreement: 0 }; + +export function recordShadowOutcome(status: string): void { + if (status === 'agreed') counters.agreed++; + else if (status === 'mismatch') counters.mismatch++; + else if (status === 'engine-disagreement') counters.engineDisagreement++; +} + +/** Counters since this process started. */ +export function shadowCounters(): ShadowCounters { + return { ...counters }; +} + +/** Test seam: resets the in-process counters. */ +export function resetShadowCounters(): void { + counters.agreed = 0; + counters.mismatch = 0; + counters.engineDisagreement = 0; +} + +export interface ShadowSummary { + /** Runs predicted but not yet observed. Not a failure: settle may still be in flight. */ + pending: number; + agreed: number; + mismatch: number; + engineDisagreement: number; + /** True only when something was actually observed and none of it disagreed. */ + clean: boolean; +} + +/** + * The durable answer to "has the backend engine ever disagreed with the chain". + * + * `clean` requires at least one observed run, so an empty table cannot be mistaken for a + * passed observation window — which is exactly the misreading that would let the phase gate + * open on no evidence at all. + */ +export async function shadowSummary(since?: Date): Promise { + const where = since ? { predictedAt: { gte: since } } : {}; + const rows = await prisma.battleShadowRun.groupBy({ + by: ['status'], + where, + _count: { status: true }, + }); + + const byStatus = new Map(rows.map((row) => [row.status, row._count.status])); + const summary = { + pending: byStatus.get('pending') ?? 0, + agreed: byStatus.get('agreed') ?? 0, + mismatch: byStatus.get('mismatch') ?? 0, + engineDisagreement: byStatus.get('engine-disagreement') ?? 0, + }; + + return { + ...summary, + clean: summary.agreed > 0 && summary.mismatch === 0 && summary.engineDisagreement === 0, + }; +} diff --git a/backend/src/features/battle-shadow/shadow.service.ts b/backend/src/features/battle-shadow/shadow.service.ts new file mode 100644 index 00000000..e9d85f79 --- /dev/null +++ b/backend/src/features/battle-shadow/shadow.service.ts @@ -0,0 +1,221 @@ +import { simulate, type SkillConfig } from '@cryptopets/protocol'; +import type { Prisma } from '@generated/prisma/client'; + +import { prisma } from '@config/prisma'; +import { callVerifyBattle } from '@grpc-client/verifyBattle'; + +import { + compareShadowRun, + type FightOutcome, + type ObservedOutcome, + type PredictedOutcome, +} from './compare'; +import { recordShadowOutcome } from './metrics'; + +/** + * Shadow mode: recompute every settled on-chain battle through the backend engine and + * compare (§L Phase 2). + * + * On-chain battles keep running exactly as they did. Nothing here settles anything, blocks + * anything, or writes to any table the live path reads — the whole point of a shadow is to + * be removable without consequence. Every function is best-effort: a failure logs and + * returns, because a shadow run that could break a real battle would be worse than no + * shadow at all. + * + * Two stages, forced by the contract's own lifecycle. `settleBattle` deletes its + * request-time snapshot, so the frozen sim inputs only exist between entropy revealing and + * the settle landing. `predictOnReveal` captures them in that window; `observeOnSettle` + * fills in the chain's answer when `BattleResolved` arrives. + */ + +export interface ShadowInputs { + dna1: bigint; + rarity1: number; + level1: number; + skill1: number; + dna2: bigint; + rarity2: number; + level2: number; + skill2: number; +} + +export interface PredictRequest { + chainId: string; + requestId: bigint; + petId1: bigint; + petId2: bigint; + seed: bigint; + inputs: ShadowInputs; + skillConfig: SkillConfig; +} + +/** + * Records what the backend engine expects, before the chain has answered. + * + * Also asks indexer-go for its own recomputation. That call is fail-open here, unlike the + * verify worker's: nothing is being signed, so an unreachable verifier should cost the run + * its second opinion, not the whole observation. + */ +export async function predictOnReveal(request: PredictRequest): Promise { + try { + const outcome = simulate( + request.inputs.dna1, + request.inputs.rarity1, + request.inputs.level1, + request.inputs.skill1, + request.inputs.dna2, + request.inputs.rarity2, + request.inputs.level2, + request.inputs.skill2, + request.seed, + request.skillConfig, + ); + + const predicted: PredictedOutcome = { + firstWins: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + winnerPetId: (outcome.result.firstWins ? request.petId1 : request.petId2).toString(), + loserPetId: (outcome.result.firstWins ? request.petId2 : request.petId1).toString(), + }; + + const goVerdict = await askGoVerifier(request); + + await prisma.battleShadowRun.upsert({ + where: { chainId_requestId: { chainId: request.chainId, requestId: request.requestId.toString() } }, + // A re-reveal for a request already predicted must not overwrite the original + // prediction: the first one is the honest record of what the engine said before + // the chain answered. + update: {}, + create: { + chainId: request.chainId, + requestId: request.requestId.toString(), + seed: `0x${request.seed.toString(16).padStart(64, '0')}`, + attackerPetId: request.petId1.toString(), + defenderPetId: request.petId2.toString(), + inputs: serializeInputs(request.inputs), + predicted: toJson(predicted), + goVerdict: toJson(goVerdict), + status: 'pending', + }, + }); + } catch (error) { + console.error(`[battle-shadow] prediction failed for request ${request.requestId}: ${describe(error)}`); + } +} + +export interface ObserveRequest { + chainId: string; + requestId: bigint; + observed: ObservedOutcome; +} + +/** Fills in the chain's answer and records whether it matched. */ +export async function observeOnSettle(request: ObserveRequest): Promise { + try { + const key = { chainId: request.chainId, requestId: request.requestId.toString() }; + const run = await prisma.battleShadowRun.findUnique({ where: { chainId_requestId: key } }); + if (!run) { + // Settled without a prediction: the reveal happened before shadow mode was on, + // or on another process. Nothing to compare, and inventing a prediction now + // from post-settle state would compare the engine against itself. + return; + } + if (run.observedAt) return; // already compared; a re-emitted log is not new evidence + + const predicted = run.predicted as unknown as PredictedOutcome; + const goOutcome = (run.goVerdict as { outcome?: FightOutcome } | null)?.outcome ?? null; + const { status, mismatches } = compareShadowRun(predicted, request.observed, goOutcome); + + await prisma.battleShadowRun.update({ + where: { chainId_requestId: key }, + data: { + observed: toJson(request.observed), + mismatches, + status, + observedAt: new Date(), + }, + }); + + recordShadowOutcome(status); + if (status !== 'agreed') { + // Loud on purpose: this is the signal the phase gate depends on, and a + // mismatch that only ever appeared in a database row would be missed. + console.error( + `[battle-shadow] ${status} for ${request.chainId} request ${request.requestId}: ${mismatches.join('; ')}`, + ); + } + } catch (error) { + console.error(`[battle-shadow] observation failed for request ${request.requestId}: ${describe(error)}`); + } +} + +/** + * indexer-go's independent recomputation of the same fight. + * + * Progression inputs are sent as zeros: `VerifyBattle` computes progression too, but shadow + * mode does not compare it (see `compare.ts` on why XP is out of scope), and passing state + * this function cannot observe atomically would be inventing inputs rather than reporting + * them. + */ +async function askGoVerifier(request: PredictRequest): Promise<{ status: string; outcome?: FightOutcome; detail?: string }> { + const result = await callVerifyBattle({ + attacker: { + petId: request.petId1.toString(), + dna: request.inputs.dna1.toString(), + rarity: request.inputs.rarity1, + level: request.inputs.level1, + skill: request.inputs.skill1, + xp: 0, + lastOpponentId: '0', + streak: 0, + }, + defender: { + petId: request.petId2.toString(), + dna: request.inputs.dna2.toString(), + rarity: request.inputs.rarity2, + level: request.inputs.level2, + skill: request.inputs.skill2, + xp: 0, + lastOpponentId: '0', + streak: 0, + }, + seed: `0x${request.seed.toString(16).padStart(64, '0')}`, + skillConfig: request.skillConfig, + maxLevel: 0, + }); + + if (!result.ok) { + return { status: result.reason, detail: result.detail }; + } + return { + status: 'ok', + outcome: { + firstWins: result.response.firstWins, + rounds: result.response.rounds, + winnerHpRemaining: result.response.winnerHpRemaining, + }, + }; +} + +/** Prisma's JSON columns want plain objects; a typed interface has no index signature. */ +function toJson(value: T): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +} + +function serializeInputs(inputs: ShadowInputs) { + return { + dna1: inputs.dna1.toString(), + rarity1: inputs.rarity1, + level1: inputs.level1, + skill1: inputs.skill1, + dna2: inputs.dna2.toString(), + rarity2: inputs.rarity2, + level2: inputs.level2, + skill2: inputs.skill2, + }; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message.split('\n')[0]! : String(error); +} diff --git a/backend/src/features/settle-keeper/index.ts b/backend/src/features/settle-keeper/index.ts index 95f854b4..45ca7dbf 100644 --- a/backend/src/features/settle-keeper/index.ts +++ b/backend/src/features/settle-keeper/index.ts @@ -20,8 +20,16 @@ export function startSettleKeeper(): void { return; } - const { rpcUrl, privateKey, chainId, gameLogicAddress, gameConfigAddress, backfillBlocks, mockReveal } = - env.settleKeeper; + const { + rpcUrl, + privateKey, + chainId, + gameLogicAddress, + gameConfigAddress, + backfillBlocks, + mockReveal, + shadowEnabled, + } = env.settleKeeper; if (!rpcUrl || !privateKey || !chainId || !gameLogicAddress) { console.error( '[settle-keeper] KEEPER_ENABLED=true but KEEPER_RPC_URL / KEEPER_PRIVATE_KEY / ' + @@ -36,7 +44,25 @@ export function startSettleKeeper(): void { ); } - startKeeper({ rpcUrl, privateKey, chainId, gameLogicAddress, gameConfigAddress, backfillBlocks, mockReveal }) + if (shadowEnabled && !gameConfigAddress) { + // Shadow mode reads the skill config from GameConfig, so without that address it + // would silently observe nothing. Better to say so than to look enabled. + console.warn( + '[settle-keeper] KEEPER_SHADOW_ENABLED=true but KEEPER_GAME_CONFIG_ADDRESS is not set; ' + + 'shadow mode will not record any predictions', + ); + } + + startKeeper({ + rpcUrl, + privateKey, + chainId, + gameLogicAddress, + gameConfigAddress, + backfillBlocks, + mockReveal, + shadowEnabled, + }) .then((h) => { handle = h; }) .catch((err) => console.error(`[settle-keeper] failed to start: ${(err as Error).message}`)); } diff --git a/backend/src/features/settle-keeper/keeper.ts b/backend/src/features/settle-keeper/keeper.ts index f24e8fee..8c7a4511 100644 --- a/backend/src/features/settle-keeper/keeper.ts +++ b/backend/src/features/settle-keeper/keeper.ts @@ -20,6 +20,7 @@ import { } from './requests'; import { createSubmitter } from './submitter'; import { broadcastLiveBattle } from '@ws/liveBattleSocket'; +import { observeOnSettle, predictOnReveal } from '@features/battle-shadow'; import { simulate, encodeSimOutcome } from '@shared/core/node'; export interface SettleKeeperConfig { @@ -34,6 +35,10 @@ export interface SettleKeeperConfig { * tracked request against MockEntropy so battles/breeds/mints actually * progress without a human calling mockReveal by hand. */ mockReveal: boolean; + /** Shadow mode (§L Phase 2): recompute settled battles and compare, changing nothing. + * Off by default — it writes rows and calls indexer-go per battle, and the on-chain + * path must keep running identically whether it is on or not. */ + shadowEnabled: boolean; } export interface SettleKeeperHandle { @@ -173,6 +178,8 @@ export async function startKeeper(config: SettleKeeperConfig): Promise): Promise { + await observeOnSettle({ + chainId: String(config.chainId), + requestId, + observed: { + firstWins: args.firstWins as boolean, + rounds: Number(args.rounds), + winnerHpRemaining: Number(args.winnerHpRemaining), + winnerPetId: String(args.winnerId), + loserPetId: String(args.loserId), + }, + }); + } + // Backfill: catch up on anything requested-but-not-settled while this keeper (or its // predecessor) was offline, so a restart self-heals instead of losing track. const latestBlock = await publicClient.getBlockNumber(); @@ -269,6 +317,11 @@ export async function startKeeper(config: SettleKeeperConfig): Promise { + it('agrees when the engine, the chain, and Go all match', () => { + expect(compareShadowRun(PREDICTED, OBSERVED, { firstWins: true, rounds: 7, winnerHpRemaining: 42 })).toEqual({ + status: 'agreed', + mismatches: [], + }); + }); + + it('agrees on the chain alone when Go could not be reached', () => { + // A missing second opinion is not a disagreement; the chain comparison still stands. + expect(compareShadowRun(PREDICTED, OBSERVED, null)).toEqual({ status: 'agreed', mismatches: [] }); + }); +}); + +describe('disagreeing with the chain', () => { + it.each([ + ['firstWins', { firstWins: false, winnerPetId: '2', loserPetId: '1' }], + ['rounds', { rounds: 8 }], + ['winnerHpRemaining', { winnerHpRemaining: 41 }], + ])('flags a %s mismatch', (field, patch) => { + const result = compareShadowRun(PREDICTED, { ...OBSERVED, ...patch }, null); + expect(result.status).toBe('mismatch'); + expect(result.mismatches.join(' ')).toContain(field); + }); + + it('checks the winner pet id separately from firstWins', () => { + // Both agreeing is what proves the engine and the chain also agree on which pet sat + // in which slot — a swap would otherwise pass on `firstWins` alone. + const result = compareShadowRun(PREDICTED, { ...OBSERVED, winnerPetId: '99', loserPetId: '98' }, null); + expect(result.status).toBe('mismatch'); + expect(result.mismatches.join(' ')).toContain('winnerPetId'); + expect(result.mismatches.join(' ')).toContain('loserPetId'); + }); + + it('reports every differing field, not just the first', () => { + const result = compareShadowRun(PREDICTED, { ...OBSERVED, rounds: 9, winnerHpRemaining: 1 }, null); + expect(result.mismatches).toHaveLength(2); + }); + + it('reports a Go disagreement alongside a chain mismatch rather than hiding it', () => { + const result = compareShadowRun( + PREDICTED, + { ...OBSERVED, rounds: 9 }, + { firstWins: true, rounds: 11, winnerHpRemaining: 42 }, + ); + expect(result.status).toBe('mismatch'); + expect(result.mismatches.join(' ')).toContain('rounds:'); + expect(result.mismatches.join(' ')).toContain('go.rounds:'); + }); +}); + +describe('the two backend engines disagreeing with each other', () => { + it('is its own status, separate from a chain mismatch', () => { + // Points at the ports having drifted, not at the chain, and the fix is different. + const result = compareShadowRun(PREDICTED, OBSERVED, { firstWins: true, rounds: 8, winnerHpRemaining: 42 }); + expect(result.status).toBe('engine-disagreement'); + expect(result.mismatches.join(' ')).toContain('go.rounds'); + }); + + it('does not fire when Go simply was not consulted', () => { + expect(compareShadowRun(PREDICTED, OBSERVED, null).status).toBe('agreed'); + }); +}); diff --git a/backend/tests/features/battle-shadow/metrics.test.ts b/backend/tests/features/battle-shadow/metrics.test.ts new file mode 100644 index 00000000..3b1d1b4f --- /dev/null +++ b/backend/tests/features/battle-shadow/metrics.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@config/prisma', () => ({ + prisma: { battleShadowRun: { groupBy: vi.fn() } }, +})); + +import { prisma } from '@config/prisma'; +import { recordShadowOutcome, resetShadowCounters, shadowCounters, shadowSummary } from '@features/battle-shadow'; + +function grouped(counts: Record) { + return Object.entries(counts).map(([status, n]) => ({ status, _count: { status: n } })); +} + +beforeEach(() => { + vi.clearAllMocks(); + resetShadowCounters(); +}); + +describe('in-process counters', () => { + it('counts each outcome separately', () => { + recordShadowOutcome('agreed'); + recordShadowOutcome('agreed'); + recordShadowOutcome('mismatch'); + recordShadowOutcome('engine-disagreement'); + + expect(shadowCounters()).toEqual({ agreed: 2, mismatch: 1, engineDisagreement: 1 }); + }); + + it('ignores a status it does not track', () => { + recordShadowOutcome('pending'); + expect(shadowCounters()).toEqual({ agreed: 0, mismatch: 0, engineDisagreement: 0 }); + }); + + it('hands back a copy, so a caller cannot mutate the counters', () => { + recordShadowOutcome('agreed'); + const snapshot = shadowCounters(); + snapshot.agreed = 999; + expect(shadowCounters().agreed).toBe(1); + }); +}); + +describe('the durable summary behind the phase gate', () => { + it('is clean only when something was observed and none of it disagreed', async () => { + vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue(grouped({ agreed: 500, pending: 3 }) as never); + + await expect(shadowSummary()).resolves.toEqual({ + pending: 3, + agreed: 500, + mismatch: 0, + engineDisagreement: 0, + clean: true, + }); + }); + + it('is not clean on an empty table', async () => { + // The misreading that matters: no evidence is not the same as passed evidence, and + // treating it as clean would open the phase gate on nothing at all. + vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue([] as never); + + const summary = await shadowSummary(); + expect(summary.agreed).toBe(0); + expect(summary.clean).toBe(false); + }); + + it('is not clean while only predictions exist', async () => { + vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue(grouped({ pending: 40 }) as never); + await expect(shadowSummary()).resolves.toMatchObject({ pending: 40, clean: false }); + }); + + it('is not clean with a single mismatch among many agreements', async () => { + vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue( + grouped({ agreed: 10_000, mismatch: 1 }) as never, + ); + await expect(shadowSummary()).resolves.toMatchObject({ mismatch: 1, clean: false }); + }); + + it('is not clean when only the two backend engines disagreed', async () => { + // The chain agreed, but the ports drifted; that still blocks the gate. + vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue( + grouped({ agreed: 100, 'engine-disagreement': 2 }) as never, + ); + await expect(shadowSummary()).resolves.toMatchObject({ engineDisagreement: 2, clean: false }); + }); + + it('scopes the window when given a start time', async () => { + vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue(grouped({ agreed: 1 }) as never); + const since = new Date('2026-07-01T00:00:00.000Z'); + + await shadowSummary(since); + + expect(prisma.battleShadowRun.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ where: { predictedAt: { gte: since } } }), + ); + }); +}); diff --git a/backend/tests/features/battle-shadow/shadow.service.test.ts b/backend/tests/features/battle-shadow/shadow.service.test.ts new file mode 100644 index 00000000..1831ac55 --- /dev/null +++ b/backend/tests/features/battle-shadow/shadow.service.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { hashRuleset, SOURCE_DEFAULT_RULESET, simulate } from '@cryptopets/protocol'; + +vi.mock('@config/prisma', () => ({ + prisma: { + battleShadowRun: { upsert: vi.fn(), findUnique: vi.fn(), update: vi.fn(), groupBy: vi.fn() }, + }, +})); +vi.mock('@grpc-client/verifyBattle', () => ({ callVerifyBattle: vi.fn() })); + +import { prisma } from '@config/prisma'; +import { observeOnSettle, predictOnReveal, resetShadowCounters, shadowCounters } from '@features/battle-shadow'; +import { callVerifyBattle } from '@grpc-client/verifyBattle'; + +const INPUTS = { + dna1: 1234567890123456n, + rarity1: 3, + level1: 10, + skill1: 4, + dna2: 6543210987654321n, + rarity2: 2, + level2: 11, + skill2: 7, +}; +const SEED = 0x1234n; + +/** The outcome the real engine produces for these inputs — never a hand-written guess. */ +const EXPECTED = simulate( + INPUTS.dna1, INPUTS.rarity1, INPUTS.level1, INPUTS.skill1, + INPUTS.dna2, INPUTS.rarity2, INPUTS.level2, INPUTS.skill2, + SEED, SOURCE_DEFAULT_RULESET.skillConfig, +); + +const PREDICT_REQUEST = { + chainId: '84532', + requestId: 77n, + petId1: 1n, + petId2: 2n, + seed: SEED, + inputs: INPUTS, + skillConfig: SOURCE_DEFAULT_RULESET.skillConfig, +}; + +function predictedFromEngine() { + return { + firstWins: EXPECTED.result.firstWins, + rounds: EXPECTED.result.rounds, + winnerHpRemaining: EXPECTED.result.winnerHpRemaining, + winnerPetId: EXPECTED.result.firstWins ? '1' : '2', + loserPetId: EXPECTED.result.firstWins ? '2' : '1', + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + resetShadowCounters(); + vi.mocked(callVerifyBattle).mockResolvedValue({ + ok: true, + response: { + firstWins: EXPECTED.result.firstWins, + rounds: EXPECTED.result.rounds, + winnerHpRemaining: EXPECTED.result.winnerHpRemaining, + } as never, + }); +}); + +describe('predictOnReveal', () => { + it('records the real engine outcome for the frozen inputs', async () => { + await predictOnReveal(PREDICT_REQUEST); + + const call = vi.mocked(prisma.battleShadowRun.upsert).mock.calls[0]![0] as { + create: { predicted: unknown; seed: string; status: string }; + }; + expect(call.create.predicted).toEqual(predictedFromEngine()); + expect(call.create.status).toBe('pending'); + expect(call.create.seed).toBe(`0x${SEED.toString(16).padStart(64, '0')}`); + }); + + it('asks indexer-go for an independent recomputation and stores its verdict', async () => { + await predictOnReveal(PREDICT_REQUEST); + + expect(callVerifyBattle).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.battleShadowRun.upsert).mock.calls[0]![0] as { + create: { goVerdict: { status: string; outcome: unknown } }; + }; + expect(call.create.goVerdict.status).toBe('ok'); + expect(call.create.goVerdict.outcome).toMatchObject({ rounds: EXPECTED.result.rounds }); + }); + + it('records why Go was unavailable rather than pretending it agreed', async () => { + // Nothing is being signed here, so an unreachable verifier costs the run its second + // opinion, not the whole observation — but it must not read as agreement. + vi.mocked(callVerifyBattle).mockResolvedValue({ ok: false, reason: 'not-configured', detail: 'no addr' }); + await predictOnReveal(PREDICT_REQUEST); + + const call = vi.mocked(prisma.battleShadowRun.upsert).mock.calls[0]![0] as { + create: { goVerdict: { status: string; outcome?: unknown } }; + }; + expect(call.create.goVerdict.status).toBe('not-configured'); + expect(call.create.goVerdict.outcome).toBeUndefined(); + }); + + it('never overwrites an existing prediction on a repeated reveal', async () => { + // The first prediction is the honest record of what the engine said before the + // chain answered; a second one could be written after the fact. + await predictOnReveal(PREDICT_REQUEST); + const call = vi.mocked(prisma.battleShadowRun.upsert).mock.calls[0]![0] as { update: object }; + expect(call.update).toEqual({}); + }); + + it('swallows a database failure rather than disturbing a real settle', async () => { + vi.mocked(prisma.battleShadowRun.upsert).mockRejectedValue(new Error('db down')); + await expect(predictOnReveal(PREDICT_REQUEST)).resolves.toBeUndefined(); + }); +}); + +describe('observeOnSettle', () => { + const storedRun = { + predicted: predictedFromEngine(), + goVerdict: { status: 'ok', outcome: { ...EXPECTED.result, firstWins: EXPECTED.result.firstWins } }, + observedAt: null, + }; + + beforeEach(() => { + vi.mocked(prisma.battleShadowRun.findUnique).mockResolvedValue(storedRun as never); + }); + + it('marks a matching battle as agreed', async () => { + await observeOnSettle({ chainId: '84532', requestId: 77n, observed: predictedFromEngine() }); + + const call = vi.mocked(prisma.battleShadowRun.update).mock.calls[0]![0] as { + data: { status: string; mismatches: string[] }; + }; + expect(call.data.status).toBe('agreed'); + expect(call.data.mismatches).toEqual([]); + expect(shadowCounters().agreed).toBe(1); + }); + + it('records a mismatch when the chain disagrees', async () => { + await observeOnSettle({ + chainId: '84532', + requestId: 77n, + observed: { ...predictedFromEngine(), rounds: EXPECTED.result.rounds + 1 }, + }); + + const call = vi.mocked(prisma.battleShadowRun.update).mock.calls[0]![0] as { + data: { status: string; mismatches: string[] }; + }; + expect(call.data.status).toBe('mismatch'); + expect(call.data.mismatches.join(' ')).toContain('rounds'); + expect(shadowCounters().mismatch).toBe(1); + }); + + it('does nothing when the reveal was never predicted', async () => { + // Inventing a prediction from post-settle state would compare the engine to itself. + vi.mocked(prisma.battleShadowRun.findUnique).mockResolvedValue(null); + await observeOnSettle({ chainId: '84532', requestId: 77n, observed: predictedFromEngine() }); + expect(prisma.battleShadowRun.update).not.toHaveBeenCalled(); + }); + + it('ignores a re-emitted log for a run already observed', async () => { + vi.mocked(prisma.battleShadowRun.findUnique).mockResolvedValue({ + ...storedRun, + observedAt: new Date(), + } as never); + + await observeOnSettle({ chainId: '84532', requestId: 77n, observed: predictedFromEngine() }); + + expect(prisma.battleShadowRun.update).not.toHaveBeenCalled(); + expect(shadowCounters().agreed).toBe(0); + }); + + it('swallows a database failure rather than throwing into the keeper', async () => { + vi.mocked(prisma.battleShadowRun.findUnique).mockRejectedValue(new Error('db down')); + await expect( + observeOnSettle({ chainId: '84532', requestId: 77n, observed: predictedFromEngine() }), + ).resolves.toBeUndefined(); + }); +}); From 63422c1059bf6e63ca1a8f7e664fce2d2d4d7e3e Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 16:06:49 -0400 Subject: [PATCH 37/76] feat: launch rewardless backend battle mode behind a flag --- backend/API.md | 5 + backend/env.example | 10 + backend/src/config/env.ts | 13 ++ backend/src/features/battle-ledger/index.ts | 2 + backend/src/features/battle-ledger/mode.ts | 32 ++++ backend/src/features/battle-ledger/outbox.ts | 24 +++ .../features/battle-ledger/reads.service.ts | 10 + backend/src/routes/battle.ts | 12 +- backend/src/server.ts | 21 ++- .../battle-ledger/config.service.test.ts | 18 ++ .../features/battle-ledger/drills.test.ts | 149 +++++++++++++++ .../tests/features/battle-ledger/mode.test.ts | 48 +++++ docs/runbook-backend-battles.md | 178 ++++++++++++++++++ shared/src/hooks/index.ts | 1 + shared/src/hooks/useBattleConfig.ts | 2 + shared/src/hooks/useBattleMode.ts | 36 ++++ shared/tests/hooks/useBattleMode.test.tsx | 59 ++++++ 17 files changed, 610 insertions(+), 10 deletions(-) create mode 100644 backend/src/features/battle-ledger/mode.ts create mode 100644 backend/tests/features/battle-ledger/drills.test.ts create mode 100644 backend/tests/features/battle-ledger/mode.test.ts create mode 100644 docs/runbook-backend-battles.md create mode 100644 shared/src/hooks/useBattleMode.ts create mode 100644 shared/tests/hooks/useBattleMode.test.tsx diff --git a/backend/API.md b/backend/API.md index 9bcf1174..62355db3 100644 --- a/backend/API.md +++ b/backend/API.md @@ -249,6 +249,11 @@ to check independently. | Method | Path | Auth | Purpose | | --- | --- | --- | --- | +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. + | 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). | diff --git a/backend/env.example b/backend/env.example index 72b5b65c..9fea2964 100644 --- a/backend/env.example +++ b/backend/env.example @@ -137,6 +137,16 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # KEEPER_SOLANA_POLL_INTERVAL_MS=5000 # --- Backend-authoritative battles (docs/plan-backend-battle-architecture.md) --- +# Backend-authoritative battle mode (docs/plan-backend-battle-architecture.md §L Phase 3, +# operated per docs/runbook-backend-battles.md). Off by default, and a separate switch from +# the on-chain path rather than a replacement: Phase 3 runs both side by side. +# +# Off: the write routes (POST /intents, /accept, /authorizations) return 503, the outbox +# worker does not start, and no signing key is required. Every read route and the public +# receipt corpus keep serving either way — switching the mode off stops new battles, it +# does not retract receipts already issued, which §H requires stay checkable forever. +# BATTLE_BACKEND_MODE_ENABLED=true + # Which chain and deployment this process serves. Every wallet-signed object (battle # intents, defence authorizations) binds both, and the server refuses payloads naming a # different one, so a signature captured from staging is not a valid production signature. diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 092f2a03..f0c587dd 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -149,6 +149,19 @@ export const env = { * sharing staging's identity. */ battle: { + /** + * Backend-authoritative battle mode (§L Phase 3). + * + * Off by default, and deliberately a separate switch from the on-chain path rather + * than a replacement for it: Phase 3 runs both side by side, and the on-chain flow + * has to keep working untouched whether this is on or not. With it off the write + * routes refuse, the worker does not start, and no signer is required — a + * deployment that never turns this on should not need a signing key at all. + * + * "Rewardless" is not a setting. Receipts carry no transferable reward at any + * setting; that arrives with the batch registry in Group I. + */ + enabled: process.env.BATTLE_BACKEND_MODE_ENABLED?.trim().toLowerCase() === 'true', deploymentId: process.env.BATTLE_DEPLOYMENT_ID?.trim() || 'local-dev', /** Comma-separated protocol chain ids, e.g. `eip155:84532,solana:devnet`. */ chainIds: (process.env.BATTLE_CHAIN_IDS?.trim() || 'eip155:31337,solana:localnet') diff --git a/backend/src/features/battle-ledger/index.ts b/backend/src/features/battle-ledger/index.ts index 2206c2d0..c8b28c97 100644 --- a/backend/src/features/battle-ledger/index.ts +++ b/backend/src/features/battle-ledger/index.ts @@ -62,6 +62,7 @@ export { verifyAuthorizationSignature, } from './consent.service'; export { assertServedDomain, servedChainIds, servedDeploymentId, servedDomain } from './domain'; +export { backendBattleModeEnabled, requireBackendBattleMode } from './mode'; export { postBattleIntent } from './intent.controller'; export { type BattleIntentWire, @@ -80,6 +81,7 @@ export { enqueueOutbox, failOutbox, listDeadLetters, + requeueDeadLetter, MAX_OUTBOX_ATTEMPTS, OUTBOX_TOPICS, type OutboxMessage, diff --git a/backend/src/features/battle-ledger/mode.ts b/backend/src/features/battle-ledger/mode.ts new file mode 100644 index 00000000..0d2f507b --- /dev/null +++ b/backend/src/features/battle-ledger/mode.ts @@ -0,0 +1,32 @@ +import type { NextFunction, Request, Response } from 'express'; + +import { env } from '@config/env'; + +/** + * Gates the backend-authoritative battle mode (§L Phase 3). + * + * Only the *write* paths are gated. Reads — battle state, commitments, receipts, combat + * logs, signing keys, rulesets, and the public corpus — stay open regardless, and that + * asymmetry is deliberate: a deployment that runs backend battles for a while and then + * switches the mode off has still issued signed receipts, and §H's claim is that anyone can + * check them. Retracting the evidence along with the feature would turn every past receipt + * into an assertion nobody can verify, which is the exact failure this design exists to + * avoid. Turning the mode off stops new battles; it does not un-publish old ones. + * + * 503 rather than 404: the routes exist and the client did nothing wrong, the server is + * simply not accepting battles. A client can tell the difference and say so. + */ +export function backendBattleModeEnabled(): boolean { + return env.battle.enabled; +} + +export function requireBackendBattleMode(_req: Request, res: Response, next: NextFunction): void { + if (!backendBattleModeEnabled()) { + res.status(503).json({ + error: 'backend-battle-mode-disabled', + detail: 'this deployment is not currently accepting backend-authoritative battles', + }); + return; + } + next(); +} diff --git a/backend/src/features/battle-ledger/outbox.ts b/backend/src/features/battle-ledger/outbox.ts index c9750240..eb3af5df 100644 --- a/backend/src/features/battle-ledger/outbox.ts +++ b/backend/src/features/battle-ledger/outbox.ts @@ -199,3 +199,27 @@ export async function listDeadLetters(limit = 100): Promise { attempts: row.attempts, })); } + +/** + * Puts a dead-lettered message back in the queue, once a human has decided it should run + * again. + * + * The deliberate counterpart to `listDeadLetters`: dead-lettering is not automatic retry + * exhaustion to be undone by a cron, it is a battle parked for a person to look at, and this + * is what that person calls after fixing whatever parked it. `attempts` resets so the + * backoff starts fresh rather than dead-lettering again on the first hiccup. + * + * `lastError` is deliberately left in place. It is the record of why this message died, and + * a requeue is not evidence that the cause is gone — the next genuine failure overwrites it + * anyway. + * + * Returns false when the id is unknown or was never dead-lettered, so a mistyped id during + * an incident reads as "nothing happened" instead of silently succeeding. + */ +export async function requeueDeadLetter(id: string, now: Date): Promise { + const { count } = await prisma.battleOutbox.updateMany({ + where: { id, deadLetteredAt: { not: null } }, + data: { deadLetteredAt: null, attempts: 0, lockedAt: null, lockedBy: null, availableAt: now }, + }); + return count > 0; +} diff --git a/backend/src/features/battle-ledger/reads.service.ts b/backend/src/features/battle-ledger/reads.service.ts index 9976f979..744e5f32 100644 --- a/backend/src/features/battle-ledger/reads.service.ts +++ b/backend/src/features/battle-ledger/reads.service.ts @@ -5,6 +5,7 @@ import { prisma } from '@config/prisma'; import { listSigningKeys } from '@features/battle-signer'; import { servedChainIds, servedDeploymentId } from './domain'; +import { backendBattleModeEnabled } from './mode'; /** * Public, authoritative reads for a battle in flight or settled (§J). @@ -24,6 +25,14 @@ import { servedChainIds, servedDeploymentId } from './domain'; */ export interface BattleConfig { + /** + * Whether this deployment is currently accepting backend-authoritative battles. + * + * False means the write routes refuse (503) while every read still answers, so a client + * should offer the on-chain path instead. Reported here rather than discovered by + * attempting a battle and failing after the wallet prompt. + */ + enabled: boolean; /** The deployment an intent must name, or it is refused as `wrong-deployment`. */ deploymentId: string; /** Chain ids this process serves battles for. */ @@ -49,6 +58,7 @@ export interface BattleConfig { export function getBattleConfig(): BattleConfig { const ruleset = SOURCE_DEFAULT_RULESET; return { + enabled: backendBattleModeEnabled(), deploymentId: servedDeploymentId(), chainIds: servedChainIds(), ruleset: { hash: hashRuleset(ruleset), version: ruleset.version }, diff --git a/backend/src/routes/battle.ts b/backend/src/routes/battle.ts index 73f05a50..47e23ee5 100644 --- a/backend/src/routes/battle.ts +++ b/backend/src/routes/battle.ts @@ -14,6 +14,7 @@ import { postBattleIntent, postDefenseAuthorization, postVerifyReceipt, + requireBackendBattleMode, } from '@features/battle-ledger'; import { verifyToken } from '@middleware/auth'; import { battleRoomRateLimit } from '@middleware/rateLimit'; @@ -23,18 +24,23 @@ const router: Router = express.Router(); // The JWT identifies the caller; the wallet signature inside the body is what authorizes // the battle (§D). Rate limiting runs after verifyToken so the budget is per wallet rather // than per IP, which is what makes it a per-wallet submission limit (threat T5). -router.post('/intents', verifyToken, battleRoomRateLimit, postBattleIntent); +// 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); // 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', verifyToken, battleRoomRateLimit, (req, res) => { +router.post('/intents/:intentHash/accept', requireBackendBattleMode, verifyToken, battleRoomRateLimit, (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', verifyToken, battleRoomRateLimit, postDefenseAuthorization); +router.post('/authorizations', requireBackendBattleMode, verifyToken, battleRoomRateLimit, 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); // Authoritative, re-fetchable reads (§J). No auth: every value here is either already diff --git a/backend/src/server.ts b/backend/src/server.ts index f8135dc6..ac84c9f1 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -38,13 +38,20 @@ const server = app.listen(env.port, '0.0.0.0', () => { // KEEPER_SOLANA_ENABLED is set. startSolanaSettleKeeperFeature(); - // Backend-authoritative battles (docs/plan-backend-battle-architecture.md). Selects the - // signing backend (refuses an in-process key in production; see @features/battle-signer) - // and starts the outbox worker that carries accepted battles from `committed` through - // `computed`. Both are always on: unlike the settle keepers there is no separate enable - // flag yet, since accepting a battle already requires a configured signer to succeed. - configureSigner(Math.floor(Date.now() / 1000)); - battleWorker = startBattleWorker(`backend-${process.pid}`); + // Backend-authoritative battles (docs/plan-backend-battle-architecture.md §L Phase 3). + // Selects the signing backend (refuses an in-process key in production; see + // @features/battle-signer) and starts the outbox worker that carries accepted battles + // through to a signed receipt. + // + // Both are gated on the mode, so a deployment running only the on-chain path needs no + // 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)); + battleWorker = startBattleWorker(`backend-${process.pid}`); + } else { + console.log('[battle] BATTLE_BACKEND_MODE_ENABLED not set; backend battle writes disabled (reads stay served)'); + } }); /** Force-exit deadline: don't let a stuck connection block the orchestrator forever. */ diff --git a/backend/tests/features/battle-ledger/config.service.test.ts b/backend/tests/features/battle-ledger/config.service.test.ts index 8b8e7740..ebe42a2e 100644 --- a/backend/tests/features/battle-ledger/config.service.test.ts +++ b/backend/tests/features/battle-ledger/config.service.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { hashRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; const battleEnv = vi.hoisted(() => ({ + enabled: true, deploymentId: 'base-sepolia-live', chainIds: ['eip155:84532', 'solana:devnet'], })); @@ -21,6 +22,7 @@ vi.mock('@features/battle-signer', () => ({ listSigningKeys: vi.fn() })); import { getBattleConfig } from '@features/battle-ledger'; beforeEach(() => { + battleEnv.enabled = true; battleEnv.deploymentId = 'base-sepolia-live'; battleEnv.chainIds = ['eip155:84532', 'solana:devnet']; }); @@ -55,6 +57,22 @@ describe('getBattleConfig', () => { }); }); + it('reports whether this deployment is accepting backend battles', () => { + // The frontend mode switch reads this. Discovering the answer by submitting an + // intent and getting a 503 would mean finding out after the wallet prompt. + expect(getBattleConfig().enabled).toBe(true); + + battleEnv.enabled = false; + expect(getBattleConfig().enabled).toBe(false); + }); + + it('keeps serving the deployment and ruleset while the mode is off', () => { + // Reads stay open when writes are refused, so a client can still verify receipts + // this deployment issued before the mode was switched off. + battleEnv.enabled = false; + expect(getBattleConfig()).toMatchObject({ deploymentId: 'base-sepolia-live' }); + }); + it('rejects a chain id the protocol does not recognise', () => { // Served config is what clients build signable objects from, so a malformed chain // id must fail here rather than become an unsignable intent. diff --git a/backend/tests/features/battle-ledger/drills.test.ts b/backend/tests/features/battle-ledger/drills.test.ts new file mode 100644 index 00000000..311bcad1 --- /dev/null +++ b/backend/tests/features/battle-ledger/drills.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ethers } from 'ethers'; + +/** + * The §L Phase 3 drills, executed rather than described. + * + * `docs/runbook-backend-battles.md` documents the procedures; this file runs the mechanical + * half of each one on every CI pass, so a drill cannot quietly stop being true between + * incidents. What is deliberately not here is the human half — who is paged, who decides — + * which is what the runbook prose is for. + */ + +vi.mock('@config/env', () => ({ + env: { + nodeEnv: 'test', + battle: { enabled: true }, + battleSigner: {}, + }, +})); + +vi.mock('@config/prisma', () => ({ + prisma: { battleOutbox: { updateMany: vi.fn(), findMany: vi.fn() } }, +})); + +import { env } from '@config/env'; +import { prisma } from '@config/prisma'; +import { listDeadLetters, requeueDeadLetter } from '@features/battle-ledger'; +import { backendBattleModeEnabled } from '@features/battle-ledger'; +import { listSigningKeys, registerRotatedKey, resetSigner } from '@features/battle-signer'; + +const NOW = new Date('2026-07-26T12:00:00.000Z'); + +beforeEach(() => { + vi.clearAllMocks(); + (env as { battle: { enabled: boolean } }).battle.enabled = true; +}); + +describe('drill 1: recovery from dead-lettered work', () => { + it('lists what is parked, with the reason it died', async () => { + vi.mocked(prisma.battleOutbox.findMany).mockResolvedValue([ + { id: 'msg_1', battleId: 'btl_1', topic: 'compute', payload: {}, attempts: 8 }, + ] as never); + + const parked = await listDeadLetters(); + + expect(parked).toEqual([{ id: 'msg_1', battleId: 'btl_1', topic: 'compute', payload: {}, attempts: 8 }]); + expect(prisma.battleOutbox.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { deadLetteredAt: { not: null } } }), + ); + }); + + it('requeues a dead letter with a fresh retry budget', async () => { + vi.mocked(prisma.battleOutbox.updateMany).mockResolvedValue({ count: 1 } as never); + + await expect(requeueDeadLetter('msg_1', NOW)).resolves.toBe(true); + + const call = vi.mocked(prisma.battleOutbox.updateMany).mock.calls[0]![0] as { + where: unknown; + data: Record; + }; + // Attempts reset, or the first hiccup after a fix would dead-letter it again. + expect(call.data.attempts).toBe(0); + expect(call.data.deadLetteredAt).toBeNull(); + expect(call.data.availableAt).toBe(NOW); + // The lock is cleared too: whichever worker died holding it is not coming back. + expect(call.data.lockedAt).toBeNull(); + expect(call.data.lockedBy).toBeNull(); + }); + + it('keeps lastError, because a requeue is not evidence the cause is gone', async () => { + vi.mocked(prisma.battleOutbox.updateMany).mockResolvedValue({ count: 1 } as never); + await requeueDeadLetter('msg_1', NOW); + + const call = vi.mocked(prisma.battleOutbox.updateMany).mock.calls[0]![0] as { data: Record }; + expect('lastError' in call.data).toBe(false); + }); + + it('only requeues messages that actually dead-lettered', async () => { + vi.mocked(prisma.battleOutbox.updateMany).mockResolvedValue({ count: 1 } as never); + await requeueDeadLetter('msg_1', NOW); + + const call = vi.mocked(prisma.battleOutbox.updateMany).mock.calls[0]![0] as { where: Record }; + // Guarded, so requeuing an id that is merely slow cannot reset a live message's + // backoff out from under the worker holding it. + expect(call.where.deadLetteredAt).toEqual({ not: null }); + }); + + it('reports a mistyped id as nothing happened rather than silent success', async () => { + vi.mocked(prisma.battleOutbox.updateMany).mockResolvedValue({ count: 0 } as never); + await expect(requeueDeadLetter('typo', NOW)).resolves.toBe(false); + }); +}); + +describe('drill 3: key rotation', () => { + beforeEach(() => { + resetSigner(); + }); + + function descriptor(keyId: string, notAfter: number | null) { + const wallet = ethers.Wallet.createRandom(); + return { + keyId, + algorithm: 'secp256k1' as const, + publicKey: wallet.signingKey.publicKey as `0x${string}`, + address: wallet.address.toLowerCase() as `0x${string}`, + notBefore: 1_700_000_000, + notAfter, + status: notAfter === null ? ('active' as const) : ('retired' as const), + }; + } + + it('keeps a rotated key published, so receipts it signed still verify', () => { + // The rule the drill exists to protect: delisting a retired key silently + // invalidates every receipt it ever signed. + registerRotatedKey(descriptor('battle-signer-2026-06', 1_760_000_000)); + + const published = listSigningKeys(); + expect(published.map((key) => key.keyId)).toContain('battle-signer-2026-06'); + }); + + it('publishes a retired key with the window it was valid for', () => { + registerRotatedKey(descriptor('battle-signer-2026-06', 1_760_000_000)); + + const retired = listSigningKeys().find((key) => key.keyId === 'battle-signer-2026-06'); + expect(retired?.notAfter).toBe(1_760_000_000); + expect(retired?.status).toBe('retired'); + }); + + it('keeps every rotated key, not just the most recent', () => { + registerRotatedKey(descriptor('battle-signer-2026-05', 1_750_000_000)); + registerRotatedKey(descriptor('battle-signer-2026-06', 1_760_000_000)); + + const ids = listSigningKeys().map((key) => key.keyId); + expect(ids).toContain('battle-signer-2026-05'); + expect(ids).toContain('battle-signer-2026-06'); + }); +}); + +describe('the mode switch', () => { + it('reports enabled when the flag is set', () => { + expect(backendBattleModeEnabled()).toBe(true); + }); + + it('reports disabled when it is not', () => { + (env as { battle: { enabled: boolean } }).battle.enabled = false; + expect(backendBattleModeEnabled()).toBe(false); + }); +}); diff --git a/backend/tests/features/battle-ledger/mode.test.ts b/backend/tests/features/battle-ledger/mode.test.ts new file mode 100644 index 00000000..2ed55b9a --- /dev/null +++ b/backend/tests/features/battle-ledger/mode.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@config/env', () => ({ env: { battle: { enabled: false } } })); + +import { env } from '@config/env'; +import { backendBattleModeEnabled, requireBackendBattleMode } from '@features/battle-ledger'; + +function res() { + const json = vi.fn(); + const status = vi.fn().mockReturnValue({ json }); + return { status, json, res: { status } as never }; +} + +beforeEach(() => { + (env as { battle: { enabled: boolean } }).battle.enabled = false; +}); + +describe('requireBackendBattleMode', () => { + it('refuses with 503 when the mode is off', () => { + const { status, json, res: response } = res(); + const next = vi.fn(); + + requireBackendBattleMode({} as never, response, next); + + // 503, not 404: the route exists and the client did nothing wrong — the server is + // simply not accepting battles, and a client can tell the difference. + expect(status).toHaveBeenCalledWith(503); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: 'backend-battle-mode-disabled' })); + expect(next).not.toHaveBeenCalled(); + }); + + it('passes through when the mode is on', () => { + (env as { battle: { enabled: boolean } }).battle.enabled = true; + const { status, res: response } = res(); + const next = vi.fn(); + + requireBackendBattleMode({} as never, response, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(status).not.toHaveBeenCalled(); + }); + + it('reads the flag per request rather than caching it at import time', () => { + expect(backendBattleModeEnabled()).toBe(false); + (env as { battle: { enabled: boolean } }).battle.enabled = true; + expect(backendBattleModeEnabled()).toBe(true); + }); +}); diff --git a/docs/runbook-backend-battles.md b/docs/runbook-backend-battles.md new file mode 100644 index 00000000..c313d1b4 --- /dev/null +++ b/docs/runbook-backend-battles.md @@ -0,0 +1,178 @@ +# Runbook: backend-authoritative battles + +Operating the backend battle mode described in +[plan-backend-battle-architecture.md](./plan-backend-battle-architecture.md). Covers the +four drills §L Phase 3 requires before the mode carries anything of value: recovery, replay, +key rotation, and incident response. + +Key compromise has its own runbook: +[runbook-signing-key-compromise.md](./runbook-signing-key-compromise.md). This one is for +everything short of that. + +## The drills are tests, not a checklist + +Each drill below is executed by `backend/tests/features/battle-ledger/drills.test.ts`, so it +runs on every CI pass rather than being performed once and slowly becoming untrue. A drill +that only ever lived in this file would describe a system nobody had checked in months. + +What the tests cannot cover is the human half — who gets paged, who decides, how long it +takes. That is what the procedures here are for. + +## Turning the mode on and off + +`BATTLE_BACKEND_MODE_ENABLED=true` enables it. Off by default. + +| Off | On | +| --- | --- | +| `POST /api/battle/intents`, `/accept`, `/authorizations` return **503** | accepted | +| `DELETE /api/battle/authorizations` still works | works | +| every read route and `/api/receipts/*` still works | works | +| the outbox worker does not start | runs | +| no signing key required | required | + +**Switching the mode off does not retract anything.** Receipts already issued stay served, +and the public corpus stays public. That asymmetry is the point: §H's claim is that anyone +can check what we did, and a feature flag that could un-publish past evidence would turn +every issued receipt into an assertion. Turning the mode off stops new battles only. + +Revocation is ungated for the same class of reason — refusing battles is never the +dangerous direction, so a defender must be able to withdraw consent even after the mode is +off. + +### Kill switch + +Set `BATTLE_BACKEND_MODE_ENABLED=false` and restart. Battles already in flight stop +advancing (the worker is gone) and stay in whatever state they reached; they resume when +the mode is turned back on, because state lives in the ledger rather than in the worker. +Pets stay locked in the meantime — see *Stuck battles* below if that window is long. + +## Drill 1: recovery + +**Scenario.** A dependency failed long enough that outbox messages exhausted their retries +and dead-lettered. Battles are parked mid-pipeline. + +Dead-lettering is deliberately not automatic-retry exhaustion to be undone by a cron. It +parks the battle for a person, because a message that failed eight times with exponential +backoff is usually failing for a reason that retrying will not fix. + +**Procedure.** + +1. List what is parked: `listDeadLetters()`. Each entry names the `battleId`, the `topic` + it died on, and `lastError`. +2. Group by `lastError`. One shared cause (drand unreachable, indexer-go down, signer + unconfigured) is the common case and means one fix. +3. Fix the cause. Confirm it is actually fixed before requeuing — a requeue against a still + broken dependency just burns the retry budget again. +4. Requeue each message: `requeueDeadLetter(id, new Date())`. Attempts reset so backoff + starts fresh. `lastError` is deliberately left in place; a requeue is not evidence the + cause is gone. +5. Watch the battles advance. Anything that dead-letters a second time on the same error is + not a transient failure and needs the incident procedure below. + +**What is safe about this.** Requeuing cannot double-apply anything. Every worker is +idempotent on its own transition — each checks the battle's current state and completes the +message as a no-op if another worker already moved it — so a message that actually +succeeded before dying is harmless to run again. + +## Drill 2: replay + +**Scenario.** Confirming that receipts this deployment issued verify independently. Run +routinely, not only during an incident: the value of a receipt is that someone outside can +check it, and a claim nobody has ever tested is not worth much. + +**Procedure.** + +1. Export a corpus: `GET /api/receipts?signingKeyId=` and page through `nextAfter`. +2. Fetch the published keys: `GET /api/battle/signing-keys`. +3. Run the standalone verifier over them, from a checkout with no access to this backend: + ```bash + pnpm --filter @cryptopets/verifier cli -- ./corpus.json --keys ./keys.json + ``` +4. Every check must pass and the exit code must be `0`. + +The verifier holds its own pinned ruleset bundles, so this works with the backend entirely +unreachable — which is the situation the drill is really rehearsing. + +**If it fails.** A failing check is not automatically our fault: confirm the corpus and key +list were fetched completely and that the ruleset the receipts name is one the verifier +holds (`ruleset-unavailable` means it is not). A genuine `combat-replay`, +`beacon-signature`, or `operator-signature` failure is an incident — go to Drill 4. + +## Drill 3: key rotation + +**Scenario.** Routine rotation, or a key approaching the end of its validity window. For a +*compromised* key, stop and use +[runbook-signing-key-compromise.md](./runbook-signing-key-compromise.md) instead. + +**Procedure.** + +1. Provision the new key in the KMS. It never leaves the KMS; the backend holds a reference. +2. Register the outgoing key as rotated: `registerRotatedKey(descriptor)` with `notAfter` + set. It stays published from `GET /api/battle/signing-keys` permanently. +3. Point the signer at the new key and restart. +4. Confirm `GET /api/battle/signing-keys` lists **both**, and that a receipt signed under + the old key still verifies. + +**The rule that matters.** A retired key is never removed from the published list. Receipts +signed under it must keep verifying forever, and delisting a key silently invalidates every +receipt it ever signed — a retroactive erasure of evidence, which is exactly what §G's +validity windows exist to make unnecessary. + +**Known gap.** The key registry is in-memory. A key registered via `registerRotatedKey` does +not survive a process restart, so rotation is not yet durable across deploys and the +registry must be re-seeded at startup. This is a real limitation, not a footnote — it is +flagged in `backend/API.md` too, and it needs closing before the mode carries value. + +## Drill 4: incident + +### Engine mismatch (`verification_failed`) + +The TypeScript engine and the Go verifier disagreed, so the battle was never signed. This is +the circuit breaker doing its job. + +1. **Do not sign it.** There is no override, deliberately: signing something two engines + disagree about is the one action that cannot be walked back. +2. Read `verificationDetail` on the ledger row. It holds both outputs and the field-level + mismatches. +3. Reproduce offline from the receipt's inputs — the snapshot, seed, and ruleset are all in + the row. +4. Whichever port is wrong, fix that port and rerun the golden vectors. **Never edit the + vectors** (`AGENTS.md`). +5. Affected battles stay `verification_failed`. They are not retried into existence; the + honest outcome is that the fight did not resolve. + +### Shadow mismatch + +Shadow mode (§L Phase 2) says the backend engine disagreed with the chain. Same substance as +above, with a stronger signal: the chain is the reference implementation. Blocks the Phase 3 +gate until resolved. `shadowSummary()` is the durable record. + +### Stuck battles + +A battle not advancing is one of: a dead letter (Drill 1), a committed drand round that has +not published (waits, then forfeits — by design), or a worker that is not running (check the +mode flag). + +Both pets stay locked until the battle reaches `signed` or a terminal state. If a battle is +genuinely unresolvable, moving it to a terminal state is what releases them; leaving it +pending indefinitely is worse for the player than a forfeit. + +### Drand outage + +Committed rounds are never substituted — §E allows only "keep waiting" or "give up". An +outage past `BATTLE_FORFEIT_AFTER_SECONDS` forfeits affected battles, with no progression +change. If an outage is ongoing, turn the mode off rather than let battles accumulate +toward mass forfeiture. + +## What this mode deliberately does not do + +- **No transferable reward.** Receipts carry no `rewardDelta` at any setting. Rewards arrive + with the Merkle batch registry in Group I, behind their own caps and review. +- **No rating.** There is no rating or matchmaking-score system in this repo yet. §L Phase 3 + lists "off-chain XP, rating, and cooldown"; XP and cooldown exist in `pet_battle_progress`, + stored separately from NFT state. Rating is a game-design decision — what it measures, how + it decays, whether it is public — and is not something to invent as a side effect of + shipping this mode. +- **No NFT mutation.** Backend battles never write pet state on chain. Off-chain progression + lives in `pet_battle_progress`, keyed separately from `pet_roster`, so the two can never be + confused for each other. diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index c33ef3a0..e38ff498 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -70,6 +70,7 @@ export { useBattleTaunts, type GenerateTauntsVars } from './useBattleTaunts'; export { useCreateBattleRoom, type CreateRoomVars } from './useCreateBattleRoom'; // Backend-authoritative battles (docs/plan-backend-battle-architecture.md §D, §E, §J). export { BATTLE_CONFIG_QUERY_KEY, useBattleConfig, type BattleConfig } from './useBattleConfig'; +export { useBattleMode, type BattleMode, type BattleModeState } from './useBattleMode'; export { useSubmitBattleIntent, type AcceptedBattle, diff --git a/shared/src/hooks/useBattleConfig.ts b/shared/src/hooks/useBattleConfig.ts index 93d4268b..fc495b26 100644 --- a/shared/src/hooks/useBattleConfig.ts +++ b/shared/src/hooks/useBattleConfig.ts @@ -17,6 +17,8 @@ import { useApiClient } from '../contexts/ApiClientContext'; */ export interface BattleConfig { + /** False when this deployment is not accepting backend battles; offer the on-chain path. */ + enabled: boolean; deploymentId: string; chainIds: string[]; ruleset: { hash: string; version: number }; diff --git a/shared/src/hooks/useBattleMode.ts b/shared/src/hooks/useBattleMode.ts new file mode 100644 index 00000000..b599c5a7 --- /dev/null +++ b/shared/src/hooks/useBattleMode.ts @@ -0,0 +1,36 @@ +import { useBattleConfig } from './useBattleConfig'; + +/** + * Which battle path this client should use (§L Phase 3). + * + * Phase 3 runs both modes side by side, so this is a question with a real answer rather + * than a migration flag. The server decides — `GET /api/battle/config` reports whether it + * is accepting backend battles — because the client cannot know, and finding out by + * submitting an intent and getting a 503 would mean discovering it after the wallet prompt. + * + * Fails to `onchain`, deliberately and in every uncertain case: while the config is still + * loading, if the request failed, and if the deployment says it is not accepting backend + * battles. The on-chain path always works; guessing the other way would offer a player a + * battle this deployment cannot actually run. + */ + +export type BattleMode = 'backend' | 'onchain'; + +export interface BattleModeState { + mode: BattleMode; + /** True while the answer is still the fallback rather than the server's. */ + isLoading: boolean; + /** True once the server has actually answered, whichever way. */ + isResolved: boolean; +} + +export function useBattleMode(): BattleModeState { + const { data, isLoading, isError } = useBattleConfig(); + + const resolved = !isLoading && !isError && data !== undefined; + return { + mode: resolved && data.enabled ? 'backend' : 'onchain', + isLoading, + isResolved: resolved, + }; +} diff --git a/shared/tests/hooks/useBattleMode.test.tsx b/shared/tests/hooks/useBattleMode.test.tsx new file mode 100644 index 00000000..6f7c8403 --- /dev/null +++ b/shared/tests/hooks/useBattleMode.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const configQuery = vi.hoisted(() => ({ + current: { data: undefined as unknown, isLoading: false, isError: false }, +})); + +vi.mock('../../src/hooks/useBattleConfig', () => ({ useBattleConfig: () => configQuery.current })); + +import { useBattleMode } from '../../src/hooks/useBattleMode'; + +const CONFIG = { + enabled: true, + deploymentId: 'base-sepolia-live', + chainIds: ['eip155:84532'], + ruleset: { hash: '0xabc', version: 1 }, +}; + +beforeEach(() => { + configQuery.current = { data: CONFIG, isLoading: false, isError: false }; +}); + +describe('useBattleMode', () => { + it('uses the backend path when the deployment accepts backend battles', () => { + const { result } = renderHook(() => useBattleMode()); + expect(result.current).toEqual({ mode: 'backend', isLoading: false, isResolved: true }); + }); + + it('uses the on-chain path when the deployment says it is not accepting them', () => { + configQuery.current = { data: { ...CONFIG, enabled: false }, isLoading: false, isError: false }; + const { result } = renderHook(() => useBattleMode()); + expect(result.current.mode).toBe('onchain'); + expect(result.current.isResolved).toBe(true); + }); + + it('falls back to on-chain while the answer is still loading', () => { + // The on-chain path always works. Guessing the other way would offer a player a + // battle this deployment cannot actually run. + configQuery.current = { data: undefined, isLoading: true, isError: false }; + const { result } = renderHook(() => useBattleMode()); + expect(result.current).toEqual({ mode: 'onchain', isLoading: true, isResolved: false }); + }); + + it('falls back to on-chain when the config request failed', () => { + configQuery.current = { data: undefined, isLoading: false, isError: true }; + const { result } = renderHook(() => useBattleMode()); + expect(result.current.mode).toBe('onchain'); + expect(result.current.isResolved).toBe(false); + }); + + it('does not report resolved on a stale cache alongside an error', () => { + // An error with data still in cache must not read as a confident answer. + configQuery.current = { data: CONFIG, isLoading: false, isError: true }; + const { result } = renderHook(() => useBattleMode()); + expect(result.current.mode).toBe('onchain'); + expect(result.current.isResolved).toBe(false); + }); +}); From 7f1f4ea1af706c6120d56b614d9dd7a93a040387 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 16:22:23 -0400 Subject: [PATCH 38/76] feat(contracts): add battle batch root registry --- .../ethereum/src/BattleBatchRegistry.sol | 168 +++++++++++ .../ethereum/test/BattleBatchRegistry.test.ts | 261 ++++++++++++++++++ 2 files changed, 429 insertions(+) create mode 100644 contracts/ethereum/src/BattleBatchRegistry.sol create mode 100644 contracts/ethereum/test/BattleBatchRegistry.test.ts diff --git a/contracts/ethereum/src/BattleBatchRegistry.sol b/contracts/ethereum/src/BattleBatchRegistry.sol new file mode 100644 index 00000000..33faf86d --- /dev/null +++ b/contracts/ethereum/src/BattleBatchRegistry.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; + +/** + * @title BattleBatchRegistry + * @notice Immutable publication record for batches of backend-resolved battle receipts. + * @dev docs/plan-backend-battle-architecture.md §I. Deliberately minimal: this contract + * stores roots and nothing else. It does not verify proofs, hold funds, or know what + * a reward is — the claim path is a separate contract, so the thing every player's + * history is anchored against stays small enough to audit in one sitting. + * + * **What anchoring does and does not prove.** Publishing a root here makes the batch + * immutable and ordered: once written, we cannot change what a batch contained or + * insert one after the fact. It does *not* prove the receipts inside were computed + * honestly — that is public replay's job (§H) — and it does not force us to include + * any particular receipt. A signed receipt that never appears in a batch is evidence + * of operator failure, not a claim this contract can settle. + * + * Not behind a proxy, on purpose. An upgradeable registry would defeat the point: the + * operator could rewrite history by upgrading the thing that records it. Migrating + * means deploying a new registry and starting a new chain of batches, which is + * visible to everyone rather than silent. + */ +contract BattleBatchRegistry is Ownable, Pausable { + /// @notice One published batch. Mirrors §I's commitment field list exactly. + struct Batch { + bytes32 previousRoot; + bytes32 merkleRoot; + /// @dev Hash over the set of ruleset hashes the batched receipts used, so a batch + /// names the rules its contents were fought under. + bytes32 rulesetSetHash; + uint64 firstSequence; + uint64 lastSequence; + /// @dev Block timestamp at publication. The operator's own `createdAt` travels in + /// the receipts; this is when the chain saw it, which is the one nobody can + /// backdate. + uint64 publishedAt; + } + + /// @notice Wallets permitted to publish. Held by a multisig/timelock in production. + mapping(address => bool) public isPublisher; + + /// @notice batchNumber => published batch. Batch numbers start at 1. + mapping(uint64 => Batch) private _batches; + + /// @notice Highest batch number published so far. 0 before the first batch. + uint64 public latestBatchNumber; + + /// @notice Merkle root of the most recent batch, which the next batch must name. + bytes32 public latestRoot; + + event PublisherSet(address indexed publisher, bool allowed); + event BatchPublished( + uint64 indexed batchNumber, + bytes32 indexed merkleRoot, + bytes32 previousRoot, + bytes32 rulesetSetHash, + uint64 firstSequence, + uint64 lastSequence + ); + + error NotPublisher(); + error WrongBatchNumber(uint64 expected, uint64 given); + error WrongPreviousRoot(bytes32 expected, bytes32 given); + error EmptyRoot(); + error BadSequenceRange(); + error SequenceNotContiguous(uint64 expectedFirst, uint64 given); + + modifier onlyPublisher() { + if (!isPublisher[msg.sender]) revert NotPublisher(); + _; + } + + constructor(address initialOwner) Ownable(initialOwner) {} + + /// @notice Grants or revokes publishing rights. + /// @dev Owner-only, and the owner is expected to be a multisig behind a timelock (§I). + /// Rotating a compromised publisher must not require touching anything else. + function setPublisher(address publisher, bool allowed) external onlyOwner { + isPublisher[publisher] = allowed; + emit PublisherSet(publisher, allowed); + } + + /// @notice Emergency stop. Publication resumes exactly where it left off. + /// @dev Pausing does not invalidate anything already published; it only stops new + /// batches, which is the correct response to a suspected signer compromise. + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + /** + * @notice Publishes the next batch. + * @dev Every argument is checked against on-chain state rather than trusted, because + * the ordering guarantee is the only thing this contract actually provides: + * + * - `batchNumber` must be exactly the next one, so a batch cannot be skipped or + * republished. + * - `previousRoot` must be the current head, so the chain of batches is + * append-only and a fork is impossible rather than merely detectable. + * - `firstSequence` must continue from the previous batch's `lastSequence`, so a + * run of receipts cannot be silently dropped between batches. This is the check + * that turns "we published some receipts" into "we published all of them, in + * order, or the transaction reverted". + * + * A gap is still possible *within* the operator's own numbering — nothing here can + * force a receipt to be assigned a sequence at all. That gap is what the inclusion + * SLO and its alert exist for, and it is deliberately visible rather than papered + * over here. + */ + function publishBatch( + uint64 batchNumber, + bytes32 previousRoot, + bytes32 merkleRoot, + bytes32 rulesetSetHash, + uint64 firstSequence, + uint64 lastSequence + ) external onlyPublisher whenNotPaused { + uint64 expectedNumber = latestBatchNumber + 1; + if (batchNumber != expectedNumber) revert WrongBatchNumber(expectedNumber, batchNumber); + if (previousRoot != latestRoot) revert WrongPreviousRoot(latestRoot, previousRoot); + if (merkleRoot == bytes32(0)) revert EmptyRoot(); + if (lastSequence < firstSequence) revert BadSequenceRange(); + + if (latestBatchNumber != 0) { + uint64 expectedFirst = _batches[latestBatchNumber].lastSequence + 1; + if (firstSequence != expectedFirst) revert SequenceNotContiguous(expectedFirst, firstSequence); + } + + _batches[batchNumber] = Batch({ + previousRoot: previousRoot, + merkleRoot: merkleRoot, + rulesetSetHash: rulesetSetHash, + firstSequence: firstSequence, + lastSequence: lastSequence, + publishedAt: uint64(block.timestamp) + }); + latestBatchNumber = batchNumber; + latestRoot = merkleRoot; + + emit BatchPublished( + batchNumber, + merkleRoot, + previousRoot, + rulesetSetHash, + firstSequence, + lastSequence + ); + } + + /// @notice Reads one published batch. Zeroed struct for a batch number never published. + function getBatch(uint64 batchNumber) external view returns (Batch memory) { + return _batches[batchNumber]; + } + + /// @notice Whether a root was ever published, for a claim contract to check cheaply. + /// @dev Returns false for the zero root, which `publishBatch` refuses, so an + /// uninitialised lookup can never read as an accepted root. + function isPublishedRoot(uint64 batchNumber, bytes32 merkleRoot) external view returns (bool) { + return merkleRoot != bytes32(0) && _batches[batchNumber].merkleRoot == merkleRoot; + } +} diff --git a/contracts/ethereum/test/BattleBatchRegistry.test.ts b/contracts/ethereum/test/BattleBatchRegistry.test.ts new file mode 100644 index 00000000..b843ca67 --- /dev/null +++ b/contracts/ethereum/test/BattleBatchRegistry.test.ts @@ -0,0 +1,261 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { network } from "hardhat"; +import { parseEventLogs, toFunctionSelector } from "viem"; + +/** + * Asserts a call reverted with a specific custom error. + * + * Matches the error *name* or its 4-byte selector, because viem decodes custom errors on + * some paths and reports a bare `unrecognized custom error (return data: 0x...)` on others + * — notably when the revert surfaces during gas estimation. Matching only the name would + * make this test depend on which path viem happened to take rather than on what the + * contract did. The selector is derived from the signature, so renaming an error still + * fails the test rather than silently matching nothing. + */ +async function rejectsWithError(promise: Promise, signature: string): Promise { + const name = signature.slice(0, signature.indexOf("(")); + // Custom-error selectors are the first 4 bytes of keccak(signature), the same rule + // functions use — so the bare signature, with no `error ` prefix to hash along with it. + const selector = toFunctionSelector(signature); + await assert.rejects(promise, (error: unknown) => { + const text = String(error); + assert.ok( + text.includes(name) || text.includes(selector), + `expected a revert with ${signature} (${selector}), got:\n${text}`, + ); + return true; + }); +} + +/** + * BattleBatchRegistry (docs/plan-backend-battle-architecture.md §I). + * + * The contract's only real guarantee is ordering: batches are append-only, linked, and + * sequence-contiguous. Most of what follows is aimed at that, because a registry that + * accepted a batch out of order, or let one be republished, would let the operator rewrite + * the history it exists to fix. + */ +describe("BattleBatchRegistry", async function () { + const { viem } = await network.connect(); + + const ROOT_1 = `0x${"11".repeat(32)}` as const; + const ROOT_2 = `0x${"22".repeat(32)}` as const; + const ROOT_3 = `0x${"33".repeat(32)}` as const; + const RULESET_SET = `0x${"aa".repeat(32)}` as const; + const ZERO = `0x${"00".repeat(32)}` as const; + + async function deploy() { + const [owner, publisher, stranger] = await viem.getWalletClients(); + const registry = await viem.deployContract("BattleBatchRegistry", [owner.account.address]); + await registry.write.setPublisher([publisher.account.address, true]); + return { registry, owner, publisher, stranger }; + } + + /** Publishes batch n, continuing from wherever the registry currently is. */ + async function publish( + registry: Awaited>["registry"], + publisher: Awaited>["publisher"], + batchNumber: bigint, + previousRoot: `0x${string}`, + merkleRoot: `0x${string}`, + firstSequence: bigint, + lastSequence: bigint, + ) { + return registry.write.publishBatch( + [batchNumber, previousRoot, merkleRoot, RULESET_SET, firstSequence, lastSequence], + { account: publisher.account }, + ); + } + + describe("publishing", () => { + it("accepts the first batch and records every committed field", async () => { + const { registry, publisher } = await deploy(); + + await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + + const batch = await registry.read.getBatch([1n]); + assert.equal(batch.previousRoot, ZERO); + assert.equal(batch.merkleRoot, ROOT_1); + assert.equal(batch.rulesetSetHash, RULESET_SET); + assert.equal(batch.firstSequence, 1n); + assert.equal(batch.lastSequence, 100n); + assert.ok(batch.publishedAt > 0n); + }); + + it("advances the head so the next batch knows what to link to", async () => { + const { registry, publisher } = await deploy(); + + await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + + assert.equal(await registry.read.latestBatchNumber(), 1n); + assert.equal(await registry.read.latestRoot(), ROOT_1); + }); + + it("chains batches together", async () => { + const { registry, publisher } = await deploy(); + + await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + await publish(registry, publisher, 2n, ROOT_1, ROOT_2, 101n, 200n); + await publish(registry, publisher, 3n, ROOT_2, ROOT_3, 201n, 300n); + + assert.equal(await registry.read.latestBatchNumber(), 3n); + assert.equal((await registry.read.getBatch([3n])).previousRoot, ROOT_2); + }); + + it("emits the batch for anyone watching the chain", async () => { + const { registry, publisher } = await deploy(); + const hash = await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + + const client = await viem.getPublicClient(); + const receipt = await client.waitForTransactionReceipt({ hash }); + const logs = parseEventLogs({ abi: registry.abi, eventName: "BatchPublished", logs: receipt.logs }); + + assert.equal(logs.length, 1); + assert.equal(logs[0]!.args.batchNumber, 1n); + assert.equal(logs[0]!.args.merkleRoot, ROOT_1); + assert.equal(logs[0]!.args.lastSequence, 100n); + }); + }); + + describe("ordering, which is the only thing this contract really guarantees", () => { + it("refuses a skipped batch number", async () => { + const { registry, publisher } = await deploy(); + await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + + await rejectsWithError(publish(registry, publisher, 3n, ROOT_1, ROOT_2, 101n, 200n), "WrongBatchNumber(uint64,uint64)"); + }); + + it("refuses republishing a batch number already used", async () => { + // Otherwise the operator could overwrite what a batch contained after the fact. + const { registry, publisher } = await deploy(); + await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + + await rejectsWithError(publish(registry, publisher, 1n, ZERO, ROOT_2, 1n, 100n), "WrongBatchNumber(uint64,uint64)"); + }); + + it("refuses a batch that does not link to the current head", async () => { + // This is what makes the chain append-only rather than merely fork-detectable. + const { registry, publisher } = await deploy(); + await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + + await rejectsWithError(publish(registry, publisher, 2n, ROOT_3, ROOT_2, 101n, 200n), "WrongPreviousRoot(bytes32,bytes32)"); + }); + + it("refuses a sequence gap between batches", async () => { + // The check that turns "we published some receipts" into "we published all of + // them, in order, or the transaction reverted". + const { registry, publisher } = await deploy(); + await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + + await rejectsWithError(publish(registry, publisher, 2n, ROOT_1, ROOT_2, 102n, 200n), "SequenceNotContiguous(uint64,uint64)"); + }); + + it("refuses a sequence range that overlaps the previous batch", async () => { + const { registry, publisher } = await deploy(); + await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + + await rejectsWithError(publish(registry, publisher, 2n, ROOT_1, ROOT_2, 100n, 200n), "SequenceNotContiguous(uint64,uint64)"); + }); + + it("refuses an inverted sequence range", async () => { + const { registry, publisher } = await deploy(); + await rejectsWithError(publish(registry, publisher, 1n, ZERO, ROOT_1, 100n, 1n), "BadSequenceRange()"); + }); + + it("accepts a single-receipt batch", async () => { + const { registry, publisher } = await deploy(); + await publish(registry, publisher, 1n, ZERO, ROOT_1, 7n, 7n); + assert.equal((await registry.read.getBatch([1n])).firstSequence, 7n); + }); + + it("refuses an empty root", async () => { + // An unpublished batch reads as the zero root, so accepting one would make + // "never published" and "published nothing" indistinguishable. + const { registry, publisher } = await deploy(); + await rejectsWithError(publish(registry, publisher, 1n, ZERO, ZERO, 1n, 100n), "EmptyRoot()"); + }); + }); + + describe("who may publish", () => { + it("refuses a wallet that was never granted the role", async () => { + const { registry, stranger } = await deploy(); + await rejectsWithError( + registry.write.publishBatch([1n, ZERO, ROOT_1, RULESET_SET, 1n, 100n], { + account: stranger.account, + }), + "NotPublisher()", + ); + }); + + it("lets the owner revoke a publisher, so a compromised key can be rotated out", async () => { + const { registry, publisher } = await deploy(); + await registry.write.setPublisher([publisher.account.address, false]); + + await rejectsWithError(publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n), "NotPublisher()"); + }); + + it("does not let a publisher grant the role to anyone else", async () => { + const { registry, publisher, stranger } = await deploy(); + await rejectsWithError( + registry.write.setPublisher([stranger.account.address, true], { account: publisher.account }), + "OwnableUnauthorizedAccount(address)", + ); + }); + }); + + describe("emergency pause", () => { + it("stops new batches while paused", async () => { + const { registry, publisher } = await deploy(); + await registry.write.pause(); + + await rejectsWithError(publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n), "EnforcedPause()"); + }); + + it("resumes exactly where it left off, invalidating nothing", async () => { + // Pausing is the right response to a suspected signer compromise, and it must + // not cost the batches already published. + const { registry, publisher } = await deploy(); + await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + + await registry.write.pause(); + await registry.write.unpause(); + await publish(registry, publisher, 2n, ROOT_1, ROOT_2, 101n, 200n); + + assert.equal(await registry.read.latestBatchNumber(), 2n); + assert.equal((await registry.read.getBatch([1n])).merkleRoot, ROOT_1); + }); + + it("only the owner may pause", async () => { + const { registry, publisher } = await deploy(); + await rejectsWithError( + registry.write.pause({ account: publisher.account }), + "OwnableUnauthorizedAccount(address)", + ); + }); + }); + + describe("isPublishedRoot", () => { + it("confirms a root that was published", async () => { + const { registry, publisher } = await deploy(); + await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + + assert.equal(await registry.read.isPublishedRoot([1n, ROOT_1]), true); + }); + + it("rejects a root under the wrong batch number", async () => { + const { registry, publisher } = await deploy(); + await publish(registry, publisher, 1n, ZERO, ROOT_1, 1n, 100n); + + assert.equal(await registry.read.isPublishedRoot([2n, ROOT_1]), false); + }); + + it("never reads an unpublished batch as an accepted root", async () => { + // A claim contract asking about a batch that does not exist must get a no, + // not an accidental yes from a zeroed slot. + const { registry } = await deploy(); + assert.equal(await registry.read.isPublishedRoot([99n, ZERO]), false); + }); + }); +}); From 88e86b8644232a65c88508ca9b985b532ab9d866 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 16:34:39 -0400 Subject: [PATCH 39/76] feat(backend): aggregate signed receipts into anchored Merkle batches --- backend/API.md | 1 + backend/env.example | 8 + backend/src/config/env.ts | 12 ++ .../features/battle-batcher/batch.builder.ts | 95 ++++++++ .../battle-batcher/batcher.controller.ts | 28 +++ .../battle-batcher/batcher.service.ts | 204 ++++++++++++++++++ backend/src/features/battle-batcher/index.ts | 9 + backend/src/features/battle-worker/index.ts | 1 + .../features/battle-worker/publish.worker.ts | 69 ++++++ backend/src/features/battle-worker/runner.ts | 2 + backend/src/routes/receipts.ts | 4 + .../battle-batcher/batch.builder.test.ts | 141 ++++++++++++ .../battle-batcher/batcher.service.test.ts | 204 ++++++++++++++++++ .../battle-worker/publish.worker.test.ts | 196 +++++++++++++++++ .../features/battle-worker/runner.test.ts | 20 +- 15 files changed, 991 insertions(+), 3 deletions(-) create mode 100644 backend/src/features/battle-batcher/batch.builder.ts create mode 100644 backend/src/features/battle-batcher/batcher.controller.ts create mode 100644 backend/src/features/battle-batcher/batcher.service.ts create mode 100644 backend/src/features/battle-batcher/index.ts create mode 100644 backend/src/features/battle-worker/publish.worker.ts create mode 100644 backend/tests/features/battle-batcher/batch.builder.test.ts create mode 100644 backend/tests/features/battle-batcher/batcher.service.test.ts create mode 100644 backend/tests/features/battle-worker/publish.worker.test.ts diff --git a/backend/API.md b/backend/API.md index 62355db3..97053fa7 100644 --- a/backend/API.md +++ b/backend/API.md @@ -311,6 +311,7 @@ empty extra request. | GET | `/api/receipts/by-pet/:chainId/:petId?cursor=&limit=` | Every receipt naming this pet as attacker or defender, oldest first. This is the export a per-pet chain walk (§G) starts from — proving a pet was really level 12 means replaying the receipts that got it there. | | GET | `/api/receipts/by-wallet/:wallet?cursor=&limit=` | Every receipt where this wallet owned either side, oldest first. Matched case-insensitively against the ledger's owner columns (the receipt table itself has no owner field, only pet ids). | | GET | `/api/receipts?signingKeyId=&after=&limit=` | Receipts under one signing key, strictly in `sequence` order — the order the *global* hash chain requires. `signingKeyId` is required; this is the endpoint for walking one key's whole chain end to end, not for a general receipt search. | +| GET | `/api/receipts/:receiptHash/inclusion-proof` | The Merkle proof that this receipt is in its anchored batch (§I). Returns `{ receiptHash, batchNumber, merkleRoot, proof }`. **404 `not-batched`** when the receipt is unknown *or* exists but has not been batched yet — the latter is normal and temporary, while an unbatched receipt past the inclusion SLO is operator failure, so a client needs to be able to tell those apart from a receipt that never existed. | `limit` defaults to 100 and is clamped to 500 on every route. The by-pet and by-wallet exports order by `(createdAt, receiptHash)`, since two receipts can diff --git a/backend/env.example b/backend/env.example index 9fea2964..9fc30e04 100644 --- a/backend/env.example +++ b/backend/env.example @@ -147,6 +147,14 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # does not retract receipts already issued, which §H requires stay checkable forever. # BATTLE_BACKEND_MODE_ENABLED=true +# Merkle batching (§I). A batch costs one transaction regardless of how many receipts it +# covers, so tiny batches spend gas to amortise nothing. The default minimum is low enough +# for a quiet deployment rather than tuned — the right cadence depends on real battle +# volume and gas price, which §L Phase 4 says to set from what the rewardless launch +# actually shows. +# BATTLE_BATCH_MIN_SIZE=1 +# BATTLE_BATCH_MAX_SIZE=1000 + # Which chain and deployment this process serves. Every wallet-signed object (battle # intents, defence authorizations) binds both, and the server refuses payloads naming a # different one, so a signature captured from staging is not a valid production signature. diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index f0c587dd..aafa8756 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -205,6 +205,18 @@ export const env = { * is just a sane starting value rather than an arbitrary one. */ cooldownSeconds: Number(process.env.BATTLE_COOLDOWN_SECONDS?.trim() || '900'), + /** + * Smallest run of published receipts worth anchoring (§I). + * + * A batch costs one transaction regardless of how many receipts it covers, so tiny + * batches spend gas to amortise nothing. The default is deliberately low for a + * quiet deployment rather than tuned: the right cadence depends on real battle + * volume and gas price, which §L Phase 4 says to set from what the rewardless + * launch actually shows, not from a guess made here. + */ + batchMinSize: Number(process.env.BATTLE_BATCH_MIN_SIZE?.trim() || '1'), + /** Most receipts in one batch. Bounds proof length and the anchoring transaction. */ + batchMaxSize: Number(process.env.BATTLE_BATCH_MAX_SIZE?.trim() || '1000'), }, /** diff --git a/backend/src/features/battle-batcher/batch.builder.ts b/backend/src/features/battle-batcher/batch.builder.ts new file mode 100644 index 00000000..3ac806f6 --- /dev/null +++ b/backend/src/features/battle-batcher/batch.builder.ts @@ -0,0 +1,95 @@ +import { buildMerkleTree, type Hex, merkleLeaf, merkleProof } from '@cryptopets/protocol'; +import { keccak256, toBytes } from 'viem'; + +/** + * Turning a run of signed receipts into the batch §I commits to. + * + * Pure: it takes receipts and returns a root, so the shape of a batch can be tested + * without a database, a chain, or a wallet anywhere near it. + */ + +export interface BatchableReceipt { + receiptHash: string; + sequence: bigint; + rulesetHash: string; +} + +export interface BuiltBatch { + merkleRoot: Hex; + rulesetSetHash: Hex; + firstSequence: bigint; + lastSequence: bigint; + /** Leaf order, which proofs are generated against. */ + receiptHashes: string[]; + proofFor(receiptHash: string): Hex[]; +} + +/** + * Builds a batch over receipts **in sequence order**. + * + * Ordering is not cosmetic. The registry enforces that each batch's `firstSequence` + * continues the previous batch's `lastSequence`, so a batch assembled out of order, or with + * a hole in it, is rejected on chain rather than quietly anchoring a partial history. This + * function refuses the same things locally so the failure surfaces before a transaction is + * paid for. + * + * A gap in the middle of a run is the interesting case: it means a receipt that should have + * been batched is missing — withheld, lost, or still unpublished. Anchoring around it would + * produce a root that looks complete while omitting a battle, which is precisely the + * omission §I says must stay visible. So it throws, and the operator has to decide whether + * to wait for the missing receipt or to batch only the contiguous prefix. + */ +export function buildBatch(receipts: readonly BatchableReceipt[]): BuiltBatch { + if (receipts.length === 0) { + throw new Error('cannot build a batch with no receipts'); + } + + const ordered = [...receipts].sort((a, b) => (a.sequence === b.sequence ? 0 : a.sequence < b.sequence ? -1 : 1)); + for (let i = 1; i < ordered.length; i++) { + const previous = ordered[i - 1]!.sequence; + const current = ordered[i]!.sequence; + if (current === previous) { + throw new Error(`duplicate receipt sequence ${current} in batch`); + } + if (current !== previous + 1n) { + throw new Error( + `receipt sequence gap: ${previous} is followed by ${current}. A batch must cover a ` + + 'contiguous run, or it would anchor a history with a hole in it', + ); + } + } + + const receiptHashes = ordered.map((receipt) => receipt.receiptHash); + const leaves = receiptHashes.map((hash) => merkleLeaf(hash as Hex)); + const tree = buildMerkleTree(leaves); + const indexByHash = new Map(receiptHashes.map((hash, index) => [hash.toLowerCase(), index])); + + return { + merkleRoot: tree.root, + rulesetSetHash: hashRulesetSet(ordered.map((receipt) => receipt.rulesetHash)), + firstSequence: ordered[0]!.sequence, + lastSequence: ordered[ordered.length - 1]!.sequence, + receiptHashes, + proofFor(receiptHash: string): Hex[] { + const index = indexByHash.get(receiptHash.toLowerCase()); + if (index === undefined) { + throw new Error(`receipt ${receiptHash} is not in this batch`); + } + return merkleProof(tree, index); + }, + }; +} + +/** + * Hashes the *set* of ruleset hashes the batched receipts were fought under. + * + * Deduplicated and sorted, so the value depends on which rulesets appear and not on how + * many battles happened to use each or what order they landed in. A batch of a thousand + * receipts all under one ruleset produces the same `rulesetSetHash` as a batch of two, which + * is the point: this field answers "which rules govern the contents of this batch", and a + * verifier checking it should not have to reconstruct battle ordering to do so. + */ +export function hashRulesetSet(rulesetHashes: readonly string[]): Hex { + const unique = [...new Set(rulesetHashes.map((hash) => hash.toLowerCase()))].sort(); + return keccak256(toBytes(`cryptopets/ruleset-set/v1|${unique.join(',')}`)); +} diff --git a/backend/src/features/battle-batcher/batcher.controller.ts b/backend/src/features/battle-batcher/batcher.controller.ts new file mode 100644 index 00000000..557d77ad --- /dev/null +++ b/backend/src/features/battle-batcher/batcher.controller.ts @@ -0,0 +1,28 @@ +import type { Request, Response } from 'express'; + +import { getInclusionProof } from './batcher.service'; + +/** + * The receipt-to-root inclusion proof §I requires be published. + * + * Unauthenticated, like every other read on this path. A proof is only useful to someone + * checking our work, and requiring a login to obtain one would make the anchoring claim + * checkable only by people we chose to let check it. + * + * 404 distinguishes two genuinely different answers: no such receipt at all, versus a + * receipt that exists but is not yet in a batch. The second is a normal, temporary state — + * a receipt batched a minute from now is simply waiting — while an *unbatched* receipt past + * the inclusion SLO is operator failure. A client that could not tell them apart could not + * raise that alarm. + */ +export async function getReceiptInclusionProof(req: Request, res: Response): Promise { + const proof = await getInclusionProof(req.params.receiptHash as string); + if (!proof) { + res.status(404).json({ + error: 'not-batched', + detail: 'no inclusion proof: this receipt is unknown, or has not been anchored in a batch yet', + }); + return; + } + res.status(200).json(proof); +} diff --git a/backend/src/features/battle-batcher/batcher.service.ts b/backend/src/features/battle-batcher/batcher.service.ts new file mode 100644 index 00000000..6836e881 --- /dev/null +++ b/backend/src/features/battle-batcher/batcher.service.ts @@ -0,0 +1,204 @@ +import type { Hex } from '@cryptopets/protocol'; +import { BattleState } from '@generated/prisma/enums'; + +import { env } from '@config/env'; +import { prisma } from '@config/prisma'; + +import { buildBatch, type BatchableReceipt } from './batch.builder'; + +/** + * Aggregating published receipts into Merkle batches (§I). + * + * Normal battles send no transactions. This is the only part of the backend battle path + * that ever touches a chain, and even then it anchors a fingerprint of many battles rather + * than any single one — which is what makes the whole design affordable. + * + * Batching is not outbox work. The outbox carries per-battle steps, and a batch is by + * definition about many battles at once, so it runs on its own schedule and finds its + * inputs by query. A `batch` outbox message per receipt would just be a queue that has to + * be drained in lockstep anyway. + */ + +export interface BatchScope { + chainId: string; + deploymentId: string; +} + +export type BatchOutcome = + | { status: 'batched'; batchNumber: bigint; merkleRoot: string; receiptCount: number } + | { status: 'nothing-to-batch' } + | { status: 'below-threshold'; available: number; minimum: number }; + +/** + * Builds and records the next batch for one chain and deployment. + * + * Receipts are taken **in sequence order, starting where the last batch ended**, and only a + * contiguous run is taken. If receipt N is missing — still unpublished, or withheld — the + * batch stops at N-1 rather than skipping it. Anchoring around a hole would produce a root + * that looks complete while omitting a battle, and §I is explicit that omission has to stay + * visible rather than be papered over. + * + * Recording the batch and moving its battles to `batched` happen in one transaction. A + * batch whose receipts were not marked, or marked receipts with no batch, would both lead + * to the same place: a receipt that is either anchored twice or never. + */ +export async function buildNextBatch(scope: BatchScope, minimumSize = env.battle.batchMinSize): Promise { + const previous = await prisma.battleBatch.findFirst({ + where: { chainId: scope.chainId, deploymentId: scope.deploymentId }, + orderBy: { batchNumber: 'desc' }, + }); + + const candidates = await prisma.battleReceipt.findMany({ + where: { + chainId: scope.chainId, + deploymentId: scope.deploymentId, + batchId: null, + battle: { state: BattleState.published }, + }, + orderBy: { sequence: 'asc' }, + take: env.battle.batchMaxSize, + select: { receiptHash: true, sequence: true, battleId: true, payload: true }, + }); + + if (candidates.length === 0) { + return { status: 'nothing-to-batch' }; + } + + const expectedFirst = previous ? previous.lastSequence + 1n : candidates[0]!.sequence; + const run = contiguousRunFrom(candidates, expectedFirst); + if (run.length === 0) { + // The next receipt by sequence is not the one the chain expects next. Waiting is + // correct: the missing one may still be mid-pipeline, and batching past it would + // anchor a gap. + return { status: 'nothing-to-batch' }; + } + if (run.length < minimumSize) { + return { status: 'below-threshold', available: run.length, minimum: minimumSize }; + } + + const built = buildBatch( + run.map((receipt) => ({ + receiptHash: receipt.receiptHash, + sequence: receipt.sequence, + rulesetHash: rulesetHashOf(receipt.payload), + })), + ); + const batchNumber = previous ? previous.batchNumber + 1n : 1n; + + await prisma.$transaction(async (tx) => { + const batch = await tx.battleBatch.create({ + data: { + chainId: scope.chainId, + deploymentId: scope.deploymentId, + batchNumber, + previousRoot: previous?.merkleRoot ?? null, + merkleRoot: built.merkleRoot, + rulesetSetHash: built.rulesetSetHash, + firstSequence: built.firstSequence, + lastSequence: built.lastSequence, + }, + }); + await tx.battleReceipt.updateMany({ + where: { receiptHash: { in: built.receiptHashes } }, + data: { batchId: batch.id }, + }); + for (const receipt of run) { + await tx.battleLedger.updateMany({ + where: { battleId: receipt.battleId, state: BattleState.published }, + data: { state: BattleState.batched }, + }); + } + }); + + return { + status: 'batched', + batchNumber, + merkleRoot: built.merkleRoot, + receiptCount: run.length, + }; +} + +/** + * The longest run starting at `expectedFirst` with no gaps. + * + * Returns empty when the first candidate is not `expectedFirst` at all, which means the + * receipt the chain expects next has not been published yet. + */ +function contiguousRunFrom(candidates: readonly T[], expectedFirst: bigint): T[] { + const run: T[] = []; + let expected = expectedFirst; + for (const candidate of candidates) { + if (candidate.sequence !== expected) break; + run.push(candidate); + expected += 1n; + } + return run; +} + +/** The ruleset a stored receipt names. */ +function rulesetHashOf(payload: unknown): string { + const hash = (payload as { rulesetHash?: unknown } | null)?.rulesetHash; + if (typeof hash !== 'string') { + throw new Error('stored receipt payload has no rulesetHash'); + } + return hash; +} + +export interface InclusionProof { + receiptHash: string; + batchNumber: string; + merkleRoot: string; + proof: Hex[]; +} + +/** + * The proof that a receipt is in its batch's root (§I's "publish receipt-to-root inclusion + * proofs"). + * + * Rebuilt from the batch's own receipts rather than stored, because storing a proof per + * receipt would duplicate a tree that is cheap to recompute and could drift from it. Null + * when the receipt exists but has not been batched yet, which is a normal state and not an + * error — an *unbatched* receipt past the inclusion SLO is operator failure, but one + * batched a minute from now is just waiting. + */ +export async function getInclusionProof(receiptHash: string): Promise { + const receipt = await prisma.battleReceipt.findUnique({ + where: { receiptHash }, + select: { batchId: true, receiptHash: true }, + }); + if (!receipt?.batchId) return null; + + const batch = await prisma.battleBatch.findUnique({ + where: { id: receipt.batchId }, + include: { + receipts: { + orderBy: { sequence: 'asc' }, + select: { receiptHash: true, sequence: true, payload: true }, + }, + }, + }); + if (!batch) return null; + + const built = buildBatch( + batch.receipts.map((row) => ({ + receiptHash: row.receiptHash, + sequence: row.sequence, + rulesetHash: rulesetHashOf(row.payload), + })), + ); + // A rebuilt root that disagrees with the stored one means the batch's membership + // changed after it was anchored. Serving a proof against the recomputed root would hide + // that; refusing surfaces it. + if (built.merkleRoot.toLowerCase() !== batch.merkleRoot.toLowerCase()) { + throw new Error( + `batch ${batch.batchNumber} rebuilds to ${built.merkleRoot} but was recorded as ${batch.merkleRoot}`, + ); + } + + return { + receiptHash: receipt.receiptHash, + batchNumber: batch.batchNumber.toString(), + merkleRoot: batch.merkleRoot, + proof: built.proofFor(receipt.receiptHash), + }; +} diff --git a/backend/src/features/battle-batcher/index.ts b/backend/src/features/battle-batcher/index.ts new file mode 100644 index 00000000..ee0b3406 --- /dev/null +++ b/backend/src/features/battle-batcher/index.ts @@ -0,0 +1,9 @@ +export { buildBatch, hashRulesetSet, type BatchableReceipt, type BuiltBatch } from './batch.builder'; +export { getReceiptInclusionProof } from './batcher.controller'; +export { + buildNextBatch, + getInclusionProof, + type BatchOutcome, + type BatchScope, + type InclusionProof, +} from './batcher.service'; diff --git a/backend/src/features/battle-worker/index.ts b/backend/src/features/battle-worker/index.ts index dd20f7c1..85c9a87a 100644 --- a/backend/src/features/battle-worker/index.ts +++ b/backend/src/features/battle-worker/index.ts @@ -1,5 +1,6 @@ export { processAwaitBeaconMessage } from './beacon.worker'; export { processComputeMessage } from './compute.worker'; +export { processPublishMessage } from './publish.worker'; export { processSignMessage } from './sign.worker'; export { processVerifyMessage } from './verify.worker'; export { diff --git a/backend/src/features/battle-worker/publish.worker.ts b/backend/src/features/battle-worker/publish.worker.ts new file mode 100644 index 00000000..6eeff6e6 --- /dev/null +++ b/backend/src/features/battle-worker/publish.worker.ts @@ -0,0 +1,69 @@ +import { assertBattleReceipt, hashBattleReceipt, receiptFromWire, type WireBattleReceipt } from '@cryptopets/protocol'; +import { BattleState } from '@generated/prisma/enums'; + +import { prisma } from '@config/prisma'; +import { applyTransition, type ClaimedMessage, completeOutbox } from '@features/battle-ledger'; +import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; + +/** + * Handles `publish` messages: `signed` -> `published` (§J). + * + * The receipt row is written by the sign worker, and the public corpus serves it from that + * moment, so this transition does not perform publication so much as confirm it. What it + * adds is the last integrity gate before the receipt is treated as publicly final: the + * stored payload is parsed back through the protocol types, revalidated, and re-hashed, and + * the digest must equal the id it is stored under. + * + * That is worth doing precisely because it should never fail. A row that no longer hashes + * to its own key means the payload was corrupted between signing and storage — by a bad + * serialization, a partial write, a schema drift — and the signature over it no longer + * proves anything. Catching that here stops us announcing a receipt nobody can verify; + * catching it later means a third party finds it first, which is the outcome this whole + * design exists to avoid. + * + * A mismatch throws rather than transitioning to a failure state. There is no honest + * `publish_failed` outcome for a battle that was already signed: the fight really did + * happen and the signature really was issued, so the correct response is a dead-lettered + * message and a human looking at why storage disagrees with what was signed. + */ +export async function processPublishMessage(message: ClaimedMessage, nowSeconds: number): Promise { + const battle = await prisma.battleLedger.findUnique({ where: { battleId: message.battleId } }); + if (!battle) { + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + if (battle.state !== BattleState.signed) { + // Already published by another worker, or this is a stale retry of a transition + // that already landed. + await completeOutbox(message.id, new Date(nowSeconds * 1000)); + return; + } + + const receipt = await prisma.battleReceipt.findUnique({ where: { battleId: battle.battleId } }); + if (!receipt) { + throw new Error(`battle ${battle.battleId} is signed but has no receipt row to publish`); + } + + const recomputed = hashBattleReceipt(assertBattleReceipt(receiptFromWire(receipt.payload as unknown as WireBattleReceipt))); + if (recomputed.toLowerCase() !== receipt.receiptHash.toLowerCase()) { + throw new Error( + `receipt ${receipt.receiptHash} for battle ${battle.battleId} re-hashes to ${recomputed}; ` + + 'the stored payload does not match what was signed', + ); + } + + await applyTransition({ + battleId: battle.battleId, + from: BattleState.signed, + to: BattleState.published, + // Batching is not per-battle work — it aggregates across many receipts on its own + // schedule — so no `batch` message is enqueued here. The batcher picks up published + // receipts by querying for them. + }); + notifyBattleRoomIfPresent(battle.roomId, { + type: 'battle-updated', + battleId: battle.battleId, + state: BattleState.published, + }); + await completeOutbox(message.id, new Date(nowSeconds * 1000)); +} diff --git a/backend/src/features/battle-worker/runner.ts b/backend/src/features/battle-worker/runner.ts index 5b478e6f..07af94c3 100644 --- a/backend/src/features/battle-worker/runner.ts +++ b/backend/src/features/battle-worker/runner.ts @@ -3,6 +3,7 @@ import { type ClaimedMessage, claimOutbox, failOutbox, OUTBOX_TOPICS } from '@fe import { processAwaitBeaconMessage } from './beacon.worker'; import { processComputeMessage } from './compute.worker'; +import { processPublishMessage } from './publish.worker'; import { processSignMessage } from './sign.worker'; import { processVerifyMessage } from './verify.worker'; @@ -21,6 +22,7 @@ const HANDLERS: Record [OUTBOX_TOPICS.compute]: processComputeMessage, [OUTBOX_TOPICS.verify]: processVerifyMessage, [OUTBOX_TOPICS.sign]: processSignMessage, + [OUTBOX_TOPICS.publish]: processPublishMessage, }; /** One poll: claims due messages for the topics this worker owns and processes each in turn. */ diff --git a/backend/src/routes/receipts.ts b/backend/src/routes/receipts.ts index f991b06d..918aac37 100644 --- a/backend/src/routes/receipts.ts +++ b/backend/src/routes/receipts.ts @@ -1,5 +1,6 @@ import express, { Router } from 'express'; +import { getReceiptInclusionProof } from '@features/battle-batcher'; import { getReceiptsByPet, getReceiptsBySequence, getReceiptsByWallet } from '@features/battle-ledger'; /** @@ -12,6 +13,9 @@ const router: Router = express.Router(); router.get('/by-pet/:chainId/:petId', getReceiptsByPet); router.get('/by-wallet/:wallet', getReceiptsByWallet); +// Declared before `/`, and specific enough not to collide with it: the Merkle proof that +// one receipt is in its anchored batch (§I). +router.get('/:receiptHash/inclusion-proof', getReceiptInclusionProof); router.get('/', getReceiptsBySequence); export default router; diff --git a/backend/tests/features/battle-batcher/batch.builder.test.ts b/backend/tests/features/battle-batcher/batch.builder.test.ts new file mode 100644 index 00000000..e775d26a --- /dev/null +++ b/backend/tests/features/battle-batcher/batch.builder.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { merkleLeaf, verifyReceiptInclusion, type Hex } from '@cryptopets/protocol'; + +import { buildBatch, hashRulesetSet, type BatchableReceipt } from '@features/battle-batcher'; + +const RULESET_A = `0x${'aa'.repeat(32)}`; +const RULESET_B = `0x${'bb'.repeat(32)}`; + +function receipt(sequence: number, rulesetHash = RULESET_A): BatchableReceipt { + return { + receiptHash: `0x${sequence.toString(16).padStart(64, '0')}`, + sequence: BigInt(sequence), + rulesetHash, + }; +} + +function run(from: number, to: number, rulesetHash = RULESET_A): BatchableReceipt[] { + return Array.from({ length: to - from + 1 }, (_, i) => receipt(from + i, rulesetHash)); +} + +describe('building a batch', () => { + it('commits the sequence range it covers', () => { + const batch = buildBatch(run(1, 5)); + expect(batch.firstSequence).toBe(1n); + expect(batch.lastSequence).toBe(5n); + expect(batch.receiptHashes).toHaveLength(5); + }); + + it('orders leaves by sequence regardless of the order it was handed', () => { + // The registry enforces contiguity on chain, so a batch assembled out of order + // would be rejected after paying for the transaction. + const shuffled = [receipt(3), receipt(1), receipt(5), receipt(2), receipt(4)]; + expect(buildBatch(shuffled).receiptHashes).toEqual(buildBatch(run(1, 5)).receiptHashes); + }); + + it('is deterministic', () => { + expect(buildBatch(run(1, 8)).merkleRoot).toBe(buildBatch(run(1, 8)).merkleRoot); + }); + + it('produces a different root when membership changes', () => { + expect(buildBatch(run(1, 5)).merkleRoot).not.toBe(buildBatch(run(1, 6)).merkleRoot); + }); + + it('handles a single-receipt batch', () => { + const batch = buildBatch([receipt(7)]); + expect(batch.firstSequence).toBe(7n); + expect(batch.lastSequence).toBe(7n); + }); + + it('refuses an empty batch', () => { + expect(() => buildBatch([])).toThrow(/no receipts/); + }); +}); + +describe('refusing to anchor a history with a hole in it', () => { + it('rejects a gap in the middle of the run', () => { + // Anchoring around a missing receipt would produce a root that looks complete while + // omitting a battle — the omission §I says has to stay visible. + expect(() => buildBatch([receipt(1), receipt(2), receipt(4)])).toThrow(/sequence gap/); + }); + + it('names both sides of the gap, so the missing receipt is identifiable', () => { + expect(() => buildBatch([receipt(1), receipt(9)])).toThrow(/1 is followed by 9/); + }); + + it('rejects a duplicated sequence', () => { + expect(() => buildBatch([receipt(1), receipt(1)])).toThrow(/duplicate receipt sequence/); + }); +}); + +describe('inclusion proofs', () => { + it('proves every member against the root', () => { + const receipts = run(1, 9); + const batch = buildBatch(receipts); + + for (const member of receipts) { + const proof = batch.proofFor(member.receiptHash); + expect(verifyReceiptInclusion(member.receiptHash as Hex, proof, batch.merkleRoot)).toBe(true); + } + }); + + it('proves membership in an odd-sized tree, where promotion makes proof lengths vary', () => { + const receipts = run(1, 7); + const batch = buildBatch(receipts); + expect( + receipts.every((m) => verifyReceiptInclusion(m.receiptHash as Hex, batch.proofFor(m.receiptHash), batch.merkleRoot)), + ).toBe(true); + }); + + it('does not prove a receipt that is not in the batch', () => { + const batch = buildBatch(run(1, 5)); + const outsider = receipt(99); + const someProof = batch.proofFor(batch.receiptHashes[0]!); + + expect(verifyReceiptInclusion(outsider.receiptHash as Hex, someProof, batch.merkleRoot)).toBe(false); + }); + + it('refuses to produce a proof for a non-member', () => { + const batch = buildBatch(run(1, 5)); + expect(() => batch.proofFor(`0x${'ff'.repeat(32)}`)).toThrow(/not in this batch/); + }); + + it('matches leaves case-insensitively, since hex casing is not identity', () => { + const batch = buildBatch(run(1, 4)); + const upper = batch.receiptHashes[2]!.toUpperCase().replace('0X', '0x'); + expect(() => batch.proofFor(upper)).not.toThrow(); + }); + + it('builds leaves through the protocol domain-separated hash, not the raw receipt hash', () => { + // A tree over raw hashes would let a receipt hash be reinterpreted as an internal + // node; the leaf domain tag is what stops that. + const batch = buildBatch([receipt(1)]); + expect(batch.merkleRoot).toBe(merkleLeaf(receipt(1).receiptHash as Hex)); + }); +}); + +describe('hashRulesetSet', () => { + it('depends on which rulesets appear, not how many receipts used each', () => { + // The field answers "which rules govern this batch"; a verifier checking it should + // not have to reconstruct battle ordering. + expect(hashRulesetSet([RULESET_A, RULESET_A, RULESET_A])).toBe(hashRulesetSet([RULESET_A])); + }); + + it('is order independent', () => { + expect(hashRulesetSet([RULESET_A, RULESET_B])).toBe(hashRulesetSet([RULESET_B, RULESET_A])); + }); + + it('is case insensitive', () => { + expect(hashRulesetSet([RULESET_A.toUpperCase().replace('0X', '0x')])).toBe(hashRulesetSet([RULESET_A])); + }); + + it('changes when a new ruleset enters the batch', () => { + expect(hashRulesetSet([RULESET_A])).not.toBe(hashRulesetSet([RULESET_A, RULESET_B])); + }); + + it('flows through to the built batch', () => { + const mixed = [...run(1, 2, RULESET_A), ...run(3, 4, RULESET_B)]; + expect(buildBatch(mixed).rulesetSetHash).toBe(hashRulesetSet([RULESET_A, RULESET_B])); + }); +}); diff --git a/backend/tests/features/battle-batcher/batcher.service.test.ts b/backend/tests/features/battle-batcher/batcher.service.test.ts new file mode 100644 index 00000000..627877be --- /dev/null +++ b/backend/tests/features/battle-batcher/batcher.service.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@config/env', () => ({ env: { battle: { batchMinSize: 1, batchMaxSize: 1000 } } })); + +vi.mock('@config/prisma', () => { + const tx = { + battleBatch: { create: vi.fn() }, + battleReceipt: { updateMany: vi.fn() }, + battleLedger: { updateMany: vi.fn() }, + }; + return { + prisma: { + battleBatch: { findFirst: vi.fn(), findUnique: vi.fn() }, + battleReceipt: { findMany: vi.fn(), findUnique: vi.fn() }, + $transaction: vi.fn(async (fn: (t: typeof tx) => Promise) => fn(tx)), + __tx: tx, + }, + }; +}); + +import { prisma } from '@config/prisma'; +import { buildBatch, buildNextBatch, getInclusionProof } from '@features/battle-batcher'; + +const RULESET = `0x${'aa'.repeat(32)}`; +const SCOPE = { chainId: 'eip155:84532', deploymentId: 'base-sepolia-live' }; +const tx = (prisma as unknown as { __tx: Record>> }).__tx; + +function row(sequence: number) { + return { + receiptHash: `0x${sequence.toString(16).padStart(64, '0')}`, + sequence: BigInt(sequence), + battleId: `btl_${sequence}`, + payload: { rulesetHash: RULESET }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue(null); + tx.battleBatch.create.mockResolvedValue({ id: 'batch_1' }); + tx.battleReceipt.updateMany.mockResolvedValue({ count: 0 }); + tx.battleLedger.updateMany.mockResolvedValue({ count: 1 }); +}); + +describe('assembling the next batch', () => { + it('batches a contiguous run starting at sequence 1', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([row(1), row(2), row(3)] as never); + + const outcome = await buildNextBatch(SCOPE); + + expect(outcome).toMatchObject({ status: 'batched', batchNumber: 1n, receiptCount: 3 }); + const created = tx.battleBatch.create.mock.calls[0]![0] as { data: Record }; + expect(created.data.firstSequence).toBe(1n); + expect(created.data.lastSequence).toBe(3n); + expect(created.data.previousRoot).toBeNull(); + }); + + it('continues from the previous batch, linking to its root', async () => { + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue({ + batchNumber: 4n, + lastSequence: 40n, + merkleRoot: `0x${'99'.repeat(32)}`, + } as never); + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([row(41), row(42)] as never); + + const outcome = await buildNextBatch(SCOPE); + + expect(outcome).toMatchObject({ status: 'batched', batchNumber: 5n }); + const created = tx.battleBatch.create.mock.calls[0]![0] as { data: Record }; + expect(created.data.previousRoot).toBe(`0x${'99'.repeat(32)}`); + expect(created.data.firstSequence).toBe(41n); + }); + + it('reports nothing to batch when no receipts are published', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([] as never); + await expect(buildNextBatch(SCOPE)).resolves.toEqual({ status: 'nothing-to-batch' }); + expect(tx.battleBatch.create).not.toHaveBeenCalled(); + }); + + it('holds below the minimum batch size rather than spending a transaction on it', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([row(1)] as never); + await expect(buildNextBatch(SCOPE, 10)).resolves.toEqual({ + status: 'below-threshold', + available: 1, + minimum: 10, + }); + expect(tx.battleBatch.create).not.toHaveBeenCalled(); + }); +}); + +describe('never anchoring around a missing receipt', () => { + it('stops at the gap instead of skipping it', async () => { + // Receipt 3 has not been published yet. Batching 1,2,4 would anchor a root that + // looks complete while omitting a battle. + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([row(1), row(2), row(4)] as never); + + const outcome = await buildNextBatch(SCOPE); + + expect(outcome).toMatchObject({ status: 'batched', receiptCount: 2 }); + const created = tx.battleBatch.create.mock.calls[0]![0] as { data: Record }; + expect(created.data.lastSequence).toBe(2n); + }); + + it('waits when the very next receipt the chain expects is missing', async () => { + // The registry enforces firstSequence == previous.lastSequence + 1, so publishing a + // batch that starts later would revert. Waiting is the correct move. + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue({ + batchNumber: 1n, + lastSequence: 10n, + merkleRoot: `0x${'99'.repeat(32)}`, + } as never); + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([row(12), row(13)] as never); + + await expect(buildNextBatch(SCOPE)).resolves.toEqual({ status: 'nothing-to-batch' }); + expect(tx.battleBatch.create).not.toHaveBeenCalled(); + }); + + it('only considers receipts whose battle actually reached published', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([row(1)] as never); + await buildNextBatch(SCOPE); + + const query = vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0] as { + where: { batchId: null; battle: { state: string } }; + }; + expect(query.where.batchId).toBeNull(); + expect(query.where.battle.state).toBe('published'); + }); +}); + +describe('recording the batch', () => { + it('marks receipts and their battles in one transaction with the batch row', async () => { + // A batch whose receipts were not marked, or marked receipts with no batch, both + // end at a receipt anchored twice or never. + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([row(1), row(2)] as never); + + await buildNextBatch(SCOPE); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(tx.battleReceipt.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { batchId: 'batch_1' } }), + ); + expect(tx.battleLedger.updateMany).toHaveBeenCalledTimes(2); + }); + + it('guards the ledger update on the battle still being published', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([row(1)] as never); + await buildNextBatch(SCOPE); + + const update = tx.battleLedger.updateMany.mock.calls[0]![0] as { where: { state: string } }; + expect(update.where.state).toBe('published'); + }); +}); + +describe('inclusion proofs', () => { + it('returns null for a receipt that has not been batched yet', async () => { + // Normal and temporary, not an error: an unbatched receipt past the inclusion SLO + // is operator failure, but one batched a minute from now is just waiting. + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ receiptHash: '0xabc', batchId: null } as never); + await expect(getInclusionProof('0xabc')).resolves.toBeNull(); + }); + + it('returns null for an unknown receipt', async () => { + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue(null); + await expect(getInclusionProof('0xabc')).resolves.toBeNull(); + }); + + it('rebuilds a proof that verifies against the recorded root', async () => { + const receipts = [row(1), row(2), row(3), row(4), row(5)]; + const expected = buildBatch( + receipts.map((r) => ({ receiptHash: r.receiptHash, sequence: r.sequence, rulesetHash: RULESET })), + ); + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash: receipts[2]!.receiptHash, + batchId: 'batch_1', + } as never); + vi.mocked(prisma.battleBatch.findUnique).mockResolvedValue({ + batchNumber: 1n, + merkleRoot: expected.merkleRoot, + receipts, + } as never); + + const proof = await getInclusionProof(receipts[2]!.receiptHash); + + expect(proof?.merkleRoot).toBe(expected.merkleRoot); + expect(proof?.proof).toEqual(expected.proofFor(receipts[2]!.receiptHash)); + expect(proof?.batchNumber).toBe('1'); + }); + + it('refuses to serve a proof when the batch no longer rebuilds to its recorded root', async () => { + // Membership changed after anchoring. Serving a proof against the recomputed root + // would hide that; refusing surfaces it. + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash: row(1).receiptHash, + batchId: 'batch_1', + } as never); + vi.mocked(prisma.battleBatch.findUnique).mockResolvedValue({ + batchNumber: 1n, + merkleRoot: `0x${'de'.repeat(32)}`, + receipts: [row(1), row(2)], + } as never); + + await expect(getInclusionProof(row(1).receiptHash)).rejects.toThrow(/rebuilds to .* but was recorded as/); + }); +}); diff --git a/backend/tests/features/battle-worker/publish.worker.test.ts b/backend/tests/features/battle-worker/publish.worker.test.ts new file mode 100644 index 00000000..c7bfd7be --- /dev/null +++ b/backend/tests/features/battle-worker/publish.worker.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + computeProgression, + deriveBattleSeed, + hashBattleReceipt, + hashBattleSnapshot, + hashCombatLog, + hashRuleset, + QUICKNET, + roundTime, + simulate, + SOURCE_DEFAULT_RULESET, + type BattleReceipt, + type BattleSnapshot, + type Hex, +} from '@cryptopets/protocol'; + +vi.mock('@config/prisma', () => ({ + prisma: { battleLedger: { findUnique: vi.fn() }, battleReceipt: { findUnique: vi.fn() } }, +})); +vi.mock('@features/battle-ledger', () => ({ + applyTransition: vi.fn(), + completeOutbox: vi.fn(), + claimOutbox: vi.fn(), + failOutbox: vi.fn(), + // The barrel pulls in the dispatcher, which builds its handler map from these. Every + // topic has to be present or a missing one registers as an `undefined` key. + OUTBOX_TOPICS: { + awaitBeacon: 'await-beacon', + compute: 'compute', + verify: 'verify', + sign: 'sign', + publish: 'publish', + batch: 'batch', + }, +})); +vi.mock('@ws/battleRoomSocket', () => ({ notifyBattleRoomIfPresent: vi.fn() })); + +import { prisma } from '@config/prisma'; +import { applyTransition, completeOutbox } from '@features/battle-ledger'; +import { processPublishMessage } from '@features/battle-worker'; +import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; + +const BEACON = { + chainHash: QUICKNET.chainHash, + round: 1000, + signature: + '0xb44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39' as Hex, + randomness: '0xfe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd' as Hex, +}; +const PUBLISHED_AT = roundTime(QUICKNET, BEACON.round); +const DOMAIN = { chainId: 'eip155:84532' as const, deploymentId: 'base-sepolia-live' }; +const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); + +const SNAPSHOT: BattleSnapshot = { + domain: DOMAIN, + attacker: { + petId: 1n, owner: '0xabcdef0123456789abcdef0123456789abcdef01', dna: 1234567890123456n, + rarity: 3, level: 10, skill: 4, xp: 120, lastOpponentId: 0n, streak: 0, + readyAt: PUBLISHED_AT - 100, sourceVersion: BigInt(PUBLISHED_AT - 50), + }, + defender: { + petId: 2n, owner: '0x2222222222222222222222222222222222222222', dna: 6543210987654321n, + rarity: 2, level: 11, skill: 7, xp: 45, lastOpponentId: 1n, streak: 2, + readyAt: PUBLISHED_AT - 100, sourceVersion: BigInt(PUBLISHED_AT - 50), + }, + takenAt: PUBLISHED_AT - 6, +}; + +function buildReceipt(): BattleReceipt { + const seed = deriveBattleSeed({ + domain: DOMAIN, + drandRandomness: BEACON.randomness, + battleId: 'btl_0001', + snapshotHash: hashBattleSnapshot(SNAPSHOT), + rulesetHash: RULESET_HASH, + }); + const outcome = simulate( + SNAPSHOT.attacker.dna, SNAPSHOT.attacker.rarity, SNAPSHOT.attacker.level, SNAPSHOT.attacker.skill, + SNAPSHOT.defender.dna, SNAPSHOT.defender.rarity, SNAPSHOT.defender.level, SNAPSHOT.defender.skill, + seed.value, SOURCE_DEFAULT_RULESET.skillConfig, + ); + return { + domain: DOMAIN, + battleId: 'btl_0001', + intentHash: `0x${'11'.repeat(32)}`, + commitmentHash: `0x${'22'.repeat(32)}`, + defenseAuthorizationHash: `0x${'33'.repeat(32)}`, + snapshot: SNAPSHOT, + beacon: BEACON, + seed: seed.hex, + rulesetVersion: SOURCE_DEFAULT_RULESET.version, + rulesetHash: RULESET_HASH, + result: { + attackerWon: outcome.result.firstWins, + rounds: outcome.result.rounds, + winnerHpRemaining: outcome.result.winnerHpRemaining, + }, + combatLogHash: hashCombatLog(outcome), + progression: computeProgression(SNAPSHOT, outcome.result.firstWins), + sequence: 1, + previousReceiptHash: null, + attackerPreviousReceiptHash: null, + defenderPreviousReceiptHash: null, + createdAt: PUBLISHED_AT + 1, + signingKeyId: 'battle-signer-2026-07', + }; +} + +const RECEIPT = buildReceipt(); +const WIRE = JSON.parse(JSON.stringify(RECEIPT, (_k, v) => (typeof v === 'bigint' ? v.toString() : v))); +const MESSAGE = { id: 'msg_1', battleId: 'btl_0001', topic: 'publish', payload: {}, attempts: 1 }; +const BATTLE = { battleId: 'btl_0001', state: 'signed', roomId: 'room_1' }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(BATTLE as never); + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash: hashBattleReceipt(RECEIPT), + payload: WIRE, + } as never); +}); + +describe('publishing a signed receipt', () => { + it('moves the battle to published', async () => { + await processPublishMessage(MESSAGE, PUBLISHED_AT + 10); + + expect(applyTransition).toHaveBeenCalledWith( + expect.objectContaining({ battleId: 'btl_0001', from: 'signed', to: 'published' }), + ); + expect(completeOutbox).toHaveBeenCalledWith('msg_1', expect.any(Date)); + }); + + it('enqueues nothing, since batching is not per-battle work', async () => { + await processPublishMessage(MESSAGE, PUBLISHED_AT + 10); + const call = vi.mocked(applyTransition).mock.calls[0]![0] as { outbox?: unknown[] }; + expect(call.outbox).toBeUndefined(); + }); + + it('notifies the room', async () => { + await processPublishMessage(MESSAGE, PUBLISHED_AT + 10); + expect(notifyBattleRoomIfPresent).toHaveBeenCalledWith('room_1', { + type: 'battle-updated', + battleId: 'btl_0001', + state: 'published', + }); + }); +}); + +describe('the integrity gate before a receipt is called final', () => { + it('throws when the stored payload no longer hashes to its own id', async () => { + // Corruption between signing and storage means the signature over it proves + // nothing. Catching it here beats a third party finding it first. + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash: `0x${'de'.repeat(32)}`, + payload: WIRE, + } as never); + + await expect(processPublishMessage(MESSAGE, PUBLISHED_AT + 10)).rejects.toThrow( + /does not match what was signed/, + ); + expect(applyTransition).not.toHaveBeenCalled(); + }); + + it('throws when the stored payload is not a valid receipt at all', async () => { + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue({ + receiptHash: hashBattleReceipt(RECEIPT), + payload: { ...WIRE, seed: `0x${'99'.repeat(32)}` }, + } as never); + + await expect(processPublishMessage(MESSAGE, PUBLISHED_AT + 10)).rejects.toThrow(); + expect(applyTransition).not.toHaveBeenCalled(); + }); + + it('throws when a signed battle has no receipt row', async () => { + vi.mocked(prisma.battleReceipt.findUnique).mockResolvedValue(null); + await expect(processPublishMessage(MESSAGE, PUBLISHED_AT + 10)).rejects.toThrow(/no receipt row/); + }); +}); + +describe('idempotence', () => { + it('completes without acting when the battle already moved on', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue({ ...BATTLE, state: 'batched' } as never); + await processPublishMessage(MESSAGE, PUBLISHED_AT + 10); + expect(applyTransition).not.toHaveBeenCalled(); + expect(completeOutbox).toHaveBeenCalled(); + }); + + it('completes without acting when the battle no longer exists', async () => { + vi.mocked(prisma.battleLedger.findUnique).mockResolvedValue(null); + await processPublishMessage(MESSAGE, PUBLISHED_AT + 10); + expect(applyTransition).not.toHaveBeenCalled(); + expect(completeOutbox).toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/features/battle-worker/runner.test.ts b/backend/tests/features/battle-worker/runner.test.ts index 09cabfaa..379e0e70 100644 --- a/backend/tests/features/battle-worker/runner.test.ts +++ b/backend/tests/features/battle-worker/runner.test.ts @@ -7,7 +7,16 @@ vi.mock('@config/env', () => ({ vi.mock('@features/battle-ledger', () => ({ claimOutbox: vi.fn(), failOutbox: vi.fn(), - OUTBOX_TOPICS: { awaitBeacon: 'await-beacon', compute: 'compute', verify: 'verify', sign: 'sign' }, + // Must list every real topic. A missing one is not a smaller map — it registers as an + // `undefined` key and the dispatcher claims a topic called "undefined". + OUTBOX_TOPICS: { + awaitBeacon: 'await-beacon', + compute: 'compute', + verify: 'verify', + sign: 'sign', + publish: 'publish', + batch: 'batch', + }, })); vi.mock('@features/battle-worker/beacon.worker', () => ({ @@ -22,6 +31,9 @@ vi.mock('@features/battle-worker/verify.worker', () => ({ vi.mock('@features/battle-worker/sign.worker', () => ({ processSignMessage: vi.fn(), })); +vi.mock('@features/battle-worker/publish.worker', () => ({ + processPublishMessage: vi.fn(), +})); import { claimOutbox, failOutbox } from '@features/battle-ledger'; import { processAwaitBeaconMessage } from '@features/battle-worker/beacon.worker'; @@ -58,7 +70,7 @@ describe('dispatch', () => { vi.mocked(claimOutbox).mockResolvedValue([]); await runBattleWorkerOnce('worker-a', NOW); expect(claimOutbox).toHaveBeenCalledWith( - ['await-beacon', 'compute', 'verify', 'sign'], + ['await-beacon', 'compute', 'verify', 'sign', 'publish'], 'worker-a', 10, NOW, @@ -81,8 +93,10 @@ 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. vi.mocked(claimOutbox).mockResolvedValue([ - { id: 'm1', battleId: 'btl_1', topic: 'publish', payload: {}, attempts: 1 }, + { id: 'm1', battleId: 'btl_1', topic: 'batch', payload: {}, attempts: 1 }, ]); await runBattleWorkerOnce('worker-a', NOW); From eea141779f772b1840a559755660f19c34dfb231 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 16:39:21 -0400 Subject: [PATCH 40/76] feat(backend): anchor batch roots in the on-chain registry --- backend/API.md | 4 + backend/env.example | 13 ++ backend/src/config/env.ts | 18 ++ backend/src/features/battle-anchor/abi.ts | 58 +++++ .../features/battle-anchor/anchor.service.ts | 142 +++++++++++++ backend/src/features/battle-anchor/index.ts | 95 +++++++++ backend/src/server.ts | 5 + .../battle-anchor/anchor.service.test.ts | 201 ++++++++++++++++++ 8 files changed, 536 insertions(+) create mode 100644 backend/src/features/battle-anchor/abi.ts create mode 100644 backend/src/features/battle-anchor/anchor.service.ts create mode 100644 backend/src/features/battle-anchor/index.ts create mode 100644 backend/tests/features/battle-anchor/anchor.service.test.ts diff --git a/backend/API.md b/backend/API.md index 97053fa7..bd417ee9 100644 --- a/backend/API.md +++ b/backend/API.md @@ -336,6 +336,10 @@ so historical-key durability is not yet backed by persistent storage. | `KEEPER_BACKFILL_BLOCKS` | How far back to scan on boot for requests never settled (default 5000). | | `KEEPER_MOCK_REVEAL` | Local dev only: keeper also acts as the Entropy provider (`MockEntropy.mockReveal`). Only takes effect when `KEEPER_CHAIN_ID=31337`. | | `KEEPER_SHADOW_ENABLED` | Shadow mode (§L Phase 2): recompute settled on-chain battles through the backend engine and indexer-go and record whether they matched `BattleResolved`. Observation only. Off by default. | +| `BATTLE_BACKEND_MODE_ENABLED` | Backend-authoritative battle mode (§L Phase 3). Off by default; gates the write routes, the outbox worker, and the signer requirement. Reads stay served either way. | +| `BATTLE_BATCH_MIN_SIZE` / `BATTLE_BATCH_MAX_SIZE` | Smallest run worth anchoring, and the cap on one batch (§I). | +| `BATTLE_ANCHOR_RPC_URL` / `BATTLE_ANCHOR_PRIVATE_KEY` / `BATTLE_ANCHOR_REGISTRY_ADDRESS` / `BATTLE_ANCHOR_CHAIN_ID` | Anchoring batch roots in `BattleBatchRegistry`. Required together; with any missing, batches are built but never anchored. The wallet needs the registry's publisher role. | +| `BATTLE_ANCHOR_INTERVAL_MS` | How often to build and anchor (default 60000). Latency only — both halves are idempotent. | > **Migration prerequisite:** the v2 `pet_roster` / `battle_history` columns ship > in `prisma/schema.prisma`; run `pnpm prisma:migrate` then `pnpm prisma:generate` diff --git a/backend/env.example b/backend/env.example index 9fc30e04..3ad2eb80 100644 --- a/backend/env.example +++ b/backend/env.example @@ -155,6 +155,19 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # BATTLE_BATCH_MIN_SIZE=1 # BATTLE_BATCH_MAX_SIZE=1000 +# Anchoring batch roots in BattleBatchRegistry (§I). All four are required together; with +# any missing, batches are still built and their receipts are still signed and public, they +# are simply never anchored. That is the right degradation: anchoring proves publication, +# not honesty, so losing it costs immutability rather than correctness. +# +# The wallet needs the registry's publisher role (setPublisher) and enough gas for one +# ~200k transaction per batch, regardless of how many receipts each batch covers. +# BATTLE_ANCHOR_RPC_URL=https://sepolia.base.org +# BATTLE_ANCHOR_PRIVATE_KEY= +# BATTLE_ANCHOR_REGISTRY_ADDRESS= +# BATTLE_ANCHOR_CHAIN_ID=84532 +# BATTLE_ANCHOR_INTERVAL_MS=60000 + # Which chain and deployment this process serves. Every wallet-signed object (battle # intents, defence authorizations) binds both, and the server refuses payloads naming a # different one, so a signature captured from staging is not a valid production signature. diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index aafa8756..6605cf76 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -217,6 +217,24 @@ export const env = { batchMinSize: Number(process.env.BATTLE_BATCH_MIN_SIZE?.trim() || '1'), /** Most receipts in one batch. Bounds proof length and the anchoring transaction. */ batchMaxSize: Number(process.env.BATTLE_BATCH_MAX_SIZE?.trim() || '1000'), + /** + * Anchoring batch roots in `BattleBatchRegistry` (§I). + * + * All four are required together; with any missing, batches are still built and + * their receipts are still signed and public, they are simply not anchored. That + * degradation is the right one: anchoring proves publication, not honesty, so + * losing it costs immutability rather than correctness. + */ + anchorRpcUrl: process.env.BATTLE_ANCHOR_RPC_URL?.trim() || undefined, + anchorPrivateKey: (process.env.BATTLE_ANCHOR_PRIVATE_KEY?.trim() + ? (process.env.BATTLE_ANCHOR_PRIVATE_KEY.trim().startsWith('0x') + ? process.env.BATTLE_ANCHOR_PRIVATE_KEY.trim() + : `0x${process.env.BATTLE_ANCHOR_PRIVATE_KEY.trim()}`) + : undefined) as `0x${string}` | undefined, + anchorRegistryAddress: process.env.BATTLE_ANCHOR_REGISTRY_ADDRESS?.trim() || undefined, + anchorChainId: process.env.BATTLE_ANCHOR_CHAIN_ID ? Number(process.env.BATTLE_ANCHOR_CHAIN_ID) : undefined, + /** How often to build and anchor. Latency only — both halves are idempotent. */ + anchorIntervalMs: Number(process.env.BATTLE_ANCHOR_INTERVAL_MS?.trim() || '60000'), }, /** diff --git a/backend/src/features/battle-anchor/abi.ts b/backend/src/features/battle-anchor/abi.ts new file mode 100644 index 00000000..ed45ab51 --- /dev/null +++ b/backend/src/features/battle-anchor/abi.ts @@ -0,0 +1,58 @@ +/** + * Minimal BattleBatchRegistry ABI — only what anchoring needs. + * + * Hand-written rather than imported from the Hardhat artifacts, matching how the settle + * keeper declares GameLogic: the backend does not build the contracts, and depending on a + * compiled artifact path would couple deploys of one to builds of the other. + */ +export const BATTLE_BATCH_REGISTRY_ABI = [ + { + type: 'function', + name: 'publishBatch', + stateMutability: 'nonpayable', + inputs: [ + { name: 'batchNumber', type: 'uint64' }, + { name: 'previousRoot', type: 'bytes32' }, + { name: 'merkleRoot', type: 'bytes32' }, + { name: 'rulesetSetHash', type: 'bytes32' }, + { name: 'firstSequence', type: 'uint64' }, + { name: 'lastSequence', type: 'uint64' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'latestBatchNumber', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'uint64' }], + }, + { + type: 'function', + name: 'latestRoot', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'bytes32' }], + }, + { + type: 'event', + name: 'BatchPublished', + inputs: [ + { indexed: true, name: 'batchNumber', type: 'uint64' }, + { indexed: true, name: 'merkleRoot', type: 'bytes32' }, + { indexed: false, name: 'previousRoot', type: 'bytes32' }, + { indexed: false, name: 'rulesetSetHash', type: 'bytes32' }, + { indexed: false, name: 'firstSequence', type: 'uint64' }, + { indexed: false, name: 'lastSequence', type: 'uint64' }, + ], + }, +] as const; + +/** + * Gas ceiling for one `publishBatch`. The call writes a fixed-size struct and two slots + * regardless of how many receipts the batch covers, so this does not scale with batch size. + */ +export const PUBLISH_BATCH_GAS_LIMIT = 200_000n; + +/** The registry's `previousRoot` for the very first batch. */ +export const ZERO_ROOT = `0x${'00'.repeat(32)}` as const; diff --git a/backend/src/features/battle-anchor/anchor.service.ts b/backend/src/features/battle-anchor/anchor.service.ts new file mode 100644 index 00000000..66deca6e --- /dev/null +++ b/backend/src/features/battle-anchor/anchor.service.ts @@ -0,0 +1,142 @@ +import type { Address, Chain, PublicClient, Transport, WalletClient, Account } from 'viem'; + +import { prisma } from '@config/prisma'; + +import { BATTLE_BATCH_REGISTRY_ABI, PUBLISH_BATCH_GAS_LIMIT, ZERO_ROOT } from './abi'; + +/** + * Anchoring batch roots in `BattleBatchRegistry` (§I). + * + * This is the one place the backend battle path sends a transaction. It anchors a + * fingerprint of many battles rather than any single one, which is what makes the design + * affordable — and it is deliberately the *last* step, after signing, publication, and + * batching, so an outage here costs latency rather than correctness. + * + * Anchoring proves publication, not honesty. A root on chain means we cannot later change + * what a batch contained; it says nothing about whether the battles inside were computed + * correctly, which is public replay's job (§H). + */ + +export interface AnchorContext { + publicClient: PublicClient; + walletClient: WalletClient; + registryAddress: Address; + chainId: string; + deploymentId: string; +} + +export type AnchorOutcome = + | { status: 'anchored'; batchNumber: bigint; txHash: string } + | { status: 'nothing-to-anchor' } + | { status: 'already-anchored'; batchNumber: bigint } + | { status: 'out-of-sync'; detail: string } + | { status: 'failed'; detail: string }; + +/** + * Anchors the oldest unanchored batch, if it is the one the registry expects next. + * + * One batch per call, in order, on purpose. The registry refuses anything but the next + * batch number linked to the current head, so there is no useful concurrency here — and + * attempting several would just mean one success and a queue of reverts. + * + * The on-chain head is read *before* submitting rather than discovering a mismatch by + * paying for a revert. That read also makes this crash-safe: a batch whose transaction + * landed but whose row never got updated shows up as already anchored on chain, and is + * reconciled rather than submitted a second time. + */ +export async function anchorNextBatch(context: AnchorContext): Promise { + const batch = await prisma.battleBatch.findFirst({ + where: { chainId: context.chainId, deploymentId: context.deploymentId, anchoredAt: null }, + orderBy: { batchNumber: 'asc' }, + }); + if (!batch) { + return { status: 'nothing-to-anchor' }; + } + + const [onChainNumber, onChainRoot] = await Promise.all([ + context.publicClient.readContract({ + address: context.registryAddress, + abi: BATTLE_BATCH_REGISTRY_ABI, + functionName: 'latestBatchNumber', + }), + context.publicClient.readContract({ + address: context.registryAddress, + abi: BATTLE_BATCH_REGISTRY_ABI, + functionName: 'latestRoot', + }), + ]); + + // The transaction landed but the row never got updated — a crash between the two. The + // chain is the authority here, so reconcile rather than resubmit. + if (BigInt(onChainNumber) >= batch.batchNumber) { + await markAnchored(batch.id, null); + return { status: 'already-anchored', batchNumber: batch.batchNumber }; + } + + const expectedNumber = BigInt(onChainNumber) + 1n; + if (batch.batchNumber !== expectedNumber) { + // A batch is missing between the chain's head and ours. Submitting would revert, and + // guessing which batch to send instead would risk anchoring them out of order. + return { + status: 'out-of-sync', + detail: `registry expects batch ${expectedNumber}, oldest unanchored batch is ${batch.batchNumber}`, + }; + } + + const previousRoot = (batch.previousRoot ?? ZERO_ROOT) as `0x${string}`; + if (previousRoot.toLowerCase() !== String(onChainRoot).toLowerCase()) { + // Our idea of the chain's head disagrees with the chain's. Anchoring anyway would + // revert; the divergence needs a human, since it means the local batch chain was + // built on something the registry never accepted. + return { + status: 'out-of-sync', + detail: `batch ${batch.batchNumber} links to ${previousRoot} but the registry head is ${String(onChainRoot)}`, + }; + } + + try { + const hash = await context.walletClient.writeContract({ + address: context.registryAddress, + abi: BATTLE_BATCH_REGISTRY_ABI, + functionName: 'publishBatch', + args: [ + batch.batchNumber, + previousRoot, + batch.merkleRoot as `0x${string}`, + batch.rulesetSetHash as `0x${string}`, + batch.firstSequence, + batch.lastSequence, + ], + gas: PUBLISH_BATCH_GAS_LIMIT, + }); + const receipt = await context.publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== 'success') { + return { status: 'failed', detail: `publishBatch(${batch.batchNumber}) reverted in ${hash}` }; + } + + await markAnchored(batch.id, hash); + return { status: 'anchored', batchNumber: batch.batchNumber, txHash: hash }; + } catch (error) { + // Left unanchored so the next pass retries it. The batch itself is already durable + // and its receipts already public; only the anchor is missing. + return { status: 'failed', detail: describe(error) }; + } +} + +/** + * Records that a batch is anchored. + * + * `txHash` is null when reconciling a batch found already anchored on chain: we know it + * landed but not in which transaction, and inventing a hash would be worse than admitting + * the gap. The event log is the record of record either way. + */ +async function markAnchored(batchId: string, txHash: string | null): Promise { + await prisma.battleBatch.update({ + where: { id: batchId }, + data: { anchoredAt: new Date(), ...(txHash ? { anchoredTxHash: txHash } : {}) }, + }); +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message.split('\n')[0]! : String(error); +} diff --git a/backend/src/features/battle-anchor/index.ts b/backend/src/features/battle-anchor/index.ts new file mode 100644 index 00000000..5d8307de --- /dev/null +++ b/backend/src/features/battle-anchor/index.ts @@ -0,0 +1,95 @@ +import { createPublicClient, createWalletClient, http, type Address, type Chain } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { env } from '@config/env'; +import { buildNextBatch } from '@features/battle-batcher'; + +import { anchorNextBatch, type AnchorContext } from './anchor.service'; + +export { anchorNextBatch, type AnchorContext, type AnchorOutcome } from './anchor.service'; +export { BATTLE_BATCH_REGISTRY_ABI, PUBLISH_BATCH_GAS_LIMIT, ZERO_ROOT } from './abi'; + +/** + * The batch-and-anchor loop (§I). + * + * Batching and anchoring run on one timer, in that order, because they are the two halves + * of the same job: aggregate what is publishable, then anchor what is aggregated. Both are + * idempotent and both no-op when there is nothing to do, so the interval only controls + * latency, never correctness. + * + * Off unless configured, like the settle keepers. A deployment that has not decided its + * batch cadence or funded an anchoring wallet should batch nothing rather than anchor on + * defaults nobody chose. + */ + +export interface BatchAnchorHandle { + stop(): void; +} + +let handle: BatchAnchorHandle | undefined; + +export function startBatchAnchor(): void { + if (!env.battle.enabled) { + return; + } + const { anchorRpcUrl, anchorPrivateKey, anchorRegistryAddress, anchorChainId, anchorIntervalMs } = env.battle; + if (!anchorRpcUrl || !anchorPrivateKey || !anchorRegistryAddress || !anchorChainId) { + console.log( + '[battle-anchor] BATTLE_ANCHOR_* not fully set; batches will still be built but never anchored ' + + '(receipts stay signed and public either way)', + ); + return; + } + + const chain = { id: anchorChainId, name: `chain-${anchorChainId}`, nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { default: { http: [anchorRpcUrl] } } } as const satisfies Chain; + const publicClient = createPublicClient({ chain, transport: http(anchorRpcUrl) }); + const walletClient = createWalletClient({ + account: privateKeyToAccount(anchorPrivateKey), + chain, + transport: http(anchorRpcUrl), + }); + + const context: AnchorContext = { + publicClient, + walletClient, + registryAddress: anchorRegistryAddress as Address, + // The registry is per-deployment, so one scope per process. A deployment serving + // several protocol chain ids anchors each one against its own registry. + chainId: env.battle.chainIds[0] ?? '', + deploymentId: env.battle.deploymentId, + }; + + const timer = setInterval(() => void runOnce(context), anchorIntervalMs); + timer.unref(); + handle = { stop: () => clearInterval(timer) }; + console.log(`[battle-anchor] batching and anchoring every ${anchorIntervalMs}ms to ${anchorRegistryAddress}`); +} + +export function stopBatchAnchor(): void { + handle?.stop(); + handle = undefined; +} + +/** One pass: build whatever is batchable, then anchor whatever is unanchored. */ +export async function runOnce(context: AnchorContext): Promise { + try { + const built = await buildNextBatch({ chainId: context.chainId, deploymentId: context.deploymentId }); + if (built.status === 'batched') { + console.log(`[battle-anchor] built batch ${built.batchNumber} over ${built.receiptCount} receipts`); + } + } catch (error) { + console.error(`[battle-anchor] batching failed: ${(error as Error).message.split('\n')[0]}`); + } + + try { + const anchored = await anchorNextBatch(context); + if (anchored.status === 'anchored') { + console.log(`[battle-anchor] anchored batch ${anchored.batchNumber} in ${anchored.txHash}`); + } else if (anchored.status === 'out-of-sync' || anchored.status === 'failed') { + // Loud: an unanchored backlog past the inclusion SLO is operator failure (§I). + console.error(`[battle-anchor] ${anchored.status}: ${anchored.detail}`); + } + } catch (error) { + console.error(`[battle-anchor] anchoring failed: ${(error as Error).message.split('\n')[0]}`); + } +} diff --git a/backend/src/server.ts b/backend/src/server.ts index ac84c9f1..d645027b 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,6 +7,7 @@ import { configureSigner } from '@features/battle-signer'; import { startSettleKeeper, stopSettleKeeper } from '@features/settle-keeper'; import { startSolanaSettleKeeperFeature, stopSolanaSettleKeeperFeature } from '@features/settle-keeper-solana'; import { type BattleWorkerHandle, startBattleWorker } from '@features/battle-worker'; +import { startBatchAnchor, stopBatchAnchor } from '@features/battle-anchor'; import { startLiveBattleSocket, stopLiveBattleSocket } from '@ws/liveBattleSocket'; import { startBattleRoomSocket, stopBattleRoomSocket } from '@ws/battleRoomSocket'; @@ -49,6 +50,9 @@ const server = app.listen(env.port, '0.0.0.0', () => { if (env.battle.enabled) { configureSigner(Math.floor(Date.now() / 1000)); 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. + startBatchAnchor(); } else { console.log('[battle] BATTLE_BACKEND_MODE_ENABLED not set; backend battle writes disabled (reads stay served)'); } @@ -79,6 +83,7 @@ async function shutdown(signal: NodeJS.Signals): Promise { stopSettleKeeper(); stopSolanaSettleKeeperFeature(); battleWorker?.stop(); + stopBatchAnchor(); stopLiveBattleSocket(); stopBattleRoomSocket(); await new Promise((resolve) => server.close(() => resolve())); diff --git a/backend/tests/features/battle-anchor/anchor.service.test.ts b/backend/tests/features/battle-anchor/anchor.service.test.ts new file mode 100644 index 00000000..38ffdcdc --- /dev/null +++ b/backend/tests/features/battle-anchor/anchor.service.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@config/prisma', () => ({ + prisma: { battleBatch: { findFirst: vi.fn(), update: vi.fn() } }, +})); + +import { prisma } from '@config/prisma'; +import { anchorNextBatch, ZERO_ROOT, type AnchorContext } from '@features/battle-anchor'; + +const ROOT_1 = `0x${'11'.repeat(32)}`; +const ROOT_2 = `0x${'22'.repeat(32)}`; +const RULESET_SET = `0x${'aa'.repeat(32)}`; +const TX_HASH = `0x${'ee'.repeat(32)}`; + +const readContract = vi.fn(); +const writeContract = vi.fn(); +const waitForTransactionReceipt = vi.fn(); + +function context(): AnchorContext { + return { + publicClient: { readContract, waitForTransactionReceipt } as never, + walletClient: { writeContract } as never, + registryAddress: '0x1111111111111111111111111111111111111111', + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + }; +} + +/** Registry state: head batch number and head root. */ +function onChain(latestBatchNumber: bigint, latestRoot: string) { + readContract.mockImplementation(({ functionName }: { functionName: string }) => + Promise.resolve(functionName === 'latestBatchNumber' ? latestBatchNumber : latestRoot), + ); +} + +function batch(overrides: Partial> = {}) { + return { + id: 'batch_1', + batchNumber: 1n, + previousRoot: null, + merkleRoot: ROOT_1, + rulesetSetHash: RULESET_SET, + firstSequence: 1n, + lastSequence: 100n, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.battleBatch.update).mockResolvedValue({} as never); + writeContract.mockResolvedValue(TX_HASH); + waitForTransactionReceipt.mockResolvedValue({ status: 'success' }); +}); + +describe('anchoring the next batch', () => { + it('publishes the first batch against the zero root', async () => { + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue(batch() as never); + onChain(0n, ZERO_ROOT); + + const outcome = await anchorNextBatch(context()); + + expect(outcome).toEqual({ status: 'anchored', batchNumber: 1n, txHash: TX_HASH }); + const call = writeContract.mock.calls[0]![0] as { args: unknown[] }; + expect(call.args).toEqual([1n, ZERO_ROOT, ROOT_1, RULESET_SET, 1n, 100n]); + }); + + it('records the transaction hash against the batch', async () => { + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue(batch() as never); + onChain(0n, ZERO_ROOT); + + await anchorNextBatch(context()); + + expect(prisma.battleBatch.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'batch_1' }, + data: expect.objectContaining({ anchoredTxHash: TX_HASH }), + }), + ); + }); + + it('publishes a later batch linked to the registry head', async () => { + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue( + batch({ id: 'batch_2', batchNumber: 2n, previousRoot: ROOT_1, merkleRoot: ROOT_2 }) as never, + ); + onChain(1n, ROOT_1); + + await expect(anchorNextBatch(context())).resolves.toMatchObject({ status: 'anchored', batchNumber: 2n }); + }); + + it('takes the oldest unanchored batch, so batches anchor in order', async () => { + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue(batch() as never); + onChain(0n, ZERO_ROOT); + + await anchorNextBatch(context()); + + const query = vi.mocked(prisma.battleBatch.findFirst).mock.calls[0]![0] as { + where: { anchoredAt: null }; + orderBy: { batchNumber: string }; + }; + expect(query.where.anchoredAt).toBeNull(); + expect(query.orderBy.batchNumber).toBe('asc'); + }); + + it('does nothing when every batch is anchored', async () => { + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue(null); + await expect(anchorNextBatch(context())).resolves.toEqual({ status: 'nothing-to-anchor' }); + expect(writeContract).not.toHaveBeenCalled(); + }); +}); + +describe('crash safety', () => { + it('reconciles a batch whose transaction landed but whose row was never updated', async () => { + // The chain is the authority: resubmitting would revert on the batch number anyway. + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue(batch() as never); + onChain(1n, ROOT_1); + + const outcome = await anchorNextBatch(context()); + + expect(outcome).toEqual({ status: 'already-anchored', batchNumber: 1n }); + expect(writeContract).not.toHaveBeenCalled(); + expect(prisma.battleBatch.update).toHaveBeenCalled(); + }); + + it('does not invent a transaction hash when reconciling', async () => { + // We know it landed, not in which transaction; the event log is the record. + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue(batch() as never); + onChain(1n, ROOT_1); + + await anchorNextBatch(context()); + + const call = vi.mocked(prisma.battleBatch.update).mock.calls[0]![0] as { data: Record }; + expect(call.data.anchoredAt).toBeInstanceOf(Date); + expect('anchoredTxHash' in call.data).toBe(false); + }); +}); + +describe('refusing to submit a transaction that would revert', () => { + it('reports out-of-sync when a batch is missing between the chain head and ours', async () => { + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue( + batch({ batchNumber: 5n, previousRoot: ROOT_1 }) as never, + ); + onChain(1n, ROOT_1); + + const outcome = await anchorNextBatch(context()); + + expect(outcome).toMatchObject({ status: 'out-of-sync' }); + expect(String((outcome as { detail: string }).detail)).toContain('expects batch 2'); + expect(writeContract).not.toHaveBeenCalled(); + }); + + it('reports out-of-sync when our link disagrees with the registry head', async () => { + // The local batch chain was built on something the registry never accepted, which + // needs a human rather than a retry. + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue( + batch({ batchNumber: 2n, previousRoot: ROOT_2 }) as never, + ); + onChain(1n, ROOT_1); + + const outcome = await anchorNextBatch(context()); + + expect(outcome).toMatchObject({ status: 'out-of-sync' }); + expect(String((outcome as { detail: string }).detail)).toContain('registry head'); + expect(writeContract).not.toHaveBeenCalled(); + }); + + it('reads the on-chain head before submitting rather than paying for a revert', async () => { + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue(batch() as never); + onChain(0n, ZERO_ROOT); + + await anchorNextBatch(context()); + + expect(readContract).toHaveBeenCalledTimes(2); + }); +}); + +describe('failures leave the batch retryable', () => { + it('reports a reverted transaction without marking the batch anchored', async () => { + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue(batch() as never); + onChain(0n, ZERO_ROOT); + waitForTransactionReceipt.mockResolvedValue({ status: 'reverted' }); + + const outcome = await anchorNextBatch(context()); + + expect(outcome).toMatchObject({ status: 'failed' }); + expect(prisma.battleBatch.update).not.toHaveBeenCalled(); + }); + + it('reports a send failure without marking the batch anchored', async () => { + // The batch is durable and its receipts are already public; only the anchor is + // missing, so the next pass retries it. + vi.mocked(prisma.battleBatch.findFirst).mockResolvedValue(batch() as never); + onChain(0n, ZERO_ROOT); + writeContract.mockRejectedValue(new Error('insufficient funds')); + + const outcome = await anchorNextBatch(context()); + + expect(outcome).toMatchObject({ status: 'failed', detail: expect.stringContaining('insufficient funds') }); + expect(prisma.battleBatch.update).not.toHaveBeenCalled(); + }); +}); From 858897de31b4c600496b8d0f234f3313cdedbb6d Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 16:48:42 -0400 Subject: [PATCH 41/76] feat(contracts): add capped aggregate reward claims with nullifiers --- contracts/ethereum/package.json | 1 + contracts/ethereum/src/MockERC20.sol | 14 + .../ethereum/src/SeasonRewardDistributor.sol | 269 +++++++++++++ .../test/SeasonRewardDistributor.test.ts | 373 ++++++++++++++++++ pnpm-lock.yaml | 3 + protocol/src/domain/schemaVersions.ts | 2 + protocol/src/encoding/domain.ts | 4 + protocol/src/merkle/index.ts | 5 + protocol/src/merkle/reward.ts | 89 +++++ protocol/tests/domain/schemaVersions.test.ts | 1 + protocol/tests/merkle/reward.test.ts | 104 +++++ 11 files changed, 865 insertions(+) create mode 100644 contracts/ethereum/src/MockERC20.sol create mode 100644 contracts/ethereum/src/SeasonRewardDistributor.sol create mode 100644 contracts/ethereum/test/SeasonRewardDistributor.test.ts create mode 100644 protocol/src/merkle/reward.ts create mode 100644 protocol/tests/merkle/reward.test.ts diff --git a/contracts/ethereum/package.json b/contracts/ethereum/package.json index 6f365d59..2abbc68a 100644 --- a/contracts/ethereum/package.json +++ b/contracts/ethereum/package.json @@ -23,6 +23,7 @@ "upgrade:game-logic:base-sepolia": "tsx scripts/upgrade-game-logic.ts --network=base-sepolia" }, "devDependencies": { + "@cryptopets/protocol": "workspace:*", "@nomicfoundation/hardhat-ignition": "^3.0.3", "@nomicfoundation/hardhat-toolbox-viem": "^5.0.0", "@nomicfoundation/hardhat-verify": "3.0.13", diff --git a/contracts/ethereum/src/MockERC20.sol b/contracts/ethereum/src/MockERC20.sol new file mode 100644 index 00000000..e1d45340 --- /dev/null +++ b/contracts/ethereum/src/MockERC20.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @dev Test-only ERC-20 with open minting, for exercising SeasonRewardDistributor. +/// Never deployed anywhere real. +contract MockERC20 is ERC20 { + constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/contracts/ethereum/src/SeasonRewardDistributor.sol b/contracts/ethereum/src/SeasonRewardDistributor.sol new file mode 100644 index 00000000..0bdbae47 --- /dev/null +++ b/contracts/ethereum/src/SeasonRewardDistributor.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title SeasonRewardDistributor + * @notice Capped, one-time reward claims against a per-season Merkle root. + * @dev docs/plan-backend-battle-architecture.md §I. Separate from BattleBatchRegistry on + * purpose: that contract is the immutable record of what happened and must stay + * minimal, while this one holds funds. Keeping the ledger away from the money means a + * bug here cannot corrupt the history, and a pause here cannot stop battles. + * + * **What a claim proves.** Membership in a season's reward tree, and nothing more. + * The tree is computed off chain from anchored receipts, so the per-battle reward cap + * §I asks for is applied there, where the battles are actually visible. What this + * contract enforces is the part that must not depend on the operator being honest: + * one claim per wallet per season, a per-wallet ceiling, and a season total that + * cannot be exceeded no matter what root was posted. + * + * That division matters. A root is operator-supplied, so treating it as authoritative + * for *value* would make a bad root an unbounded loss. The caps here bound the damage + * to something the owner chose in advance, which is what makes posting a root a + * recoverable mistake rather than a fatal one. + * + * Not upgradeable, and holds only what has been deposited for the seasons it knows + * about. + */ +contract SeasonRewardDistributor is Ownable, Pausable { + using SafeERC20 for IERC20; + + /// @dev `keccak256("CRYPTOPETS_MERKLE_REWARD_LEAF_V1")` — must equal the protocol's + /// `MERKLE_REWARD_LEAF_DOMAIN`, or no proof this contract checks will ever match a + /// tree the backend builds. + bytes32 public constant REWARD_LEAF_DOMAIN = keccak256("CRYPTOPETS_MERKLE_REWARD_LEAF_V1"); + /// @dev `keccak256("CRYPTOPETS_MERKLE_NODE_V1")` — the protocol's internal-node tag. + bytes32 public constant MERKLE_NODE_DOMAIN = keccak256("CRYPTOPETS_MERKLE_NODE_V1"); + /// @dev Schema version written into every reward leaf. Bumping it in the protocol + /// without bumping it here silently invalidates every proof. + uint16 public constant REWARD_LEAF_SCHEMA_VERSION = 1; + + struct Season { + bytes32 merkleRoot; + IERC20 token; + /// @dev Most any single wallet may claim. Bounds one bad leaf. + uint256 perWalletCap; + /// @dev Most the whole season may pay out. Bounds one bad root. + uint256 seasonCap; + uint256 totalClaimed; + uint64 claimsOpenAt; + uint64 claimsCloseAt; + } + + mapping(uint32 => Season) private _seasons; + /// @notice nullifier => claimed. `keccak256(seasonId, wallet)`, so one claim per wallet. + mapping(bytes32 => bool) public claimed; + + event SeasonOpened( + uint32 indexed seasonId, + bytes32 indexed merkleRoot, + address indexed token, + uint256 perWalletCap, + uint256 seasonCap, + uint64 claimsOpenAt, + uint64 claimsCloseAt + ); + event RewardClaimed(uint32 indexed seasonId, address indexed wallet, uint256 amount, bytes32 nullifier); + event UnclaimedSwept(uint32 indexed seasonId, address indexed to, uint256 amount); + + error SeasonAlreadyOpen(); + error SeasonUnknown(); + error EmptyRoot(); + error ClaimsNotOpen(); + error ClaimsClosed(); + error ClaimsStillOpen(); + error AlreadyClaimed(); + error BadProof(); + error ExceedsWalletCap(uint256 cap, uint256 amount); + error ExceedsSeasonCap(uint256 remaining, uint256 amount); + error BadClaimWindow(); + + constructor(address initialOwner) Ownable(initialOwner) {} + + /** + * @notice Opens a season with its root and its caps. + * @dev A season can be opened exactly once. Re-posting a root would let the operator + * rewrite entitlements after people had already read them, which is the single + * most valuable thing an attacker who compromised the owner key could do — so it + * is not possible even for the owner. A mistaken root is corrected by opening a + * new season, visibly, not by editing this one. + * + * Caps are per season rather than global constants because the right numbers are a + * product decision that depends on real battle volume, and §L defers them to what + * the rewardless launch shows. The mechanism is fixed here; the numbers are not. + */ + function openSeason( + uint32 seasonId, + bytes32 merkleRoot, + IERC20 token, + uint256 perWalletCap, + uint256 seasonCap, + uint64 claimsOpenAt, + uint64 claimsCloseAt + ) external onlyOwner { + if (_seasons[seasonId].merkleRoot != bytes32(0)) revert SeasonAlreadyOpen(); + if (merkleRoot == bytes32(0)) revert EmptyRoot(); + if (claimsCloseAt <= claimsOpenAt) revert BadClaimWindow(); + + _seasons[seasonId] = Season({ + merkleRoot: merkleRoot, + token: token, + perWalletCap: perWalletCap, + seasonCap: seasonCap, + totalClaimed: 0, + claimsOpenAt: claimsOpenAt, + claimsCloseAt: claimsCloseAt + }); + + emit SeasonOpened(seasonId, merkleRoot, address(token), perWalletCap, seasonCap, claimsOpenAt, claimsCloseAt); + } + + /** + * @notice Claims a season entitlement for `wallet`. + * @dev Permissionless in who *sends* it but not in who is *paid*: the leaf binds the + * beneficiary, so anyone may pay the gas to deliver someone else's reward and + * nobody can redirect it. That makes sponsored claims possible without adding a + * way to steal one. + * + * Effects before interactions, and the nullifier is set before the transfer, so a + * token with a callback cannot re-enter into a second claim. + */ + function claim( + uint32 seasonId, + address wallet, + uint256 amount, + bytes32[] calldata proof + ) external whenNotPaused { + Season storage season = _seasons[seasonId]; + if (season.merkleRoot == bytes32(0)) revert SeasonUnknown(); + if (block.timestamp < season.claimsOpenAt) revert ClaimsNotOpen(); + if (block.timestamp >= season.claimsCloseAt) revert ClaimsClosed(); + + bytes32 nullifier = claimNullifier(seasonId, wallet); + if (claimed[nullifier]) revert AlreadyClaimed(); + + if (amount > season.perWalletCap) revert ExceedsWalletCap(season.perWalletCap, amount); + uint256 remaining = season.seasonCap - season.totalClaimed; + if (amount > remaining) revert ExceedsSeasonCap(remaining, amount); + + bytes32 leaf = rewardLeaf(seasonId, wallet, address(season.token), amount); + if (!_verifyProof(proof, season.merkleRoot, leaf)) revert BadProof(); + + claimed[nullifier] = true; + season.totalClaimed += amount; + + emit RewardClaimed(seasonId, wallet, amount, nullifier); + season.token.safeTransfer(wallet, amount); + } + + /** + * @notice Recovers whatever a closed season never paid out. + * @dev Only after the window shuts, so this cannot be used to pull the funds out from + * under people who are still entitled to them. + */ + function sweepUnclaimed(uint32 seasonId, address to) external onlyOwner { + Season storage season = _seasons[seasonId]; + if (season.merkleRoot == bytes32(0)) revert SeasonUnknown(); + if (block.timestamp < season.claimsCloseAt) revert ClaimsStillOpen(); + + uint256 balance = season.token.balanceOf(address(this)); + emit UnclaimedSwept(seasonId, to, balance); + season.token.safeTransfer(to, balance); + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + function getSeason(uint32 seasonId) external view returns (Season memory) { + return _seasons[seasonId]; + } + + /// @notice Whether this wallet has already claimed this season. + function hasClaimed(uint32 seasonId, address wallet) external view returns (bool) { + return claimed[claimNullifier(seasonId, wallet)]; + } + + /// @notice The one-time claim id for a wallet in a season. + /// @dev Derived rather than supplied, so a claimant cannot choose their own nullifier + /// and mint themselves a second claim. + function claimNullifier(uint32 seasonId, address wallet) public pure returns (bytes32) { + return keccak256(abi.encode(seasonId, wallet)); + } + + /** + * @notice The reward leaf, byte-identical to the protocol's `rewardMerkleLeaf`. + * @dev `abi.encodePacked` over fixed-width fields only, which is why the protocol's + * layout avoids length prefixes: framing it would mean reimplementing the + * canonical writer on chain. `block.chainid` and `address(this)` come from the + * chain rather than the caller, so a proof built for another deployment cannot be + * replayed here — it simply hashes to a leaf that is not in this root. + */ + function rewardLeaf( + uint32 seasonId, + address wallet, + address token, + uint256 amount + ) public view returns (bytes32) { + return rewardLeafFor(block.chainid, address(this), seasonId, wallet, token, amount); + } + + /** + * @notice The same leaf for an arbitrary chain and distributor. + * @dev Pure, so off-chain tooling can cross-check a tree it built against this exact + * encoding, and so the encoding is testable against a fixed vector without + * depending on where a test happens to deploy this contract. The security property + * is unaffected: `claim` always goes through `rewardLeaf`, which supplies + * `block.chainid` and `address(this)` itself and never takes them from a caller. + */ + function rewardLeafFor( + uint256 chainId, + address distributor, + uint32 seasonId, + address wallet, + address token, + uint256 amount + ) public pure returns (bytes32) { + return keccak256( + abi.encodePacked( + REWARD_LEAF_DOMAIN, + REWARD_LEAF_SCHEMA_VERSION, + chainId, + distributor, + uint256(seasonId), + wallet, + token, + amount + ) + ); + } + + /** + * @dev Verifies a proof using the protocol's domain-separated node hash, not + * OpenZeppelin's. `MerkleProof.verify` hashes a sorted pair with no tag, which + * lets an internal node be presented as a leaf; the tag is what makes that + * structurally impossible. + */ + function _verifyProof(bytes32[] calldata proof, bytes32 root, bytes32 leaf) private pure returns (bool) { + bytes32 computed = leaf; + for (uint256 i = 0; i < proof.length; i++) { + computed = _hashNode(computed, proof[i]); + } + return computed == root; + } + + /// @dev `keccak256(NODE_DOMAIN || min(a,b) || max(a,b))`, matching `merkleNode`. + function _hashNode(bytes32 a, bytes32 b) private pure returns (bytes32) { + return a <= b + ? keccak256(abi.encodePacked(MERKLE_NODE_DOMAIN, a, b)) + : keccak256(abi.encodePacked(MERKLE_NODE_DOMAIN, b, a)); + } +} diff --git a/contracts/ethereum/test/SeasonRewardDistributor.test.ts b/contracts/ethereum/test/SeasonRewardDistributor.test.ts new file mode 100644 index 00000000..096f18e5 --- /dev/null +++ b/contracts/ethereum/test/SeasonRewardDistributor.test.ts @@ -0,0 +1,373 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { buildMerkleTree, merkleProof, rewardMerkleLeaf } from "@cryptopets/protocol"; +import { network } from "hardhat"; +import { toFunctionSelector } from "viem"; + +/** + * SeasonRewardDistributor (docs/plan-backend-battle-architecture.md §I). + * + * Two things are being tested. First, that the Solidity leaf encoding is byte-identical to + * the protocol's `rewardMerkleLeaf` — if it is not, no proof the backend ever builds will + * verify, and every other test here would pass against a tree only Solidity can produce. + * Second, that the caps and the nullifier hold even when the posted root is wrong, since a + * root is operator-supplied and treating it as authoritative for value would make a bad + * root an unbounded loss. + */ +async function rejectsWithError(promise: Promise, signature: string): Promise { + const name = signature.slice(0, signature.indexOf("(")); + const selector = toFunctionSelector(signature); + await assert.rejects(promise, (error: unknown) => { + const text = String(error); + assert.ok( + text.includes(name) || text.includes(selector), + `expected a revert with ${signature} (${selector}), got:\n${text}`, + ); + return true; + }); +} + +describe("SeasonRewardDistributor", async function () { + const { viem } = await network.connect(); + + const SEASON = 1; + const PER_WALLET_CAP = 1000n; + const SEASON_CAP = 2500n; + const FAR_FUTURE = 4_000_000_000n; + + async function deploy() { + const [owner, alice, bob, carol] = await viem.getWalletClients(); + const token = await viem.deployContract("MockERC20", ["Reward", "RWD"]); + const distributor = await viem.deployContract("SeasonRewardDistributor", [owner.account.address]); + await token.write.mint([distributor.address, 100_000n]); + const publicClient = await viem.getPublicClient(); + return { distributor, token, owner, alice, bob, carol, publicClient }; + } + + /** Builds the reward tree with the protocol, exactly as the backend would. */ + function buildTree( + distributor: `0x${string}`, + token: `0x${string}`, + chainId: number, + entries: { wallet: `0x${string}`; amount: bigint }[], + ) { + const leaves = entries.map((entry) => + rewardMerkleLeaf({ + chainId, + distributor, + seasonId: SEASON, + wallet: entry.wallet, + token, + amount: entry.amount, + }), + ); + const tree = buildMerkleTree(leaves); + return { + root: tree.root, + proofFor: (index: number) => merkleProof(tree, index), + }; + } + + async function openSeasonWith( + entries: { wallet: `0x${string}`; amount: bigint }[], + overrides: { perWalletCap?: bigint; seasonCap?: bigint; opensAt?: bigint; closesAt?: bigint } = {}, + ) { + const ctx = await deploy(); + const chainId = await ctx.publicClient.getChainId(); + const tree = buildTree(ctx.distributor.address, ctx.token.address, chainId, entries); + await ctx.distributor.write.openSeason([ + SEASON, + tree.root, + ctx.token.address, + overrides.perWalletCap ?? PER_WALLET_CAP, + overrides.seasonCap ?? SEASON_CAP, + overrides.opensAt ?? 0n, + overrides.closesAt ?? FAR_FUTURE, + ]); + return { ...ctx, tree, chainId }; + } + + describe("leaf encoding matches the protocol", () => { + it("computes the same leaf Solidity and TypeScript do", async () => { + // The check everything else depends on. A mismatch here means the backend's + // trees and this contract's proofs describe different sets entirely. + const { distributor, token, alice, publicClient } = await deploy(); + const chainId = await publicClient.getChainId(); + + const onChain = await distributor.read.rewardLeaf([SEASON, alice.account.address, token.address, 777n]); + const offChain = rewardMerkleLeaf({ + chainId, + distributor: distributor.address, + seasonId: SEASON, + wallet: alice.account.address, + token: token.address, + amount: 777n, + }); + + assert.equal(onChain.toLowerCase(), offChain.toLowerCase()); + }); + + it("binds this contract's own address, not one a caller supplies", async () => { + const { distributor, token, alice } = await deploy(); + const elsewhere = await distributor.read.rewardLeafFor([ + 1n, + "0x9999999999999999999999999999999999999999", + SEASON, + alice.account.address, + token.address, + 777n, + ]); + const here = await distributor.read.rewardLeaf([SEASON, alice.account.address, token.address, 777n]); + assert.notEqual(elsewhere, here); + }); + + it("agrees with the protocol on the domain tag", async () => { + const { distributor } = await deploy(); + const { MERKLE_REWARD_LEAF_DOMAIN } = await import("@cryptopets/protocol"); + assert.equal((await distributor.read.REWARD_LEAF_DOMAIN()).toLowerCase(), MERKLE_REWARD_LEAF_DOMAIN); + }); + }); + + describe("claiming", () => { + it("pays a valid claim and marks it claimed", async () => { + const { distributor, token, alice, tree } = await openSeasonWith([ + { wallet: (await viem.getWalletClients())[1]!.account.address, amount: 500n }, + ]); + + await distributor.write.claim([SEASON, alice.account.address, 500n, tree.proofFor(0)]); + + assert.equal(await token.read.balanceOf([alice.account.address]), 500n); + assert.equal(await distributor.read.hasClaimed([SEASON, alice.account.address]), true); + }); + + it("proves membership in a multi-entry tree", async () => { + const [, alice, bob, carol] = await viem.getWalletClients(); + const entries = [ + { wallet: alice.account.address, amount: 100n }, + { wallet: bob.account.address, amount: 200n }, + { wallet: carol.account.address, amount: 300n }, + ]; + const { distributor, token, tree } = await openSeasonWith(entries); + + await distributor.write.claim([SEASON, bob.account.address, 200n, tree.proofFor(1)]); + + assert.equal(await token.read.balanceOf([bob.account.address]), 200n); + }); + + it("lets anyone pay the gas without being able to redirect the reward", async () => { + // The leaf binds the beneficiary, so sponsored claims are possible and theft is + // not. + const [, alice, bob] = await viem.getWalletClients(); + const { distributor, token, tree } = await openSeasonWith([{ wallet: alice.account.address, amount: 400n }]); + + await distributor.write.claim([SEASON, alice.account.address, 400n, tree.proofFor(0)], { + account: bob.account, + }); + + assert.equal(await token.read.balanceOf([alice.account.address]), 400n); + assert.equal(await token.read.balanceOf([bob.account.address]), 0n); + }); + + it("refuses a second claim by the same wallet", async () => { + const [, alice] = await viem.getWalletClients(); + const { distributor, tree } = await openSeasonWith([{ wallet: alice.account.address, amount: 500n }]); + await distributor.write.claim([SEASON, alice.account.address, 500n, tree.proofFor(0)]); + + await rejectsWithError( + distributor.write.claim([SEASON, alice.account.address, 500n, tree.proofFor(0)]), + "AlreadyClaimed()", + ); + }); + + it("refuses a proof for a different amount than the leaf committed to", async () => { + const [, alice] = await viem.getWalletClients(); + const { distributor, tree } = await openSeasonWith([{ wallet: alice.account.address, amount: 500n }]); + + await rejectsWithError( + distributor.write.claim([SEASON, alice.account.address, 501n, tree.proofFor(0)]), + "BadProof()", + ); + }); + + it("refuses a wallet that is not in the tree", async () => { + const [, alice, bob] = await viem.getWalletClients(); + const { distributor, tree } = await openSeasonWith([{ wallet: alice.account.address, amount: 500n }]); + + await rejectsWithError( + distributor.write.claim([SEASON, bob.account.address, 500n, tree.proofFor(0)]), + "BadProof()", + ); + }); + + it("refuses a claim against an unknown season", async () => { + const { distributor, alice, tree } = await openSeasonWith([ + { wallet: (await viem.getWalletClients())[1]!.account.address, amount: 500n }, + ]); + await rejectsWithError( + distributor.write.claim([99, alice.account.address, 500n, tree.proofFor(0)]), + "SeasonUnknown()", + ); + }); + }); + + describe("caps bound the damage a bad root can do", () => { + it("refuses an entitlement above the per-wallet cap", async () => { + // A root is operator-supplied. The cap is what makes a bad one recoverable. + const [, alice] = await viem.getWalletClients(); + const { distributor, tree } = await openSeasonWith([{ wallet: alice.account.address, amount: 5000n }]); + + await rejectsWithError( + distributor.write.claim([SEASON, alice.account.address, 5000n, tree.proofFor(0)]), + "ExceedsWalletCap(uint256,uint256)", + ); + }); + + it("refuses once the season total would be exceeded", async () => { + const [, alice, bob, carol] = await viem.getWalletClients(); + const entries = [ + { wallet: alice.account.address, amount: 1000n }, + { wallet: bob.account.address, amount: 1000n }, + { wallet: carol.account.address, amount: 1000n }, + ]; + const { distributor, tree } = await openSeasonWith(entries, { seasonCap: 2500n }); + + await distributor.write.claim([SEASON, alice.account.address, 1000n, tree.proofFor(0)]); + await distributor.write.claim([SEASON, bob.account.address, 1000n, tree.proofFor(1)]); + + // 2000 paid, 500 left, third wallet wants 1000. + await rejectsWithError( + distributor.write.claim([SEASON, carol.account.address, 1000n, tree.proofFor(2)]), + "ExceedsSeasonCap(uint256,uint256)", + ); + }); + + it("tracks the running total across claims", async () => { + const [, alice, bob] = await viem.getWalletClients(); + const entries = [ + { wallet: alice.account.address, amount: 300n }, + { wallet: bob.account.address, amount: 400n }, + ]; + const { distributor, tree } = await openSeasonWith(entries); + + await distributor.write.claim([SEASON, alice.account.address, 300n, tree.proofFor(0)]); + await distributor.write.claim([SEASON, bob.account.address, 400n, tree.proofFor(1)]); + + assert.equal((await distributor.read.getSeason([SEASON])).totalClaimed, 700n); + }); + }); + + describe("seasons are immutable once opened", () => { + it("refuses to reopen a season", async () => { + // Re-posting a root would let entitlements be rewritten after people read them + // — the most valuable thing a compromised owner key could do. + const { distributor, token, tree } = await openSeasonWith([ + { wallet: (await viem.getWalletClients())[1]!.account.address, amount: 100n }, + ]); + + await rejectsWithError( + distributor.write.openSeason([SEASON, tree.root, token.address, 1n, 1n, 0n, FAR_FUTURE]), + "SeasonAlreadyOpen()", + ); + }); + + it("refuses an empty root", async () => { + const { distributor, token } = await deploy(); + await rejectsWithError( + distributor.write.openSeason([SEASON, `0x${"00".repeat(32)}`, token.address, 1n, 1n, 0n, FAR_FUTURE]), + "EmptyRoot()", + ); + }); + + it("refuses a window that closes before it opens", async () => { + const { distributor, token } = await deploy(); + await rejectsWithError( + distributor.write.openSeason([SEASON, `0x${"11".repeat(32)}`, token.address, 1n, 1n, 100n, 50n]), + "BadClaimWindow()", + ); + }); + + it("only the owner may open a season", async () => { + const { distributor, token, alice } = await deploy(); + await rejectsWithError( + distributor.write.openSeason([SEASON, `0x${"11".repeat(32)}`, token.address, 1n, 1n, 0n, FAR_FUTURE], { + account: alice.account, + }), + "OwnableUnauthorizedAccount(address)", + ); + }); + }); + + describe("claim window", () => { + it("refuses a claim before the window opens", async () => { + const [, alice] = await viem.getWalletClients(); + const { distributor, tree } = await openSeasonWith([{ wallet: alice.account.address, amount: 100n }], { + opensAt: FAR_FUTURE - 1n, + closesAt: FAR_FUTURE, + }); + + await rejectsWithError( + distributor.write.claim([SEASON, alice.account.address, 100n, tree.proofFor(0)]), + "ClaimsNotOpen()", + ); + }); + + it("refuses to sweep while claims are still open", async () => { + // Otherwise the owner could pull funds out from under people still entitled. + const { distributor, owner } = await openSeasonWith([ + { wallet: (await viem.getWalletClients())[1]!.account.address, amount: 100n }, + ]); + + await rejectsWithError( + distributor.write.sweepUnclaimed([SEASON, owner.account.address]), + "ClaimsStillOpen()", + ); + }); + }); + + describe("emergency pause", () => { + it("stops claims while paused", async () => { + const [, alice] = await viem.getWalletClients(); + const { distributor, tree } = await openSeasonWith([{ wallet: alice.account.address, amount: 100n }]); + await distributor.write.pause(); + + await rejectsWithError( + distributor.write.claim([SEASON, alice.account.address, 100n, tree.proofFor(0)]), + "EnforcedPause()", + ); + }); + + it("resumes without losing anything", async () => { + const [, alice] = await viem.getWalletClients(); + const { distributor, token, tree } = await openSeasonWith([{ wallet: alice.account.address, amount: 100n }]); + + await distributor.write.pause(); + await distributor.write.unpause(); + await distributor.write.claim([SEASON, alice.account.address, 100n, tree.proofFor(0)]); + + assert.equal(await token.read.balanceOf([alice.account.address]), 100n); + }); + + it("only the owner may pause", async () => { + const { distributor, alice } = await deploy(); + await rejectsWithError(distributor.write.pause({ account: alice.account }), "OwnableUnauthorizedAccount(address)"); + }); + }); + + describe("nullifiers", () => { + it("differs per wallet and per season", async () => { + const { distributor, alice, bob } = await deploy(); + const a1 = await distributor.read.claimNullifier([SEASON, alice.account.address]); + const b1 = await distributor.read.claimNullifier([SEASON, bob.account.address]); + const a2 = await distributor.read.claimNullifier([2, alice.account.address]); + + assert.notEqual(a1, b1); + assert.notEqual(a1, a2); + }); + + it("reports unclaimed before a claim", async () => { + const { distributor, alice } = await deploy(); + assert.equal(await distributor.read.hasClaimed([SEASON, alice.account.address]), false); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f072cc09..9a10e755 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,6 +157,9 @@ importers: specifier: ^2.2.1 version: 2.2.1 devDependencies: + '@cryptopets/protocol': + specifier: workspace:* + version: link:../../protocol '@nomicfoundation/hardhat-ignition': specifier: ^3.0.3 version: 3.0.3(@nomicfoundation/hardhat-verify@3.0.13(hardhat@3.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(bufferutil@4.0.9)(hardhat@3.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) diff --git a/protocol/src/domain/schemaVersions.ts b/protocol/src/domain/schemaVersions.ts index a1e76607..e6fa8cc7 100644 --- a/protocol/src/domain/schemaVersions.ts +++ b/protocol/src/domain/schemaVersions.ts @@ -25,6 +25,7 @@ export const SCHEMA_VERSIONS = { receipt: 1, combatLog: 1, merkleLeaf: 1, + merkleRewardLeaf: 1, } as const; /** Kinds of object this protocol versions. */ @@ -40,6 +41,7 @@ const SUPPORTED_VERSIONS: Record = { receipt: [1], combatLog: [1], merkleLeaf: [1], + merkleRewardLeaf: [1], }; /** The version this build writes for `kind`. */ diff --git a/protocol/src/encoding/domain.ts b/protocol/src/encoding/domain.ts index 93be256f..71e2f709 100644 --- a/protocol/src/encoding/domain.ts +++ b/protocol/src/encoding/domain.ts @@ -41,6 +41,10 @@ export const DOMAIN_TAGS = { /** Merkle internal node (§I). Distinct from the leaf tag, so a leaf digest can * never be presented as an internal node in a proof. */ MERKLE_NODE: 'CRYPTOPETS_MERKLE_NODE_V1', + /** Merkle leaf over a season reward entitlement (§I). Its own tag rather than an + * extension of `MERKLE_LEAF`, so a receipt leaf can never be presented as a claim + * on a reward, or the reverse. */ + MERKLE_REWARD_LEAF: 'CRYPTOPETS_MERKLE_REWARD_LEAF_V1', } as const; /** One of the protocol's domain tags. */ diff --git a/protocol/src/merkle/index.ts b/protocol/src/merkle/index.ts index 437d4005..b45d5ae1 100644 --- a/protocol/src/merkle/index.ts +++ b/protocol/src/merkle/index.ts @@ -1,3 +1,8 @@ +export { + MERKLE_REWARD_LEAF_DOMAIN, + rewardMerkleLeaf, + type RewardEntitlement, +} from './reward'; export { buildMerkleTree, MERKLE_LEAF_DOMAIN, diff --git a/protocol/src/merkle/reward.ts b/protocol/src/merkle/reward.ts new file mode 100644 index 00000000..82b9fcb7 --- /dev/null +++ b/protocol/src/merkle/reward.ts @@ -0,0 +1,89 @@ +import { currentSchemaVersion } from '../domain/schemaVersions'; +import { concatBytes, type Hex, normalizeAccount, toBytes, uintToBytes, utf8ToBytes } from '../encoding/bytes'; +import { DOMAIN_TAGS } from '../encoding/domain'; +import { keccak256Hex } from '../encoding/hash'; + +/** + * Merkle leaves for season reward entitlements (§I). + * + * A receipt leaf proves a battle happened. A reward leaf says a wallet may withdraw a + * specific amount of a specific asset — a much stronger claim, so it gets its own domain + * tag rather than extending the receipt one. Without that separation a receipt hash could + * be presented where a reward leaf is expected, or the reverse. + * + * §I requires a claim to bind: the chain and the contract that will honour it, the season, + * the beneficiary wallet, the asset, and the amount. All of that is in the leaf, which is + * what makes a proof non-transferable across deployments: the same entitlement computed for + * staging hashes differently from production, so a staging proof simply is not in the + * production tree. + * + * Layout mirrors `merkleLeaf`'s constraint — fixed-width fields only, no length prefixes — + * because a Solidity verifier has to reproduce these bytes with `abi.encodePacked` and + * framing them would mean reimplementing the canonical writer on chain. + * + * The nullifier is deliberately *not* in the leaf. It is derived from the same fields by + * the contract, so a claimant cannot choose it. + */ + +/** `keccak256("CRYPTOPETS_MERKLE_REWARD_LEAF_V1")`. */ +export const MERKLE_REWARD_LEAF_DOMAIN: Hex = keccak256Hex(utf8ToBytes(DOMAIN_TAGS.MERKLE_REWARD_LEAF)); + +export interface RewardEntitlement { + /** EVM chain id of the distributor that will honour this claim. */ + chainId: number; + /** The distributor contract address. Binds the proof to one deployment. */ + distributor: string; + /** Which season this entitlement belongs to. */ + seasonId: number; + /** Wallet permitted to claim it. */ + wallet: string; + /** ERC-20 token address the reward is paid in. */ + token: string; + /** Amount, in the token's own smallest unit. */ + amount: bigint; +} + +const MAX_UINT256 = 1n << 256n; + +/** + * `keccak256(REWARD_LEAF_DOMAIN || schemaVersion || chainId || distributor || seasonId || + * wallet || token || amount)`. + * + * Addresses are lowercased before hashing, matching how every other account in this + * protocol is normalized, so a checksummed and a lowercase spelling of one wallet produce + * the same leaf rather than two entitlements for the same person. + */ +export function rewardMerkleLeaf(entitlement: RewardEntitlement): Hex { + assertUint(entitlement.chainId, 'chainId'); + assertUint(entitlement.seasonId, 'seasonId'); + if (typeof entitlement.amount !== 'bigint' || entitlement.amount < 0n || entitlement.amount >= MAX_UINT256) { + throw new Error(`amount must fit in a uint256, got ${entitlement.amount}`); + } + + return keccak256Hex( + concatBytes([ + toBytes(MERKLE_REWARD_LEAF_DOMAIN), + uintToBytes(currentSchemaVersion('merkleRewardLeaf'), 2), + uintToBytes(entitlement.chainId, 32), + addressBytes(entitlement.distributor, 'distributor'), + uintToBytes(entitlement.seasonId, 32), + addressBytes(entitlement.wallet, 'wallet'), + addressBytes(entitlement.token, 'token'), + uintToBytes(entitlement.amount, 32), + ]), + ); +} + +function addressBytes(value: string, field: string): Uint8Array { + const bytes = toBytes(normalizeAccount(value)); + if (bytes.length !== 20) { + throw new Error(`${field} must be a 20-byte EVM address, got ${bytes.length} bytes`); + } + return bytes; +} + +function assertUint(value: number, field: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${field} must be a non-negative integer, got ${value}`); + } +} diff --git a/protocol/tests/domain/schemaVersions.test.ts b/protocol/tests/domain/schemaVersions.test.ts index efae7c59..e24fdc49 100644 --- a/protocol/tests/domain/schemaVersions.test.ts +++ b/protocol/tests/domain/schemaVersions.test.ts @@ -29,6 +29,7 @@ describe('schema versions', () => { 'receipt', 'combatLog', 'merkleLeaf', + 'merkleRewardLeaf', ]); }); diff --git a/protocol/tests/merkle/reward.test.ts b/protocol/tests/merkle/reward.test.ts new file mode 100644 index 00000000..a98fe7db --- /dev/null +++ b/protocol/tests/merkle/reward.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { MERKLE_LEAF_DOMAIN } from '../../src/merkle/tree'; +import { merkleLeaf } from '../../src/merkle'; +import { MERKLE_REWARD_LEAF_DOMAIN, rewardMerkleLeaf, type RewardEntitlement } from '../../src/merkle/reward'; + +const BASE: RewardEntitlement = { + chainId: 84532, + distributor: '0x1111111111111111111111111111111111111111', + seasonId: 1, + wallet: '0xabcdef0123456789abcdef0123456789abcdef01', + token: '0x2222222222222222222222222222222222222222', + amount: 1_000_000_000_000_000_000n, +}; + +describe('reward leaves', () => { + it('is deterministic', () => { + expect(rewardMerkleLeaf(BASE)).toBe(rewardMerkleLeaf({ ...BASE })); + }); + + it('produces a 32-byte digest', () => { + expect(rewardMerkleLeaf(BASE)).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('uses a different domain from receipt leaves', () => { + // Without separate tags a receipt hash could be presented where a reward leaf is + // expected, turning "this battle happened" into "pay me". + expect(MERKLE_REWARD_LEAF_DOMAIN).not.toBe(MERKLE_LEAF_DOMAIN); + }); + + it('never collides with a receipt leaf', () => { + const receiptShaped = merkleLeaf(`0x${'11'.repeat(32)}`); + expect(rewardMerkleLeaf(BASE)).not.toBe(receiptShaped); + }); +}); + +describe('every field the claim binds changes the leaf', () => { + it.each([ + ['chainId', { chainId: 8453 }], + ['distributor', { distributor: '0x3333333333333333333333333333333333333333' }], + ['seasonId', { seasonId: 2 }], + ['wallet', { wallet: '0x4444444444444444444444444444444444444444' }], + ['token', { token: '0x5555555555555555555555555555555555555555' }], + ['amount', { amount: BASE.amount + 1n }], + ])('%s', (_field, patch) => { + expect(rewardMerkleLeaf({ ...BASE, ...patch })).not.toBe(rewardMerkleLeaf(BASE)); + }); + + it('makes a proof non-transferable between deployments', () => { + // The same entitlement computed for staging hashes differently from production, so + // a staging proof is simply not in the production tree. + const staging = rewardMerkleLeaf({ ...BASE, distributor: '0x9999999999999999999999999999999999999999' }); + expect(staging).not.toBe(rewardMerkleLeaf(BASE)); + }); + + it('makes a proof non-transferable between chains', () => { + expect(rewardMerkleLeaf({ ...BASE, chainId: 1 })).not.toBe(rewardMerkleLeaf(BASE)); + }); + + it('makes a proof non-transferable between seasons', () => { + expect(rewardMerkleLeaf({ ...BASE, seasonId: 99 })).not.toBe(rewardMerkleLeaf(BASE)); + }); +}); + +describe('normalization', () => { + it('treats a checksummed address as the same wallet', () => { + // Otherwise one person could hold two entitlements depending on how their address + // was spelled when the tree was built. + const checksummed = { ...BASE, wallet: BASE.wallet.toUpperCase().replace('0X', '0x') }; + expect(rewardMerkleLeaf(checksummed)).toBe(rewardMerkleLeaf(BASE)); + }); + + it('normalizes the distributor and token the same way', () => { + expect( + rewardMerkleLeaf({ + ...BASE, + distributor: BASE.distributor.toUpperCase().replace('0X', '0x'), + token: BASE.token.toUpperCase().replace('0X', '0x'), + }), + ).toBe(rewardMerkleLeaf(BASE)); + }); +}); + +describe('rejecting malformed entitlements', () => { + it('rejects a non-address wallet', () => { + expect(() => rewardMerkleLeaf({ ...BASE, wallet: '0x1234' })).toThrow(/20-byte EVM address/); + }); + + it('rejects a negative amount', () => { + expect(() => rewardMerkleLeaf({ ...BASE, amount: -1n })).toThrow(/uint256/); + }); + + it('rejects an amount that does not fit in uint256', () => { + expect(() => rewardMerkleLeaf({ ...BASE, amount: 1n << 256n })).toThrow(/uint256/); + }); + + it('accepts a zero amount, which is a real entitlement of nothing', () => { + expect(() => rewardMerkleLeaf({ ...BASE, amount: 0n })).not.toThrow(); + }); + + it.each([-1, 1.5])('rejects %s as a season id', (seasonId) => { + expect(() => rewardMerkleLeaf({ ...BASE, seasonId })).toThrow(/non-negative integer/); + }); +}); From 8f93e2cee65d80ef4299ba35a0eabf576905208f Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 16:56:51 -0400 Subject: [PATCH 42/76] feat(backend): compute reward seasons and serve claim proofs --- backend/API.md | 19 ++ .../migration.sql | 39 ++++ backend/prisma/schema.prisma | 61 ++++++ backend/src/app.ts | 2 + .../features/battle-rewards/entitlements.ts | 108 ++++++++++ backend/src/features/battle-rewards/index.ts | 15 ++ .../battle-rewards/season.controller.ts | 67 ++++++ .../features/battle-rewards/season.service.ts | 204 ++++++++++++++++++ backend/src/routes/rewards.ts | 22 ++ .../battle-rewards/entitlements.test.ts | 139 ++++++++++++ .../battle-rewards/season.service.test.ts | 191 ++++++++++++++++ 11 files changed, 867 insertions(+) create mode 100644 backend/prisma/migrations/20260726140000_add_reward_seasons/migration.sql create mode 100644 backend/src/features/battle-rewards/entitlements.ts create mode 100644 backend/src/features/battle-rewards/index.ts create mode 100644 backend/src/features/battle-rewards/season.controller.ts create mode 100644 backend/src/features/battle-rewards/season.service.ts create mode 100644 backend/src/routes/rewards.ts create mode 100644 backend/tests/features/battle-rewards/entitlements.test.ts create mode 100644 backend/tests/features/battle-rewards/season.service.test.ts diff --git a/backend/API.md b/backend/API.md index bd417ee9..5156f2e4 100644 --- a/backend/API.md +++ b/backend/API.md @@ -324,6 +324,25 @@ Known gap: `GET /api/battle/signing-keys` serves whatever registered via `registerRotatedKey` does not survive a process restart today, so historical-key durability is not yet backed by persistent storage. +### Reward seasons (v2) + +`backend/src/routes/rewards.ts` — the claim-proof half of §I. Unauthenticated, like the +receipt corpus: a claim proof only ever pays the wallet bound inside its leaf, so publishing +one lets a third party sponsor someone's gas rather than take their reward. + +Read-only by design. Building a season and opening it on chain are operator actions with +real money attached; they belong behind an owner key and a deliberate command, not an HTTP +route reachable by anything holding a token. + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/api/rewards/seasons/:seasonId` | Season metadata: the receipt `sequence` range it covers, the distributor and token its leaves bind to, the root, the total, and the rates it was computed from. The range and rates are the reproducibility contract — they say exactly which slice of the corpus to replay to arrive at this root. | +| GET | `/api/rewards/seasons/:seasonId/claim/:wallet` | The wallet's `amount`, its Merkle `proof`, and the `breakdown` behind the number. **404 `no-entitlement`** covers both an unknown season and a wallet that earned nothing — the answer to "what can I claim" is the same either way, and distinguishing them would leak which wallets participated to anyone enumerating. | + +Only **anchored** receipts count toward a season. An unanchored receipt is signed and public, +but its batch root is not yet immutable, so rewarding it would mean paying against a history +that could still be reorganised. + ### Relevant environment variables | Var | Purpose | diff --git a/backend/prisma/migrations/20260726140000_add_reward_seasons/migration.sql b/backend/prisma/migrations/20260726140000_add_reward_seasons/migration.sql new file mode 100644 index 00000000..3b108960 --- /dev/null +++ b/backend/prisma/migrations/20260726140000_add_reward_seasons/migration.sql @@ -0,0 +1,39 @@ +-- CreateTable +CREATE TABLE "reward_season" ( + "season_id" INTEGER NOT NULL, + "chain_id" TEXT NOT NULL, + "deployment_id" TEXT NOT NULL, + "first_sequence" BIGINT NOT NULL, + "last_sequence" BIGINT NOT NULL, + "distributor" TEXT NOT NULL, + "evm_chain_id" INTEGER NOT NULL, + "token" TEXT NOT NULL, + "merkle_root" TEXT NOT NULL, + "total_amount" TEXT NOT NULL, + "params" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "opened_tx_hash" TEXT, + "opened_at" TIMESTAMP(3), + + CONSTRAINT "reward_season_pkey" PRIMARY KEY ("season_id") +); + +-- CreateTable +CREATE TABLE "reward_entitlement" ( + "season_id" INTEGER NOT NULL, + "wallet" TEXT NOT NULL, + "amount" TEXT NOT NULL, + "leaf_index" INTEGER NOT NULL, + "breakdown" JSONB NOT NULL, + + CONSTRAINT "reward_entitlement_pkey" PRIMARY KEY ("season_id","wallet") +); + +-- CreateIndex +CREATE INDEX "reward_season_chain_id_deployment_id_idx" ON "reward_season"("chain_id", "deployment_id"); + +-- CreateIndex +CREATE INDEX "reward_entitlement_season_id_leaf_index_idx" ON "reward_entitlement"("season_id", "leaf_index"); + +-- AddForeignKey +ALTER TABLE "reward_entitlement" ADD CONSTRAINT "reward_entitlement_season_id_fkey" FOREIGN KEY ("season_id") REFERENCES "reward_season"("season_id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c18dda38..ff169da3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -569,3 +569,64 @@ model BattleShadowRun { @@index([status, predictedAt]) @@map("battle_shadow_run") } + +/// A reward season: the set of entitlements computed from anchored receipts, and the +/// Merkle root a claim is proven against (§I). +/// +/// Immutable once its root is set, mirroring `SeasonRewardDistributor.openSeason`: a +/// re-posted root would let entitlements be rewritten after people had read them. A +/// mistaken season is superseded by a new one, visibly. +model RewardSeason { + seasonId Int @id @map("season_id") + chainId String @map("chain_id") + deploymentId String @map("deployment_id") + + /// The receipt sequence range this season covers, inclusive. Recorded so anyone can + /// recompute the entitlements from the public corpus and get the same root. + firstSequence BigInt @map("first_sequence") + lastSequence BigInt @map("last_sequence") + + /// Distributor address and chain the leaves are bound to. A season built for one + /// deployment produces leaves that are not in any other deployment's tree. + distributor String + evmChainId Int @map("evm_chain_id") + token String + + merkleRoot String @map("merkle_root") + /// Sum of every entitlement. Must be within the on-chain season cap, or late claims + /// revert once the pool is drained. + totalAmount String @map("total_amount") + + /// Inputs the amounts were computed from, kept so a season is reproducible rather than + /// merely asserted. + params Json + + createdAt DateTime @default(now()) @map("created_at") + /// Null until the root is posted on chain. + openedTxHash String? @map("opened_tx_hash") + openedAt DateTime? @map("opened_at") + + entitlements RewardEntitlement[] + + @@index([chainId, deploymentId]) + @@map("reward_season") +} + +/// One wallet's entitlement in one season. The Merkle leaf is derived from these fields. +model RewardEntitlement { + seasonId Int @map("season_id") + season RewardSeason @relation(fields: [seasonId], references: [seasonId], onDelete: Cascade) + /// Lowercased, matching how the protocol normalizes accounts before hashing. + wallet String + /// Decimal string: amounts are uint256 on chain and exceed Int range. + amount String + /// Position in the tree, which is what `proof` is generated against. + leafIndex Int @map("leaf_index") + /// How this amount was arrived at — battles counted, wins, losses, caps applied. Not + /// protocol input; it exists so a player asking "why this number" gets an answer. + breakdown Json + + @@id([seasonId, wallet]) + @@index([seasonId, leafIndex]) + @@map("reward_entitlement") +} diff --git a/backend/src/app.ts b/backend/src/app.ts index fa33b5a3..fa22b559 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -10,6 +10,7 @@ import dialogueRoutes from '@routes/dialogue'; import battleRoomRoutes from '@routes/battle-room'; import battleRoutes from '@routes/battle'; import receiptRoutes from '@routes/receipts'; +import rewardRoutes from '@routes/rewards'; const app = express(); @@ -36,6 +37,7 @@ app.use('/api/battle-dialogue', dialogueRoutes); app.use('/api/battle-room', battleRoomRoutes); app.use('/api/battle', battleRoutes); app.use('/api/receipts', receiptRoutes); +app.use('/api/rewards', rewardRoutes); app.get('/', (_req: Request, res: Response) => { res.json({ diff --git a/backend/src/features/battle-rewards/entitlements.ts b/backend/src/features/battle-rewards/entitlements.ts new file mode 100644 index 00000000..f470cf3b --- /dev/null +++ b/backend/src/features/battle-rewards/entitlements.ts @@ -0,0 +1,108 @@ +import { normalizeAccount } from '@cryptopets/protocol'; + +/** + * Turning anchored battles into per-wallet entitlements (§I). + * + * **The rates are inputs, not a formula this file decides.** How much a battle is worth, + * and whether winning pays more than losing, is a game-design question that depends on + * token supply, season length, and what the rewardless launch actually shows — §L defers it + * deliberately, and inventing an answer here would bake a product decision into + * infrastructure. What this file owns is the mechanism: attribute each battle to the + * wallets that fought it, apply the per-battle cap §I requires, and aggregate. + * + * The per-battle cap is applied here rather than on chain because this is where battles are + * visible. The contract only ever sees a wallet and a total, so it can bound a wallet and a + * season but not a single fight; those are complementary limits, not duplicates. + */ + +export interface RewardRates { + /** Paid to the winner of a battle. */ + perWin: bigint; + /** Paid to the loser. Non-zero keeps participation from being punished. */ + perLoss: bigint; + /** + * Most any single battle may contribute to one wallet, before aggregation. + * + * The bound that survives a bug in the rates: with it, an absurd `perWin` inflates one + * battle to the cap rather than to whatever the mistake produced. + */ + perBattleCap: bigint; +} + +/** One battle's contribution, as read from an anchored receipt. */ +export interface BattleContribution { + attackerOwner: string; + defenderOwner: string; + attackerWon: boolean; +} + +export interface WalletEntitlement { + wallet: string; + amount: bigint; + breakdown: { battles: number; wins: number; losses: number; capped: number }; +} + +/** + * Aggregates contributions into one entitlement per wallet, sorted by wallet. + * + * Sorted because the leaf order defines the tree, and a tree that depended on the order rows + * came back from a database would not be reproducible by anyone else from the same corpus. + * Reproducibility is the whole claim: a player should be able to rebuild the root and check + * that their entitlement is what we said it was. + * + * A wallet fighting itself is counted once as a win and once as a loss, which is what + * actually happened — the battle had two sides and this wallet was both. + */ +export function computeEntitlements( + contributions: readonly BattleContribution[], + rates: RewardRates, +): WalletEntitlement[] { + assertRates(rates); + const byWallet = new Map(); + + for (const battle of contributions) { + const winner = battle.attackerWon ? battle.attackerOwner : battle.defenderOwner; + const loser = battle.attackerWon ? battle.defenderOwner : battle.attackerOwner; + credit(byWallet, winner, rates.perWin, rates.perBattleCap, true); + credit(byWallet, loser, rates.perLoss, rates.perBattleCap, false); + } + + return [...byWallet.values()].sort((a, b) => (a.wallet < b.wallet ? -1 : a.wallet > b.wallet ? 1 : 0)); +} + +function credit( + byWallet: Map, + account: string, + reward: bigint, + perBattleCap: bigint, + won: boolean, +): void { + const wallet = normalizeAccount(account); + const capped = reward > perBattleCap; + const applied = capped ? perBattleCap : reward; + + const existing = byWallet.get(wallet) ?? { + wallet, + amount: 0n, + breakdown: { battles: 0, wins: 0, losses: 0, capped: 0 }, + }; + existing.amount += applied; + existing.breakdown.battles += 1; + if (won) existing.breakdown.wins += 1; + else existing.breakdown.losses += 1; + if (capped) existing.breakdown.capped += 1; + byWallet.set(wallet, existing); +} + +function assertRates(rates: RewardRates): void { + for (const [field, value] of Object.entries(rates)) { + if (typeof value !== 'bigint' || value < 0n) { + throw new Error(`reward rate ${field} must be a non-negative bigint, got ${String(value)}`); + } + } +} + +/** Sum of every entitlement, which must fit inside the on-chain season cap. */ +export function totalEntitled(entitlements: readonly WalletEntitlement[]): bigint { + return entitlements.reduce((sum, entitlement) => sum + entitlement.amount, 0n); +} diff --git a/backend/src/features/battle-rewards/index.ts b/backend/src/features/battle-rewards/index.ts new file mode 100644 index 00000000..f657a15e --- /dev/null +++ b/backend/src/features/battle-rewards/index.ts @@ -0,0 +1,15 @@ +export { + computeEntitlements, + totalEntitled, + type BattleContribution, + type RewardRates, + type WalletEntitlement, +} from './entitlements'; +export { getSeason, getSeasonClaim } from './season.controller'; +export { + buildSeason, + getClaimProof, + type BuiltSeason, + type ClaimProof, + type SeasonInputs, +} from './season.service'; diff --git a/backend/src/features/battle-rewards/season.controller.ts b/backend/src/features/battle-rewards/season.controller.ts new file mode 100644 index 00000000..ccebdf2d --- /dev/null +++ b/backend/src/features/battle-rewards/season.controller.ts @@ -0,0 +1,67 @@ +import type { Request, Response } from 'express'; + +import { prisma } from '@config/prisma'; + +import { getClaimProof } from './season.service'; + +/** + * Public reads for reward seasons (§I). + * + * Unauthenticated, like every other read on this path. A claim proof is worthless to anyone + * but the wallet named inside it — the leaf binds the beneficiary, so handing one out lets + * a stranger pay the gas to deliver someone's reward, not take it. Requiring a login would + * only stop people from checking our arithmetic. + */ + +export async function getSeasonClaim(req: Request, res: Response): Promise { + const seasonId = Number(req.params.seasonId); + if (!Number.isSafeInteger(seasonId) || seasonId < 0) { + res.status(422).json({ error: 'invalid-season-id' }); + return; + } + + const proof = await getClaimProof(seasonId, req.params.wallet as string); + if (!proof) { + // One status for two cases on purpose: whether the season is unknown or the wallet + // simply earned nothing, the answer to "what can I claim" is the same, and + // distinguishing them would leak which wallets participated to anyone enumerating. + res.status(404).json({ error: 'no-entitlement', detail: 'no claimable entitlement for this wallet and season' }); + return; + } + res.status(200).json(proof); +} + +/** Season metadata, so a client can see the range and rates a season was computed from. */ +export async function getSeason(req: Request, res: Response): Promise { + const seasonId = Number(req.params.seasonId); + const season = await prisma.rewardSeason.findUnique({ + where: { seasonId }, + select: { + seasonId: true, + chainId: true, + deploymentId: true, + firstSequence: true, + lastSequence: true, + distributor: true, + evmChainId: true, + token: true, + merkleRoot: true, + totalAmount: true, + params: true, + openedTxHash: true, + openedAt: true, + }, + }); + if (!season) { + res.status(404).json({ error: 'season-not-found' }); + return; + } + + res.status(200).json({ + ...season, + // Sequence bounds are the reproducibility contract: they say exactly which slice of + // the public corpus to replay to arrive at this root. + firstSequence: season.firstSequence.toString(), + lastSequence: season.lastSequence.toString(), + }); +} diff --git a/backend/src/features/battle-rewards/season.service.ts b/backend/src/features/battle-rewards/season.service.ts new file mode 100644 index 00000000..f6b4c217 --- /dev/null +++ b/backend/src/features/battle-rewards/season.service.ts @@ -0,0 +1,204 @@ +import { buildMerkleTree, merkleProof, rewardMerkleLeaf, type Hex } from '@cryptopets/protocol'; + +import { prisma } from '@config/prisma'; + +import { + computeEntitlements, + totalEntitled, + type BattleContribution, + type RewardRates, + type WalletEntitlement, +} from './entitlements'; + +/** + * Building and serving a reward season (§I). + * + * A season is computed once, from receipts that are already anchored, and then frozen. Two + * properties matter more than anything else here: + * + * - **Only anchored receipts count.** An unanchored receipt is signed and public but its + * batch root is not yet immutable, so rewarding it would mean paying against a history we + * could still, in principle, reorganise. Waiting for the anchor costs latency and buys the + * guarantee the whole design is for. + * - **A season is reproducible.** The sequence range, the rates, and the entitlement list + * are all stored, so a player can rebuild the root from the public corpus and check that + * their number is the one we published. A season that could only be verified by asking us + * would be the assertion this design exists to avoid. + */ + +export interface SeasonInputs { + seasonId: number; + chainId: string; + deploymentId: string; + /** Inclusive receipt sequence range. */ + firstSequence: bigint; + lastSequence: bigint; + /** Distributor the leaves bind to, and the EVM chain it lives on. */ + distributor: string; + evmChainId: number; + token: string; + rates: RewardRates; +} + +export interface BuiltSeason { + seasonId: number; + merkleRoot: Hex; + totalAmount: bigint; + entitlements: WalletEntitlement[]; +} + +/** + * Computes a season and records it. + * + * Refuses to overwrite an existing season, matching `SeasonRewardDistributor.openSeason`'s + * own refusal: once entitlements are readable, changing them retroactively is exactly the + * move the contract's immutability exists to prevent, and allowing it here would just move + * the problem upstream of the chain. + */ +export async function buildSeason(inputs: SeasonInputs): Promise { + const existing = await prisma.rewardSeason.findUnique({ where: { seasonId: inputs.seasonId } }); + if (existing) { + throw new Error(`season ${inputs.seasonId} already exists; supersede it with a new season rather than editing it`); + } + + const contributions = await loadAnchoredContributions(inputs); + if (contributions.length === 0) { + throw new Error( + `no anchored receipts in sequence range ${inputs.firstSequence}..${inputs.lastSequence}; ` + + 'a season over nothing would publish a root nobody can claim against', + ); + } + + const entitlements = computeEntitlements(contributions, inputs.rates); + const leaves = entitlements.map((entitlement) => + rewardMerkleLeaf({ + chainId: inputs.evmChainId, + distributor: inputs.distributor, + seasonId: inputs.seasonId, + wallet: entitlement.wallet, + token: inputs.token, + amount: entitlement.amount, + }), + ); + const tree = buildMerkleTree(leaves); + const totalAmount = totalEntitled(entitlements); + + await prisma.$transaction(async (tx) => { + await tx.rewardSeason.create({ + data: { + seasonId: inputs.seasonId, + chainId: inputs.chainId, + deploymentId: inputs.deploymentId, + firstSequence: inputs.firstSequence, + lastSequence: inputs.lastSequence, + distributor: inputs.distributor.toLowerCase(), + evmChainId: inputs.evmChainId, + token: inputs.token.toLowerCase(), + merkleRoot: tree.root, + totalAmount: totalAmount.toString(), + // Stored so the season is reproducible rather than merely asserted. + params: { + perWin: inputs.rates.perWin.toString(), + perLoss: inputs.rates.perLoss.toString(), + perBattleCap: inputs.rates.perBattleCap.toString(), + }, + }, + }); + await tx.rewardEntitlement.createMany({ + data: entitlements.map((entitlement, leafIndex) => ({ + seasonId: inputs.seasonId, + wallet: entitlement.wallet, + amount: entitlement.amount.toString(), + leafIndex, + breakdown: entitlement.breakdown, + })), + }); + }); + + return { seasonId: inputs.seasonId, merkleRoot: tree.root, totalAmount, entitlements }; +} + +/** Every anchored battle in the range, in the shape the entitlement maths needs. */ +async function loadAnchoredContributions(inputs: SeasonInputs): Promise { + const receipts = await prisma.battleReceipt.findMany({ + where: { + chainId: inputs.chainId, + deploymentId: inputs.deploymentId, + sequence: { gte: inputs.firstSequence, lte: inputs.lastSequence }, + // Anchored only: `anchoredAt` is set when the batch root is on chain. + batch: { anchoredAt: { not: null } }, + }, + orderBy: { sequence: 'asc' }, + select: { payload: true }, + }); + + return receipts.map((receipt) => { + const payload = receipt.payload as { + snapshot?: { attacker?: { owner?: unknown }; defender?: { owner?: unknown } }; + result?: { attackerWon?: unknown }; + }; + const attackerOwner = payload?.snapshot?.attacker?.owner; + const defenderOwner = payload?.snapshot?.defender?.owner; + const attackerWon = payload?.result?.attackerWon; + if (typeof attackerOwner !== 'string' || typeof defenderOwner !== 'string' || typeof attackerWon !== 'boolean') { + throw new Error('anchored receipt payload is missing the owners or the result'); + } + return { attackerOwner, defenderOwner, attackerWon }; + }); +} + +export interface ClaimProof { + seasonId: number; + wallet: string; + amount: string; + merkleRoot: string; + proof: Hex[]; + breakdown: unknown; +} + +/** + * The proof a wallet needs to claim its season entitlement. + * + * Rebuilt from the stored entitlements rather than persisted per wallet, for the same + * reason batch inclusion proofs are: a stored proof duplicates a tree that is cheap to + * recompute and can drift from it. The rebuilt root is checked against the recorded one, so + * a drift surfaces instead of producing a proof that verifies against nothing on chain. + */ +export async function getClaimProof(seasonId: number, wallet: string): Promise { + const season = await prisma.rewardSeason.findUnique({ + where: { seasonId }, + include: { entitlements: { orderBy: { leafIndex: 'asc' } } }, + }); + if (!season) return null; + + const target = wallet.toLowerCase(); + const index = season.entitlements.findIndex((entitlement) => entitlement.wallet === target); + if (index < 0) return null; + + const leaves = season.entitlements.map((entitlement) => + rewardMerkleLeaf({ + chainId: season.evmChainId, + distributor: season.distributor, + seasonId: season.seasonId, + wallet: entitlement.wallet, + token: season.token, + amount: BigInt(entitlement.amount), + }), + ); + const tree = buildMerkleTree(leaves); + if (tree.root.toLowerCase() !== season.merkleRoot.toLowerCase()) { + throw new Error( + `season ${seasonId} rebuilds to ${tree.root} but was recorded as ${season.merkleRoot}`, + ); + } + + const entitlement = season.entitlements[index]!; + return { + seasonId, + wallet: target, + amount: entitlement.amount, + merkleRoot: season.merkleRoot, + proof: merkleProof(tree, index), + breakdown: entitlement.breakdown, + }; +} diff --git a/backend/src/routes/rewards.ts b/backend/src/routes/rewards.ts new file mode 100644 index 00000000..274df354 --- /dev/null +++ b/backend/src/routes/rewards.ts @@ -0,0 +1,22 @@ +import express, { Router } from 'express'; + +import { getSeason, getSeasonClaim } from '@features/battle-rewards'; + +/** + * Reward seasons and claim proofs (§I). + * + * No `verifyToken` anywhere here, matching the receipt corpus. A claim proof only ever pays + * the wallet bound inside its leaf, so publishing one lets a third party sponsor someone's + * gas rather than take their reward — and the season metadata is what makes the arithmetic + * checkable by anyone at all. + * + * Deliberately read-only. Building a season and opening it on chain are operator actions + * with real money attached; they belong behind an owner key and a deliberate command, not + * an HTTP route that could be reached by anything holding a token. + */ +const router: Router = express.Router(); + +router.get('/seasons/:seasonId', getSeason); +router.get('/seasons/:seasonId/claim/:wallet', getSeasonClaim); + +export default router; diff --git a/backend/tests/features/battle-rewards/entitlements.test.ts b/backend/tests/features/battle-rewards/entitlements.test.ts new file mode 100644 index 00000000..46a600d1 --- /dev/null +++ b/backend/tests/features/battle-rewards/entitlements.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import { computeEntitlements, totalEntitled, type BattleContribution, type RewardRates } from '@features/battle-rewards'; + +const ALICE = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const BOB = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const CAROL = '0xcccccccccccccccccccccccccccccccccccccccc'; + +const RATES: RewardRates = { perWin: 100n, perLoss: 25n, perBattleCap: 1000n }; + +function battle(attackerOwner: string, defenderOwner: string, attackerWon: boolean): BattleContribution { + return { attackerOwner, defenderOwner, attackerWon }; +} + +describe('attributing battles to wallets', () => { + it('pays the winner and the loser their rates', () => { + const result = computeEntitlements([battle(ALICE, BOB, true)], RATES); + + expect(result).toEqual([ + { wallet: ALICE, amount: 100n, breakdown: { battles: 1, wins: 1, losses: 0, capped: 0 } }, + { wallet: BOB, amount: 25n, breakdown: { battles: 1, wins: 0, losses: 1, capped: 0 } }, + ]); + }); + + it('credits the defender when the attacker loses', () => { + const result = computeEntitlements([battle(ALICE, BOB, false)], RATES); + expect(result.find((e) => e.wallet === BOB)?.amount).toBe(100n); + expect(result.find((e) => e.wallet === ALICE)?.amount).toBe(25n); + }); + + it('aggregates across many battles', () => { + const result = computeEntitlements( + [battle(ALICE, BOB, true), battle(ALICE, CAROL, true), battle(BOB, ALICE, true)], + RATES, + ); + + // Alice: two wins, one loss. + expect(result.find((e) => e.wallet === ALICE)).toEqual({ + wallet: ALICE, + amount: 225n, + breakdown: { battles: 3, wins: 2, losses: 1, capped: 0 }, + }); + }); + + it('counts a wallet fighting itself as both sides, which is what happened', () => { + const result = computeEntitlements([battle(ALICE, ALICE, true)], RATES); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + wallet: ALICE, + amount: 125n, + breakdown: { battles: 2, wins: 1, losses: 1, capped: 0 }, + }); + }); + + it('returns nothing for no battles', () => { + expect(computeEntitlements([], RATES)).toEqual([]); + }); +}); + +describe('reproducibility', () => { + it('sorts by wallet, so the tree does not depend on database row order', () => { + // The leaf order defines the root. A player rebuilding it from the public corpus + // has to arrive at the same one. + const forward = computeEntitlements([battle(CAROL, ALICE, true), battle(BOB, ALICE, true)], RATES); + const reversed = computeEntitlements([battle(BOB, ALICE, true), battle(CAROL, ALICE, true)], RATES); + + expect(forward.map((e) => e.wallet)).toEqual([ALICE, BOB, CAROL]); + expect(forward).toEqual(reversed); + }); + + it('normalizes wallet casing, so one person is not two entitlements', () => { + const result = computeEntitlements( + [battle(ALICE.toUpperCase().replace('0X', '0x'), BOB, true), battle(ALICE, CAROL, true)], + RATES, + ); + + expect(result.filter((e) => e.wallet === ALICE)).toHaveLength(1); + expect(result.find((e) => e.wallet === ALICE)?.amount).toBe(200n); + }); +}); + +describe('the per-battle cap', () => { + it('bounds a single battle rather than the mistake that produced it', () => { + // The limit that survives a bug in the rates: an absurd perWin inflates one battle + // to the cap, not to whatever the mistake produced. + const absurd: RewardRates = { perWin: 10n ** 30n, perLoss: 0n, perBattleCap: 500n }; + const result = computeEntitlements([battle(ALICE, BOB, true)], absurd); + + expect(result.find((e) => e.wallet === ALICE)?.amount).toBe(500n); + }); + + it('records how many battles were capped, so the cause is visible', () => { + const absurd: RewardRates = { perWin: 10_000n, perLoss: 0n, perBattleCap: 500n }; + const result = computeEntitlements([battle(ALICE, BOB, true), battle(ALICE, CAROL, true)], absurd); + + expect(result.find((e) => e.wallet === ALICE)?.breakdown.capped).toBe(2); + }); + + it('caps per battle, not per wallet, so honest battles still accumulate', () => { + const rates: RewardRates = { perWin: 100n, perLoss: 0n, perBattleCap: 100n }; + const result = computeEntitlements([battle(ALICE, BOB, true), battle(ALICE, CAROL, true)], rates); + + expect(result.find((e) => e.wallet === ALICE)?.amount).toBe(200n); + expect(result.find((e) => e.wallet === ALICE)?.breakdown.capped).toBe(0); + }); + + it('allows a zero loss rate without counting it as capped', () => { + const result = computeEntitlements([battle(ALICE, BOB, true)], { perWin: 100n, perLoss: 0n, perBattleCap: 100n }); + expect(result.find((e) => e.wallet === BOB)).toEqual({ + wallet: BOB, + amount: 0n, + breakdown: { battles: 1, wins: 0, losses: 1, capped: 0 }, + }); + }); +}); + +describe('rejecting nonsense rates', () => { + it.each([ + ['perWin', { perWin: -1n }], + ['perLoss', { perLoss: -1n }], + ['perBattleCap', { perBattleCap: -1n }], + ])('rejects a negative %s', (_field, patch) => { + expect(() => computeEntitlements([battle(ALICE, BOB, true)], { ...RATES, ...patch })).toThrow( + /non-negative bigint/, + ); + }); +}); + +describe('totalEntitled', () => { + it('sums every entitlement, which is what the season cap must cover', () => { + const result = computeEntitlements([battle(ALICE, BOB, true), battle(BOB, CAROL, true)], RATES); + expect(totalEntitled(result)).toBe(250n); + }); + + it('is zero for an empty season', () => { + expect(totalEntitled([])).toBe(0n); + }); +}); diff --git a/backend/tests/features/battle-rewards/season.service.test.ts b/backend/tests/features/battle-rewards/season.service.test.ts new file mode 100644 index 00000000..8dbad228 --- /dev/null +++ b/backend/tests/features/battle-rewards/season.service.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildMerkleTree, rewardMerkleLeaf, verifyMerkleProof } from '@cryptopets/protocol'; + +vi.mock('@config/prisma', () => { + const tx = { + rewardSeason: { create: vi.fn() }, + rewardEntitlement: { createMany: vi.fn() }, + }; + return { + prisma: { + rewardSeason: { findUnique: vi.fn() }, + battleReceipt: { findMany: vi.fn() }, + $transaction: vi.fn(async (fn: (t: typeof tx) => Promise) => fn(tx)), + __tx: tx, + }, + }; +}); + +import { prisma } from '@config/prisma'; +import { buildSeason, getClaimProof, type SeasonInputs } from '@features/battle-rewards'; + +const ALICE = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const BOB = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const CAROL = '0xcccccccccccccccccccccccccccccccccccccccc'; +const DISTRIBUTOR = '0x1111111111111111111111111111111111111111'; +const TOKEN = '0x2222222222222222222222222222222222222222'; +const tx = (prisma as unknown as { __tx: Record>> }).__tx; + +const INPUTS: SeasonInputs = { + seasonId: 1, + chainId: 'eip155:84532', + deploymentId: 'base-sepolia-live', + firstSequence: 1n, + lastSequence: 100n, + distributor: DISTRIBUTOR, + evmChainId: 84532, + token: TOKEN, + rates: { perWin: 100n, perLoss: 25n, perBattleCap: 1000n }, +}; + +function receiptRow(attackerOwner: string, defenderOwner: string, attackerWon: boolean) { + return { payload: { snapshot: { attacker: { owner: attackerOwner }, defender: { owner: defenderOwner } }, result: { attackerWon } } }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.rewardSeason.findUnique).mockResolvedValue(null); + tx.rewardSeason.create.mockResolvedValue({}); + tx.rewardEntitlement.createMany.mockResolvedValue({ count: 0 }); +}); + +describe('building a season', () => { + it('computes entitlements and a root over the anchored battles', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([ + receiptRow(ALICE, BOB, true), + receiptRow(BOB, CAROL, true), + ] as never); + + const season = await buildSeason(INPUTS); + + expect(season.totalAmount).toBe(250n); + expect(season.entitlements.map((e) => e.wallet)).toEqual([ALICE, BOB, CAROL]); + expect(season.merkleRoot).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('only counts receipts whose batch is anchored', async () => { + // An unanchored receipt is public but its root is not yet immutable, so rewarding + // it means paying against a history that could still be reorganised. + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([receiptRow(ALICE, BOB, true)] as never); + await buildSeason(INPUTS); + + const query = vi.mocked(prisma.battleReceipt.findMany).mock.calls[0]![0] as { + where: { batch: { anchoredAt: { not: null } }; sequence: unknown }; + }; + expect(query.where.batch.anchoredAt).toEqual({ not: null }); + expect(query.where.sequence).toEqual({ gte: 1n, lte: 100n }); + }); + + it('stores the rates, so the season is reproducible rather than asserted', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([receiptRow(ALICE, BOB, true)] as never); + await buildSeason(INPUTS); + + const created = tx.rewardSeason.create.mock.calls[0]![0] as { data: { params: unknown; totalAmount: string } }; + expect(created.data.params).toEqual({ perWin: '100', perLoss: '25', perBattleCap: '1000' }); + expect(created.data.totalAmount).toBe('125'); + }); + + it('writes the season and its entitlements in one transaction', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([receiptRow(ALICE, BOB, true)] as never); + await buildSeason(INPUTS); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + const entitlements = tx.rewardEntitlement.createMany.mock.calls[0]![0] as { data: { leafIndex: number }[] }; + expect(entitlements.data.map((e) => e.leafIndex)).toEqual([0, 1]); + }); + + it('refuses to overwrite an existing season', async () => { + // Changing entitlements after they are readable is exactly what the contract's + // immutability prevents; allowing it here would just move the problem upstream. + vi.mocked(prisma.rewardSeason.findUnique).mockResolvedValue({ seasonId: 1 } as never); + + await expect(buildSeason(INPUTS)).rejects.toThrow(/already exists/); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it('refuses a season with no anchored receipts', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([] as never); + await expect(buildSeason(INPUTS)).rejects.toThrow(/no anchored receipts/); + }); + + it('refuses a receipt payload missing the owners or the result', async () => { + vi.mocked(prisma.battleReceipt.findMany).mockResolvedValue([{ payload: { snapshot: {} } }] as never); + await expect(buildSeason(INPUTS)).rejects.toThrow(/missing the owners or the result/); + }); +}); + +describe('claim proofs', () => { + const entitlements = [ + { wallet: ALICE, amount: '100', leafIndex: 0, breakdown: { battles: 1 } }, + { wallet: BOB, amount: '125', leafIndex: 1, breakdown: { battles: 2 } }, + { wallet: CAROL, amount: '25', leafIndex: 2, breakdown: { battles: 1 } }, + ]; + + function storedSeason(root?: string) { + const leaves = entitlements.map((e) => + rewardMerkleLeaf({ + chainId: 84532, + distributor: DISTRIBUTOR, + seasonId: 1, + wallet: e.wallet, + token: TOKEN, + amount: BigInt(e.amount), + }), + ); + return { + seasonId: 1, + evmChainId: 84532, + distributor: DISTRIBUTOR, + token: TOKEN, + merkleRoot: root ?? buildMerkleTree(leaves).root, + entitlements, + }; + } + + it('returns a proof that verifies against the recorded root', async () => { + const season = storedSeason(); + vi.mocked(prisma.rewardSeason.findUnique).mockResolvedValue(season as never); + + const claim = await getClaimProof(1, BOB); + + expect(claim?.amount).toBe('125'); + const leaf = rewardMerkleLeaf({ + chainId: 84532, + distributor: DISTRIBUTOR, + seasonId: 1, + wallet: BOB, + token: TOKEN, + amount: 125n, + }); + expect(verifyMerkleProof(leaf, claim!.proof, season.merkleRoot as `0x${string}`)).toBe(true); + }); + + it('matches a wallet regardless of casing', async () => { + vi.mocked(prisma.rewardSeason.findUnique).mockResolvedValue(storedSeason() as never); + const claim = await getClaimProof(1, ALICE.toUpperCase().replace('0X', '0x')); + expect(claim?.wallet).toBe(ALICE); + }); + + it('includes the breakdown, so a player asking why gets an answer', async () => { + vi.mocked(prisma.rewardSeason.findUnique).mockResolvedValue(storedSeason() as never); + expect((await getClaimProof(1, BOB))?.breakdown).toEqual({ battles: 2 }); + }); + + it('returns null for a wallet with no entitlement', async () => { + vi.mocked(prisma.rewardSeason.findUnique).mockResolvedValue(storedSeason() as never); + await expect(getClaimProof(1, '0x9999999999999999999999999999999999999999')).resolves.toBeNull(); + }); + + it('returns null for an unknown season', async () => { + vi.mocked(prisma.rewardSeason.findUnique).mockResolvedValue(null); + await expect(getClaimProof(99, ALICE)).resolves.toBeNull(); + }); + + it('refuses when the stored entitlements no longer rebuild the recorded root', async () => { + // Serving a proof against a recomputed root would produce something that verifies + // nowhere on chain, and hide that the season drifted. + vi.mocked(prisma.rewardSeason.findUnique).mockResolvedValue(storedSeason(`0x${'de'.repeat(32)}`) as never); + await expect(getClaimProof(1, ALICE)).rejects.toThrow(/rebuilds to .* but was recorded as/); + }); +}); From 25a2d5c0a95f1d2c62c09e6b239bac06d8a4a8e6 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 17:19:45 -0400 Subject: [PATCH 43/76] feat: enable bounded aggregate season rewards --- AGENTS.md | 9 +- CLAUDE.md | 19 +- backend/env.example | 7 + backend/src/features/battle-rewards/index.ts | 8 + .../features/battle-rewards/season.open.ts | 189 ++++++++++++++++ .../battle-rewards/season.open.test.ts | 204 ++++++++++++++++++ docs/plan-backend-battle-architecture.md | 7 + docs/plan-backend-battle-steps.md | 6 + docs/runbook-backend-battles.md | 53 ++++- docs/threat-model-backend-battles.md | 19 ++ 10 files changed, 512 insertions(+), 9 deletions(-) create mode 100644 backend/src/features/battle-rewards/season.open.ts create mode 100644 backend/tests/features/battle-rewards/season.open.test.ts diff --git a/AGENTS.md b/AGENTS.md index a90cb864..6b0424ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,9 @@ Normative language: `MUST`/`MUST NOT` are mandatory. `SHOULD`/`SHOULD NOT` are e ## Non-Negotiables -- `MUST NOT` edit the golden test vectors in `contracts/test-vectors/{battle,xp}.json` to make a failing test pass. If a vector fails, the Go or Rust port has drifted from the Solidity contract; fix the drifted port, never the vector. -- `MUST` update all four combat-simulator ports together (`contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, `indexer-go/internal/combat/`, `protocol/src/combat/`) when changing combat logic. Never patch one leg alone. The TS port (`protocol/src/combat/`, re-exported from `shared/src/utils/combat` for existing importers) now covers XP and level progression too (`protocol/src/combat/xp.ts`, validated against `contracts/test-vectors/xp.json`), so an XP or decay change is also a four-port change. `indexer-go/internal/combat/xp.go` covers the formula and the decay but not level-up; that gap closes when the Go verifier lands. +- `MUST NOT` change the on-chain combat ports. `contracts/ethereum/src/CombatSim.sol` and Solana's `combat.rs` are **frozen** as of §L Phase 6 (see `docs/plan-backend-battle-architecture.md`). Every battle they ever settled is a permanent on-chain record, and those records have to stay replayable forever, so editing either one silently rewrites history rather than fixing anything. A bug found in them is fixed forward in the live ports below, under a new `rulesetVersion`, never by patching the frozen ones. +- `MUST` keep the two **live** combat ports in step with each other and with the golden vectors: `protocol/src/combat/` (the canonical engine, re-exported from `shared/src/utils/combat` for existing importers) and `indexer-go/internal/combat/` (the independent verifier). Changing one without the other re-breaks the circuit breaker in §F, whose whole value is that the two were written to disagree if either drifts. This covers XP and level progression too (`protocol/src/combat/xp.ts`, validated against `contracts/test-vectors/xp.json`), so an XP or decay change is a both-ports change. `indexer-go/internal/combat/xp.go` still covers the formula and the decay but not level-up. +- `MUST NOT` edit `contracts/test-vectors/{battle,xp}.json` to make a failing test pass — this holds more strongly now, not less. The vectors are the only mechanical link left between the frozen ports and the live ones. A live port that fails them has drifted away from the rules real battles were settled under. - `MUST NOT` assume the `ChainAdapter` interface (`shared/src/hooks/adapters/`) covers more than pet-action mutations and reads. It is a real, shared interface (`useEvmAdapter`/`useSolanaAdapter` both implement it) and every public pet-action hook consumes it chain-blind, but the low-level chain wiring in `frontend/src/chains/{ethereum,solana}/`, the async battle/breed VRF flows, and the combat simulator remain intentionally separate per chain. See CLAUDE.md's cross-chain interfaces section for the exact boundary. - `MUST` match the license of the package being edited when adding new files: `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, `protocol`, and `verifier` are MIT; everything else is PolyForm Noncommercial 1.0.0 (root `LICENSE`). See the table in `README.md`. `protocol` is MIT on purpose (third parties have to be able to replay signed battle receipts), so it `MUST NOT` import from a PolyForm package; a test in that package enforces it. `verifier` is MIT for the same reason and depends on nothing but `protocol`. - `MUST NOT` treat the v1 contract gaps documented in `contracts/plan-contract-upgrade.md` (no battle authorization, the `changeDna` cheat, client-supplied Solana starter-pet DNA) as bugs to silently patch. They are the known baseline the v2 rewrite is designed around. @@ -45,6 +46,6 @@ Full per-package lint/test/build matrix and single-test syntax: see [CLAUDE.md]( Mechanical checks over prose, where they exist: - ESLint per package (`frontend`, `shared`, `website`, `mobile`), plus a custom CSS-naming check in `frontend` (`lint:css`). -- Golden test vectors (`contracts/test-vectors/{battle,xp}.json`), run by Hardhat, Anchor, `indexer-go`'s `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts` (Vitest), are the cross-language enforcement for combat-simulator parity. -- CI coverage workflow (`.github/workflows/coverage.yml`) runs frontend/backend/shared vitest coverage on every PR and posts a combined comment. +- Golden test vectors (`contracts/test-vectors/{battle,xp}.json`), run by Hardhat, Anchor, `indexer-go`'s `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts` (Vitest), are the cross-language enforcement for combat-simulator parity. All four suites keep running after the freeze: the two frozen ports prove the vectors still describe what really settled on chain, and the two live ports prove they have not drifted from it. +- CI coverage workflow (`.github/workflows/coverage.yml`) runs frontend/backend/shared vitest coverage on every PR and posts a combined comment. The verifier workflow (`.github/workflows/verifier.yml`) replays a committed receipt corpus through the standalone verifier, and asserts a tampered corpus is rejected. - There is no repo-wide `agents:check` or module-boundary lint yet. Rely on the per-package commands above and the golden vectors until one exists. diff --git a/CLAUDE.md b/CLAUDE.md index 5a6fc030..3edaa0b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,11 +101,24 @@ Note: `docs/README.md` and `docs/architecture.md` link to `indexer-go/ARCHITECTU What that adapter does NOT unify: `frontend/src/chains/ethereum/` (wagmi client, in-tree ABI JSONs: `combatSimAbi.json`, `gameConfigAbi.json`, `gameLogicAbi.json`, `petCoreAbi.json`) and `frontend/src/chains/solana/` (Anchor wallet/provider/signer) are still separate, low-level wiring with no shared interface between them, each adapter reaches into its own directly. The async battle/breed VRF flows (`useEvmBattleFlow.ts`, `battleWithSwitchboardVrf.ts`) and the combat simulator itself are also not unified; see the next section. Treat the adapter as a thin, uniform shape over pet-action mutations and reads, not a claim that the underlying chain logic is shared. -### Combat simulator is ported four times: golden vectors keep them in sync -The battle/combat logic is implemented independently in `contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, pure Go in `indexer-go/internal/combat/`, and pure TypeScript in `protocol/src/combat/` (the fourth port, added for client-side live battle replay — see `docs/plan-realtime-battle-impl.md` Phase 3; it lived in `shared/src/utils/combat/` until the backend-battle work moved it into the MIT `protocol` package, which now re-exports through that old path). All four are validated against the same golden test vectors at `contracts/test-vectors/{battle,xp}.json`, run by Hardhat, Anchor, `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts` respectively. Hashing uses **legacy Keccak-256** (`keccak256(abi.encodePacked(...))` byte layout); a SHA3-vs-Keccak mismatch fails every vector. The TS port covers XP and level progression as well as fight math: `protocol/src/combat/xp.ts` mirrors `GameLogic._calcXp` / `PetCore.addXp` / `PetCore.recordBattleOpponent` and is validated against `contracts/test-vectors/xp.json`, with the snapshot-shaped wrapper in `protocol/src/progression/` (vectors: `protocol-progression.json`). This became portable once `lastOpponentId`/`streak` were frozen into the battle snapshot; before that the client had no way to know the streak state XP depends on. Note the decay shift **must be clamped to 31** in TS: JavaScript's `>>` masks the shift count to 5 bits, so an unclamped `200 >> 32` returns 200 where Solidity, Rust, and Go all return 0. `indexer-go/internal/combat/xp.go` still covers only the formula and decay, not level-up. -**If a golden vector test fails, the Go, Rust, or TS implementation has drifted from the Solidity contract. Fix the drifted port, never edit the vector.** +### Combat simulator: two frozen ports, two live ones, one set of golden vectors +The battle/combat logic exists in four independent implementations, and as of §L Phase 6 they are **no longer peers**: + +- **Frozen** — `contracts/ethereum/src/CombatSim.sol` and Solana's `combat.rs`. These settled real battles whose results are permanent on-chain records, so they have to keep replaying those records forever. **Do not change them.** A bug found here is fixed forward in the live ports under a new `rulesetVersion`; patching a frozen port silently rewrites history instead of fixing anything. +- **Live** — `protocol/src/combat/` (the canonical engine, re-exported from `shared/src/utils/combat` for existing importers) and `indexer-go/internal/combat/` (the independent verifier). These two `MUST` change together. §F's circuit breaker only has value because they were written to disagree if either drifts, so updating one alone quietly disarms it. + +All four are still validated against the same golden vectors at `contracts/test-vectors/{battle,xp}.json`, run by Hardhat, Anchor, `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts`. Keeping the frozen suites running is the point: they prove the vectors still describe what actually settled on chain, and the live suites prove the current engine has not drifted from it. + +Hashing uses **legacy Keccak-256** (`keccak256(abi.encodePacked(...))` byte layout); a SHA3-vs-Keccak mismatch fails every vector. The TS port covers XP and level progression as well as fight math: `protocol/src/combat/xp.ts` mirrors `GameLogic._calcXp` / `PetCore.addXp` / `PetCore.recordBattleOpponent` and is validated against `contracts/test-vectors/xp.json`, with the snapshot-shaped wrapper in `protocol/src/progression/` (vectors: `protocol-progression.json`). This became portable once `lastOpponentId`/`streak` were frozen into the battle snapshot; before that the client had no way to know the streak state XP depends on. Note the decay shift **must be clamped to 31** in TS: JavaScript's `>>` masks the shift count to 5 bits, so an unclamped `200 >> 32` returns 200 where Solidity, Rust, and Go all return 0. `indexer-go/internal/combat/xp.go` still covers only the formula and decay, not level-up. + +**If a golden vector test fails, a live port has drifted from the rules real battles were settled under. Fix the drifted port, never edit the vector.** A *frozen* port failing a vector means something worse — the vectors or the contract source no longer match what is deployed — and is an incident, not a test failure. + +### Per-battle on-chain settlement is retired (§L Phase 6) +New battles run through the backend-authoritative path (`BATTLE_BACKEND_MODE_ENABLED`): signed intent, committed drand round, signed receipt, Merkle batch anchored by `BattleBatchRegistry`. `GameLogic`'s `requestBattle`/`settleBattle` flow and both settle keepers are **legacy**, kept for one reason — every battle they settled has to stay replayable, and the events and receipts they produced stay served indefinitely (§H). Retiring the path means new battles stop using it, never that old ones become uncheckable. The keepers remain deployable (`KEEPER_ENABLED`, `KEEPER_SOLANA_ENABLED`, both off by default) so an existing deployment can drain in-flight requests rather than stranding them. ### Settle keeper: the second EVM battle/breed/mint transaction isn't the player's +**Legacy for battles** as of §L Phase 6 (above); still current for breed and mint, which have no backend-authoritative equivalent and continue to settle on chain. + `GameLogic`'s async flows (`requestBattle`/`requestCreateFromDNA`/`requestMintStarter` → Pyth Entropy reveals → `settleX`) used to have the frontend send the settle transaction itself, meaning two wallet prompts per action even though settle is permissionless. A backend service, `backend/src/features/settle-keeper/`, now watches Pyth Entropy's `Revealed` event and sends the settle transaction from its own wallet; the frontend only falls back to prompting the player if the keeper hasn't settled within ~45s (keeper outage or not configured — see `useEvmBattleFlow.ts`'s `FALLBACK_SETTLE_DELAY_MS`). Gated by `KEEPER_ENABLED` (off by default); see `backend/env.example` for the full var list. This fixes only the double-signature UX; the related security fix — `requestBattle` snapshotting sim inputs so a level-up between request and settle can't reroll a committed battle — already lives in `GameLogic.sol` itself. See `docs/plan-realtime-battle-ux.md` / `docs/plan-realtime-battle-impl.md` for the full design and threat model. ### Battle fee funds the settle keeper's own gas (EVM) diff --git a/backend/env.example b/backend/env.example index 3ad2eb80..08487c9b 100644 --- a/backend/env.example +++ b/backend/env.example @@ -86,6 +86,13 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # ROSTER_READ_SOURCE=postgres # --- Settle keeper (GameLogic battle/breed/mint settlement) --- +# LEGACY FOR BATTLES as of §L Phase 6: new battles run through the backend-authoritative +# path (BATTLE_BACKEND_MODE_ENABLED). Still current for breed and mint, which have no +# backend equivalent and continue to settle on chain. Keep this enabled on an existing +# deployment long enough to drain in-flight requests — turning it off with requests +# pending strands them until someone settles by hand. Battles it already settled stay +# replayable regardless; retiring the path stops new battles, not old ones. +# # Settles requestBattle/requestCreateFromDNA/requestMintStarter requests from this # wallet once Pyth Entropy reveals, so the player only signs the request transaction # (see docs/plan-realtime-battle-ux.md, docs/plan-realtime-battle-impl.md Phase 2). diff --git a/backend/src/features/battle-rewards/index.ts b/backend/src/features/battle-rewards/index.ts index f657a15e..d0c13ec9 100644 --- a/backend/src/features/battle-rewards/index.ts +++ b/backend/src/features/battle-rewards/index.ts @@ -6,6 +6,14 @@ export { type WalletEntitlement, } from './entitlements'; export { getSeason, getSeasonClaim } from './season.controller'; +export { + boundsViolations, + openSeasonOnChain, + type BoundsCheck, + type OpenSeasonContext, + type OpenSeasonOutcome, + type OpenSeasonRequest, +} from './season.open'; export { buildSeason, getClaimProof, diff --git a/backend/src/features/battle-rewards/season.open.ts b/backend/src/features/battle-rewards/season.open.ts new file mode 100644 index 00000000..b8f23f05 --- /dev/null +++ b/backend/src/features/battle-rewards/season.open.ts @@ -0,0 +1,189 @@ +import type { Account, Address, Chain, PublicClient, Transport, WalletClient } from 'viem'; + +import { prisma } from '@config/prisma'; + +/** + * Opening a season on chain, and refusing to open one that cannot be honoured (§I). + * + * The caps in `SeasonRewardDistributor` are a safety net against a bad root: they bound what + * a mistake can cost. But a net that catches a *correct* season is not protection, it is a + * silent injustice — the contract enforces caps per claim, first come first served, so a + * season whose total exceeds its cap pays early claimants in full and leaves the last ones + * with a revert they did nothing to deserve. Same for a single entitlement above the + * per-wallet cap: that wallet can never claim, and would find out only by trying. + * + * So the bound is checked here, before the root is posted, where the answer is still "do not + * open this season" rather than "some people lost". Three things must hold, and all three + * are refusals rather than warnings: + * + * - every entitlement fits under the per-wallet cap; + * - the season total fits under the season cap; + * - the distributor actually holds enough tokens to pay the total. + * + * The third matters as much as the other two. Caps bound what *may* be claimed; only the + * balance decides what *can* be, and a season opened against an underfunded distributor + * fails in exactly the same first-come-first-served way. + */ + +const ERC20_BALANCE_ABI = [ + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ type: 'uint256' }], + }, +] as const; + +const DISTRIBUTOR_ABI = [ + { + type: 'function', + name: 'openSeason', + stateMutability: 'nonpayable', + inputs: [ + { name: 'seasonId', type: 'uint32' }, + { name: 'merkleRoot', type: 'bytes32' }, + { name: 'token', type: 'address' }, + { name: 'perWalletCap', type: 'uint256' }, + { name: 'seasonCap', type: 'uint256' }, + { name: 'claimsOpenAt', type: 'uint64' }, + { name: 'claimsCloseAt', type: 'uint64' }, + ], + outputs: [], + }, +] as const; + +export interface OpenSeasonContext { + publicClient: PublicClient; + walletClient: WalletClient; + distributor: Address; +} + +export interface OpenSeasonRequest { + seasonId: number; + perWalletCap: bigint; + seasonCap: bigint; + claimsOpenAt: bigint; + claimsCloseAt: bigint; +} + +export type OpenSeasonOutcome = + | { status: 'opened'; txHash: string; totalAmount: bigint } + | { status: 'season-not-built' } + | { status: 'already-opened'; txHash: string | null } + | { status: 'refused'; reasons: string[] }; + +/** + * Validates a built season against its caps and funding, then opens it. + * + * Every check runs before any of them fails the call, so an operator sees every reason at + * once rather than fixing them one transaction at a time. + */ +export async function openSeasonOnChain( + context: OpenSeasonContext, + request: OpenSeasonRequest, +): Promise { + const season = await prisma.rewardSeason.findUnique({ + where: { seasonId: request.seasonId }, + include: { entitlements: true }, + }); + if (!season) { + return { status: 'season-not-built' }; + } + if (season.openedAt) { + return { status: 'already-opened', txHash: season.openedTxHash }; + } + + const totalAmount = BigInt(season.totalAmount); + const balance = (await context.publicClient.readContract({ + address: season.token as Address, + abi: ERC20_BALANCE_ABI, + functionName: 'balanceOf', + args: [context.distributor], + })) as bigint; + + const reasons = boundsViolations({ + entitlements: season.entitlements.map((entitlement) => ({ + wallet: entitlement.wallet, + amount: BigInt(entitlement.amount), + })), + totalAmount, + balance, + perWalletCap: request.perWalletCap, + seasonCap: request.seasonCap, + }); + if (reasons.length > 0) { + return { status: 'refused', reasons }; + } + + const txHash = await context.walletClient.writeContract({ + address: context.distributor, + abi: DISTRIBUTOR_ABI, + functionName: 'openSeason', + args: [ + season.seasonId, + season.merkleRoot as `0x${string}`, + season.token as Address, + request.perWalletCap, + request.seasonCap, + request.claimsOpenAt, + request.claimsCloseAt, + ], + }); + const receipt = await context.publicClient.waitForTransactionReceipt({ hash: txHash }); + if (receipt.status !== 'success') { + return { status: 'refused', reasons: [`openSeason reverted in ${txHash}`] }; + } + + await prisma.rewardSeason.update({ + where: { seasonId: request.seasonId }, + data: { openedTxHash: txHash, openedAt: new Date() }, + }); + + return { status: 'opened', txHash, totalAmount }; +} + +export interface BoundsCheck { + entitlements: readonly { wallet: string; amount: bigint }[]; + totalAmount: bigint; + balance: bigint; + perWalletCap: bigint; + seasonCap: bigint; +} + +/** + * Every reason this season could not be paid in full, or an empty list. + * + * Exported and pure so the bound can be checked without a chain — an operator can ask + * "would this season open" against candidate caps before committing to any of them. + */ +export function boundsViolations(check: BoundsCheck): string[] { + const reasons: string[] = []; + + const overCap = check.entitlements.filter((entitlement) => entitlement.amount > check.perWalletCap); + if (overCap.length > 0) { + // Naming a few rather than all: an operator needs to know it happened and where to + // look, not to scroll past ten thousand addresses. + const sample = overCap.slice(0, 3).map((e) => `${e.wallet}=${e.amount}`).join(', '); + reasons.push( + `${overCap.length} entitlement(s) exceed the per-wallet cap of ${check.perWalletCap} ` + + `and could never be claimed (e.g. ${sample})`, + ); + } + + if (check.totalAmount > check.seasonCap) { + reasons.push( + `season total ${check.totalAmount} exceeds the season cap of ${check.seasonCap}; ` + + 'claims would succeed first-come-first-served until the cap was reached', + ); + } + + if (check.balance < check.totalAmount) { + reasons.push( + `distributor holds ${check.balance} but the season owes ${check.totalAmount}; ` + + 'later claims would revert once the balance ran out', + ); + } + + return reasons; +} diff --git a/backend/tests/features/battle-rewards/season.open.test.ts b/backend/tests/features/battle-rewards/season.open.test.ts new file mode 100644 index 00000000..bf82873a --- /dev/null +++ b/backend/tests/features/battle-rewards/season.open.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@config/prisma', () => ({ + prisma: { rewardSeason: { findUnique: vi.fn(), update: vi.fn() } }, +})); + +import { prisma } from '@config/prisma'; +import { boundsViolations, openSeasonOnChain, type OpenSeasonContext } from '@features/battle-rewards'; + +const ALICE = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const BOB = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const TOKEN = '0x2222222222222222222222222222222222222222'; +const TX_HASH = `0x${'ee'.repeat(32)}`; + +const readContract = vi.fn(); +const writeContract = vi.fn(); +const waitForTransactionReceipt = vi.fn(); + +function context(): OpenSeasonContext { + return { + publicClient: { readContract, waitForTransactionReceipt } as never, + walletClient: { writeContract } as never, + distributor: '0x1111111111111111111111111111111111111111', + }; +} + +const REQUEST = { + seasonId: 1, + perWalletCap: 1000n, + seasonCap: 5000n, + claimsOpenAt: 0n, + claimsCloseAt: 4_000_000_000n, +}; + +function season(overrides: Record = {}) { + return { + seasonId: 1, + merkleRoot: `0x${'11'.repeat(32)}`, + token: TOKEN, + totalAmount: '600', + openedAt: null, + openedTxHash: null, + entitlements: [ + { wallet: ALICE, amount: '400' }, + { wallet: BOB, amount: '200' }, + ], + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.rewardSeason.findUnique).mockResolvedValue(season() as never); + vi.mocked(prisma.rewardSeason.update).mockResolvedValue({} as never); + readContract.mockResolvedValue(10_000n); + writeContract.mockResolvedValue(TX_HASH); + waitForTransactionReceipt.mockResolvedValue({ status: 'success' }); +}); + +describe('opening a season that can be paid in full', () => { + it('opens it and records the transaction', async () => { + const outcome = await openSeasonOnChain(context(), REQUEST); + + expect(outcome).toEqual({ status: 'opened', txHash: TX_HASH, totalAmount: 600n }); + expect(prisma.rewardSeason.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ openedTxHash: TX_HASH }) }), + ); + }); + + it('passes the stored root and the caller-supplied caps', async () => { + await openSeasonOnChain(context(), REQUEST); + + const call = writeContract.mock.calls[0]![0] as { args: unknown[] }; + expect(call.args).toEqual([1, `0x${'11'.repeat(32)}`, TOKEN, 1000n, 5000n, 0n, 4_000_000_000n]); + }); + + it('does nothing for a season that was never built', async () => { + vi.mocked(prisma.rewardSeason.findUnique).mockResolvedValue(null); + await expect(openSeasonOnChain(context(), REQUEST)).resolves.toEqual({ status: 'season-not-built' }); + expect(writeContract).not.toHaveBeenCalled(); + }); + + it('does not reopen a season already opened', async () => { + vi.mocked(prisma.rewardSeason.findUnique).mockResolvedValue( + season({ openedAt: new Date(), openedTxHash: TX_HASH }) as never, + ); + + await expect(openSeasonOnChain(context(), REQUEST)).resolves.toEqual({ + status: 'already-opened', + txHash: TX_HASH, + }); + expect(writeContract).not.toHaveBeenCalled(); + }); +}); + +describe('refusing a season that could not be honoured', () => { + it('refuses when an entitlement exceeds the per-wallet cap', async () => { + // That wallet could never claim, and would find out only by trying. + const outcome = await openSeasonOnChain(context(), { ...REQUEST, perWalletCap: 300n }); + + expect(outcome).toMatchObject({ status: 'refused' }); + expect((outcome as { reasons: string[] }).reasons.join(' ')).toContain('per-wallet cap'); + expect(writeContract).not.toHaveBeenCalled(); + }); + + it('refuses when the total exceeds the season cap', async () => { + // The contract enforces caps per claim, first come first served, so this would pay + // early claimants in full and revert on the last ones. + const outcome = await openSeasonOnChain(context(), { ...REQUEST, seasonCap: 500n }); + + expect((outcome as { reasons: string[] }).reasons.join(' ')).toContain('first-come-first-served'); + expect(writeContract).not.toHaveBeenCalled(); + }); + + it('refuses when the distributor is underfunded', async () => { + // Caps bound what may be claimed; only the balance decides what can be. + readContract.mockResolvedValue(100n); + + const outcome = await openSeasonOnChain(context(), REQUEST); + + expect((outcome as { reasons: string[] }).reasons.join(' ')).toContain('holds 100'); + expect(writeContract).not.toHaveBeenCalled(); + }); + + it('reports every reason at once rather than one transaction at a time', async () => { + readContract.mockResolvedValue(0n); + const outcome = await openSeasonOnChain(context(), { ...REQUEST, perWalletCap: 100n, seasonCap: 100n }); + + expect((outcome as { reasons: string[] }).reasons).toHaveLength(3); + }); + + it('reports a reverted open without marking the season opened', async () => { + waitForTransactionReceipt.mockResolvedValue({ status: 'reverted' }); + + const outcome = await openSeasonOnChain(context(), REQUEST); + + expect(outcome).toMatchObject({ status: 'refused' }); + expect(prisma.rewardSeason.update).not.toHaveBeenCalled(); + }); + + it('checks the balance of the distributor, in the season token', async () => { + await openSeasonOnChain(context(), REQUEST); + + const call = readContract.mock.calls[0]![0] as { address: string; args: string[] }; + expect(call.address).toBe(TOKEN); + expect(call.args[0]).toBe('0x1111111111111111111111111111111111111111'); + }); +}); + +describe('boundsViolations', () => { + const entitlements = [ + { wallet: ALICE, amount: 400n }, + { wallet: BOB, amount: 200n }, + ]; + + it('passes a season that fits under every bound', () => { + expect( + boundsViolations({ + entitlements, + totalAmount: 600n, + balance: 600n, + perWalletCap: 400n, + seasonCap: 600n, + }), + ).toEqual([]); + }); + + it('treats the caps as inclusive, so an exact fit is allowed', () => { + // An entitlement exactly at the cap is claimable; refusing it would be an + // off-by-one that silently disenfranchises the boundary case. + expect( + boundsViolations({ + entitlements: [{ wallet: ALICE, amount: 100n }], + totalAmount: 100n, + balance: 100n, + perWalletCap: 100n, + seasonCap: 100n, + }), + ).toEqual([]); + }); + + it('counts how many entitlements are over the cap, and samples a few', () => { + const many = Array.from({ length: 10 }, (_, i) => ({ wallet: `0x${String(i).repeat(40)}`, amount: 999n })); + const [reason] = boundsViolations({ + entitlements: many, + totalAmount: 9990n, + balance: 9990n, + perWalletCap: 1n, + seasonCap: 10_000n, + }); + + expect(reason).toContain('10 entitlement(s)'); + // Samples rather than listing every address. + expect(reason!.split('=').length - 1).toBe(3); + }); + + it('is pure, so candidate caps can be tested without a chain', () => { + const tooTight = boundsViolations({ entitlements, totalAmount: 600n, balance: 600n, perWalletCap: 1n, seasonCap: 600n }); + const workable = boundsViolations({ entitlements, totalAmount: 600n, balance: 600n, perWalletCap: 400n, seasonCap: 600n }); + + expect(tooTight).not.toEqual([]); + expect(workable).toEqual([]); + }); +}); diff --git a/docs/plan-backend-battle-architecture.md b/docs/plan-backend-battle-architecture.md index fa9cb990..e9467504 100644 --- a/docs/plan-backend-battle-architecture.md +++ b/docs/plan-backend-battle-architecture.md @@ -732,6 +732,13 @@ The four-port combat rule is a `MUST` in `AGENTS.md`, restated in `CLAUDE.md`. I roadmap guidance, so accepting this document does not relax it. Amend both files **at Phase 6**, when the legacy on-chain path actually retires, not at acceptance. +**Done (Step 40).** The amendment split the four ports rather than loosening the rule: `CombatSim.sol` +and `combat.rs` are now `MUST NOT` change — frozen, because the battles they settled are permanent +records that must stay replayable — while `protocol/src/combat/` and `indexer-go/internal/combat/` +are `MUST` change together, since §F's circuit breaker depends on the two being independent. All four +golden-vector suites keep running: the frozen pair proves the vectors still describe what settled on +chain, the live pair proves the current engine has not drifted from it. + Update `docs/plan-future-features-roadmap.md` on acceptance: - Replace team battles that repeatedly call on-chain `CombatSim` with backend orchestration over a diff --git a/docs/plan-backend-battle-steps.md b/docs/plan-backend-battle-steps.md index 1de9ac21..c8fbcf3d 100644 --- a/docs/plan-backend-battle-steps.md +++ b/docs/plan-backend-battle-steps.md @@ -375,6 +375,12 @@ caps, and claim shape depend on what shadow mode and the rewardless launch actua stays a `MUST` until the legacy on-chain path actually retires. Legacy receipts and events stay replayable. - Commit: `docs: retire per-battle settlement and amend the four-port combat rule` +- **Done.** The rule was split rather than relaxed: `CombatSim.sol` and `combat.rs` became + `MUST NOT` change (frozen, so the battles they settled stay replayable), while + `protocol/src/combat/` and `indexer-go/internal/combat/` stay `MUST` change together, since + §F's circuit breaker only works while those two are independent. All four golden-vector + suites keep running. The settle keepers stay deployable so in-flight requests can drain + rather than being stranded, and breed/mint still settle on chain — only battles retired. --- diff --git a/docs/runbook-backend-battles.md b/docs/runbook-backend-battles.md index c313d1b4..b8655b8c 100644 --- a/docs/runbook-backend-battles.md +++ b/docs/runbook-backend-battles.md @@ -164,10 +164,59 @@ outage past `BATTLE_FORFEIT_AFTER_SECONDS` forfeits affected battles, with no pr change. If an outage is ongoing, turn the mode off rather than let battles accumulate toward mass forfeiture. +## Drill 5: opening a reward season + +**Scenario.** A season's battles are anchored and it is time to pay out. This is the only +procedure here that moves real value, so it is the one worth rehearsing on a testnet first. + +**Procedure.** + +1. Confirm the receipts are **anchored**, not merely signed. `buildSeason` only counts + anchored receipts, but check the batch backlog is drained rather than discovering a + short season afterwards. +2. Build the season: sequence range, distributor address, token, and rates. The season is + written with its rates and range so anyone can recompute the root from the public corpus. +3. Fund the distributor with at least the season total. +4. Choose caps and dry-run them with `boundsViolations` before committing to any. It is + pure, so this costs nothing and answers "would this season open" directly. +5. `openSeasonOnChain`. It refuses unless every entitlement fits the per-wallet cap, the + total fits the season cap, and the distributor already holds the full amount — and + reports every failing reason at once rather than one transaction at a time. +6. Spot-check a claim proof against the on-chain root before announcing anything. + +**The rule that matters.** Caps are enforced per claim, first come first served. A season +opened over its cap, or underfunded, pays whoever claims first and reverts on whoever claims +last (threat T20). That is why the bound is checked *before* the root is posted: afterwards, +the season is immutable and the only remedy is a second season making people whole. + +**Sweeping.** `sweepUnclaimed` only works after the claim window closes, so it cannot be +used to pull funds out from under people still entitled to them. + +## Drill 6: a bad season root + +**Scenario.** A season was opened with wrong entitlements. + +1. **Pause the distributor.** This stops claims without touching battles — the registry and + the battle path are separate contracts precisely so one can be halted without the other. +2. Work out who was overpaid before the pause. Claims are events; the nullifier mapping says + who has claimed. +3. **The season cannot be corrected in place.** `openSeason` refuses to reopen a season, and + that refusal is deliberate: a rewritable root would let entitlements change after people + had read them. The remedy is a new season that makes the difference up. +4. Unpause once the replacement is ready, or leave paused and sweep after the window if the + season is being abandoned entirely. + +**Note on the owner key.** The distributor owner can open seasons, pause, and sweep after +close. It cannot rewrite an open season, mint, or take funds mid-window. That is the blast +radius to assume if the key is compromised — and it is why the owner should be a multisig +behind a timelock (§I) rather than a hot wallet. + ## What this mode deliberately does not do -- **No transferable reward.** Receipts carry no `rewardDelta` at any setting. Rewards arrive - with the Merkle batch registry in Group I, behind their own caps and review. +- **No reward inside a receipt.** Receipts carry no `rewardDelta` at any setting, and they + never will — rewards are computed *from* anchored receipts into a separate season tree, so + a receipt stays a statement about a fight rather than a promise of payment. Nothing pays + out until a season is deliberately built, funded, bounded, and opened (Drill 5). - **No rating.** There is no rating or matchmaking-score system in this repo yet. §L Phase 3 lists "off-chain XP, rating, and cooldown"; XP and cooldown exist in `pet_battle_progress`, stored separately from NFT state. Rating is a game-design decision — what it measures, how diff --git a/docs/threat-model-backend-battles.md b/docs/threat-model-backend-battles.md index b57b6a0f..10a3f86c 100644 --- a/docs/threat-model-backend-battles.md +++ b/docs/threat-model-backend-battles.md @@ -229,6 +229,25 @@ Each row: what the attacker does, what stops or bounds it, how we notice, what i - **Residual.** Recovery procedure must reconcile against the published corpus, not just restore. Point-in-time recovery drills have to include that reconciliation (Step 36). +### T20: a season nobody can fully claim + +- **Attack.** Not an attack so much as a self-inflicted one, which is why it is easy to miss. The + reward caps in `SeasonRewardDistributor` are enforced *per claim*, first come first served. Open a + season whose total exceeds its season cap, or whose distributor is underfunded, and early claimants + are paid in full while the last ones get a revert they did nothing to earn. An entitlement above + the per-wallet cap is worse: that wallet can never claim at all, and finds out only by trying. +- **Control.** `boundsViolations` refuses to open a season unless every entitlement fits the + per-wallet cap, the total fits the season cap, and the distributor already holds the full amount + (Step 39). All three are checked before the root is posted, where the answer is still "do not open + this season" rather than "some people lost". The check is pure, so candidate caps can be tested + before any of them are committed to. +- **Detection.** Refusal at open time, with every failing reason reported at once. After opening, + a claim reverting with `ExceedsSeasonCap` means this check was bypassed. +- **Residual.** The caps still protect against a *bad* root, which is their real job; this control + only stops a *correct* season from being opened in an unpayable state. A distributor drained by + some other means after opening reintroduces the same race, so the balance is a precondition rather + than a guarantee. + ## 4. Invariants These are the properties tests and alerts exist to defend. Any one of them breaking is an incident, From ef4c47bde8cf0451db0ee90e7e44899569579dab Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 17:28:05 -0400 Subject: [PATCH 44/76] fix(backend): persist the signing-key registry across restarts --- backend/API.md | 11 +- .../migration.sql | 13 ++ backend/prisma/schema.prisma | 31 ++++ backend/src/features/battle-signer/index.ts | 2 + .../features/battle-signer/signer.registry.ts | 77 +++++++++ .../features/battle-signer/signer.service.ts | 48 +++++- backend/src/server.ts | 8 +- .../battle-signer/signer.registry.test.ts | 155 ++++++++++++++++++ docs/runbook-backend-battles.md | 19 ++- 9 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 backend/prisma/migrations/20260726150000_add_battle_signing_key/migration.sql create mode 100644 backend/src/features/battle-signer/signer.registry.ts create mode 100644 backend/tests/features/battle-signer/signer.registry.test.ts diff --git a/backend/API.md b/backend/API.md index 5156f2e4..6057f8ee 100644 --- a/backend/API.md +++ b/backend/API.md @@ -319,10 +319,13 @@ share a `createdAt` (concurrent battles resolving in the same second) and an order that isn't fully deterministic makes cursor pagination silently skip or repeat rows at a page boundary. -Known gap: `GET /api/battle/signing-keys` serves whatever -`@features/battle-signer`'s in-memory registry currently holds. A rotated key -registered via `registerRotatedKey` does not survive a process restart today, -so historical-key durability is not yet backed by persistent storage. +`GET /api/battle/signing-keys` is backed by the `battle_signing_key` table, so a rotated +key keeps being published across restarts and deploys. The in-memory copy is a cache that +keeps the lookup synchronous on the receipt-verification path; the durable list is reloaded +at startup, and any key that is not the one currently signing is reported as rotated — +so an operator who swapped keys without registering the old one explicitly still gets the +old key published. Rows are never deleted: dropping a key would make its receipts +*unverifiable* rather than invalid, which is a different and worse outcome (§H item 4). ### Reward seasons (v2) diff --git a/backend/prisma/migrations/20260726150000_add_battle_signing_key/migration.sql b/backend/prisma/migrations/20260726150000_add_battle_signing_key/migration.sql new file mode 100644 index 00000000..01fd54a6 --- /dev/null +++ b/backend/prisma/migrations/20260726150000_add_battle_signing_key/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "battle_signing_key" ( + "key_id" TEXT NOT NULL, + "algorithm" TEXT NOT NULL, + "public_key" TEXT NOT NULL, + "address" TEXT NOT NULL, + "not_before" BIGINT NOT NULL, + "not_after" BIGINT, + "compromised" BOOLEAN NOT NULL DEFAULT false, + "first_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "battle_signing_key_pkey" PRIMARY KEY ("key_id") +); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ff169da3..02bdad1c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -630,3 +630,34 @@ model RewardEntitlement { @@index([seasonId, leafIndex]) @@map("reward_entitlement") } + +/// Every signing key this deployment has ever used (§G, §H item 4). +/// +/// Persisted because the registry it feeds is what makes a receipt checkable: a verifier +/// asks `GET /api/battle/signing-keys` for the addresses it should trust, and a key missing +/// from that list makes every receipt it signed *unverifiable* rather than invalid — a +/// different and much worse outcome. Holding the list only in memory meant a restart +/// silently retracted the ability to check historical receipts. +/// +/// Rows are never deleted. `notAfter` marks a key as retired; nothing marks it as gone. +model BattleSigningKey { + keyId String @id @map("key_id") + algorithm String + /// Uncompressed public key, 0x-hex. + publicKey String @map("public_key") + /// EVM address form, lowercased — what a receipt signature recovers to. + address String + /// Unix seconds this key became valid. + notBefore BigInt @map("not_before") + /// Unix seconds it stopped signing, or null while it is the active key. + notAfter BigInt? @map("not_after") + /// True when this key was retired because it was compromised, rather than routinely + /// rotated. Persisted rather than derived: "this key may have signed things we did not + /// authorise" is a fact about history that a restart must not downgrade to an ordinary + /// rotation. See docs/runbook-signing-key-compromise.md. + compromised Boolean @default(false) + + firstSeenAt DateTime @default(now()) @map("first_seen_at") + + @@map("battle_signing_key") +} diff --git a/backend/src/features/battle-signer/index.ts b/backend/src/features/battle-signer/index.ts index 0d8e74f5..a11f6af2 100644 --- a/backend/src/features/battle-signer/index.ts +++ b/backend/src/features/battle-signer/index.ts @@ -4,11 +4,13 @@ export { activeSigningKey, configureSigner, listSigningKeys, + loadPersistedSigningKeys, registerRotatedKey, resetSigner, sign, signerAuditLog, } from './signer.service'; +export { loadSigningKeys, persistSigningKey } from './signer.registry'; export { type EngineAttestation, type SignableKind, diff --git a/backend/src/features/battle-signer/signer.registry.ts b/backend/src/features/battle-signer/signer.registry.ts new file mode 100644 index 00000000..c724cc68 --- /dev/null +++ b/backend/src/features/battle-signer/signer.registry.ts @@ -0,0 +1,77 @@ +import type { Hex } from '@cryptopets/protocol'; + +import { prisma } from '@config/prisma'; + +import type { SigningKeyDescriptor } from './signer.types'; + +/** + * Durable storage for the signing-key registry (§G, §H item 4). + * + * The registry is what makes a receipt checkable by anyone else: a verifier asks which + * addresses to trust, and a key missing from the answer makes every receipt it signed + * *unverifiable* rather than invalid. Keeping the list only in memory meant a process + * restart silently retracted the ability to check historical receipts — the one thing §H + * promises never happens. + * + * **Status is derived, not stored.** Whether a key is active depends on which key this + * process is currently signing with, and a stored `status` column would go stale the moment + * a deployment changed its key without anyone remembering to update the row. Persisting only + * the facts — the key, and when it stopped signing — means a forgotten rotation still leaves + * the old key published, which is the safe direction to fail in. + */ + +/** + * Records a key, or updates the validity window of one already known. + * + * `compromised` is sticky: once a key has been marked compromised it stays so, even if a + * later call passes a milder status. Downgrading that flag would quietly turn "this key may + * have signed things we did not authorise" back into an ordinary rotation, and the whole + * point of the distinction is that the two demand different responses + * (docs/runbook-signing-key-compromise.md). + */ +export async function persistSigningKey(key: SigningKeyDescriptor): Promise { + const compromised = key.status === 'compromised'; + await prisma.battleSigningKey.upsert({ + where: { keyId: key.keyId }, + // `publicKey`/`address` are deliberately not updated: a key id whose material changed + // is a different key wearing the same name, and quietly overwriting it would make + // every receipt signed under the old material unverifiable. + update: { + notAfter: key.notAfter === null ? null : BigInt(key.notAfter), + ...(compromised ? { compromised: true } : {}), + }, + create: { + keyId: key.keyId, + algorithm: key.algorithm, + publicKey: key.publicKey, + address: key.address.toLowerCase(), + notBefore: BigInt(key.notBefore), + notAfter: key.notAfter === null ? null : BigInt(key.notAfter), + compromised, + }, + }); +} + +/** + * Every key this deployment has ever used, as descriptors. + * + * `activeKeyId` decides which key is reported active; everything else is rotated, whatever + * the rows happen to say. An operator who swapped keys without registering the old one + * explicitly still gets the right answer. + * + * A compromised key is never reported active, even if it somehow matches `activeKeyId`. + * Signing with a key known to be compromised is the situation the runbook exists to end, so + * the registry refuses to describe it as the current one. + */ +export async function loadSigningKeys(activeKeyId: string | null): Promise { + const rows = await prisma.battleSigningKey.findMany({ orderBy: { notBefore: 'asc' } }); + return rows.map((row) => ({ + keyId: row.keyId, + algorithm: row.algorithm as SigningKeyDescriptor['algorithm'], + publicKey: row.publicKey as Hex, + address: row.address as Hex, + notBefore: Number(row.notBefore), + notAfter: row.notAfter === null ? null : Number(row.notAfter), + status: row.compromised ? 'compromised' : row.keyId === activeKeyId ? 'active' : 'rotated', + })); +} diff --git a/backend/src/features/battle-signer/signer.service.ts b/backend/src/features/battle-signer/signer.service.ts index 58295470..4be80e73 100644 --- a/backend/src/features/battle-signer/signer.service.ts +++ b/backend/src/features/battle-signer/signer.service.ts @@ -11,6 +11,7 @@ import { env } from '@config/env'; import { createKmsSigner } from './signer.kms'; import { createLocalSigner } from './signer.local'; +import { loadSigningKeys, persistSigningKey } from './signer.registry'; import { type EngineAttestation, type SignerAuditEntry, @@ -73,9 +74,52 @@ export function configureSigner(nowSeconds: number): void { backend = createLocalSigner({ keyId, privateKey, notBefore: nowSeconds }); } -/** Registers a key that is no longer signing but must stay published for verification. */ +/** + * Registers a key that is no longer signing but must stay published for verification. + * + * Writes through to storage as well as memory. The in-memory copy keeps `listSigningKeys` + * synchronous, which matters because it runs on every receipt verification; the persisted + * copy is what survives a restart. + * + * Persistence is best-effort here so a database blip cannot fail a rotation half-way — but + * an unpersisted key is a real gap, so it is logged loudly rather than swallowed. Rerunning + * `registerRotatedKey` is idempotent and is the fix. + */ export function registerRotatedKey(key: SigningKeyDescriptor): void { - rotatedKeys.push(key); + if (!rotatedKeys.some((existing) => existing.keyId === key.keyId)) { + rotatedKeys.push(key); + } + void persistSigningKey(key).catch((error: unknown) => { + console.error( + `[battle-signer] failed to persist rotated key ${key.keyId}: ${(error as Error).message}. ` + + 'It is published by this process but will not survive a restart; re-register it once the database is reachable.', + ); + }); +} + +/** + * Reloads every key this deployment has ever used from storage. + * + * Called at startup, after `configureSigner`, so a restart republishes the keys that signed + * historical receipts instead of quietly forgetting them. Without this the registry was only + * ever as old as the process, and a rotated key disappeared on the next deploy — making its + * 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 stored = await loadSigningKeys(active?.keyId ?? null); + rotatedKeys.length = 0; + for (const key of stored) { + if (key.keyId !== active?.keyId) { + rotatedKeys.push(key); + } + } } /** The key currently signing, or null when the signer is unconfigured. */ diff --git a/backend/src/server.ts b/backend/src/server.ts index d645027b..3d39478a 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -3,7 +3,7 @@ import { env } from '@config/env'; import { prisma } from '@config/prisma'; import app from './app'; import { startBattleStream, stopBattleStream } from '@grpc-client/battleStream'; -import { configureSigner } from '@features/battle-signer'; +import { configureSigner, loadPersistedSigningKeys } from '@features/battle-signer'; import { startSettleKeeper, stopSettleKeeper } from '@features/settle-keeper'; import { startSolanaSettleKeeperFeature, stopSolanaSettleKeeperFeature } from '@features/settle-keeper-solana'; import { type BattleWorkerHandle, startBattleWorker } from '@features/battle-worker'; @@ -49,6 +49,12 @@ const server = app.listen(env.port, '0.0.0.0', () => { // receipts already issued must remain checkable after the mode is switched off. if (env.battle.enabled) { configureSigner(Math.floor(Date.now() / 1000)); + // 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}`), + ); 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/features/battle-signer/signer.registry.test.ts b/backend/tests/features/battle-signer/signer.registry.test.ts new file mode 100644 index 00000000..d6967284 --- /dev/null +++ b/backend/tests/features/battle-signer/signer.registry.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@config/prisma', () => ({ + prisma: { battleSigningKey: { upsert: vi.fn(), findMany: vi.fn() } }, +})); + +import { prisma } from '@config/prisma'; +import { loadSigningKeys, persistSigningKey } from '@features/battle-signer'; +import type { SigningKeyDescriptor } from '@features/battle-signer'; + +function key(overrides: Partial = {}): SigningKeyDescriptor { + return { + keyId: 'battle-signer-2026-07', + algorithm: 'secp256k1', + publicKey: `0x04${'11'.repeat(64)}`, + address: `0x${'ab'.repeat(20)}`, + notBefore: 1_700_000_000, + notAfter: null, + status: 'active', + ...overrides, + }; +} + +function row(overrides: Record = {}) { + return { + keyId: 'battle-signer-2026-07', + algorithm: 'secp256k1', + publicKey: `0x04${'11'.repeat(64)}`, + address: `0x${'ab'.repeat(20)}`, + notBefore: 1_700_000_000n, + notAfter: null, + compromised: false, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.battleSigningKey.upsert).mockResolvedValue({} as never); +}); + +describe('persisting a key', () => { + it('records everything a verifier needs to check a signature', async () => { + await persistSigningKey(key()); + + const call = vi.mocked(prisma.battleSigningKey.upsert).mock.calls[0]![0] as { + create: Record; + }; + expect(call.create).toMatchObject({ + keyId: 'battle-signer-2026-07', + algorithm: 'secp256k1', + address: `0x${'ab'.repeat(20)}`, + notBefore: 1_700_000_000n, + notAfter: null, + }); + }); + + it('lowercases the address, matching what a signature recovers to', async () => { + await persistSigningKey(key({ address: `0x${'AB'.repeat(20)}` as `0x${string}` })); + const call = vi.mocked(prisma.battleSigningKey.upsert).mock.calls[0]![0] as { create: { address: string } }; + expect(call.create.address).toBe(`0x${'ab'.repeat(20)}`); + }); + + it('updates the validity window of a key already known', async () => { + await persistSigningKey(key({ notAfter: 1_760_000_000, status: 'rotated' })); + const call = vi.mocked(prisma.battleSigningKey.upsert).mock.calls[0]![0] as { update: Record }; + expect(call.update.notAfter).toBe(1_760_000_000n); + }); + + it('never overwrites the key material of an existing id', async () => { + // A key id whose material changed is a different key wearing the same name; + // overwriting would make every receipt under the old material unverifiable. + await persistSigningKey(key()); + const call = vi.mocked(prisma.battleSigningKey.upsert).mock.calls[0]![0] as { update: Record }; + expect('publicKey' in call.update).toBe(false); + expect('address' in call.update).toBe(false); + }); +}); + +describe('the compromised flag is sticky', () => { + it('is set when a key is persisted as compromised', async () => { + await persistSigningKey(key({ status: 'compromised', notAfter: 1_760_000_000 })); + const call = vi.mocked(prisma.battleSigningKey.upsert).mock.calls[0]![0] as { + create: { compromised: boolean }; + update: Record; + }; + expect(call.create.compromised).toBe(true); + expect(call.update.compromised).toBe(true); + }); + + it('is never cleared by a later, milder status', async () => { + // Downgrading it would turn "this key may have signed things we did not authorise" + // back into an ordinary rotation, and the two demand different responses. + await persistSigningKey(key({ status: 'rotated', notAfter: 1_760_000_000 })); + const call = vi.mocked(prisma.battleSigningKey.upsert).mock.calls[0]![0] as { update: Record }; + expect('compromised' in call.update).toBe(false); + }); +}); + +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'); + expect(keys[0]?.status).toBe('active'); + }); + + it('reports every other key as rotated, whatever the row says', async () => { + // An operator who swapped keys without registering the old one still gets the right + // answer, and the old key stays published — the safe direction to fail in. + vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([ + row({ keyId: 'old' }), + row({ keyId: 'current' }), + ] as never); + + const keys = await loadSigningKeys('current'); + + expect(keys.find((k) => k.keyId === 'old')?.status).toBe('rotated'); + expect(keys.find((k) => k.keyId === 'current')?.status).toBe('active'); + }); + + it('never reports a compromised key as active', async () => { + // Signing with a key known to be compromised is what the runbook exists to end. + vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([ + row({ keyId: 'burned', compromised: true }), + ] as never); + + const keys = await loadSigningKeys('burned'); + + expect(keys[0]?.status).toBe('compromised'); + }); + + it('keeps retired keys, so their receipts stay verifiable', async () => { + vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([ + row({ keyId: 'old', notAfter: 1_760_000_000n }), + row({ keyId: 'current' }), + ] as never); + + const keys = await loadSigningKeys('current'); + + expect(keys).toHaveLength(2); + expect(keys.find((k) => k.keyId === 'old')?.notAfter).toBe(1_760_000_000); + }); + + it('returns nothing when no key was ever recorded', async () => { + vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([] as never); + await expect(loadSigningKeys(null)).resolves.toEqual([]); + }); + + it('orders by when each key became valid', async () => { + vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([] as never); + await loadSigningKeys(null); + const call = vi.mocked(prisma.battleSigningKey.findMany).mock.calls[0]![0] as { orderBy: unknown }; + expect(call.orderBy).toEqual({ notBefore: 'asc' }); + }); +}); diff --git a/docs/runbook-backend-battles.md b/docs/runbook-backend-battles.md index b8655b8c..903b5a1c 100644 --- a/docs/runbook-backend-battles.md +++ b/docs/runbook-backend-battles.md @@ -118,10 +118,21 @@ signed under it must keep verifying forever, and delisting a key silently invali receipt it ever signed — a retroactive erasure of evidence, which is exactly what §G's validity windows exist to make unnecessary. -**Known gap.** The key registry is in-memory. A key registered via `registerRotatedKey` does -not survive a process restart, so rotation is not yet durable across deploys and the -registry must be re-seeded at startup. This is a real limitation, not a footnote — it is -flagged in `backend/API.md` too, and it needs closing before the mode carries value. +**Durability.** The registry is persisted in `battle_signing_key` and reloaded at startup, so +a rotated key keeps being published across restarts and deploys. Two properties are worth +knowing during an incident: + +- Any key that is not the one currently signing is reported as **rotated**, whatever the row + says. Swapping keys without calling `registerRotatedKey` still leaves the old key + published — the safe direction to fail in. +- **`compromised` is sticky.** Once a key is marked compromised it stays marked, and is never + reported active again, even if configuration points back at it. "This key may have signed + things we did not authorise" is a fact about history that a restart must not quietly + downgrade to a routine rotation. + +If `registerRotatedKey` logs a persistence failure, the key is published by the running +process but will not survive a restart. Re-run it once the database is reachable; it is +idempotent. ## Drill 4: incident From dc1fd0554c09b116ad2d3cd2a967af7f457fa287 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 17:30:56 -0400 Subject: [PATCH 45/76] chore(backend): add safe prisma migrate status and deploy scripts --- backend/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/package.json b/backend/package.json index a1b4312e..e7d4d4c1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,8 @@ "postinstall": "prisma generate", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", + "prisma:status": "prisma migrate status", + "prisma:deploy": "prisma migrate deploy", "prisma:studio": "prisma studio", "db:push": "prisma db push", "test": "vitest run", From 775acdaa19ea28f815f418e67849d77570386801 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 18:42:22 -0400 Subject: [PATCH 46/76] refactor(contracts): remove on-chain battle settlement from EVM contracts --- .../ignition/modules/CryptoPetsV2Live.ts | 21 +- contracts/ethereum/src/GameConfig.sol | 27 +- contracts/ethereum/src/GameLogic.sol | 249 ++----------- contracts/ethereum/src/PetCore.sol | 46 +-- contracts/ethereum/src/TestDeployer.sol | 4 - contracts/ethereum/test/CryptoPetsV2.test.ts | 332 +----------------- 6 files changed, 67 insertions(+), 612 deletions(-) diff --git a/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts b/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts index 21315467..ac966d40 100644 --- a/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts +++ b/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts @@ -3,11 +3,16 @@ import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; /** * Generic v2 UUPS proxy-stack deployment for any EVM network with Pyth Entropy V2. * - * Deploys GameConfig + CombatSim (plain contracts) and the PetCore / - * GameLogic implementations behind ERC1967 proxies, then wires ownership - * and caller authorization. The deploying account ends up as `owner()` of - * every contract (GameConfig, PetCore proxy, GameLogic proxy) and as the - * UUPS upgrade authority for both proxies. + * Deploys GameConfig (a plain contract) and the PetCore / GameLogic + * implementations behind ERC1967 proxies, then wires ownership and caller + * authorization. The deploying account ends up as `owner()` of every contract + * (GameConfig, PetCore proxy, GameLogic proxy) and as the UUPS upgrade + * authority for both proxies. + * + * CombatSim is deliberately **not** deployed (§L Phase 6). Battles are settled by + * the backend, so nothing on chain calls the simulator; the Solidity source stays + * in the repository only as the fourth leg of the golden-vector parity check, which + * deploys it locally per test run. * * The `entropyAddress` parameter (Pyth Entropy V2 contract) must be supplied * via a parameters file, which `scripts/deploy.ts` generates from per-network @@ -18,10 +23,8 @@ const CryptoPetsV2LiveModule = buildModule("CryptoPetsV2Live", (m) => { const deployer = m.getAccount(0); - // ── config & combat sim (plain contracts, not behind proxies) ────────── + // ── config (plain contract, not behind a proxy) ──────────────────────── const config = m.contract("GameConfig", [deployer]); - const combatSim = m.contract("CombatSim", []); - m.call(config, "setCombatSim", [combatSim]); // ── PetCore proxy ───────────────────────────────────────────────────── const petCoreImpl = m.contract("PetCore", [], { id: "PetCoreImpl" }); @@ -56,7 +59,7 @@ const CryptoPetsV2LiveModule = buildModule("CryptoPetsV2Live", (m) => { // ── wire up ────────────────────────────────────────────────────────────── m.call(petCore, "authorizeCaller", [gameLogicProxy]); - return { config, combatSim, petCore, gameLogic }; + return { config, petCore, gameLogic }; }); export default CryptoPetsV2LiveModule; diff --git a/contracts/ethereum/src/GameConfig.sol b/contracts/ethereum/src/GameConfig.sol index d5614f41..0c1d658f 100644 --- a/contracts/ethereum/src/GameConfig.sol +++ b/contracts/ethereum/src/GameConfig.sol @@ -15,13 +15,11 @@ contract GameConfig is Ownable { uint256 public levelUpFee = 0.004 ether; // level-scaled: baseFee * (100 + (L-1)^2) / 100, capped at maxLevel; 1->99 total > train's 1->99 total uint256 public breedFee = 0.0005 ether; uint256 public baseMintFee = 0.001 ether; - // Charged on top of the Entropy fee at requestBattle time, to cover the settle keeper's - // own settleBattle gas (~800k gas, see backend/src/features/settle-keeper/abi.ts's - // SETTLE_GAS_LIMIT) — that second tx is sent from the keeper's wallet, not the player's, - // and was previously fully unfunded. Starting estimate; tune via setBattleFee() against - // observed keeper gas spend. Refunded on cancelBattle (no settle tx is ever sent). - uint256 public battleFee = 0.0005 ether; - uint256 public battleCooldown = 900 seconds; // post-battle lockout (§3.4: 15 min) + // `battleFee` and `battleCooldown` were removed with the on-chain battle path (§L Phase + // 6): the first funded settleBattle's gas and the second fed PetCore.triggerCooldown, + // and neither exists any more. Backend battles carry no per-battle transaction, and their + // cooldown is BATTLE_COOLDOWN_SECONDS in the backend. GameConfig is not behind a proxy, + // so dropping the fields is a redeploy rather than a layout hazard. uint256 public breedCooldownBase = 3600 seconds; // doubles per breedCount, capped at 30 days (§4.1: 1h base) uint256 public newbornCooldown = 43200 seconds; // bred pets: battle lockout after birth (§4.2: 12h) uint256 public maxNameLength = 32; @@ -38,8 +36,6 @@ contract GameConfig is Ownable { uint256 public marriageCooldown = 60 seconds; // lockout after divorce/stale (§5 dev: 60s, prod: 24h) uint256 public proposalTTL = 60 seconds; // marriage proposal expiry (§5 dev: 60s, prod: 7 days) - address public combatSim; - // Species pool sizes per rarity tier (1-5); speciesId = digitPair % poolSizes[rarity] (§3.7). mapping(uint8 => uint8) public poolSizes; @@ -56,7 +52,6 @@ contract GameConfig is Ownable { event LevelUpFeeUpdated(uint256 fee); event BreedFeeUpdated(uint256 fee); event BaseMintFeeUpdated(uint256 fee); - event BattleFeeUpdated(uint256 fee); event BreedCooldownBaseUpdated(uint256 cooldown); event NewbornCooldownUpdated(uint256 cooldown); event GenerationCapUpdated(uint8 cap); @@ -68,7 +63,6 @@ contract GameConfig is Ownable { event StudFeeUpdated(uint256 fee); event MarriageCooldownUpdated(uint256 cooldown); event ProposalTTLUpdated(uint256 ttl); - event CombatSimUpdated(address sim); event PoolSizeUpdated(uint8 tier, uint8 size); event TankHpMultUpdated(uint16 value); event ShellDefMultUpdated(uint16 value); @@ -100,11 +94,6 @@ contract GameConfig is Ownable { emit BaseMintFeeUpdated(fee); } - function setBattleFee(uint256 fee) external onlyOwner { - battleFee = fee; - emit BattleFeeUpdated(fee); - } - function setBreedCooldownBase(uint256 cooldown) external onlyOwner { breedCooldownBase = cooldown; emit BreedCooldownBaseUpdated(cooldown); @@ -162,12 +151,6 @@ contract GameConfig is Ownable { emit ProposalTTLUpdated(ttl); } - function setCombatSim(address sim) external onlyOwner { - require(sim != address(0), "Zero address"); - combatSim = sim; - emit CombatSimUpdated(sim); - } - function setPoolSize(uint8 tier, uint8 size) external onlyOwner { require(tier >= 1 && tier <= 5, "Invalid tier"); poolSizes[tier] = size; diff --git a/contracts/ethereum/src/GameLogic.sol b/contracts/ethereum/src/GameLogic.sol index 813cd65b..81693582 100644 --- a/contracts/ethereum/src/GameLogic.sol +++ b/contracts/ethereum/src/GameLogic.sol @@ -9,24 +9,25 @@ import {IEntropyConsumer} from "@pythnetwork/entropy-sdk-solidity/IEntropyConsum import "./PetCore.sol"; import "./GameConfig.sol"; -import "./CombatSim.sol"; import "./DnaLib.sol"; /** * @title GameLogic - * @dev UUPS-upgradeable contract holding all game mechanics: battle, breed, randomness handling. + * @dev UUPS-upgradeable contract holding the on-chain game mechanics: breeding, starter + * minting, training, and randomness handling. * - * Both battle and breed use the store-then-settle pattern (plan §3.5): - * requestBattle / requestCreateFromDNA → Pyth Entropy request, store pending record - * entropyCallback → store randomness only (provider's default callback gas) - * settleBattle / settleBreed → run sim / mix DNA, apply results - * This makes a failed settle retryable and keeps states symmetric across EVM/Solana. + * Breed and mint use the store-then-settle pattern (plan §3.5): + * requestCreateFromDNA / requestMintStarter → Pyth Entropy request, store pending record + * entropyCallback → store randomness only (provider's default callback gas) + * settleBreed / settleMint → mix DNA, apply results + * This makes a failed settle retryable. * - * requestBattle additionally snapshots both pets' sim inputs (dna/level/rarity/species) - * into the pending record; settleBattle sims from that snapshot, not live state, so a - * mutation between request and settle (e.g. train()) can't change a committed battle's - * outcome (plan-realtime-battle-impl Phase 1). Requests from before this field existed - * fall back to a live read, guarded by the record's `snapshotted` flag. + * **Battles are no longer settled here** (§L Phase 6). They run through the + * backend-authoritative path — signed intent, committed drand round, signed receipt, + * Merkle batch anchored by `BattleBatchRegistry` — so this contract no longer runs the + * combat simulator or mutates pet battle state. `CombatSim.sol` remains in the + * repository as the Solidity leg of the cross-language golden-vector check, but is not + * deployed and has no on-chain caller. */ contract GameLogic is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, IEntropyConsumer { @@ -47,23 +48,6 @@ contract GameLogic is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, address studFeePaidTo // zero for same-owner breeds (plan §4.4) ); - event BattleRandomnessRequested( - address indexed requester, - uint256 indexed requestId, - uint256 petId1, - uint256 petId2 - ); - event BattleResolved( - uint256 indexed requestId, - uint256 indexed winnerId, - uint256 indexed loserId, - uint256 randomness, - bool firstWins, - uint8 rounds, - uint16 winnerHpRemaining, - uint32 xpWin, - uint32 xpLoss - ); event Trained(uint256 indexed petId, uint32 xpGained, uint32 newXp, uint32 newLevel); @@ -93,31 +77,9 @@ contract GameLogic is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, address otherOwner; // recipient of studFee at settle; address(0) for same-owner breeds } - struct PendingBattle { - address requester; - uint256 petId1; - uint256 petId2; - uint256 randomness; - bool fulfilled; - // v1.1 snapshot (plan-realtime-battle-impl Phase 1). Captured in requestBattle; - // settleBattle sims from these, not live state, so a train() between request - // and settle cannot change a committed battle's outcome. - bool snapshotted; // false for requests created before this upgrade - uint256 dna1; - uint256 dna2; - uint32 level1; - uint32 level2; - uint8 rarity1; - uint8 rarity2; - uint16 speciesId1; - uint16 speciesId2; - // Escrowed battleFee (GameConfig.battleFee at request time), refunded on cancelBattle - // since no settleBattle tx — and therefore no keeper gas cost — is ever sent for a - // cancelled request. 0 for requests made before this field existed. - uint256 battleFee; - } - - enum RequestType { None, Breed, Battle, Mint } + /// @dev `Battle` is retired (§L Phase 6) but kept in place: removing it would renumber + /// `Mint`, and this enum's values are persisted in `_requestTypes`. + enum RequestType { None, Breed, RetiredBattle, Mint } // ─── storage (layout append-only) ──────────────────────────────────────── @@ -129,8 +91,12 @@ contract GameLogic is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, mapping(uint256 => uint256) public petBreedRequestId; mapping(uint256 => RequestType) private _requestTypes; - mapping(uint256 => PendingBattle) private _battleRequests; - mapping(uint256 => uint256) public petBattleRequestId; + /// @dev Retired with the on-chain battle path (§L Phase 6). The slots stay declared and + /// unused rather than deleted: this contract sits behind a UUPS proxy, and removing a + /// storage variable shifts every slot after it, which would silently reinterpret live + /// breeding and mint state on the next upgrade. Never reuse these. + mapping(uint256 => uint256) private __retired_battleRequests; + mapping(uint256 => uint256) private __retired_petBattleRequestId; // Stud fees owed to the non-initiating owner of a cross-owner breed (plan §4.4), // released as a pull payment via withdrawStudFees(). @@ -185,172 +151,6 @@ contract GameLogic is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, emit GameConfigUpdated(gameConfig_); } - // ─── battle ─────────────────────────────────────────────────────────────── - - /// @notice Request a battle between two ready, cross-owned pets, paying the battle + Pyth Entropy fees. - /// @dev Caller must own petId1; randomness arrives via entropyCallback, then anyone settles. - /// battleFee funds the settle keeper's settleBattle gas and is refunded on cancelBattle. - /// @param petId1 The caller's pet. - /// @param petId2 The opponent's pet (must have a different owner). - /// @return requestId The Entropy sequence number identifying this pending battle. - function requestBattle( - uint256 petId1, - uint256 petId2 - ) external payable whenNotPaused onlyPetOwner(petId1) returns (uint256 requestId) { - require(petId1 != petId2, "Can't fight self"); - require(petCore.isReady(petId1), "First pet not ready"); - require(petCore.isReady(petId2), "Second pet not ready"); - // onlyPetOwner(petId1) already proved ownerOf(petId1) == msg.sender. - require(msg.sender != petCore.ownerOf(petId2), "Can't fight own pet"); - - // Snapshot both pets now: settleBattle sims from this snapshot, not live state, - // so no mutation between request and settle (e.g. train()) can change a - // committed battle's outcome (plan-realtime-battle-impl Phase 1). - PetCore.Pet memory p1 = petCore.getPet(petId1); - PetCore.Pet memory p2 = petCore.getPet(petId2); - uint32 gap = p1.level > p2.level ? p1.level - p2.level : p2.level - p1.level; - require(gap <= gameConfig.levelBandWidth(), "Level gap too large"); - require( - petBattleRequestId[petId1] == 0 && petBattleRequestId[petId2] == 0, - "Battle pending for pet" - ); - - uint256 entropyFee = entropy.getFeeV2(); - uint256 battleFee = gameConfig.battleFee(); - require(msg.value >= battleFee + entropyFee, "Insufficient battle/entropy fee"); - requestId = _requestRandomness(entropyFee); - - _requestTypes[requestId] = RequestType.Battle; - petBattleRequestId[petId1] = requestId; - petBattleRequestId[petId2] = requestId; - _battleRequests[requestId] = PendingBattle({ - requester: msg.sender, - petId1: petId1, - petId2: petId2, - randomness: 0, - fulfilled: false, - snapshotted: true, - dna1: p1.dna, - dna2: p2.dna, - level1: p1.level, - level2: p2.level, - rarity1: p1.rarity, - rarity2: p2.rarity, - speciesId1: p1.speciesId, - speciesId2: p2.speciesId, - battleFee: battleFee - }); - - emit BattleRandomnessRequested(msg.sender, requestId, petId1, petId2); - } - - /// @notice Run the combat simulation for a fulfilled battle request and apply results. - /// @dev Permissionless: anyone may settle once entropy has been fulfilled (retryable on failure). - /// @param requestId The pending battle's Entropy sequence number. - function settleBattle(uint256 requestId) external whenNotPaused { - PendingBattle memory pending = _battleRequests[requestId]; - require(pending.requester != address(0), "No pending battle"); - require(pending.fulfilled, "Entropy not yet fulfilled"); - - // Sim from the request-time snapshot when available (plan-realtime-battle-impl - // Phase 1) so a mutation between request and settle can't change the outcome. - // Requests created before this upgrade have no snapshot; fall back to live state. - uint256 dna1; uint256 dna2; - uint32 level1; uint32 level2; - uint8 rarity1; uint8 rarity2; - uint16 speciesId1; uint16 speciesId2; - if (pending.snapshotted) { - dna1 = pending.dna1; dna2 = pending.dna2; - level1 = pending.level1; level2 = pending.level2; - rarity1 = pending.rarity1; rarity2 = pending.rarity2; - speciesId1 = pending.speciesId1; speciesId2 = pending.speciesId2; - } else { - PetCore.Pet memory p1 = petCore.getPet(pending.petId1); - PetCore.Pet memory p2 = petCore.getPet(pending.petId2); - dna1 = p1.dna; dna2 = p2.dna; - level1 = p1.level; level2 = p2.level; - rarity1 = p1.rarity; rarity2 = p2.rarity; - speciesId1 = p1.speciesId; speciesId2 = p2.speciesId; - } - - uint8 skill1 = uint8(speciesId1 % 8); - uint8 skill2 = uint8(speciesId2 % 8); - - CombatSim.BattleResult memory sim = CombatSim(gameConfig.combatSim()).simulate( - dna1, rarity1, level1, skill1, - dna2, rarity2, level2, skill2, - pending.randomness, - gameConfig.getSkillConfig() - ); - - uint256 winnerId = sim.firstWins ? pending.petId1 : pending.petId2; - uint256 loserId = sim.firstWins ? pending.petId2 : pending.petId1; - uint32 winnerLevel = sim.firstWins ? level1 : level2; - uint32 loserLevel = sim.firstWins ? level2 : level1; - - petCore.updateBattleStats(winnerId, true); - petCore.updateBattleStats(loserId, false); - - // XP formula (plan §3.4): xpMult = clamp(100 + 10*(oppLevel - myLevel), 0, 200) - // Winner +100 XP × mult / 100. Loser +25 XP × mult / 100. - // Same-opponent decay: consecutive battles vs the same foe halve XP each time. - uint8 winnerDecay = petCore.recordBattleOpponent(winnerId, loserId); - uint8 loserDecay = petCore.recordBattleOpponent(loserId, winnerId); - uint32 xpWin = _calcXp(100, winnerLevel, loserLevel) >> winnerDecay; - uint32 xpLoss = _calcXp(25, loserLevel, winnerLevel) >> loserDecay; - if (xpWin > 0) petCore.addXp(winnerId, xpWin); - if (xpLoss > 0) petCore.addXp(loserId, xpLoss); - - petCore.triggerCooldown(pending.petId1); - petCore.triggerCooldown(pending.petId2); - - petBattleRequestId[pending.petId1] = 0; - petBattleRequestId[pending.petId2] = 0; - delete _battleRequests[requestId]; - - emit BattleResolved( - requestId, - winnerId, loserId, - pending.randomness, - sim.firstWins, sim.rounds, sim.winnerHpRemaining, - xpWin, xpLoss - ); - } - - /// @notice Cancel an unfulfilled battle request, freeing both pets' locks. - /// @dev Callable by the original requester or the contract owner; rejected once fulfilled. - /// @param requestId The pending battle's Entropy sequence number. - function cancelBattle(uint256 requestId) external { - PendingBattle memory pending = _battleRequests[requestId]; - require(pending.requester != address(0), "No pending battle"); - require( - msg.sender == pending.requester || msg.sender == owner(), - "Not requester or owner" - ); - require(!pending.fulfilled, "Already fulfilled - call settleBattle"); - - petBattleRequestId[pending.petId1] = 0; - petBattleRequestId[pending.petId2] = 0; - delete _requestTypes[requestId]; - delete _battleRequests[requestId]; - - // No settle, no keeper gas spent — refund the escrowed battle fee. - if (pending.battleFee > 0) { - (bool ok, ) = payable(pending.requester).call{value: pending.battleFee}(""); - require(ok, "Battle fee refund failed"); - } - } - - /// @notice Read a pending battle's stored request/snapshot data. - /// @dev Lets the frontend read the exact inputs settleBattle will simulate from, so it can - /// run the same deterministic sim client-side once entropy reveals - /// (plan-realtime-battle-impl Phase 4). Returns a zeroed struct for an unknown/settled - /// requestId; callers must check `requester != address(0)`. - /// @param requestId The pending battle's Entropy sequence number. - function getBattleRequest(uint256 requestId) external view returns (PendingBattle memory) { - return _battleRequests[requestId]; - } - // ─── breeding ───────────────────────────────────────────────────────────── /// @notice Request to breed two pets into a named child, paying breed (+ stud) + Entropy fees. @@ -503,10 +303,7 @@ contract GameLogic is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, function _fulfill(uint256 requestId, uint256 word) internal { RequestType type_ = _requestTypes[requestId]; delete _requestTypes[requestId]; - if (type_ == RequestType.Battle) { - _battleRequests[requestId].randomness = word; - _battleRequests[requestId].fulfilled = true; - } else if (type_ == RequestType.Breed) { + if (type_ == RequestType.Breed) { _breedRequests[requestId].randomness = word; _breedRequests[requestId].fulfilled = true; } else if (type_ == RequestType.Mint) { diff --git a/contracts/ethereum/src/PetCore.sol b/contracts/ethereum/src/PetCore.sol index 9cf86e73..74522d4b 100644 --- a/contracts/ethereum/src/PetCore.sol +++ b/contracts/ethereum/src/PetCore.sol @@ -28,6 +28,20 @@ contract PetCore is ERC721PausableUpgradeable, UUPSUpgradeable, OwnableUpgradeab event CallerRevoked(address indexed caller); event GameConfigUpdated(address config); + /// @dev `winCount`, `lossCount`, `lastOpponentId`, and `sameOpponentStreak` are retired + /// with the on-chain battle path (§L Phase 6): nothing writes them any more, because + /// backend battles keep progression in `pet_battle_progress`, keyed separately so + /// on-chain and off-chain state can never be mistaken for each other. + /// + /// They stay declared, in place, because this contract is behind a UUPS proxy and + /// `Pet` lives in a mapping: removing or reordering a member re-lays out every pet + /// already minted. Read them as a frozen record of whatever the last on-chain battle + /// left behind — zero for a pet that never fought on chain. + /// + /// `readyTime` is **not** retired. Breeding still writes it (`setCooldown` applies + /// `newbornCooldown` to offspring), and the backend honours it through the indexed + /// `pet_roster.ready_at`, so a newborn is still barred from fighting. What changed is + /// only that battles no longer *set* it. struct Pet { string name; uint256 dna; @@ -158,10 +172,6 @@ contract PetCore is ERC721PausableUpgradeable, UUPSUpgradeable, OwnableUpgradeab _mint(to, tokenId); } - function triggerCooldown(uint256 petId) external onlyAuthorized entryExists(petId) { - _pets[petId].readyTime = _deadline(gameConfig.battleCooldown()); - } - // Set the breed-specific cooldown (does NOT touch the battle readyTime). function triggerBreedCooldown( uint256 petId, @@ -180,10 +190,6 @@ contract PetCore is ERC721PausableUpgradeable, UUPSUpgradeable, OwnableUpgradeab _pets[petId].trainReadyAt = _deadline(gameConfig.trainCooldown()); } - function updateBattleStats(uint256 petId, bool won) external onlyAuthorized entryExists(petId) { - if (won) { _pets[petId].winCount++; } else { _pets[petId].lossCount++; } - } - function addXp(uint256 petId, uint32 amount) external onlyAuthorized entryExists(petId) { Pet storage p = _pets[petId]; uint32 cap = gameConfig.maxLevel(); @@ -208,25 +214,6 @@ contract PetCore is ERC721PausableUpgradeable, UUPSUpgradeable, OwnableUpgradeab walletMintCount[account]++; } - // Same-opponent decay (plan §3.4): tracks consecutive battles against `opponentId` and - // returns the XP-halving shift to apply (0 = full XP, 1 = half, 2 = quarter, ...). - // Facing a different opponent resets the streak to 0. - function recordBattleOpponent( - uint256 petId, - uint256 opponentId - ) external onlyAuthorized entryExists(petId) returns (uint8 decayShift) { - Pet storage p = _pets[petId]; - if (p.lastOpponentId == opponentId) { - if (p.sameOpponentStreak < type(uint8).max) { - p.sameOpponentStreak++; - } - } else { - p.lastOpponentId = opponentId; - p.sameOpponentStreak = 0; - } - decayShift = p.sameOpponentStreak; - } - // ─── user-facing functions ──────────────────────────────────────────────── // @dev Starter minting lives in GameLogic (requestMintStarter → settleMint): DNA is @@ -495,7 +482,10 @@ contract PetCore is ERC721PausableUpgradeable, UUPSUpgradeable, OwnableUpgradeab name: name_, dna: dna, level: 1, - readyTime: _deadline(gameConfig.battleCooldown()), + // Retired battle field (§L Phase 6). Zeroed at mint rather than seeded from a + // cooldown that no longer exists: nothing on chain reads it, and backend battles + // track readiness in `pet_battle_progress`. + readyTime: 0, winCount: 0, lossCount: 0, rarity: rarity, diff --git a/contracts/ethereum/src/TestDeployer.sol b/contracts/ethereum/src/TestDeployer.sol index c1f87a92..16a483c7 100644 --- a/contracts/ethereum/src/TestDeployer.sol +++ b/contracts/ethereum/src/TestDeployer.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.24; import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import "./GameConfig.sol"; -import "./CombatSim.sol"; import "./PetCore.sol"; import "./GameLogic.sol"; @@ -30,7 +29,6 @@ contract TestDeployer { address public immutable entropy; GameConfig public immutable config; - CombatSim public immutable combatSim; PetCore public immutable petCore; // proxy, typed as impl for convenience GameLogic public immutable gameLogic; // proxy, typed as impl for convenience @@ -40,8 +38,6 @@ contract TestDeployer { // ── config & sim ────────────────────────────────────────────────────── config = new GameConfig(address(this)); - combatSim = new CombatSim(); - config.setCombatSim(address(combatSim)); // ── PetCore proxy — owner starts as address(this) for wiring ──────── PetCore petCoreImpl = new PetCore(); diff --git a/contracts/ethereum/test/CryptoPetsV2.test.ts b/contracts/ethereum/test/CryptoPetsV2.test.ts index 6c51ad10..89d20c59 100644 --- a/contracts/ethereum/test/CryptoPetsV2.test.ts +++ b/contracts/ethereum/test/CryptoPetsV2.test.ts @@ -279,268 +279,6 @@ describe("CryptoPetsV2 (UUPS proxies)", async function () { } }); - it("Should battle via entropy request->store->settle", async function () { - const { petCore, gameLogic, entropy, config } = await deployV2(); - const publicClient = await viem.getPublicClient(); - const testClient = await viem.getTestClient(); - const [, addr1, addr2] = await viem.getWalletClients(); - - await mintStarter(petCore, gameLogic, entropy, config, addr1, "Mine"); - await mintStarter(petCore, gameLogic, entropy, config, addr2, "Theirs"); - - await testClient.increaseTime({ seconds: 901 }); // > battleCooldown (900s) - await testClient.mine({ blocks: 1 }); - - // Step 1: request - const reqHash = await gameLogic.write.requestBattle([1n, 2n], { - account: addr1.account, value: await battleValue(entropy, config) - }); - const reqReceipt = await publicClient.waitForTransactionReceipt({ hash: reqHash }); - const reqLogs = parseEventLogs({ - abi: gameLogic.abi, - logs: reqReceipt.logs, - eventName: "BattleRandomnessRequested", - strict: false - }); - const requestId = reqLogs[0].args.requestId; - assert(requestId != null, "No BattleRandomnessRequested event emitted"); - - // Step 2: entropy fulfills - await revealEntropy(entropy, requestId, addr1.account); - - // Step 3: settle - const settleHash = await gameLogic.write.settleBattle([requestId], { - account: addr1.account - }); - const settleReceipt = await publicClient.waitForTransactionReceipt({ hash: settleHash }); - const settleLogs = parseEventLogs({ - abi: gameLogic.abi, - logs: settleReceipt.logs, - eventName: "BattleResolved", - strict: false - }); - assert.equal(settleLogs.length, 1, "Expected BattleResolved event"); - - // One pet won, one lost - const [, win1, loss1] = await petCore.read.getPetStats([1n]); - const [, win2, loss2] = await petCore.read.getPetStats([2n]); - assert.equal(win1 + loss1 + win2 + loss2, 2); - - // Both pets receive XP: winner +100, loser +25 (level 1 vs 1 → xpMult = 100%) - const pet1 = await petCore.read.getPet([1n]); - const pet2 = await petCore.read.getPet([2n]); - const [winner, loser] = win1 > 0 ? [pet1, pet2] : [pet2, pet1]; - // winner XP = 100 (or levelled up: 100 xp, threshold = 100 * 1 = 100, so exactly levels up) - assert(winner.xp === 0 && winner.level === 2 || winner.xp === 100, "Winner XP/level wrong"); - // loser XP = 25 - assert.equal(loser.xp, 25); - }); - - it("Should snapshot battle sim inputs at requestBattle so leveling a pet before settle can't change the outcome", async function () { - // Threat model (plan-realtime-battle-ux.md / plan-realtime-battle-impl.md Phase 1): - // settleBattle used to read pet stats live, and nothing blocks a pet's level from - // changing while a battle is pending, so a player who saw they'd lose could - // front-run settle with a level-up to flip the result. requestBattle now snapshots - // sim inputs; settleBattle must sim from that frozen snapshot regardless of what - // happens to the pets afterward. - const { petCore, gameLogic, entropy, config } = await deployV2(); - const publicClient = await viem.getPublicClient(); - const testClient = await viem.getTestClient(); - const [, addr1, addr2] = await viem.getWalletClients(); - - await mintStarter(petCore, gameLogic, entropy, config, addr1, "Mine"); - await mintStarter(petCore, gameLogic, entropy, config, addr2, "Theirs"); - - const levelUpFee = await config.read.levelUpFee(); - async function levelUpTo(petId: bigint, account: any, targetLevel: number) { - for (let level = 1; level < targetLevel; level++) { - const diff = BigInt(level - 1); - const fee = levelUpFee * (100n + diff * diff) / 100n; - await petCore.write.levelUp([petId], { account, value: fee }); - } - } - - // Pet 2 is clearly stronger before the battle is even requested — dna is identical - // between the two starters here (same mocked reveal), so this is a pure level gap. - await levelUpTo(2n, addr2.account, 15); - - await testClient.increaseTime({ seconds: 901 }); // > battleCooldown (900s) - await testClient.mine({ blocks: 1 }); - - // Step 1: request — the snapshot is captured here (pet1 level 1, pet2 level 15). - const reqHash = await gameLogic.write.requestBattle([1n, 2n], { - account: addr1.account, value: await battleValue(entropy, config) - }); - const reqReceipt = await publicClient.waitForTransactionReceipt({ hash: reqHash }); - const requestId = parseEventLogs({ - abi: gameLogic.abi, logs: reqReceipt.logs, eventName: "BattleRandomnessRequested", strict: false - })[0].args.requestId; - assert(requestId != null, "No BattleRandomnessRequested event emitted"); - - const snapshotAtRequest = await gameLogic.read.getBattleRequest([requestId]); - assert.equal(snapshotAtRequest.snapshotted, true); - assert.equal(snapshotAtRequest.level1, 1); - assert.equal(snapshotAtRequest.level2, 15); - - // Step 2: front-run — level pet 1 up well past pet 2 *after* requesting, *before* - // settling. Nothing in GameLogic blocks this while a battle is pending; that gap is - // exactly what the snapshot neutralizes. - await levelUpTo(1n, addr1.account, 30); - const pet1AfterLevelUp = await petCore.read.getPet([1n]); - assert.equal(pet1AfterLevelUp.level, 30, "pet1 should have leveled up well past pet2 before settle"); - - // Step 3: entropy fulfills. Snapshot fields must still read the pre-level-up values. - await revealEntropy(entropy, requestId, addr1.account); - const snapshotAtSettle = await gameLogic.read.getBattleRequest([requestId]); - assert.equal(snapshotAtSettle.level1, 1, "snapshot level must stay frozen at 1 despite the level-up"); - assert.equal(snapshotAtSettle.fulfilled, true); - - // Independently compute what settleBattle must produce from the frozen snapshot, - // and — as a sanity check that this test actually exercises the race — what it - // would have produced had it (wrongly) read pet 1's post-level-up live state instead. - const combatSim = await viem.getContractAt("CombatSim", await config.read.combatSim()); - const sc = await config.read.getSkillConfig(); - const skill1 = Number(snapshotAtSettle.speciesId1) % 8; - const skill2 = Number(snapshotAtSettle.speciesId2) % 8; - - const expectedFromSnapshot = await combatSim.read.simulate([ - snapshotAtSettle.dna1, Number(snapshotAtSettle.rarity1), Number(snapshotAtSettle.level1), skill1, - snapshotAtSettle.dna2, Number(snapshotAtSettle.rarity2), Number(snapshotAtSettle.level2), skill2, - snapshotAtSettle.randomness, sc, - ]); - const expectedFromLiveState = await combatSim.read.simulate([ - snapshotAtSettle.dna1, Number(snapshotAtSettle.rarity1), Number(pet1AfterLevelUp.level), skill1, - snapshotAtSettle.dna2, Number(snapshotAtSettle.rarity2), Number(snapshotAtSettle.level2), skill2, - snapshotAtSettle.randomness, sc, - ]); - assert.equal( - expectedFromSnapshot.firstWins, false, - "test setup sanity check: pet2 should win at the snapshot's levels (1 vs 15)" - ); - assert.equal( - expectedFromLiveState.firstWins, true, - "test setup sanity check: pet1 should win if settle wrongly used live state (30 vs 15)" - ); - - // Step 4: settle. The on-chain result must match the frozen snapshot (pet2 wins), - // not the post-level-up live state (which would have pet1 win instead). - const settleHash = await gameLogic.write.settleBattle([requestId], { account: addr1.account }); - const settleReceipt = await publicClient.waitForTransactionReceipt({ hash: settleHash }); - const resolved = parseEventLogs({ - abi: gameLogic.abi, logs: settleReceipt.logs, eventName: "BattleResolved", strict: false - })[0].args; - - assert.equal(resolved.firstWins, false, "on-chain result must honor the frozen snapshot, not the live-leveled pet1"); - assert.equal(resolved.winnerId, 2n); - assert.equal(resolved.loserId, 1n); - assert.equal(resolved.rounds, expectedFromSnapshot.rounds); - assert.equal(resolved.winnerHpRemaining, expectedFromSnapshot.winnerHpRemaining); - // XP uses the snapshot levels too (winner=15, loser=1): xpMult clamps the winner's - // share to 0 (beating a foe 14 levels below is worth nothing, anti-seal-clubbing), - // while the loser's share doubles to its 200%-clamp for "punching up" (plan §3.4). - // First-ever meeting between these two pets, so same-opponent decay is 0 either way. - assert.equal(resolved.xpWin, 0, "winner (level-15 snapshot) beating a level-1 foe earns 0 xp"); - assert.equal(resolved.xpLoss, 50, "loser (level-1 snapshot) punching up 14 levels earns the 200%-clamped 50 xp"); - }); - - it("Should reject requestBattle with insufficient battle fee", async function () { - const { petCore, gameLogic, entropy, config } = await deployV2(); - const testClient = await viem.getTestClient(); - const [, addr1, addr2] = await viem.getWalletClients(); - - await mintStarter(petCore, gameLogic, entropy, config, addr1, "Mine"); - await mintStarter(petCore, gameLogic, entropy, config, addr2, "Theirs"); - - await testClient.increaseTime({ seconds: 901 }); // > battleCooldown (900s) - await testClient.mine({ blocks: 1 }); - - const short = (await battleValue(entropy, config)) - 1n; // one wei short of battle + entropy - try { - await gameLogic.write.requestBattle([1n, 2n], { account: addr1.account, value: short }); - assert.fail("Expected revert"); - } catch (error: unknown) { - assert((error as Error).message.includes("Insufficient battle/entropy fee")); - } - }); - - it("Should return a zeroed record from getBattleRequest for an unknown/settled requestId", async function () { - const { gameLogic } = await deployV2(); - const empty = await gameLogic.read.getBattleRequest([999999n]); - assert.equal(empty.requester, "0x0000000000000000000000000000000000000000"); - assert.equal(empty.snapshotted, false); - }); - - it("Should reject battle between pets owned by the same address", async function () { - const { petCore, gameLogic } = await deployV2(); - const testClient = await viem.getTestClient(); - const [deployer] = await viem.getWalletClients(); - - // deployer (owner) creates and mints two pets to themselves - await petCore.write.createPet(["PetA", 1234567890123456n, 1, 0, 0n, 0n], { account: deployer.account }); - await petCore.write.mintTo([deployer.account.address, 1n], { account: deployer.account }); - await petCore.write.createPet(["PetB", 9876543210987654n, 1, 0, 0n, 0n], { account: deployer.account }); - await petCore.write.mintTo([deployer.account.address, 2n], { account: deployer.account }); - - await testClient.increaseTime({ seconds: 901 }); // > battleCooldown (900s) - await testClient.mine({ blocks: 1 }); - - try { - await gameLogic.write.requestBattle([1n, 2n], { account: deployer.account }); - assert.fail("Expected revert"); - } catch (error: unknown) { - assert((error as Error).message.includes("Can't fight own pet")); - } - }); - - it("Should reject battle with a pet the caller does not own", async function () { - const { petCore, gameLogic, entropy, config } = await deployV2(); - const testClient = await viem.getTestClient(); - const [, addr1, addr2] = await viem.getWalletClients(); - - await mintStarter(petCore, gameLogic, entropy, config, addr1, "Mine"); - await mintStarter(petCore, gameLogic, entropy, config, addr2, "Theirs"); - - await testClient.increaseTime({ seconds: 901 }); // > battleCooldown (900s) - await testClient.mine({ blocks: 1 }); - - try { - await gameLogic.write.requestBattle([1n, 2n], { account: addr2.account }); - assert.fail("Expected revert"); - } catch (error: unknown) { - assert((error as Error).message.includes("Not the owner of this pet")); - } - }); - - it("Should reject requestBattle when the level gap exceeds levelBandWidth", async function () { - const { petCore, gameLogic, entropy, config } = await deployV2(); - const testClient = await viem.getTestClient(); - const [deployer, addr1, addr2] = await viem.getWalletClients(); - - await mintStarter(petCore, gameLogic, entropy, config, addr1, "Strong"); - await mintStarter(petCore, gameLogic, entropy, config, addr2, "Weak"); - - // Level pet 1 up to level 12 (11 level-ups from level 1), one addXp call per level - // since add_xp/addXp applies at most one level-up per call (plan §3.4). - for (let level = 1; level < 12; level++) { - await petCore.write.addXp([1n, BigInt(100 * level)], { account: deployer.account }); - } - const pet1 = await petCore.read.getPet([1n]); - assert.equal(pet1.level, 12); - - await testClient.increaseTime({ seconds: 901 }); // > battleCooldown (900s) - await testClient.mine({ blocks: 1 }); - - // Default levelBandWidth (100) tolerates an 11-level gap; tighten it to 10. - await config.write.setLevelBandWidth([10], { account: deployer.account }); - - try { - await gameLogic.write.requestBattle([1n, 2n], { account: addr1.account }); - assert.fail("Expected revert"); - } catch (error: unknown) { - assert((error as Error).message.includes("Level gap too large")); - } - }); - it("Should pause and block actions", async function () { const { petCore, gameLogic, entropy, config } = await deployV2(); const [deployer, addr1] = await viem.getWalletClients(); @@ -564,7 +302,7 @@ describe("CryptoPetsV2 (UUPS proxies)", async function () { assert.equal(await petCore.read.totalPets(), 1n); }); - it("Pause drill (GameLogic): blocks battle/breed/train but leaves withdrawals callable", async function () { + it("Pause drill (GameLogic): blocks breed/train but leaves withdrawals callable", async function () { const { petCore, gameLogic, entropy, config } = await deployV2(); const [deployer, addr1, addr2] = await viem.getWalletClients(); @@ -582,13 +320,6 @@ describe("CryptoPetsV2 (UUPS proxies)", async function () { assert((error as Error).message.includes("Pausable: paused")); } - try { - await gameLogic.write.requestBattle([1n, 2n], { account: addr1.account }); - assert.fail("Expected revert while paused"); - } catch (error: unknown) { - assert((error as Error).message.includes("Pausable: paused")); - } - try { await gameLogic.write.requestCreateFromDNA([1n, 2n, "X"], { account: addr1.account }); assert.fail("Expected revert while paused"); @@ -850,51 +581,6 @@ describe("CryptoPetsV2 (UUPS proxies)", async function () { assert.equal(rec2.status, "success"); }); - it("Should cancel a pending battle request before fulfillment", async function () { - const { petCore, gameLogic, entropy, config } = await deployV2(); - const publicClient = await viem.getPublicClient(); - const testClient = await viem.getTestClient(); - const [, addr1, addr2] = await viem.getWalletClients(); - - await mintStarter(petCore, gameLogic, entropy, config, addr1, "Mine"); - await mintStarter(petCore, gameLogic, entropy, config, addr2, "Theirs"); - - await testClient.increaseTime({ seconds: 901 }); // > battleCooldown (900s) - await testClient.mine({ blocks: 1 }); - - const battleFee = await config.read.battleFee(); - const reqHash = await gameLogic.write.requestBattle([1n, 2n], { - account: addr1.account, value: await battleValue(entropy, config) - }); - const reqReceipt = await publicClient.waitForTransactionReceipt({ hash: reqHash }); - const reqLogs = parseEventLogs({ - abi: gameLogic.abi, - logs: reqReceipt.logs, - eventName: "BattleRandomnessRequested", - strict: false - }); - const requestId = reqLogs[0].args.requestId; - - // Pets are locked - assert.equal(await gameLogic.read.petBattleRequestId([1n]), requestId); - - // Cancel frees the lock and refunds the escrowed battle fee - const balanceBefore = await publicClient.getBalance({ address: addr1.account.address }); - const cancelHash = await gameLogic.write.cancelBattle([requestId], { account: addr1.account }); - const cancelReceipt = await publicClient.waitForTransactionReceipt({ hash: cancelHash }); - const gasCost = cancelReceipt.gasUsed * cancelReceipt.effectiveGasPrice; - const balanceAfter = await publicClient.getBalance({ address: addr1.account.address }); - assert.equal(balanceAfter, balanceBefore - gasCost + battleFee, "battleFee must be refunded on cancel"); - assert.equal(await gameLogic.read.petBattleRequestId([1n]), 0n); - - // Pets can be re-requested - const reqHash2 = await gameLogic.write.requestBattle([1n, 2n], { - account: addr1.account, value: await battleValue(entropy, config) - }); - const rec2 = await publicClient.waitForTransactionReceipt({ hash: reqHash2 }); - assert.equal(rec2.status, "success"); - }); - it("Should train a pet: pay level-scaled fee, receive XP, trigger train cooldown", async function () { const { petCore, gameLogic, entropy, config } = await deployV2(); const testClient = await viem.getTestClient(); @@ -991,16 +677,16 @@ describe("CryptoPetsV2 (UUPS proxies)", async function () { // Offspring is pet 3 const newborn = await petCore.read.getPet([3n]); - const battleCooldown = await config.read.battleCooldown(); // 5s const newbornCooldown = await config.read.newbornCooldown(); // 60s - // newborn readyTime should be further in future than battleCooldown would give - // (i.e. readyTime > block.timestamp + battleCooldown) + // A starter mints with readyTime 0 (battles no longer set it, §L Phase 6), so a + // newborn's cooldown is visible as a readyTime in the future at all. assert( - newborn.readyTime > BigInt(Math.floor(Date.now() / 1000)) + battleCooldown, - "Newborn should have newborn cooldown, not just battle cooldown" + newborn.readyTime > BigInt(Math.floor(Date.now() / 1000)), + "Newborn should carry the newborn cooldown" ); - // Pet is not ready for battle immediately + // Pet is not ready for battle immediately. The backend honours this through the + // indexed pet_roster.ready_at, so newborns stay barred from backend battles too. assert.equal(await petCore.read.isReady([3n]), false); // After newborn cooldown elapses, pet becomes battle-ready @@ -1209,7 +895,7 @@ describe("CryptoPetsV2 (UUPS proxies)", async function () { it("Should apply the Tank skill's pre-battle HP bonus in CombatSim.simulate", async function () { const { config } = await deployV2(); - const combatSim = await viem.getContractAt("CombatSim", await config.read.combatSim()); + const combatSim = await viem.deployContract("CombatSim"); const sc = await config.read.getSkillConfig(); const dna1 = 1234567890123456n; // level-50 attacker, far stronger than dna2 @@ -1238,7 +924,7 @@ describe("CryptoPetsV2 (UUPS proxies)", async function () { it("Should run CombatSim.simulate without reverting for every skill archetype (0-7)", async function () { const { config } = await deployV2(); - const combatSim = await viem.getContractAt("CombatSim", await config.read.combatSim()); + const combatSim = await viem.deployContract("CombatSim"); const sc = await config.read.getSkillConfig(); const dna1 = 1234567890123456n; From 7303b602fb166d773043f121dc7fea5aad90a8cb Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 19:09:51 -0400 Subject: [PATCH 47/76] refactor(backend): remove on-chain battle settlement and shadow mode --- backend/API.md | 12 +- backend/env.example | 45 +-- .../migration.sql | 2 + backend/prisma/schema.prisma | 46 --- backend/src/config/env.ts | 28 -- backend/src/features/battle-shadow/compare.ts | 121 -------- backend/src/features/battle-shadow/index.ts | 23 -- backend/src/features/battle-shadow/metrics.ts | 75 ----- .../features/battle-shadow/shadow.service.ts | 221 --------------- .../settle-keeper-solana/battleRequests.ts | 27 -- .../features/settle-keeper-solana/index.ts | 54 ---- .../features/settle-keeper-solana/keeper.ts | 206 -------------- backend/src/features/settle-keeper/index.ts | 38 +-- backend/src/features/settle-keeper/keeper.ts | 120 +------- .../src/features/settle-keeper/requests.ts | 8 +- backend/src/server.ts | 5 - .../features/battle-shadow/compare.test.ts | 77 ----- .../features/battle-shadow/metrics.test.ts | 95 ------- .../battle-shadow/shadow.service.test.ts | 180 ------------ .../battleRequests.test.ts | 39 --- .../settle-keeper-solana/index.test.ts | 71 ----- .../settle-keeper-solana/keeper.test.ts | 263 ------------------ .../features/settle-keeper/keeper.test.ts | 30 +- .../features/settle-keeper/requests.test.ts | 29 +- indexer-go/internal/evm/battles.go | 90 ------ indexer-go/internal/evm/client.go | 22 -- indexer-go/internal/evm/indexer.go | 17 +- indexer-go/internal/evm/indexer_test.go | 110 -------- 28 files changed, 60 insertions(+), 1994 deletions(-) create mode 100644 backend/prisma/migrations/20260726160000_drop_battle_shadow_run/migration.sql delete mode 100644 backend/src/features/battle-shadow/compare.ts delete mode 100644 backend/src/features/battle-shadow/index.ts delete mode 100644 backend/src/features/battle-shadow/metrics.ts delete mode 100644 backend/src/features/battle-shadow/shadow.service.ts delete mode 100644 backend/src/features/settle-keeper-solana/battleRequests.ts delete mode 100644 backend/src/features/settle-keeper-solana/index.ts delete mode 100644 backend/src/features/settle-keeper-solana/keeper.ts delete mode 100644 backend/tests/features/battle-shadow/compare.test.ts delete mode 100644 backend/tests/features/battle-shadow/metrics.test.ts delete mode 100644 backend/tests/features/battle-shadow/shadow.service.test.ts delete mode 100644 backend/tests/features/settle-keeper-solana/battleRequests.test.ts delete mode 100644 backend/tests/features/settle-keeper-solana/index.test.ts delete mode 100644 backend/tests/features/settle-keeper-solana/keeper.test.ts delete mode 100644 indexer-go/internal/evm/battles.go diff --git a/backend/API.md b/backend/API.md index 6057f8ee..313ced30 100644 --- a/backend/API.md +++ b/backend/API.md @@ -229,15 +229,18 @@ AI battle dialogue uses `rounds`/HP/XP to flavor its narration. ### Settle keeper -`backend/src/features/settle-keeper/` settles EVM `GameLogic` battle/breed/mint +`backend/src/features/settle-keeper/` settles EVM `GameLogic` **breed and mint** requests (the `requestX` → Pyth Entropy reveals → `settleX` flow) from a backend-held wallet once entropy reveals, so the player only signs the request transaction — `settleX` is permissionless and needed no special authorization, it was just being sent from the player's wallet by default. Off unless -`KEEPER_ENABLED=true`; the frontend falls back to sending the settle tx itself -if the keeper hasn't within ~45s. See `docs/plan-realtime-battle-ux.md` / +`KEEPER_ENABLED=true`. See `docs/plan-realtime-battle-ux.md` / `docs/plan-realtime-battle-impl.md` for the design and threat model. +Battles are **not** settled here any more (§L Phase 6). `requestBattle`/`settleBattle` +were removed from the contracts entirely, along with the Solana settle keeper and shadow +mode; battles run through the backend-authoritative path below. + ### Backend-authoritative battles (v2) `backend/src/routes/battle.ts` — the workflow described in @@ -353,11 +356,10 @@ that could still be reorganised. | `INDEXER_GRPC_ADDR` | indexer-go gRPC link (e.g. `localhost:50051`). Unset = stream + `winEstimate` off; roster falls back to Postgres. | | `ROSTER_READ_SOURCE` | `grpc` to read matchmaking from indexer-go's cache (Postgres fallback); `postgres` (default) for Prisma only. | | `INDEXER_PROTO_PATH` | Override path to `proto/cryptopets.proto` (defaults to `../proto`). | -| `KEEPER_ENABLED` | Turns the settle keeper on. Off by default. | +| `KEEPER_ENABLED` | Turns the settle keeper on (breed and mint only — battles are settled by the backend, §L Phase 6). Off by default. | | `KEEPER_RPC_URL` / `KEEPER_PRIVATE_KEY` / `KEEPER_CHAIN_ID` / `KEEPER_GAME_LOGIC_ADDRESS` | Required once enabled; keeper logs and no-ops if any are missing rather than crashing the server. | | `KEEPER_BACKFILL_BLOCKS` | How far back to scan on boot for requests never settled (default 5000). | | `KEEPER_MOCK_REVEAL` | Local dev only: keeper also acts as the Entropy provider (`MockEntropy.mockReveal`). Only takes effect when `KEEPER_CHAIN_ID=31337`. | -| `KEEPER_SHADOW_ENABLED` | Shadow mode (§L Phase 2): recompute settled on-chain battles through the backend engine and indexer-go and record whether they matched `BattleResolved`. Observation only. Off by default. | | `BATTLE_BACKEND_MODE_ENABLED` | Backend-authoritative battle mode (§L Phase 3). Off by default; gates the write routes, the outbox worker, and the signer requirement. Reads stay served either way. | | `BATTLE_BATCH_MIN_SIZE` / `BATTLE_BATCH_MAX_SIZE` | Smallest run worth anchoring, and the cap on one batch (§I). | | `BATTLE_ANCHOR_RPC_URL` / `BATTLE_ANCHOR_PRIVATE_KEY` / `BATTLE_ANCHOR_REGISTRY_ADDRESS` / `BATTLE_ANCHOR_CHAIN_ID` | Anchoring batch roots in `BattleBatchRegistry`. Required together; with any missing, batches are built but never anchored. The wallet needs the registry's publisher role. | diff --git a/backend/env.example b/backend/env.example index 08487c9b..6fd5e133 100644 --- a/backend/env.example +++ b/backend/env.example @@ -85,16 +85,13 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # Only enable after indexer-go is promoted (ROSTER_CACHE_ENABLED=true there). # ROSTER_READ_SOURCE=postgres -# --- Settle keeper (GameLogic battle/breed/mint settlement) --- -# LEGACY FOR BATTLES as of §L Phase 6: new battles run through the backend-authoritative -# path (BATTLE_BACKEND_MODE_ENABLED). Still current for breed and mint, which have no -# backend equivalent and continue to settle on chain. Keep this enabled on an existing -# deployment long enough to drain in-flight requests — turning it off with requests -# pending strands them until someone settles by hand. Battles it already settled stay -# replayable regardless; retiring the path stops new battles, not old ones. +# --- Settle keeper (GameLogic breed/mint settlement) --- +# Battles are no longer settled on chain (§L Phase 6) — they run through the +# backend-authoritative path (BATTLE_BACKEND_MODE_ENABLED). This keeper now covers breed +# and mint only, which have no backend equivalent. # -# Settles requestBattle/requestCreateFromDNA/requestMintStarter requests from this -# wallet once Pyth Entropy reveals, so the player only signs the request transaction +# Settles requestCreateFromDNA/requestMintStarter requests from this wallet once Pyth +# Entropy reveals, so the player only signs the request transaction # (see docs/plan-realtime-battle-ux.md, docs/plan-realtime-battle-impl.md Phase 2). # Off by default. All four of RPC_URL/PRIVATE_KEY/CHAIN_ID/GAME_LOGIC_ADDRESS are # required once enabled; the keeper logs and no-ops (doesn't crash the server) if @@ -104,11 +101,6 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # KEEPER_PRIVATE_KEY=0x... # KEEPER_CHAIN_ID=31337 # KEEPER_GAME_LOGIC_ADDRESS=0x... -# Optional: GameConfig address. Enables the live-battle-socket feature — the keeper runs -# the same battle sim CombatSim.settleBattle will use the moment entropy reveals, and -# pushes it over WebSocket (ws:///ws/live-battle) so the frontend's live animation -# doesn't depend on its own RPC event watching. Settling itself works without this set. -# KEEPER_GAME_CONFIG_ADDRESS=0x... # How many blocks of history to scan on boot for requests that were never settled # (self-heals after keeper downtime). Default: 5000. # KEEPER_BACKFILL_BLOCKS=5000 @@ -118,31 +110,6 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # enable against anything else, so this can't accidentally run on a real network. # KEEPER_MOCK_REVEAL=true -# Shadow mode (docs/plan-backend-battle-architecture.md §L Phase 2): recompute every -# settled on-chain battle through the backend engine and indexer-go, and record whether -# they agreed with BattleResolved. Observation only — it settles nothing, blocks nothing, -# and writes to no table the live path reads, so the on-chain flow behaves identically -# whether this is on or off. Needs KEEPER_GAME_CONFIG_ADDRESS (the skill config comes from -# GameConfig) and, for the second opinion, INDEXER_GRPC_ADDR. -# Off by default: it writes a row and makes a gRPC call per battle. -# KEEPER_SHADOW_ENABLED=true - -# --- Solana settle keeper (commit_battle settlement) --- -# Settles commit_battle requests from this wallet once Switchboard On-Demand reveals -# their randomness, so the player only signs the commit transaction (see -# docs/plan-realtime-battle-solana.md Workstream S2). Battle only — settle_breed/ -# settle_mint still require the player's own signature (their Metaplex Core mint CPI -# needs a real payer signature; see the plan doc for why). Off by default. All three of -# RPC_URL/KEYPAIR/PROGRAM_ID are required once enabled; the keeper logs and no-ops -# (doesn't crash the server) if any are missing or invalid. -# KEEPER_SOLANA_ENABLED=true -# KEEPER_SOLANA_RPC_URL=http://127.0.0.1:8899 -# JSON array string (solana-keygen file format), e.g. the contents of ~/.config/solana/id.json. -# KEEPER_SOLANA_KEYPAIR=[12,34,...] -# KEEPER_SOLANA_PROGRAM_ID=EVzXwxHqwbTLMxfTG3amCb2Sjwmy5A7hqR59GbrvEyV1 -# How often to poll for pending battle requests, in ms. Default: 5000. -# KEEPER_SOLANA_POLL_INTERVAL_MS=5000 - # --- Backend-authoritative battles (docs/plan-backend-battle-architecture.md) --- # Backend-authoritative battle mode (docs/plan-backend-battle-architecture.md §L Phase 3, # operated per docs/runbook-backend-battles.md). Off by default, and a separate switch from diff --git a/backend/prisma/migrations/20260726160000_drop_battle_shadow_run/migration.sql b/backend/prisma/migrations/20260726160000_drop_battle_shadow_run/migration.sql new file mode 100644 index 00000000..2a220c24 --- /dev/null +++ b/backend/prisma/migrations/20260726160000_drop_battle_shadow_run/migration.sql @@ -0,0 +1,2 @@ +-- DropTable +DROP TABLE "battle_shadow_run"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 02bdad1c..063bdaf4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -524,52 +524,6 @@ model BattleOutbox { @@map("battle_outbox") } -/// One shadow run: what the backend engine predicted for an on-chain battle, and what -/// the chain actually did (§L Phase 2). -/// -/// Written in two stages, because the inputs and the answer are available at different -/// moments. `GameLogic.settleBattle` deletes its request-time snapshot, so the frozen sim -/// inputs only exist between entropy revealing and the settle transaction landing — the -/// prediction is recorded in that window, and the observation is filled in when -/// `BattleResolved` arrives. -/// -/// Deliberately no relation to `battle_history`: shadow mode observes the on-chain path -/// without participating in it, and a foreign key would make a shadow write able to fail -/// a real settle. -model BattleShadowRun { - chainId String @map("chain_id") - /// Pyth Entropy sequence number, which is GameLogic's battle request id. - requestId String @map("request_id") - - /// Frozen sim inputs and the revealed seed, exactly as predicted from. - seed String - attackerPetId String @map("attacker_pet_id") - defenderPetId String @map("defender_pet_id") - inputs Json - - /// The TypeScript engine's outcome, computed from `inputs` and `seed`. - predicted Json - /// indexer-go's independent recomputation, plus whether it could be reached at all. - /// Null means the verifier was not configured or did not answer; that is recorded as - /// its own status rather than being mistaken for agreement. - goVerdict Json? @map("go_verdict") - - /// The chain's own answer, from the `BattleResolved` event. Null until it lands. - observed Json? - /// Field-level differences, empty when everything matched. - mismatches Json? - - /// 'pending' | 'agreed' | 'mismatch' | 'engine-disagreement' - status String @default("pending") - - predictedAt DateTime @default(now()) @map("predicted_at") - observedAt DateTime? @map("observed_at") - - @@id([chainId, requestId]) - @@index([status, predictedAt]) - @@map("battle_shadow_run") -} - /// A reward season: the set of entitlements computed from anchored receipts, and the /// Merkle root a claim is proven against (§I). /// diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 6605cf76..1e56dd92 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -96,10 +96,6 @@ export const env = { : undefined) as `0x${string}` | undefined, chainId: process.env.KEEPER_CHAIN_ID ? Number(process.env.KEEPER_CHAIN_ID) : undefined, gameLogicAddress: process.env.KEEPER_GAME_LOGIC_ADDRESS?.trim() as `0x${string}` | undefined, - /** Optional: enables the live-battle-socket feature (push a computed sim to the - * frontend over WebSocket the moment entropy reveals). Unset = feature just - * doesn't broadcast; settling itself is unaffected. */ - gameConfigAddress: process.env.KEEPER_GAME_CONFIG_ADDRESS?.trim() as `0x${string}` | undefined, backfillBlocks: BigInt(process.env.KEEPER_BACKFILL_BLOCKS?.trim() || '5000'), /** Local dev only: also acts as the Entropy provider (MockEntropy.mockReveal), * replacing the old removed vrf-fulfill-watcher.ts for the entropy flow. Refuse @@ -109,30 +105,6 @@ export const env = { mockReveal: process.env.KEEPER_MOCK_REVEAL?.trim().toLowerCase() === 'true' && Number(process.env.KEEPER_CHAIN_ID) === 31337, - /** Shadow mode (docs/plan-backend-battle-architecture.md §L Phase 2): recompute - * every settled on-chain battle through the backend engine and record whether it - * agreed. Observation only — it settles nothing and blocks nothing. Off by - * default because it writes a row and calls indexer-go per battle, and the - * on-chain path has to behave identically whether it is on or not. */ - shadowEnabled: process.env.KEEPER_SHADOW_ENABLED?.trim().toLowerCase() === 'true', - }, - - /** - * Solana settle keeper (docs/plan-realtime-battle-solana.md Workstream S2): settles - * `commit_battle` requests from this wallet once Switchboard On-Demand reveals their - * randomness. Battle only — settle_breed/settle_mint still need the player's own - * signature (their Metaplex Core mint CPI requires a real payer signature; see the - * plan doc). Off unless KEEPER_SOLANA_ENABLED=true; all three fields below are - * required once it is (checked at startSolanaSettleKeeperFeature() time so a - * misconfigured keeper logs and no-ops rather than crashing the server on boot). - */ - solanaSettleKeeper: { - enabled: process.env.KEEPER_SOLANA_ENABLED?.trim().toLowerCase() === 'true', - rpcUrl: process.env.KEEPER_SOLANA_RPC_URL?.trim() || undefined, - /** JSON array string (solana-keygen file format), e.g. "[12,34,...]". */ - keypairJson: process.env.KEEPER_SOLANA_KEYPAIR?.trim() || undefined, - programId: process.env.KEEPER_SOLANA_PROGRAM_ID?.trim() || undefined, - pollIntervalMs: Number(process.env.KEEPER_SOLANA_POLL_INTERVAL_MS?.trim() || '5000'), }, /** diff --git a/backend/src/features/battle-shadow/compare.ts b/backend/src/features/battle-shadow/compare.ts deleted file mode 100644 index 6fea370e..00000000 --- a/backend/src/features/battle-shadow/compare.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Comparing what the backend engine predicted against what the chain actually did - * (§L Phase 2). - * - * ## What is compared, and what deliberately is not - * - * Only the fight outcome: `firstWins`, `rounds`, `winnerHpRemaining`, and which pet id - * won. Every one of those is a pure function of the request-time snapshot and the revealed - * seed, both of which are captured before `settleBattle` runs, so a disagreement is a real - * engine disagreement and nothing else. That is what makes "zero deterministic mismatch" - * a meaningful stop condition rather than a noise threshold. - * - * `xpWin` and `xpLoss` are excluded on purpose, even though `BattleResolved` carries them. - * They depend on each pet's `lastOpponentId` and `sameOpponentStreak`, which - * `settleBattle` reads *and mutates* through `recordBattleOpponent` at settle time — not - * from the frozen snapshot. Shadow mode observes at reveal, so any other battle settling - * for the same pet in between would move the decay shift and produce a mismatch that means - * nothing about the engine. Including them would trade a clean signal for a noisy one. - * - * The XP formula is not going unchecked as a result: `contracts/test-vectors/xp.json` is - * run against all four ports. What shadow mode adds is confirmation that the *simulator* - * reproduces real chain outcomes on real inputs, which vectors cannot do — so this is the - * gap it is aimed at. - */ - -/** The fight outcome, as any of the three engines states it. */ -export interface FightOutcome { - firstWins: boolean; - rounds: number; - winnerHpRemaining: number; -} - -/** What the chain reported, decoded from `BattleResolved`. */ -export interface ObservedOutcome extends FightOutcome { - winnerPetId: string; - loserPetId: string; -} - -/** What an engine predicted, plus which pet that makes the winner. */ -export interface PredictedOutcome extends FightOutcome { - winnerPetId: string; - loserPetId: string; -} - -export type ShadowStatus = 'pending' | 'agreed' | 'mismatch' | 'engine-disagreement'; - -export interface ComparisonResult { - status: Exclude; - mismatches: string[]; -} - -/** - * Compares the TypeScript prediction, the Go verifier's recomputation, and the chain. - * - * Three distinct outcomes, because they mean different things to whoever reads the log: - * - * - `agreed` — everything that could be checked matched. - * - `mismatch` — the backend engine and the chain disagree. This is the one that blocks - * the phase gate. - * - `engine-disagreement` — the two backend engines disagree with each other. Reported - * separately because it points at the ports having drifted, not at the chain, and the - * fix is a different one. - * - * A Go verdict of `null` is not agreement. It means the check did not run, and is recorded - * as such rather than folded into a pass — the same fail-closed reasoning the verify worker - * uses. - */ -export function compareShadowRun( - predicted: PredictedOutcome, - observed: ObservedOutcome, - goOutcome: FightOutcome | null, -): ComparisonResult { - const mismatches = diffAgainstChain(predicted, observed); - const engineMismatches = goOutcome ? diffEngines(predicted, goOutcome) : []; - - if (mismatches.length > 0) { - return { status: 'mismatch', mismatches: [...mismatches, ...engineMismatches] }; - } - if (engineMismatches.length > 0) { - return { status: 'engine-disagreement', mismatches: engineMismatches }; - } - return { status: 'agreed', mismatches: [] }; -} - -function diffAgainstChain(predicted: PredictedOutcome, observed: ObservedOutcome): string[] { - const mismatches: string[] = []; - if (predicted.firstWins !== observed.firstWins) { - mismatches.push(`firstWins: engine=${predicted.firstWins} chain=${observed.firstWins}`); - } - if (predicted.rounds !== observed.rounds) { - mismatches.push(`rounds: engine=${predicted.rounds} chain=${observed.rounds}`); - } - if (predicted.winnerHpRemaining !== observed.winnerHpRemaining) { - mismatches.push( - `winnerHpRemaining: engine=${predicted.winnerHpRemaining} chain=${observed.winnerHpRemaining}`, - ); - } - // Checked separately from `firstWins` rather than derived from it: the two agreeing is - // what proves the engine and the chain also agree on which pet was in which slot. - if (predicted.winnerPetId !== observed.winnerPetId) { - mismatches.push(`winnerPetId: engine=${predicted.winnerPetId} chain=${observed.winnerPetId}`); - } - if (predicted.loserPetId !== observed.loserPetId) { - mismatches.push(`loserPetId: engine=${predicted.loserPetId} chain=${observed.loserPetId}`); - } - return mismatches; -} - -function diffEngines(predicted: FightOutcome, go: FightOutcome): string[] { - const mismatches: string[] = []; - if (predicted.firstWins !== go.firstWins) { - mismatches.push(`go.firstWins: ts=${predicted.firstWins} go=${go.firstWins}`); - } - if (predicted.rounds !== go.rounds) { - mismatches.push(`go.rounds: ts=${predicted.rounds} go=${go.rounds}`); - } - if (predicted.winnerHpRemaining !== go.winnerHpRemaining) { - mismatches.push(`go.winnerHpRemaining: ts=${predicted.winnerHpRemaining} go=${go.winnerHpRemaining}`); - } - return mismatches; -} diff --git a/backend/src/features/battle-shadow/index.ts b/backend/src/features/battle-shadow/index.ts deleted file mode 100644 index 7456b4a1..00000000 --- a/backend/src/features/battle-shadow/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -export { - compareShadowRun, - type ComparisonResult, - type FightOutcome, - type ObservedOutcome, - type PredictedOutcome, - type ShadowStatus, -} from './compare'; -export { - recordShadowOutcome, - resetShadowCounters, - shadowCounters, - shadowSummary, - type ShadowCounters, - type ShadowSummary, -} from './metrics'; -export { - observeOnSettle, - predictOnReveal, - type ObserveRequest, - type PredictRequest, - type ShadowInputs, -} from './shadow.service'; diff --git a/backend/src/features/battle-shadow/metrics.ts b/backend/src/features/battle-shadow/metrics.ts deleted file mode 100644 index bcf3defb..00000000 --- a/backend/src/features/battle-shadow/metrics.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { prisma } from '@config/prisma'; - -/** - * Shadow-mode counters, and the query behind the phase gate. - * - * The in-process counters are for a liveness check on a running instance. They are not the - * stop condition: §L Phase 2's gate is "zero deterministic mismatch over the agreed - * observation window", and a window measured in days outlives any process, so the real - * answer is `shadowSummary`, which reads the durable rows. - */ - -export interface ShadowCounters { - agreed: number; - mismatch: number; - engineDisagreement: number; -} - -const counters: ShadowCounters = { agreed: 0, mismatch: 0, engineDisagreement: 0 }; - -export function recordShadowOutcome(status: string): void { - if (status === 'agreed') counters.agreed++; - else if (status === 'mismatch') counters.mismatch++; - else if (status === 'engine-disagreement') counters.engineDisagreement++; -} - -/** Counters since this process started. */ -export function shadowCounters(): ShadowCounters { - return { ...counters }; -} - -/** Test seam: resets the in-process counters. */ -export function resetShadowCounters(): void { - counters.agreed = 0; - counters.mismatch = 0; - counters.engineDisagreement = 0; -} - -export interface ShadowSummary { - /** Runs predicted but not yet observed. Not a failure: settle may still be in flight. */ - pending: number; - agreed: number; - mismatch: number; - engineDisagreement: number; - /** True only when something was actually observed and none of it disagreed. */ - clean: boolean; -} - -/** - * The durable answer to "has the backend engine ever disagreed with the chain". - * - * `clean` requires at least one observed run, so an empty table cannot be mistaken for a - * passed observation window — which is exactly the misreading that would let the phase gate - * open on no evidence at all. - */ -export async function shadowSummary(since?: Date): Promise { - const where = since ? { predictedAt: { gte: since } } : {}; - const rows = await prisma.battleShadowRun.groupBy({ - by: ['status'], - where, - _count: { status: true }, - }); - - const byStatus = new Map(rows.map((row) => [row.status, row._count.status])); - const summary = { - pending: byStatus.get('pending') ?? 0, - agreed: byStatus.get('agreed') ?? 0, - mismatch: byStatus.get('mismatch') ?? 0, - engineDisagreement: byStatus.get('engine-disagreement') ?? 0, - }; - - return { - ...summary, - clean: summary.agreed > 0 && summary.mismatch === 0 && summary.engineDisagreement === 0, - }; -} diff --git a/backend/src/features/battle-shadow/shadow.service.ts b/backend/src/features/battle-shadow/shadow.service.ts deleted file mode 100644 index e9d85f79..00000000 --- a/backend/src/features/battle-shadow/shadow.service.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { simulate, type SkillConfig } from '@cryptopets/protocol'; -import type { Prisma } from '@generated/prisma/client'; - -import { prisma } from '@config/prisma'; -import { callVerifyBattle } from '@grpc-client/verifyBattle'; - -import { - compareShadowRun, - type FightOutcome, - type ObservedOutcome, - type PredictedOutcome, -} from './compare'; -import { recordShadowOutcome } from './metrics'; - -/** - * Shadow mode: recompute every settled on-chain battle through the backend engine and - * compare (§L Phase 2). - * - * On-chain battles keep running exactly as they did. Nothing here settles anything, blocks - * anything, or writes to any table the live path reads — the whole point of a shadow is to - * be removable without consequence. Every function is best-effort: a failure logs and - * returns, because a shadow run that could break a real battle would be worse than no - * shadow at all. - * - * Two stages, forced by the contract's own lifecycle. `settleBattle` deletes its - * request-time snapshot, so the frozen sim inputs only exist between entropy revealing and - * the settle landing. `predictOnReveal` captures them in that window; `observeOnSettle` - * fills in the chain's answer when `BattleResolved` arrives. - */ - -export interface ShadowInputs { - dna1: bigint; - rarity1: number; - level1: number; - skill1: number; - dna2: bigint; - rarity2: number; - level2: number; - skill2: number; -} - -export interface PredictRequest { - chainId: string; - requestId: bigint; - petId1: bigint; - petId2: bigint; - seed: bigint; - inputs: ShadowInputs; - skillConfig: SkillConfig; -} - -/** - * Records what the backend engine expects, before the chain has answered. - * - * Also asks indexer-go for its own recomputation. That call is fail-open here, unlike the - * verify worker's: nothing is being signed, so an unreachable verifier should cost the run - * its second opinion, not the whole observation. - */ -export async function predictOnReveal(request: PredictRequest): Promise { - try { - const outcome = simulate( - request.inputs.dna1, - request.inputs.rarity1, - request.inputs.level1, - request.inputs.skill1, - request.inputs.dna2, - request.inputs.rarity2, - request.inputs.level2, - request.inputs.skill2, - request.seed, - request.skillConfig, - ); - - const predicted: PredictedOutcome = { - firstWins: outcome.result.firstWins, - rounds: outcome.result.rounds, - winnerHpRemaining: outcome.result.winnerHpRemaining, - winnerPetId: (outcome.result.firstWins ? request.petId1 : request.petId2).toString(), - loserPetId: (outcome.result.firstWins ? request.petId2 : request.petId1).toString(), - }; - - const goVerdict = await askGoVerifier(request); - - await prisma.battleShadowRun.upsert({ - where: { chainId_requestId: { chainId: request.chainId, requestId: request.requestId.toString() } }, - // A re-reveal for a request already predicted must not overwrite the original - // prediction: the first one is the honest record of what the engine said before - // the chain answered. - update: {}, - create: { - chainId: request.chainId, - requestId: request.requestId.toString(), - seed: `0x${request.seed.toString(16).padStart(64, '0')}`, - attackerPetId: request.petId1.toString(), - defenderPetId: request.petId2.toString(), - inputs: serializeInputs(request.inputs), - predicted: toJson(predicted), - goVerdict: toJson(goVerdict), - status: 'pending', - }, - }); - } catch (error) { - console.error(`[battle-shadow] prediction failed for request ${request.requestId}: ${describe(error)}`); - } -} - -export interface ObserveRequest { - chainId: string; - requestId: bigint; - observed: ObservedOutcome; -} - -/** Fills in the chain's answer and records whether it matched. */ -export async function observeOnSettle(request: ObserveRequest): Promise { - try { - const key = { chainId: request.chainId, requestId: request.requestId.toString() }; - const run = await prisma.battleShadowRun.findUnique({ where: { chainId_requestId: key } }); - if (!run) { - // Settled without a prediction: the reveal happened before shadow mode was on, - // or on another process. Nothing to compare, and inventing a prediction now - // from post-settle state would compare the engine against itself. - return; - } - if (run.observedAt) return; // already compared; a re-emitted log is not new evidence - - const predicted = run.predicted as unknown as PredictedOutcome; - const goOutcome = (run.goVerdict as { outcome?: FightOutcome } | null)?.outcome ?? null; - const { status, mismatches } = compareShadowRun(predicted, request.observed, goOutcome); - - await prisma.battleShadowRun.update({ - where: { chainId_requestId: key }, - data: { - observed: toJson(request.observed), - mismatches, - status, - observedAt: new Date(), - }, - }); - - recordShadowOutcome(status); - if (status !== 'agreed') { - // Loud on purpose: this is the signal the phase gate depends on, and a - // mismatch that only ever appeared in a database row would be missed. - console.error( - `[battle-shadow] ${status} for ${request.chainId} request ${request.requestId}: ${mismatches.join('; ')}`, - ); - } - } catch (error) { - console.error(`[battle-shadow] observation failed for request ${request.requestId}: ${describe(error)}`); - } -} - -/** - * indexer-go's independent recomputation of the same fight. - * - * Progression inputs are sent as zeros: `VerifyBattle` computes progression too, but shadow - * mode does not compare it (see `compare.ts` on why XP is out of scope), and passing state - * this function cannot observe atomically would be inventing inputs rather than reporting - * them. - */ -async function askGoVerifier(request: PredictRequest): Promise<{ status: string; outcome?: FightOutcome; detail?: string }> { - const result = await callVerifyBattle({ - attacker: { - petId: request.petId1.toString(), - dna: request.inputs.dna1.toString(), - rarity: request.inputs.rarity1, - level: request.inputs.level1, - skill: request.inputs.skill1, - xp: 0, - lastOpponentId: '0', - streak: 0, - }, - defender: { - petId: request.petId2.toString(), - dna: request.inputs.dna2.toString(), - rarity: request.inputs.rarity2, - level: request.inputs.level2, - skill: request.inputs.skill2, - xp: 0, - lastOpponentId: '0', - streak: 0, - }, - seed: `0x${request.seed.toString(16).padStart(64, '0')}`, - skillConfig: request.skillConfig, - maxLevel: 0, - }); - - if (!result.ok) { - return { status: result.reason, detail: result.detail }; - } - return { - status: 'ok', - outcome: { - firstWins: result.response.firstWins, - rounds: result.response.rounds, - winnerHpRemaining: result.response.winnerHpRemaining, - }, - }; -} - -/** Prisma's JSON columns want plain objects; a typed interface has no index signature. */ -function toJson(value: T): Prisma.InputJsonValue { - return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; -} - -function serializeInputs(inputs: ShadowInputs) { - return { - dna1: inputs.dna1.toString(), - rarity1: inputs.rarity1, - level1: inputs.level1, - skill1: inputs.skill1, - dna2: inputs.dna2.toString(), - rarity2: inputs.rarity2, - level2: inputs.level2, - skill2: inputs.skill2, - }; -} - -function describe(error: unknown): string { - return error instanceof Error ? error.message.split('\n')[0]! : String(error); -} diff --git a/backend/src/features/settle-keeper-solana/battleRequests.ts b/backend/src/features/settle-keeper-solana/battleRequests.ts deleted file mode 100644 index 355b6444..00000000 --- a/backend/src/features/settle-keeper-solana/battleRequests.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { PublicKey } from '@solana/web3.js'; -import { toU32 } from '@shared/core/node'; - -/** - * Normalized fields pulled off an Anchor-decoded `BattleRequest` account - * (`program.account.battleRequest.all()`'s `.account`). Anchor decodes u32 - * fields as either `BN` or `number` depending on version, hence `toU32`. - */ -export interface DecodedBattleRequest { - attackerOwner: PublicKey; - defenderOwner: PublicKey; - attackerPetId: number; - defenderPetId: number; - randomnessAccount: PublicKey; -} - -/** Decodes the fields settle needs from a raw Anchor account object. Pure — no chain - * access — so it's testable without a validator. */ -export function decodeBattleRequest(account: Record): DecodedBattleRequest { - return { - attackerOwner: account.attackerOwner as PublicKey, - defenderOwner: account.defenderOwner as PublicKey, - attackerPetId: toU32(account.attackerPetId), - defenderPetId: toU32(account.defenderPetId), - randomnessAccount: account.randomnessAccount as PublicKey, - }; -} diff --git a/backend/src/features/settle-keeper-solana/index.ts b/backend/src/features/settle-keeper-solana/index.ts deleted file mode 100644 index 7f3f1a71..00000000 --- a/backend/src/features/settle-keeper-solana/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Keypair, PublicKey } from '@solana/web3.js'; -import { env } from '@config/env'; -import { startSolanaSettleKeeper, type SolanaSettleKeeperHandle } from './keeper'; - -/** - * Solana counterpart to backend/src/features/settle-keeper/ (EVM). Settles - * `commit_battle` requests once Switchboard On-Demand reveals their randomness, so the - * player only signs the commit transaction. See docs/plan-realtime-battle-solana.md for - * the design and why this covers battle only (breed/mint settle still require the - * player's own signature). - * - * Off unless KEEPER_SOLANA_ENABLED=true, mirroring the EVM keeper: the feature simply - * doesn't start rather than failing, so local dev / CI without a configured keeper wallet - * is unaffected. - */ - -let handle: SolanaSettleKeeperHandle | null = null; - -export function startSolanaSettleKeeperFeature(): void { - if (!env.solanaSettleKeeper.enabled) { - console.log('[settle-keeper-solana] KEEPER_SOLANA_ENABLED not set; keeper disabled'); - return; - } - - const { rpcUrl, keypairJson, programId: programIdStr, pollIntervalMs } = env.solanaSettleKeeper; - if (!rpcUrl || !keypairJson || !programIdStr) { - console.error( - '[settle-keeper-solana] KEEPER_SOLANA_ENABLED=true but KEEPER_SOLANA_RPC_URL / ' + - 'KEEPER_SOLANA_KEYPAIR / KEEPER_SOLANA_PROGRAM_ID are not all set; keeper disabled', - ); - return; - } - - let keypair: Keypair; - let programId: PublicKey; - try { - keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(keypairJson) as number[])); - programId = new PublicKey(programIdStr); - } catch (err) { - console.error( - `[settle-keeper-solana] invalid KEEPER_SOLANA_KEYPAIR or KEEPER_SOLANA_PROGRAM_ID: ${(err as Error).message}`, - ); - return; - } - - startSolanaSettleKeeper({ rpcUrl, keypair, programId, pollIntervalMs }) - .then((h) => { handle = h; }) - .catch((err) => console.error(`[settle-keeper-solana] failed to start: ${(err as Error).message}`)); -} - -export function stopSolanaSettleKeeperFeature(): void { - handle?.stop(); - handle = null; -} diff --git a/backend/src/features/settle-keeper-solana/keeper.ts b/backend/src/features/settle-keeper-solana/keeper.ts deleted file mode 100644 index b90a9371..00000000 --- a/backend/src/features/settle-keeper-solana/keeper.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Connection, Keypair, PublicKey, type VersionedTransaction } from '@solana/web3.js'; -import { AnchorProvider, Program, Wallet, type Idl } from '@coral-xyz/anchor'; -import * as sb from '@switchboard-xyz/on-demand'; -import { - fetchAssetByPetId, - getAccountClient, - globalStatePda, - petPdaByAsset, - sendSignedTx, -} from '@shared/core/node'; -import { decodeBattleRequest } from './battleRequests'; - -export interface SolanaSettleKeeperConfig { - rpcUrl: string; - keypair: Keypair; - programId: PublicKey; - pollIntervalMs: number; -} - -export interface SolanaSettleKeeperHandle { - stop(): void; -} - -/** - * Settles CryptoPets Solana battle requests once Switchboard On-Demand has revealed their - * committed randomness, so the player only signs `commit_battle` — mirrors the EVM settle - * keeper (backend/src/features/settle-keeper/), but the watch/submit mechanics differ: - * - * - No IDL build artifact needed: the program's IDL is fetched on-chain - * (`Program.fetchIdl`), the same way the frontend's `useProgram` hook does. - * - No event-log backfill needed: `program.account.battleRequest.all()` always returns - * the complete, current pending set directly (a settled/cancelled request's account is - * closed and simply stops appearing), unlike EVM's event-log reconstruction. - * - No push-event watch: this polls on an interval and attempts settle for every open - * request each tick. Attempting `randomness.revealIx(...)` before the oracle has - * produced a value fails — that failure just means "not ready yet, try again next - * tick," the same as the frontend's own bounded retry loop, just unbounded here since a - * long-running keeper has no reason to give up. - * - * Requires `settle_battle` to be permissionless (plan-realtime-battle-solana.md Workstream - * S2's program change) — battle only; breed/mint settle still require the player's own - * signature (see the plan doc for why: their Metaplex Core mint CPI needs a real payer - * signature), so this keeper does not attempt those. - */ - -/** Below this, settle txs risk failing outright on an unfunded keeper wallet — nothing - * tops the wallet up automatically, so this is just a loud, periodic reminder to do it - * manually (mirrors the EVM keeper's MIN_BALANCE_WEI check). */ -const MIN_BALANCE_LAMPORTS = 50_000_000; // 0.05 SOL -const BALANCE_CHECK_INTERVAL_MS = 10 * 60_000; - -export async function startSolanaSettleKeeper( - config: SolanaSettleKeeperConfig, -): Promise { - const connection = new Connection(config.rpcUrl, 'confirmed'); - const wallet = new Wallet(config.keypair); - const provider = new AnchorProvider(connection, wallet, { - commitment: 'confirmed', - preflightCommitment: 'confirmed', - }); - - const idl = await Program.fetchIdl(config.programId, provider); - if (!idl) { - throw new Error( - `No on-chain IDL found for program ${config.programId.toBase58()} — deploy it with ` + - "'anchor idl init' or point KEEPER_SOLANA_RPC_URL at a cluster where it exists.", - ); - } - const program = new Program(idl as Idl, provider); - if (typeof program.methods.settleBattle !== 'function') { - throw new Error( - `Program ${config.programId.toBase58()}'s on-chain IDL has no settleBattle instruction ` + - '— check KEEPER_SOLANA_PROGRAM_ID is correct and the deployed program includes it.', - ); - } - - let stopped = false; - let tickInFlight = false; - - async function trySettle( - battleRequestKey: PublicKey, - account: Record, - queue: Awaited>, - ): Promise { - const req = decodeBattleRequest(account); - const label = battleRequestKey.toBase58(); - - let attackerAsset: PublicKey | null; - let defenderAsset: PublicKey | null; - try { - [attackerAsset, defenderAsset] = await Promise.all([ - fetchAssetByPetId(program, req.attackerPetId), - fetchAssetByPetId(program, req.defenderPetId), - ]); - } catch (err) { - console.error(`[settle-keeper-solana] ${label}: failed to look up pet assets: ${(err as Error).message}`); - return; - } - if (!attackerAsset || !defenderAsset) { - console.error(`[settle-keeper-solana] ${label}: attacker or defender pet asset not found, skipping`); - return; - } - - let revealIx; - try { - const randomness = new sb.Randomness(queue.program, req.randomnessAccount); - // Fails until the oracle has actually produced a value — expected and harmless; - // the next poll tick retries. Not logged as an error. - revealIx = await randomness.revealIx(config.keypair.publicKey); - } catch { - return; - } - - try { - const [globalState] = globalStatePda(config.programId); - const [attackerPet] = petPdaByAsset(config.programId, attackerAsset.toBase58()); - const [defenderPet] = petPdaByAsset(config.programId, defenderAsset.toBase58()); - - // Non-null assertion: `program.methods` is a generic `Program` index - // signature, so `noUncheckedIndexedAccess` (backend's tsconfig only — shared's - // own Solana utils hit this same friction but aren't checked under this flag) - // types every property access as possibly undefined. Startup already verified - // settleBattle exists on this program's IDL (see the check above), so this is - // just satisfying the type checker, not the only thing standing behind it. - const settleBattleIx = await program.methods - .settleBattle!() - .accounts({ - globalState, - attackerOwner: req.attackerOwner, - attackerAsset, - attackerPet, - defenderOwner: req.defenderOwner, - defenderAsset, - defenderPet, - battleRequest: battleRequestKey, - randomnessAccountData: req.randomnessAccount, - }) - .instruction(); - - const tx: VersionedTransaction = await sb.asV0Tx({ - connection, - ixs: [revealIx, settleBattleIx], - payer: config.keypair.publicKey, - computeUnitPrice: 75_000, - computeUnitLimitMultiple: 1.3, - }); - - const sig = await sendSignedTx(provider, tx); - console.log(`[settle-keeper-solana] ${label}: settled (${sig})`); - } catch (err) { - console.error(`[settle-keeper-solana] ${label}: settle failed: ${(err as Error).message.split('\n')[0]}`); - } - } - - async function tick(): Promise { - if (tickInFlight || stopped) return; - tickInFlight = true; - try { - const rows = await getAccountClient(program, 'battleRequest').all(); - if (rows.length === 0) return; - // Fetched once per tick (not once per request — it's effectively static within a - // tick and this was previously a redundant round-trip per pending request). - const queue = await sb.getDefaultQueue(connection.rpcEndpoint); - for (const { publicKey, account } of rows) { - if (stopped) break; - await trySettle(publicKey as PublicKey, account, queue); - } - } catch (err) { - console.error(`[settle-keeper-solana] poll failed: ${(err as Error).message}`); - } finally { - tickInFlight = false; - } - } - - console.log( - `[settle-keeper-solana] watching program ${config.programId.toBase58()} as ` + - `${config.keypair.publicKey.toBase58()}, polling every ${config.pollIntervalMs}ms`, - ); - const interval = setInterval(() => void tick(), config.pollIntervalMs); - void tick(); // don't wait a full interval for the first poll - - async function checkBalance(): Promise { - try { - const balance = await connection.getBalance(config.keypair.publicKey); - if (balance < MIN_BALANCE_LAMPORTS) { - console.error( - `[settle-keeper-solana] wallet ${config.keypair.publicKey.toBase58()} balance is low ` + - `(${balance} lamports, min ${MIN_BALANCE_LAMPORTS}) — settle txs may start failing; ` + - 'top it up from fee vault proceeds', - ); - } - } catch (err) { - console.error(`[settle-keeper-solana] balance check failed: ${(err as Error).message}`); - } - } - void checkBalance(); - const balanceCheckTimer = setInterval(() => { void checkBalance(); }, BALANCE_CHECK_INTERVAL_MS); - - return { - stop() { - stopped = true; - clearInterval(interval); - clearInterval(balanceCheckTimer); - }, - }; -} diff --git a/backend/src/features/settle-keeper/index.ts b/backend/src/features/settle-keeper/index.ts index 45ca7dbf..0afe67c6 100644 --- a/backend/src/features/settle-keeper/index.ts +++ b/backend/src/features/settle-keeper/index.ts @@ -20,16 +20,7 @@ export function startSettleKeeper(): void { return; } - const { - rpcUrl, - privateKey, - chainId, - gameLogicAddress, - gameConfigAddress, - backfillBlocks, - mockReveal, - shadowEnabled, - } = env.settleKeeper; + const { rpcUrl, privateKey, chainId, gameLogicAddress, backfillBlocks, mockReveal } = env.settleKeeper; if (!rpcUrl || !privateKey || !chainId || !gameLogicAddress) { console.error( '[settle-keeper] KEEPER_ENABLED=true but KEEPER_RPC_URL / KEEPER_PRIVATE_KEY / ' + @@ -37,32 +28,7 @@ export function startSettleKeeper(): void { ); return; } - if (!gameConfigAddress) { - console.log( - '[settle-keeper] KEEPER_GAME_CONFIG_ADDRESS not set; live-battle-socket broadcast disabled ' + - '(settling itself is unaffected)', - ); - } - - if (shadowEnabled && !gameConfigAddress) { - // Shadow mode reads the skill config from GameConfig, so without that address it - // would silently observe nothing. Better to say so than to look enabled. - console.warn( - '[settle-keeper] KEEPER_SHADOW_ENABLED=true but KEEPER_GAME_CONFIG_ADDRESS is not set; ' + - 'shadow mode will not record any predictions', - ); - } - - startKeeper({ - rpcUrl, - privateKey, - chainId, - gameLogicAddress, - gameConfigAddress, - backfillBlocks, - mockReveal, - shadowEnabled, - }) + startKeeper({ rpcUrl, privateKey, chainId, gameLogicAddress, backfillBlocks, mockReveal }) .then((h) => { handle = h; }) .catch((err) => console.error(`[settle-keeper] failed to start: ${(err as Error).message}`)); } diff --git a/backend/src/features/settle-keeper/keeper.ts b/backend/src/features/settle-keeper/keeper.ts index 8c7a4511..9dba61fa 100644 --- a/backend/src/features/settle-keeper/keeper.ts +++ b/backend/src/features/settle-keeper/keeper.ts @@ -9,7 +9,7 @@ import { type PublicClient, } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; -import { ENTROPY_ABI, GAME_CONFIG_ABI, GAME_LOGIC_ABI } from './abi'; +import { ENTROPY_ABI, GAME_LOGIC_ABI } from './abi'; import { buildPendingMap, isSettledEvent, @@ -19,26 +19,17 @@ import { type TrackedRequestType, } from './requests'; import { createSubmitter } from './submitter'; -import { broadcastLiveBattle } from '@ws/liveBattleSocket'; -import { observeOnSettle, predictOnReveal } from '@features/battle-shadow'; -import { simulate, encodeSimOutcome } from '@shared/core/node'; export interface SettleKeeperConfig { rpcUrl: string; privateKey: `0x${string}`; chainId: number; gameLogicAddress: Address; - /** Optional: enables the live-battle-socket broadcast (see its call site). */ - gameConfigAddress?: Address | undefined; backfillBlocks: bigint; /** Local-dev only: also act as the Entropy provider, auto-revealing every - * tracked request against MockEntropy so battles/breeds/mints actually - * progress without a human calling mockReveal by hand. */ + * tracked request against MockEntropy so breeds/mints actually progress + * without a human calling mockReveal by hand. */ mockReveal: boolean; - /** Shadow mode (§L Phase 2): recompute settled battles and compare, changing nothing. - * Off by default — it writes rows and calls indexer-go per battle, and the on-chain - * path must keep running identically whether it is on or not. */ - shadowEnabled: boolean; } export interface SettleKeeperHandle { @@ -155,102 +146,6 @@ export async function startKeeper(config: SettleKeeperConfig): Promise { - if (!config.gameConfigAddress) return; - try { - const [request, skillConfig] = await Promise.all([ - publicClient.readContract({ - address: config.gameLogicAddress, - abi: GAME_LOGIC_ABI, - functionName: 'getBattleRequest', - args: [requestId], - }), - publicClient.readContract({ - address: config.gameConfigAddress, - abi: GAME_CONFIG_ABI, - functionName: 'getSkillConfig', - }), - ]); - const req = request as { - snapshotted: boolean; - petId1: bigint; - petId2: bigint; - dna1: bigint; - dna2: bigint; - level1: number; - level2: number; - rarity1: number; - rarity2: number; - speciesId1: number; - speciesId2: number; - }; - if (!req.snapshotted) return; // request predates the Phase 1 snapshot upgrade - - const outcome = simulate( - req.dna1, req.rarity1, req.level1, req.speciesId1 % 8, - req.dna2, req.rarity2, req.level2, req.speciesId2 % 8, - seed, skillConfig as never, - ); - broadcastLiveBattle({ - type: 'live', - chainId: config.chainId, - requestId: requestId.toString(), - outcome: encodeSimOutcome(outcome), - }); - - // Shadow mode (§L Phase 2). This is the only window in which the frozen sim - // inputs are readable at all — settleBattle deletes them — so the prediction is - // recorded here, alongside a read that was happening anyway. Awaited but never - // allowed to throw: predictOnReveal swallows its own failures, because a shadow - // run must not be able to disturb a real settle. - if (config.shadowEnabled) { - await predictOnReveal({ - chainId: String(config.chainId), - requestId, - petId1: req.petId1, - petId2: req.petId2, - seed, - inputs: { - dna1: req.dna1, - rarity1: req.rarity1, - level1: req.level1, - skill1: req.speciesId1 % 8, - dna2: req.dna2, - rarity2: req.rarity2, - level2: req.level2, - skill2: req.speciesId2 % 8, - }, - skillConfig: skillConfig as never, - }); - } - } catch (err) { - console.error( - `[settle-keeper] live-battle-socket sim failed for request ${requestId}: ` + - `${(err as Error).message.split('\n')[0]}`, - ); - } - } - - /** Hands a decoded `BattleResolved` to shadow mode as the chain's own answer. */ - async function observeBattleResolved(requestId: bigint, args: Record): Promise { - await observeOnSettle({ - chainId: String(config.chainId), - requestId, - observed: { - firstWins: args.firstWins as boolean, - rounds: Number(args.rounds), - winnerHpRemaining: Number(args.winnerHpRemaining), - winnerPetId: String(args.winnerId), - loserPetId: String(args.loserId), - }, - }); - } - // Backfill: catch up on anything requested-but-not-settled while this keeper (or its // predecessor) was offline, so a restart self-heals instead of losing track. const latestBlock = await publicClient.getBlockNumber(); @@ -317,11 +212,6 @@ export async function startKeeper(config: SettleKeeperConfig): Promise = { - BattleRandomnessRequested: 'battle', BreedRandomnessRequested: 'breed', MintRequested: 'mint', }; const SETTLE_FUNCTION: Record = { - battle: 'settleBattle', breed: 'settleBreed', mint: 'settleMint', }; -const SETTLED_EVENTS = new Set(['BattleResolved', 'BreedSettled', 'MintSettled']); +const SETTLED_EVENTS = new Set(['BreedSettled', 'MintSettled']); /** * Minimal shape shared by viem's decoded `getContractEvents`/`watchContractEvent` logs. @@ -53,7 +51,7 @@ export function isSettledEvent(eventName: string): boolean { * downtime self-heals instead of losing track of anything left pending. * * Note this can't distinguish a settled request from a *cancelled* one — - * cancelBattle/cancelBreed/cancelMint emit no event. That's fine: the + * cancelBreed/cancelMint emit no event. That's fine: the * settle-simulation step (see submitter.ts) is the authoritative pending * check, and simulating a cancelled request simply fails harmlessly. */ diff --git a/backend/src/server.ts b/backend/src/server.ts index 3d39478a..64eceba6 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -5,7 +5,6 @@ import app from './app'; import { startBattleStream, stopBattleStream } from '@grpc-client/battleStream'; import { configureSigner, loadPersistedSigningKeys } from '@features/battle-signer'; import { startSettleKeeper, stopSettleKeeper } from '@features/settle-keeper'; -import { startSolanaSettleKeeperFeature, stopSolanaSettleKeeperFeature } from '@features/settle-keeper-solana'; import { type BattleWorkerHandle, startBattleWorker } from '@features/battle-worker'; import { startBatchAnchor, stopBatchAnchor } from '@features/battle-anchor'; import { startLiveBattleSocket, stopLiveBattleSocket } from '@ws/liveBattleSocket'; @@ -35,9 +34,6 @@ const server = app.listen(env.port, '0.0.0.0', () => { // Settles GameLogic battle/breed/mint requests once entropy reveals. No-op unless // KEEPER_ENABLED is set. startSettleKeeper(); - // Settles Solana commit_battle requests once Switchboard reveals. No-op unless - // KEEPER_SOLANA_ENABLED is set. - startSolanaSettleKeeperFeature(); // Backend-authoritative battles (docs/plan-backend-battle-architecture.md §L Phase 3). // Selects the signing backend (refuses an in-process key in production; see @@ -87,7 +83,6 @@ async function shutdown(signal: NodeJS.Signals): Promise { stopBattleStream(); stopSettleKeeper(); - stopSolanaSettleKeeperFeature(); battleWorker?.stop(); stopBatchAnchor(); stopLiveBattleSocket(); diff --git a/backend/tests/features/battle-shadow/compare.test.ts b/backend/tests/features/battle-shadow/compare.test.ts deleted file mode 100644 index 1273b2b3..00000000 --- a/backend/tests/features/battle-shadow/compare.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { compareShadowRun, type ObservedOutcome, type PredictedOutcome } from '@features/battle-shadow'; - -const PREDICTED: PredictedOutcome = { - firstWins: true, - rounds: 7, - winnerHpRemaining: 42, - winnerPetId: '1', - loserPetId: '2', -}; - -const OBSERVED: ObservedOutcome = { ...PREDICTED }; - -describe('agreement', () => { - it('agrees when the engine, the chain, and Go all match', () => { - expect(compareShadowRun(PREDICTED, OBSERVED, { firstWins: true, rounds: 7, winnerHpRemaining: 42 })).toEqual({ - status: 'agreed', - mismatches: [], - }); - }); - - it('agrees on the chain alone when Go could not be reached', () => { - // A missing second opinion is not a disagreement; the chain comparison still stands. - expect(compareShadowRun(PREDICTED, OBSERVED, null)).toEqual({ status: 'agreed', mismatches: [] }); - }); -}); - -describe('disagreeing with the chain', () => { - it.each([ - ['firstWins', { firstWins: false, winnerPetId: '2', loserPetId: '1' }], - ['rounds', { rounds: 8 }], - ['winnerHpRemaining', { winnerHpRemaining: 41 }], - ])('flags a %s mismatch', (field, patch) => { - const result = compareShadowRun(PREDICTED, { ...OBSERVED, ...patch }, null); - expect(result.status).toBe('mismatch'); - expect(result.mismatches.join(' ')).toContain(field); - }); - - it('checks the winner pet id separately from firstWins', () => { - // Both agreeing is what proves the engine and the chain also agree on which pet sat - // in which slot — a swap would otherwise pass on `firstWins` alone. - const result = compareShadowRun(PREDICTED, { ...OBSERVED, winnerPetId: '99', loserPetId: '98' }, null); - expect(result.status).toBe('mismatch'); - expect(result.mismatches.join(' ')).toContain('winnerPetId'); - expect(result.mismatches.join(' ')).toContain('loserPetId'); - }); - - it('reports every differing field, not just the first', () => { - const result = compareShadowRun(PREDICTED, { ...OBSERVED, rounds: 9, winnerHpRemaining: 1 }, null); - expect(result.mismatches).toHaveLength(2); - }); - - it('reports a Go disagreement alongside a chain mismatch rather than hiding it', () => { - const result = compareShadowRun( - PREDICTED, - { ...OBSERVED, rounds: 9 }, - { firstWins: true, rounds: 11, winnerHpRemaining: 42 }, - ); - expect(result.status).toBe('mismatch'); - expect(result.mismatches.join(' ')).toContain('rounds:'); - expect(result.mismatches.join(' ')).toContain('go.rounds:'); - }); -}); - -describe('the two backend engines disagreeing with each other', () => { - it('is its own status, separate from a chain mismatch', () => { - // Points at the ports having drifted, not at the chain, and the fix is different. - const result = compareShadowRun(PREDICTED, OBSERVED, { firstWins: true, rounds: 8, winnerHpRemaining: 42 }); - expect(result.status).toBe('engine-disagreement'); - expect(result.mismatches.join(' ')).toContain('go.rounds'); - }); - - it('does not fire when Go simply was not consulted', () => { - expect(compareShadowRun(PREDICTED, OBSERVED, null).status).toBe('agreed'); - }); -}); diff --git a/backend/tests/features/battle-shadow/metrics.test.ts b/backend/tests/features/battle-shadow/metrics.test.ts deleted file mode 100644 index 3b1d1b4f..00000000 --- a/backend/tests/features/battle-shadow/metrics.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('@config/prisma', () => ({ - prisma: { battleShadowRun: { groupBy: vi.fn() } }, -})); - -import { prisma } from '@config/prisma'; -import { recordShadowOutcome, resetShadowCounters, shadowCounters, shadowSummary } from '@features/battle-shadow'; - -function grouped(counts: Record) { - return Object.entries(counts).map(([status, n]) => ({ status, _count: { status: n } })); -} - -beforeEach(() => { - vi.clearAllMocks(); - resetShadowCounters(); -}); - -describe('in-process counters', () => { - it('counts each outcome separately', () => { - recordShadowOutcome('agreed'); - recordShadowOutcome('agreed'); - recordShadowOutcome('mismatch'); - recordShadowOutcome('engine-disagreement'); - - expect(shadowCounters()).toEqual({ agreed: 2, mismatch: 1, engineDisagreement: 1 }); - }); - - it('ignores a status it does not track', () => { - recordShadowOutcome('pending'); - expect(shadowCounters()).toEqual({ agreed: 0, mismatch: 0, engineDisagreement: 0 }); - }); - - it('hands back a copy, so a caller cannot mutate the counters', () => { - recordShadowOutcome('agreed'); - const snapshot = shadowCounters(); - snapshot.agreed = 999; - expect(shadowCounters().agreed).toBe(1); - }); -}); - -describe('the durable summary behind the phase gate', () => { - it('is clean only when something was observed and none of it disagreed', async () => { - vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue(grouped({ agreed: 500, pending: 3 }) as never); - - await expect(shadowSummary()).resolves.toEqual({ - pending: 3, - agreed: 500, - mismatch: 0, - engineDisagreement: 0, - clean: true, - }); - }); - - it('is not clean on an empty table', async () => { - // The misreading that matters: no evidence is not the same as passed evidence, and - // treating it as clean would open the phase gate on nothing at all. - vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue([] as never); - - const summary = await shadowSummary(); - expect(summary.agreed).toBe(0); - expect(summary.clean).toBe(false); - }); - - it('is not clean while only predictions exist', async () => { - vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue(grouped({ pending: 40 }) as never); - await expect(shadowSummary()).resolves.toMatchObject({ pending: 40, clean: false }); - }); - - it('is not clean with a single mismatch among many agreements', async () => { - vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue( - grouped({ agreed: 10_000, mismatch: 1 }) as never, - ); - await expect(shadowSummary()).resolves.toMatchObject({ mismatch: 1, clean: false }); - }); - - it('is not clean when only the two backend engines disagreed', async () => { - // The chain agreed, but the ports drifted; that still blocks the gate. - vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue( - grouped({ agreed: 100, 'engine-disagreement': 2 }) as never, - ); - await expect(shadowSummary()).resolves.toMatchObject({ engineDisagreement: 2, clean: false }); - }); - - it('scopes the window when given a start time', async () => { - vi.mocked(prisma.battleShadowRun.groupBy).mockResolvedValue(grouped({ agreed: 1 }) as never); - const since = new Date('2026-07-01T00:00:00.000Z'); - - await shadowSummary(since); - - expect(prisma.battleShadowRun.groupBy).toHaveBeenCalledWith( - expect.objectContaining({ where: { predictedAt: { gte: since } } }), - ); - }); -}); diff --git a/backend/tests/features/battle-shadow/shadow.service.test.ts b/backend/tests/features/battle-shadow/shadow.service.test.ts deleted file mode 100644 index 1831ac55..00000000 --- a/backend/tests/features/battle-shadow/shadow.service.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { hashRuleset, SOURCE_DEFAULT_RULESET, simulate } from '@cryptopets/protocol'; - -vi.mock('@config/prisma', () => ({ - prisma: { - battleShadowRun: { upsert: vi.fn(), findUnique: vi.fn(), update: vi.fn(), groupBy: vi.fn() }, - }, -})); -vi.mock('@grpc-client/verifyBattle', () => ({ callVerifyBattle: vi.fn() })); - -import { prisma } from '@config/prisma'; -import { observeOnSettle, predictOnReveal, resetShadowCounters, shadowCounters } from '@features/battle-shadow'; -import { callVerifyBattle } from '@grpc-client/verifyBattle'; - -const INPUTS = { - dna1: 1234567890123456n, - rarity1: 3, - level1: 10, - skill1: 4, - dna2: 6543210987654321n, - rarity2: 2, - level2: 11, - skill2: 7, -}; -const SEED = 0x1234n; - -/** The outcome the real engine produces for these inputs — never a hand-written guess. */ -const EXPECTED = simulate( - INPUTS.dna1, INPUTS.rarity1, INPUTS.level1, INPUTS.skill1, - INPUTS.dna2, INPUTS.rarity2, INPUTS.level2, INPUTS.skill2, - SEED, SOURCE_DEFAULT_RULESET.skillConfig, -); - -const PREDICT_REQUEST = { - chainId: '84532', - requestId: 77n, - petId1: 1n, - petId2: 2n, - seed: SEED, - inputs: INPUTS, - skillConfig: SOURCE_DEFAULT_RULESET.skillConfig, -}; - -function predictedFromEngine() { - return { - firstWins: EXPECTED.result.firstWins, - rounds: EXPECTED.result.rounds, - winnerHpRemaining: EXPECTED.result.winnerHpRemaining, - winnerPetId: EXPECTED.result.firstWins ? '1' : '2', - loserPetId: EXPECTED.result.firstWins ? '2' : '1', - }; -} - -beforeEach(() => { - vi.clearAllMocks(); - resetShadowCounters(); - vi.mocked(callVerifyBattle).mockResolvedValue({ - ok: true, - response: { - firstWins: EXPECTED.result.firstWins, - rounds: EXPECTED.result.rounds, - winnerHpRemaining: EXPECTED.result.winnerHpRemaining, - } as never, - }); -}); - -describe('predictOnReveal', () => { - it('records the real engine outcome for the frozen inputs', async () => { - await predictOnReveal(PREDICT_REQUEST); - - const call = vi.mocked(prisma.battleShadowRun.upsert).mock.calls[0]![0] as { - create: { predicted: unknown; seed: string; status: string }; - }; - expect(call.create.predicted).toEqual(predictedFromEngine()); - expect(call.create.status).toBe('pending'); - expect(call.create.seed).toBe(`0x${SEED.toString(16).padStart(64, '0')}`); - }); - - it('asks indexer-go for an independent recomputation and stores its verdict', async () => { - await predictOnReveal(PREDICT_REQUEST); - - expect(callVerifyBattle).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.battleShadowRun.upsert).mock.calls[0]![0] as { - create: { goVerdict: { status: string; outcome: unknown } }; - }; - expect(call.create.goVerdict.status).toBe('ok'); - expect(call.create.goVerdict.outcome).toMatchObject({ rounds: EXPECTED.result.rounds }); - }); - - it('records why Go was unavailable rather than pretending it agreed', async () => { - // Nothing is being signed here, so an unreachable verifier costs the run its second - // opinion, not the whole observation — but it must not read as agreement. - vi.mocked(callVerifyBattle).mockResolvedValue({ ok: false, reason: 'not-configured', detail: 'no addr' }); - await predictOnReveal(PREDICT_REQUEST); - - const call = vi.mocked(prisma.battleShadowRun.upsert).mock.calls[0]![0] as { - create: { goVerdict: { status: string; outcome?: unknown } }; - }; - expect(call.create.goVerdict.status).toBe('not-configured'); - expect(call.create.goVerdict.outcome).toBeUndefined(); - }); - - it('never overwrites an existing prediction on a repeated reveal', async () => { - // The first prediction is the honest record of what the engine said before the - // chain answered; a second one could be written after the fact. - await predictOnReveal(PREDICT_REQUEST); - const call = vi.mocked(prisma.battleShadowRun.upsert).mock.calls[0]![0] as { update: object }; - expect(call.update).toEqual({}); - }); - - it('swallows a database failure rather than disturbing a real settle', async () => { - vi.mocked(prisma.battleShadowRun.upsert).mockRejectedValue(new Error('db down')); - await expect(predictOnReveal(PREDICT_REQUEST)).resolves.toBeUndefined(); - }); -}); - -describe('observeOnSettle', () => { - const storedRun = { - predicted: predictedFromEngine(), - goVerdict: { status: 'ok', outcome: { ...EXPECTED.result, firstWins: EXPECTED.result.firstWins } }, - observedAt: null, - }; - - beforeEach(() => { - vi.mocked(prisma.battleShadowRun.findUnique).mockResolvedValue(storedRun as never); - }); - - it('marks a matching battle as agreed', async () => { - await observeOnSettle({ chainId: '84532', requestId: 77n, observed: predictedFromEngine() }); - - const call = vi.mocked(prisma.battleShadowRun.update).mock.calls[0]![0] as { - data: { status: string; mismatches: string[] }; - }; - expect(call.data.status).toBe('agreed'); - expect(call.data.mismatches).toEqual([]); - expect(shadowCounters().agreed).toBe(1); - }); - - it('records a mismatch when the chain disagrees', async () => { - await observeOnSettle({ - chainId: '84532', - requestId: 77n, - observed: { ...predictedFromEngine(), rounds: EXPECTED.result.rounds + 1 }, - }); - - const call = vi.mocked(prisma.battleShadowRun.update).mock.calls[0]![0] as { - data: { status: string; mismatches: string[] }; - }; - expect(call.data.status).toBe('mismatch'); - expect(call.data.mismatches.join(' ')).toContain('rounds'); - expect(shadowCounters().mismatch).toBe(1); - }); - - it('does nothing when the reveal was never predicted', async () => { - // Inventing a prediction from post-settle state would compare the engine to itself. - vi.mocked(prisma.battleShadowRun.findUnique).mockResolvedValue(null); - await observeOnSettle({ chainId: '84532', requestId: 77n, observed: predictedFromEngine() }); - expect(prisma.battleShadowRun.update).not.toHaveBeenCalled(); - }); - - it('ignores a re-emitted log for a run already observed', async () => { - vi.mocked(prisma.battleShadowRun.findUnique).mockResolvedValue({ - ...storedRun, - observedAt: new Date(), - } as never); - - await observeOnSettle({ chainId: '84532', requestId: 77n, observed: predictedFromEngine() }); - - expect(prisma.battleShadowRun.update).not.toHaveBeenCalled(); - expect(shadowCounters().agreed).toBe(0); - }); - - it('swallows a database failure rather than throwing into the keeper', async () => { - vi.mocked(prisma.battleShadowRun.findUnique).mockRejectedValue(new Error('db down')); - await expect( - observeOnSettle({ chainId: '84532', requestId: 77n, observed: predictedFromEngine() }), - ).resolves.toBeUndefined(); - }); -}); diff --git a/backend/tests/features/settle-keeper-solana/battleRequests.test.ts b/backend/tests/features/settle-keeper-solana/battleRequests.test.ts deleted file mode 100644 index fc427b1d..00000000 --- a/backend/tests/features/settle-keeper-solana/battleRequests.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { PublicKey } from '@solana/web3.js'; -import { BN } from '@coral-xyz/anchor'; -import { decodeBattleRequest } from '../../../src/features/settle-keeper-solana/battleRequests'; - -describe('decodeBattleRequest', () => { - const attackerOwner = PublicKey.unique(); - const defenderOwner = PublicKey.unique(); - const randomnessAccount = PublicKey.unique(); - - it('decodes plain-number pet ids (newer Anchor clients)', () => { - const decoded = decodeBattleRequest({ - attackerOwner, - defenderOwner, - attackerPetId: 1, - defenderPetId: 2, - randomnessAccount, - }); - expect(decoded).toEqual({ - attackerOwner, - defenderOwner, - attackerPetId: 1, - defenderPetId: 2, - randomnessAccount, - }); - }); - - it('decodes BN-wrapped pet ids (older Anchor clients)', () => { - const decoded = decodeBattleRequest({ - attackerOwner, - defenderOwner, - attackerPetId: new BN(1), - defenderPetId: new BN(2), - randomnessAccount, - }); - expect(decoded.attackerPetId).toBe(1); - expect(decoded.defenderPetId).toBe(2); - }); -}); diff --git a/backend/tests/features/settle-keeper-solana/index.test.ts b/backend/tests/features/settle-keeper-solana/index.test.ts deleted file mode 100644 index eccc33cb..00000000 --- a/backend/tests/features/settle-keeper-solana/index.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Keypair } from '@solana/web3.js'; - -const startSolanaSettleKeeperMock = vi.fn(); -vi.mock('../../../src/features/settle-keeper-solana/keeper', () => ({ - startSolanaSettleKeeper: startSolanaSettleKeeperMock, -})); - -async function loadWithEnv(solanaSettleKeeper: Record) { - vi.resetModules(); - vi.doMock('@config/env', () => ({ env: { solanaSettleKeeper } })); - return import('../../../src/features/settle-keeper-solana/index'); -} - -const validKeypairJson = JSON.stringify(Array.from(Keypair.generate().secretKey)); - -describe('startSolanaSettleKeeperFeature', () => { - afterEach(() => { - vi.clearAllMocks(); - vi.doUnmock('@config/env'); - }); - - it('no-ops when KEEPER_SOLANA_ENABLED is not set', async () => { - const { startSolanaSettleKeeperFeature } = await loadWithEnv({ enabled: false }); - startSolanaSettleKeeperFeature(); - expect(startSolanaSettleKeeperMock).not.toHaveBeenCalled(); - }); - - it('no-ops and logs when enabled but missing required config', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const { startSolanaSettleKeeperFeature } = await loadWithEnv({ enabled: true, rpcUrl: undefined }); - startSolanaSettleKeeperFeature(); - expect(startSolanaSettleKeeperMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('keeper disabled')); - errorSpy.mockRestore(); - }); - - it('no-ops and logs when the keypair JSON is invalid', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const { startSolanaSettleKeeperFeature } = await loadWithEnv({ - enabled: true, - rpcUrl: 'http://127.0.0.1:8899', - keypairJson: 'not-json', - programId: '11111111111111111111111111111111', - pollIntervalMs: 5000, - }); - startSolanaSettleKeeperFeature(); - expect(startSolanaSettleKeeperMock).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('invalid KEEPER_SOLANA_KEYPAIR')); - errorSpy.mockRestore(); - }); - - it('starts the keeper when enabled with full, valid config', async () => { - startSolanaSettleKeeperMock.mockResolvedValue({ stop: vi.fn() }); - const { startSolanaSettleKeeperFeature, stopSolanaSettleKeeperFeature } = await loadWithEnv({ - enabled: true, - rpcUrl: 'http://127.0.0.1:8899', - keypairJson: validKeypairJson, - programId: '11111111111111111111111111111111', - pollIntervalMs: 5000, - }); - - startSolanaSettleKeeperFeature(); - await Promise.resolve(); // let the start promise's .then() run - expect(startSolanaSettleKeeperMock).toHaveBeenCalledWith( - expect.objectContaining({ rpcUrl: 'http://127.0.0.1:8899', pollIntervalMs: 5000 }), - ); - - stopSolanaSettleKeeperFeature(); // must not throw even with an async start in flight - }); -}); diff --git a/backend/tests/features/settle-keeper-solana/keeper.test.ts b/backend/tests/features/settle-keeper-solana/keeper.test.ts deleted file mode 100644 index 996c9c2c..00000000 --- a/backend/tests/features/settle-keeper-solana/keeper.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Keypair, PublicKey } from '@solana/web3.js'; - -// vi.mock factories are hoisted above all top-level `const`s in this file, so any mock -// state they close over has to be built inside vi.hoisted (which itself runs before the -// hoisted vi.mock calls) rather than referenced from an ordinary top-level const. -const mocks = vi.hoisted(() => { - const settleBattleIxBuilder = { accounts: vi.fn(), instruction: vi.fn() }; - settleBattleIxBuilder.accounts.mockReturnValue(settleBattleIxBuilder); - const methods = { settleBattle: vi.fn(() => settleBattleIxBuilder) }; - - const fetchIdl = vi.fn(); - function ProgramCtor(this: { methods: typeof methods }) { - this.methods = methods; - } - (ProgramCtor as unknown as { fetchIdl: typeof fetchIdl }).fetchIdl = fetchIdl; - - const revealIx = vi.fn(); - class MockRandomness { - revealIx(...args: unknown[]) { return revealIx(...args); } - } - - return { - settleBattleIxBuilder, - methods, - fetchIdl, - ProgramCtor, - revealIx, - MockRandomness, - getDefaultQueue: vi.fn(), - asV0Tx: vi.fn(), - getAccountClient: vi.fn(), - fetchAssetByPetId: vi.fn(), - sendSignedTx: vi.fn(), - getBalance: vi.fn(), - }; -}); - -vi.mock('@solana/web3.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Connection: function ConnectionMock(this: unknown, rpcUrl: string) { - return { rpcEndpoint: rpcUrl, getBalance: mocks.getBalance }; - }, - }; -}); - -vi.mock('@coral-xyz/anchor', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - AnchorProvider: function AnchorProviderMock(this: unknown, connection: unknown, wallet: unknown, opts: unknown) { - return { connection, wallet, opts }; - }, - Program: mocks.ProgramCtor, - Wallet: function WalletMock(this: unknown, keypair: Keypair) { - return { publicKey: keypair.publicKey }; - }, - }; -}); - -vi.mock('@switchboard-xyz/on-demand', () => ({ - getDefaultQueue: mocks.getDefaultQueue, - Randomness: mocks.MockRandomness, - asV0Tx: mocks.asV0Tx, -})); - -vi.mock('@shared/core/node', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getAccountClient: mocks.getAccountClient, - fetchAssetByPetId: mocks.fetchAssetByPetId, - globalStatePda: () => ['global-state-pda', 1], - petPdaByAsset: () => ['pet-pda', 1], - sendSignedTx: mocks.sendSignedTx, - }; -}); - -import { startSolanaSettleKeeper, type SolanaSettleKeeperConfig } from '../../../src/features/settle-keeper-solana/keeper'; - -const { - settleBattleIxBuilder, methods, fetchIdl, revealIx, getDefaultQueue, asV0Tx, - getAccountClient, fetchAssetByPetId, sendSignedTx, getBalance, -} = mocks; - -const originalSettleBattle = methods.settleBattle; - -const ZERO_KEY = new PublicKey('11111111111111111111111111111111'); -const BATTLE_REQUEST_KEY = new PublicKey('11111111111111111111111111111112'); -const ATTACKER_ASSET = new PublicKey('11111111111111111111111111111113'); -const DEFENDER_ASSET = new PublicKey('11111111111111111111111111111114'); - -const rawAccount = { - attackerOwner: ZERO_KEY, - defenderOwner: ZERO_KEY, - attackerPetId: 1, - defenderPetId: 2, - randomnessAccount: ZERO_KEY, -}; - -const baseConfig: SolanaSettleKeeperConfig = { - rpcUrl: 'http://127.0.0.1:8899', - keypair: Keypair.generate(), - programId: ZERO_KEY, - pollIntervalMs: 5_000, -}; - -/** Flush the microtask chain so an in-flight (unawaited) `void tick()` settles — deep - * enough for several sequentially-awaited trySettle calls within one tick. */ -async function flush(): Promise { - for (let i = 0; i < 20; i++) await Promise.resolve(); -} - -beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - methods.settleBattle = originalSettleBattle; - fetchIdl.mockResolvedValue({ instructions: [] }); - settleBattleIxBuilder.accounts.mockReturnValue(settleBattleIxBuilder); - settleBattleIxBuilder.instruction.mockResolvedValue({ programId: ZERO_KEY, keys: [], data: Buffer.alloc(0) }); - getDefaultQueue.mockResolvedValue({ program: {} }); - asV0Tx.mockResolvedValue({}); - sendSignedTx.mockResolvedValue('sig123'); - fetchAssetByPetId.mockImplementation(async (_program: unknown, petId: number) => - (petId === 1 ? ATTACKER_ASSET : DEFENDER_ASSET), - ); - getAccountClient.mockReturnValue({ - all: vi.fn().mockResolvedValue([{ publicKey: BATTLE_REQUEST_KEY, account: rawAccount }]), - }); - revealIx.mockResolvedValue({ programId: ZERO_KEY, keys: [], data: Buffer.alloc(0) }); - getBalance.mockResolvedValue(1_000_000_000); // 1 SOL, well above MIN_BALANCE_LAMPORTS -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('startSolanaSettleKeeper', () => { - it('refuses to start with a clear error when the fetched IDL has no settleBattle instruction', async () => { - delete (methods as Partial).settleBattle; - - await expect(startSolanaSettleKeeper(baseConfig)).rejects.toThrow(/no settleBattle instruction/); - }); - - it('settles an open battle request once Switchboard has revealed', async () => { - const handle = await startSolanaSettleKeeper(baseConfig); - await flush(); - - expect(settleBattleIxBuilder.accounts).toHaveBeenCalledWith( - expect.objectContaining({ - battleRequest: BATTLE_REQUEST_KEY, - attackerAsset: ATTACKER_ASSET, - defenderAsset: DEFENDER_ASSET, - }), - ); - expect(sendSignedTx).toHaveBeenCalled(); - handle.stop(); - }); - - it('does not settle yet when Switchboard has not revealed, and retries successfully next tick', async () => { - revealIx.mockRejectedValueOnce(new Error('not ready')); - - const handle = await startSolanaSettleKeeper(baseConfig); - await flush(); - expect(sendSignedTx).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(baseConfig.pollIntervalMs); - expect(sendSignedTx).toHaveBeenCalled(); - handle.stop(); - }); - - it('logs and skips when pet asset lookup fails', async () => { - fetchAssetByPetId.mockRejectedValue(new Error('rpc down')); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - - const handle = await startSolanaSettleKeeper(baseConfig); - await flush(); - - expect(sendSignedTx).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('failed to look up pet assets')); - errorSpy.mockRestore(); - handle.stop(); - }); - - it('logs and skips when a pet asset is not found', async () => { - fetchAssetByPetId.mockResolvedValue(null); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - - const handle = await startSolanaSettleKeeper(baseConfig); - await flush(); - - expect(sendSignedTx).not.toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('asset not found')); - errorSpy.mockRestore(); - handle.stop(); - }); - - it('fetches the Switchboard queue once per tick, not once per pending request', async () => { - const secondRequestKey = new PublicKey('11111111111111111111111111111115'); - getAccountClient.mockReturnValue({ - all: vi.fn().mockResolvedValue([ - { publicKey: BATTLE_REQUEST_KEY, account: rawAccount }, - { publicKey: secondRequestKey, account: rawAccount }, - ]), - }); - - const handle = await startSolanaSettleKeeper(baseConfig); - await flush(); - - expect(sendSignedTx).toHaveBeenCalledTimes(2); // both requests settled... - expect(getDefaultQueue).toHaveBeenCalledTimes(1); // ...off a single queue fetch - handle.stop(); - }); - - it('stop() halts further polling', async () => { - const handle = await startSolanaSettleKeeper(baseConfig); - await flush(); - const allMock = getAccountClient.mock.results[0]!.value.all as ReturnType; - const callsBefore = allMock.mock.calls.length; - - handle.stop(); - await vi.advanceTimersByTimeAsync(baseConfig.pollIntervalMs * 3); - - expect(allMock.mock.calls.length).toBe(callsBefore); - }); - - it('warns when the keeper wallet balance is below the minimum threshold', async () => { - getBalance.mockResolvedValue(1); // effectively empty - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - - const handle = await startSolanaSettleKeeper(baseConfig); - await flush(); - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('balance is low')); - errorSpy.mockRestore(); - handle.stop(); - }); - - it('does not warn when the keeper wallet balance is sufficient', async () => { - getBalance.mockResolvedValue(1_000_000_000); // 1 SOL - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - - const handle = await startSolanaSettleKeeper(baseConfig); - await flush(); - - expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('balance is low')); - errorSpy.mockRestore(); - handle.stop(); - }); - - it('re-checks the wallet balance periodically', async () => { - const handle = await startSolanaSettleKeeper(baseConfig); - await flush(); - const callsBefore = getBalance.mock.calls.length; - - await vi.advanceTimersByTimeAsync(10 * 60_000); - - expect(getBalance.mock.calls.length).toBeGreaterThan(callsBefore); - handle.stop(); - }); -}); diff --git a/backend/tests/features/settle-keeper/keeper.test.ts b/backend/tests/features/settle-keeper/keeper.test.ts index b5062444..2c0f4c0a 100644 --- a/backend/tests/features/settle-keeper/keeper.test.ts +++ b/backend/tests/features/settle-keeper/keeper.test.ts @@ -85,12 +85,12 @@ afterEach(() => { describe('startKeeper', () => { it('backfills still-pending requests on startup and attempts to settle them', async () => { - backfillLogs = [{ eventName: 'BattleRandomnessRequested', args: { requestId: 7n } }]; + backfillLogs = [{ eventName: 'BreedRandomnessRequested', args: { requestId: 7n } }]; const handle = await startKeeper(baseConfig); await flush(); - expect(submit).toHaveBeenCalledWith('settleBattle', 7n); + expect(submit).toHaveBeenCalledWith('settleBreed', 7n); handle.stop(); }); @@ -100,7 +100,7 @@ describe('startKeeper', () => { // hiccup, gas spike, momentarily low balance), nothing would retry it without // this sweep. A still-tracked (never untracked) request should keep getting // re-attempted on a timer regardless of what triggered the first attempt. - backfillLogs = [{ eventName: 'BattleRandomnessRequested', args: { requestId: 7n } }]; + backfillLogs = [{ eventName: 'BreedRandomnessRequested', args: { requestId: 7n } }]; const handle = await startKeeper(baseConfig); await flush(); @@ -116,14 +116,14 @@ describe('startKeeper', () => { }); it('stops re-attempting a request once it is settled (untracked)', async () => { - backfillLogs = [{ eventName: 'BattleRandomnessRequested', args: { requestId: 7n } }]; + backfillLogs = [{ eventName: 'BreedRandomnessRequested', args: { requestId: 7n } }]; const handle = await startKeeper(baseConfig); await flush(); submit.mockClear(); // The settlement lands on the live watch (by us or anyone else) — untracks it. currentBlock = 101n; - gameLogicLiveLogs = [{ eventName: 'BattleResolved', args: { requestId: 7n } }]; + gameLogicLiveLogs = [{ eventName: 'BreedSettled', args: { requestId: 7n } }]; await vi.advanceTimersByTimeAsync(4_000); submit.mockClear(); @@ -135,8 +135,8 @@ describe('startKeeper', () => { it('does not resettle a backfilled request whose settlement is also in the backfill window', async () => { backfillLogs = [ - { eventName: 'BattleRandomnessRequested', args: { requestId: 7n } }, - { eventName: 'BattleResolved', args: { requestId: 7n } }, + { eventName: 'BreedRandomnessRequested', args: { requestId: 7n } }, + { eventName: 'BreedSettled', args: { requestId: 7n } }, ]; const handle = await startKeeper(baseConfig); @@ -148,7 +148,7 @@ describe('startKeeper', () => { it('warns when a still-pending backfilled request sits near the oldest edge of the backfill window', async () => { // backfillBlocks=50n, currentBlock=100n -> window is [50n, 100n], staleness edge at 55n. - backfillLogs = [{ eventName: 'BattleRandomnessRequested', args: { requestId: 7n }, blockNumber: 52n }]; + backfillLogs = [{ eventName: 'BreedRandomnessRequested', args: { requestId: 7n }, blockNumber: 52n }]; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); const handle = await startKeeper(baseConfig); @@ -160,7 +160,7 @@ describe('startKeeper', () => { }); it('does not warn when a still-pending backfilled request is well inside the backfill window', async () => { - backfillLogs = [{ eventName: 'BattleRandomnessRequested', args: { requestId: 7n }, blockNumber: 90n }]; + backfillLogs = [{ eventName: 'BreedRandomnessRequested', args: { requestId: 7n }, blockNumber: 90n }]; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); const handle = await startKeeper(baseConfig); @@ -176,18 +176,18 @@ describe('startKeeper', () => { await flush(); submit.mockClear(); - // A new block arrives with a fresh battle request and, in the same window, its + // A new block arrives with a fresh breed request and, in the same window, its // entropy reveal — mirrors the real sequence (request confirms, then some blocks // later Pyth's callback lands). currentBlock = 101n; - gameLogicLiveLogs = [{ eventName: 'BattleRandomnessRequested', args: { requestId: 9n } }]; + gameLogicLiveLogs = [{ eventName: 'BreedRandomnessRequested', args: { requestId: 9n } }]; entropyLiveLogs = [ { eventName: 'Revealed', args: { caller: GAME_LOGIC, sequenceNumber: 9n, callbackFailed: false, randomNumber: '0x01' } }, ]; await vi.advanceTimersByTimeAsync(4_000); - expect(submit).toHaveBeenCalledWith('settleBattle', 9n); + expect(submit).toHaveBeenCalledWith('settleBreed', 9n); handle.stop(); }); @@ -198,7 +198,7 @@ describe('startKeeper', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); currentBlock = 101n; - gameLogicLiveLogs = [{ eventName: 'BattleRandomnessRequested', args: { requestId: 11n } }]; + gameLogicLiveLogs = [{ eventName: 'BreedRandomnessRequested', args: { requestId: 11n } }]; entropyLiveLogs = [ { eventName: 'Revealed', args: { caller: GAME_LOGIC, sequenceNumber: 11n, callbackFailed: true } }, ]; @@ -212,13 +212,13 @@ describe('startKeeper', () => { }); it('untracks a request once its settlement is observed on the live watch, so a later reveal is a no-op', async () => { - backfillLogs = [{ eventName: 'BattleRandomnessRequested', args: { requestId: 7n } }]; + backfillLogs = [{ eventName: 'BreedRandomnessRequested', args: { requestId: 7n } }]; const handle = await startKeeper(baseConfig); await flush(); submit.mockClear(); currentBlock = 101n; - gameLogicLiveLogs = [{ eventName: 'BattleResolved', args: { requestId: 7n } }]; + gameLogicLiveLogs = [{ eventName: 'BreedSettled', args: { requestId: 7n } }]; await vi.advanceTimersByTimeAsync(4_000); currentBlock = 102n; diff --git a/backend/tests/features/settle-keeper/requests.test.ts b/backend/tests/features/settle-keeper/requests.test.ts index 0192cf38..28253b39 100644 --- a/backend/tests/features/settle-keeper/requests.test.ts +++ b/backend/tests/features/settle-keeper/requests.test.ts @@ -13,49 +13,46 @@ function log(eventName: string, requestId?: bigint): DecodedGameLogicLog { describe('requestTypeForEvent', () => { it('maps each request event to its tracked type', () => { - expect(requestTypeForEvent('BattleRandomnessRequested')).toBe('battle'); expect(requestTypeForEvent('BreedRandomnessRequested')).toBe('breed'); expect(requestTypeForEvent('MintRequested')).toBe('mint'); }); it('returns undefined for settlement or unknown events', () => { - expect(requestTypeForEvent('BattleResolved')).toBeUndefined(); + expect(requestTypeForEvent('BreedSettled')).toBeUndefined(); expect(requestTypeForEvent('SomeUnrelatedEvent')).toBeUndefined(); }); }); describe('settleFunctionFor', () => { it('maps each tracked type to its settle function', () => { - expect(settleFunctionFor('battle')).toBe('settleBattle'); expect(settleFunctionFor('breed')).toBe('settleBreed'); expect(settleFunctionFor('mint')).toBe('settleMint'); }); }); describe('isSettledEvent', () => { - it('recognizes all three settlement events', () => { - expect(isSettledEvent('BattleResolved')).toBe(true); + it('recognizes both settlement events', () => { expect(isSettledEvent('BreedSettled')).toBe(true); expect(isSettledEvent('MintSettled')).toBe(true); }); it('rejects request and unrelated events', () => { - expect(isSettledEvent('BattleRandomnessRequested')).toBe(false); + expect(isSettledEvent('BreedRandomnessRequested')).toBe(false); expect(isSettledEvent('SomeUnrelatedEvent')).toBe(false); }); }); describe('buildPendingMap', () => { it('tracks a request with no matching settlement', () => { - const pending = buildPendingMap([log('BattleRandomnessRequested', 1n)], []); - expect(pending.get(1n)).toBe('battle'); + const pending = buildPendingMap([log('BreedRandomnessRequested', 1n)], []); + expect(pending.get(1n)).toBe('breed'); expect(pending.size).toBe(1); }); it('removes a request once its settlement is seen in the same window', () => { const pending = buildPendingMap( - [log('BattleRandomnessRequested', 1n)], - [log('BattleResolved', 1n)], + [log('BreedRandomnessRequested', 1n)], + [log('BreedSettled', 1n)], ); expect(pending.has(1n)).toBe(false); }); @@ -63,25 +60,23 @@ describe('buildPendingMap', () => { it('tracks multiple request types independently', () => { const pending = buildPendingMap( [ - log('BattleRandomnessRequested', 1n), log('BreedRandomnessRequested', 2n), log('MintRequested', 3n), ], - [log('BattleResolved', 1n)], // only the battle settles + [log('BreedSettled', 2n)], // only the breed settles ); - expect(pending.has(1n)).toBe(false); - expect(pending.get(2n)).toBe('breed'); + expect(pending.has(2n)).toBe(false); expect(pending.get(3n)).toBe('mint'); - expect(pending.size).toBe(2); + expect(pending.size).toBe(1); }); it('ignores logs with no requestId (defensive — should not happen in practice)', () => { - const pending = buildPendingMap([log('BattleRandomnessRequested')], []); + const pending = buildPendingMap([log('BreedRandomnessRequested')], []); expect(pending.size).toBe(0); }); it('a settlement for an unseen requestId is a harmless no-op', () => { - const pending = buildPendingMap([], [log('BattleResolved', 999n)]); + const pending = buildPendingMap([], [log('BreedSettled', 999n)]); expect(pending.size).toBe(0); }); }); diff --git a/indexer-go/internal/evm/battles.go b/indexer-go/internal/evm/battles.go deleted file mode 100644 index 1ee03833..00000000 --- a/indexer-go/internal/evm/battles.go +++ /dev/null @@ -1,90 +0,0 @@ -package evm - -import ( - "context" - "fmt" - "log/slog" - "strconv" - "strings" - - "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" -) - -// tickBattles runs one battle sync, logging instead of failing the loop. -func (ix *Indexer) tickBattles(ctx context.Context, battles chan<- indexer.BattleEvent) { - if battles == nil { - return - } - synced, err := ix.syncBattles(ctx, battles) - switch { - case err != nil && ctx.Err() != nil: - case err != nil && strings.Contains(err.Error(), "has no field `battles`"): - slog.Warn("evm battle sync skipped: Battle entity not deployed on subgraph yet") - case err != nil: - slog.Error("evm battle sync failed", "err", err) - case synced > 0: - slog.Info("evm battle sync", "synced", synced, "watermark", ix.battleWatermark) - } -} - -// syncBattles pages Battle entities settled after the watermark, oldest -// first. foughtAt is the per-chain version for resume; equal-timestamp -// battles land in the same block, so The Graph exposes them atomically and -// the strict `_gt` cannot split them across polls. -func (ix *Indexer) syncBattles(ctx context.Context, battles chan<- indexer.BattleEvent) (int, error) { - emitted := 0 - for { - page, err := ix.client.fetchBattlesPage(ctx, strconv.FormatUint(ix.battleWatermark, 10)) - if err != nil { - return emitted, err - } - if len(page) == 0 { - return emitted, nil - } - - maxFoughtAt := ix.battleWatermark - for _, b := range page { - foughtAt, err := strconv.ParseUint(b.FoughtAt, 10, 64) - if err != nil { - return emitted, fmt.Errorf("battle %s: invalid foughtAt %q: %w", b.ID, b.FoughtAt, err) - } - event := indexer.BattleEvent{ - Chain: ix.chain, - BattleID: b.ID, - Attacker: b.Attacker, - Defender: b.Defender, - WinnerPetID: b.WinnerPetID, - LoserPetID: idOrZero(b.LoserPetID), - Seed: normalizeSeed(b.Seed), - Rounds: b.Rounds, - WinnerHpRemaining: b.WinnerHpRemaining, - XPWin: b.XPWin, - XPLoss: b.XPLoss, - Version: foughtAt, - FoughtAt: int64(foughtAt), - } - select { - case <-ctx.Done(): - return emitted, ctx.Err() - case battles <- event: - emitted++ - } - if foughtAt > maxFoughtAt { - maxFoughtAt = foughtAt - } - } - - if maxFoughtAt == ix.battleWatermark { - // A full page sharing one timestamp cannot advance the cursor; - // bail rather than loop forever. Page size 1000 makes this a - // pathological case, not a real one. - slog.Warn("evm battle sync: page did not advance watermark", "foughtAt", maxFoughtAt) - return emitted, nil - } - ix.battleWatermark = maxFoughtAt - - if len(page) < ix.client.pageSize { - return emitted, nil - } - } -} diff --git a/indexer-go/internal/evm/client.go b/indexer-go/internal/evm/client.go index b9d8eed1..5d2da7f9 100644 --- a/indexer-go/internal/evm/client.go +++ b/indexer-go/internal/evm/client.go @@ -31,17 +31,6 @@ const ( ` + petFields + ` } } -` - // The Battle entity joins BattleRandomnessRequested (attacker/defender) with - // BattleResolved (winner/loser + sim outputs) by requestId — the subgraph is - // the EVM join layer (plan §3.5). The v2 sim fields require the subgraph - // schema bump (plan §6 open decision). - battlesQuery = ` - query BattlesSince($first: Int!, $since: BigInt!) { - battles(first: $first, orderBy: foughtAt, orderDirection: asc, where: { foughtAt_gt: $since }) { - id attacker defender winnerPetId loserPetId seed rounds winnerHpRemaining xpWin xpLoss foughtAt - } - } ` ) @@ -153,17 +142,6 @@ func (c *client) fetchPetsPage(ctx context.Context, query string, variables map[ return data.Pets, nil } -func (c *client) fetchBattlesPage(ctx context.Context, since string) ([]subgraphBattle, error) { - var data struct { - Battles []subgraphBattle `json:"battles"` - } - vars := map[string]any{"first": c.pageSize, "since": since} - if err := c.query(ctx, battlesQuery, vars, &data); err != nil { - return nil, err - } - return data.Battles, nil -} - // paginate cursor-pages through all matching pets using the given query and // variable builder, same contract as the TS implementation. func (c *client) paginate( diff --git a/indexer-go/internal/evm/indexer.go b/indexer-go/internal/evm/indexer.go index d41b17c5..1a796bbb 100644 --- a/indexer-go/internal/evm/indexer.go +++ b/indexer-go/internal/evm/indexer.go @@ -93,13 +93,18 @@ func (ix *Indexer) sync(ctx context.Context, roster chan<- indexer.RosterUpdate) return ix.emit(ctx, roster, pets) } -// Run scans once to prime the watermark, then polls incrementally — pets and -// battles on the same ticker. Transient subgraph errors are logged and -// retried on the next tick. +// Run scans once to prime the watermark, then polls incrementally. Transient +// subgraph errors are logged and retried on the next tick. +// +// Battles are no longer ingested (§L Phase 6): GameLogic has no requestBattle / +// settleBattle and the subgraph no longer emits a Battle entity, so there is nothing on +// chain left to index. The `battles` channel is kept in the signature because the +// delivery path behind it (battle_history, the bus, StreamLiveBattles) is still wired and +// would be the place to feed backend-resolved receipts if they are ever mirrored here. func (ix *Indexer) Run( ctx context.Context, roster chan<- indexer.RosterUpdate, - battles chan<- indexer.BattleEvent, + _ chan<- indexer.BattleEvent, ) error { if scanned, err := ix.Scan(ctx, roster); err != nil { if ctx.Err() != nil { @@ -109,7 +114,6 @@ func (ix *Indexer) Run( } else { slog.Info("evm scan complete", "scanned", scanned, "watermark", ix.watermark) } - ix.tickBattles(ctx, battles) ticker := time.NewTicker(ix.poll) defer ticker.Stop() @@ -128,8 +132,7 @@ func (ix *Indexer) Run( case synced > 0: slog.Info("evm sync", "synced", synced, "watermark", ix.watermark) } - ix.tickBattles(ctx, battles) - } + } } } diff --git a/indexer-go/internal/evm/indexer_test.go b/indexer-go/internal/evm/indexer_test.go index 0be694b4..8de3cb8f 100644 --- a/indexer-go/internal/evm/indexer_test.go +++ b/indexer-go/internal/evm/indexer_test.go @@ -285,113 +285,3 @@ func TestNewRequiresURL(t *testing.T) { } } -func battle(id, attacker, defender, winner, foughtAt string) subgraphBattle { - return subgraphBattle{ - ID: id, Attacker: attacker, Defender: defender, WinnerPetID: winner, FoughtAt: foughtAt, - // Fixed v2 sim outputs so the mapping is exercised. seed "291" = 0x123. - LoserPetID: defender, Seed: "291", Rounds: 6, WinnerHpRemaining: 174, XPWin: 100, XPLoss: 25, - } -} - -func TestSyncBattlesSweepsHistoryThenOnlyNew(t *testing.T) { - fake := &fakeSubgraph{battles: []subgraphBattle{ - battle("0xaaa-1", "1", "2", "1", "100"), - battle("0xbbb-3", "2", "3", "3", "200"), - }} - - ix := newTestIndexer(t, fake.handler(t), 100) - ch := make(chan indexer.BattleEvent, 10) - - // First sync from watermark 0: full history backfill. - emitted, err := ix.syncBattles(context.Background(), ch) - if err != nil { - t.Fatalf("syncBattles: %v", err) - } - if emitted != 2 { - t.Fatalf("emitted = %d, want 2", emitted) - } - if ix.battleWatermark != 200 { - t.Errorf("battleWatermark = %d, want 200", ix.battleWatermark) - } - - first := <-ch - if first.BattleID != "0xaaa-1" || first.Chain != "evm" || first.WinnerPetID != "1" || - first.Version != 100 || first.FoughtAt != 100 { - t.Errorf("first event = %+v", first) - } - // v2 sim outputs map through, and the decimal seed normalizes to 0x-hex. - if first.Rounds != 6 || first.WinnerHpRemaining != 174 || first.XPWin != 100 || first.XPLoss != 25 { - t.Errorf("first event sim fields = %+v", first) - } - if first.Seed != "0x0000000000000000000000000000000000000000000000000000000000000123" { - t.Errorf("seed = %q, want normalized 0x-hex of decimal 291", first.Seed) - } - - // Quiet tick: nothing new. - emitted, err = ix.syncBattles(context.Background(), ch) - if err != nil || emitted != 0 { - t.Fatalf("quiet sync: emitted=%d err=%v, want 0/nil", emitted, err) - } - - // One new settle arrives. - fake.battles = append(fake.battles, battle("0xccc-0", "1", "3", "3", "300")) - emitted, err = ix.syncBattles(context.Background(), ch) - if err != nil || emitted != 1 { - t.Fatalf("incremental sync: emitted=%d err=%v, want 1/nil", emitted, err) - } - <-ch // drain b2 - if got := <-ch; got.BattleID != "0xccc-0" || got.Version != 300 { - t.Errorf("incremental event = %+v", got) - } -} - -func TestSyncBattlesPaginatesFullPages(t *testing.T) { - fake := &fakeSubgraph{battles: []subgraphBattle{ - battle("b1", "1", "2", "1", "100"), - battle("b2", "1", "2", "2", "200"), - battle("b3", "1", "2", "1", "300"), - }} - - ix := newTestIndexer(t, fake.handler(t), 2) // page size 2 forces a second page - ch := make(chan indexer.BattleEvent, 10) - - emitted, err := ix.syncBattles(context.Background(), ch) - if err != nil { - t.Fatalf("syncBattles: %v", err) - } - if emitted != 3 { - t.Errorf("emitted = %d, want 3 across two pages", emitted) - } - if ix.battleWatermark != 300 { - t.Errorf("battleWatermark = %d, want 300", ix.battleWatermark) - } -} - -func TestRunPollsBattlesAlongsideRoster(t *testing.T) { - fake := &fakeSubgraph{ - pets: []subgraphPet{pet("1", "0xA", 5, "100")}, - battles: []subgraphBattle{battle("0xdd-2", "1", "2", "2", "150")}, - } - - ix := newTestIndexer(t, fake.handler(t), 100) - roster := make(chan indexer.RosterUpdate, 10) - battles := make(chan indexer.BattleEvent, 10) - ctx, cancel := context.WithCancel(context.Background()) - - done := make(chan error, 1) - go func() { done <- ix.Run(ctx, roster, battles) }() - - select { - case b := <-battles: - if b.BattleID != "0xdd-2" || b.WinnerPetID != "2" { - t.Errorf("battle = %+v", b) - } - case <-time.After(2 * time.Second): - t.Fatal("Run never emitted the battle") - } - - cancel() - if err := <-done; err != nil { - t.Errorf("Run = %v on clean shutdown, want nil", err) - } -} From 28bc05eb4b611c070d1a044b960a02154f1898b7 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 19:19:13 -0400 Subject: [PATCH 48/76] refactor(indexer-go): stop ingesting on-chain battles --- indexer-go/internal/solana/backfill.go | 71 ------------- indexer-go/internal/solana/decode.go | 92 +---------------- indexer-go/internal/solana/decode_test.go | 64 ------------ indexer-go/internal/solana/indexer_test.go | 106 +------------------- indexer-go/internal/solana/notifications.go | 45 +-------- indexer-go/internal/solana/session.go | 23 ++--- 6 files changed, 14 insertions(+), 387 deletions(-) delete mode 100644 indexer-go/internal/solana/backfill.go diff --git a/indexer-go/internal/solana/backfill.go b/indexer-go/internal/solana/backfill.go deleted file mode 100644 index 6996b3c9..00000000 --- a/indexer-go/internal/solana/backfill.go +++ /dev/null @@ -1,71 +0,0 @@ -package solana - -import ( - "context" - "fmt" - "log/slog" - "time" - - "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" -) - -const backfillLimit = 1000 // getSignaturesForAddress page cap - -// backfillBattles sweeps signatures newer than lastSig and re-emits any -// BattleResult they carry. On the very first connect there is no baseline — -// set one at the chain head instead of replaying history that predates the -// indexer (battle_history rows before that exist via the dialogue path). -func (ix *Indexer) backfillBattles(ctx context.Context, battles chan<- indexer.BattleEvent) error { - if ix.lastSig == "" { - head, err := ix.rpc.getSignaturesForAddress(ctx, ix.cfg.ProgramID, "", 1) - if err != nil { - return fmt.Errorf("baseline: %w", err) - } - if len(head) > 0 { - ix.lastSig = head[0].Signature - } - return nil - } - - sigs, err := ix.rpc.getSignaturesForAddress(ctx, ix.cfg.ProgramID, ix.lastSig, backfillLimit) - if err != nil { - return err - } - if len(sigs) == 0 { - return nil - } - - // Newest-first from RPC; emit oldest-first so the stream stays ordered. - emitted := 0 - for i := len(sigs) - 1; i >= 0; i-- { - sig := sigs[i] - if sig.failed() { - continue - } - tx, err := ix.rpc.getTransaction(ctx, sig.Signature) - if err != nil { - return fmt.Errorf("tx %s: %w", sig.Signature, err) - } - if tx.Meta == nil { - continue - } - foughtAt := time.Now().Unix() - if tx.BlockTime != nil { - foughtAt = *tx.BlockTime - } - for _, r := range parseBattleResults(tx.Meta.LogMessages) { - select { - case <-ctx.Done(): - return ctx.Err() - case battles <- r.toBattleEvent(sig.Signature, sig.Slot, foughtAt): - emitted++ - } - } - } - ix.lastSig = sigs[0].Signature - - if emitted > 0 { - slog.Info("solana battle backfill", "signatures", len(sigs), "battles", emitted) - } - return nil -} diff --git a/indexer-go/internal/solana/decode.go b/indexer-go/internal/solana/decode.go index f8025363..f21dee6f 100644 --- a/indexer-go/internal/solana/decode.go +++ b/indexer-go/internal/solana/decode.go @@ -1,20 +1,13 @@ package solana -// Decoders for the two on-chain shapes the adapter consumes: +// Decoder for the on-chain shape the adapter consumes: // - PetAccount state (port of backend/indexing/solana/scanner/decode.ts) -// - the BattleResult Anchor event emitted by settle_battle -// (contracts/solana/.../instructions/settle_battle.rs) import ( "bytes" - "crypto/sha256" _ "embed" - "encoding/base64" - "encoding/binary" - "encoding/hex" "fmt" "strconv" - "strings" "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" ) @@ -82,86 +75,3 @@ func decodePetAccount(layout *accountLayout, data []byte) (indexer.RosterUpdate, Asset: fields["asset"].(string), // base58 Core asset pubkey }, true } - -// battleResolved mirrors the v2 BattleResolved Anchor event -// (settle_battle.rs). The winner/loser ids are absolute (resolved on-chain), -// and the seed makes the round-based sim replayable off-chain. -type battleResolved struct { - AttackerPetID uint32 - DefenderPetID uint32 - WinnerPetID uint32 - LoserPetID uint32 - Seed [32]byte - FirstWins bool - Rounds uint8 - WinnerHpRemaining uint16 - XPWin uint32 - XPLoss uint32 -} - -// battleResolvedBodyLen is the Borsh body length (excluding the 8-byte -// discriminator): 4+4+4+4+32+1+1+2+4+4. -const battleResolvedBodyLen = 60 - -// Anchor event discriminator: sha256("event:BattleResolved")[:8]. -var battleResolvedDiscriminator = func() []byte { - sum := sha256.Sum256([]byte("event:BattleResolved")) - return sum[:8] -}() - -const programDataPrefix = "Program data: " - -// parseBattleResults extracts every BattleResolved event from a transaction's -// log messages. Anchor emits events as base64 `Program data:` lines holding -// an 8-byte event discriminator + Borsh body; other events and undecodable -// lines are skipped. -func parseBattleResults(logs []string) []battleResolved { - var results []battleResolved - for _, line := range logs { - payload, found := strings.CutPrefix(line, programDataPrefix) - if !found { - continue - } - raw, err := base64.StdEncoding.DecodeString(payload) - if err != nil || len(raw) != 8+battleResolvedBodyLen { - continue - } - if !bytes.Equal(raw[:8], battleResolvedDiscriminator) { - continue - } - b := raw[8:] - var r battleResolved - r.AttackerPetID = binary.LittleEndian.Uint32(b[0:4]) - r.DefenderPetID = binary.LittleEndian.Uint32(b[4:8]) - r.WinnerPetID = binary.LittleEndian.Uint32(b[8:12]) - r.LoserPetID = binary.LittleEndian.Uint32(b[12:16]) - copy(r.Seed[:], b[16:48]) - r.FirstWins = b[48] != 0 - r.Rounds = b[49] - r.WinnerHpRemaining = binary.LittleEndian.Uint16(b[50:52]) - r.XPWin = binary.LittleEndian.Uint32(b[52:56]) - r.XPLoss = binary.LittleEndian.Uint32(b[56:60]) - results = append(results, r) - } - return results -} - -// toBattleEvent maps an on-chain result to the pipeline shape. Winner/loser -// are already absolute pet ids on-chain (matching battle_history semantics). -func (r battleResolved) toBattleEvent(signature string, slot uint64, foughtAt int64) indexer.BattleEvent { - return indexer.BattleEvent{ - Chain: "solana", - BattleID: signature, - Attacker: strconv.FormatUint(uint64(r.AttackerPetID), 10), - Defender: strconv.FormatUint(uint64(r.DefenderPetID), 10), - WinnerPetID: strconv.FormatUint(uint64(r.WinnerPetID), 10), - LoserPetID: strconv.FormatUint(uint64(r.LoserPetID), 10), - Seed: "0x" + hex.EncodeToString(r.Seed[:]), - Rounds: uint32(r.Rounds), - WinnerHpRemaining: uint32(r.WinnerHpRemaining), - XPWin: r.XPWin, - XPLoss: r.XPLoss, - Version: slot, - FoughtAt: foughtAt, - } -} diff --git a/indexer-go/internal/solana/decode_test.go b/indexer-go/internal/solana/decode_test.go index 5cdc739f..03340c15 100644 --- a/indexer-go/internal/solana/decode_test.go +++ b/indexer-go/internal/solana/decode_test.go @@ -2,7 +2,6 @@ package solana import ( "bytes" - "encoding/base64" "encoding/binary" "testing" ) @@ -203,66 +202,3 @@ func fieldDataOffset(t *testing.T, name string) int { return 0 } -// buildBattleLog serializes a BattleResolved event as the on-chain program -// emits it: 8-byte discriminator + Borsh body. attackerWon picks which pet id -// fills the winner/loser slots (the chain resolves these absolutely). -func buildBattleLog(attacker, defender uint32, attackerWon bool) string { - winner, loser := defender, attacker - if attackerWon { - winner, loser = attacker, defender - } - raw := make([]byte, 8+battleResolvedBodyLen) - copy(raw, battleResolvedDiscriminator) - b := raw[8:] - binary.LittleEndian.PutUint32(b[0:4], attacker) - binary.LittleEndian.PutUint32(b[4:8], defender) - binary.LittleEndian.PutUint32(b[8:12], winner) - binary.LittleEndian.PutUint32(b[12:16], loser) - for i := range 32 { // distinctive seed: 0x00010203... - b[16+i] = byte(i) - } - if attackerWon { - b[48] = 1 // firstWins - } - b[49] = 6 // rounds - binary.LittleEndian.PutUint16(b[50:52], 174) // winnerHpRemaining - binary.LittleEndian.PutUint32(b[52:56], 100) // xpWin - binary.LittleEndian.PutUint32(b[56:60], 25) // xpLoss - return programDataPrefix + base64.StdEncoding.EncodeToString(raw) -} - -func TestParseBattleResults(t *testing.T) { - logs := []string{ - "Program EVzXwxHqwbTLMxfTG3amCb2Sjwmy5A7hqR59GbrvEyV1 invoke [1]", - "Program log: Instruction: SettleBattle", - buildBattleLog(7, 9, true), - programDataPrefix + "bm90LWFuLWV2ZW50", // valid b64, wrong shape — skipped - "Program data: %%%not-base64%%%", // undecodable — skipped - buildBattleLog(3, 5, false), - } - - results := parseBattleResults(logs) - if len(results) != 2 { - t.Fatalf("parsed %d results, want 2", len(results)) - } - if results[0].AttackerPetID != 7 || results[0].DefenderPetID != 9 || - results[0].WinnerPetID != 7 || results[0].LoserPetID != 9 || !results[0].FirstWins { - t.Errorf("first result: %+v", results[0]) - } - if results[0].Rounds != 6 || results[0].WinnerHpRemaining != 174 || - results[0].XPWin != 100 || results[0].XPLoss != 25 { - t.Errorf("first result sim fields: %+v", results[0]) - } - - event := results[1].toBattleEvent("sig123", 555, 1770000300) - if event.WinnerPetID != "5" || event.LoserPetID != "3" { - t.Errorf("defender won: winner=%s loser=%s, want 5/3", event.WinnerPetID, event.LoserPetID) - } - if event.Seed != "0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" { - t.Errorf("seed = %q", event.Seed) - } - if event.Chain != "solana" || event.BattleID != "sig123" || event.Attacker != "3" || - event.Defender != "5" || event.Version != 555 || event.FoughtAt != 1770000300 { - t.Errorf("event mapping: %+v", event) - } -} diff --git a/indexer-go/internal/solana/indexer_test.go b/indexer-go/internal/solana/indexer_test.go index bfca73f3..6e9668a2 100644 --- a/indexer-go/internal/solana/indexer_test.go +++ b/indexer-go/internal/solana/indexer_test.go @@ -163,20 +163,6 @@ func programNotification(slot uint64, data []byte) map[string]any { } } -func logsNotification(slot uint64, signature string, logs []string) map[string]any { - return map[string]any{ - "jsonrpc": "2.0", - "method": "logsNotification", - "params": map[string]any{ - "subscription": 2, - "result": map[string]any{ - "context": map[string]any{"slot": slot}, - "value": map[string]any{"signature": signature, "err": nil, "logs": logs}, - }, - }, - } -} - // newTestIndexer wires an Indexer to the fake RPC and a scripted dialer. func newTestIndexer(t *testing.T, rpc *fakeRPC, conns ...*fakeConn) (*Indexer, *atomic.Int32) { t.Helper() @@ -227,7 +213,7 @@ func TestScanEmitsDecodedPetsWithSnapshotSlot(t *testing.T) { } } -func TestSessionStreamsAccountAndBattleNotifications(t *testing.T) { +func TestSessionStreamsAccountNotifications(t *testing.T) { var owner [32]byte petData := buildPetAccount(t, 11, owner, 5, 1, 2, 100, 1, 0, "Nyx") rpc := &fakeRPC{slot: 1000} // empty roster scan, no signatures → baseline stays "" @@ -253,21 +239,9 @@ func TestSessionStreamsAccountAndBattleNotifications(t *testing.T) { t.Fatal("no roster update from program notification") } - conn.push(t, logsNotification(1300, "settleSig1", []string{ - "Program log: Instruction: SettleBattle", - buildBattleLog(11, 22, false), - })) - select { - case b := <-battles: - if b.BattleID != "settleSig1" || b.WinnerPetID != "22" || b.Version != 1300 { - t.Errorf("battle = %+v, want settleSig1 winner 22 slot 1300", b) - } - case <-time.After(2 * time.Second): - t.Fatal("no battle event from logs notification") - } - - if methods := conn.subscribeMethods(); len(methods) != 2 || - methods[0] != "programSubscribe" || methods[1] != "logsSubscribe" { + // Only the roster subscription is issued now: logsSubscribe existed to catch + // settle_battle's BattleResolved event, and battles no longer settle on chain. + if methods := conn.subscribeMethods(); len(methods) != 1 || methods[0] != "programSubscribe" { t.Errorf("subscriptions = %v", methods) } @@ -295,7 +269,7 @@ func TestRunRedialsAfterConnectionLoss(t *testing.T) { // Backoff after one healthy session is attempt 1: ~1-1.5s. testutil.WaitFor(t, "redial after drop", func() bool { return dials.Load() >= 2 }) - testutil.WaitFor(t, "resubscribe on new conn", func() bool { return len(conn2.subscribeMethods()) == 2 }) + testutil.WaitFor(t, "resubscribe on new conn", func() bool { return len(conn2.subscribeMethods()) == 1 }) cancel() if err := <-done; err != nil { @@ -303,73 +277,3 @@ func TestRunRedialsAfterConnectionLoss(t *testing.T) { } } -func TestBackfillEmitsMissedBattlesOldestFirst(t *testing.T) { - failedErr := json.RawMessage(`{"InstructionError":[0,"Custom"]}`) - rpc := &fakeRPC{ - signatures: []signatureInfo{ // newest-first, as RPC returns them - {Signature: "sig3", Slot: 30, BlockTime: ptr(int64(3000))}, - {Signature: "sigFailed", Slot: 25, Err: failedErr}, - {Signature: "sig2", Slot: 20, BlockTime: ptr(int64(2000))}, - {Signature: "sigOld", Slot: 10}, - }, - transactions: map[string]transactionResult{ - "sig2": txWithLogs(20, 2000, buildBattleLog(1, 2, true)), - "sig3": txWithLogs(30, 3000, buildBattleLog(3, 4, false)), - }, - } - - ix, _ := newTestIndexer(t, rpc) - ix.lastSig = "sigOld" - - battles := make(chan indexer.BattleEvent, 10) - if err := ix.backfillBattles(context.Background(), battles); err != nil { - t.Fatalf("backfill: %v", err) - } - close(battles) - - var events []indexer.BattleEvent - for b := range battles { - events = append(events, b) - } - if len(events) != 2 { - t.Fatalf("backfilled %d battles, want 2 (failed tx skipped)", len(events)) - } - if events[0].BattleID != "sig2" || events[1].BattleID != "sig3" { - t.Errorf("order = %s, %s — want oldest first", events[0].BattleID, events[1].BattleID) - } - if events[0].FoughtAt != 2000 { - t.Errorf("foughtAt = %d, want blockTime 2000", events[0].FoughtAt) - } - if ix.lastSig != "sig3" { - t.Errorf("lastSig = %q, want sig3", ix.lastSig) - } -} - -func TestBackfillSetsBaselineOnFirstConnect(t *testing.T) { - rpc := &fakeRPC{signatures: []signatureInfo{{Signature: "head", Slot: 99}}} - ix, _ := newTestIndexer(t, rpc) - - battles := make(chan indexer.BattleEvent, 1) - if err := ix.backfillBattles(context.Background(), battles); err != nil { - t.Fatalf("backfill: %v", err) - } - if len(battles) != 0 { - t.Error("baseline connect must not replay history") - } - if ix.lastSig != "head" { - t.Errorf("lastSig = %q, want head", ix.lastSig) - } -} - -func ptr[T any](v T) *T { return &v } - -func txWithLogs(slot uint64, blockTime int64, logs ...string) transactionResult { - var tx transactionResult - tx.Slot = slot - tx.BlockTime = &blockTime - tx.Meta = &struct { - Err json.RawMessage `json:"err"` - LogMessages []string `json:"logMessages"` - }{LogMessages: logs} - return tx -} diff --git a/indexer-go/internal/solana/notifications.go b/indexer-go/internal/solana/notifications.go index 3bb36f23..78d82c34 100644 --- a/indexer-go/internal/solana/notifications.go +++ b/indexer-go/internal/solana/notifications.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "log/slog" - "time" "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" ) @@ -22,13 +21,13 @@ type wsNotification struct { } // subNames maps subscribe request ids to what was requested (see subscribe). -var subNames = map[int]string{1: "programSubscribe", 2: "logsSubscribe"} +var subNames = map[int]string{1: "programSubscribe"} func (ix *Indexer) handleMessage( ctx context.Context, msg []byte, roster chan<- indexer.RosterUpdate, - battles chan<- indexer.BattleEvent, + _ chan<- indexer.BattleEvent, ) { var note wsNotification if err := json.Unmarshal(msg, ¬e); err != nil { @@ -51,8 +50,6 @@ func (ix *Indexer) handleMessage( switch note.Method { case "programNotification": ix.handleProgramNotification(ctx, note.Params.Result, roster) - case "logsNotification": - ix.handleLogsNotification(ctx, note.Params.Result, battles) } } @@ -89,41 +86,3 @@ func (ix *Indexer) handleProgramNotification( } } -func (ix *Indexer) handleLogsNotification( - ctx context.Context, - result json.RawMessage, - battles chan<- indexer.BattleEvent, -) { - var payload struct { - Context struct { - Slot uint64 `json:"slot"` - } `json:"context"` - Value struct { - Signature string `json:"signature"` - Err json.RawMessage `json:"err"` - Logs []string `json:"logs"` - } `json:"value"` - } - if err := json.Unmarshal(result, &payload); err != nil { - slog.Warn("solana bad logs notification", "err", err) - return - } - if string(payload.Value.Err) != "null" && len(payload.Value.Err) > 0 { - return // failed transaction - } - - // The notification carries no blockTime; the settle just happened, so - // wall clock is honest within seconds (matches the dialogue recorder). - now := time.Now().Unix() - for _, r := range parseBattleResults(payload.Value.Logs) { - event := r.toBattleEvent(payload.Value.Signature, payload.Context.Slot, now) - slog.Info("solana live battle", - "battle", event.BattleID, "winner", event.WinnerPetID, "slot", event.Version) - select { - case <-ctx.Done(): - return - case battles <- event: - } - } - ix.lastSig = payload.Value.Signature -} diff --git a/indexer-go/internal/solana/session.go b/indexer-go/internal/solana/session.go index 88d06510..fc463e4f 100644 --- a/indexer-go/internal/solana/session.go +++ b/indexer-go/internal/solana/session.go @@ -73,8 +73,7 @@ func (ix *Indexer) session( return false, fmt.Errorf("subscribe: %w", err) } - // Catch-up before streaming: a full account scan covers roster gaps, the - // signature sweep covers battles settled while disconnected. + // Catch-up before streaming: a full account scan covers roster gaps. if scanned, err := ix.Scan(ctx, roster); err != nil { if ctx.Err() != nil { return true, nil @@ -83,9 +82,6 @@ func (ix *Indexer) session( } else { slog.Info("solana catch-up scan complete", "scanned", scanned) } - if err := ix.backfillBattles(ctx, battles); err != nil && ctx.Err() == nil { - slog.Error("solana battle backfill failed", "err", err) - } msgs := make(chan []byte) readErr := make(chan error, 1) @@ -125,8 +121,11 @@ func (ix *Indexer) session( } } -// subscribe issues both subscriptions. Request ids are only used to tell +// subscribe issues the roster subscription. The request id is only used to tell // confirmations apart from notifications later; dispatch is by method name. +// +// There is no logsSubscribe any more: it existed to catch settle_battle's BattleResolved +// event, and battles are no longer settled on chain (§L Phase 6). func (ix *Indexer) subscribe(conn wsConn) error { programSub := map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "programSubscribe", @@ -139,17 +138,7 @@ func (ix *Indexer) subscribe(conn wsConn) error { }, }}, } - logsSub := map[string]any{ - "jsonrpc": "2.0", "id": 2, "method": "logsSubscribe", - "params": []any{ - map[string]any{"mentions": []string{ix.cfg.ProgramID}}, - map[string]any{"commitment": "confirmed"}, - }, - } - if err := conn.WriteJSON(programSub); err != nil { - return err - } - return conn.WriteJSON(logsSub) + return conn.WriteJSON(programSub) } // sleepBackoff waits min(base·2^attempt, cap) + jitter; false means ctx ended. From 600610bdcdf5b6173a458627459f1ed31889b2ec Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 19:37:35 -0400 Subject: [PATCH 49/76] refactor(shared): resolve battles through the backend, not the chain adapter --- backend/src/features/settle-keeper/keeper.ts | 2 +- .../src/features/settle-keeper/submitter.ts | 51 +- .../panels/battle/parts/battle-setup.tsx | 9 - .../battle/parts/pending-battle-notice.tsx | 86 ---- frontend/src/hooks/battle/useBattlePanel.ts | 18 +- shared/src/hooks/adapters/noneAdapter.ts | 71 ++- shared/src/hooks/adapters/types.ts | 138 +++--- shared/src/hooks/adapters/useEvmAdapter.ts | 440 +++++++++--------- shared/src/hooks/adapters/useSolanaAdapter.ts | 436 ++++++++--------- .../hooks/chains/ethereum/useEvmBattleFlow.ts | 241 ---------- .../chains/ethereum/useLiveBattleReplay.ts | 40 -- .../chains/ethereum/useLiveBattleSocket.ts | 62 --- .../hooks/chains/ethereum/usePendingBattle.ts | 99 ---- .../solana/useLiveBattleReplaySolana.ts | 176 ------- .../chains/solana/usePendingSolanaBattle.ts | 98 ---- .../src/hooks/chains/solana/usePetActions.ts | 40 -- shared/src/hooks/index.ts | 4 +- shared/src/hooks/useBattlePets.ts | 213 ++++++--- .../utils/solana/battleWithSwitchboardVrf.ts | 291 ------------ shared/src/utils/solana/index.ts | 1 - shared/tests/hooks/useBattlePets.test.ts | 251 +++++++--- shared/tests/hooks/useEvmAdapter.test.tsx | 11 - shared/tests/hooks/useEvmBattleFlow.test.tsx | 69 --- shared/tests/hooks/usePendingBattle.test.tsx | 77 --- .../hooks/usePendingSolanaBattle.test.tsx | 140 ------ shared/tests/hooks/useSolanaAdapter.test.tsx | 24 - .../solana/battleWithSwitchboardVrf.test.ts | 205 -------- 27 files changed, 866 insertions(+), 2427 deletions(-) delete mode 100644 frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx delete mode 100644 shared/src/hooks/chains/ethereum/useEvmBattleFlow.ts delete mode 100644 shared/src/hooks/chains/ethereum/useLiveBattleReplay.ts delete mode 100644 shared/src/hooks/chains/ethereum/useLiveBattleSocket.ts delete mode 100644 shared/src/hooks/chains/ethereum/usePendingBattle.ts delete mode 100644 shared/src/hooks/chains/solana/useLiveBattleReplaySolana.ts delete mode 100644 shared/src/hooks/chains/solana/usePendingSolanaBattle.ts delete mode 100644 shared/src/utils/solana/battleWithSwitchboardVrf.ts delete mode 100644 shared/tests/hooks/useEvmBattleFlow.test.tsx delete mode 100644 shared/tests/hooks/usePendingBattle.test.tsx delete mode 100644 shared/tests/hooks/usePendingSolanaBattle.test.tsx delete mode 100644 shared/tests/utils/solana/battleWithSwitchboardVrf.test.ts diff --git a/backend/src/features/settle-keeper/keeper.ts b/backend/src/features/settle-keeper/keeper.ts index 9dba61fa..7f426275 100644 --- a/backend/src/features/settle-keeper/keeper.ts +++ b/backend/src/features/settle-keeper/keeper.ts @@ -131,7 +131,7 @@ export async function startKeeper(config: SettleKeeperConfig): Promise(); function track(requestId: bigint, type: TrackedRequestType): void { diff --git a/backend/src/features/settle-keeper/submitter.ts b/backend/src/features/settle-keeper/submitter.ts index 94841768..f7810b54 100644 --- a/backend/src/features/settle-keeper/submitter.ts +++ b/backend/src/features/settle-keeper/submitter.ts @@ -1,8 +1,5 @@ -import { parseEventLogs, type Account, type Address, type Chain, type PublicClient, type Transport, type WalletClient } from 'viem'; +import type { Account, Address, Chain, PublicClient, Transport, WalletClient } from 'viem'; import { GAME_LOGIC_ABI, SETTLE_GAS_LIMIT, type SettleFunctionName } from './abi'; -import { broadcastLiveBattle } from '@ws/liveBattleSocket'; -// `@shared/core/node` — React-free surface; production resolves to dist/shared-node.cjs. -import { encodeBattleResolvedResult, type BattleResolvedResult } from '@shared/core/node'; export interface Submitter { /** Enqueues a settle call; resolves once it has been attempted (sent + confirmed, or @@ -25,7 +22,6 @@ export function createSubmitter( publicClient: PublicClient, walletClient: WalletClient, gameLogic: Address, - chainId: number, ): Submitter { let queue: Promise = Promise.resolve(); @@ -34,48 +30,6 @@ export function createSubmitter( return queue; } - /** Decodes `BattleResolved` from our own settle receipt and pushes it over the - * live-battle-socket, so the frontend never needs to watch for this event itself - * (see settle-keeper/keeper.ts's pollContractEvents comment on why that's unreliable). - * Best-effort: a decode failure here doesn't affect settling, which already succeeded. */ - function broadcastResolvedBattle(requestId: bigint, logs: readonly unknown[]): void { - try { - const decoded = parseEventLogs({ - abi: GAME_LOGIC_ABI, - logs: logs as never, - eventName: 'BattleResolved', - strict: false, - }); - const match = (decoded as unknown as { args: Record }[]).find( - (log) => log.args.requestId === requestId, - ); - if (!match) return; - const a = match.args; - const result: BattleResolvedResult = { - requestId: a.requestId as bigint, - winnerId: a.winnerId as bigint, - loserId: a.loserId as bigint, - vrfSeed: a.randomness as bigint, - firstWins: a.firstWins as boolean, - rounds: Number(a.rounds), - winnerHpRemaining: Number(a.winnerHpRemaining), - xpWin: Number(a.xpWin), - xpLoss: Number(a.xpLoss), - }; - broadcastLiveBattle({ - type: 'resolved', - chainId, - requestId: requestId.toString(), - result: encodeBattleResolvedResult(result), - }); - } catch (err) { - console.error( - `[settle-keeper] failed to decode/broadcast BattleResolved for ${requestId}: ` + - `${(err as Error).message.split('\n')[0]}`, - ); - } - } - async function trySettle(functionName: SettleFunctionName, requestId: bigint): Promise { try { await publicClient.simulateContract({ @@ -107,9 +61,6 @@ export function createSubmitter( `[settle-keeper] ${functionName}(${requestId}) ` + `${receipt.status === 'success' ? 'confirmed' : 'REVERTED'}`, ); - if (functionName === 'settleBattle' && receipt.status === 'success') { - broadcastResolvedBattle(requestId, receipt.logs); - } } catch (err) { console.error( `[settle-keeper] ${functionName}(${requestId}) failed to send/confirm: ` + diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx index bc63d4be..9d1025c0 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx @@ -15,7 +15,6 @@ import { import { Tones } from '@constants/tones'; import { AuthActionButton } from '@components/common'; import Icon, { BattleIcon } from '@components/ui/icon'; -import PendingBattleNotice from './pending-battle-notice'; import OpenToChallengesToggle from './open-to-challenges-toggle'; import { opponentKey, shortAddress } from '../battle-utils'; import styles from '../index.module.css'; @@ -251,14 +250,6 @@ const BattleSetup: React.FC = ({ - - {opponent ? ( - - ) : null} = ({ - petId, - label, - checkSolana = false, -}) => { - const pending = usePendingBattle(petId); - const solanaPending = usePendingSolanaBattle(checkSolana); - useTxErrorToast(pending.settle.error ?? pending.cancel.error ?? solanaPending.cancel.error); - - if (!pending.isPending && !solanaPending.isPending) return null; - - const who = label ?? `#${petId}`; - - if (solanaPending.isPending && !pending.isPending) { - const busy = solanaPending.cancel.isPending; - return ( -
-

- You have an unresolved battle on Solana. - {solanaPending.canCancel - ? ' Randomness has expired — cancel to free the pet for a new battle.' - : ' Starting a new battle will resume it automatically.'} -

- {solanaPending.canCancel && ( -
- -
- )} -
- ); - } - - const busy = pending.settle.isPending || pending.cancel.isPending; - return ( -
-

- {who} has an unresolved battle. - {pending.canCancel - ? ' Settle it once randomness is ready, or cancel it now.' - : ' Randomness has arrived — settle to complete the battle.'} -

-
- - {pending.canCancel && ( - - )} -
-
- ); -}; - -export default PendingBattleNotice; diff --git a/frontend/src/hooks/battle/useBattlePanel.ts b/frontend/src/hooks/battle/useBattlePanel.ts index 48055f9a..9f388917 100644 --- a/frontend/src/hooks/battle/useBattlePanel.ts +++ b/frontend/src/hooks/battle/useBattlePanel.ts @@ -8,7 +8,6 @@ import { useCreateBattleRoom, useOpponents, usePetList, - usePendingBattle, useWinEstimate, type TxLifecycle, type BattleResolvedResult, @@ -203,12 +202,6 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat ); const fighterLevel = selectedFighter?.level ?? null; - // An unresolved battle on either pet makes requestBattle revert ("Battle - // pending for pet"); block the new battle until it's settled/cancelled - // (the PendingBattleNotice in the setup view drives that). - const fighterPending = usePendingBattle(selectedPet1 || undefined); - const opponentPending = usePendingBattle(opponent?.id); - const hasPendingBattle = fighterPending.isPending || opponentPending.isPending; const sortedOpponents = useMemo( () => sortOpponentsByMatch(opponents, fighterLevel), [opponents, fighterLevel], @@ -419,17 +412,13 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat : hasResolvedEvent && !animation.done ? 'Result in — playing out the fight…' : !hasResolvedEvent && animation.done && battle.liveReplay - ? 'Finalizing on-chain…' + ? 'Finalizing…' : battle.phase === 'awaiting-vrf' ? 'Awaiting randomness…' - : battle.phase === 'awaiting-settle' - ? 'Settling the battle…' - : battle.phase === 'settling' - ? 'Settling the battle…' : battle.phase === 'resolving' ? 'Resolving the outcome…' : battle.isConfirming - ? 'Confirming on-chain…' + ? 'Verifying the receipt…' : battle.isPending ? 'Awaiting your wallet…' : null; @@ -450,8 +439,7 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat overlayOpen || !selectedPet1 || !selectedOpponent || - showResult || - hasPendingBattle; + showResult; const randomMatchDisabled = !canRandomMatch || battle.isPending || showResult; const fighterDisplayName = selectedFighter?.name ?? 'Your pet'; diff --git a/shared/src/hooks/adapters/noneAdapter.ts b/shared/src/hooks/adapters/noneAdapter.ts index 94165e79..6b0408ed 100644 --- a/shared/src/hooks/adapters/noneAdapter.ts +++ b/shared/src/hooks/adapters/noneAdapter.ts @@ -1,36 +1,35 @@ -import { NoActiveChainError } from '../../utils/pets/errors'; -import type { PetAction } from '../../types/pet'; -import type { ChainAdapter, AdapterMutation, ChainCapabilities } from './types'; - -const NONE_CAPABILITIES: ChainCapabilities = { - chainLabel: '', - address: { label: 'Recipient Address:', placeholder: '', isValid: () => false }, - levelUpFee: null, - renameMinLevel: 1, - randomness: { provider: null, appliesTo: [] }, - explorerTxUrl: () => null, - parseError: (_err, fallback) => ({ message: fallback, isUserRejection: false, isContractError: false }), -}; - -const disconnectedMutation = (action: PetAction): AdapterMutation => { - return { - mutateAsync: async (): Promise => { throw new NoActiveChainError(action); }, - lifecycle: { phase: 'idle', error: null, reset: () => undefined }, - isPending: false, - }; -}; - -export const noneAdapter: ChainAdapter = { - kind: 'none', - address: null, - isConnected: false, - capabilities: NONE_CAPABILITIES, - pets: { data: [], isLoading: false, error: null, refetch: () => undefined }, - createPet: disconnectedMutation('create'), - levelUpPet: disconnectedMutation('levelUp'), - trainPet: disconnectedMutation('train'), - renamePet: disconnectedMutation('rename'), - transferPet: disconnectedMutation('transfer'), - battlePets: disconnectedMutation('battle'), - breedPets: disconnectedMutation('breed'), -}; +import { NoActiveChainError } from '../../utils/pets/errors'; +import type { PetAction } from '../../types/pet'; +import type { ChainAdapter, AdapterMutation, ChainCapabilities } from './types'; + +const NONE_CAPABILITIES: ChainCapabilities = { + chainLabel: '', + address: { label: 'Recipient Address:', placeholder: '', isValid: () => false }, + levelUpFee: null, + renameMinLevel: 1, + randomness: { provider: null, appliesTo: [] }, + explorerTxUrl: () => null, + parseError: (_err, fallback) => ({ message: fallback, isUserRejection: false, isContractError: false }), +}; + +const disconnectedMutation = (action: PetAction): AdapterMutation => { + return { + mutateAsync: async (): Promise => { throw new NoActiveChainError(action); }, + lifecycle: { phase: 'idle', error: null, reset: () => undefined }, + isPending: false, + }; +}; + +export const noneAdapter: ChainAdapter = { + kind: 'none', + address: null, + isConnected: false, + capabilities: NONE_CAPABILITIES, + pets: { data: [], isLoading: false, error: null, refetch: () => undefined }, + createPet: disconnectedMutation('create'), + levelUpPet: disconnectedMutation('levelUp'), + trainPet: disconnectedMutation('train'), + renamePet: disconnectedMutation('rename'), + transferPet: disconnectedMutation('transfer'), + breedPets: disconnectedMutation('breed'), +}; diff --git a/shared/src/hooks/adapters/types.ts b/shared/src/hooks/adapters/types.ts index fc599daa..5cc33dfd 100644 --- a/shared/src/hooks/adapters/types.ts +++ b/shared/src/hooks/adapters/types.ts @@ -1,70 +1,68 @@ -import type { Pet } from '../../types/pet'; -import type { BattleResolvedResult } from '../../types/battle'; - -export type TxPhase = - | 'idle' - | 'awaiting-wallet' - | 'confirming' - | 'awaiting-vrf' - | 'success' - | 'error'; - -export interface TxLifecycle { - phase: TxPhase; - hash?: string; - error: Error | null; - reset(): void; -} - -export interface AdapterMutation { - mutateAsync(args: TArgs): Promise; - lifecycle: TxLifecycle; - isPending: boolean; -} - -export interface ChainCapabilities { - chainLabel: string; - address: { - label: string; - placeholder: string; - isValid(value: string): boolean; - }; - /** null when the action is free on this chain. */ - levelUpFee: { amount: string; symbol: string } | null; - /** Minimum pet level before rename is allowed. */ - renameMinLevel: number; - randomness: { - /** null when no chain is active (disconnected). */ - provider: 'chainlink' | 'switchboard' | null; - appliesTo: ('battle' | 'breed')[]; - }; - explorerTxUrl(hash: string): string | null; - parseError(error: unknown, fallback: string): { message: string; isUserRejection: boolean; isContractError: boolean }; -} - -export interface ChainAdapter { - kind: 'evm' | 'solana' | 'none'; - address: string | null; - isConnected: boolean; - capabilities: ChainCapabilities; - - pets: { - data: Pet[]; - isLoading: boolean; - error: Error | null; - refetch(): void; - }; - - // petId is always string; adapters convert to bigint/number internally. - // DNA/rarity are derived from VRF randomness at settle time on both chains, - // so mint takes only a name. - createPet: AdapterMutation<{ name: string }>; - levelUpPet: AdapterMutation<{ petId: string }>; - /** v2 train: pay a level-scaled fee for flat XP. */ - trainPet: AdapterMutation<{ petId: string }>; - renamePet: AdapterMutation<{ petId: string; name: string }>; - transferPet: AdapterMutation<{ petId: string; to: string }>; - battlePets: AdapterMutation<{ petId1: string; petId2: string; defenderOwner?: string }, BattleResolvedResult | null>; - // crossOwner adds the stud fee (EVM married cross-owner breeding); ignored on Solana. - breedPets: AdapterMutation<{ parentId1: string; parentId2: string; name: string; crossOwner?: boolean }>; -} +import type { Pet } from '../../types/pet'; + +export type TxPhase = + | 'idle' + | 'awaiting-wallet' + | 'confirming' + | 'awaiting-vrf' + | 'success' + | 'error'; + +export interface TxLifecycle { + phase: TxPhase; + hash?: string; + error: Error | null; + reset(): void; +} + +export interface AdapterMutation { + mutateAsync(args: TArgs): Promise; + lifecycle: TxLifecycle; + isPending: boolean; +} + +export interface ChainCapabilities { + chainLabel: string; + address: { + label: string; + placeholder: string; + isValid(value: string): boolean; + }; + /** null when the action is free on this chain. */ + levelUpFee: { amount: string; symbol: string } | null; + /** Minimum pet level before rename is allowed. */ + renameMinLevel: number; + randomness: { + /** null when no chain is active (disconnected). */ + provider: 'chainlink' | 'switchboard' | null; + appliesTo: ('battle' | 'breed')[]; + }; + explorerTxUrl(hash: string): string | null; + parseError(error: unknown, fallback: string): { message: string; isUserRejection: boolean; isContractError: boolean }; +} + +export interface ChainAdapter { + kind: 'evm' | 'solana' | 'none'; + address: string | null; + isConnected: boolean; + capabilities: ChainCapabilities; + + pets: { + data: Pet[]; + isLoading: boolean; + error: Error | null; + refetch(): void; + }; + + // petId is always string; adapters convert to bigint/number internally. + // DNA/rarity are derived from VRF randomness at settle time on both chains, + // so mint takes only a name. + createPet: AdapterMutation<{ name: string }>; + levelUpPet: AdapterMutation<{ petId: string }>; + /** v2 train: pay a level-scaled fee for flat XP. */ + trainPet: AdapterMutation<{ petId: string }>; + renamePet: AdapterMutation<{ petId: string; name: string }>; + transferPet: AdapterMutation<{ petId: string; to: string }>; + // crossOwner adds the stud fee (EVM married cross-owner breeding); ignored on Solana. + breedPets: AdapterMutation<{ parentId1: string; parentId2: string; name: string; crossOwner?: boolean }>; +} diff --git a/shared/src/hooks/adapters/useEvmAdapter.ts b/shared/src/hooks/adapters/useEvmAdapter.ts index 0ea4bd4b..5bf87e56 100644 --- a/shared/src/hooks/adapters/useEvmAdapter.ts +++ b/shared/src/hooks/adapters/useEvmAdapter.ts @@ -1,230 +1,210 @@ -import { useMemo } from 'react'; -import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'; -import { isAddress } from 'viem'; -import { usePetsContract } from '../chains/ethereum/usePetsContract'; -import { useEvmFees } from '../chains/ethereum/useEvmFees'; -import { usePetsConfig } from '../../contexts/PetsConfigContext'; -import { mapEvmPet, type EvmRawPet } from '../../utils/pets/mapEvmPet'; -import { parseContractError } from '../../utils/ethereum'; -import { EVM_GAS_LIMITS } from '../chains/ethereum/gasLimits'; -import type { Pet } from '../../types/pet'; -import type { ChainAdapter, AdapterMutation, TxLifecycle, TxPhase, ChainCapabilities } from './types'; - -export const EVM_CAPABILITIES: ChainCapabilities = { - chainLabel: 'Ethereum', - address: { - label: 'Recipient Ethereum Address:', - placeholder: '0x…', - isValid: (v) => isAddress(v), - }, - levelUpFee: { amount: '0.004', symbol: 'ETH' }, - renameMinLevel: 2, - randomness: { provider: 'chainlink', appliesTo: ['breed'] }, - explorerTxUrl: () => null, - parseError: (err, _fallback) => parseContractError(err), -}; - -type WriteState = { - isPending: boolean; - data: `0x${string}` | undefined; - error: unknown; - reset: () => void; -}; -type ReceiptState = { isSuccess: boolean; isError: boolean; error: unknown }; - -const toLc = (w: WriteState, r: ReceiptState): TxLifecycle => { - const writeError = w.error as Error | null; - const receiptError = r.isError ? (r.error as Error | null) : null; - const error = writeError ?? receiptError; - let phase: TxPhase = 'idle'; - if (error) phase = 'error'; - else if (r.isSuccess) phase = 'success'; - else if (w.data) phase = 'confirming'; - else if (w.isPending) phase = 'awaiting-wallet'; - return { phase, hash: w.data, error, reset: w.reset }; -}; - -const isInFlight = (w: WriteState, r: ReceiptState): boolean => { - return w.isPending || (!!w.data && !r.isSuccess && !r.isError); -}; - -const ZERO = '0x0000000000000000000000000000000000000000' as `0x${string}`; - -export const useEvmAdapter = ({ enabled }: { enabled: boolean }): ChainAdapter => { - const { evm } = usePetsConfig(); - - // v2 splits writes across two proxies: PetCore (ERC-721 storage, mint, - // level/XP, rename, transfer) and GameLogic (async battle/breed/train). - const petCoreAddress = evm?.petCore.address; - const petCoreAbi = evm?.petCore.abi ?? []; - const gameLogicAddress = evm?.gameLogic.address; - const gameLogicAbi = evm?.gameLogic.abi ?? []; - const petCore = (petCoreAddress ?? ZERO); - const gameLogic = (gameLogicAddress ?? ZERO); - const canWrite = enabled && Boolean(petCoreAddress) && Boolean(gameLogicAddress); - - // Reads — usePetsContract also provides the caller address for transferFrom. - const reads = usePetsContract({ contractAddress: petCoreAddress, abi: petCoreAbi, enabled, chainId: evm?.chainId }); - - // v2 fee schedule (GameConfig + per-wallet mint count). Payable writes revert - // when underpaid, so these must resolve before mint/level/breed. - const fees = useEvmFees(enabled); - const evmPets = useMemo(() => { - if (!enabled) return []; - return (reads.pets as unknown as EvmRawPet[]).map( - (raw, i) => mapEvmPet(raw, reads.petIds[i] ?? BigInt(i)), - ); - }, [enabled, reads.pets, reads.petIds]); - - // Per-action write hooks — each has isolated hash, isPending, error, reset. - const createW = useWriteContract(); - const levelUpW = useWriteContract(); - const renameW = useWriteContract(); - const transferW = useWriteContract(); - const battleW = useWriteContract(); - const breedW = useWriteContract(); - const trainW = useWriteContract(); - - // Per-action receipt watchers — enabled only when the corresponding hash exists. - const createR = useWaitForTransactionReceipt({ hash: createW.data, query: { enabled: !!createW.data } }); - const levelUpR = useWaitForTransactionReceipt({ hash: levelUpW.data, query: { enabled: !!levelUpW.data } }); - const renameR = useWaitForTransactionReceipt({ hash: renameW.data, query: { enabled: !!renameW.data } }); - const transferR = useWaitForTransactionReceipt({ hash: transferW.data, query: { enabled: !!transferW.data } }); - const battleR = useWaitForTransactionReceipt({ hash: battleW.data, query: { enabled: !!battleW.data } }); - const breedR = useWaitForTransactionReceipt({ hash: breedW.data, query: { enabled: !!breedW.data } }); - const trainR = useWaitForTransactionReceipt({ hash: trainW.data, query: { enabled: !!trainW.data } }); - - // GameLogic: async starter mint (plan §4.3). DNA is fixed by a Pyth Entropy reveal, - // so rarity can't be ground out by retrying. Fee = mintFee + entropyFee. - // The pet is minted by settleMint (frontend-driven, via useCreatePet) once - // entropy reveals randomness. - const createPet: AdapterMutation<{ name: string }> = { - async mutateAsync({ name }) { - if (!canWrite) throw new Error('EVM contract not configured'); - if (fees.nextMintFee == null) throw new Error('Mint fee not loaded yet'); - if (fees.entropyFee == null) throw new Error('Entropy fee not loaded yet'); - await createW.writeContractAsync({ - address: gameLogic, abi: gameLogicAbi, functionName: 'requestMintStarter', - args: [name], value: fees.nextMintFee + fees.entropyFee, gas: EVM_GAS_LIMITS.requestMintStarter, - chainId: evm?.chainId, - } as unknown as Parameters[0]); - }, - lifecycle: toLc(createW, createR), - isPending: isInFlight(createW, createR), - }; - - // PetCore: levelUp pays a level-scaled fee, capped at maxLevel. - // fee = levelUpFee × (100 + (level-1)²) / 100 (matches the contract). - const levelUpPet: AdapterMutation<{ petId: string }> = { - async mutateAsync({ petId }) { - if (!canWrite) throw new Error('EVM contract not configured'); - if (fees.levelUpFee == null) throw new Error('Level-up fee not loaded yet'); - const level = evmPets.find((p) => p.id === petId)?.level ?? 1; - const diff = BigInt(Math.max(level - 1, 0)); - const value = (fees.levelUpFee * (100n + diff * diff)) / 100n; - await levelUpW.writeContractAsync({ - address: petCore, abi: petCoreAbi, functionName: 'levelUp', - args: [BigInt(petId)], value, gas: EVM_GAS_LIMITS.levelUp, - chainId: evm?.chainId, - } as unknown as Parameters[0]); - }, - lifecycle: toLc(levelUpW, levelUpR), - isPending: isInFlight(levelUpW, levelUpR), - }; - - // GameLogic: train pays a level-scaled fee for flat XP. - // scaledFee = trainFee × (100 + 2·level) / 100 (matches the contract). - const trainPet: AdapterMutation<{ petId: string }> = { - async mutateAsync({ petId }) { - if (!canWrite) throw new Error('EVM contract not configured'); - if (fees.trainFee == null) throw new Error('Train fee not loaded yet'); - const level = evmPets.find((p) => p.id === petId)?.level ?? 1; - const value = (fees.trainFee * BigInt(100 + 2 * level)) / 100n; - await trainW.writeContractAsync({ - address: gameLogic, abi: gameLogicAbi, functionName: 'train', - args: [BigInt(petId)], value, gas: EVM_GAS_LIMITS.train, - chainId: evm?.chainId, - } as unknown as Parameters[0]); - }, - lifecycle: toLc(trainW, trainR), - isPending: isInFlight(trainW, trainR), - }; - - const renamePet: AdapterMutation<{ petId: string; name: string }> = { - async mutateAsync({ petId, name }) { - if (!canWrite) throw new Error('EVM contract not configured'); - await renameW.writeContractAsync({ address: petCore, abi: petCoreAbi, functionName: 'changeName', args: [BigInt(petId), name], gas: EVM_GAS_LIMITS.changeName, chainId: evm?.chainId }); - }, - lifecycle: toLc(renameW, renameR), - isPending: isInFlight(renameW, renameR), - }; - - const transferPet: AdapterMutation<{ petId: string; to: string }> = { - async mutateAsync({ petId, to }) { - if (!canWrite || !reads.address) throw new Error('EVM contract not configured or wallet not connected'); - await transferW.writeContractAsync({ - address: petCore, abi: petCoreAbi, functionName: 'transferFrom', - args: [reads.address, to as `0x${string}`, BigInt(petId)], gas: EVM_GAS_LIMITS.transferFrom, chainId: evm?.chainId, - }); - }, - lifecycle: toLc(transferW, transferR), - isPending: isInFlight(transferW, transferR), - }; - - // GameLogic: v2 battle is async (request → VRF → settle). requestBattle makes - // a VRF request, which the RPC can't gas-estimate (estimateGas returns the - // block limit → "gas limit too high"), so a manual limit is REQUIRED — sized - // like breed's working VRF path, not v1's synchronous-battle 300k. - const battlePets: AdapterMutation<{ petId1: string; petId2: string; defenderOwner?: string }, null> = { - async mutateAsync({ petId1, petId2 }) { - if (!canWrite) throw new Error('EVM contract not configured'); - if (fees.battleFee == null) throw new Error('Battle fee not loaded yet'); - if (fees.entropyFee == null) throw new Error('Entropy fee not loaded yet'); - const value = fees.battleFee + fees.entropyFee; - await battleW.writeContractAsync({ address: gameLogic, abi: gameLogicAbi, functionName: 'requestBattle', args: [BigInt(petId1), BigInt(petId2)], value, gas: EVM_GAS_LIMITS.requestBattle, chainId: evm?.chainId } as unknown as Parameters[0]); - return null; - }, - lifecycle: toLc(battleW, battleR), - isPending: isInFlight(battleW, battleR), - }; - - // GameLogic: breed request (payable). Same-owner requires msg.value >= - // breedFee(); cross-owner (married) requires breedFee() + studFee(). - // Offspring is minted on BreedSettled, watched in useBreedPets. - const breedPets: AdapterMutation<{ parentId1: string; parentId2: string; name: string; crossOwner?: boolean }> = { - async mutateAsync({ parentId1, parentId2, name, crossOwner }) { - if (!canWrite) throw new Error('EVM contract not configured'); - if (fees.breedFee == null) throw new Error('Breed fee not loaded yet'); - if (fees.entropyFee == null) throw new Error('Entropy fee not loaded yet'); - if (crossOwner && fees.studFee == null) throw new Error('Stud fee not loaded yet'); - const value = fees.breedFee + fees.entropyFee + (crossOwner ? (fees.studFee ?? 0n) : 0n); - await breedW.writeContractAsync({ - address: gameLogic, abi: gameLogicAbi, functionName: 'requestCreateFromDNA', - args: [BigInt(parentId1), BigInt(parentId2), name], value, gas: EVM_GAS_LIMITS.requestBreed, - chainId: evm?.chainId, - } as unknown as Parameters[0]); - }, - lifecycle: toLc(breedW, breedR), - isPending: isInFlight(breedW, breedR), - }; - - return { - kind: 'evm', - address: reads.address ?? null, - isConnected: enabled && reads.isConnected, - capabilities: EVM_CAPABILITIES, - pets: { - data: evmPets, - isLoading: reads.isLoading, - error: (reads.contractError as Error | undefined) ?? null, - refetch: () => { reads.refetchPetIds(); void reads.refetchPetsData(); }, - }, - createPet, - levelUpPet, - trainPet, - renamePet, - transferPet, - battlePets, - breedPets, - }; -}; +import { useMemo } from 'react'; +import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'; +import { isAddress } from 'viem'; +import { usePetsContract } from '../chains/ethereum/usePetsContract'; +import { useEvmFees } from '../chains/ethereum/useEvmFees'; +import { usePetsConfig } from '../../contexts/PetsConfigContext'; +import { mapEvmPet, type EvmRawPet } from '../../utils/pets/mapEvmPet'; +import { parseContractError } from '../../utils/ethereum'; +import { EVM_GAS_LIMITS } from '../chains/ethereum/gasLimits'; +import type { Pet } from '../../types/pet'; +import type { ChainAdapter, AdapterMutation, TxLifecycle, TxPhase, ChainCapabilities } from './types'; + +export const EVM_CAPABILITIES: ChainCapabilities = { + chainLabel: 'Ethereum', + address: { + label: 'Recipient Ethereum Address:', + placeholder: '0x…', + isValid: (v) => isAddress(v), + }, + levelUpFee: { amount: '0.004', symbol: 'ETH' }, + renameMinLevel: 2, + randomness: { provider: 'chainlink', appliesTo: ['breed'] }, + explorerTxUrl: () => null, + parseError: (err, _fallback) => parseContractError(err), +}; + +type WriteState = { + isPending: boolean; + data: `0x${string}` | undefined; + error: unknown; + reset: () => void; +}; +type ReceiptState = { isSuccess: boolean; isError: boolean; error: unknown }; + +const toLc = (w: WriteState, r: ReceiptState): TxLifecycle => { + const writeError = w.error as Error | null; + const receiptError = r.isError ? (r.error as Error | null) : null; + const error = writeError ?? receiptError; + let phase: TxPhase = 'idle'; + if (error) phase = 'error'; + else if (r.isSuccess) phase = 'success'; + else if (w.data) phase = 'confirming'; + else if (w.isPending) phase = 'awaiting-wallet'; + return { phase, hash: w.data, error, reset: w.reset }; +}; + +const isInFlight = (w: WriteState, r: ReceiptState): boolean => { + return w.isPending || (!!w.data && !r.isSuccess && !r.isError); +}; + +const ZERO = '0x0000000000000000000000000000000000000000' as `0x${string}`; + +export const useEvmAdapter = ({ enabled }: { enabled: boolean }): ChainAdapter => { + const { evm } = usePetsConfig(); + + // v2 splits writes across two proxies: PetCore (ERC-721 storage, mint, + // level/XP, rename, transfer) and GameLogic (async battle/breed/train). + const petCoreAddress = evm?.petCore.address; + const petCoreAbi = evm?.petCore.abi ?? []; + const gameLogicAddress = evm?.gameLogic.address; + const gameLogicAbi = evm?.gameLogic.abi ?? []; + const petCore = (petCoreAddress ?? ZERO); + const gameLogic = (gameLogicAddress ?? ZERO); + const canWrite = enabled && Boolean(petCoreAddress) && Boolean(gameLogicAddress); + + // Reads — usePetsContract also provides the caller address for transferFrom. + const reads = usePetsContract({ contractAddress: petCoreAddress, abi: petCoreAbi, enabled, chainId: evm?.chainId }); + + // v2 fee schedule (GameConfig + per-wallet mint count). Payable writes revert + // when underpaid, so these must resolve before mint/level/breed. + const fees = useEvmFees(enabled); + const evmPets = useMemo(() => { + if (!enabled) return []; + return (reads.pets as unknown as EvmRawPet[]).map( + (raw, i) => mapEvmPet(raw, reads.petIds[i] ?? BigInt(i)), + ); + }, [enabled, reads.pets, reads.petIds]); + + // Per-action write hooks — each has isolated hash, isPending, error, reset. + const createW = useWriteContract(); + const levelUpW = useWriteContract(); + const renameW = useWriteContract(); + const transferW = useWriteContract(); + const breedW = useWriteContract(); + const trainW = useWriteContract(); + + // Per-action receipt watchers — enabled only when the corresponding hash exists. + const createR = useWaitForTransactionReceipt({ hash: createW.data, query: { enabled: !!createW.data } }); + const levelUpR = useWaitForTransactionReceipt({ hash: levelUpW.data, query: { enabled: !!levelUpW.data } }); + const renameR = useWaitForTransactionReceipt({ hash: renameW.data, query: { enabled: !!renameW.data } }); + const transferR = useWaitForTransactionReceipt({ hash: transferW.data, query: { enabled: !!transferW.data } }); + const breedR = useWaitForTransactionReceipt({ hash: breedW.data, query: { enabled: !!breedW.data } }); + const trainR = useWaitForTransactionReceipt({ hash: trainW.data, query: { enabled: !!trainW.data } }); + + // GameLogic: async starter mint (plan §4.3). DNA is fixed by a Pyth Entropy reveal, + // so rarity can't be ground out by retrying. Fee = mintFee + entropyFee. + // The pet is minted by settleMint (frontend-driven, via useCreatePet) once + // entropy reveals randomness. + const createPet: AdapterMutation<{ name: string }> = { + async mutateAsync({ name }) { + if (!canWrite) throw new Error('EVM contract not configured'); + if (fees.nextMintFee == null) throw new Error('Mint fee not loaded yet'); + if (fees.entropyFee == null) throw new Error('Entropy fee not loaded yet'); + await createW.writeContractAsync({ + address: gameLogic, abi: gameLogicAbi, functionName: 'requestMintStarter', + args: [name], value: fees.nextMintFee + fees.entropyFee, gas: EVM_GAS_LIMITS.requestMintStarter, + chainId: evm?.chainId, + } as unknown as Parameters[0]); + }, + lifecycle: toLc(createW, createR), + isPending: isInFlight(createW, createR), + }; + + // PetCore: levelUp pays a level-scaled fee, capped at maxLevel. + // fee = levelUpFee × (100 + (level-1)²) / 100 (matches the contract). + const levelUpPet: AdapterMutation<{ petId: string }> = { + async mutateAsync({ petId }) { + if (!canWrite) throw new Error('EVM contract not configured'); + if (fees.levelUpFee == null) throw new Error('Level-up fee not loaded yet'); + const level = evmPets.find((p) => p.id === petId)?.level ?? 1; + const diff = BigInt(Math.max(level - 1, 0)); + const value = (fees.levelUpFee * (100n + diff * diff)) / 100n; + await levelUpW.writeContractAsync({ + address: petCore, abi: petCoreAbi, functionName: 'levelUp', + args: [BigInt(petId)], value, gas: EVM_GAS_LIMITS.levelUp, + chainId: evm?.chainId, + } as unknown as Parameters[0]); + }, + lifecycle: toLc(levelUpW, levelUpR), + isPending: isInFlight(levelUpW, levelUpR), + }; + + // GameLogic: train pays a level-scaled fee for flat XP. + // scaledFee = trainFee × (100 + 2·level) / 100 (matches the contract). + const trainPet: AdapterMutation<{ petId: string }> = { + async mutateAsync({ petId }) { + if (!canWrite) throw new Error('EVM contract not configured'); + if (fees.trainFee == null) throw new Error('Train fee not loaded yet'); + const level = evmPets.find((p) => p.id === petId)?.level ?? 1; + const value = (fees.trainFee * BigInt(100 + 2 * level)) / 100n; + await trainW.writeContractAsync({ + address: gameLogic, abi: gameLogicAbi, functionName: 'train', + args: [BigInt(petId)], value, gas: EVM_GAS_LIMITS.train, + chainId: evm?.chainId, + } as unknown as Parameters[0]); + }, + lifecycle: toLc(trainW, trainR), + isPending: isInFlight(trainW, trainR), + }; + + const renamePet: AdapterMutation<{ petId: string; name: string }> = { + async mutateAsync({ petId, name }) { + if (!canWrite) throw new Error('EVM contract not configured'); + await renameW.writeContractAsync({ address: petCore, abi: petCoreAbi, functionName: 'changeName', args: [BigInt(petId), name], gas: EVM_GAS_LIMITS.changeName, chainId: evm?.chainId }); + }, + lifecycle: toLc(renameW, renameR), + isPending: isInFlight(renameW, renameR), + }; + + const transferPet: AdapterMutation<{ petId: string; to: string }> = { + async mutateAsync({ petId, to }) { + if (!canWrite || !reads.address) throw new Error('EVM contract not configured or wallet not connected'); + await transferW.writeContractAsync({ + address: petCore, abi: petCoreAbi, functionName: 'transferFrom', + args: [reads.address, to as `0x${string}`, BigInt(petId)], gas: EVM_GAS_LIMITS.transferFrom, chainId: evm?.chainId, + }); + }, + lifecycle: toLc(transferW, transferR), + isPending: isInFlight(transferW, transferR), + }; + + // GameLogic: breed request (payable). Same-owner requires msg.value >= + // breedFee(); cross-owner (married) requires breedFee() + studFee(). + // Offspring is minted on BreedSettled, watched in useBreedPets. + const breedPets: AdapterMutation<{ parentId1: string; parentId2: string; name: string; crossOwner?: boolean }> = { + async mutateAsync({ parentId1, parentId2, name, crossOwner }) { + if (!canWrite) throw new Error('EVM contract not configured'); + if (fees.breedFee == null) throw new Error('Breed fee not loaded yet'); + if (fees.entropyFee == null) throw new Error('Entropy fee not loaded yet'); + if (crossOwner && fees.studFee == null) throw new Error('Stud fee not loaded yet'); + const value = fees.breedFee + fees.entropyFee + (crossOwner ? (fees.studFee ?? 0n) : 0n); + await breedW.writeContractAsync({ + address: gameLogic, abi: gameLogicAbi, functionName: 'requestCreateFromDNA', + args: [BigInt(parentId1), BigInt(parentId2), name], value, gas: EVM_GAS_LIMITS.requestBreed, + chainId: evm?.chainId, + } as unknown as Parameters[0]); + }, + lifecycle: toLc(breedW, breedR), + isPending: isInFlight(breedW, breedR), + }; + + return { + kind: 'evm', + address: reads.address ?? null, + isConnected: enabled && reads.isConnected, + capabilities: EVM_CAPABILITIES, + pets: { + data: evmPets, + isLoading: reads.isLoading, + error: (reads.contractError as Error | undefined) ?? null, + refetch: () => { reads.refetchPetIds(); void reads.refetchPetsData(); }, + }, + createPet, + levelUpPet, + trainPet, + renamePet, + transferPet, + breedPets, + }; +}; diff --git a/shared/src/hooks/adapters/useSolanaAdapter.ts b/shared/src/hooks/adapters/useSolanaAdapter.ts index 147630fc..5ca55e8b 100644 --- a/shared/src/hooks/adapters/useSolanaAdapter.ts +++ b/shared/src/hooks/adapters/useSolanaAdapter.ts @@ -1,229 +1,207 @@ -import { useMemo } from 'react'; -import { PublicKey } from '@solana/web3.js'; -import { usePetActions } from '../chains/solana/usePetActions'; -import { usePets as useSolanaPets } from '../chains/solana/usePets'; -import { useProgram } from '../chains/solana/useProgram'; -import { useSolanaAnchor } from '../../contexts/SolanaAnchorContext'; -import { mapSolanaPet, type SolanaPetAccountRow } from '../../utils/pets/mapSolanaPet'; -import { formatSolanaActionError } from '../../utils/solana'; -import { fetchAssetByPetId, fetchMarriageOwnerSnapshot } from '../../utils/solana/accountClient'; -import type { Pet } from '../../types/pet'; -import type { BattleResolvedResult } from '../../types/battle'; -import type { ChainAdapter, AdapterMutation, TxLifecycle, TxPhase, ChainCapabilities } from './types'; - -export const SOLANA_CAPABILITIES: ChainCapabilities = { - chainLabel: 'Solana', - address: { - label: 'Recipient Solana Address:', - placeholder: 'Solana address (base58)', - isValid: (v) => { try { new PublicKey(v); return true; } catch { return false; } }, - }, - levelUpFee: null, - renameMinLevel: 1, - randomness: { provider: 'switchboard', appliesTo: ['battle', 'breed'] }, - explorerTxUrl: () => null, - parseError: (err, fallback) => { - const message = formatSolanaActionError(err, fallback); - return { message, isUserRejection: message.toLowerCase().includes('cancelled'), isContractError: true }; - }, -}; - -type SolanaMutation = { - isPending: boolean; - isSuccess: boolean; - isError: boolean; - error: Error | null; - data: TData | undefined; - reset: () => void; -}; - -const resolveHash = (data: unknown): string | undefined => { - if (typeof data === 'string') return data; - if (data && typeof data === 'object' && 'sig' in data && typeof (data as { sig: unknown }).sig === 'string') { - return (data as { sig: string }).sig; - } - return undefined; -}; - -const toLc = (m: SolanaMutation): TxLifecycle => { - let phase: TxPhase = 'idle'; - if (m.isError) phase = 'error'; - else if (m.isSuccess) phase = 'success'; - else if (m.isPending) phase = 'awaiting-wallet'; - return { - phase, - hash: resolveHash(m.data), - error: m.error, - reset: m.reset, - }; -}; - -// toLc for the two-phase VRF flows (battle/breed): once the commit tx lands and -// we're waiting on randomness, promote 'awaiting-wallet' to 'awaiting-vrf'. -const toVrfLc = ( - m: SolanaMutation, - subPhase: 'idle' | 'awaiting-vrf', -): TxLifecycle => { - const lc = toLc(m); - if (subPhase === 'awaiting-vrf' && lc.phase === 'awaiting-wallet') { - return { ...lc, phase: 'awaiting-vrf' as TxPhase }; - } - return lc; -}; - -/** Infer Solana Explorer cluster param from an RPC endpoint URL. */ -const clusterParam = (rpcEndpoint: string): string => { - if (rpcEndpoint.includes('devnet')) return 'devnet'; - if (rpcEndpoint.includes('mainnet')) return 'mainnet-beta'; - if (rpcEndpoint.includes('testnet')) return 'testnet'; - return `custom&customUrl=${encodeURIComponent(rpcEndpoint)}`; -}; - -export const useSolanaAdapter = ({ enabled }: { enabled: boolean }): ChainAdapter => { - const { signingWallet, connection } = useSolanaAnchor(); - const owner = enabled && signingWallet?.publicKey ? signingWallet.publicKey : null; - - const actions = usePetActions(); - const { program, programId } = useProgram(); - const petsQuery = useSolanaPets(owner); - - const solanaPets = useMemo(() => { - if (!enabled) return []; - return ((petsQuery.data ?? []) as SolanaPetAccountRow[]).map(mapSolanaPet); - }, [enabled, petsQuery.data]); - - const requireAssetKey = (petId: string): string => { - const pet = solanaPets.find(p => p.id === petId); - if (!pet?.assetKey) throw new Error(`Asset key not found for pet ${petId}`); - return pet.assetKey; - }; - - const createPet: AdapterMutation<{ name: string }> = { - async mutateAsync({ name }) { - await actions.mintPet.mutateAsync({ name }); - }, - lifecycle: toLc(actions.mintPet), - isPending: actions.mintPet.isPending, - }; - - const levelUpPet: AdapterMutation<{ petId: string }> = { - async mutateAsync({ petId }) { - await actions.levelUpPet.mutateAsync({ petId: Number(petId), assetKey: requireAssetKey(petId) }); - }, - lifecycle: toLc(actions.levelUpPet), - isPending: actions.levelUpPet.isPending, - }; - - const trainPet: AdapterMutation<{ petId: string }> = { - async mutateAsync({ petId }) { - await actions.trainPet.mutateAsync({ petId: Number(petId), assetKey: requireAssetKey(petId) }); - }, - lifecycle: toLc(actions.trainPet), - isPending: actions.trainPet.isPending, - }; - - const renamePet: AdapterMutation<{ petId: string; name: string }> = { - async mutateAsync({ petId, name }) { - await actions.renamePet.mutateAsync({ petId: Number(petId), name, assetKey: requireAssetKey(petId) }); - }, - lifecycle: toLc(actions.renamePet), - isPending: actions.renamePet.isPending, - }; - - // `transfer_pet` CPIs mpl-core TransferV1 to move the Core asset and syncs the - // denormalized `PetAccount.owner` so the gallery's owner-memcmp query follows the pet. - const transferPet: AdapterMutation<{ petId: string; to: string }> = { - async mutateAsync({ petId, to }) { - await actions.transferPet.mutateAsync({ assetKey: requireAssetKey(petId), to }); - }, - lifecycle: toLc(actions.transferPet), - isPending: actions.transferPet.isPending, - }; - - const battleLc = useMemo( - () => toVrfLc(actions.battlePets, actions.battleSubPhase), - [actions.battlePets, actions.battleSubPhase], - ); - - const battlePets: AdapterMutation<{ petId1: string; petId2: string; defenderOwner?: string }, BattleResolvedResult | null> = { - async mutateAsync({ petId1, petId2, defenderOwner }) { - const { sig, firstWins } = await actions.battlePets.mutateAsync({ - attackerPetId: Number(petId1), - defenderPetId: Number(petId2), - attackerAssetKey: requireAssetKey(petId1), - ...(defenderOwner ? { defenderOwner } : {}), - }); - if (firstWins === null) return null; - return { firstWins, sig, requestId: 0n, winnerId: 0n, loserId: 0n, vrfSeed: 0n, rounds: 0, winnerHpRemaining: 0, xpWin: 0, xpLoss: 0 }; - }, - lifecycle: battleLc, - isPending: actions.battlePets.isPending, - }; - - const breedLc = useMemo( - () => toVrfLc(actions.breedPets, actions.breedSubPhase), - [actions.breedPets, actions.breedSubPhase], - ); - - const breedPets: AdapterMutation<{ parentId1: string; parentId2: string; name: string; crossOwner?: boolean }> = { - async mutateAsync({ parentId1, parentId2, name, crossOwner }) { - const parent1AssetKey = requireAssetKey(parentId1); - const parent2Pet = solanaPets.find(p => p.id === parentId2); - - let parent2AssetKey = parent2Pet?.assetKey; - let parent2Owner: string | undefined; - - if (crossOwner) { - if (!program || !programId) throw new Error('Solana program not ready — cannot resolve spouse owner for cross-owner breed'); - // Spouse pet belongs to another wallet — look up their asset + owner on-chain. - if (!parent2AssetKey) { - const assetPk = await fetchAssetByPetId(program, Number(parentId2)); - if (!assetPk) throw new Error(`Spouse pet #${parentId2} not found on-chain`); - parent2AssetKey = assetPk.toBase58(); - } - // marriageOwnerSnapshot = spouse wallet captured at accept_marriage time. - const snapshot = await fetchMarriageOwnerSnapshot( - program, - programId, - new PublicKey(parent2AssetKey), - ); - if (!snapshot) throw new Error(`Pet #${parentId2} is not married or marriage owner not found`); - parent2Owner = snapshot.toBase58(); - } - - await actions.breedPets.mutateAsync({ - parent1Id: Number(parentId1), - parent2Id: Number(parentId2), - name, - parent1AssetKey, - parent2AssetKey, - parent2Owner, - }); - }, - lifecycle: breedLc, - isPending: actions.breedPets.isPending, - }; - - const explorerTxUrl = (hash: string) => - `https://explorer.solana.com/tx/${hash}?cluster=${clusterParam(connection.rpcEndpoint)}`; - - return { - kind: 'solana', - address: signingWallet?.publicKey?.toBase58() ?? null, - isConnected: enabled && Boolean(signingWallet?.publicKey), - capabilities: { ...SOLANA_CAPABILITIES, explorerTxUrl }, - pets: { - data: solanaPets, - isLoading: petsQuery.isLoading || petsQuery.isFetching, - error: (petsQuery.error as Error | null) ?? null, - refetch: () => { void petsQuery.refetch(); }, - }, - createPet, - levelUpPet, - trainPet, - renamePet, - transferPet, - battlePets, - breedPets, - }; -}; +import { useMemo } from 'react'; +import { PublicKey } from '@solana/web3.js'; +import { usePetActions } from '../chains/solana/usePetActions'; +import { usePets as useSolanaPets } from '../chains/solana/usePets'; +import { useProgram } from '../chains/solana/useProgram'; +import { useSolanaAnchor } from '../../contexts/SolanaAnchorContext'; +import { mapSolanaPet, type SolanaPetAccountRow } from '../../utils/pets/mapSolanaPet'; +import { formatSolanaActionError } from '../../utils/solana'; +import { fetchAssetByPetId, fetchMarriageOwnerSnapshot } from '../../utils/solana/accountClient'; +import type { Pet } from '../../types/pet'; +import type { ChainAdapter, AdapterMutation, TxLifecycle, TxPhase, ChainCapabilities } from './types'; + +export const SOLANA_CAPABILITIES: ChainCapabilities = { + chainLabel: 'Solana', + address: { + label: 'Recipient Solana Address:', + placeholder: 'Solana address (base58)', + isValid: (v) => { try { new PublicKey(v); return true; } catch { return false; } }, + }, + levelUpFee: null, + renameMinLevel: 1, + randomness: { provider: 'switchboard', appliesTo: ['battle', 'breed'] }, + explorerTxUrl: () => null, + parseError: (err, fallback) => { + const message = formatSolanaActionError(err, fallback); + return { message, isUserRejection: message.toLowerCase().includes('cancelled'), isContractError: true }; + }, +}; + +type SolanaMutation = { + isPending: boolean; + isSuccess: boolean; + isError: boolean; + error: Error | null; + data: TData | undefined; + reset: () => void; +}; + +const resolveHash = (data: unknown): string | undefined => { + if (typeof data === 'string') return data; + if (data && typeof data === 'object' && 'sig' in data && typeof (data as { sig: unknown }).sig === 'string') { + return (data as { sig: string }).sig; + } + return undefined; +}; + +const toLc = (m: SolanaMutation): TxLifecycle => { + let phase: TxPhase = 'idle'; + if (m.isError) phase = 'error'; + else if (m.isSuccess) phase = 'success'; + else if (m.isPending) phase = 'awaiting-wallet'; + return { + phase, + hash: resolveHash(m.data), + error: m.error, + reset: m.reset, + }; +}; + +// toLc for the two-phase VRF flows (battle/breed): once the commit tx lands and +// we're waiting on randomness, promote 'awaiting-wallet' to 'awaiting-vrf'. +const toVrfLc = ( + m: SolanaMutation, + subPhase: 'idle' | 'awaiting-vrf', +): TxLifecycle => { + const lc = toLc(m); + if (subPhase === 'awaiting-vrf' && lc.phase === 'awaiting-wallet') { + return { ...lc, phase: 'awaiting-vrf' as TxPhase }; + } + return lc; +}; + +/** Infer Solana Explorer cluster param from an RPC endpoint URL. */ +const clusterParam = (rpcEndpoint: string): string => { + if (rpcEndpoint.includes('devnet')) return 'devnet'; + if (rpcEndpoint.includes('mainnet')) return 'mainnet-beta'; + if (rpcEndpoint.includes('testnet')) return 'testnet'; + return `custom&customUrl=${encodeURIComponent(rpcEndpoint)}`; +}; + +export const useSolanaAdapter = ({ enabled }: { enabled: boolean }): ChainAdapter => { + const { signingWallet, connection } = useSolanaAnchor(); + const owner = enabled && signingWallet?.publicKey ? signingWallet.publicKey : null; + + const actions = usePetActions(); + const { program, programId } = useProgram(); + const petsQuery = useSolanaPets(owner); + + const solanaPets = useMemo(() => { + if (!enabled) return []; + return ((petsQuery.data ?? []) as SolanaPetAccountRow[]).map(mapSolanaPet); + }, [enabled, petsQuery.data]); + + const requireAssetKey = (petId: string): string => { + const pet = solanaPets.find(p => p.id === petId); + if (!pet?.assetKey) throw new Error(`Asset key not found for pet ${petId}`); + return pet.assetKey; + }; + + const createPet: AdapterMutation<{ name: string }> = { + async mutateAsync({ name }) { + await actions.mintPet.mutateAsync({ name }); + }, + lifecycle: toLc(actions.mintPet), + isPending: actions.mintPet.isPending, + }; + + const levelUpPet: AdapterMutation<{ petId: string }> = { + async mutateAsync({ petId }) { + await actions.levelUpPet.mutateAsync({ petId: Number(petId), assetKey: requireAssetKey(petId) }); + }, + lifecycle: toLc(actions.levelUpPet), + isPending: actions.levelUpPet.isPending, + }; + + const trainPet: AdapterMutation<{ petId: string }> = { + async mutateAsync({ petId }) { + await actions.trainPet.mutateAsync({ petId: Number(petId), assetKey: requireAssetKey(petId) }); + }, + lifecycle: toLc(actions.trainPet), + isPending: actions.trainPet.isPending, + }; + + const renamePet: AdapterMutation<{ petId: string; name: string }> = { + async mutateAsync({ petId, name }) { + await actions.renamePet.mutateAsync({ petId: Number(petId), name, assetKey: requireAssetKey(petId) }); + }, + lifecycle: toLc(actions.renamePet), + isPending: actions.renamePet.isPending, + }; + + // `transfer_pet` CPIs mpl-core TransferV1 to move the Core asset and syncs the + // denormalized `PetAccount.owner` so the gallery's owner-memcmp query follows the pet. + const transferPet: AdapterMutation<{ petId: string; to: string }> = { + async mutateAsync({ petId, to }) { + await actions.transferPet.mutateAsync({ assetKey: requireAssetKey(petId), to }); + }, + lifecycle: toLc(actions.transferPet), + isPending: actions.transferPet.isPending, + }; + + const breedLc = useMemo( + () => toVrfLc(actions.breedPets, actions.breedSubPhase), + [actions.breedPets, actions.breedSubPhase], + ); + + const breedPets: AdapterMutation<{ parentId1: string; parentId2: string; name: string; crossOwner?: boolean }> = { + async mutateAsync({ parentId1, parentId2, name, crossOwner }) { + const parent1AssetKey = requireAssetKey(parentId1); + const parent2Pet = solanaPets.find(p => p.id === parentId2); + + let parent2AssetKey = parent2Pet?.assetKey; + let parent2Owner: string | undefined; + + if (crossOwner) { + if (!program || !programId) throw new Error('Solana program not ready — cannot resolve spouse owner for cross-owner breed'); + // Spouse pet belongs to another wallet — look up their asset + owner on-chain. + if (!parent2AssetKey) { + const assetPk = await fetchAssetByPetId(program, Number(parentId2)); + if (!assetPk) throw new Error(`Spouse pet #${parentId2} not found on-chain`); + parent2AssetKey = assetPk.toBase58(); + } + // marriageOwnerSnapshot = spouse wallet captured at accept_marriage time. + const snapshot = await fetchMarriageOwnerSnapshot( + program, + programId, + new PublicKey(parent2AssetKey), + ); + if (!snapshot) throw new Error(`Pet #${parentId2} is not married or marriage owner not found`); + parent2Owner = snapshot.toBase58(); + } + + await actions.breedPets.mutateAsync({ + parent1Id: Number(parentId1), + parent2Id: Number(parentId2), + name, + parent1AssetKey, + parent2AssetKey, + parent2Owner, + }); + }, + lifecycle: breedLc, + isPending: actions.breedPets.isPending, + }; + + const explorerTxUrl = (hash: string) => + `https://explorer.solana.com/tx/${hash}?cluster=${clusterParam(connection.rpcEndpoint)}`; + + return { + kind: 'solana', + address: signingWallet?.publicKey?.toBase58() ?? null, + isConnected: enabled && Boolean(signingWallet?.publicKey), + capabilities: { ...SOLANA_CAPABILITIES, explorerTxUrl }, + pets: { + data: solanaPets, + isLoading: petsQuery.isLoading || petsQuery.isFetching, + error: (petsQuery.error as Error | null) ?? null, + refetch: () => { void petsQuery.refetch(); }, + }, + createPet, + levelUpPet, + trainPet, + renamePet, + transferPet, + breedPets, + }; +}; diff --git a/shared/src/hooks/chains/ethereum/useEvmBattleFlow.ts b/shared/src/hooks/chains/ethereum/useEvmBattleFlow.ts deleted file mode 100644 index 6a27762e..00000000 --- a/shared/src/hooks/chains/ethereum/useEvmBattleFlow.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useAccount, usePublicClient, useWaitForTransactionReceipt, useWriteContract } from 'wagmi'; -import { parseEventLogs } from 'viem'; -import { usePetsConfig } from '../../../contexts/PetsConfigContext'; -import { useLiveBattleSocket } from './useLiveBattleSocket'; -import { EVM_GAS_LIMITS } from './gasLimits'; -import { sleep } from '../../../utils/common'; -import type { BattleResolvedResult, EvmBattlePhase } from '../../../types/battle'; - -type UseEvmBattleFlowParams = { - /** `requestBattle` tx hash from the adapter; drives the rest of the flow. */ - requestHash?: `0x${string}`; - enabled: boolean; - onResolved?: (result: BattleResolvedResult) => void; -}; - -/** How long to wait after the request confirms before even trying to self-settle — a - * generous estimate covering typical entropy-reveal latency plus the keeper's own settle - * time, so the fallback essentially never engages in the normal case. */ -const FALLBACK_START_DELAY_MS = 60_000; -/** Once the fallback window opens, how often to re-check (via a read-only simulateContract, - * no wallet prompt) whether settle would actually succeed yet. */ -const FALLBACK_SIMULATE_RETRY_MS = 5_000; -/** ~60s of retrying (on top of FALLBACK_START_DELAY_MS) before giving up waiting for entropy - * and sending the real transaction anyway, letting it revert if reveal truly never lands. */ -const FALLBACK_SIMULATE_MAX_ATTEMPTS = 12; - -/** - * EVM battle settlement, normally hands-off after the request. Given the `requestBattle` - * tx hash, this: - * 1. parses the requestId from `BattleRandomnessRequested`, - * 2. gets live-progress updates (sim + final result) from the backend settle keeper over - * WebSocket (useLiveBattleSocket) — deliberately not from watching chain events - * directly, since that RPC watching proved unreliable against public endpoints (see - * settle-keeper/keeper.ts's pollContractEvents comment); a disconnected socket just - * means no live updates, not a fallback to a less reliable mechanism, - * 3. as a safety net independent of the backend entirely, starts trying to settle from the - * player's own wallet after FALLBACK_START_DELAY_MS if no result has arrived (keeper - * outage / backend down) — checked via read-only simulation first so the player isn't - * asked to sign (and pay gas for) a transaction that would obviously revert, - * 4. resolves from whichever arrives first: the socket's authoritative `resolved` message, - * or (for a self-sent fallback settle) this hook's own transaction receipt. - */ -export const useEvmBattleFlow = ({ requestHash, enabled, onResolved }: UseEvmBattleFlowParams) => { - const { evm } = usePetsConfig(); - const { address } = useAccount(); - const gameLogic = evm?.gameLogic.address; - const gameLogicAbi = useMemo(() => evm?.gameLogic.abi ?? [], [evm?.gameLogic.abi]); - const chainId = evm?.chainId; - const publicClient = usePublicClient({ chainId }); - - const [requestId, setRequestId] = useState(null); - const [phase, setPhase] = useState('idle'); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - const onResolvedRef = useRef(onResolved); - onResolvedRef.current = onResolved; - - // 1. Parse requestId from the request tx receipt. - const { data: requestReceipt } = useWaitForTransactionReceipt({ - hash: enabled && requestHash ? requestHash : undefined, - }); - useEffect(() => { - if (!enabled || !requestReceipt || !address || !evm?.gameLogic.abi) return; - try { - const logs = parseEventLogs({ - abi: evm.gameLogic.abi, - logs: requestReceipt.logs, - eventName: 'BattleRandomnessRequested', - strict: false, - }) as unknown as { args: { requester?: string; requestId?: bigint } }[]; - const mine = logs.find((l) => l.args.requester?.toLowerCase() === address.toLowerCase()); - if (mine?.args.requestId != null) { - setRequestId(mine.args.requestId); - setPhase('awaiting-vrf'); - } - } catch { - /* not a battle tx / ABI mismatch */ - } - }, [enabled, requestReceipt, address, evm?.gameLogic.abi]); - - // 2. Live updates from the backend over WebSocket — the sole source of in-progress - // battle info (see this hook's header comment for why not chain-watching). - const { liveOutcome, resolvedResult } = useLiveBattleSocket(evm?.liveBattleWsUrl, chainId, requestId); - - // 3. settleBattle tx — normally sent by the backend settle keeper, not the player. This - // hook only sends it itself as a last-resort fallback (see maybeStartFallback below). - const settle = useWriteContract(); - const settleSentRef = useRef(false); - const fallbackTimerRef = useRef | null>(null); - const fallbackCancelledRef = useRef(false); - - const clearFallbackTimer = useCallback(() => { - if (fallbackTimerRef.current != null) { - clearTimeout(fallbackTimerRef.current); - fallbackTimerRef.current = null; - } - }, []); - - const sendSettleFallback = useCallback((id: bigint) => { - if (settleSentRef.current || !gameLogic) return; - settleSentRef.current = true; - setPhase('settling'); - settle.writeContract( - // settle runs the full combat sim + auto-leveling + writes; give it a - // generous explicit limit (consistent with the request/breed paths). - { address: gameLogic, abi: gameLogicAbi, functionName: 'settleBattle', args: [id], gas: EVM_GAS_LIMITS.settleBattle, chainId }, - { - onSuccess: () => setPhase('resolving'), - onError: (e) => { setError(e as Error); setPhase('error'); }, - }, - ); - }, [gameLogic, gameLogicAbi, chainId, settle]); - - // Waits for settle to actually be able to succeed (read-only simulateContract, no wallet - // prompt) before asking the player to sign — avoids wasting their gas on a transaction - // that reverts because entropy genuinely hasn't revealed yet. - const waitThenSendFallback = useCallback(async (id: bigint) => { - if (!gameLogic || !publicClient) return; - for (let attempt = 0; attempt < FALLBACK_SIMULATE_MAX_ATTEMPTS; attempt++) { - if (settleSentRef.current || fallbackCancelledRef.current) return; - try { - await publicClient.simulateContract({ - address: gameLogic, - abi: gameLogicAbi, - functionName: 'settleBattle', - args: [id], - }); - break; // would succeed now - } catch { - await sleep(FALLBACK_SIMULATE_RETRY_MS); - } - } - if (!settleSentRef.current && !fallbackCancelledRef.current) sendSettleFallback(id); - }, [gameLogic, gameLogicAbi, publicClient, sendSettleFallback]); - - // Latest-ref so the arming effect below doesn't depend on this callback's identity — - // `settle` (from useWriteContract) is a new object every render, which would otherwise - // reset the 60s timer on every re-render while awaiting-vrf and defeat the whole point - // of the fallback (see onResolvedRef above for the same pattern). - const waitThenSendFallbackRef = useRef(waitThenSendFallback); - waitThenSendFallbackRef.current = waitThenSendFallback; - - // Starts the fallback window the moment the request is confirmed — independent of any - // reveal signal, since the whole point is this must work even if the backend (keeper and - // live-battle-socket both) is completely down. - useEffect(() => { - if (phase !== 'awaiting-vrf' || requestId == null) return; - fallbackCancelledRef.current = false; - clearFallbackTimer(); - fallbackTimerRef.current = setTimeout(() => { - setPhase('awaiting-settle'); - void waitThenSendFallbackRef.current(requestId); - }, FALLBACK_START_DELAY_MS); - return clearFallbackTimer; - }, [phase, requestId, clearFallbackTimer]); - - // Cancel any pending fallback on unmount so it can't fire (and send a tx) after the - // component watching this battle is gone. - useEffect(() => () => { fallbackCancelledRef.current = true; clearFallbackTimer(); }, [clearFallbackTimer]); - - // 4. Resolve — fire at most once per battle, from whichever source arrives first. - const resolvedFiredRef = useRef(false); - const applyResolved = useCallback((resolved: BattleResolvedResult) => { - if (resolvedFiredRef.current) return; - resolvedFiredRef.current = true; - fallbackCancelledRef.current = true; - clearFallbackTimer(); // the keeper (or the fallback itself) already settled this - setResult(resolved); - setPhase('resolved'); - onResolvedRef.current?.(resolved); - }, [clearFallbackTimer]); - - // Primary path: the backend's authoritative push, decoded from its own settle receipt. - useEffect(() => { - if (resolvedResult) applyResolved(resolvedResult); - }, [resolvedResult, applyResolved]); - - // Fallback-only path: if *this hook* sent the settle tx, decode BattleResolved from its - // own receipt directly rather than waiting on the socket (belt-and-braces — the socket - // should also report it, but this doesn't depend on the backend at all). - const { data: settleReceipt } = useWaitForTransactionReceipt({ - hash: settle.data, - query: { enabled: !!settle.data }, - }); - useEffect(() => { - if (!enabled || !settleReceipt || requestId == null || !evm?.gameLogic.abi) return; - try { - const logs = parseEventLogs({ - abi: evm.gameLogic.abi, logs: settleReceipt.logs, eventName: 'BattleResolved', strict: false, - }) as unknown as { args: Record }[]; - const mine = logs.find((l) => l.args.requestId === requestId); - if (!mine) return; - const a = mine.args; - applyResolved({ - requestId: a.requestId as bigint, - winnerId: a.winnerId as bigint, - loserId: a.loserId as bigint, - vrfSeed: a.randomness as bigint, - firstWins: a.firstWins as boolean, - rounds: Number(a.rounds), - winnerHpRemaining: Number(a.winnerHpRemaining), - xpWin: Number(a.xpWin), - xpLoss: Number(a.xpLoss), - }); - } catch { /* ignore */ } - }, [enabled, settleReceipt, requestId, evm?.gameLogic.abi, applyResolved]); - - const reset = useCallback(() => { - fallbackCancelledRef.current = true; - clearFallbackTimer(); - setRequestId(null); - setPhase('idle'); - setResult(null); - setError(null); - settleSentRef.current = false; - resolvedFiredRef.current = false; - settle.reset(); - }, [settle, clearFallbackTimer]); - - const isActive = - phase === 'awaiting-vrf' || - phase === 'awaiting-settle' || - phase === 'settling' || - phase === 'resolving'; - - return { - phase, - requestId, - result, - error: error ?? (settle.error as Error | null), - isActive, - reset, - /** Backend-pushed sim outcome (log + startHp1/startHp2 + its own result), available - * as soon as entropy reveals — drives the live animation. Presentation only; - * `result` above (from BattleResolved) is always the authoritative outcome; see - * plan-realtime-battle-ux.md's reconciliation rule. Null until the backend pushes - * one (see useLiveBattleSocket's header comment on why there's no other source). */ - liveReplay: liveOutcome, - }; -}; diff --git a/shared/src/hooks/chains/ethereum/useLiveBattleReplay.ts b/shared/src/hooks/chains/ethereum/useLiveBattleReplay.ts deleted file mode 100644 index 6878f0c6..00000000 --- a/shared/src/hooks/chains/ethereum/useLiveBattleReplay.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useMemo } from 'react'; -import { simulate, type SimOutcome, type SkillConfig } from '../../../utils/combat'; - -export interface LiveBattleReplayInput { - dna1: bigint; - rarity1: number; - level1: number; - speciesId1: number; - dna2: bigint; - rarity2: number; - level2: number; - speciesId2: number; - /** The revealed Pyth Entropy word — the exact seed settleBattle will simulate from. */ - randomNumber: bigint; - skillConfig: SkillConfig; -} - -/** - * Runs the client-side combat sim (shared/src/utils/combat) the instant all - * its inputs are known — right after entropy reveals, independent of when - * settleBattle actually gets mined by the settle keeper. Presentation only: - * the on-chain `BattleResolved` event is always the authoritative result (see - * plan-realtime-battle-ux.md's reconciliation rule) — this hook's output - * drives the live round-by-round animation, never the final verdict. - * - * `input` is expected to be memoized by the caller (useEvmBattleFlow) so this - * only recomputes when an actual input value changes, not on every render. - */ -export function useLiveBattleReplay(input: LiveBattleReplayInput | null): SimOutcome | null { - return useMemo(() => { - if (!input) return null; - const skill1 = input.speciesId1 % 8; - const skill2 = input.speciesId2 % 8; - return simulate( - input.dna1, input.rarity1, input.level1, skill1, - input.dna2, input.rarity2, input.level2, skill2, - input.randomNumber, input.skillConfig, - ); - }, [input]); -} diff --git a/shared/src/hooks/chains/ethereum/useLiveBattleSocket.ts b/shared/src/hooks/chains/ethereum/useLiveBattleSocket.ts deleted file mode 100644 index c8c52856..00000000 --- a/shared/src/hooks/chains/ethereum/useLiveBattleSocket.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect, useState } from 'react'; -import { decodeSimOutcome, type SimOutcome } from '../../../utils/combat'; -import { decodeBattleResolvedResult, type LiveBattleWireMessage } from '../../../types/liveBattleSocket'; -import type { BattleResolvedResult } from '../../../types/battle'; - -export interface LiveBattleSocketState { - /** Backend-computed sim, pushed the instant entropy reveals. Presentation only. */ - liveOutcome: SimOutcome | null; - /** The actual settled outcome, pushed once the keeper's settle tx confirms — - * decoded from the same on-chain BattleResolved event the keeper itself reads, - * so this is just as authoritative as watching the event directly would be. */ - resolvedResult: BattleResolvedResult | null; -} - -const EMPTY_STATE: LiveBattleSocketState = { liveOutcome: null, resolvedResult: null }; - -/** - * Receives the backend settle keeper's pushed battle updates (docs/plan-realtime-battle-ux.md's - * live-battle-socket feature) over WebSocket — both the live pre-settle sim and the final - * settled result. This is the *only* source of battle-in-progress information the frontend - * uses; it deliberately does not fall back to polling the chain directly, since that RPC - * watching proved unreliable against public endpoints (see keeper.ts's pollContractEvents - * comment) — a disconnected/unavailable socket just means no live updates for this battle, - * not a fallback to a different, less reliable mechanism. - */ -export function useLiveBattleSocket( - wsUrl: string | undefined, - chainId: number | undefined, - requestId: bigint | null, -): LiveBattleSocketState { - const [state, setState] = useState(EMPTY_STATE); - - useEffect(() => { - setState(EMPTY_STATE); - if (!wsUrl || !chainId || requestId == null) return; - - let cancelled = false; - const ws = new WebSocket(wsUrl); - - ws.onmessage = (event) => { - if (cancelled) return; - try { - const msg = JSON.parse(event.data as string) as LiveBattleWireMessage; - if (msg.chainId !== chainId || msg.requestId !== requestId.toString()) return; - if (msg.type === 'live') { - setState((prev) => ({ ...prev, liveOutcome: decodeSimOutcome(msg.outcome) })); - } else { - setState((prev) => ({ ...prev, resolvedResult: decodeBattleResolvedResult(msg.result) })); - } - } catch { - // Malformed message — ignore, no update for this one. - } - }; - - return () => { - cancelled = true; - ws.close(); - }; - }, [wsUrl, chainId, requestId]); - - return state; -} diff --git a/shared/src/hooks/chains/ethereum/usePendingBattle.ts b/shared/src/hooks/chains/ethereum/usePendingBattle.ts deleted file mode 100644 index 6c27908d..00000000 --- a/shared/src/hooks/chains/ethereum/usePendingBattle.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { useAccount, useReadContract, useSimulateContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'; -import { usePetsConfig } from '../../../contexts/PetsConfigContext'; -import { EVM_GAS_LIMITS } from './gasLimits'; - -export interface PendingBattleTx { - run(): Promise; - isPending: boolean; - error: Error | null; - hash?: `0x${string}`; -} - -export interface PendingBattle { - /** The open VRF request id for this pet (undefined / 0n when none). */ - requestId?: bigint; - /** True when this pet has an unresolved battle blocking new ones. */ - isPending: boolean; - /** settleBattle — works once entropy has fulfilled; permissionless. */ - settle: PendingBattleTx; - /** cancelBattle — only before entropy fulfillment; requester or contract owner. */ - cancel: PendingBattleTx; - /** True only while cancelBattle would succeed (entropy not yet fulfilled). */ - canCancel: boolean; - refetch(): void; -} - -/** - * Reads a pet's open battle request (`petBattleRequestId`) and exposes manual - * settle/cancel so an interrupted async battle can be recovered from the UI. - * EVM-only; returns a not-pending shell on other chains. - */ -export const usePendingBattle = (petId?: string): PendingBattle => { - const { evm } = usePetsConfig(); - const gameLogic = evm?.gameLogic.address; - const abi = evm?.gameLogic.abi ?? []; - const chainId = evm?.chainId; - const enabled = Boolean(gameLogic && petId); - - const { data: requestIdData, refetch: refetchId } = useReadContract({ - address: gameLogic, - abi, - functionName: 'petBattleRequestId', - args: petId ? [BigInt(petId)] : undefined, - chainId, - query: { enabled }, - }); - - const requestId = requestIdData as bigint | undefined; - const isPending = requestId != null && requestId !== 0n; - - const { address: userAddress } = useAccount(); - - // Simulate cancelBattle to detect whether entropy has already been fulfilled. - // cancelBattle reverts with "Already fulfilled" once entropyCallback fires, so - // simulation failure means only settleBattle is still valid. - const { isSuccess: cancelFeasible } = useSimulateContract({ - address: gameLogic, - abi, - functionName: 'cancelBattle', - args: requestId != null ? [requestId] : undefined, - account: userAddress, - chainId, - query: { enabled: isPending && requestId != null && Boolean(userAddress) }, - }); - - const settleW = useWriteContract(); - const cancelW = useWriteContract(); - const settleR = useWaitForTransactionReceipt({ hash: settleW.data, query: { enabled: !!settleW.data } }); - const cancelR = useWaitForTransactionReceipt({ hash: cancelW.data, query: { enabled: !!cancelW.data } }); - - const refetch = useCallback(() => { void refetchId(); }, [refetchId]); - - // Re-read petBattleRequestId once either action confirms so the notice clears. - useEffect(() => { - if (settleR.isSuccess || cancelR.isSuccess) void refetchId(); - }, [settleR.isSuccess, cancelR.isSuccess, refetchId]); - - const settle: PendingBattleTx = { - async run() { - if (!gameLogic || requestId == null) throw new Error('No pending battle to settle'); - await settleW.writeContractAsync({ address: gameLogic, abi, functionName: 'settleBattle', args: [requestId], gas: EVM_GAS_LIMITS.settleBattle, chainId }); - }, - isPending: settleW.isPending || (!!settleW.data && !settleR.isSuccess && !settleR.isError), - error: (settleW.error as Error | null) ?? (settleR.isError ? (settleR.error as Error) : null), - hash: settleW.data, - }; - - const cancel: PendingBattleTx = { - async run() { - if (!gameLogic || requestId == null) throw new Error('No pending battle to cancel'); - await cancelW.writeContractAsync({ address: gameLogic, abi, functionName: 'cancelBattle', args: [requestId], gas: EVM_GAS_LIMITS.cancelBattle, chainId }); - }, - isPending: cancelW.isPending || (!!cancelW.data && !cancelR.isSuccess && !cancelR.isError), - error: (cancelW.error as Error | null) ?? (cancelR.isError ? (cancelR.error as Error) : null), - hash: cancelW.data, - }; - - return { requestId, isPending, settle, cancel, canCancel: cancelFeasible === true, refetch }; -}; diff --git a/shared/src/hooks/chains/solana/useLiveBattleReplaySolana.ts b/shared/src/hooks/chains/solana/useLiveBattleReplaySolana.ts deleted file mode 100644 index 00abea38..00000000 --- a/shared/src/hooks/chains/solana/useLiveBattleReplaySolana.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Buffer } from 'buffer'; -import { PublicKey } from '@solana/web3.js'; -import * as sb from '@switchboard-xyz/on-demand'; -import { useProgram } from './useProgram'; -import { useSolanaAnchor } from '../../../contexts/SolanaAnchorContext'; -import { battleRequestPda } from '../../../utils/solana/pdas'; -import { getAccountClient } from '../../../utils/solana/accountClient'; -import { toU32 } from '../../../utils/solana/numbers'; -import { simulate, DEFAULT_SKILL_CONFIG, type SimOutcome } from '../../../utils/combat'; - -const POLL_INTERVAL_MS = 2_000; - -const toPublicKey = (value: unknown): PublicKey => { - if (value instanceof PublicKey) return value; - if (value && typeof value === 'object' && 'toBase58' in value) { - return new PublicKey((value as { toBase58: () => string }).toBase58()); - } - return new PublicKey(String(value)); -}; - -const toBigIntField = (value: unknown): bigint => { - if (typeof value === 'bigint') return value; - if (value && typeof value === 'object' && 'toString' in value) { - return BigInt((value as { toString(): string }).toString()); - } - return BigInt(value as number); -}; - -const bytesToBigIntBE = (bytes: ArrayLike): bigint => { - let seed = 0n; - for (let i = 0; i < bytes.length; i++) seed = (seed << 8n) | BigInt(bytes[i]); - return seed; -}; - -/** - * Decodes the Switchboard gateway's revealed randomness `value` into the - * big-endian seed the shared combat sim expects. Defensive because the exact - * wire shape (byte array vs. hex/base64 string) isn't verifiable without a - * live gateway round-trip in this environment — see this file's header - * comment. Returns `null` on any unrecognized shape. - */ -const decodeRevealedValue = (raw: unknown): bigint | null => { - if (Array.isArray(raw) && raw.every((b) => typeof b === 'number')) { - return bytesToBigIntBE(raw); - } - if (raw instanceof Uint8Array) { - return bytesToBigIntBE(raw); - } - if (typeof raw === 'string' && raw.length > 0) { - const hex = raw.startsWith('0x') ? raw.slice(2) : raw; - if (/^[0-9a-fA-F]+$/.test(hex) && hex.length % 2 === 0) { - return bytesToBigIntBE(Uint8Array.from(Buffer.from(hex, 'hex'))); - } - try { - const decoded = Buffer.from(raw, 'base64'); - if (decoded.length > 0) return bytesToBigIntBE(Uint8Array.from(decoded)); - } catch { - // fall through to null below - } - } - return null; -}; - -/** - * Best-effort live-before-settle battle animation for Solana (mirrors EVM's - * useLiveBattleReplay; plan-realtime-battle-solana.md Workstream S3). - * - * Switchboard On-Demand has no separate on-chain "reveal" event before settle - * the way Pyth Entropy's `Revealed` event does on EVM — the settle keeper - * bundles reveal+settle into one transaction (battleWithSwitchboardVrf.ts). - * To get the seed before that transaction lands, this independently calls - * the same `randomness.revealIx()` the keeper will call (which round-trips - * to the Switchboard gateway) but never broadcasts the resulting - * instruction — it only Borsh-decodes it locally to read the revealed - * `value` field back out. - * - * NOT verified against a live devnet gateway in this environment (no network - * access here) — the exact byte encoding of the gateway's `value` response is - * asserted from the SDK's type defs, not observed. Every step is wrapped so a - * wrong assumption yields `null` (no live animation — identical UX to before - * this feature existed) rather than a broken UI; the on-chain - * `BattleResolved` event / stat-diff fallback (useBattleOutcome.ts) remain - * authoritative regardless. Please verify against a real Switchboard queue - * (devnet or mainnet) before relying on this for production animation. - */ -export function useLiveBattleReplaySolana(enabled: boolean): SimOutcome | null { - const { program, programId } = useProgram(); - const { signingWallet, connection } = useSolanaAnchor(); - const owner = signingWallet?.publicKey ?? null; - const [outcome, setOutcome] = useState(null); - const resolvedKeyRef = useRef(null); - - useEffect(() => { - if (!enabled || !program || !programId || !owner) { - setOutcome(null); - resolvedKeyRef.current = null; - return; - } - - let cancelled = false; - let timer: ReturnType | undefined; - - const poll = async () => { - try { - const [battleRequestKey] = battleRequestPda(programId, owner); - const req = await getAccountClient(program, 'battleRequest').fetchNullable(battleRequestKey) as Record | null; - if (!req) { - if (!cancelled) { - setOutcome(null); - resolvedKeyRef.current = null; - } - return; - } - - const key = battleRequestKey.toBase58(); - if (resolvedKeyRef.current === key) return; // already computed for this request - - const randomnessPk = toPublicKey(req.randomnessAccount); - const queue = await sb.getDefaultQueue(connection.rpcEndpoint); - const randomness = new sb.Randomness(queue.program, randomnessPk); - - let revealIx; - try { - revealIx = await randomness.revealIx(owner); - } catch { - return; // oracle hasn't revealed yet — retry next poll - } - if (cancelled) return; - - // `InstructionCoder`'s TS type only declares `encode`, but Anchor's - // `BorshInstructionCoder` runtime implementation also has `decode` - // (verified by reading the compiled coder source directly). - const instructionCoder = queue.program.coder.instruction as unknown as { - decode: (data: Buffer) => { name: string; data: unknown } | null; - }; - let decoded: { name: string; data: unknown } | null; - try { - decoded = instructionCoder.decode(revealIx.data); - } catch { - decoded = null; - } - const value = (decoded?.data as { value?: unknown } | undefined)?.value; - const seed = decodeRevealedValue(value); - if (seed === null || cancelled) return; - - const skill1 = toU32(req.attackerSpeciesId) % 8; - const skill2 = toU32(req.defenderSpeciesId) % 8; - - const result = simulate( - toBigIntField(req.attackerDna), toU32(req.attackerRarity), toU32(req.attackerLevel), skill1, - toBigIntField(req.defenderDna), toU32(req.defenderRarity), toU32(req.defenderLevel), skill2, - seed, DEFAULT_SKILL_CONFIG, - ); - - if (!cancelled) { - resolvedKeyRef.current = key; - setOutcome(result); - } - } catch { - // Any unexpected shape or transient network failure: no live - // animation this poll; the next tick tries again. - } finally { - if (!cancelled) timer = setTimeout(() => { void poll(); }, POLL_INTERVAL_MS); - } - }; - - void poll(); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - }; - }, [enabled, program, programId, owner, connection]); - - return outcome; -} diff --git a/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts b/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts deleted file mode 100644 index eb285e2e..00000000 --- a/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { useCallback } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { SystemProgram } from '@solana/web3.js'; -import { useProgram } from './useProgram'; -import { useSolanaAnchor } from '../../../contexts/SolanaAnchorContext'; -import { battleRequestPda, feeVaultPda, globalStatePda } from '../../../utils/solana/pdas'; -import { getAccountClient } from '../../../utils/solana/accountClient'; - -export interface PendingSolanaBattle { - /** True when the current wallet has an unresolved on-chain battle request. */ - isPending: boolean; - /** - * True when the randomness has expired and cancel_battle can be called. - * Always false until the slot data has loaded. - */ - canCancel: boolean; - cancel: { - run(): Promise; - isPending: boolean; - error: Error | null; - }; - refetch(): void; -} - -const toNumber = (v: unknown): number => { - if (typeof v === 'number') return v; - if (typeof v === 'bigint') return Number(v); - if (v && typeof (v as { toString(): string }).toString === 'function') return Number((v as { toString(): string }).toString()); - return 0; -}; - -export const usePendingSolanaBattle = (enabled = true): PendingSolanaBattle => { - const { signingWallet, connection } = useSolanaAnchor(); - const { program, programId, isReady } = useProgram(); - const owner = signingWallet?.publicKey; - const queryClient = useQueryClient(); - - const queryKey = ['cryptopets', 'battleRequest', owner?.toBase58(), programId?.toBase58()]; - - const query = useQuery({ - queryKey, - enabled: enabled && isReady && Boolean(owner && program && programId), - queryFn: async () => { - if (!program || !programId || !owner) return null; - const [pda] = battleRequestPda(programId, owner); - const request = await getAccountClient(program, 'battleRequest').fetchNullable(pda); - if (!request) return null; - const [gsPda] = globalStatePda(programId); - const gs = await getAccountClient(program, 'globalState').fetchNullable(gsPda) as Record | null; - const currentSlot = await connection.getSlot('confirmed'); - const req = request as Record; - const commitSlot = toNumber(req.commitSlot); - const expirySlots = gs ? toNumber(gs.randomnessExpirySlots) : 0; - return { request: req, commitSlot, expirySlots, currentSlot }; - }, - refetchInterval: 5_000, - }); - - const isPending = query.data != null; - const canCancel = isPending && query.data != null - ? query.data.currentSlot > query.data.commitSlot + query.data.expirySlots - : false; - - const cancelMutation = useMutation({ - mutationFn: async () => { - if (!program || !programId || !owner) throw new Error('Solana program not ready'); - const [globalState] = globalStatePda(programId); - const [battleRequest] = battleRequestPda(programId, owner); - const [feeVault] = feeVaultPda(programId); - await program.methods - .cancelBattle() - .accounts({ - globalState, - attackerOwner: owner, - battleRequest, - feeVault, - systemProgram: SystemProgram.programId, - }) - .rpc(); - }, - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey }); - }, - }); - - const refetch = useCallback(() => { void query.refetch(); }, [query]); - - return { - isPending, - canCancel, - cancel: { - run: cancelMutation.mutateAsync, - isPending: cancelMutation.isPending, - error: cancelMutation.error as Error | null, - }, - refetch, - }; -}; diff --git a/shared/src/hooks/chains/solana/usePetActions.ts b/shared/src/hooks/chains/solana/usePetActions.ts index ebf44e46..4de23904 100644 --- a/shared/src/hooks/chains/solana/usePetActions.ts +++ b/shared/src/hooks/chains/solana/usePetActions.ts @@ -8,7 +8,6 @@ import { petPdaByAsset, studFeeAccountPda, } from '../../../utils/solana/pdas'; -import { battleWithSwitchboardVrf, type BattleVrfResult } from '../../../utils/solana/battleWithSwitchboardVrf'; import { breedWithSwitchboardVrf } from '../../../utils/solana/breedWithSwitchboardVrf'; import { mintWithSwitchboardVrf } from '../../../utils/solana/mintWithSwitchboardVrf'; import { getAccountClient } from '../../../utils/solana/accountClient'; @@ -20,7 +19,6 @@ export const usePetActions = () => { const { signingWallet } = useSolanaAnchor(); const { program, programId, provider } = useProgram(); - const [battleSubPhase, setBattleSubPhase] = useState<'idle' | 'awaiting-vrf'>('idle'); const [breedSubPhase, setBreedSubPhase] = useState<'idle' | 'awaiting-vrf'>('idle'); const invalidateProgramQueries = () => queryClient.invalidateQueries({ queryKey: ['cryptopets'] }); @@ -191,42 +189,6 @@ export const usePetActions = () => { onSuccess: invalidateProgramQueries, }); - /** - * Battle the signer's pet (attacker) against any pet. When `defenderOwner` is - * omitted it defaults to the signer (same-wallet battle); pass a foreign owner - * pubkey for PvP against another player's pet. - */ - const battlePets = useMutation({ - mutationFn: async (args) => { - const { program, programId, owner } = requireReady(); - if (!provider) throw new Error('Solana provider is not ready'); - setBattleSubPhase('idle'); - try { - return await battleWithSwitchboardVrf({ - program, - provider, - programId, - owner, - attackerPetId: args.attackerPetId, - defenderPetId: args.defenderPetId, - attackerAssetKey: args.attackerAssetKey, - ...(args.defenderOwner - ? { defenderOwner: new PublicKey(args.defenderOwner) } - : {}), - onCommitted: () => setBattleSubPhase('awaiting-vrf'), - }); - } finally { - setBattleSubPhase('idle'); - } - }, - onSuccess: invalidateProgramQueries, - }); - /** * Breed via Switchboard On-Demand VRF (commit + reveal), matching the EVM Chainlink flow. * For cross-owner breeding, pass `parent2AssetKey` and `parent2Owner`; for same-wallet @@ -276,8 +238,6 @@ export const usePetActions = () => { withdrawStudFees, syncMetadata, setOpenToChallenges, - battlePets, - battleSubPhase, breedPets, breedSubPhase, walletPublicKey: signingWallet?.publicKey ?? null, diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index e38ff498..f8594097 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -7,12 +7,10 @@ export { usePetsContract } from './chains/ethereum/usePetsContract'; export { useFees, type UnifiedFees } from './useFees'; export { useEvmFees, type EvmFees } from './chains/ethereum/useEvmFees'; export { useSolanaFees, type SolanaFees } from './chains/solana/useSolanaFees'; -// Manual recovery for an interrupted async battle (settle / cancel a pending request). -export { usePendingBattle, type PendingBattle } from './chains/ethereum/usePendingBattle'; +// Manual recovery for an interrupted async breed (settle / cancel a pending request). export { usePendingBreed, type PendingBreed } from './chains/ethereum/usePendingBreed'; export { useBreedRelationCheck, type BreedRelationCheck } from './chains/ethereum/useBreedRelationCheck'; // Solana pending VRF requests — auto-resumes on next action; cancel available after randomness expiry. -export { usePendingSolanaBattle, type PendingSolanaBattle } from './chains/solana/usePendingSolanaBattle'; export { usePendingSolanaBreed, type PendingSolanaBreed } from './chains/solana/usePendingSolanaBreed'; // Solana defender-consent toggle (openToChallenges). No-op on EVM. export { useSetOpenToChallenges, type UseSetOpenToChallengesResult } from './useSetOpenToChallenges'; diff --git a/shared/src/hooks/useBattlePets.ts b/shared/src/hooks/useBattlePets.ts index 912b5ab9..502a6fc9 100644 --- a/shared/src/hooks/useBattlePets.ts +++ b/shared/src/hooks/useBattlePets.ts @@ -1,101 +1,176 @@ -import { useCallback, useRef } from 'react'; -import { useChainAdapter } from './adapters/useChainAdapter'; -import { useTxSuccess } from './useTxSuccess'; -import { useEvmBattleFlow } from './chains/ethereum/useEvmBattleFlow'; -import { useLiveBattleReplaySolana } from './chains/solana/useLiveBattleReplaySolana'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +import type { BattleReceipt } from '@cryptopets/protocol'; + import type { BattleResolvedResult } from '../types/battle'; +import { useBackendBattle } from './useBackendBattle'; +import { useSubmitBattleIntent } from './useSubmitBattleIntent'; +import { useVerifiedBattleReceipt } from './useVerifiedBattleReceipt'; + +/** + * Starting a battle and following it to a verified result (§D, §E, §J). + * + * Battles are resolved by the backend, not on chain, so this no longer goes through the + * chain adapter: there is nothing chain-specific left about running a fight. The wallet + * still signs — the intent is what authorizes the battle (§D) — but no transaction is sent + * and no gas is paid. + * + * What this returns is deliberately the same shape as the on-chain version, so the battle + * UI keeps working: a `mutate`, a phase, a `liveReplay` to animate, and an `onSuccess` + * carrying the resolved outcome. + * + * One thing genuinely changed, and it is the point of the design: `liveReplay` and the + * authoritative result are now the *same computation*. The old flow animated a local + * prediction and reconciled it against the chain afterwards; here the client verifies the + * signed receipt and replays that, so what is animated is what the receipt commits to, or + * nothing is animated at all. + */ + export interface BattlePetsArgs { /** Attacker — must be a pet the caller owns. */ petId1: string; /** Defender — may belong to another player. */ petId2: string; - /** - * Owner of the defender pet. Required for cross-owner Solana battles (used to - * derive the defender pet PDA). Ignored on EVM, where `petId2` is a global id. - */ + /** Owner of the defender pet. The intent binds both owners (§D). */ defenderOwner?: string; } export type UseBattlePetsOptions = { - /** Fires once the battle is settled on-chain (EVM: BattleResolved; Solana: confirm). */ + /** Fires once the receipt is signed and has verified locally. */ onSuccess?: (result: BattleResolvedResult | null) => void; + /** Room to follow for push updates, and the socket to reach it on. */ + roomId?: string | null; + roomSocketUrl?: string | undefined; }; +/** Where a battle is, in the vocabulary the existing UI already speaks. */ +export type BackendBattlePhase = + | 'idle' + | 'requesting' + | 'awaiting-vrf' + | 'resolving' + | 'resolved' + | 'error'; + +const TERMINAL_FAILURES = new Set(['rejected', 'forfeited', 'verification_failed', 'signing_failed']); + export const useBattlePets = (options?: UseBattlePetsOptions) => { - const adapter = useChainAdapter(); - const { battlePets } = adapter; - const isEvm = adapter.kind === 'evm'; + const { submit, isPending: isSubmitting, error: submitError } = useSubmitBattleIntent(); + const [battleId, setBattleId] = useState(null); const onSuccessRef = useRef(options?.onSuccess); onSuccessRef.current = options?.onSuccess; - // EVM: v2 battle is async (request → VRF → settle → BattleResolved). Success - // is event-driven, not receipt-driven, so the request hash feeds the flow - // and onSuccess fires only once the battle is actually resolved on-chain. - const battleFlow = useEvmBattleFlow({ - requestHash: isEvm ? (battlePets.lifecycle.hash as `0x${string}` | undefined) : undefined, - enabled: isEvm, - onResolved: (result) => onSuccessRef.current?.(result), + const battle = useBackendBattle(battleId, { + roomId: options?.roomId ?? null, + roomSocketUrl: options?.roomSocketUrl, }); - // Solana: no async request/settle state machine like EVM's, so live - // replay is derived independently by polling for a pending battleRequest - // (see useLiveBattleReplaySolana's header comment for why, and its - // verification caveat). - const solanaLiveReplay = useLiveBattleReplaySolana(!isEvm); - - // Solana: success fires from the mutateAsync return value (BattleResolvedResult | null). - // useTxSuccess is kept as a fallback only — the ref prevents double-firing. - const solanaBattleFiredRef = useRef(false); - useTxSuccess(battlePets.lifecycle, useCallback(() => { - if (!isEvm && !solanaBattleFiredRef.current) onSuccessRef.current?.(null); - solanaBattleFiredRef.current = false; - }, [isEvm])); - - const mutate = async (args: BattlePetsArgs) => { - battleFlow.reset(); - solanaBattleFiredRef.current = false; - try { - const result = await battlePets.mutateAsync({ - petId1: args.petId1, - petId2: args.petId2, - defenderOwner: args.defenderOwner, + // Only fetched once a receipt exists. Verification runs locally and gates the + // animation: an unverified receipt yields no outcome, so nothing is shown. + const hasReceipt = battle.data ? battle.isSettled && !TERMINAL_FAILURES.has(battle.data.state) : false; + const verified = useVerifiedBattleReceipt(hasReceipt ? battleId : null); + + const result = useMemo( + () => (verified.data?.verified ? toResolvedResult(verified.data.receipt) : null), + [verified.data], + ); + + // Fired exactly once per battle, when the verified result first lands. + const firedForRef = useRef(null); + if (result && battleId && firedForRef.current !== battleId) { + firedForRef.current = battleId; + onSuccessRef.current?.(result); + } + + const mutate = useCallback( + async (args: BattlePetsArgs) => { + setBattleId(null); + firedForRef.current = null; + const accepted = await submit({ + attackerPetId: args.petId1, + defenderPetId: args.petId2, + defenderOwner: args.defenderOwner ?? '', + ...(options?.roomId ? { roomId: options.roomId } : {}), }); - if (!isEvm) { - solanaBattleFiredRef.current = true; - onSuccessRef.current?.(result ?? null); - } - } catch { - // error tracked in battlePets.lifecycle.error - } - }; + if (accepted) setBattleId(accepted.battleId); + }, + [submit, options?.roomId], + ); const reset = useCallback(() => { - battleFlow.reset(); - battlePets.lifecycle.reset(); - }, [battleFlow, battlePets.lifecycle]); + setBattleId(null); + firedForRef.current = null; + }, []); - // On EVM the arena must stay "fighting" through VRF + settle, not just the - // request tx, so fold the async flow's active state into isPending. - const isPending = battlePets.isPending || (isEvm && battleFlow.isActive); + const phase = derivePhase(isSubmitting, battle.data?.state, Boolean(result), Boolean(submitError)); + const error = submitError ?? (battle.error as Error | null) ?? (verified.error as Error | null) ?? null; return { mutate, - isPending, - isConfirming: battlePets.lifecycle.phase === 'confirming' || (isEvm && battleFlow.phase === 'settling'), - isAwaitingVrf: isEvm ? battleFlow.phase === 'awaiting-vrf' : battlePets.lifecycle.phase === 'awaiting-vrf', - phase: isEvm ? battleFlow.phase : (battlePets.lifecycle.phase === 'awaiting-vrf' ? 'awaiting-vrf' : undefined), - result: battleFlow.result, - /** Client-side live-replay outcome, presentation only — see - * useEvmBattleFlow's liveReplay doc (EVM) and - * useLiveBattleReplaySolana's header doc (Solana, incl. its - * verification caveat) for how each chain derives this. */ - liveReplay: isEvm ? battleFlow.liveReplay : solanaLiveReplay, + isPending: isSubmitting || (battleId !== null && !result && phase !== 'error'), + isConfirming: phase === 'resolving', + /** Waiting on the committed drand round — the backend-mode analogue of VRF. */ + isAwaitingVrf: phase === 'awaiting-vrf', + phase, + result, + /** + * The client's own replay of the verified receipt. Present only once every check + * passed, so the UI can never animate a fight the receipt does not commit to. + */ + liveReplay: verified.data?.outcome ?? null, reset, clearErrors: reset, - hash: battlePets.lifecycle.hash, - error: battlePets.lifecycle.error ?? battleFlow.error, - lifecycle: battlePets.lifecycle, + /** The battle id, which replaces the transaction hash as this battle's identifier. */ + hash: battleId ?? undefined, + error, + /** Kept for the shared, phase-driven contract. */ + lifecycle: { + phase: phase === 'resolved' ? ('success' as const) : phase === 'error' ? ('error' as const) : ('confirming' as const), + hash: battleId ?? undefined, + error, + reset, + }, + /** Every local check and its verdict, so a UI can show *why* a result is trusted. */ + checks: verified.data?.checks ?? [], }; }; + +function derivePhase( + isSubmitting: boolean, + state: string | undefined, + hasResult: boolean, + hasSubmitError: boolean, +): BackendBattlePhase { + if (hasSubmitError) return 'error'; + if (isSubmitting) return 'requesting'; + if (hasResult) return 'resolved'; + if (!state) return 'idle'; + if (TERMINAL_FAILURES.has(state)) return 'error'; + // `committed`/`seeded` is the wait for the drand round this battle was committed to, + // which is the same shape of wait the old flow called `awaiting-vrf`. + if (state === 'accepted' || state === 'committed' || state === 'seeded') return 'awaiting-vrf'; + return 'resolving'; +} + +/** + * Maps a verified receipt onto the result shape the UI already renders. + * + * `requestId` is zero: there is no on-chain request behind a backend battle, and inventing + * an identifier that looked like one would invite treating it as a chain reference. + */ +function toResolvedResult(receipt: BattleReceipt): BattleResolvedResult { + const attackerWon = receipt.result.attackerWon; + return { + requestId: 0n, + winnerId: attackerWon ? receipt.snapshot.attacker.petId : receipt.snapshot.defender.petId, + loserId: attackerWon ? receipt.snapshot.defender.petId : receipt.snapshot.attacker.petId, + vrfSeed: BigInt(receipt.seed), + firstWins: attackerWon, + rounds: receipt.result.rounds, + winnerHpRemaining: receipt.result.winnerHpRemaining, + xpWin: attackerWon ? receipt.progression.attacker.xpAwarded : receipt.progression.defender.xpAwarded, + xpLoss: attackerWon ? receipt.progression.defender.xpAwarded : receipt.progression.attacker.xpAwarded, + }; +} diff --git a/shared/src/utils/solana/battleWithSwitchboardVrf.ts b/shared/src/utils/solana/battleWithSwitchboardVrf.ts deleted file mode 100644 index bc55271f..00000000 --- a/shared/src/utils/solana/battleWithSwitchboardVrf.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { AnchorProvider, Program , Idl } from '@coral-xyz/anchor'; -import { EventParser } from '@coral-xyz/anchor'; -import { Keypair, PublicKey, SystemProgram } from '@solana/web3.js'; -import * as sb from '@switchboard-xyz/on-demand'; -import { battleRequestPda, feeVaultPda, globalStatePda, petPdaByAsset } from './pdas'; -import { fetchAssetByPetId, getAccountClient } from './accountClient'; -import { toU32 } from './numbers'; -import { - vrfTimingForEndpoint, - sendSignedTx, - waitForRevealIx, -} from './switchboardVrfTx'; -import { sleep } from '../common'; - -/** How long to wait for the backend settle keeper (docs/plan-realtime-battle-solana.md - * Workstream S2) before falling back to sending reveal+settle from the player's own - * wallet — mirrors EVM's FALLBACK_SETTLE_DELAY_MS. */ -const KEEPER_SETTLE_TIMEOUT_MS = 45_000; -const KEEPER_POLL_INTERVAL_MS = 2_000; - -/** - * Polls for the keeper having settled this battle: `settle_battle` closes `battleRequest` - * (`close = attacker_owner`), so its disappearance is a reliable "someone settled it" - * signal — no need to intercept the keeper's own transaction or its signature. - */ -const waitForKeeperSettle = async ( - program: Program, - battleRequestKey: PublicKey, - timeoutMs: number, -): Promise => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const stillPending = await getAccountClient(program, 'battleRequest').fetchNullable(battleRequestKey); - if (!stillPending) return true; - await sleep(KEEPER_POLL_INTERVAL_MS); - } - return false; -}; - -// `program.methods.!(...)` below: `program: Program` (untyped generic IDL, not -// a generated `Program`) makes `.methods` an index signature, so consumers with -// `noUncheckedIndexedAccess` enabled (backend, not frontend/mobile) see every property as -// possibly undefined. The instruction genuinely exists on whichever program's IDL was -// fetched — asserted, not defensively checked. - -/** Parse `firstWins` from the `BattleResolved` Anchor event in settle tx logs. */ -const parseFirstWins = async ( - program: Program, - connection: AnchorProvider['connection'], - sig: string, -): Promise => { - try { - const tx = await connection.getTransaction(sig, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); - const logs = tx?.meta?.logMessages ?? []; - const parser = new EventParser(program.programId, program.coder); - for (const event of parser.parseLogs(logs)) { - if (event.name === 'BattleResolved') { - return (event.data as { firstWins: boolean }).firstWins; - } - } - } catch { - // Non-fatal — caller gets null and UI falls back to stat-diff. - } - return null; -}; - -export type BattleVrfResult = { sig: string; firstWins: boolean | null }; - -const toPublicKey = (value: unknown): PublicKey => { - if (value instanceof PublicKey) return value; - if (value && typeof value === 'object' && 'toBase58' in value) { - return new PublicKey((value as { toBase58: () => string }).toBase58()); - } - return new PublicKey(String(value)); -}; - -const requireAsset = async (program: Program, petId: number): Promise => { - const asset = await fetchAssetByPetId(program, petId); - if (!asset) throw new Error(`Pet ${petId} not found on-chain`); - return asset; -}; - -export type BattleWithVrfArgs = { - program: Program; - provider: AnchorProvider; - programId: PublicKey; - owner: PublicKey; - attackerPetId: number; - defenderPetId: number; - /** Pubkey of the asset account for the attacker's pet (v2.1 asset-keyed PDA). */ - attackerAssetKey: string; - /** Defaults to `owner` for same-wallet battles. */ - defenderOwner?: PublicKey; - /** Fires after commit tx confirms, while the oracle is fulfilling randomness. */ - onCommitted?: () => void; -}; - -/** Completes a battle whose commit phase succeeded but settle was never submitted. */ -const trySettlePendingBattle = async (args: BattleWithVrfArgs): Promise => { - const { program, provider, programId, owner, onCommitted } = args; - const connection = provider.connection; - const [battleRequestKey] = battleRequestPda(programId, owner); - const pending = await getAccountClient(program, 'battleRequest').fetchNullable(battleRequestKey); - if (!pending) return null; - - const req = pending as Record; - const attackerPetId = toU32(req.attackerPetId); - const defenderPetId = toU32(req.defenderPetId); - const defenderOwnerPk = toPublicKey(req.defenderOwner); - const randomnessPk = toPublicKey(req.randomnessAccount); - - const [globalState] = globalStatePda(programId); - const [battleRequest] = battleRequestPda(programId, owner); - - const attackerAsset = await requireAsset(program, attackerPetId); - const defenderAsset = await requireAsset(program, defenderPetId); - const [attackerPet] = petPdaByAsset(programId, attackerAsset.toBase58()); - const [defenderPet] = petPdaByAsset(programId, defenderAsset.toBase58()); - - const queue = await sb.getDefaultQueue(connection.rpcEndpoint); - const randomness = new sb.Randomness(queue.program, randomnessPk); - const { revealRetries, revealBackoffMs } = vrfTimingForEndpoint(connection.rpcEndpoint); - - onCommitted?.(); - - // Give the backend settle keeper a chance first (plan-realtime-battle-solana.md - // Workstream S2): it watches for the same revealed randomness and submits reveal+settle - // itself, so the player isn't asked to sign a second transaction in the common case. The - // poll already waits far longer than `commitRevealWaitMs` ever did, so there's no - // separate pre-reveal sleep needed on the fallback path below. `firstWins: null` here - // lets the caller's existing stat-diff fallback (useBattleOutcome) resolve the result - // exactly as it already does when `sig` isn't available. - if (await waitForKeeperSettle(program, battleRequest, KEEPER_SETTLE_TIMEOUT_MS)) { - return { sig: '', firstWins: null }; - } - - const revealIx = await waitForRevealIx(randomness, owner, revealRetries, revealBackoffMs); - - // The keeper may have settled while we were waiting on the oracle to reveal — one more - // check right before signing avoids sending a doomed (and wasted-gas) tx. EVM has an - // equivalent guard via simulateContract immediately before send; this is Solana's. - if (!(await getAccountClient(program, 'battleRequest').fetchNullable(battleRequest))) { - return { sig: '', firstWins: null }; - } - - const settleBattleIx = await program.methods - .settleBattle!() - .accounts({ - globalState, - attackerOwner: owner, - attackerAsset, - attackerPet, - defenderOwner: defenderOwnerPk, - defenderAsset, - defenderPet, - battleRequest, - randomnessAccountData: randomnessPk, - }) - .instruction(); - - const settleTx = await sb.asV0Tx({ - connection, - ixs: [revealIx, settleBattleIx], - payer: owner, - computeUnitPrice: 75_000, - computeUnitLimitMultiple: 1.3, - }); - const sig = await sendSignedTx(provider, settleTx); - const firstWins = await parseFirstWins(program, connection, sig); - return { sig, firstWins }; -}; - -/** - * Two-phase battle using Switchboard On-Demand VRF (commit → reveal). - * Returns the settle tx signature and parsed `firstWins` from the `BattleResolved` event. - */ -export const battleWithSwitchboardVrf = async (args: BattleWithVrfArgs): Promise => { - const resumed = await trySettlePendingBattle(args); - if (resumed) return resumed; - - const { - program, - provider, - programId, - owner, - defenderPetId, - attackerAssetKey, - defenderOwner = owner, - onCommitted, - } = args; - const connection = provider.connection; - - const attackerAsset = new PublicKey(attackerAssetKey); - const defenderAsset = await requireAsset(program, defenderPetId); - - const [globalState] = globalStatePda(programId); - const [attackerPet] = petPdaByAsset(programId, attackerAssetKey); - const [defenderPet] = petPdaByAsset(programId, defenderAsset.toBase58()); - const [battleRequest] = battleRequestPda(programId, owner); - const [feeVault] = feeVaultPda(programId); - - const queue = await sb.getDefaultQueue(connection.rpcEndpoint); - const sbProgram = queue.program; - const rngKp = Keypair.generate(); - - const [randomness, createIx] = await sb.Randomness.create( - sbProgram, - rngKp, - queue.pubkey, - owner - ); - - const commitIx = await randomness.commitIx(queue.pubkey, owner); - const commitBattleIx = await program.methods - .commitBattle!(rngKp.publicKey) - .accounts({ - globalState, - attackerOwner: owner, - attackerAsset, - attackerPet, - defenderOwner, - defenderAsset, - defenderPet, - battleRequest, - feeVault, - randomnessAccountData: rngKp.publicKey, - systemProgram: SystemProgram.programId, - }) - .instruction(); - - // Create + Switchboard commit + program commit in one tx (wallet prompt 1 of 2). - const commitTx = await sb.asV0Tx({ - connection, - ixs: [createIx, commitIx, commitBattleIx], - payer: owner, - computeUnitPrice: 75_000, - computeUnitLimitMultiple: 1.3, - }); - await sendSignedTx(provider, commitTx, [rngKp]); - onCommitted?.(); - - // Give the backend settle keeper a chance first (plan-realtime-battle-solana.md - // Workstream S2): it watches for the same revealed randomness and submits reveal+settle - // itself, so the player isn't asked to sign a second transaction (wallet prompt 2 of 2 - // becomes the exception, not the rule). `firstWins: null` here lets the caller's - // existing stat-diff fallback (useBattleOutcome) resolve the result exactly as it - // already does when `sig` isn't available. - if (await waitForKeeperSettle(program, battleRequest, KEEPER_SETTLE_TIMEOUT_MS)) { - return { sig: '', firstWins: null }; - } - - const { revealRetries, revealBackoffMs } = vrfTimingForEndpoint(connection.rpcEndpoint); - const revealIx = await waitForRevealIx(randomness, owner, revealRetries, revealBackoffMs); - - // The keeper may have settled while we were waiting on the oracle to reveal — one more - // check right before signing avoids sending a doomed (and wasted-gas) tx. EVM has an - // equivalent guard via simulateContract immediately before send; this is Solana's. - if (!(await getAccountClient(program, 'battleRequest').fetchNullable(battleRequest))) { - return { sig: '', firstWins: null }; - } - - const settleBattleIx = await program.methods - .settleBattle!() - .accounts({ - globalState, - attackerOwner: owner, - attackerAsset, - attackerPet, - defenderOwner, - defenderAsset, - defenderPet, - battleRequest, - randomnessAccountData: rngKp.publicKey, - }) - .instruction(); - - const settleTx = await sb.asV0Tx({ - connection, - ixs: [revealIx, settleBattleIx], - payer: owner, - computeUnitPrice: 75_000, - computeUnitLimitMultiple: 1.3, - }); - // Reveal + settle after oracle fulfills randomness (wallet prompt 2 of 2). - const sig = await sendSignedTx(provider, settleTx); - const firstWins = await parseFirstWins(program, connection, sig); - return { sig, firstWins }; -}; diff --git a/shared/src/utils/solana/index.ts b/shared/src/utils/solana/index.ts index 5e929973..e7de85df 100644 --- a/shared/src/utils/solana/index.ts +++ b/shared/src/utils/solana/index.ts @@ -12,7 +12,6 @@ export { studFeeAccountPda, } from './pdas'; export { breedWithSwitchboardVrf } from './breedWithSwitchboardVrf'; -export { battleWithSwitchboardVrf } from './battleWithSwitchboardVrf'; export { mintWithSwitchboardVrf } from './mintWithSwitchboardVrf'; export { sendSignedTx } from './switchboardVrfTx'; export { toU32, formatLamports } from './numbers'; diff --git a/shared/tests/hooks/useBattlePets.test.ts b/shared/tests/hooks/useBattlePets.test.ts index cdcc1ca5..42619bf9 100644 --- a/shared/tests/hooks/useBattlePets.test.ts +++ b/shared/tests/hooks/useBattlePets.test.ts @@ -1,96 +1,237 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const submit = vi.hoisted(() => vi.fn()); +const submitState = vi.hoisted(() => ({ isPending: false, error: null as Error | null })); +const backendBattle = vi.hoisted(() => ({ + current: { data: undefined as unknown, isSettled: false, error: null as Error | null }, +})); +const verified = vi.hoisted(() => ({ + current: { data: undefined as unknown, error: null as Error | null }, +})); -const battlePets = { - mutateAsync: vi.fn().mockResolvedValue(undefined), - isPending: false, - lifecycle: { phase: 'idle', hash: '0xhash', error: null as Error | null, reset: vi.fn() }, -}; -const adapter = { battlePets }; - -let txSuccessArgs: [unknown, () => void] | undefined; -vi.mock('../../src/hooks/adapters/useChainAdapter', () => ({ useChainAdapter: () => adapter })); -vi.mock('../../src/hooks/useTxSuccess', () => ({ - useTxSuccess: (lifecycle: unknown, cb: () => void) => { - txSuccessArgs = [lifecycle, cb]; - }, +vi.mock('../../src/hooks/useSubmitBattleIntent', () => ({ + useSubmitBattleIntent: () => ({ submit, isPending: submitState.isPending, error: submitState.error }), })); -// EVM-only async battle flow; stub it so the hook stays chain-agnostic here. -vi.mock('../../src/hooks/chains/ethereum/useEvmBattleFlow', () => ({ - useEvmBattleFlow: () => ({ reset: vi.fn() }), +vi.mock('../../src/hooks/useBackendBattle', () => ({ + useBackendBattle: () => backendBattle.current, })); -// Solana-only live replay; stub it out here to avoid pulling in the real -// @switchboard-xyz/on-demand SDK (its own tests live alongside the hook). -vi.mock('../../src/hooks/chains/solana/useLiveBattleReplaySolana', () => ({ - useLiveBattleReplaySolana: () => null, +vi.mock('../../src/hooks/useVerifiedBattleReceipt', () => ({ + useVerifiedBattleReceipt: () => verified.current, })); import { useBattlePets } from '../../src/hooks/useBattlePets'; +const ACCEPTED = { battleId: 'btl_0001' }; + +/** A receipt shaped as the protocol types it, with the attacker winning by default. */ +function receipt(attackerWon = true) { + return { + seed: `0x${'0'.repeat(63)}5`, + result: { attackerWon, rounds: 7, winnerHpRemaining: 42 }, + snapshot: { attacker: { petId: 1n }, defender: { petId: 2n } }, + progression: { + attacker: { xpAwarded: attackerWon ? 100 : 25 }, + defender: { xpAwarded: attackerWon ? 25 : 100 }, + }, + }; +} + +function settledWith(state: string) { + return { data: { state }, isSettled: true, error: null }; +} + beforeEach(() => { vi.clearAllMocks(); - battlePets.isPending = false; - battlePets.lifecycle.phase = 'idle'; - battlePets.lifecycle.error = null; + submitState.isPending = false; + submitState.error = null; + backendBattle.current = { data: undefined, isSettled: false, error: null }; + verified.current = { data: undefined, error: null }; + submit.mockResolvedValue(ACCEPTED); }); -describe('useBattlePets', () => { - it('maps args to the adapter mutation', async () => { +describe('starting a battle', () => { + it('submits a signed intent rather than sending a transaction', async () => { const { result } = renderHook(() => useBattlePets()); await act(async () => { - await result.current.mutate({ petId1: 'a', petId2: 'b', defenderOwner: '0xowner' }); + await result.current.mutate({ petId1: '1', petId2: '2', defenderOwner: '0xdef' }); + }); + + expect(submit).toHaveBeenCalledWith({ + attackerPetId: '1', + defenderPetId: '2', + defenderOwner: '0xdef', }); + }); + + it('passes the room through so spectators follow along', async () => { + const { result } = renderHook(() => useBattlePets({ roomId: 'room_1' })); + + await act(async () => { + await result.current.mutate({ petId1: '1', petId2: '2' }); + }); + + expect(submit).toHaveBeenCalledWith(expect.objectContaining({ roomId: 'room_1' })); + }); - expect(battlePets.mutateAsync).toHaveBeenCalledWith({ - petId1: 'a', - petId2: 'b', - defenderOwner: '0xowner', + it('reports the battle id as the identifier, in place of a tx hash', async () => { + const { result } = renderHook(() => useBattlePets()); + await act(async () => { + await result.current.mutate({ petId1: '1', petId2: '2' }); }); + + expect(result.current.hash).toBe('btl_0001'); }); - it('swallows mutation errors (tracked on the lifecycle instead)', async () => { - battlePets.mutateAsync.mockRejectedValueOnce(new Error('boom')); + it('stays idle when the submission was refused', async () => { + submit.mockResolvedValue(null); const { result } = renderHook(() => useBattlePets()); await act(async () => { - await expect( - result.current.mutate({ petId1: 'a', petId2: 'b' }), - ).resolves.toBeUndefined(); + await result.current.mutate({ petId1: '1', petId2: '2' }); }); + + expect(result.current.hash).toBeUndefined(); + expect(result.current.phase).toBe('idle'); + }); +}); + +describe('phases', () => { + it.each([ + ['committed', 'awaiting-vrf'], + ['seeded', 'awaiting-vrf'], + ['computed', 'resolving'], + ['verified', 'resolving'], + ['verification_failed', 'error'], + ['forfeited', 'error'], + ['rejected', 'error'], + ])('maps %s to %s', (state, expected) => { + backendBattle.current = { data: { state }, isSettled: false, error: null }; + const { result } = renderHook(() => useBattlePets()); + expect(result.current.phase).toBe(expected); }); - it('reflects lifecycle state', () => { - battlePets.isPending = true; - battlePets.lifecycle.phase = 'confirming'; - battlePets.lifecycle.error = new Error('x'); + it('treats the wait for the committed drand round as awaiting-vrf', () => { + // The backend-mode analogue of the old VRF wait, so the existing UI copy + // ("Awaiting randomness…") stays accurate. + backendBattle.current = { data: { state: 'committed' }, isSettled: false, error: null }; + const { result } = renderHook(() => useBattlePets()); + expect(result.current.isAwaitingVrf).toBe(true); + }); + + it('reports error when the submission itself failed', () => { + submitState.error = new Error('daily-cap-reached'); + const { result } = renderHook(() => useBattlePets()); + expect(result.current.phase).toBe('error'); + expect(result.current.error?.message).toBe('daily-cap-reached'); + }); +}); + +describe('the verified result', () => { + it('produces no outcome until every check passes', () => { + // The whole point: an unverified receipt animates nothing. + backendBattle.current = settledWith('signed'); + verified.current = { data: { verified: false, receipt: receipt(), outcome: null, checks: [] }, error: null }; const { result } = renderHook(() => useBattlePets()); - expect(result.current.isPending).toBe(true); - expect(result.current.isConfirming).toBe(true); - expect(result.current.hash).toBe('0xhash'); - expect(result.current.error?.message).toBe('x'); + + expect(result.current.result).toBeNull(); + expect(result.current.liveReplay).toBeNull(); }); - it('reset and clearErrors both reset the lifecycle', () => { + it('maps a verified receipt onto the result the UI renders', () => { + backendBattle.current = settledWith('signed'); + verified.current = { + data: { verified: true, receipt: receipt(true), outcome: { log: [] }, checks: [] }, + error: null, + }; + const { result } = renderHook(() => useBattlePets()); - act(() => { - result.current.reset(); - result.current.clearErrors(); + expect(result.current.result).toEqual({ + requestId: 0n, + winnerId: 1n, + loserId: 2n, + vrfSeed: 5n, + firstWins: true, + rounds: 7, + winnerHpRemaining: 42, + xpWin: 100, + xpLoss: 25, }); + }); + + it('swaps winner and loser when the defender won', () => { + backendBattle.current = settledWith('signed'); + verified.current = { + data: { verified: true, receipt: receipt(false), outcome: { log: [] }, checks: [] }, + error: null, + }; - expect(battlePets.lifecycle.reset).toHaveBeenCalledTimes(2); + const { result } = renderHook(() => useBattlePets()); + + expect(result.current.result).toMatchObject({ firstWins: false, winnerId: 2n, loserId: 1n, xpWin: 100 }); }); - it('wires onSuccess through useTxSuccess', () => { + it('animates the verified replay, which is the same computation as the result', () => { + backendBattle.current = settledWith('signed'); + const outcome = { log: [{ round: 1 }] }; + verified.current = { data: { verified: true, receipt: receipt(), outcome, checks: [] }, error: null }; + + const { result } = renderHook(() => useBattlePets()); + + expect(result.current.liveReplay).toBe(outcome); + expect(result.current.phase).toBe('resolved'); + }); + + it('fires onSuccess once when the verified result lands', async () => { const onSuccess = vi.fn(); - renderHook(() => useBattlePets({ onSuccess })); + backendBattle.current = settledWith('signed'); + verified.current = { + data: { verified: true, receipt: receipt(), outcome: { log: [] }, checks: [] }, + error: null, + }; + + const { result, rerender } = renderHook(() => useBattlePets({ onSuccess })); + await act(async () => { + await result.current.mutate({ petId1: '1', petId2: '2' }); + }); + rerender(); + rerender(); + + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ firstWins: true })); + }); + + it('exposes the individual checks, so a UI can show why a result is trusted', () => { + backendBattle.current = settledWith('signed'); + const checks = [{ check: 'beacon-signature', ok: true }]; + verified.current = { data: { verified: true, receipt: receipt(), outcome: { log: [] }, checks }, error: null }; + + const { result } = renderHook(() => useBattlePets()); - expect(txSuccessArgs?.[0]).toBe(battlePets.lifecycle); - // Invoking the notify callback should fan out to the latest onSuccess. - act(() => txSuccessArgs?.[1]()); - expect(onSuccess).toHaveBeenCalledOnce(); + expect(result.current.checks).toBe(checks); + }); +}); + +describe('reset', () => { + it('clears the battle so a new one can start', async () => { + const { result } = renderHook(() => useBattlePets()); + await act(async () => { + await result.current.mutate({ petId1: '1', petId2: '2' }); + }); + expect(result.current.hash).toBe('btl_0001'); + + act(() => result.current.reset()); + + expect(result.current.hash).toBeUndefined(); + expect(result.current.phase).toBe('idle'); + }); + + it('clearErrors is the same reset', () => { + const { result } = renderHook(() => useBattlePets()); + expect(result.current.clearErrors).toBe(result.current.reset); }); }); diff --git a/shared/tests/hooks/useEvmAdapter.test.tsx b/shared/tests/hooks/useEvmAdapter.test.tsx index dfb1bd71..5609913b 100644 --- a/shared/tests/hooks/useEvmAdapter.test.tsx +++ b/shared/tests/hooks/useEvmAdapter.test.tsx @@ -164,17 +164,6 @@ describe('useEvmAdapter', () => { it('maps GameLogic mutations to their v2 contract calls', async () => { const { result } = renderHook(() => useEvmAdapter({ enabled: true })); - await result.current.battlePets.mutateAsync({ petId1: '1', petId2: '2' }); - expect(write.writeContractAsync).toHaveBeenCalledWith( - expect.objectContaining({ - address: '0x2222222222222222222222222222222222222222', - functionName: 'requestBattle', - args: [1n, 2n], - value: 1n, - gas: 800000n, - }), - ); - await result.current.breedPets.mutateAsync({ parentId1: '1', parentId2: '2', name: 'Baby' }); expect(write.writeContractAsync).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/shared/tests/hooks/useEvmBattleFlow.test.tsx b/shared/tests/hooks/useEvmBattleFlow.test.tsx deleted file mode 100644 index 5314aeeb..00000000 --- a/shared/tests/hooks/useEvmBattleFlow.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { renderHook } from '@testing-library/react'; - -const ADDRESS = '0xOwner'; -const REQUEST_HASH = '0xhash'; - -const publicClient = { simulateContract: vi.fn() }; - -vi.mock('viem', () => ({ - parseEventLogs: vi.fn(({ eventName }: { eventName: string }) => { - if (eventName === 'BattleRandomnessRequested') { - return [{ args: { requester: ADDRESS, requestId: 5n } }]; - } - return []; - }), -})); - -vi.mock('wagmi', () => ({ - useAccount: () => ({ address: ADDRESS }), - usePublicClient: () => publicClient, - useWaitForTransactionReceipt: (config: { hash?: string }) => - (config.hash === REQUEST_HASH ? { data: { logs: [] } } : { data: undefined }), - // A fresh object every call, matching wagmi's real (non-memoized) useWriteContract — - // this is what the fallback-timer bug depended on to reproduce. - useWriteContract: () => ({ writeContract: vi.fn(), data: undefined, error: null, reset: vi.fn() }), -})); - -vi.mock('../../src/hooks/chains/ethereum/useLiveBattleSocket', () => ({ - useLiveBattleSocket: () => ({ liveOutcome: null, resolvedResult: null }), -})); - -const config = { - evm: { gameLogic: { address: '0xlogic', abi: [] }, chainId: 1, liveBattleWsUrl: 'ws://test' }, -}; -vi.mock('../../src/contexts/PetsConfigContext', () => ({ usePetsConfig: () => config })); - -import { useEvmBattleFlow } from '../../src/hooks/chains/ethereum/useEvmBattleFlow'; - -beforeEach(() => { - vi.clearAllMocks(); - publicClient.simulateContract.mockResolvedValue(undefined); - vi.useFakeTimers(); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('useEvmBattleFlow fallback timer', () => { - it('still fires the 60s fallback even if the hook keeps re-rendering in the meantime', async () => { - const { result, rerender } = renderHook(() => - useEvmBattleFlow({ requestHash: REQUEST_HASH, enabled: true }), - ); - - expect(result.current.phase).toBe('awaiting-vrf'); - - // Re-render every 15s — comfortably inside the 60s fallback window each time. None of - // these should reset the countdown. (Regression: useWriteContract() returns a new - // object every render; that used to flow into the arming effect's deps and reset the - // 60s timer on every re-render, so the fallback could be pushed out indefinitely.) - for (let i = 0; i < 4; i++) { - await vi.advanceTimersByTimeAsync(15_000); - rerender(); - } - - expect(publicClient.simulateContract).toHaveBeenCalled(); - }); -}); diff --git a/shared/tests/hooks/usePendingBattle.test.tsx b/shared/tests/hooks/usePendingBattle.test.tsx deleted file mode 100644 index b33f64dc..00000000 --- a/shared/tests/hooks/usePendingBattle.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { renderHook } from '@testing-library/react'; - -const readContract = { data: undefined as bigint | undefined, refetch: vi.fn() }; -const settleW = { writeContractAsync: vi.fn().mockResolvedValue('0xs'), isPending: false, data: undefined, error: null }; -const cancelW = { writeContractAsync: vi.fn().mockResolvedValue('0xc'), isPending: false, data: undefined, error: null }; -const writeQueue = [settleW, cancelW]; -let writeIdx = 0; - -vi.mock('wagmi', () => ({ - useAccount: () => ({ address: '0xowner' }), - useReadContract: () => readContract, - useSimulateContract: () => ({ isSuccess: false }), - useWriteContract: () => writeQueue[writeIdx++ % 2], - useWaitForTransactionReceipt: () => ({ isSuccess: false, isError: false, error: null }), -})); - -const config: { evm: unknown } = { evm: { gameLogic: { address: '0xlogic', abi: [] } } }; -vi.mock('../../src/contexts/PetsConfigContext', () => ({ usePetsConfig: () => config })); - -import { usePendingBattle } from '../../src/hooks/chains/ethereum/usePendingBattle'; - -beforeEach(() => { - vi.clearAllMocks(); - writeIdx = 0; - readContract.data = undefined; - config.evm = { gameLogic: { address: '0xlogic', abi: [] } }; -}); - -describe('usePendingBattle', () => { - it('is not pending when the request id is zero', () => { - readContract.data = 0n; - const { result } = renderHook(() => usePendingBattle('1')); - expect(result.current.isPending).toBe(false); - }); - - it('is pending with a non-zero request id', () => { - readContract.data = 5n; - const { result } = renderHook(() => usePendingBattle('1')); - expect(result.current.isPending).toBe(true); - expect(result.current.requestId).toBe(5n); - }); - - it('settles the open battle by request id', async () => { - readContract.data = 5n; - const { result } = renderHook(() => usePendingBattle('1')); - - await result.current.settle.run(); - expect(settleW.writeContractAsync).toHaveBeenCalledWith( - expect.objectContaining({ functionName: 'settleBattle', args: [5n] }), - ); - }); - - it('cancels the open battle by request id', async () => { - readContract.data = 5n; - const { result } = renderHook(() => usePendingBattle('1')); - - await result.current.cancel.run(); - expect(cancelW.writeContractAsync).toHaveBeenCalledWith( - expect.objectContaining({ functionName: 'cancelBattle', args: [5n] }), - ); - }); - - it('throws when there is nothing to settle', async () => { - readContract.data = undefined; - const { result } = renderHook(() => usePendingBattle('1')); - await expect(result.current.settle.run()).rejects.toThrow('No pending battle to settle'); - }); - - it('is inert when not on an EVM chain', () => { - config.evm = undefined; - const { result } = renderHook(() => usePendingBattle('1')); - expect(result.current.isPending).toBe(false); - expect(result.current.requestId).toBeUndefined(); - }); -}); diff --git a/shared/tests/hooks/usePendingSolanaBattle.test.tsx b/shared/tests/hooks/usePendingSolanaBattle.test.tsx deleted file mode 100644 index 0f3e7e4c..00000000 --- a/shared/tests/hooks/usePendingSolanaBattle.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Keypair, PublicKey } from '@solana/web3.js'; -import React from 'react'; - -// ---------- stubs ---------- -const owner = Keypair.generate().publicKey; -const programId = Keypair.generate().publicKey; - -const fetchNullable = vi.fn(); -const cancelBattleRpc = vi.fn().mockResolvedValue('cancel-sig'); -const getSlot = vi.fn().mockResolvedValue(1000); - -vi.mock('../../src/contexts/SolanaAnchorContext', () => ({ - useSolanaAnchor: () => ({ - signingWallet: { publicKey: owner }, - connection: { getSlot }, - }), -})); - -const cancelBattleAccounts = vi.fn(() => ({ rpc: cancelBattleRpc })); -const programStub: { program: unknown; programId: PublicKey | null; isReady: boolean } = { - program: { - methods: { - cancelBattle: () => ({ - accounts: cancelBattleAccounts, - }), - }, - }, - programId, - isReady: true, -}; - -vi.mock('../../src/hooks/chains/solana/useProgram', () => ({ - useProgram: () => programStub, -})); - -vi.mock('../../src/utils/solana/accountClient', () => ({ - getAccountClient: () => ({ fetchNullable }), -})); - -// Avoid calling PublicKey.findProgramAddressSync in jsdom (crypto compat issues) -const stubPda = (name: string) => [{ toBase58: () => `${name}11111111111111111` }, 255] as const; -vi.mock('../../src/utils/solana/pdas', () => ({ - battleRequestPda: () => stubPda('BattleReq'), - globalStatePda: () => stubPda('GlobalState'), - feeVaultPda: () => stubPda('FeeVault'), -})); - -import { usePendingSolanaBattle } from '../../src/hooks/chains/solana/usePendingSolanaBattle'; - -function wrapper({ children }: { children: React.ReactNode }) { - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return React.createElement(QueryClientProvider, { client: qc }, children); -} - -beforeEach(() => { - vi.clearAllMocks(); - programStub.isReady = true; - programStub.programId = programId; - fetchNullable.mockResolvedValue(null); - getSlot.mockResolvedValue(1000); - cancelBattleRpc.mockResolvedValue('cancel-sig'); -}); - -describe('usePendingSolanaBattle', () => { - it('query is disabled when enabled=false', () => { - const { result } = renderHook(() => usePendingSolanaBattle(false), { wrapper }); - expect(result.current.isPending).toBe(false); - expect(fetchNullable).not.toHaveBeenCalled(); - }); - - it('query is disabled when program is not ready', () => { - programStub.isReady = false; - const { result } = renderHook(() => usePendingSolanaBattle(true), { wrapper }); - expect(result.current.isPending).toBe(false); - expect(fetchNullable).not.toHaveBeenCalled(); - }); - - it('isPending=false when battleRequest PDA is empty', async () => { - fetchNullable.mockResolvedValue(null); - const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); - await waitFor(() => expect(result.current.isPending).toBe(false)); - expect(result.current.canCancel).toBe(false); - }); - - it('isPending=true when battleRequest exists', async () => { - fetchNullable - .mockResolvedValueOnce({ commitSlot: 900, randomnessAccount: PublicKey.default }) - .mockResolvedValue({ randomnessExpirySlots: 50 }); - const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); - await waitFor(() => expect(result.current.isPending).toBe(true)); - }); - - it('canCancel=false when slot has not exceeded commit+expiry', async () => { - // commitSlot=900, expirySlots=200 → expires at 1100; currentSlot=1000 < 1100 - fetchNullable - .mockResolvedValueOnce({ commitSlot: 900, randomnessAccount: PublicKey.default }) - .mockResolvedValue({ randomnessExpirySlots: 200 }); - getSlot.mockResolvedValue(1000); - const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); - await waitFor(() => expect(result.current.isPending).toBe(true)); - expect(result.current.canCancel).toBe(false); - }); - - it('canCancel=true when slot has exceeded commit+expiry', async () => { - // commitSlot=900, expirySlots=50 → expires at 950; currentSlot=1000 > 950 - fetchNullable - .mockResolvedValueOnce({ commitSlot: 900, randomnessAccount: PublicKey.default }) - .mockResolvedValue({ randomnessExpirySlots: 50 }); - getSlot.mockResolvedValue(1000); - const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); - await waitFor(() => expect(result.current.canCancel).toBe(true)); - }); - - it('cancel.isPending and cancel.error default to false/null', () => { - const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); - expect(result.current.cancel.isPending).toBe(false); - expect(result.current.cancel.error).toBeNull(); - }); - - it('exposes a refetch function', () => { - const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); - expect(typeof result.current.refetch).toBe('function'); - }); - - it('cancel.run() passes feeVault + systemProgram so the escrowed battle fee is refunded', async () => { - const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); - await result.current.cancel.run(); - - expect(cancelBattleAccounts).toHaveBeenCalledWith( - expect.objectContaining({ - feeVault: expect.objectContaining({ toBase58: expect.any(Function) }), - systemProgram: expect.anything(), - }), - ); - }); -}); diff --git a/shared/tests/hooks/useSolanaAdapter.test.tsx b/shared/tests/hooks/useSolanaAdapter.test.tsx index 669e7169..af70ccf5 100644 --- a/shared/tests/hooks/useSolanaAdapter.test.tsx +++ b/shared/tests/hooks/useSolanaAdapter.test.tsx @@ -28,13 +28,11 @@ const actions = { levelUpPet: makeMutation(), trainPet: makeMutation(), renamePet: makeMutation(), - battlePets: makeMutation({ sig: 'settle-sig', firstWins: true }), breedPets: makeMutation(), transferPet: makeMutation(), setOpenToChallenges: makeMutation(), syncMetadata: makeMutation(), withdrawStudFees: makeMutation(), - battleSubPhase: 'idle' as 'idle' | 'awaiting-vrf', breedSubPhase: 'idle' as 'idle' | 'awaiting-vrf', }; const petsQuery = { data: testPets, isLoading: false, isFetching: false, error: null, refetch: vi.fn() }; @@ -68,15 +66,12 @@ beforeEach(() => { Object.assign(actions.levelUpPet, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.trainPet, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.renamePet, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); - Object.assign(actions.battlePets, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.breedPets, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.transferPet, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.setOpenToChallenges, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.syncMetadata, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.withdrawStudFees, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); - actions.battlePets.mutateAsync.mockResolvedValue({ sig: 'settle-sig', firstWins: true }); actions.breedPets.mutateAsync.mockResolvedValue(undefined); - actions.battleSubPhase = 'idle'; actions.breedSubPhase = 'idle'; anchor.signingWallet = { publicKey: Keypair.generate().publicKey }; }); @@ -163,25 +158,6 @@ describe('useSolanaAdapter', () => { ).rejects.toThrow(/not found on-chain|program.*not ready|programId/i); }); - it('includes defenderOwner and attackerAssetKey in battle', async () => { - const { result } = renderHook(() => useSolanaAdapter({ enabled: true })); - - await result.current.battlePets.mutateAsync({ petId1: '1', petId2: '2' }); - expect(actions.battlePets.mutateAsync).toHaveBeenCalledWith({ - attackerPetId: 1, - defenderPetId: 2, - attackerAssetKey: ASSET_1, - }); - - await result.current.battlePets.mutateAsync({ petId1: '1', petId2: '2', defenderOwner: '0xo' }); - expect(actions.battlePets.mutateAsync).toHaveBeenLastCalledWith({ - attackerPetId: 1, - defenderPetId: 2, - attackerAssetKey: ASSET_1, - defenderOwner: '0xo', - }); - }); - it('derives the lifecycle phase from the action mutation state', () => { actions.mintPet.isPending = true; let hook = renderHook(() => useSolanaAdapter({ enabled: true })); diff --git a/shared/tests/utils/solana/battleWithSwitchboardVrf.test.ts b/shared/tests/utils/solana/battleWithSwitchboardVrf.test.ts deleted file mode 100644 index 178b41de..00000000 --- a/shared/tests/utils/solana/battleWithSwitchboardVrf.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { PublicKey } from '@solana/web3.js'; - -// vi.mock factories are hoisted above all top-level `const`s in this file, so shared mock -// state has to live inside vi.hoisted (which itself runs before the hoisted vi.mock calls). -const mocks = vi.hoisted(() => { - const randomnessCreate = vi.fn(); - const commitIx = vi.fn(); - class MockRandomness { - static create = randomnessCreate; - commitIx(...args: unknown[]) { return commitIx(...args); } - } - const parseLogs = vi.fn(); - class MockEventParser { - parseLogs(logs: string[]) { return parseLogs(logs); } - } - return { - fetchNullable: vi.fn(), - fetchAssetByPetId: vi.fn(), - sendSignedTx: vi.fn(), - waitForRevealIx: vi.fn(), - getDefaultQueue: vi.fn(), - randomnessCreate, - commitIx, - asV0Tx: vi.fn(), - parseLogs, - MockRandomness, - MockEventParser, - }; -}); - -vi.mock('../../../src/utils/solana/accountClient', () => ({ - getAccountClient: () => ({ fetchNullable: mocks.fetchNullable }), - fetchAssetByPetId: (...args: unknown[]) => mocks.fetchAssetByPetId(...args), -})); - -vi.mock('../../../src/utils/solana/switchboardVrfTx', () => ({ - sendSignedTx: (...args: unknown[]) => mocks.sendSignedTx(...args), - waitForRevealIx: (...args: unknown[]) => mocks.waitForRevealIx(...args), - vrfTimingForEndpoint: () => ({ commitRevealWaitMs: 3_000, revealRetries: 5, revealBackoffMs: 2_000 }), -})); - -vi.mock('@switchboard-xyz/on-demand', () => ({ - getDefaultQueue: (...args: unknown[]) => mocks.getDefaultQueue(...args), - Randomness: mocks.MockRandomness, - asV0Tx: (...args: unknown[]) => mocks.asV0Tx(...args), -})); - -vi.mock('@coral-xyz/anchor', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - EventParser: mocks.MockEventParser, - }; -}); - -import { battleWithSwitchboardVrf, type BattleWithVrfArgs } from '../../../src/utils/solana/battleWithSwitchboardVrf'; - -const PROGRAM_ID = new PublicKey('11111111111111111111111111111111'); -const OWNER = new PublicKey('11111111111111111111111111111112'); -const DEFENDER_OWNER = new PublicKey('11111111111111111111111111111113'); -const ATTACKER_ASSET = new PublicKey('11111111111111111111111111111114'); -const DEFENDER_ASSET = new PublicKey('11111111111111111111111111111115'); -const RANDOMNESS_ACCOUNT = new PublicKey('11111111111111111111111111111116'); - -function makeBuilder() { - const builder = { accounts: vi.fn(), instruction: vi.fn() }; - builder.accounts.mockReturnValue(builder); - builder.instruction.mockResolvedValue({ programId: PROGRAM_ID, keys: [], data: Buffer.alloc(0) }); - return builder; -} - -function makeArgs(overrides: Partial = {}): BattleWithVrfArgs { - const settleBuilder = makeBuilder(); - const commitBuilder = makeBuilder(); - const program = { - programId: PROGRAM_ID, - coder: {}, - methods: { - commitBattle: vi.fn(() => commitBuilder), - settleBattle: vi.fn(() => settleBuilder), - }, - }; - const connection = { - rpcEndpoint: 'https://api.devnet.solana.com', - getTransaction: vi.fn().mockResolvedValue({ meta: { logMessages: [] } }), - }; - const provider = { connection, wallet: {} }; - - return { - program: program as never, - provider: provider as never, - programId: PROGRAM_ID, - owner: OWNER, - attackerPetId: 1, - defenderPetId: 2, - attackerAssetKey: ATTACKER_ASSET.toBase58(), - ...overrides, - }; -} - -beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - mocks.fetchAssetByPetId.mockImplementation(async (_program: unknown, petId: number) => - (petId === 1 ? ATTACKER_ASSET : DEFENDER_ASSET), - ); - mocks.sendSignedTx.mockResolvedValue('sig-settle'); - mocks.waitForRevealIx.mockResolvedValue({ programId: PROGRAM_ID, keys: [], data: Buffer.alloc(0) }); - mocks.getDefaultQueue.mockResolvedValue({ program: {}, pubkey: PROGRAM_ID }); - mocks.randomnessCreate.mockResolvedValue([ - { commitIx: (...args: unknown[]) => mocks.commitIx(...args) }, - { programId: PROGRAM_ID, keys: [], data: Buffer.alloc(0) }, - ]); - mocks.commitIx.mockResolvedValue({ programId: PROGRAM_ID, keys: [], data: Buffer.alloc(0) }); - mocks.asV0Tx.mockResolvedValue({}); - mocks.parseLogs.mockReturnValue([]); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -const pendingRecord = { - attackerPetId: 1, - defenderPetId: 2, - defenderOwner: DEFENDER_OWNER, - randomnessAccount: RANDOMNESS_ACCOUNT, -}; - -describe('battleWithSwitchboardVrf — resuming a pending battle', () => { - it('returns immediately (no reveal/settle sent) when the keeper already settled it', async () => { - mocks.fetchNullable - .mockResolvedValueOnce(pendingRecord) // trySettlePendingBattle's own pending check - .mockResolvedValueOnce(null); // waitForKeeperSettle: gone — keeper settled it - - const result = await battleWithSwitchboardVrf(makeArgs()); - - expect(result).toEqual({ sig: '', firstWins: null }); - expect(mocks.sendSignedTx).not.toHaveBeenCalled(); - }); - - it('falls back to reveal+settle itself when the keeper never settles within the timeout', async () => { - mocks.fetchNullable.mockResolvedValue(pendingRecord); // always still pending - - const resultPromise = battleWithSwitchboardVrf(makeArgs()); - await vi.advanceTimersByTimeAsync(60_000); - const result = await resultPromise; - - expect(mocks.waitForRevealIx).toHaveBeenCalled(); - expect(mocks.sendSignedTx).toHaveBeenCalledTimes(1); - expect(result.sig).toBe('sig-settle'); - }); - - it('bails out gracefully when the keeper settles during the reveal wait, just before the fallback would submit', async () => { - // waitForKeeperSettle's own loop always sees it still pending (so it times out - // naturally, not because it noticed a settlement) — the keeper only "wins the race" - // right as the fallback starts waiting on the oracle reveal, which the final - // pre-submit check (the fix under test) must catch. - let keeperSettledDuringReveal = false; - mocks.fetchNullable.mockImplementation(async () => (keeperSettledDuringReveal ? null : pendingRecord)); - mocks.waitForRevealIx.mockImplementation(async () => { - keeperSettledDuringReveal = true; - return { programId: PROGRAM_ID, keys: [], data: Buffer.alloc(0) }; - }); - - const resultPromise = battleWithSwitchboardVrf(makeArgs()); - await vi.advanceTimersByTimeAsync(60_000); - const result = await resultPromise; - - expect(result).toEqual({ sig: '', firstWins: null }); - expect(mocks.sendSignedTx).not.toHaveBeenCalled(); - }); -}); - -describe('battleWithSwitchboardVrf — committing a fresh battle', () => { - it('commits, then defers to the keeper when it settles in time', async () => { - mocks.fetchNullable - .mockResolvedValueOnce(null) // no pending request to resume - .mockResolvedValueOnce(null); // waitForKeeperSettle: settled immediately - - const args = makeArgs(); - const result = await battleWithSwitchboardVrf(args); - - expect((args.program as unknown as { methods: { commitBattle: ReturnType } }).methods.commitBattle) - .toHaveBeenCalledWith(expect.anything()); - expect(mocks.sendSignedTx).toHaveBeenCalledTimes(1); // only the commit tx, no settle tx - expect(result).toEqual({ sig: '', firstWins: null }); - }); - - it('commits, falls back to reveal+settle, and parses firstWins from the settle tx', async () => { - mocks.fetchNullable - .mockResolvedValueOnce(null) // no pending request to resume - .mockResolvedValue({}); // waitForKeeperSettle: never settles, times out - - mocks.parseLogs.mockReturnValue([{ name: 'BattleResolved', data: { firstWins: true } }]); - - const resultPromise = battleWithSwitchboardVrf(makeArgs()); - await vi.advanceTimersByTimeAsync(60_000); - const result = await resultPromise; - - expect(mocks.sendSignedTx).toHaveBeenCalledTimes(2); // commit tx, then settle tx - expect(result).toEqual({ sig: 'sig-settle', firstWins: true }); - }); -}); From 17666ad0b4b5436b24a41d2dd321f220c8e17876 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 20:10:09 -0400 Subject: [PATCH 50/76] refactor(frontend): resolve battles from the verified receipt, not chain state --- backend/src/config/env.ts | 12 +- backend/src/features/settle-keeper/abi.ts | 110 +- backend/src/features/settle-keeper/index.ts | 9 +- backend/src/features/settle-keeper/keeper.ts | 4 +- backend/src/server.ts | 6 - backend/src/ws/battleRoomSocket.ts | 23 +- backend/src/ws/liveBattleSocket.ts | 39 - .../features/settle-keeper/submitter.test.ts | 113 +- .../ethereum/scripts/resolve-stuck-battle.ts | 62 - .../src/chains/ethereum/combatSimAbi.json | 127 -- frontend/src/chains/ethereum/contracts.ts | 16 +- .../src/chains/ethereum/gameConfigAbi.json | 69 +- .../src/chains/ethereum/gameLogicAbi.json | 1576 +++++++---------- frontend/src/chains/ethereum/petCoreAbi.json | 163 +- frontend/src/hooks/battle/useBattleOutcome.ts | 130 +- frontend/src/hooks/battle/useBattlePanel.ts | 64 +- frontend/src/hooks/usePetError.ts | 2 +- frontend/src/petsContractParams.ts | 8 - frontend/src/vite-env.d.ts | 4 +- .../hooks/battle/useBattleOutcome.test.ts | 108 +- .../tests/hooks/battle/useBattlePanel.test.ts | 22 +- shared/src/contexts/PetsConfigContext.tsx | 12 +- .../src/hooks/chains/ethereum/useEvmFees.ts | 7 +- .../src/hooks/chains/solana/useSolanaFees.ts | 3 - shared/src/hooks/useBattlePets.ts | 9 +- shared/src/index.ts | 8 +- shared/src/node.ts | 10 +- shared/src/types/battle.ts | 38 +- shared/src/types/liveBattleSocket.ts | 51 - shared/tests/hooks/useEvmAdapter.test.tsx | 1 - shared/tests/hooks/useEvmFees.test.tsx | 4 +- shared/tests/hooks/useSolanaFees.test.tsx | 2 - 32 files changed, 898 insertions(+), 1914 deletions(-) delete mode 100644 backend/src/ws/liveBattleSocket.ts delete mode 100644 contracts/ethereum/scripts/resolve-stuck-battle.ts delete mode 100644 frontend/src/chains/ethereum/combatSimAbi.json delete mode 100644 shared/src/types/liveBattleSocket.ts diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 1e56dd92..eb4bedd8 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -170,11 +170,13 @@ export const env = { /** Messages claimed per poll, per topic. */ workerBatchSize: Number(process.env.BATTLE_WORKER_BATCH_SIZE?.trim() || '10'), /** - * Backend-mode post-battle cooldown, applied to both pets after a signed - * receipt (§C's `pet_battle_progress`, distinct from the on-chain - * `pet_roster.ready_at`). Matches `GameConfig.battleCooldown`'s 900s - * on-chain default; there is no requirement the two agree going forward; this - * is just a sane starting value rather than an arbitrary one. + * Post-battle cooldown, applied to both pets after a signed receipt + * (§C's `pet_battle_progress`, distinct from the indexed `pet_roster.ready_at`, + * which breeding still writes). + * + * 900s carries over from the retired on-chain `GameConfig.battleCooldown`, which + * no longer exists (§L Phase 6) — it is kept as a sane starting value rather than + * an arbitrary one, and is now free to change independently. */ cooldownSeconds: Number(process.env.BATTLE_COOLDOWN_SECONDS?.trim() || '900'), /** diff --git a/backend/src/features/settle-keeper/abi.ts b/backend/src/features/settle-keeper/abi.ts index ef740649..082b2397 100644 --- a/backend/src/features/settle-keeper/abi.ts +++ b/backend/src/features/settle-keeper/abi.ts @@ -1,23 +1,14 @@ /** * Minimal hand-written ABI fragments for the settle keeper — only the events * and functions it actually calls, not the full generated GameLogic/Entropy - * artifacts (mirrors the existing pattern in - * contracts/ethereum/scripts/resolve-stuck-battle.ts). Keeping this - * hand-rolled avoids giving `backend` a build dependency on - * `contracts/ethereum`'s compiled artifacts. + * artifacts. Keeping this hand-rolled avoids giving `backend` a build + * dependency on `contracts/ethereum`'s compiled artifacts. + * + * Battle entries are gone (§L Phase 6): the keeper now settles breed and mint + * only, the two flows that still resolve on chain. */ export const GAME_LOGIC_ABI = [ - { - type: 'event', - name: 'BattleRandomnessRequested', - inputs: [ - { indexed: true, name: 'requester', type: 'address' }, - { indexed: true, name: 'requestId', type: 'uint256' }, - { indexed: false, name: 'petId1', type: 'uint256' }, - { indexed: false, name: 'petId2', type: 'uint256' }, - ], - }, { type: 'event', name: 'BreedRandomnessRequested', @@ -36,21 +27,6 @@ export const GAME_LOGIC_ABI = [ { indexed: true, name: 'requestId', type: 'uint256' }, ], }, - { - type: 'event', - name: 'BattleResolved', - inputs: [ - { indexed: true, name: 'requestId', type: 'uint256' }, - { indexed: true, name: 'winnerId', type: 'uint256' }, - { indexed: true, name: 'loserId', type: 'uint256' }, - { indexed: false, name: 'randomness', type: 'uint256' }, - { indexed: false, name: 'firstWins', type: 'bool' }, - { indexed: false, name: 'rounds', type: 'uint8' }, - { indexed: false, name: 'winnerHpRemaining', type: 'uint16' }, - { indexed: false, name: 'xpWin', type: 'uint32' }, - { indexed: false, name: 'xpLoss', type: 'uint32' }, - ], - }, { type: 'event', name: 'BreedSettled', @@ -70,13 +46,6 @@ export const GAME_LOGIC_ABI = [ { indexed: true, name: 'requestId', type: 'uint256' }, ], }, - { - type: 'function', - name: 'settleBattle', - stateMutability: 'nonpayable', - inputs: [{ name: 'requestId', type: 'uint256' }], - outputs: [], - }, { type: 'function', name: 'settleBreed', @@ -98,74 +67,8 @@ export const GAME_LOGIC_ABI = [ inputs: [], outputs: [{ type: 'address' }], }, - { - // Frozen sim-input snapshot (plan-realtime-battle-impl.md Phase 1), read once - // entropy reveals so the live-battle-socket feature can run the identical sim - // CombatSim.settleBattle will use. Only the fields the sim needs are declared. - type: 'function', - name: 'getBattleRequest', - stateMutability: 'view', - inputs: [{ name: 'requestId', type: 'uint256' }], - outputs: [ - { - type: 'tuple', - components: [ - { name: 'requester', type: 'address' }, - { name: 'petId1', type: 'uint256' }, - { name: 'petId2', type: 'uint256' }, - { name: 'randomness', type: 'uint256' }, - { name: 'fulfilled', type: 'bool' }, - { name: 'snapshotted', type: 'bool' }, - { name: 'dna1', type: 'uint256' }, - { name: 'dna2', type: 'uint256' }, - { name: 'level1', type: 'uint32' }, - { name: 'level2', type: 'uint32' }, - { name: 'rarity1', type: 'uint8' }, - { name: 'rarity2', type: 'uint8' }, - { name: 'speciesId1', type: 'uint16' }, - { name: 'speciesId2', type: 'uint16' }, - ], - }, - ], - }, -] as const; - -/** Minimal GameConfig ABI — only `getSkillConfig`, read once per battle reveal to feed the - * live-battle-socket sim (plan-realtime-battle-impl.md Phase 4's skill-balance values). */ -export const GAME_CONFIG_ABI = [ - { - type: 'function', - name: 'getSkillConfig', - stateMutability: 'view', - inputs: [], - outputs: [ - { - type: 'tuple', - components: [ - { name: 'tankHpMult', type: 'uint16' }, - { name: 'shellDefMult', type: 'uint16' }, - { name: 'swiftCritBonus', type: 'uint16' }, - { name: 'cunningCritCap', type: 'uint16' }, - { name: 'furyDmgMult', type: 'uint16' }, - { name: 'furyHpThreshold', type: 'uint16' }, - { name: 'sageMdefMult', type: 'uint16' }, - { name: 'bloodlustBps', type: 'uint16' }, - ], - }, - ], - }, -] as const; - -/** The three request-type events GameLogic emits at requestX time. */ -export const REQUEST_EVENT_NAMES = [ - 'BattleRandomnessRequested', - 'BreedRandomnessRequested', - 'MintRequested', ] as const; -/** The three settlement events GameLogic emits once a request is settled. */ -export const SETTLED_EVENT_NAMES = ['BattleResolved', 'BreedSettled', 'MintSettled'] as const; - /** * Pyth Entropy's `Revealed` event (exact shape copied from * shared/src/hooks/chains/ethereum/useWatchEntropyFulfillment.ts, the @@ -213,12 +116,11 @@ export const ENTROPY_ABI = [ }, ] as const; -export type SettleFunctionName = 'settleBattle' | 'settleBreed' | 'settleMint'; +export type SettleFunctionName = 'settleBreed' | 'settleMint'; /** Gas limits for settle calls — RPC gas estimation fails on these (mirrors the * identical comment/values in shared/src/hooks/chains/ethereum/gasLimits.ts). */ export const SETTLE_GAS_LIMIT: Record = { - settleBattle: 800_000n, settleBreed: 800_000n, settleMint: 500_000n, }; diff --git a/backend/src/features/settle-keeper/index.ts b/backend/src/features/settle-keeper/index.ts index 0afe67c6..61b1852f 100644 --- a/backend/src/features/settle-keeper/index.ts +++ b/backend/src/features/settle-keeper/index.ts @@ -2,10 +2,11 @@ import { env } from '@config/env'; import { startKeeper, type SettleKeeperHandle } from './keeper'; /** - * Settles GameLogic battle/breed/mint requests from a backend-held wallet the - * moment Pyth Entropy reveals, so the player never has to send the second - * (settle) transaction themselves. See docs/plan-realtime-battle-ux.md and - * docs/plan-realtime-battle-impl.md Phase 2. + * Settles GameLogic breed/mint requests from a backend-held wallet the moment + * Pyth Entropy reveals, so the player never has to send the second (settle) + * transaction themselves. See docs/plan-realtime-battle-ux.md and + * docs/plan-realtime-battle-impl.md Phase 2 for the original design; battles no + * longer take this path at all (§L Phase 6), breed and mint still do. * * Off unless KEEPER_ENABLED=true, mirroring the indexer-go gRPC stream * (src/grpc/battleStream.ts): the feature simply doesn't start rather than diff --git a/backend/src/features/settle-keeper/keeper.ts b/backend/src/features/settle-keeper/keeper.ts index 7f426275..fa0a3c87 100644 --- a/backend/src/features/settle-keeper/keeper.ts +++ b/backend/src/features/settle-keeper/keeper.ts @@ -43,8 +43,8 @@ const MAX_LOG_RANGE_BLOCKS = 2000n; const POLL_INTERVAL_MS = 4_000; /** Below this, settle txs (~800k gas, see SETTLE_GAS_LIMIT) risk failing outright on an - * unfunded keeper wallet — nothing tops the wallet up automatically (see GameConfig.battleFee - * doc comment in CLAUDE.md), so this is just a loud, periodic reminder to do it manually. */ + * unfunded keeper wallet — nothing tops the wallet up automatically, so this is just a + * loud, periodic reminder to do it manually from `withdraw()` proceeds. */ const MIN_BALANCE_WEI = 20_000_000_000_000_000n; // 0.02 ETH const BALANCE_CHECK_INTERVAL_MS = 10 * 60_000; diff --git a/backend/src/server.ts b/backend/src/server.ts index 64eceba6..bf1dc3d4 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,7 +7,6 @@ import { configureSigner, loadPersistedSigningKeys } from '@features/battle-sign import { startSettleKeeper, stopSettleKeeper } from '@features/settle-keeper'; import { type BattleWorkerHandle, startBattleWorker } from '@features/battle-worker'; import { startBatchAnchor, stopBatchAnchor } from '@features/battle-anchor'; -import { startLiveBattleSocket, stopLiveBattleSocket } from '@ws/liveBattleSocket'; import { startBattleRoomSocket, stopBattleRoomSocket } from '@ws/battleRoomSocket'; let battleWorker: BattleWorkerHandle | undefined; @@ -22,10 +21,6 @@ const server = app.listen(env.port, '0.0.0.0', () => { console.log(`🛡️ Protected endpoints: http://localhost:${port}/api/protected`); console.log(`⚔️ GraphQL endpoint: http://localhost:${port}/graphql`); - // Pushes a computed battle sim to the frontend the moment entropy reveals (settle - // keeper's job). Always listening; only actually broadcasts once the keeper is enabled - // with KEEPER_GAME_CONFIG_ADDRESS set. - startLiveBattleSocket(server); // Notification-only per-room channel for backend-authoritative battles (§J). Always on; // a client only gets pushed to if it connected with a roomId it already knows about. startBattleRoomSocket(server); @@ -85,7 +80,6 @@ async function shutdown(signal: NodeJS.Signals): Promise { stopSettleKeeper(); battleWorker?.stop(); stopBatchAnchor(); - stopLiveBattleSocket(); stopBattleRoomSocket(); await new Promise((resolve) => server.close(() => resolve())); await prisma.$disconnect(); diff --git a/backend/src/ws/battleRoomSocket.ts b/backend/src/ws/battleRoomSocket.ts index 3d155b5a..8ca40dac 100644 --- a/backend/src/ws/battleRoomSocket.ts +++ b/backend/src/ws/battleRoomSocket.ts @@ -1,24 +1,23 @@ import type { Server } from 'node:http'; import { URL } from 'node:url'; -// `WebSocket.Server` (liveBattleSocket.ts's form) is only attached to the default export -// under `ws`'s CJS entry point; its ESM entry (`wrapper.mjs`, what Vitest resolves) exports -// the server class only as the named `WebSocketServer`, with no `.Server` static property. -// The named form resolves correctly under both, so it's used here instead. +// `WebSocket.Server` is only attached to the default export under `ws`'s CJS entry point; +// its ESM entry (`wrapper.mjs`, what Vitest resolves) exports the server class only as the +// named `WebSocketServer`, with no `.Server` static property. The named form resolves +// correctly under both, so it's used here instead. import WebSocket, { WebSocketServer } from 'ws'; /** * The per-room, notification-only channel for backend-authoritative battles * (docs/plan-backend-battle-architecture.md §J). * - * This is deliberately a second, separate socket from `liveBattleSocket.ts`, not - * a change to it. That socket's global broadcast is correct for what it carries - * today: chain-derived data for the legacy on-chain flow, filtered client-side - * by `(chainId, requestId)`, which is fine because anyone could read the same - * data straight off the chain anyway. Backend-resolved battles carry full - * combat logs, which is not chain-derived data — a global broadcast would tell - * every connected client the outcome of every battle as it resolves. So this - * channel scopes delivery to one room, and carries no battle content at all: + * It replaced a global-broadcast socket that pushed chain-derived data for the + * on-chain flow, filtered client-side by `(chainId, requestId)`. Broadcasting was + * acceptable there because anyone could read the same data straight off the chain + * anyway. Backend-resolved battles carry full combat logs, which is not chain-derived + * data — a global broadcast would tell every connected client the outcome of every + * battle as it resolves. So this channel scopes delivery to one room, and carries + * no battle content at all: * only "battleId X changed to state Y, go re-fetch it" (§J's read APIs, Step * 27). A client that missed a notification, or was never connected, gets the * exact same information by polling those same endpoints — this socket makes diff --git a/backend/src/ws/liveBattleSocket.ts b/backend/src/ws/liveBattleSocket.ts deleted file mode 100644 index 2a5cb074..00000000 --- a/backend/src/ws/liveBattleSocket.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Server } from 'node:http'; -// `import WebSocket, { Server as WebSocketServer } from 'ws'` isn't available under the -// installed @types/ws version (7.4, CJS `export = WebSocket` with `WebSocket.Server` as a -// namespace member, not a named `WebSocketServer` export — that's an 8.x typings addition). -// The installed `ws` runtime (8.18.3) supports both forms; using the older-but-compatible -// `WebSocket.Server` form works regardless of which @types/ws version ends up installed. -import WebSocket from 'ws'; -import type { LiveBattleWireMessage } from '@shared/core/node'; - -/** - * Pushes battle updates (backend-run sim once entropy reveals, then the actual settled - * result once the keeper's settle tx confirms) to any connected frontend, so the whole - * live-battle flow — both the pre-settle animation and the final outcome — doesn't depend - * on the client's own RPC event watching (which public RPCs like Base Sepolia's default - * endpoint make unreliable; see settle-keeper/keeper.ts's pollContractEvents comment). - * - * No per-battle subscription bookkeeping — broadcasts to every connected client, which - * filters by (chainId, requestId) itself. Battle volume doesn't justify the added - * complexity of a subscribe/unsubscribe protocol. - */ -let wss: WebSocket.Server | null = null; - -export function startLiveBattleSocket(server: Server): void { - wss = new WebSocket.Server({ server, path: '/ws/live-battle' }); - console.log('[live-battle-ws] listening on /ws/live-battle'); -} - -export function stopLiveBattleSocket(): void { - wss?.close(); - wss = null; -} - -export function broadcastLiveBattle(message: LiveBattleWireMessage): void { - if (!wss) return; - const payload = JSON.stringify(message); - for (const client of wss.clients) { - if (client.readyState === WebSocket.OPEN) client.send(payload); - } -} diff --git a/backend/tests/features/settle-keeper/submitter.test.ts b/backend/tests/features/settle-keeper/submitter.test.ts index 5c066c82..4facf530 100644 --- a/backend/tests/features/settle-keeper/submitter.test.ts +++ b/backend/tests/features/settle-keeper/submitter.test.ts @@ -1,16 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { encodeAbiParameters, encodeEventTopics } from 'viem'; - -const broadcastLiveBattle = vi.fn(); -vi.mock('../../../src/ws/liveBattleSocket', () => ({ - broadcastLiveBattle: (...args: unknown[]) => broadcastLiveBattle(...args), -})); +import { describe, expect, it, vi } from 'vitest'; import { createSubmitter } from '../../../src/features/settle-keeper/submitter'; -import { GAME_LOGIC_ABI } from '../../../src/features/settle-keeper/abi'; const GAME_LOGIC = '0x0000000000000000000000000000000000000001' as const; -const CHAIN_ID = 31337; function makeClients(overrides: { simulateContract?: ReturnType; writeContract?: ReturnType; waitForTransactionReceipt?: ReturnType } = {}) { const publicClient = { @@ -24,55 +16,18 @@ function makeClients(overrides: { simulateContract?: ReturnType; w return { publicClient, walletClient }; } -/** Builds a real, ABI-encoded `BattleResolved` log so submitter.ts's own (unmocked) - * `parseEventLogs` call can actually decode it, exercising the real decode path rather - * than asserting against a hand-rolled `args` object. */ -function makeBattleResolvedLog(params: { - requestId: bigint; - winnerId: bigint; - loserId: bigint; - randomness: bigint; - firstWins: boolean; - rounds: number; - winnerHpRemaining: number; - xpWin: number; - xpLoss: number; -}) { - const topics = encodeEventTopics({ - abi: GAME_LOGIC_ABI, - eventName: 'BattleResolved', - args: { requestId: params.requestId, winnerId: params.winnerId, loserId: params.loserId }, - }); - const data = encodeAbiParameters( - [ - { name: 'randomness', type: 'uint256' }, - { name: 'firstWins', type: 'bool' }, - { name: 'rounds', type: 'uint8' }, - { name: 'winnerHpRemaining', type: 'uint16' }, - { name: 'xpWin', type: 'uint32' }, - { name: 'xpLoss', type: 'uint32' }, - ], - [params.randomness, params.firstWins, params.rounds, params.winnerHpRemaining, params.xpWin, params.xpLoss], - ); - return { address: GAME_LOGIC, topics, data }; -} - describe('createSubmitter', () => { - beforeEach(() => { - broadcastLiveBattle.mockClear(); - }); - it('sends the settle tx when simulation succeeds', async () => { const { publicClient, walletClient } = makeClients(); - const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC, CHAIN_ID); + const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC); - await submitter.submit('settleBattle', 1n); + await submitter.submit('settleBreed', 1n); expect(publicClient.simulateContract).toHaveBeenCalledWith( - expect.objectContaining({ functionName: 'settleBattle', args: [1n] }), + expect.objectContaining({ functionName: 'settleBreed', args: [1n] }), ); expect(walletClient.writeContract).toHaveBeenCalledWith( - expect.objectContaining({ functionName: 'settleBattle', args: [1n], gas: 800_000n }), + expect.objectContaining({ functionName: 'settleBreed', args: [1n], gas: 800_000n }), ); expect(publicClient.waitForTransactionReceipt).toHaveBeenCalledWith({ hash: '0xhash' }); }); @@ -80,9 +35,9 @@ describe('createSubmitter', () => { it('skips the write entirely when simulation reverts (already settled/cancelled/unfulfilled)', async () => { const simulateContract = vi.fn().mockRejectedValue(new Error('Entropy not yet fulfilled')); const { publicClient, walletClient } = makeClients({ simulateContract }); - const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC, CHAIN_ID); + const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC); - await submitter.submit('settleBattle', 1n); + await submitter.submit('settleBreed', 1n); expect(walletClient.writeContract).not.toHaveBeenCalled(); }); @@ -90,14 +45,14 @@ describe('createSubmitter', () => { it('never throws, even when the write itself fails', async () => { const writeContract = vi.fn().mockRejectedValue(new Error('nonce too low')); const { publicClient, walletClient } = makeClients({ writeContract }); - const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC, CHAIN_ID); + const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC); - await expect(submitter.submit('settleBattle', 1n)).resolves.toBeUndefined(); + await expect(submitter.submit('settleBreed', 1n)).resolves.toBeUndefined(); }); it('uses the settle function-specific gas limit', async () => { const { publicClient, walletClient } = makeClients(); - const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC, CHAIN_ID); + const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC); await submitter.submit('settleMint', 2n); @@ -120,10 +75,10 @@ describe('createSubmitter', () => { return `0xhash-${args[0]}`; }); const { publicClient, walletClient } = makeClients({ writeContract }); - const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC, CHAIN_ID); + const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC); - const first = submitter.submit('settleBattle', 1n); - const second = submitter.submit('settleBattle', 2n); + const first = submitter.submit('settleBreed', 1n); + const second = submitter.submit('settleBreed', 2n); await Promise.resolve(); await Promise.resolve(); @@ -133,46 +88,4 @@ describe('createSubmitter', () => { await Promise.all([first, second]); expect(order).toEqual([1n, 2n]); }); - - it('broadcasts the decoded BattleResolved result over the live-battle-socket on a successful settleBattle', async () => { - const log = makeBattleResolvedLog({ - requestId: 1n, - winnerId: 10n, - loserId: 20n, - randomness: 999n, - firstWins: true, - rounds: 3, - winnerHpRemaining: 50, - xpWin: 12, - xpLoss: 3, - }); - const waitForTransactionReceipt = vi.fn().mockResolvedValue({ status: 'success', logs: [log] }); - const { publicClient, walletClient } = makeClients({ waitForTransactionReceipt }); - const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC, CHAIN_ID); - - await submitter.submit('settleBattle', 1n); - - expect(broadcastLiveBattle).toHaveBeenCalledWith( - expect.objectContaining({ type: 'resolved', chainId: CHAIN_ID, requestId: '1' }), - ); - }); - - it('does not broadcast when settleBattle is not the function settled', async () => { - const { publicClient, walletClient } = makeClients(); - const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC, CHAIN_ID); - - await submitter.submit('settleMint', 2n); - - expect(broadcastLiveBattle).not.toHaveBeenCalled(); - }); - - it('does not broadcast when the settle tx reverted', async () => { - const waitForTransactionReceipt = vi.fn().mockResolvedValue({ status: 'reverted', logs: [] }); - const { publicClient, walletClient } = makeClients({ waitForTransactionReceipt }); - const submitter = createSubmitter(publicClient as any, walletClient as any, GAME_LOGIC, CHAIN_ID); - - await submitter.submit('settleBattle', 1n); - - expect(broadcastLiveBattle).not.toHaveBeenCalled(); - }); }); diff --git a/contracts/ethereum/scripts/resolve-stuck-battle.ts b/contracts/ethereum/scripts/resolve-stuck-battle.ts deleted file mode 100644 index 3be35070..00000000 --- a/contracts/ethereum/scripts/resolve-stuck-battle.ts +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env tsx -/** - * Clear a stuck pending battle on GameLogic. - * - * A v2 battle is request → VRF → settle. If the frontend flow is interrupted - * before settling, `petBattleRequestId[petId]` stays set and every new - * `requestBattle` for that pet reverts with "Battle pending for pet". - * - * This resolves a given requestId: it tries `settleBattle` (works once VRF has - * fulfilled — completes the battle), and falls back to `cancelBattle` (requires - * the caller to be the original requester or the contract owner, and that VRF - * has NOT fulfilled). - * - * Usage: - * PRIVATE_KEY=0x... SEPOLIA_RPC_URL=https://... \ - * npx tsx scripts/resolve-stuck-battle.ts [gameLogicAddress] - */ -import 'dotenv/config'; -import { createPublicClient, createWalletClient, http } from 'viem'; -import { privateKeyToAccount } from 'viem/accounts'; -import { sepolia } from 'viem/chains'; - -const requestIdArg = process.argv[2]; -if (!requestIdArg) { - console.error('Usage: tsx scripts/resolve-stuck-battle.ts [gameLogicAddress]'); - process.exit(1); -} -const requestId = BigInt(requestIdArg); -const gameLogic = (process.argv[3] ?? '0xaDEC55D3b9B2517D37C4bAbbb0dDc9F34de256ee') as `0x${string}`; - -const pk = process.env.PRIVATE_KEY; -if (!pk) { console.error('Set PRIVATE_KEY (requester or contract owner).'); process.exit(1); } -const account = privateKeyToAccount((pk.startsWith('0x') ? pk : `0x${pk}`) as `0x${string}`); -const rpc = process.env.SEPOLIA_RPC_URL ?? 'https://ethereum-sepolia-rpc.publicnode.com'; - -const abi = [ - { type: 'function', name: 'settleBattle', inputs: [{ name: 'requestId', type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' }, - { type: 'function', name: 'cancelBattle', inputs: [{ name: 'requestId', type: 'uint256' }], outputs: [], stateMutability: 'nonpayable' }, -] as const; - -const publicClient = createPublicClient({ chain: sepolia, transport: http(rpc) }); -const wallet = createWalletClient({ account, chain: sepolia, transport: http(rpc) }); - -async function trySend(functionName: 'settleBattle' | 'cancelBattle'): Promise { - try { - await publicClient.simulateContract({ account, address: gameLogic, abi, functionName, args: [requestId] }); - } catch (e) { - console.log(`- ${functionName} not applicable: ${(e as Error).message.split('\n')[0]}`); - return false; - } - const hash = await wallet.writeContract({ address: gameLogic, abi, functionName, args: [requestId], gas: 800000n }); - console.log(`- ${functionName} sent: ${hash}`); - const rcpt = await publicClient.waitForTransactionReceipt({ hash }); - console.log(`- ${functionName} ${rcpt.status === 'success' ? 'confirmed ✓' : 'REVERTED'}`); - return rcpt.status === 'success'; -} - -console.log(`Resolving requestId ${requestId} on ${gameLogic} as ${account.address}`); -if (await trySend('settleBattle')) { console.log('Battle settled.'); process.exit(0); } -if (await trySend('cancelBattle')) { console.log('Battle cancelled — pets are free again.'); process.exit(0); } -console.error('Could not settle or cancel. If VRF just fulfilled, retry settleBattle in a moment; otherwise only the requester/owner can cancel.'); -process.exit(1); diff --git a/frontend/src/chains/ethereum/combatSimAbi.json b/frontend/src/chains/ethereum/combatSimAbi.json deleted file mode 100644 index 48b7a2e6..00000000 --- a/frontend/src/chains/ethereum/combatSimAbi.json +++ /dev/null @@ -1,127 +0,0 @@ -{ - "abi": [ - { - "inputs": [ - { - "internalType": "uint256", - "name": "dna1", - "type": "uint256" - }, - { - "internalType": "uint8", - "name": "rarity1", - "type": "uint8" - }, - { - "internalType": "uint32", - "name": "level1", - "type": "uint32" - }, - { - "internalType": "uint8", - "name": "skill1", - "type": "uint8" - }, - { - "internalType": "uint256", - "name": "dna2", - "type": "uint256" - }, - { - "internalType": "uint8", - "name": "rarity2", - "type": "uint8" - }, - { - "internalType": "uint32", - "name": "level2", - "type": "uint32" - }, - { - "internalType": "uint8", - "name": "skill2", - "type": "uint8" - }, - { - "internalType": "uint256", - "name": "seed", - "type": "uint256" - }, - { - "components": [ - { - "internalType": "uint16", - "name": "tankHpMult", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "shellDefMult", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "swiftCritBonus", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "cunningCritCap", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "furyDmgMult", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "furyHpThreshold", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "sageMdefMult", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "bloodlustBps", - "type": "uint16" - } - ], - "internalType": "struct CombatSimV1.SkillConfig", - "name": "sc", - "type": "tuple" - } - ], - "name": "simulate", - "outputs": [ - { - "components": [ - { - "internalType": "bool", - "name": "firstWins", - "type": "bool" - }, - { - "internalType": "uint8", - "name": "rounds", - "type": "uint8" - }, - { - "internalType": "uint16", - "name": "winnerHpRemaining", - "type": "uint16" - } - ], - "internalType": "struct CombatSimV1.BattleResult", - "name": "result", - "type": "tuple" - } - ], - "stateMutability": "pure", - "type": "function" - } - ] -} \ No newline at end of file diff --git a/frontend/src/chains/ethereum/contracts.ts b/frontend/src/chains/ethereum/contracts.ts index 7f2bd82f..55655dfd 100644 --- a/frontend/src/chains/ethereum/contracts.ts +++ b/frontend/src/chains/ethereum/contracts.ts @@ -2,14 +2,15 @@ import type { Abi } from 'viem'; import petCoreAbi from '@chains/ethereum/petCoreAbi.json'; import gameLogicAbi from '@chains/ethereum/gameLogicAbi.json'; import gameConfigAbi from '@chains/ethereum/gameConfigAbi.json'; -import combatSimAbi from '@chains/ethereum/combatSimAbi.json'; /** - * v2 EVM contract surface. The monolithic v1 contract is split into four units: + * v2 EVM contract surface. The monolithic v1 contract is split into three units: * - PetCore (proxy) — ERC-721 storage, mint, rename, level/XP, cooldowns, marriage. - * - GameLogic (proxy) — async battle/breed/train (request → settle) + VRF wiring. + * - GameLogic (proxy) — async breed/mint (request → settle) + entropy wiring. * - GameConfig — tunable fees / cooldowns / XP-curve / skill params (read for UI). - * - CombatSim — pure `simulate(...)` combat lib (client-side pre-fight estimates). + * + * CombatSim is deliberately absent: battles are resolved by the backend and replayed + * from the signed receipt (§L Phase 6), so no client ever calls the on-chain sim. * * Addresses come from env (per-deployment) and fall back to the current * Sepolia (chain 11155111) deployment so local dev works out of the box. @@ -18,7 +19,6 @@ import combatSimAbi from '@chains/ethereum/combatSimAbi.json'; const SEPOLIA_PETCORE = '0xD94B02fC6238AcE5c0Fd767bFf8f5A1FCD9B59DB'; const SEPOLIA_GAMELOGIC = '0x87E3E1e3EB22eC45fB99715BdF91911697997Be4'; const SEPOLIA_GAMECONFIG = '0xE16e0e982D390C4F826D00Fc0E771846a002F10B'; -const SEPOLIA_COMBATSIM = '0x81A7E05fFd0E2D41e3CdA232e34175d2b9c921a4'; interface EvmContract { address: `0x${string}`; @@ -40,14 +40,8 @@ const gameConfigContract: EvmContract = { abi: gameConfigAbi.abi as Abi, }; -const combatSimContract: EvmContract = { - address: (import.meta.env.VITE_COMBATSIM_ADDRESS || SEPOLIA_COMBATSIM) as `0x${string}`, - abi: combatSimAbi.abi as Abi, -}; - export const evmContracts = { petCore: petCoreContract, gameLogic: gameLogicContract, gameConfig: gameConfigContract, - combatSim: combatSimContract, } as const; diff --git a/frontend/src/chains/ethereum/gameConfigAbi.json b/frontend/src/chains/ethereum/gameConfigAbi.json index aa1fe668..740db4ee 100644 --- a/frontend/src/chains/ethereum/gameConfigAbi.json +++ b/frontend/src/chains/ethereum/gameConfigAbi.json @@ -85,19 +85,6 @@ "name": "BreedFeeUpdated", "type": "event" }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "address", - "name": "sim", - "type": "address" - } - ], - "name": "CombatSimUpdated", - "type": "event" - }, { "anonymous": false, "inputs": [ @@ -383,32 +370,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "battleCooldown", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "battleFee", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "bloodlustBps", @@ -448,19 +409,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "combatSim", - "outputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "cunningCritCap", @@ -560,7 +508,7 @@ "type": "uint16" } ], - "internalType": "struct CombatSimV1.SkillConfig", + "internalType": "struct CombatSim.SkillConfig", "name": "", "type": "tuple" } @@ -763,19 +711,6 @@ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "sim", - "type": "address" - } - ], - "name": "setCombatSim", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -1133,4 +1068,4 @@ "type": "function" } ] -} \ No newline at end of file +} diff --git a/frontend/src/chains/ethereum/gameLogicAbi.json b/frontend/src/chains/ethereum/gameLogicAbi.json index f7650ba3..a3db0342 100644 --- a/frontend/src/chains/ethereum/gameLogicAbi.json +++ b/frontend/src/chains/ethereum/gameLogicAbi.json @@ -1,931 +1,647 @@ -{"abi": [ - { - "inputs": [ - - ], - "stateMutability": "nonpayable", - "type": "constructor" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "address", - "name": "previousAdmin", - "type": "address" - }, - { - "indexed": false, - "internalType": "address", - "name": "newAdmin", - "type": "address" - } - ], - "name": "AdminChanged", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "requester", - "type": "address" - }, - { - "indexed": true, - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "petId1", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "petId2", - "type": "uint256" - } - ], - "name": "BattleRandomnessRequested", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - }, - { - "indexed": true, - "internalType": "uint256", - "name": "winnerId", - "type": "uint256" - }, - { - "indexed": true, - "internalType": "uint256", - "name": "loserId", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "randomness", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "bool", - "name": "firstWins", - "type": "bool" - }, - { - "indexed": false, - "internalType": "uint8", - "name": "rounds", - "type": "uint8" - }, - { - "indexed": false, - "internalType": "uint16", - "name": "winnerHpRemaining", - "type": "uint16" - }, - { - "indexed": false, - "internalType": "uint32", - "name": "xpWin", - "type": "uint32" - }, - { - "indexed": false, - "internalType": "uint32", - "name": "xpLoss", - "type": "uint32" - } - ], - "name": "BattleResolved", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "beacon", - "type": "address" - } - ], - "name": "BeaconUpgraded", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "owner", - "type": "address" - }, - { - "indexed": true, - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "petId1", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "petId2", - "type": "uint256" - } - ], - "name": "BreedRandomnessRequested", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "owner", - "type": "address" - }, - { - "indexed": true, - "internalType": "uint256", - "name": "childId", - "type": "uint256" - }, - { - "indexed": true, - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "address", - "name": "studFeePaidTo", - "type": "address" - } - ], - "name": "BreedSettled", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint8", - "name": "version", - "type": "uint8" - } - ], - "name": "Initialized", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "owner", - "type": "address" - }, - { - "indexed": true, - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "name": "MintRequested", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "owner", - "type": "address" - }, - { - "indexed": true, - "internalType": "uint256", - "name": "petId", - "type": "uint256" - }, - { - "indexed": true, - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "name": "MintSettled", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "previousOwner", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "newOwner", - "type": "address" - } - ], - "name": "OwnershipTransferred", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "address", - "name": "account", - "type": "address" - } - ], - "name": "Paused", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "uint256", - "name": "petId", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint32", - "name": "xpGained", - "type": "uint32" - }, - { - "indexed": false, - "internalType": "uint32", - "name": "newXp", - "type": "uint32" - }, - { - "indexed": false, - "internalType": "uint32", - "name": "newLevel", - "type": "uint32" - } - ], - "name": "Trained", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "address", - "name": "account", - "type": "address" - } - ], - "name": "Unpaused", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "implementation", - "type": "address" - } - ], - "name": "Upgraded", - "type": "event" - }, - { - "inputs": [ - - ], - "name": "VERSION", - "outputs": [ - { - "internalType": "string", - "name": "", - "type": "string" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint64", - "name": "sequence", - "type": "uint64" - }, - { - "internalType": "address", - "name": "provider", - "type": "address" - }, - { - "internalType": "bytes32", - "name": "randomNumber", - "type": "bytes32" - } - ], - "name": "_entropyCallback", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "name": "cancelBattle", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "name": "getBattleRequest", - "outputs": [ - { - "components": [ - { - "internalType": "address", - "name": "requester", - "type": "address" - }, - { - "internalType": "uint256", - "name": "petId1", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "petId2", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "randomness", - "type": "uint256" - }, - { - "internalType": "bool", - "name": "fulfilled", - "type": "bool" - }, - { - "internalType": "bool", - "name": "snapshotted", - "type": "bool" - }, - { - "internalType": "uint256", - "name": "dna1", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "dna2", - "type": "uint256" - }, - { - "internalType": "uint32", - "name": "level1", - "type": "uint32" - }, - { - "internalType": "uint32", - "name": "level2", - "type": "uint32" - }, - { - "internalType": "uint8", - "name": "rarity1", - "type": "uint8" - }, - { - "internalType": "uint8", - "name": "rarity2", - "type": "uint8" - }, - { - "internalType": "uint16", - "name": "speciesId1", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "speciesId2", - "type": "uint16" - } - ], - "internalType": "struct GameLogic.PendingBattle", - "name": "", - "type": "tuple" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "name": "cancelBreed", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "name": "cancelMint", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - - ], - "name": "entropy", - "outputs": [ - { - "internalType": "contract IEntropyV2", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - - ], - "name": "gameConfig", - "outputs": [ - { - "internalType": "contract GameConfig", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "entropy_", - "type": "address" - }, - { - "internalType": "address", - "name": "petCore_", - "type": "address" - }, - { - "internalType": "address", - "name": "gameConfig_", - "type": "address" - }, - { - "internalType": "address", - "name": "initialOwner", - "type": "address" - } - ], - "name": "initialize", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - - ], - "name": "owner", - "outputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - - ], - "name": "pause", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - - ], - "name": "paused", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "pendingStudFees", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "name": "petBattleRequestId", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "name": "petBreedRequestId", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - - ], - "name": "petCore", - "outputs": [ - { - "internalType": "contract PetCore", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - - ], - "name": "proxiableUUID", - "outputs": [ - { - "internalType": "bytes32", - "name": "", - "type": "bytes32" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - - ], - "name": "renounceOwnership", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "petId1", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "petId2", - "type": "uint256" - } - ], - "name": "requestBattle", - "outputs": [ - { - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "petId1", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "petId2", - "type": "uint256" - }, - { - "internalType": "string", - "name": "name_", - "type": "string" - } - ], - "name": "requestCreateFromDNA", - "outputs": [ - { - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "string", - "name": "name_", - "type": "string" - } - ], - "name": "requestMintStarter", - "outputs": [ - { - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "name": "settleBattle", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "name": "settleBreed", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "requestId", - "type": "uint256" - } - ], - "name": "settleMint", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "petId", - "type": "uint256" - } - ], - "name": "train", - "outputs": [ - - ], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "newOwner", - "type": "address" - } - ], - "name": "transferOwnership", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - - ], - "name": "unpause", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "newImplementation", - "type": "address" - } - ], - "name": "upgradeTo", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "newImplementation", - "type": "address" - }, - { - "internalType": "bytes", - "name": "data", - "type": "bytes" - } - ], - "name": "upgradeToAndCall", - "outputs": [ - - ], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - - ], - "name": "withdraw", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - - ], - "name": "withdrawStudFees", - "outputs": [ - - ], - "stateMutability": "nonpayable", - "type": "function" +{ + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "previousAdmin", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newAdmin", + "type": "address" + } + ], + "name": "AdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "beacon", + "type": "address" + } + ], + "name": "BeaconUpgraded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "petId1", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "petId2", + "type": "uint256" + } + ], + "name": "BreedRandomnessRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "childId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "studFeePaidTo", + "type": "address" + } + ], + "name": "BreedSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "config", + "type": "address" + } + ], + "name": "GameConfigUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "MintRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "MintSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Paused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "xpGained", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "newXp", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "newLevel", + "type": "uint32" + } + ], + "name": "Trained", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Unpaused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint64", + "name": "sequence", + "type": "uint64" + }, + { + "internalType": "address", + "name": "provider", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "randomNumber", + "type": "bytes32" + } + ], + "name": "_entropyCallback", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "cancelBreed", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "cancelMint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "entropy", + "outputs": [ + { + "internalType": "contract IEntropyV2", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "gameConfig", + "outputs": [ + { + "internalType": "contract GameConfig", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "entropy_", + "type": "address" + }, + { + "internalType": "address", + "name": "petCore_", + "type": "address" + }, + { + "internalType": "address", + "name": "gameConfig_", + "type": "address" + }, + { + "internalType": "address", + "name": "initialOwner", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "pendingStudFees", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "petBreedRequestId", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "petCore", + "outputs": [ + { + "internalType": "contract PetCore", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId1", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "petId2", + "type": "uint256" + }, + { + "internalType": "string", + "name": "name_", + "type": "string" + } + ], + "name": "requestCreateFromDNA", + "outputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "name_", + "type": "string" + } + ], + "name": "requestMintStarter", + "outputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "gameConfig_", + "type": "address" + } + ], + "name": "setGameConfig", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "settleBreed", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "settleMint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + } + ], + "name": "train", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "unpause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + } + ], + "name": "upgradeTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "withdraw", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "withdrawStudFees", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" } -]} + ] +} diff --git a/frontend/src/chains/ethereum/petCoreAbi.json b/frontend/src/chains/ethereum/petCoreAbi.json index c00aec3b..434d1ae1 100644 --- a/frontend/src/chains/ethereum/petCoreAbi.json +++ b/frontend/src/chains/ethereum/petCoreAbi.json @@ -87,6 +87,45 @@ "name": "BeaconUpgraded", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + } + ], + "name": "CallerAuthorized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + } + ], + "name": "CallerRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "config", + "type": "address" + } + ], + "name": "GameConfigUpdated", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -379,6 +418,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -773,7 +825,7 @@ "type": "uint8" } ], - "internalType": "struct PetCoreV1.Pet", + "internalType": "struct PetCore.Pet", "name": "", "type": "tuple" } @@ -828,6 +880,19 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "incrementWalletMintCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -964,19 +1029,6 @@ "stateMutability": "payable", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "petId", - "type": "uint256" - } - ], - "name": "levelUpInternal", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -1049,19 +1101,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "string", - "name": "name_", - "type": "string" - } - ], - "name": "mintStarter", - "outputs": [], - "stateMutability": "payable", - "type": "function" - }, { "inputs": [ { @@ -1176,30 +1215,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "petId", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "opponentId", - "type": "uint256" - } - ], - "name": "recordBattleOpponent", - "outputs": [ - { - "internalType": "uint8", - "name": "decayShift", - "type": "uint8" - } - ], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [], "name": "renounceOwnership", @@ -1307,6 +1322,19 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "gameConfig_", + "type": "address" + } + ], + "name": "setGameConfig", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -1425,19 +1453,6 @@ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "petId", - "type": "uint256" - } - ], - "name": "triggerCooldown", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -1458,24 +1473,6 @@ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "petId", - "type": "uint256" - }, - { - "internalType": "bool", - "name": "won", - "type": "bool" - } - ], - "name": "updateBattleStats", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -1534,4 +1531,4 @@ "type": "function" } ] -} \ No newline at end of file +} diff --git a/frontend/src/hooks/battle/useBattleOutcome.ts b/frontend/src/hooks/battle/useBattleOutcome.ts index 96b08722..db1ac630 100644 --- a/frontend/src/hooks/battle/useBattleOutcome.ts +++ b/frontend/src/hooks/battle/useBattleOutcome.ts @@ -1,121 +1,41 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { Pet } from '@shared/core'; +import { useCallback, useState } from 'react'; import type { BattleOutcome } from '@components/pet/interactions/panels/battle/types'; -import type { PreBattleStats } from '@components/pet/interactions/panels/battle/battle-utils'; - -interface UseBattleOutcomeArgs { - pets: Pet[]; - selectedPet1: string; - petsLoading: boolean; -} export interface UseBattleOutcome { - /** Resolved victory/defeat, or null until the on-chain stats refresh. */ + /** Resolved victory/defeat, or null until the receipt has verified. */ battleOutcome: BattleOutcome; - /** Snapshot the fighter's pre-battle stats and clear any prior outcome. */ - snapshotFighterStats: (fighter: Pet) => void; - /** Arm outcome detection — call once the settle tx succeeds. */ - markPendingOutcome: () => void; + /** Clear any prior outcome, ready for a new battle. */ + resetOutcome: () => void; /** - * Apply the authoritative win/lose from the on-chain BattleResolved event - * (EVM). Resolves the result immediately and exactly; the stats refetch then - * only fills in `leveledUp` (the event carries no post-battle level). + * Apply the verdict from the verified battle receipt. + * + * Both the win/lose and whether the pet levelled up come straight from the receipt's own + * progression delta, so the result is exact and immediate. */ - applyResolvedOutcome: (playerWon: boolean) => void; - /** Drop the captured snapshot (on leave). */ - clearSnapshot: () => void; - /** Reset the resolved outcome back to null (on a new battle/rematch). */ - resetOutcome: () => void; + applyResolvedOutcome: (playerWon: boolean, leveledUp: boolean) => void; } /** - * Resolve a battle's result. On EVM the win/lose comes authoritatively from the - * BattleResolved event (`applyResolvedOutcome`); the on-chain stats refetch then - * only supplies `leveledUp`. On Solana (no event surfaced here) it falls back to - * diffing the fighter's win/loss stats against a pre-battle snapshot. + * Holds a battle's resolved result. + * + * This used to be considerably more involved: it snapshotted the fighter's pre-battle + * win/loss/level, then watched refreshed on-chain stats for a diff, using that to derive the + * verdict on Solana and `leveledUp` everywhere. + * + * None of that works any more, and none of it is needed. Backend battles never move on-chain + * pet stats — progression lives in `pet_battle_progress`, keyed separately from NFT state — + * so a stats diff could only ever wait forever. The signed receipt carries both values + * directly, which is also strictly better: exact rather than inferred, and available the + * moment the receipt verifies rather than whenever an indexer catches up. */ -export const useBattleOutcome = ({ - pets, - selectedPet1, - petsLoading, -}: UseBattleOutcomeArgs): UseBattleOutcome => { +export const useBattleOutcome = (): UseBattleOutcome => { const [battleOutcome, setBattleOutcome] = useState(null); - // Snapshot taken before battle.mutate; cleared after the outcome resolves. - const preBattleStatsRef = useRef(null); - // Set true once the settle tx succeeds; cleared when the effect resolves it. - const pendingOutcomeRef = useRef(false); - // Authoritative win/lose from BattleResolved (EVM); null = use the stat diff. - const authoritativeRef = useRef<'victory' | 'defeat' | null>(null); - - const snapshotFighterStats = useCallback((fighter: Pet) => { - preBattleStatsRef.current = { - winCount: fighter.winCount, - lossCount: fighter.lossCount, - level: fighter.level, - }; - pendingOutcomeRef.current = false; - authoritativeRef.current = null; - setBattleOutcome(null); - }, []); - - const markPendingOutcome = useCallback(() => { - pendingOutcomeRef.current = true; - }, []); - const applyResolvedOutcome = useCallback((playerWon: boolean) => { - const result = playerWon ? 'victory' : 'defeat'; - authoritativeRef.current = result; - // Show the verdict at once; leveledUp is filled by the stats effect below. - setBattleOutcome((prev) => ({ result, leveledUp: prev?.leveledUp ?? false })); + const applyResolvedOutcome = useCallback((playerWon: boolean, leveledUp: boolean) => { + setBattleOutcome({ result: playerWon ? 'victory' : 'defeat', leveledUp }); }, []); - const clearSnapshot = useCallback(() => { - preBattleStatsRef.current = null; - }, []); - - const resetOutcome = useCallback(() => { - authoritativeRef.current = null; - setBattleOutcome(null); - }, []); - - // After the settle tx, refetch() updates `pets` with the new on-chain stats. - // The stat diff supplies `leveledUp`, and the win/lose result when no - // authoritative on-chain result was applied (Solana). - useEffect(() => { - if ( - !pendingOutcomeRef.current || - !selectedPet1 || - !preBattleStatsRef.current || - petsLoading - ) - return; - - const updatedFighter = pets.find((p) => p.id === selectedPet1); - if (!updatedFighter) return; - - const { - winCount: prevWin, - lossCount: prevLoss, - level: prevLevel, - } = preBattleStatsRef.current; - // Stats haven't refreshed yet — wait for the next update. - if (updatedFighter.winCount === prevWin && updatedFighter.lossCount === prevLoss) return; - - setBattleOutcome({ - result: - authoritativeRef.current ?? - (updatedFighter.winCount > prevWin ? 'victory' : 'defeat'), - leveledUp: updatedFighter.level > prevLevel, - }); - pendingOutcomeRef.current = false; - }, [pets, selectedPet1, petsLoading]); + const resetOutcome = useCallback(() => setBattleOutcome(null), []); - return { - battleOutcome, - snapshotFighterStats, - markPendingOutcome, - applyResolvedOutcome, - clearSnapshot, - resetOutcome, - }; + return { battleOutcome, applyResolvedOutcome, resetOutcome }; }; diff --git a/frontend/src/hooks/battle/useBattlePanel.ts b/frontend/src/hooks/battle/useBattlePanel.ts index 9f388917..51ed1d2c 100644 --- a/frontend/src/hooks/battle/useBattlePanel.ts +++ b/frontend/src/hooks/battle/useBattlePanel.ts @@ -63,18 +63,17 @@ export interface UseBattlePanel { * in their own hooks (`useBattleOutcome`, `useResultDialogue`) and are * composed below. * - * No rematch action: GameLogic.sol's settleBattle puts both participants on a - * 900s battleCooldown (contracts/ethereum/src/GameConfig.sol) regardless of - * outcome, so the exact pairing that just fought can never legally re-battle - * immediately after a result — a same-opponent "Rematch" button would always - * fail with a cooldown error. Players re-battle by picking a fresh opponent - * from the setup screen instead. + * No rematch action: publishing a receipt puts both participants on a cooldown + * (`BATTLE_COOLDOWN_SECONDS`, 900s by default) regardless of outcome, so the exact + * pairing that just fought can never legally re-battle immediately after a result — + * a same-opponent "Rematch" button would always be rejected. Players re-battle by + * picking a fresh opponent from the setup screen instead. */ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBattlePanel => { const navigate = useNavigate(); const location = useLocation(); const capabilities = useChainCapabilities(); - const { pets, refetch, isLoading: petsLoading } = usePetList(); + const { pets, refetch } = usePetList(); // Pre-select the pet the player clicked "Battle" on from its gallery card // (navigate(BATTLE_PATH, { state: { petId } })) — falls back to unselected // for the generic nav entry, which carries no state. Only read once, on @@ -119,25 +118,22 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat refetch: refetchOpponents, } = useOpponents({ chain: activeChainKind }); - // Outcome detection (snapshot diff against refreshed on-chain stats). - const outcome = useBattleOutcome({ pets, selectedPet1, petsLoading }); + // Victory/defeat and level-up, both read straight off the verified receipt. + const outcome = useBattleOutcome(); const handleSuccess = useCallback( - (result: BattleResolvedResult | null) => { + (result: BattleResolvedResult) => { setValidationError(null); - outcome.markPendingOutcome(); - // EVM: BattleResolved is authoritative — petId1 is the player's pet, so - // firstWins is the player's verdict. Solana resolves via the stat diff. - if (result) { - outcome.applyResolvedOutcome(result.firstWins); - const local = liveReplayRef.current?.result; - if (local && local.firstWins !== result.firstWins) { - console.error('[battle] live-replay mismatch — on-chain result is authoritative', { - onChain: result, - local, - }); - setMismatchNotice(true); - } + // The verified receipt is authoritative — petId1 is the player's pet, so + // firstWins is the player's verdict on either chain. + outcome.applyResolvedOutcome(result.firstWins, result.attackerLeveledUp); + const local = liveReplayRef.current?.result; + if (local && local.firstWins !== result.firstWins) { + console.error('[battle] live-replay mismatch — the signed receipt is authoritative', { + receipt: result, + local, + }); + setMismatchNotice(true); } // Result display gates on the live animation finishing too (or the // mismatch notice, if one fired) — see the effect below. @@ -223,17 +219,14 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat // Receipt errors are folded into `battle.error` by the chain adapter. usePetErrorToast(battle.error, null, validationError, BATTLE_FAIL_MESSAGE); - const usesSwitchboardVrf = capabilities.randomness.provider === 'switchboard'; const canRandomMatch = Boolean(selectedFighter) && opponents.length > 0 && !opponentsLoading; - const subtitle = usesSwitchboardVrf - ? 'Pick your fighter and an opponent (Switchboard VRF)' - : 'Pick your fighter and an opponent'; - const pendingLabel = usesSwitchboardVrf ? 'Generating randomness…' : 'Starting Battle...'; - // Fall back to the retained battle id: the lifecycle auto-resets (hash - // cleared) once the battle settles, but the hint should keep showing. - const hashHint = usesSwitchboardVrf - ? formatTxHashHint(battle.hash ?? settledBattleId ?? undefined) - : null; + // Chain-blind: a battle is seeded from a committed drand round on either chain, so + // there is no per-chain VRF provider to name here any more. + const subtitle = 'Pick your fighter and an opponent'; + const pendingLabel = 'Starting Battle...'; + // The battle id, shown so a player can look their receipt up later. Falls back to the + // retained id: `battle.hash` clears once the battle settles, the hint should not. + const hashHint = formatTxHashHint(battle.hash ?? settledBattleId ?? undefined); const startBattle = useCallback(() => { if (!selectedPet1 || !opponent) { @@ -241,8 +234,6 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat return false; } - if (selectedFighter) outcome.snapshotFighterStats(selectedFighter); - setValidationError(null); void battle.mutate({ petId1: selectedPet1, @@ -250,7 +241,7 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat defenderOwner: opponent.owner, }); return true; - }, [battle, opponent, selectedFighter, selectedPet1, outcome]); + }, [battle, opponent, selectedPet1]); // Start Battle: generate AI pre-fight taunts, then hold the wallet prompt until // they finish playing (handleTauntsComplete / the empty-taunts fallback effect @@ -341,7 +332,6 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat taunts.reset(); setValidationError(null); outcome.resetOutcome(); - outcome.clearSnapshot(); setSelectedPet1(''); setSelectedOpponent(''); navigate(DASHBOARD_HOME); diff --git a/frontend/src/hooks/usePetError.ts b/frontend/src/hooks/usePetError.ts index d766fed7..5c9e5a26 100644 --- a/frontend/src/hooks/usePetError.ts +++ b/frontend/src/hooks/usePetError.ts @@ -1,6 +1,6 @@ export { usePetError, type PetError } from '@shared/core'; -/** Trims a tx hash to a short readable hint — UI-only, Solana path only. */ +/** Trims a tx hash or battle id to a short readable hint. UI-only. */ export const formatTxHashHint = (hash: string | undefined): string | null => { return hash ? `${hash.slice(0, 8)}…` : null; }; diff --git a/frontend/src/petsContractParams.ts b/frontend/src/petsContractParams.ts index 036fe608..84a9ac7f 100644 --- a/frontend/src/petsContractParams.ts +++ b/frontend/src/petsContractParams.ts @@ -5,19 +5,11 @@ const evmChainId = import.meta.env.VITE_EVM_CHAIN_ID ? Number(import.meta.env.VITE_EVM_CHAIN_ID) : undefined; -/** Backend's live-battle-socket WS endpoint, derived from VITE_API_URL (http(s) -> ws(s)). - * Undefined if VITE_API_URL isn't set — useEvmBattleFlow degrades to local-only sim. */ -const liveBattleWsUrl = import.meta.env.VITE_API_URL - ? `${import.meta.env.VITE_API_URL.replace(/^http/, 'ws')}/ws/live-battle` - : undefined; - /** v2 EVM contract config for `PetsConfigProvider` from `@shared/core`. */ export const petsContractParams: PetsEvmConfig = { petCore: evmContracts.petCore, gameLogic: evmContracts.gameLogic, gameConfig: evmContracts.gameConfig, - combatSim: evmContracts.combatSim, enabled: true, chainId: evmChainId, - liveBattleWsUrl, }; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 727a6291..385a9b8b 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -10,12 +10,10 @@ interface ImportMetaEnv { readonly VITE_EVM_CHAIN_ID?: string; /** v2 PetCore UUPS proxy address (ERC-721 storage, mint, level/XP, marriage). */ readonly VITE_PETCORE_ADDRESS?: string; - /** v2 GameLogic UUPS proxy address (async battle/breed/train + VRF). */ + /** v2 GameLogic UUPS proxy address (async breed/mint + entropy). */ readonly VITE_GAMELOGIC_ADDRESS?: string; /** v2 GameConfig address (tunable fees / cooldowns / XP-curve / skill params). */ readonly VITE_GAMECONFIG_ADDRESS?: string; - /** v2 CombatSim address (pure combat simulation lib). */ - readonly VITE_COMBATSIM_ADDRESS?: string; /** Target cluster for CryptoPets / wallet flows (e.g. `devnet`, `mainnet-beta`, `localnet`). */ readonly VITE_SOLANA_CLUSTER?: string; /** Deployed CryptoPets program id (public key). */ diff --git a/frontend/tests/hooks/battle/useBattleOutcome.test.ts b/frontend/tests/hooks/battle/useBattleOutcome.test.ts index c03f0242..0ba44982 100644 --- a/frontend/tests/hooks/battle/useBattleOutcome.test.ts +++ b/frontend/tests/hooks/battle/useBattleOutcome.test.ts @@ -1,115 +1,55 @@ -import { describe, expect, it } from 'vitest'; import { act, renderHook } from '@testing-library/react'; -import type { Pet } from '@shared/core'; +import { describe, expect, it } from 'vitest'; import { useBattleOutcome } from '@hooks/battle/useBattleOutcome'; -// Minimal Pet — the hook only reads id/winCount/lossCount/level. -const fighter = (over: Partial = {}): Pet => - ({ id: 'p1', winCount: 0, lossCount: 0, level: 1, ...over }) as Pet; - -type Props = { pets: Pet[]; selectedPet1: string; petsLoading: boolean }; -const initial: Props = { pets: [fighter()], selectedPet1: 'p1', petsLoading: false }; - -/** Snapshot the starting stats and arm outcome detection. */ -const arm = (result: { current: ReturnType }) => { - act(() => { - result.current.snapshotFighterStats(fighter()); - }); - act(() => { - result.current.markPendingOutcome(); - }); -}; - +/** + * The outcome now comes entirely from the verified receipt's progression delta. The old + * stats-diff path is gone: backend battles never move on-chain win/loss counters, so + * comparing refreshed chain stats could only ever wait forever. + */ describe('useBattleOutcome', () => { it('starts with no outcome', () => { - const { result } = renderHook((props: Props) => useBattleOutcome(props), { - initialProps: initial, - }); + const { result } = renderHook(() => useBattleOutcome()); expect(result.current.battleOutcome).toBeNull(); }); - it('resolves a victory when the win count increases', () => { - const { result, rerender } = renderHook((props: Props) => useBattleOutcome(props), { - initialProps: initial, - }); - arm(result); + it('resolves a victory from the receipt', () => { + const { result } = renderHook(() => useBattleOutcome()); - rerender({ ...initial, pets: [fighter({ winCount: 1 })] }); + act(() => result.current.applyResolvedOutcome(true, false)); expect(result.current.battleOutcome).toEqual({ result: 'victory', leveledUp: false }); }); - it('resolves a defeat when the loss count increases', () => { - const { result, rerender } = renderHook((props: Props) => useBattleOutcome(props), { - initialProps: initial, - }); - arm(result); + it('resolves a defeat from the receipt', () => { + const { result } = renderHook(() => useBattleOutcome()); - rerender({ ...initial, pets: [fighter({ lossCount: 1 })] }); + act(() => result.current.applyResolvedOutcome(false, false)); expect(result.current.battleOutcome).toEqual({ result: 'defeat', leveledUp: false }); }); - it('flags a level-up alongside the result', () => { - const { result, rerender } = renderHook((props: Props) => useBattleOutcome(props), { - initialProps: initial, - }); - arm(result); + it('carries a level-up through from the receipt rather than inferring it', () => { + const { result } = renderHook(() => useBattleOutcome()); - rerender({ ...initial, pets: [fighter({ winCount: 1, level: 2 })] }); + act(() => result.current.applyResolvedOutcome(true, true)); expect(result.current.battleOutcome).toEqual({ result: 'victory', leveledUp: true }); }); - it('waits while the stats have not refreshed yet', () => { - const { result, rerender } = renderHook((props: Props) => useBattleOutcome(props), { - initialProps: initial, - }); - arm(result); - - // Same win/loss as the snapshot — nothing to resolve. - rerender({ ...initial, pets: [fighter({ level: 2 })] }); - - expect(result.current.battleOutcome).toBeNull(); - }); - - it('does not resolve while pets are still loading', () => { - const { result, rerender } = renderHook((props: Props) => useBattleOutcome(props), { - initialProps: initial, - }); - arm(result); - - rerender({ ...initial, pets: [fighter({ winCount: 1 })], petsLoading: true }); - - expect(result.current.battleOutcome).toBeNull(); - }); - - it('clearSnapshot prevents any resolution', () => { - const { result, rerender } = renderHook((props: Props) => useBattleOutcome(props), { - initialProps: initial, - }); - arm(result); - act(() => { - result.current.clearSnapshot(); - }); - - rerender({ ...initial, pets: [fighter({ winCount: 1 })] }); - - expect(result.current.battleOutcome).toBeNull(); + it('resolves immediately, without waiting on an indexer', () => { + // The receipt is self-contained, so there is nothing to wait for. + const { result } = renderHook(() => useBattleOutcome()); + act(() => result.current.applyResolvedOutcome(true, false)); + expect(result.current.battleOutcome).not.toBeNull(); }); it('resetOutcome clears a resolved outcome', () => { - const { result, rerender } = renderHook((props: Props) => useBattleOutcome(props), { - initialProps: initial, - }); - arm(result); - rerender({ ...initial, pets: [fighter({ winCount: 1 })] }); - expect(result.current.battleOutcome).not.toBeNull(); + const { result } = renderHook(() => useBattleOutcome()); + act(() => result.current.applyResolvedOutcome(true, true)); - act(() => { - result.current.resetOutcome(); - }); + act(() => result.current.resetOutcome()); expect(result.current.battleOutcome).toBeNull(); }); diff --git a/frontend/tests/hooks/battle/useBattlePanel.test.ts b/frontend/tests/hooks/battle/useBattlePanel.test.ts index 9965dcba..cdb1f0ea 100644 --- a/frontend/tests/hooks/battle/useBattlePanel.test.ts +++ b/frontend/tests/hooks/battle/useBattlePanel.test.ts @@ -25,7 +25,7 @@ vi.mock('@components/pet/interactions/panels/battle/battle-utils', () => ({ toDialoguePet: (p: { id: string; name: string }) => ({ petId: p.id, name: p.name }), })); -const battleOutcome = { battleOutcome: null as null | object, markPendingOutcome: vi.fn(), applyResolvedOutcome: vi.fn(), resetOutcome: vi.fn(), clearSnapshot: vi.fn(), snapshotFighterStats: vi.fn() }; +const battleOutcome = { battleOutcome: null as null | object, applyResolvedOutcome: vi.fn(), resetOutcome: vi.fn() }; vi.mock('@hooks/battle/useBattleOutcome', () => ({ useBattleOutcome: () => battleOutcome })); const resultDialogue = { resultTurns: [], dialogueLoading: false, attackerName: '', defenderName: '', markResultDialogueDone: vi.fn(), resultDialogueDone: false, resetResultDialogue: vi.fn() }; @@ -50,7 +50,6 @@ vi.mock('@shared/core', () => ({ useBattleTaunts: () => taunts, useCreateBattleRoom: () => ({ createRoom, isLoading: false }), useOpponents: () => ({ opponents, isLoading: false, isFetching: false, refetch: vi.fn() }), - usePendingBattle: () => ({ isPending: false }), useWinEstimate: () => ({ winProbability: null, isLoading: false, samples: null }), })); @@ -150,16 +149,11 @@ describe('useBattlePanel', () => { expect(result.current.overlay.open).toBe(false); }); - it('handleSuccess marks pending outcome and refetches', () => { + it('handleSuccess applies the verified receipt outcome and refetches', () => { renderHook(() => useBattlePanel({ isStandaloneView: false })); - act(() => { capturedOnSuccess?.(null); }); - expect(battleOutcome.markPendingOutcome).toHaveBeenCalled(); - }); - - it('handleSuccess applies resolved outcome when result is provided', () => { - renderHook(() => useBattlePanel({ isStandaloneView: false })); - act(() => { capturedOnSuccess?.({ firstWins: true }); }); - expect(battleOutcome.applyResolvedOutcome).toHaveBeenCalledWith(true); + act(() => { capturedOnSuccess?.({ firstWins: true, attackerLeveledUp: true }); }); + // Both values come from the receipt; nothing is inferred from refreshed chain stats. + expect(battleOutcome.applyResolvedOutcome).toHaveBeenCalledWith(true, true); }); it('overlay.open closes when battle.error is set after battle starts', async () => { @@ -256,10 +250,10 @@ describe('useBattlePanel', () => { expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining('mismatch'), - expect.objectContaining({ onChain: { firstWins: false }, local: { firstWins: true } }), + expect.objectContaining({ receipt: { firstWins: false }, local: { firstWins: true } }), ); - // The on-chain result must still be applied as authoritative despite the mismatch. - expect(battleOutcome.applyResolvedOutcome).toHaveBeenCalledWith(false); + // The receipt must still be applied as authoritative despite the mismatch. + expect(battleOutcome.applyResolvedOutcome).toHaveBeenCalledWith(false, undefined); // Result card doesn't appear immediately — the notice holds it briefly. expect(result.current.overlay.showResult).toBe(false); diff --git a/shared/src/contexts/PetsConfigContext.tsx b/shared/src/contexts/PetsConfigContext.tsx index 9446669b..2343c397 100644 --- a/shared/src/contexts/PetsConfigContext.tsx +++ b/shared/src/contexts/PetsConfigContext.tsx @@ -9,22 +9,20 @@ export interface EvmContractRef { /** * v2 splits the monolithic v1 contract into separate units. PetCore and - * GameLogic are required (reads + writes); GameConfig and CombatSim are - * read-only and optional (fee/cooldown display, client-side combat sim). + * GameLogic are required (reads + writes); GameConfig is read-only and optional + * (fee/cooldown display). + * + * There is no CombatSim entry: battles are simulated by the backend and replayed + * from the signed receipt (§L Phase 6), so the client never calls the on-chain sim. */ export interface PetsEvmConfig { petCore: EvmContractRef; gameLogic: EvmContractRef; gameConfig?: EvmContractRef; - combatSim?: EvmContractRef; enabled?: boolean; /** EVM chain ID the contracts are deployed on. Passed to read hooks so they * use the right RPC regardless of which chain the wallet is connected to. */ chainId?: number; - /** Backend's live-battle-socket WS endpoint (e.g. `ws://localhost:3001/ws/live-battle`). - * Optional — unset means no backend-pushed live replay; the battle still resolves - * normally via the on-chain BattleResolved event, just without pre-settle animation. */ - liveBattleWsUrl?: string; } export interface PetsConfigContextValue { diff --git a/shared/src/hooks/chains/ethereum/useEvmFees.ts b/shared/src/hooks/chains/ethereum/useEvmFees.ts index d4e69d2b..8cc3f60b 100644 --- a/shared/src/hooks/chains/ethereum/useEvmFees.ts +++ b/shared/src/hooks/chains/ethereum/useEvmFees.ts @@ -23,9 +23,6 @@ export interface EvmFees { levelUpFee?: bigint; /** GameConfig.breedFee() — same-owner breed fee (stud fee is v2.1/marriage). */ breedFee?: bigint; - /** GameConfig.battleFee() — funds the settle keeper's settleBattle gas; added to - * entropyFee when calling requestBattle. */ - battleFee?: bigint; /** GameConfig.trainFee() — base train fee, scaled by level on-chain. */ trainFee?: bigint; /** GameConfig.studFee() — added to breedFee for cross-owner (married) breeding. */ @@ -59,7 +56,6 @@ export const useEvmFees = (enabled: boolean): EvmFees => { const { data: baseMintFee } = useReadContract({ address: gameConfig, abi: gameConfigAbi, functionName: 'baseMintFee', chainId, query: cfgQuery }); const { data: levelUpFee } = useReadContract({ address: gameConfig, abi: gameConfigAbi, functionName: 'levelUpFee', chainId, query: cfgQuery }); const { data: breedFee } = useReadContract({ address: gameConfig, abi: gameConfigAbi, functionName: 'breedFee', chainId, query: cfgQuery }); - const { data: battleFee } = useReadContract({ address: gameConfig, abi: gameConfigAbi, functionName: 'battleFee', chainId, query: cfgQuery }); const { data: trainFee } = useReadContract({ address: gameConfig, abi: gameConfigAbi, functionName: 'trainFee', chainId, query: cfgQuery }); const { data: studFee } = useReadContract({ address: gameConfig, abi: gameConfigAbi, functionName: 'studFee', chainId, query: cfgQuery }); @@ -100,12 +96,11 @@ export const useEvmFees = (enabled: boolean): EvmFees => { baseMintFee: base, levelUpFee: levelUpFee as bigint | undefined, breedFee: breedFee as bigint | undefined, - battleFee: battleFee as bigint | undefined, trainFee: trainFee as bigint | undefined, studFee: studFee as bigint | undefined, walletMintCount: mintCount, nextMintFee, entropyFee: entropyFeeRaw as bigint | undefined, }; - }, [baseMintFee, levelUpFee, breedFee, battleFee, trainFee, studFee, walletMintCount, entropyFeeRaw]); + }, [baseMintFee, levelUpFee, breedFee, trainFee, studFee, walletMintCount, entropyFeeRaw]); }; diff --git a/shared/src/hooks/chains/solana/useSolanaFees.ts b/shared/src/hooks/chains/solana/useSolanaFees.ts index 01d12e94..d612ff28 100644 --- a/shared/src/hooks/chains/solana/useSolanaFees.ts +++ b/shared/src/hooks/chains/solana/useSolanaFees.ts @@ -11,8 +11,6 @@ export interface SolanaFees { levelUpFeeLamports?: bigint; /** GlobalState.breed_fee_lamports — same-owner breed fee. */ breedFeeLamports?: bigint; - /** GlobalState.battle_fee_lamports — funds the settle keeper's settle_battle tx. */ - battleFeeLamports?: bigint; /** GlobalState.train_fee_lamports — base train fee, level-scaled at call time. */ trainFeeLamports?: bigint; /** GlobalState.stud_fee_lamports — added on top of breedFee for cross-owner breeding. */ @@ -69,7 +67,6 @@ export const useSolanaFees = (enabled: boolean): SolanaFees => { baseMintFeeLamports: baseMint, levelUpFeeLamports: toBigInt(gs?.levelUpFeeLamports), breedFeeLamports: toBigInt(gs?.breedFeeLamports), - battleFeeLamports: toBigInt(gs?.battleFeeLamports), trainFeeLamports: toBigInt(gs?.trainFeeLamports), studFeeLamports: toBigInt(gs?.studFeeLamports), walletMintCount: mintCount, diff --git a/shared/src/hooks/useBattlePets.ts b/shared/src/hooks/useBattlePets.ts index 502a6fc9..77399506 100644 --- a/shared/src/hooks/useBattlePets.ts +++ b/shared/src/hooks/useBattlePets.ts @@ -37,8 +37,12 @@ export interface BattlePetsArgs { } export type UseBattlePetsOptions = { - /** Fires once the receipt is signed and has verified locally. */ - onSuccess?: (result: BattleResolvedResult | null) => void; + /** + * Fires once, when the receipt is signed and has verified locally. + * + * Never fires with a null result: an unverified receipt is simply not surfaced. + */ + onSuccess?: (result: BattleResolvedResult) => void; /** Room to follow for push updates, and the socket to reach it on. */ roomId?: string | null; roomSocketUrl?: string | undefined; @@ -172,5 +176,6 @@ function toResolvedResult(receipt: BattleReceipt): BattleResolvedResult { winnerHpRemaining: receipt.result.winnerHpRemaining, xpWin: attackerWon ? receipt.progression.attacker.xpAwarded : receipt.progression.defender.xpAwarded, xpLoss: attackerWon ? receipt.progression.defender.xpAwarded : receipt.progression.attacker.xpAwarded, + attackerLeveledUp: receipt.progression.attacker.leveledUp, }; } diff --git a/shared/src/index.ts b/shared/src/index.ts index 1eba6c9f..72894d7f 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -34,11 +34,5 @@ export { type EvmContractRef, } from './contexts/PetsConfigContext'; export type { Pet, PetChain, PetAction, OpponentPet } from './types/pet'; -export type { BattleResolvedResult, EvmBattlePhase } from './types/battle'; -export { - encodeBattleResolvedResult, - decodeBattleResolvedResult, - type BattleResolvedResultWire, - type LiveBattleWireMessage, -} from './types/liveBattleSocket'; +export type { BattleResolvedResult } from './types/battle'; export { queryClient } from './queryClient'; diff --git a/shared/src/node.ts b/shared/src/node.ts index 9c648944..f529e0b1 100644 --- a/shared/src/node.ts +++ b/shared/src/node.ts @@ -1,18 +1,12 @@ /** - * Node-safe surface for the backend settle-keepers / live-battle socket. + * Node-safe surface for the backend settle keeper. * * Deliberately does NOT re-export the main barrel (`./index.ts`): that pulls in * React hooks/contexts (.tsx) which the backend typechecks without JSX and * which Node has no business loading at runtime. */ -export { - encodeBattleResolvedResult, - decodeBattleResolvedResult, - type BattleResolvedResultWire, - type LiveBattleWireMessage, -} from './types/liveBattleSocket'; -export type { BattleResolvedResult, EvmBattlePhase } from './types/battle'; +export type { BattleResolvedResult } from './types/battle'; export { simulate, encodeSimOutcome, diff --git a/shared/src/types/battle.ts b/shared/src/types/battle.ts index 6e3a3f44..95823e07 100644 --- a/shared/src/types/battle.ts +++ b/shared/src/types/battle.ts @@ -1,12 +1,19 @@ /** - * Decoded `BattleResolved` event from GameLogic (EVM). Carries everything the - * fight-replay UI needs: the VRF seed (to re-run the deterministic combat sim) - * plus the resolved outcome and XP deltas. + * A resolved battle, as the UI renders it. + * + * Previously the decoded `BattleResolved` event from GameLogic; battles are now resolved by + * the backend (§L Phase 6), so this is built from a *verified* signed receipt instead. The + * field names are kept so the existing UI is unchanged, but two now mean something slightly + * different: `requestId` is always 0 (there is no on-chain request behind a backend battle) + * and `vrfSeed` is the drand-derived battle seed rather than a Pyth Entropy word. Both still + * re-run the deterministic combat sim identically. */ export interface BattleResolvedResult { + /** Always 0 for a backend battle: there is no on-chain request to reference. */ requestId: bigint; winnerId: bigint; loserId: bigint; + /** The battle seed, derived from the committed drand round (§E). */ vrfSeed: bigint; /** True when petId1 (the requester's attacker) won. */ firstWins: boolean; @@ -14,22 +21,13 @@ export interface BattleResolvedResult { winnerHpRemaining: number; xpWin: number; xpLoss: number; + /** + * Whether the attacker's pet levelled up. + * + * Carried on the result because on-chain pet stats no longer move when a battle + * resolves — backend progression lives in `pet_battle_progress` — so the old approach of + * diffing refreshed chain stats can never detect it. + */ + attackerLeveledUp: boolean; } -/** - * Stage of the async EVM battle flow: request → VRF fulfill → settle → resolved. - * - * `awaiting-settle` is the normal post-reveal state: the backend settle keeper - * (plan-realtime-battle-impl.md Phase 2) is expected to submit `settleBattle` - * without the player's wallet. `settling` only appears if that keeper hasn't - * settled within the fallback timeout and the frontend sends it itself. - */ -export type EvmBattlePhase = - | 'idle' - | 'requesting' - | 'awaiting-vrf' - | 'awaiting-settle' - | 'settling' - | 'resolving' - | 'resolved' - | 'error'; diff --git a/shared/src/types/liveBattleSocket.ts b/shared/src/types/liveBattleSocket.ts deleted file mode 100644 index 77c13711..00000000 --- a/shared/src/types/liveBattleSocket.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { SimOutcomeWire } from '../utils/combat'; -import type { BattleResolvedResult } from './battle'; - -/** JSON-safe encoding of `BattleResolvedResult` — `bigint` fields as decimal strings. */ -export interface BattleResolvedResultWire { - requestId: string; - winnerId: string; - loserId: string; - vrfSeed: string; - firstWins: boolean; - rounds: number; - winnerHpRemaining: number; - xpWin: number; - xpLoss: number; -} - -/** - * Backend settle-keeper -> frontend push, over the live-battle-socket WebSocket - * (backend/src/ws/liveBattleSocket.ts). Two message shapes share one channel: - * 'live' - the computed sim, pushed the instant entropy reveals (presentation only). - * 'resolved' - the actual settled outcome, pushed once the keeper's settle tx confirms - * (authoritative — mirrors the on-chain BattleResolved event, since the - * keeper decodes it from its own settle receipt). - */ -export type LiveBattleWireMessage = - | { type: 'live'; chainId: number; requestId: string; outcome: SimOutcomeWire } - | { type: 'resolved'; chainId: number; requestId: string; result: BattleResolvedResultWire }; - -export const encodeBattleResolvedResult = (r: BattleResolvedResult): BattleResolvedResultWire => ({ - requestId: r.requestId.toString(), - winnerId: r.winnerId.toString(), - loserId: r.loserId.toString(), - vrfSeed: r.vrfSeed.toString(), - firstWins: r.firstWins, - rounds: r.rounds, - winnerHpRemaining: r.winnerHpRemaining, - xpWin: r.xpWin, - xpLoss: r.xpLoss, -}); - -export const decodeBattleResolvedResult = (w: BattleResolvedResultWire): BattleResolvedResult => ({ - requestId: BigInt(w.requestId), - winnerId: BigInt(w.winnerId), - loserId: BigInt(w.loserId), - vrfSeed: BigInt(w.vrfSeed), - firstWins: w.firstWins, - rounds: w.rounds, - winnerHpRemaining: w.winnerHpRemaining, - xpWin: w.xpWin, - xpLoss: w.xpLoss, -}); diff --git a/shared/tests/hooks/useEvmAdapter.test.tsx b/shared/tests/hooks/useEvmAdapter.test.tsx index 5609913b..8f81b8f7 100644 --- a/shared/tests/hooks/useEvmAdapter.test.tsx +++ b/shared/tests/hooks/useEvmAdapter.test.tsx @@ -36,7 +36,6 @@ const fees = { trainFee: 3n, breedFee: 4n, studFee: 5n, - battleFee: 1n, entropyFee: 0n, }; const config: { diff --git a/shared/tests/hooks/useEvmFees.test.tsx b/shared/tests/hooks/useEvmFees.test.tsx index 3829705d..d72cdb3f 100644 --- a/shared/tests/hooks/useEvmFees.test.tsx +++ b/shared/tests/hooks/useEvmFees.test.tsx @@ -6,7 +6,6 @@ const reads: Record = { baseMintFee: 100n, levelUpFee: 5n, breedFee: 7n, - battleFee: 6n, trainFee: 3n, studFee: 9n, walletMintCount: 2n, @@ -29,7 +28,7 @@ import { useEvmFees } from '../../src/hooks/chains/ethereum/useEvmFees'; beforeEach(() => { account.address = '0xabc'; - Object.assign(reads, { baseMintFee: 100n, levelUpFee: 5n, breedFee: 7n, battleFee: 6n, trainFee: 3n, studFee: 9n, walletMintCount: 2n }); + Object.assign(reads, { baseMintFee: 100n, levelUpFee: 5n, breedFee: 7n, trainFee: 3n, studFee: 9n, walletMintCount: 2n }); }); describe('useEvmFees', () => { @@ -39,7 +38,6 @@ describe('useEvmFees', () => { expect(result.current.baseMintFee).toBe(100n); expect(result.current.levelUpFee).toBe(5n); expect(result.current.breedFee).toBe(7n); - expect(result.current.battleFee).toBe(6n); expect(result.current.trainFee).toBe(3n); expect(result.current.studFee).toBe(9n); expect(result.current.walletMintCount).toBe(2n); diff --git a/shared/tests/hooks/useSolanaFees.test.tsx b/shared/tests/hooks/useSolanaFees.test.tsx index cda3df50..ab40ef02 100644 --- a/shared/tests/hooks/useSolanaFees.test.tsx +++ b/shared/tests/hooks/useSolanaFees.test.tsx @@ -38,7 +38,6 @@ const globalStateData = { breedFeeLamports: 10_000_000, trainFeeLamports: 10_000_000, studFeeLamports: 20_000_000, - battleFeeLamports: 5_000_000, }; const playerProfileData = { mintCount: 2 }; @@ -80,7 +79,6 @@ describe('useSolanaFees', () => { expect(result.current.breedFeeLamports).toBe(10_000_000n); expect(result.current.trainFeeLamports).toBe(10_000_000n); expect(result.current.studFeeLamports).toBe(20_000_000n); - expect(result.current.battleFeeLamports).toBe(5_000_000n); }); it('computes nextMintFeeLamports as baseMintFee << min(mintCount, 7)', async () => { From b4264884e597c40adfa7c1548304cba4c334acf9 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 20:30:16 -0400 Subject: [PATCH 51/76] refactor(solana,subgraph): remove the on-chain battle path --- CLAUDE.md | 26 +- contracts/ethereum/subgraph/schema.graphql | 53 +-- contracts/ethereum/subgraph/src/mapping.ts | 45 +- .../ethereum/subgraph/subgraph.template.yaml | 8 +- .../programs/cryptopets/src/errors.rs | 17 +- .../programs/cryptopets/src/game/mod.rs | 7 + .../src/instructions/admin/config.rs | 16 +- .../src/instructions/admin/initialize.rs | 4 +- .../src/instructions/battle/cancel_battle.rs | 82 ---- .../src/instructions/battle/commit_battle.rs | 165 ------- .../cryptopets/src/instructions/battle/mod.rs | 16 +- .../battle/set_open_to_challenges.rs | 8 +- .../src/instructions/battle/settle_battle.rs | 232 ---------- .../src/instructions/mint/cancel_mint.rs | 2 +- .../cryptopets/programs/cryptopets/src/lib.rs | 19 - .../programs/cryptopets/src/state/global.rs | 36 +- .../programs/cryptopets/src/state/pet.rs | 3 +- .../programs/cryptopets/src/state/requests.rs | 52 --- .../scripts/devnet-battle-harness.ts | 431 ------------------ .../solana/cryptopets/scripts/initialize.ts | 2 +- .../solana/cryptopets/scripts/set-config.ts | 2 +- .../solana/cryptopets/tests/cryptopets.ts | 20 +- contracts/solana/cryptopets/tests/utils.ts | 8 - .../panels/battle/parts/battle-overlay.tsx | 2 +- shared/src/hooks/chains/ethereum/gasLimits.ts | 7 +- .../ethereum/useWatchEntropyFulfillment.ts | 14 +- shared/src/utils/solana/index.ts | 1 - shared/src/utils/solana/pdas.ts | 6 - shared/tests/utils/solana/pdas.test.ts | 6 +- 29 files changed, 96 insertions(+), 1194 deletions(-) delete mode 100644 contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/cancel_battle.rs delete mode 100644 contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/commit_battle.rs delete mode 100644 contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/settle_battle.rs delete mode 100644 contracts/solana/cryptopets/scripts/devnet-battle-harness.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3edaa0b2..f2443db3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,23 +116,28 @@ Hashing uses **legacy Keccak-256** (`keccak256(abi.encodePacked(...))` byte layo ### Per-battle on-chain settlement is retired (§L Phase 6) New battles run through the backend-authoritative path (`BATTLE_BACKEND_MODE_ENABLED`): signed intent, committed drand round, signed receipt, Merkle batch anchored by `BattleBatchRegistry`. `GameLogic`'s `requestBattle`/`settleBattle` flow and both settle keepers are **legacy**, kept for one reason — every battle they settled has to stay replayable, and the events and receipts they produced stay served indefinitely (§H). Retiring the path means new battles stop using it, never that old ones become uncheckable. The keepers remain deployable (`KEEPER_ENABLED`, `KEEPER_SOLANA_ENABLED`, both off by default) so an existing deployment can drain in-flight requests rather than stranding them. -### Settle keeper: the second EVM battle/breed/mint transaction isn't the player's -**Legacy for battles** as of §L Phase 6 (above); still current for breed and mint, which have no backend-authoritative equivalent and continue to settle on chain. +### Settle keeper: the second EVM breed/mint transaction isn't the player's +Battles no longer take this path at all as of §L Phase 6 (above). Breed and mint still do — they have no backend-authoritative equivalent and continue to settle on chain. -`GameLogic`'s async flows (`requestBattle`/`requestCreateFromDNA`/`requestMintStarter` → Pyth Entropy reveals → `settleX`) used to have the frontend send the settle transaction itself, meaning two wallet prompts per action even though settle is permissionless. A backend service, `backend/src/features/settle-keeper/`, now watches Pyth Entropy's `Revealed` event and sends the settle transaction from its own wallet; the frontend only falls back to prompting the player if the keeper hasn't settled within ~45s (keeper outage or not configured — see `useEvmBattleFlow.ts`'s `FALLBACK_SETTLE_DELAY_MS`). Gated by `KEEPER_ENABLED` (off by default); see `backend/env.example` for the full var list. This fixes only the double-signature UX; the related security fix — `requestBattle` snapshotting sim inputs so a level-up between request and settle can't reroll a committed battle — already lives in `GameLogic.sol` itself. See `docs/plan-realtime-battle-ux.md` / `docs/plan-realtime-battle-impl.md` for the full design and threat model. +`GameLogic`'s async flows (`requestBattle`/`requestCreateFromDNA`/`requestMintStarter` → Pyth Entropy reveals → `settleX`) used to have the frontend send the settle transaction itself, meaning two wallet prompts per action even though settle is permissionless. A backend service, `backend/src/features/settle-keeper/`, now watches Pyth Entropy's `Revealed` event and sends the settle transaction from its own wallet; the frontend only falls back to prompting the player if the keeper hasn't settled within ~45s (keeper outage or not configured). Gated by `KEEPER_ENABLED` (off by default); see `backend/env.example` for the full var list. This fixes only the double-signature UX; the related security fix — `requestBattle` snapshotting sim inputs so a level-up between request and settle can't reroll a committed battle — already lives in `GameLogic.sol` itself. See `docs/plan-realtime-battle-ux.md` / `docs/plan-realtime-battle-impl.md` for the full design and threat model. + +### Battle fee funds the settle keeper's own gas (EVM) — retired +Retired with the on-chain battle path (§L Phase 6): `GameConfig.battleFee`, `setBattleFee`, and `useFees().battleFee` are all gone, and the keeper settles breed and mint only. Kept because the `GameConfig` migration it forced is still live and its env-staleness warning still applies. The original text follows. -### Battle fee funds the settle keeper's own gas (EVM) The EVM settle keeper (above) sends `settleBattle` from its own wallet, but until this was added that transaction (~800k gas, `SETTLE_GAS_LIMIT` in `backend/src/features/settle-keeper/abi.ts`) was entirely unfunded — the player's `requestBattle` payment only ever covered the Pyth Entropy fee. `GameConfig.battleFee` (owner-tunable via `setBattleFee`) is now required on top of the entropy fee at `requestBattle` time, escrowed in the pending record, and refunded on `cancelBattle` (no settle tx is ever sent for a cancelled request); on a normal settle it just adds to the contract's withdrawable balance alongside the other protocol fees — there's no automatic reimbursement to the keeper wallet specifically, so it still needs manual top-ups from `withdraw()` proceeds. The frontend surfaces this via `useFees().battleFee` (chain-neutral — see the Solana section below) and shows it in the Start Battle button label. Because `GameConfig` isn't behind a proxy (see its own doc comment), adding this field required a fresh `GameConfig` deployment plus a new `setGameConfig(address)` setter on both `GameLogic` and `PetCore` (added together, deliberately — `PetCore` reads several other config values like `battleCooldown`/`poolSizes`, and pointing only one proxy at a new instance would let the two silently diverge). `scripts/upgrade-game-config.ts` handles the migration: it replays every existing tunable from the old `GameConfig` onto the new one before repointing anything, so live-tuned values (fees, skill balance, cooldowns) aren't reset to source defaults. This has been run against the live Base Sepolia deployment; any client env (`VITE_GAMECONFIG_ADDRESS`, `KEEPER_GAME_CONFIG_ADDRESS`) pointing at the old `GameConfig` address needs updating too, or fee reads fail outright (the old contract has no `battleFee()` at all) — check `frontend/.env`/`.env.local` and `backend/.env` aren't stale before assuming a deployment issue is something else. -### Solana settle keeper and battle-only permissionless settle -Mirrors the EVM keeper, but for Solana's `commit_battle`/`settle_battle` (Switchboard On-Demand VRF) and **battle only** — `settle_breed`/`settle_mint` still require the player's own signature, because their Metaplex Core mint CPI needs a real payer signature (see `docs/plan-realtime-battle-solana.md` Workstream S2 for why this doesn't generalize the way the EVM keeper did). `SettleBattle`'s `attacker_owner` account was changed from `Signer` to `UncheckedAccount` (mirroring this program's own pre-existing `cancel_battle` pattern), and a sibling backend module, `backend/src/features/settle-keeper-solana/`, polls open `BattleRequest`s and submits reveal+settle once Switchboard's oracle is ready. Gated by `KEEPER_SOLANA_ENABLED` (off by default); see `backend/env.example`. The frontend (`shared/src/utils/solana/battleWithSwitchboardVrf.ts`) waits up to 45s for the keeper before falling back to sending reveal+settle from the player's own wallet, same pattern as EVM's `FALLBACK_SETTLE_DELAY_MS`. A second Rust change, `BattleRequest` snapshotting attacker/defender dna/rarity/level/species at commit time (Workstream S1), closes the same train/level-up front-run reroll Phase 1 closed on EVM — `settle_battle.rs` now simulates from that frozen snapshot instead of live pet accounts. Client-side live battle animation (Workstream S3, `useLiveBattleReplaySolana`) reuses the same `protocol/src/combat/` TS port (no new simulator code) by independently deriving the seed from an unbroadcast Switchboard reveal instruction; this specific mechanism is unverified against a live gateway (see the hook's header comment) and degrades to no animation, never a broken UI, if the assumption is wrong. +### Solana battles are retired too (§L Phase 6) +`commit_battle`/`settle_battle`/`cancel_battle`, the `BattleRequest` account, the Solana settle keeper (`backend/src/features/settle-keeper-solana/`), and `GlobalState.battle_fee_lamports` are all gone. Solana battles now take the same backend-authoritative path as EVM ones, so `settle_breed`/`settle_mint` are the only remaining commit/settle flows, and both still require the player's own signature (their Metaplex Core mint CPI needs a real payer signature — see `docs/plan-realtime-battle-solana.md` Workstream S2 for why the keeper never generalized to them). + +Two things deliberately stayed. `game/battle_sim.rs` and `game/xp.rs` have no caller left in the program but are **frozen, not deleted**: their golden-vector tests are what prove `contracts/test-vectors/{battle,xp}.json` still describe what actually settled on this chain. And `set_open_to_challenges` plus `PetAccount.open_to_challenges` remain as the owner's stated defender-consent preference — the program no longer reads the flag, so **nothing enforces it until the backend matchmaker does**. + +Account layout: removing `battle_fee_lamports` grew `GlobalState._reserved` back from 16 to 24 bytes, so `GlobalState::SPACE` and every preceding field offset are unchanged and no `CURRENT_ACCOUNT_VERSION` bump is needed. A live account reads the old fee value back as padding. `ErrorCode` did renumber, though: `#[error_code]` assigns codes sequentially from 6000, so dropping the battle variants shifted every code after them. -`commit_battle` also charges `GlobalState.battle_fee_lamports` now, mirroring the EVM battle fee above (funds the settle keeper's own `settle_battle` submission; escrowed in `BattleRequest.battle_fee`, refunded by `cancel_battle`). Owner-tunable via `set_battle_fee_lamports`. The already-deployed devnet `GlobalState` account predates this field — it lives in what was previously reserved padding (`GlobalState::SPACE` is unchanged), so after a program upgrade it reads back as `0` until an admin explicitly calls `set_battle_fee_lamports` once; `initialize` only sets the real default for a genuinely fresh account. `BattleRequest::SPACE` did grow (new `battle_fee` field, no reserved padding on request accounts — mirrors how `BreedRequest.stud_fee`/`other_owner` were added previously), so any already-committed-but-unsettled `BattleRequest` at upgrade time needs to be settled or let expire (`cancel_battle`) before deploying, or it will fail to deserialize. **Rust/Anchor changes here were written without a local toolchain (no `cargo`/`anchor`/`rustc`/`solana` on PATH in this environment) — run `anchor build` / `anchor test` before trusting them.** ### Entropy / randomness -All three async EVM flows — battle (`requestBattle`), breed (`requestCreateFromDNA`), and -starter mint (`requestMintStarter`) — use Pyth Entropy v2 (`requestV2` → `entropyCallback` +Both remaining async EVM flows — breed (`requestCreateFromDNA`) and starter mint +(`requestMintStarter`) — use Pyth Entropy v2 (`requestV2` → `entropyCallback` stores the revealed word only → a separate permissionless settle call runs the actual logic). This has already fully replaced the Chainlink VRF and predictable `Utils.randMod` (keccak-of-timestamp) schemes that older revisions of `contracts/plan-contract-upgrade.md` @@ -143,7 +148,8 @@ Locally there's no live Pyth network, so Hardhat tests deploy `MockEntropy` and (`entropy.mockReveal(...)`); the settle keeper's `KEEPER_MOCK_REVEAL` flag (see above) does the same thing for a running local node, replacing the old `vrf-fulfill-watcher.ts` script for this flow (see the stale-script note in Commands above). -Solana breeding uses Switchboard On-Demand (commit then settle), also async. +Solana breeding and minting use Switchboard On-Demand (commit then settle), also async. +Battles use neither: they are seeded from a committed drand round by the backend (§E). ### Known v1 contract limitations (design context, not regressions to "fix") `contracts/plan-contract-upgrade.md` documents intentional v1 gaps that v2 is designed around: no battle authorization (anyone can call `battle()`/`attack()` on anyone's pets), an EVM `changeDna` cheat that lets a level-20 pet set arbitrary DNA, and a Solana `create_starter_pet` that accepts client-supplied dna/rarity. v2 plan: EVM moves to UUPS proxies (`PetCoreProxy` + `GameLogicProxy`, with `CombatSimV1` deployed as a separate contract to stay under the 24KB bytecode ceiling); Solana adds versioned/reserved-space accounts and migrates pets to Metaplex Core NFTs. This is a plan doc; check current contract source before assuming any of it is implemented. diff --git a/contracts/ethereum/subgraph/schema.graphql b/contracts/ethereum/subgraph/schema.graphql index 46371686..849db762 100644 --- a/contracts/ethereum/subgraph/schema.graphql +++ b/contracts/ethereum/subgraph/schema.graphql @@ -17,10 +17,20 @@ type Pet @entity(immutable: false) { level: Int! rarity: Int! + + """ + Lifetime on-chain record. Frozen at whatever the retired on-chain battle path left + behind: nothing writes these any more, and backend battle records live in + `pet_battle_progress` instead. Kept because indexer-go's selection set reads them. + """ winCount: Int! lossCount: Int! - "Unix seconds the pet is next BATTLE-ready (Pet.readyTime)." + """ + Unix seconds the pet is next battle-ready (Pet.readyTime). Only breeding writes it + now — a newborn is barred from fighting until its cooldown expires — but the backend + still honours it before accepting a battle intent. + """ readyAt: BigInt! "Block timestamp of the last update (the per-pet version indexer-go resumes from)." @@ -46,47 +56,6 @@ type Pet @entity(immutable: false) { trainReadyAt: BigInt! } -# One row per settled battle. On EVM the attacker/defender come from -# BattleRandomnessRequested and the result from BattleResolved; the two are -# joined by requestId (see BattleRequest below). Consumed by indexer-go's -# battle polling into battle_history and the StreamLiveBattles gRPC feed. -type Battle @entity(immutable: true) { - "txHash-logIndex of the BattleResolved log — matches battle_history.battle_id." - id: ID! - - "Attacker pet id as a string (BattleRandomnessRequested.petId1), matching Pet.id." - attacker: String! - "Defender pet id as a string (BattleRandomnessRequested.petId2)." - defender: String! - - "Absolute winner pet id — head-to-head stays correct across role swaps." - winnerPetId: String! - "Absolute loser pet id." - loserPetId: String! - - # ─── v2 round-based combat sim outputs (plan §3.3) ───────────────────────── - "uint256 VRF seed; indexer-go normalizes it to 0x-hex for cross-chain replay." - seed: BigInt! - rounds: Int! - winnerHpRemaining: Int! - xpWin: Int! - xpLoss: Int! - - "Block timestamp of the settle (unix seconds) — the per-chain version cursor." - foughtAt: BigInt! -} - -# Internal join record: BattleRandomnessRequested carries attacker/defender -# keyed by requestId, but BattleResolved (which carries the outcome) only -# carries winner/loser. We persist the request so the resolve handler can -# recover attacker/defender. Not queried by indexer-go. -type BattleRequest @entity(immutable: false) { - "requestId as a string." - id: ID! - petId1: BigInt! - petId2: BigInt! -} - # Internal join record for breeding: BreedSettled carries only the childId, so # we persist the request's two parents to refresh their breedCount/cooldown at # settle time. Not queried by indexer-go. diff --git a/contracts/ethereum/subgraph/src/mapping.ts b/contracts/ethereum/subgraph/src/mapping.ts index 12decf28..1aa060ac 100644 --- a/contracts/ethereum/subgraph/src/mapping.ts +++ b/contracts/ethereum/subgraph/src/mapping.ts @@ -1,7 +1,10 @@ // Event handlers for the v2 stack. Pet state is always re-read from chain via // refreshPet (src/pet.ts) rather than accumulated from event params, so the -// Pet entity is a faithful snapshot. Battle/breed use a stored request record -// to recover the participants that the settle event omits. +// Pet entity is a faithful snapshot. Breeding uses a stored request record to +// recover the parents that BreedSettled omits. +// +// No battle handlers: battles are resolved by the backend and published as signed +// receipts (docs/plan-backend-battle-architecture.md), never as chain events. import { NewPet, @@ -12,13 +15,11 @@ import { MarriageDissolved, } from "../generated/PetCore/PetCore"; import { - BattleRandomnessRequested, - BattleResolved, BreedRandomnessRequested, BreedSettled, Trained, } from "../generated/GameLogic/GameLogic"; -import { Battle, BattleRequest, BreedRequest } from "../generated/schema"; +import { BreedRequest } from "../generated/schema"; import { refreshPet } from "./pet"; // ─── PetCore ─────────────────────────────────────────────────────────────── @@ -51,40 +52,6 @@ export function handleMarriageDissolved(event: MarriageDissolved): void { // ─── GameLogic ───────────────────────────────────────────────────────────── -// BattleRandomnessRequested carries attacker (petId1) / defender (petId2) keyed -// by requestId; persist it so the resolve handler can recover the roles. -export function handleBattleRequested(event: BattleRandomnessRequested): void { - const req = new BattleRequest(event.params.requestId.toString()); - req.petId1 = event.params.petId1; - req.petId2 = event.params.petId2; - req.save(); -} - -export function handleBattleResolved(event: BattleResolved): void { - const req = BattleRequest.load(event.params.requestId.toString()); - - const id = - event.transaction.hash.toHexString() + "-" + event.logIndex.toString(); - const battle = new Battle(id); - // attacker/defender come from the stored request; on the off chance it is - // missing (request before startBlock), fall back to winner/loser. - battle.attacker = req != null ? req.petId1.toString() : event.params.winnerId.toString(); - battle.defender = req != null ? req.petId2.toString() : event.params.loserId.toString(); - battle.winnerPetId = event.params.winnerId.toString(); - battle.loserPetId = event.params.loserId.toString(); - battle.seed = event.params.randomness; - battle.rounds = event.params.rounds; - battle.winnerHpRemaining = event.params.winnerHpRemaining; - battle.xpWin = event.params.xpWin.toI32(); - battle.xpLoss = event.params.xpLoss.toI32(); - battle.foughtAt = event.block.timestamp; - battle.save(); - - // Both pets changed (xp / level / win-loss / cooldown). - refreshPet(event.params.winnerId, event.block.timestamp); - refreshPet(event.params.loserId, event.block.timestamp); -} - export function handleBreedRequested(event: BreedRandomnessRequested): void { const req = new BreedRequest(event.params.requestId.toString()); req.petId1 = event.params.petId1; diff --git a/contracts/ethereum/subgraph/subgraph.template.yaml b/contracts/ethereum/subgraph/subgraph.template.yaml index 4445826d..f192964b 100644 --- a/contracts/ethereum/subgraph/subgraph.template.yaml +++ b/contracts/ethereum/subgraph/subgraph.template.yaml @@ -1,6 +1,6 @@ # Template — `scripts/prepare-subgraph.mjs` fills {{...}} and writes subgraph.yaml. # Two v2 data sources: the PetCore proxy (pet lifecycle + marriage) and the -# GameLogic proxy (battle/breed/train). Both share src/mapping.ts and both +# GameLogic proxy (breed/mint/train). Both share src/mapping.ts and both # carry the PetCore ABI so handlers can read full pet state via getPet(). specVersion: 1.0.0 schema: @@ -49,8 +49,6 @@ dataSources: language: wasm/assemblyscript entities: - Pet - - Battle - - BattleRequest - BreedRequest abis: - name: GameLogic @@ -58,10 +56,6 @@ dataSources: - name: PetCore file: ./abis/PetCore.json eventHandlers: - - event: BattleRandomnessRequested(indexed address,indexed uint256,uint256,uint256) - handler: handleBattleRequested - - event: BattleResolved(indexed uint256,indexed uint256,indexed uint256,uint256,bool,uint8,uint16,uint32,uint32) - handler: handleBattleResolved - event: BreedRandomnessRequested(indexed address,indexed uint256,uint256,uint256) handler: handleBreedRequested - event: BreedSettled(indexed address,indexed uint256,indexed uint256,address) diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/errors.rs b/contracts/solana/cryptopets/programs/cryptopets/src/errors.rs index 2f0490e0..8cad1195 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/errors.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/errors.rs @@ -1,5 +1,8 @@ use anchor_lang::prelude::*; +/// Note: `#[error_code]` numbers these sequentially from 6000, so removing a variant +/// renumbers every one after it. The battle-request variants were dropped when the +/// on-chain battle path was retired (§L Phase 6), which shifted the codes below them. #[error_code] pub enum ErrorCode { #[msg("Pet name exceeds max length")] @@ -10,8 +13,6 @@ pub enum ErrorCode { Paused, #[msg("Pet is on cooldown")] PetNotReady, - #[msg("Cannot battle the same pet")] - CannotBattleSelf, #[msg("Cannot breed a pet with itself")] CannotBreedSelf, #[msg("Arithmetic overflow")] @@ -22,10 +23,6 @@ pub enum ErrorCode { BreedRequestAlreadyPending, #[msg("No pending breed request for this wallet")] BreedRequestNotFound, - #[msg("Battle request already pending for this wallet")] - BattleRequestAlreadyPending, - #[msg("No pending battle request for this wallet")] - BattleRequestNotFound, #[msg("Invalid Switchboard randomness account")] InvalidRandomnessAccount, #[msg("Switchboard randomness has expired")] @@ -44,16 +41,10 @@ pub enum ErrorCode { InvalidRandomnessExpirySlots, #[msg("Switchboard randomness has not yet expired")] RandomnessNotExpired, - #[msg("Defender pet is not open to challenges")] - DefenderNotOpenToChallenges, #[msg("Max level must be greater than zero")] InvalidMaxLevel, #[msg("Pet has already reached the max level")] MaxLevelReached, - #[msg("Battle participants must have different owners")] - CannotBattleSameOwner, - #[msg("Level gap between pets exceeds the allowed band")] - LevelGapTooLarge, #[msg("Generation cap must be greater than zero")] InvalidGenerationCap, #[msg("Breed cooldown base exceeds the maximum allowed")] @@ -78,8 +69,6 @@ pub enum ErrorCode { PetNotTrainReady, #[msg("Breed fee exceeds the maximum allowed")] InvalidBreedFee, - #[msg("Battle fee exceeds the maximum allowed")] - InvalidBattleFee, #[msg("Stud fee exceeds the maximum allowed")] InvalidStudFee, #[msg("Marriage cooldown exceeds the maximum allowed")] diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/game/mod.rs b/contracts/solana/cryptopets/programs/cryptopets/src/game/mod.rs index 7c1c1233..2f8cb9a2 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/game/mod.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/game/mod.rs @@ -11,6 +11,13 @@ //! //! Cross-chain golden-vector parity with the EVM contracts is required (plan §7); //! do not modify the algorithms without updating both sides and the test vectors. +//! +//! `battle_sim` and `xp` have no caller left in this program: the instructions that +//! used them were retired with the on-chain battle path (§L Phase 6). They stay, frozen +//! and untouched, because every battle this program settled is a permanent on-chain +//! record that has to keep replaying — and their golden-vector tests are what proves +//! `contracts/test-vectors/{battle,xp}.json` still describe what really settled here. +//! Deleting them would quietly remove that proof. See AGENTS.md's non-negotiables. pub mod battle_sim; pub mod breeding; diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/config.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/config.rs index ded8dbea..d5492202 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/config.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/config.rs @@ -4,7 +4,7 @@ use crate::{ errors::ErrorCode, state::{ GlobalState, MAX_BASE_MINT_FEE_LAMPORTS, MAX_BATTLE_COOLDOWN_SECONDS, - MAX_BATTLE_FEE_LAMPORTS, MAX_BREED_COOLDOWN_BASE_SECONDS, MAX_BREED_FEE_LAMPORTS, + MAX_BREED_COOLDOWN_BASE_SECONDS, MAX_BREED_FEE_LAMPORTS, MAX_GENERATION_CAP, MAX_LEVEL_UP_FEE_LAMPORTS, MAX_MARRIAGE_COOLDOWN_SECONDS, MAX_NEWBORN_COOLDOWN_SECONDS, MAX_PROPOSAL_TTL_SECONDS, MAX_RANDOMNESS_EXPIRY_SLOTS, MAX_STUD_FEE_LAMPORTS, MAX_TRAIN_COOLDOWN_SECONDS, MAX_TRAIN_FEE_LAMPORTS, MAX_TRAIN_XP, @@ -135,15 +135,6 @@ pub fn set_breed_fee_lamports(ctx: Context, value: u64) -> Result<()> Ok(()) } -/// Mirrors EVM `GameConfig.setBattleFee`: fee charged by `commit_battle`, funding the -/// settle keeper's `settle_battle` transaction. -pub fn set_battle_fee_lamports(ctx: Context, value: u64) -> Result<()> { - require!(value <= MAX_BATTLE_FEE_LAMPORTS, ErrorCode::InvalidBattleFee); - ctx.accounts.global_state.battle_fee_lamports = value; - emit!(BattleFeeUpdated { value }); - Ok(()) -} - /// Mirrors EVM `GameConfig.setStudFee` (plan §4.4): fee paid by the proposer's spouse's /// owner to the proposer when breeding across a marriage. pub fn set_stud_fee_lamports(ctx: Context, value: u64) -> Result<()> { @@ -248,11 +239,6 @@ pub struct BreedFeeUpdated { pub value: u64, } -#[event] -pub struct BattleFeeUpdated { - pub value: u64, -} - #[event] pub struct StudFeeUpdated { pub value: u64, diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/initialize.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/initialize.rs index 6ef15e7f..1862003e 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/initialize.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/initialize.rs @@ -2,8 +2,7 @@ use crate::{ errors::ErrorCode, state::{ GlobalState, PetAccount, CURRENT_ACCOUNT_VERSION, DEFAULT_BASE_MINT_FEE_LAMPORTS, - DEFAULT_BATTLE_COOLDOWN_SECONDS, DEFAULT_BATTLE_FEE_LAMPORTS, - DEFAULT_BREED_COOLDOWN_BASE_SECONDS, + DEFAULT_BATTLE_COOLDOWN_SECONDS, DEFAULT_BREED_COOLDOWN_BASE_SECONDS, DEFAULT_BREED_FEE_LAMPORTS, DEFAULT_GENERATION_CAP, DEFAULT_LEVEL_BAND_WIDTH, DEFAULT_MARRIAGE_COOLDOWN_SECONDS, DEFAULT_MAX_LEVEL, DEFAULT_NEWBORN_COOLDOWN_SECONDS, DEFAULT_POOL_SIZE, DEFAULT_PROPOSAL_TTL_SECONDS, DEFAULT_RANDOMNESS_EXPIRY_SLOTS, @@ -32,7 +31,6 @@ pub fn handler(ctx: Context, level_up_fee_lamports: u64) -> Result<( global_state.train_cooldown_seconds = DEFAULT_TRAIN_COOLDOWN_SECONDS; global_state.train_xp = DEFAULT_TRAIN_XP; global_state.breed_fee_lamports = DEFAULT_BREED_FEE_LAMPORTS; - global_state.battle_fee_lamports = DEFAULT_BATTLE_FEE_LAMPORTS; global_state.stud_fee_lamports = DEFAULT_STUD_FEE_LAMPORTS; global_state.marriage_cooldown_seconds = DEFAULT_MARRIAGE_COOLDOWN_SECONDS; global_state.proposal_ttl_seconds = DEFAULT_PROPOSAL_TTL_SECONDS; diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/cancel_battle.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/cancel_battle.rs deleted file mode 100644 index 3f8437c1..00000000 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/cancel_battle.rs +++ /dev/null @@ -1,82 +0,0 @@ -use anchor_lang::{ - prelude::*, - system_program::{transfer, Transfer}, -}; - -use crate::{ - errors::ErrorCode, - state::{BattleRequest, GlobalState, FEE_VAULT_SEED}, -}; - -/// Permissionless cleanup (§6 Solana #2): once the committed Switchboard randomness has -/// gone unrevealed for `global_state.randomness_expiry_slots`, anyone may close the stuck -/// `BattleRequest` and refund its rent to the attacker who paid for it. -/// -/// Also refunds the escrowed battle fee (mirrors EVM `cancelBattle`'s battleFee refund): -/// no `settle_battle` tx — and therefore no keeper cost — is ever sent for a cancelled -/// request, so there's nothing for the fee to have funded. -pub fn handler(ctx: Context) -> Result<()> { - let clock = Clock::get()?; - let expiry_slot = ctx - .accounts - .battle_request - .commit_slot - .checked_add(ctx.accounts.global_state.randomness_expiry_slots) - .ok_or(ErrorCode::ArithmeticOverflow)?; - require!(clock.slot > expiry_slot, ErrorCode::RandomnessNotExpired); - - let battle_fee = ctx.accounts.battle_request.battle_fee; - if battle_fee > 0 { - let signer_seeds: &[&[&[u8]]] = &[&[FEE_VAULT_SEED, &[ctx.bumps.fee_vault]]]; - let cpi_ctx = CpiContext::new_with_signer( - ctx.accounts.system_program.to_account_info(), - Transfer { - from: ctx.accounts.fee_vault.to_account_info(), - to: ctx.accounts.attacker_owner.to_account_info(), - }, - signer_seeds, - ); - transfer(cpi_ctx, battle_fee)?; - } - - emit!(BattleCancelledEvent { - attacker_owner: ctx.accounts.battle_request.attacker_owner, - defender_owner: ctx.accounts.battle_request.defender_owner, - attacker_pet_id: ctx.accounts.battle_request.attacker_pet_id, - defender_pet_id: ctx.accounts.battle_request.defender_pet_id, - }); - - Ok(()) -} - -#[event] -pub struct BattleCancelledEvent { - pub attacker_owner: Pubkey, - pub defender_owner: Pubkey, - pub attacker_pet_id: u32, - pub defender_pet_id: u32, -} - -#[derive(Accounts)] -pub struct CancelBattle<'info> { - #[account(seeds = [GlobalState::SEED], bump = global_state.bump)] - pub global_state: Account<'info, GlobalState>, - - /// CHECK: rent refund destination for the closed `battle_request`; tied to it via PDA seeds. - #[account(mut)] - pub attacker_owner: UncheckedAccount<'info>, - - #[account( - mut, - close = attacker_owner, - seeds = [BattleRequest::SEED, attacker_owner.key().as_ref()], - bump = battle_request.bump, - constraint = battle_request.attacker_owner == attacker_owner.key() @ ErrorCode::Unauthorized, - )] - pub battle_request: Account<'info, BattleRequest>, - - #[account(mut, seeds = [FEE_VAULT_SEED], bump)] - pub fee_vault: SystemAccount<'info>, - - pub system_program: Program<'info, System>, -} diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/commit_battle.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/commit_battle.rs deleted file mode 100644 index ea3b29c9..00000000 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/commit_battle.rs +++ /dev/null @@ -1,165 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::{ - errors::ErrorCode, - state::{BattleRequest, GlobalState, PetAccount, FEE_VAULT_SEED}, - utils::metadata::core_asset_owner, - utils::randomness::assert_randomness_committed, -}; - -pub fn handler(ctx: Context, randomness_account: Pubkey) -> Result<()> { - require!(!ctx.accounts.global_state.paused, ErrorCode::Paused); - require!( - ctx.accounts.attacker_pet.key() != ctx.accounts.defender_pet.key(), - ErrorCode::CannotBattleSelf - ); - require!( - ctx.accounts.attacker_owner.key() != ctx.accounts.defender_owner.key(), - ErrorCode::CannotBattleSameOwner - ); - - let now = Clock::get()?.unix_timestamp; - - { - require_keys_eq!( - core_asset_owner(&ctx.accounts.attacker_asset.to_account_info())?, - ctx.accounts.attacker_owner.key(), - ErrorCode::Unauthorized - ); - require_keys_eq!( - core_asset_owner(&ctx.accounts.defender_asset.to_account_info())?, - ctx.accounts.defender_owner.key(), - ErrorCode::Unauthorized - ); - - let attacker_pet = &ctx.accounts.attacker_pet; - let defender_pet = &ctx.accounts.defender_pet; - require!(attacker_pet.is_ready(now), ErrorCode::PetNotReady); - require!(defender_pet.is_ready(now), ErrorCode::PetNotReady); - require!( - defender_pet.open_to_challenges, - ErrorCode::DefenderNotOpenToChallenges - ); - - let gap = attacker_pet.level.abs_diff(defender_pet.level); - require!( - gap <= ctx.accounts.global_state.level_band_width, - ErrorCode::LevelGapTooLarge - ); - } - - let commit_slot = assert_randomness_committed( - &ctx.accounts.randomness_account_data.to_account_info(), - randomness_account, - )?; - - // Battle fee (mirrors EVM `GameConfig.battleFee` / `requestBattle`): funds the settle - // keeper's own `settle_battle` transaction, which it previously sent entirely - // unfunded. Escrowed here, refunded by `cancel_battle` if the request expires unsettled. - let battle_fee = ctx.accounts.global_state.battle_fee_lamports; - let cpi_ctx = CpiContext::new( - ctx.accounts.system_program.to_account_info(), - anchor_lang::system_program::Transfer { - from: ctx.accounts.attacker_owner.to_account_info(), - to: ctx.accounts.fee_vault.to_account_info(), - }, - ); - anchor_lang::system_program::transfer(cpi_ctx, battle_fee)?; - - let battle_request = &mut ctx.accounts.battle_request; - battle_request.attacker_owner = ctx.accounts.attacker_owner.key(); - battle_request.defender_owner = ctx.accounts.defender_owner.key(); - battle_request.attacker_pet_id = ctx.accounts.attacker_pet.id; - battle_request.defender_pet_id = ctx.accounts.defender_pet.id; - battle_request.randomness_account = randomness_account; - battle_request.commit_slot = commit_slot; - battle_request.bump = ctx.bumps.battle_request; - // Sim-input snapshot (plan-realtime-battle-solana.md Workstream S1): freeze both pets' - // stats now so settle_battle can't be rerolled by a level_up (or any other stat change) - // committed between here and settle. - battle_request.attacker_dna = ctx.accounts.attacker_pet.dna; - battle_request.defender_dna = ctx.accounts.defender_pet.dna; - battle_request.attacker_rarity = ctx.accounts.attacker_pet.rarity; - battle_request.defender_rarity = ctx.accounts.defender_pet.rarity; - battle_request.attacker_level = ctx.accounts.attacker_pet.level; - battle_request.defender_level = ctx.accounts.defender_pet.level; - battle_request.attacker_species_id = ctx.accounts.attacker_pet.species_id; - battle_request.defender_species_id = ctx.accounts.defender_pet.species_id; - battle_request.battle_fee = battle_fee; - - let cooldown_seconds = ctx.accounts.global_state.battle_cooldown_seconds; - ctx.accounts.attacker_pet.trigger_cooldown(now, cooldown_seconds); - ctx.accounts.defender_pet.trigger_cooldown(now, cooldown_seconds); - - emit!(BattleCommittedEvent { - attacker_owner: battle_request.attacker_owner, - defender_owner: battle_request.defender_owner, - attacker_pet_id: battle_request.attacker_pet_id, - defender_pet_id: battle_request.defender_pet_id, - randomness_account, - }); - - Ok(()) -} - -#[event] -pub struct BattleCommittedEvent { - pub attacker_owner: Pubkey, - pub defender_owner: Pubkey, - pub attacker_pet_id: u32, - pub defender_pet_id: u32, - pub randomness_account: Pubkey, -} - -#[derive(Accounts)] -pub struct CommitBattle<'info> { - #[account(seeds = [GlobalState::SEED], bump = global_state.bump)] - pub global_state: Account<'info, GlobalState>, - - #[account(mut)] - pub attacker_owner: Signer<'info>, - - /// CHECK: attacker pet's Metaplex Core asset account; PDA seed for `attacker_pet` - /// and source of truth for ownership (plan §2.3/v2.1 Phase A). - #[account(owner = mpl_core::ID)] - pub attacker_asset: UncheckedAccount<'info>, - - #[account( - mut, - seeds = [PetAccount::SEED, attacker_asset.key().as_ref()], - bump = attacker_pet.bump, - )] - pub attacker_pet: Account<'info, PetAccount>, - - /// CHECK: defender wallet pubkey, asserted against `defender_asset`'s current owner. - pub defender_owner: UncheckedAccount<'info>, - - /// CHECK: defender pet's Metaplex Core asset account; PDA seed for `defender_pet` - /// and source of truth for ownership (plan §2.3/v2.1 Phase A). - #[account(owner = mpl_core::ID)] - pub defender_asset: UncheckedAccount<'info>, - - #[account( - mut, - seeds = [PetAccount::SEED, defender_asset.key().as_ref()], - bump = defender_pet.bump, - )] - pub defender_pet: Account<'info, PetAccount>, - - #[account( - init, - payer = attacker_owner, - seeds = [BattleRequest::SEED, attacker_owner.key().as_ref()], - bump, - space = BattleRequest::SPACE, - )] - pub battle_request: Account<'info, BattleRequest>, - - #[account(mut, seeds = [FEE_VAULT_SEED], bump)] - pub fee_vault: SystemAccount<'info>, - - /// CHECK: parsed as Switchboard `RandomnessAccountData` in the handler. - pub randomness_account_data: UncheckedAccount<'info>, - - pub system_program: Program<'info, System>, -} diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/mod.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/mod.rs index d3804e63..e9137a75 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/mod.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/mod.rs @@ -1,13 +1,11 @@ -//! Battle flow (plan §3): commit randomness against a consenting defender, settle by -//! running the deterministic combat sim and awarding XP, cancel an expired request, -//! and the defender-consent `open_to_challenges` toggle (plan §3.5). +//! The defender-consent `open_to_challenges` toggle (plan §3.5). +//! +//! The commit/settle/cancel battle instructions that used to live here are gone: battles +//! are resolved by the backend against a committed drand round and published as signed +//! receipts (docs/plan-backend-battle-architecture.md §L Phase 6), never on chain. The +//! combat simulator itself (`game::battle_sim`) stays, frozen, so every battle this +//! program did settle remains replayable. -pub mod cancel_battle; -pub mod commit_battle; pub mod set_open_to_challenges; -pub mod settle_battle; -pub use cancel_battle::*; -pub use commit_battle::*; pub use set_open_to_challenges::*; -pub use settle_battle::*; diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/set_open_to_challenges.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/set_open_to_challenges.rs index 8621f902..04c03d00 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/set_open_to_challenges.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/set_open_to_challenges.rs @@ -2,8 +2,12 @@ use anchor_lang::prelude::*; use crate::{errors::ErrorCode, utils::metadata::core_asset_owner, state::PetAccount}; -/// Interim defender-consent fix (§3.5/§6 Solana #3): lets a pet's owner opt their pet -/// out of (or back into) being targeted as a defender in `commit_battle`. +/// Defender consent (§3.5/§6 Solana #3): lets a pet's owner opt their pet out of (or +/// back into) being targeted as a defender. +/// +/// The on-chain battle path that enforced this flag is retired (§L Phase 6), so the +/// program itself no longer reads it. The flag stays as the owner's stated preference, +/// published on the pet account for the backend matchmaker to honour. pub fn handler(ctx: Context, value: bool) -> Result<()> { require_keys_eq!( core_asset_owner(&ctx.accounts.pet_asset.to_account_info())?, diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/settle_battle.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/settle_battle.rs deleted file mode 100644 index 997f408f..00000000 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/settle_battle.rs +++ /dev/null @@ -1,232 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::{ - errors::ErrorCode, - game::{battle_sim::{self, SkillConfig}, xp::calc_xp}, - utils::metadata::core_asset_owner, - utils::randomness::read_revealed_randomness, - state::{BattleRequest, GlobalState, PetAccount}, -}; - -pub fn handler(ctx: Context) -> Result<()> { - require!(!ctx.accounts.global_state.paused, ErrorCode::Paused); - - let battle_request = &ctx.accounts.battle_request; - require_keys_eq!( - battle_request.attacker_owner, - ctx.accounts.attacker_owner.key(), - ErrorCode::Unauthorized - ); - require_keys_eq!( - battle_request.defender_owner, - ctx.accounts.defender_owner.key(), - ErrorCode::Unauthorized - ); - require_keys_eq!( - core_asset_owner(&ctx.accounts.attacker_asset.to_account_info())?, - ctx.accounts.attacker_owner.key(), - ErrorCode::Unauthorized - ); - require_keys_eq!( - core_asset_owner(&ctx.accounts.defender_asset.to_account_info())?, - ctx.accounts.defender_owner.key(), - ErrorCode::Unauthorized - ); - require!( - ctx.accounts.attacker_pet.id == battle_request.attacker_pet_id, - ErrorCode::Unauthorized - ); - require!( - ctx.accounts.defender_pet.id == battle_request.defender_pet_id, - ErrorCode::Unauthorized - ); - - let seed = read_revealed_randomness( - &ctx.accounts.randomness_account_data.to_account_info(), - battle_request.randomness_account, - battle_request.commit_slot, - )?; - - let max_level = ctx.accounts.global_state.max_level; - let attacker_pet_id = ctx.accounts.attacker_pet.id; - let defender_pet_id = ctx.accounts.defender_pet.id; - - // Skill archetype (plan §3.7/v2.1 Phase B, mirrors EVM `GameLogicV1.fight`'s - // `uint8(p.speciesId % 8)`): each pet's passive skill is derived from its species id. - // Read from the commit-time snapshot (plan-realtime-battle-solana.md Workstream S1), - // not the live pet, so it can't change between commit and settle. - let attacker_skill = (battle_request.attacker_species_id % 8) as u8; - let defender_skill = (battle_request.defender_species_id % 8) as u8; - - // Sim inputs come from the frozen snapshot, not the live PetAccounts (Workstream S1): - // a level_up (or any other stat change) committed between commit_battle and - // settle_battle must not be able to change an already-committed battle's outcome. - let sim = battle_sim::simulate( - battle_request.attacker_dna, - battle_request.attacker_rarity, - battle_request.attacker_level, - attacker_skill, - battle_request.defender_dna, - battle_request.defender_rarity, - battle_request.defender_level, - defender_skill, - seed, - &SkillConfig::default(), - ); - let snapshot_attacker_level = battle_request.attacker_level; - let snapshot_defender_level = battle_request.defender_level; - - let attacker_pet = &mut ctx.accounts.attacker_pet; - let defender_pet = &mut ctx.accounts.defender_pet; - - // XP formula (plan §3.4): xpMult = clamp(100 + 10*(oppLevel - myLevel), 0, 200). - // Winner +100 XP x mult / 100. Loser +25 XP x mult / 100. - // Same-opponent decay: consecutive battles vs the same foe halve XP each time. - let attacker_decay = attacker_pet.record_battle_opponent(defender_pet_id); - let defender_decay = defender_pet.record_battle_opponent(attacker_pet_id); - - // XP also uses the snapshot levels (Workstream S1), so the sim and the XP calc agree - // on the same committed inputs rather than the sim using frozen levels while XP uses - // whatever the live levels happen to be by settle time. - let (winner_level, loser_level, winner_decay, loser_decay) = if sim.first_wins { - ( - snapshot_attacker_level, - snapshot_defender_level, - attacker_decay, - defender_decay, - ) - } else { - ( - snapshot_defender_level, - snapshot_attacker_level, - defender_decay, - attacker_decay, - ) - }; - // Clamp the shift to u32's bit width: `same_opponent_streak` (u8) can reach 255, and a - // shift >= 32 panics with `overflow-checks = true`. Values this large already yield 0 - // (base XP <= 200 < 2^8), matching Solidity's "shift >= width => 0" semantics. - let xp_win = calc_xp(100, winner_level, loser_level) >> winner_decay.min(31); - let xp_loss = calc_xp(25, loser_level, winner_level) >> loser_decay.min(31); - - let (winner_pet_id, loser_pet_id) = if sim.first_wins { - (attacker_pet_id, defender_pet_id) - } else { - (defender_pet_id, attacker_pet_id) - }; - - if sim.first_wins { - attacker_pet.win_count = attacker_pet - .win_count - .checked_add(1) - .ok_or(ErrorCode::ArithmeticOverflow)?; - defender_pet.loss_count = defender_pet - .loss_count - .checked_add(1) - .ok_or(ErrorCode::ArithmeticOverflow)?; - if xp_win > 0 { - attacker_pet.add_xp(xp_win, max_level)?; - } - if xp_loss > 0 { - defender_pet.add_xp(xp_loss, max_level)?; - } - } else { - defender_pet.win_count = defender_pet - .win_count - .checked_add(1) - .ok_or(ErrorCode::ArithmeticOverflow)?; - attacker_pet.loss_count = attacker_pet - .loss_count - .checked_add(1) - .ok_or(ErrorCode::ArithmeticOverflow)?; - if xp_win > 0 { - defender_pet.add_xp(xp_win, max_level)?; - } - if xp_loss > 0 { - attacker_pet.add_xp(xp_loss, max_level)?; - } - } - - emit!(BattleResolved { - attacker_pet_id, - defender_pet_id, - winner_pet_id, - loser_pet_id, - seed, - first_wins: sim.first_wins, - rounds: sim.rounds, - winner_hp_remaining: sim.winner_hp_remaining, - xp_win, - xp_loss, - }); - - Ok(()) -} - -#[event] -pub struct BattleResolved { - pub attacker_pet_id: u32, - pub defender_pet_id: u32, - pub winner_pet_id: u32, - pub loser_pet_id: u32, - pub seed: [u8; 32], - pub first_wins: bool, - pub rounds: u8, - pub winner_hp_remaining: u16, - pub xp_win: u32, - pub xp_loss: u32, -} - -#[derive(Accounts)] -pub struct SettleBattle<'info> { - #[account(seeds = [GlobalState::SEED], bump = global_state.bump)] - pub global_state: Account<'info, GlobalState>, - - /// CHECK: rent-refund destination for the closed `battle_request`; validated against - /// `battle_request.attacker_owner` below. Permissionless (plan-realtime-battle-solana.md - /// Workstream S2, mirrors this program's own `cancel_battle` and EVM's `settleBattle`): - /// settle only recomputes a deterministic sim from already-committed state, so it needs - /// no signature from the attacker — a backend keeper (or anyone else) may submit it. - #[account(mut)] - pub attacker_owner: UncheckedAccount<'info>, - - /// CHECK: attacker pet's Metaplex Core asset account; PDA seed for `attacker_pet` - /// and source of truth for ownership (plan §2.3/v2.1 Phase A). - #[account(owner = mpl_core::ID)] - pub attacker_asset: UncheckedAccount<'info>, - - #[account( - mut, - seeds = [PetAccount::SEED, attacker_asset.key().as_ref()], - bump = attacker_pet.bump, - )] - pub attacker_pet: Account<'info, PetAccount>, - - /// CHECK: must match `battle_request.defender_owner`. - pub defender_owner: UncheckedAccount<'info>, - - /// CHECK: defender pet's Metaplex Core asset account; PDA seed for `defender_pet` - /// and source of truth for ownership (plan §2.3/v2.1 Phase A). - #[account(owner = mpl_core::ID)] - pub defender_asset: UncheckedAccount<'info>, - - #[account( - mut, - seeds = [PetAccount::SEED, defender_asset.key().as_ref()], - bump = defender_pet.bump, - )] - pub defender_pet: Account<'info, PetAccount>, - - #[account( - mut, - close = attacker_owner, - seeds = [BattleRequest::SEED, attacker_owner.key().as_ref()], - bump = battle_request.bump, - constraint = battle_request.attacker_owner == attacker_owner.key() @ ErrorCode::Unauthorized, - )] - pub battle_request: Account<'info, BattleRequest>, - - /// CHECK: parsed as Switchboard `RandomnessAccountData` in the handler. - pub randomness_account_data: UncheckedAccount<'info>, -} - diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/cancel_mint.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/cancel_mint.rs index 1b2698b2..e54770cc 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/cancel_mint.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/cancel_mint.rs @@ -5,7 +5,7 @@ use crate::{ state::{GlobalState, MintRequest}, }; -/// Permissionless cleanup (plan §4.3, mirrors `cancel_battle`/`cancel_breed`): once the +/// Permissionless cleanup (plan §4.3, mirrors `cancel_breed`): once the /// committed Switchboard randomness has gone unrevealed for /// `global_state.randomness_expiry_slots`, anyone may close the stuck `MintRequest` and /// refund its rent to the owner who paid for it. The mint fee charged at `commit_mint` is diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs b/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs index 61faa999..a382d957 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs @@ -45,17 +45,6 @@ pub mod cryptopets { unpause::handler(ctx) } - pub fn commit_battle( - ctx: Context, - randomness_account: Pubkey, - ) -> Result<()> { - commit_battle::handler(ctx, randomness_account) - } - - pub fn settle_battle(ctx: Context) -> Result<()> { - settle_battle::handler(ctx) - } - pub fn commit_breed( ctx: Context, randomness_account: Pubkey, @@ -88,10 +77,6 @@ pub mod cryptopets { sync_metadata::handler(ctx) } - pub fn cancel_battle(ctx: Context) -> Result<()> { - cancel_battle::handler(ctx) - } - pub fn cancel_breed(ctx: Context) -> Result<()> { cancel_breed::handler(ctx) } @@ -152,10 +137,6 @@ pub mod cryptopets { config::set_breed_fee_lamports(ctx, value) } - pub fn set_battle_fee_lamports(ctx: Context, value: u64) -> Result<()> { - config::set_battle_fee_lamports(ctx, value) - } - pub fn set_stud_fee_lamports(ctx: Context, value: u64) -> Result<()> { config::set_stud_fee_lamports(ctx, value) } diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/state/global.rs b/contracts/solana/cryptopets/programs/cryptopets/src/state/global.rs index 3d8fa141..f660a434 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/state/global.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/state/global.rs @@ -4,9 +4,13 @@ use crate::errors::ErrorCode; // ─── Config defaults (mirrors EVM GameConfig initializers) ──────────────────── +/// Battle-cooldown lockout applied to a pet's `ready_time`. Despite the name, the only +/// writer left is `commit_breed`, which locks both parents out for the pending-breed +/// window; the post-battle cooldown itself is now the backend's +/// (`BATTLE_COOLDOWN_SECONDS`), applied to `pet_battle_progress`. pub const DEFAULT_BATTLE_COOLDOWN_SECONDS: i64 = 5; -/// Slots a committed Switchboard randomness has to be revealed before `cancel_battle` / +/// Slots a committed Switchboard randomness has to be revealed before `cancel_mint` / /// `cancel_breed` may close the stuck request (§5: ~150 slots, ~1 minute). pub const DEFAULT_RANDOMNESS_EXPIRY_SLOTS: u64 = 150; @@ -41,21 +45,6 @@ pub const DEFAULT_POOL_SIZE: u8 = 8; /// `GameConfig.breedFee`). pub const DEFAULT_BREED_FEE_LAMPORTS: u64 = 10_000_000; // 0.01 SOL -/// Fee charged by `commit_battle`, transferred to the fee vault (mirrors EVM -/// `GameConfig.battleFee`). Funds the settle keeper's own `settle_battle` transaction, -/// which it previously sent entirely unfunded. Refunded via `cancel_battle` — no settle -/// tx (and therefore no keeper cost) is ever sent for a cancelled request. Cheaper than -/// [`DEFAULT_BREED_FEE_LAMPORTS`]/[`DEFAULT_BASE_MINT_FEE_LAMPORTS`] since `settle_battle` -/// does no mpl-core mint CPI. Starting estimate; tune via `set_battle_fee_lamports` -/// against observed keeper compute-unit spend. -/// -/// NOTE: the already-deployed devnet `GlobalState` account predates this field — it lives -/// in what was previously `_reserved` padding, so after a program upgrade it reads back as -/// `0` (untouched reserved bytes) until an admin explicitly calls -/// `set_battle_fee_lamports(DEFAULT_BATTLE_FEE_LAMPORTS)` once. `initialize` only sets it -/// for a genuinely fresh `GlobalState`. -pub const DEFAULT_BATTLE_FEE_LAMPORTS: u64 = 5_000_000; // 0.005 SOL - /// Base fee for the gacha mint (plan §4.3, mirrors EVM `GameConfig.baseMintFee`). /// Escalates per wallet as `baseMintFee << min(mint_count, 7)` (up to 128x). pub const DEFAULT_BASE_MINT_FEE_LAMPORTS: u64 = 20_000_000; // 0.02 SOL @@ -92,7 +81,6 @@ pub const MAX_BREED_COOLDOWN_BASE_SECONDS: i64 = BREED_COOLDOWN_CAP_SECONDS; pub const MAX_NEWBORN_COOLDOWN_SECONDS: i64 = 7 * 24 * 60 * 60; pub const MAX_BASE_MINT_FEE_LAMPORTS: u64 = 1_000_000_000; // 1 SOL pub const MAX_BREED_FEE_LAMPORTS: u64 = 1_000_000_000; // 1 SOL -pub const MAX_BATTLE_FEE_LAMPORTS: u64 = 1_000_000_000; // 1 SOL pub const MAX_TRAIN_FEE_LAMPORTS: u64 = 1_000_000_000; // 1 SOL pub const MAX_TRAIN_COOLDOWN_SECONDS: i64 = 7 * 24 * 60 * 60; pub const MAX_TRAIN_XP: u32 = 10_000; @@ -152,11 +140,12 @@ pub struct GlobalState { /// `initialize`. Collection/plugin authority is the `GlobalState` PDA; `settle_mint` /// and `settle_breed` CPI into `mpl-core` to mint pet assets into it. pub collection: Pubkey, - /// Fee charged by `commit_battle`, transferred to the fee vault (mirrors EVM - /// `GameConfig.battleFee`). See [`DEFAULT_BATTLE_FEE_LAMPORTS`] for why the - /// already-deployed devnet account reads `0` here until explicitly set. - pub battle_fee_lamports: u64, - pub _reserved: [u8; 16], + /// Reserved padding for fields added by future upgrades without moving any of the + /// above. It grew back from 16 to 24 when `battle_fee_lamports` was removed with the + /// on-chain battle path (§L Phase 6): the field sat immediately before this, so + /// reclaiming its 8 bytes here keeps every preceding offset and [`GlobalState::SPACE`] + /// exactly as deployed. A live account simply reads the old fee back as padding. + pub _reserved: [u8; 24], } impl GlobalState { @@ -185,8 +174,7 @@ impl GlobalState { + 8 /* marriage_cooldown_seconds */ + 8 /* proposal_ttl_seconds */ + 32 /* collection */ - + 8 /* battle_fee_lamports */ - + 16; /* reserved */ + + 24; /* reserved */ } #[account] diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/state/pet.rs b/contracts/solana/cryptopets/programs/cryptopets/src/state/pet.rs index 5ebf3b3e..8e223c88 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/state/pet.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/state/pet.rs @@ -21,7 +21,8 @@ pub struct PetAccount { pub name: [u8; PetAccount::MAX_NAME_LEN], pub name_len: u8, /// Interim defender-consent fix (§3.5/§6 Solana #3): when false, this pet cannot be - /// targeted as a defender in `commit_battle`. Owner-toggleable, defaults to true. + /// targeted as a defender. Owner-toggleable, defaults to true. Enforced by the + /// backend matchmaker, not by this program (§L Phase 6). pub open_to_challenges: bool, /// XP toward the next level (§3.4); auto-levels via [`PetAccount::add_xp`] at `100 * level`. pub xp: u32, diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/state/requests.rs b/contracts/solana/cryptopets/programs/cryptopets/src/state/requests.rs index db3acd1a..1404ef81 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/state/requests.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/state/requests.rs @@ -59,58 +59,6 @@ impl BreedRequest { } } -// ─── BattleRequest ──────────────────────────────────────────────────────────── - -/// Pending battle after [`commit_battle`]; closed on [`settle_battle`]. -#[account] -pub struct BattleRequest { - pub attacker_owner: Pubkey, - pub defender_owner: Pubkey, - pub attacker_pet_id: u32, - pub defender_pet_id: u32, - pub randomness_account: Pubkey, - pub commit_slot: u64, - pub bump: u8, - /// Sim-input snapshot (plan-realtime-battle-solana.md Workstream S1), captured in - /// `commit_battle` from the pets' state *at commit time*. `settle_battle` simulates - /// from these fields, not from the live `PetAccount`s, so a `level_up` (or any other - /// stat change) between commit and settle cannot change an already-committed battle's - /// outcome — mirrors EVM `GameLogic.sol`'s `PendingBattle` snapshot fields. - pub attacker_dna: u64, - pub defender_dna: u64, - pub attacker_rarity: u8, - pub defender_rarity: u8, - pub attacker_level: u16, - pub defender_level: u16, - pub attacker_species_id: u16, - pub defender_species_id: u16, - /// Battle fee escrowed at `commit_battle` (mirrors EVM `PendingBattle.battleFee`), - /// refunded by `cancel_battle` since no `settle_battle` tx — and therefore no keeper - /// cost — is ever sent for a cancelled request. - pub battle_fee: u64, -} - -impl BattleRequest { - pub const SEED: &'static [u8] = b"battle-request"; - pub const SPACE: usize = 8 /* discriminator */ - + 32 /* attacker_owner */ - + 32 /* defender_owner */ - + 4 /* attacker_pet_id */ - + 4 /* defender_pet_id */ - + 32 /* randomness_account */ - + 8 /* commit_slot */ - + 1 /* bump */ - + 8 /* attacker_dna */ - + 8 /* defender_dna */ - + 1 /* attacker_rarity */ - + 1 /* defender_rarity */ - + 2 /* attacker_level */ - + 2 /* defender_level */ - + 2 /* attacker_species_id */ - + 2 /* defender_species_id */ - + 8; /* battle_fee */ -} - // ─── MintRequest ────────────────────────────────────────────────────────────── /// Pending gacha mint after [`commit_mint`]; closed on [`settle_mint`] or diff --git a/contracts/solana/cryptopets/scripts/devnet-battle-harness.ts b/contracts/solana/cryptopets/scripts/devnet-battle-harness.ts deleted file mode 100644 index 5b7ef53e..00000000 --- a/contracts/solana/cryptopets/scripts/devnet-battle-harness.ts +++ /dev/null @@ -1,431 +0,0 @@ -#!/usr/bin/env tsx -// -// Devnet-only integration check for Workstream S1 (BattleRequest snapshot) and S2 -// (permissionless settle_battle) from ../../../docs/plan-realtime-battle-solana.md. -// -// WHY DEVNET, NOT `anchor test`'S LOCAL VALIDATOR: Switchboard On-Demand has no -// local-validator path at all. `getDefaultQueue()` only resolves real mainnet/devnet -// queue accounts, and `revealIx()` calls out to a live Switchboard gateway (an actual -// internet-connected oracle operator) to get a signed reveal -- neither exists on a -// fresh local validator, genesis-loaded program bytecode notwithstanding. This script -// lives outside `tests/` on purpose so `anchor test`'s `tests/**/*.ts` glob never picks -// it up; it is meant to be run manually, not as part of CI. -// -// WHAT THIS PROVES: -// S1 - fetches the BattleRequest account right after commit_battle, then calls -// level_up on the attacker's pet, then fetches BattleRequest again and asserts -// its snapshotted attacker_level is UNCHANGED while the live PetAccount's level -// HAS changed -- direct on-chain proof that settle_battle simulates from the -// frozen snapshot, not the live (rerollable) pet stats. -// S2 - submits settle_battle from a third keypair, unrelated to either battler, as -// fee payer/signer -- proving the instruction is genuinely permissionless (a -// backend keeper needs no player signature). -// -// WHAT THIS DOES NOT COVER: the combat math itself (that's the golden vectors, run -// identically by Hardhat/Anchor/indexer-go/vitest -- see CLAUDE.md's "Combat simulator" -// section) and the client-side live-animation reveal-decode trick in -// shared/src/hooks/chains/solana/useLiveBattleReplaySolana.ts (that needs its own -// live-gateway check from the frontend/browser side; see that file's header comment). -// -// COST / SAFETY: this WRITES real on-chain state (two minted pets, a battle, a -// level-up) to whatever program id it's pointed at, and spends real (free-faucet) -// devnet SOL, all funded from the one ANCHOR_WALLET keypair. Do NOT point PROGRAM_ID -// at a program other players or the live demo also use unless you accept that -- -// prefer a dedicated devnet deployment for repeated runs. -// -// Usage: -// ANCHOR_PROVIDER_URL=https://api.devnet.solana.com \ -// ANCHOR_WALLET=$HOME/.config/solana/id.json \ -// pnpm exec tsx scripts/devnet-battle-harness.ts -// -// ANCHOR_WALLET must hold enough devnet SOL to fund itself plus two fresh throwaway -// keypairs it creates and transfers to directly (defender, keeper) -- at least ~1.5 -// SOL total is a safe margin. It does not rely on requestAirdrop for the throwaway -// keypairs (devnet airdrops are rate-limited and unreliable); it transfers from -// ANCHOR_WALLET instead, so only that one wallet needs pre-funding (faucet.solana.com). -// -// NOT RUN in this environment: no cargo/anchor/rustc/solana toolchain, and no -// reachable network here (confirmed: even `pnpm add` to the npm registry timed out). -// Written from reading the program source and the existing tests/scripts directly, -// not executed. Run this yourself before trusting it. - -import * as anchor from "@coral-xyz/anchor"; -import { EventParser } from "@coral-xyz/anchor"; -import * as sb from "@switchboard-xyz/on-demand"; -import { - globalStatePda, - playerProfilePda, - petPda, - mintRequestPda, - battleRequestPda, - feeVaultPda, -} from "../tests/utils"; - -const PROGRAM_ID = new anchor.web3.PublicKey( - process.env.PROGRAM_ID ?? "EVzXwxHqwbTLMxfTG3amCb2Sjwmy5A7hqR59GbrvEyV1" -); -const MPL_CORE_PROGRAM_ID = new anchor.web3.PublicKey( - "CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d" -); - -const REVEAL_RETRIES = 10; -const REVEAL_BACKOFF_MS = 3_000; - -async function waitForReveal( - randomness: Awaited>[0], - payer: anchor.web3.PublicKey -): Promise { - for (let attempt = 1; attempt <= REVEAL_RETRIES; attempt++) { - try { - return await randomness.revealIx(payer); - } catch (err) { - if (attempt === REVEAL_RETRIES) throw err; - console.log( - ` oracle not ready yet (attempt ${attempt}/${REVEAL_RETRIES}), retrying…` - ); - await new Promise((r) => setTimeout(r, REVEAL_BACKOFF_MS)); - } - } - throw new Error("unreachable"); -} - -async function transferSol( - provider: anchor.AnchorProvider, - from: anchor.web3.Keypair, - to: anchor.web3.PublicKey, - lamports: number -): Promise { - const tx = new anchor.web3.Transaction().add( - anchor.web3.SystemProgram.transfer({ - fromPubkey: from.publicKey, - toPubkey: to, - lamports, - }) - ); - const sig = await provider.connection.sendTransaction(tx, [from]); - await provider.connection.confirmTransaction(sig, "confirmed"); -} - -async function mintPet( - program: anchor.Program, - provider: anchor.AnchorProvider, - owner: anchor.web3.Keypair, - name: string -): Promise<{ asset: anchor.web3.PublicKey; petId: number }> { - const [globalState] = globalStatePda(PROGRAM_ID); - const [playerProfile] = playerProfilePda(PROGRAM_ID, owner.publicKey); - const [mintRequest] = mintRequestPda(PROGRAM_ID, owner.publicKey); - const [feeVault] = feeVaultPda(PROGRAM_ID); - - const gs = await (program.account as any).globalState.fetch(globalState); - const collection = gs.collection as anchor.web3.PublicKey; - - const queue = await sb.getDefaultQueue(provider.connection.rpcEndpoint); - const rngKp = anchor.web3.Keypair.generate(); - const [randomness, createIx] = await sb.Randomness.create( - queue.program, - rngKp, - queue.pubkey, - owner.publicKey - ); - const commitIx = await randomness.commitIx(queue.pubkey, owner.publicKey); - const commitMintIx = await program.methods - .commitMint(rngKp.publicKey, name) - .accounts({ - globalState, - owner: owner.publicKey, - playerProfile, - mintRequest, - feeVault, - randomnessAccountData: rngKp.publicKey, - systemProgram: anchor.web3.SystemProgram.programId, - }) - .instruction(); - - const commitTx = await sb.asV0Tx({ - connection: provider.connection, - ixs: [createIx, commitIx, commitMintIx], - payer: owner.publicKey, - computeUnitPrice: 75_000, - computeUnitLimitMultiple: 1.3, - }); - commitTx.sign([owner, rngKp]); - const commitSig = await provider.connection.sendTransaction(commitTx); - await provider.connection.confirmTransaction(commitSig, "confirmed"); - console.log(` [${name}] commit_mint: ${commitSig}`); - - const revealIx = await waitForReveal(randomness, owner.publicKey); - - const assetKp = anchor.web3.Keypair.generate(); - const [pet] = petPda(PROGRAM_ID, assetKp.publicKey); - - const settleMintIx = await program.methods - .settleMint() - .accounts({ - globalState, - owner: owner.publicKey, - mplCoreProgram: MPL_CORE_PROGRAM_ID, - asset: assetKp.publicKey, - collection, - pet, - mintRequest, - randomnessAccountData: rngKp.publicKey, - systemProgram: anchor.web3.SystemProgram.programId, - }) - .instruction(); - - const settleTx = await sb.asV0Tx({ - connection: provider.connection, - ixs: [revealIx, settleMintIx], - payer: owner.publicKey, - computeUnitPrice: 75_000, - computeUnitLimitMultiple: 1.3, - }); - settleTx.sign([owner, assetKp]); - const settleSig = await provider.connection.sendTransaction(settleTx); - await provider.connection.confirmTransaction(settleSig, "confirmed"); - console.log(` [${name}] settle_mint: ${settleSig}`); - - const petAccount = await (program.account as any).petAccount.fetch(pet); - return { asset: assetKp.publicKey, petId: petAccount.id }; -} - -async function main() { - const provider = anchor.AnchorProvider.env(); - anchor.setProvider(provider); - const attacker = (provider.wallet as anchor.Wallet).payer; - - const idl = await anchor.Program.fetchIdl(PROGRAM_ID, provider); - if (!idl) { - throw new Error( - `IDL not found on-chain for ${PROGRAM_ID.toBase58()}. Deploy the program and run ` + - "`anchor idl init` first." - ); - } - const program = new anchor.Program(idl, provider); - - const [globalState] = globalStatePda(PROGRAM_ID); - const gsInfo = await provider.connection.getAccountInfo(globalState); - if (!gsInfo) { - throw new Error( - `global-state doesn't exist for ${PROGRAM_ID.toBase58()}. Run scripts/initialize.ts first.` - ); - } - - console.log("program :", PROGRAM_ID.toBase58()); - console.log("cluster :", provider.connection.rpcEndpoint); - console.log("attacker :", attacker.publicKey.toBase58()); - console.log( - "\n⚠ This writes real state to the program above and spends real (free-faucet)", - "devnet SOL. Ctrl-C now if that program is shared with other players or the live demo.\n" - ); - - const defender = anchor.web3.Keypair.generate(); - const keeper = anchor.web3.Keypair.generate(); - console.log( - "defender :", - defender.publicKey.toBase58(), - "(fresh, funded from attacker)" - ); - console.log( - "keeper :", - keeper.publicKey.toBase58(), - "(fresh, funded from attacker)" - ); - await transferSol( - provider, - attacker, - defender.publicKey, - 0.3 * anchor.web3.LAMPORTS_PER_SOL - ); - await transferSol( - provider, - attacker, - keeper.publicKey, - 0.05 * anchor.web3.LAMPORTS_PER_SOL - ); - - console.log("\nMinting attacker pet…"); - const attackerPet = await mintPet( - program, - provider, - attacker, - "Harness Attacker" - ); - console.log("Minting defender pet…"); - const defenderPet = await mintPet( - program, - provider, - defender, - "Harness Defender" - ); - - const [attackerPetPda] = petPda(PROGRAM_ID, attackerPet.asset); - const [defenderPetPda] = petPda(PROGRAM_ID, defenderPet.asset); - const [battleRequest] = battleRequestPda(PROGRAM_ID, attacker.publicKey); - - console.log("\nCommitting battle…"); - const queue = await sb.getDefaultQueue(provider.connection.rpcEndpoint); - const rngKp = anchor.web3.Keypair.generate(); - const [randomness, createIx] = await sb.Randomness.create( - queue.program, - rngKp, - queue.pubkey, - attacker.publicKey - ); - const commitIx = await randomness.commitIx(queue.pubkey, attacker.publicKey); - const commitBattleIx = await program.methods - .commitBattle(rngKp.publicKey) - .accounts({ - globalState, - attackerOwner: attacker.publicKey, - attackerAsset: attackerPet.asset, - attackerPet: attackerPetPda, - defenderOwner: defender.publicKey, - defenderAsset: defenderPet.asset, - defenderPet: defenderPetPda, - battleRequest, - randomnessAccountData: rngKp.publicKey, - systemProgram: anchor.web3.SystemProgram.programId, - }) - .instruction(); - const commitBattleTx = await sb.asV0Tx({ - connection: provider.connection, - ixs: [createIx, commitIx, commitBattleIx], - payer: attacker.publicKey, - computeUnitPrice: 75_000, - computeUnitLimitMultiple: 1.3, - }); - commitBattleTx.sign([attacker, rngKp]); - const commitBattleSig = await provider.connection.sendTransaction( - commitBattleTx - ); - await provider.connection.confirmTransaction(commitBattleSig, "confirmed"); - console.log(" commit_battle:", commitBattleSig); - - // --- S1 check --- - const requestBeforeLevelUp = await ( - program.account as any - ).battleRequest.fetch(battleRequest); - const petBeforeLevelUp = await (program.account as any).petAccount.fetch( - attackerPetPda - ); - console.log( - `\nS1 check: battle_request.attackerLevel=${requestBeforeLevelUp.attackerLevel}`, - `live pet.level=${petBeforeLevelUp.level} (expected equal here, before level_up)` - ); - - console.log( - "Leveling up the attacker's pet (simulating the front-run reroll attempt)…" - ); - const [feeVault] = feeVaultPda(PROGRAM_ID); - await program.methods - .levelUp() - .accounts({ - globalState, - petAsset: attackerPet.asset, - pet: attackerPetPda, - feeVault, - owner: attacker.publicKey, - systemProgram: anchor.web3.SystemProgram.programId, - }) - .signers([attacker]) - .rpc(); - - const requestAfterLevelUp = await ( - program.account as any - ).battleRequest.fetch(battleRequest); - const petAfterLevelUp = await (program.account as any).petAccount.fetch( - attackerPetPda - ); - console.log( - `S1 check: battle_request.attackerLevel=${requestAfterLevelUp.attackerLevel}`, - `(must be unchanged) live pet.level=${petAfterLevelUp.level} (must be +1)` - ); - if ( - requestAfterLevelUp.attackerLevel !== requestBeforeLevelUp.attackerLevel - ) { - throw new Error( - "S1 FAILED: battle_request's frozen attackerLevel changed after level_up -- the " + - "snapshot fix is not behaving as designed." - ); - } - if (petAfterLevelUp.level !== petBeforeLevelUp.level + 1) { - throw new Error( - "level_up did not increment the live pet's level as expected -- check the setup, not S1 itself." - ); - } - if (requestAfterLevelUp.attackerLevel === petAfterLevelUp.level) { - throw new Error( - "S1 INCONCLUSIVE: snapshot level equals live level after level_up -- the two should " + - "have diverged; this run didn't actually exercise the fix." - ); - } - console.log( - "✅ S1: the frozen snapshot is unaffected by the live (now leveled-up) pet account.\n" - ); - - // --- S2 check --- - console.log("Waiting for Switchboard reveal…"); - const revealIx = await waitForReveal(randomness, keeper.publicKey); - - console.log( - "Settling battle from the unrelated keeper wallet (proves permissionless settle)…" - ); - const settleBattleIx = await program.methods - .settleBattle() - .accounts({ - globalState, - attackerOwner: attacker.publicKey, - attackerAsset: attackerPet.asset, - attackerPet: attackerPetPda, - defenderOwner: defender.publicKey, - defenderAsset: defenderPet.asset, - defenderPet: defenderPetPda, - battleRequest, - randomnessAccountData: rngKp.publicKey, - }) - .instruction(); - const settleBattleTx = await sb.asV0Tx({ - connection: provider.connection, - ixs: [revealIx, settleBattleIx], - payer: keeper.publicKey, - computeUnitPrice: 75_000, - computeUnitLimitMultiple: 1.3, - }); - settleBattleTx.sign([keeper]); - const settleBattleSig = await provider.connection.sendTransaction( - settleBattleTx - ); - await provider.connection.confirmTransaction(settleBattleSig, "confirmed"); - console.log( - " settle_battle:", - settleBattleSig, - "(signed only by the keeper, not the attacker)" - ); - console.log( - "✅ S2: settle_battle succeeded without the attacker's signature.\n" - ); - - const tx = await provider.connection.getTransaction(settleBattleSig, { - commitment: "confirmed", - maxSupportedTransactionVersion: 0, - }); - const logs = tx?.meta?.logMessages ?? []; - const parser = new EventParser(program.programId, program.coder); - for (const event of parser.parseLogs(logs)) { - if (event.name === "BattleResolved") { - console.log("BattleResolved event:", event.data); - } - } - - console.log("\nAll checks passed."); -} - -main().catch((err) => { - console.error( - "\n❌ harness failed:", - err instanceof Error ? err.message : err - ); - process.exit(1); -}); diff --git a/contracts/solana/cryptopets/scripts/initialize.ts b/contracts/solana/cryptopets/scripts/initialize.ts index cad5eef6..e934a51e 100644 --- a/contracts/solana/cryptopets/scripts/initialize.ts +++ b/contracts/solana/cryptopets/scripts/initialize.ts @@ -3,7 +3,7 @@ // One-time on-chain setup for a freshly deployed `cryptopets` program: runs the // `initialize` instruction, which creates the `global-state` PDA (admin + fee // config + next_pet_id) and the Metaplex Core collection that every pet is -// minted into. Without this, mint/breed/battle and pet loading have nothing to +// minted into. Without this, mint/breed and pet loading have nothing to // read or write, so the frontend shows an empty list and create fails. // // Idempotent: if `global-state` already exists it prints the current config and diff --git a/contracts/solana/cryptopets/scripts/set-config.ts b/contracts/solana/cryptopets/scripts/set-config.ts index e81bb811..e518ca0b 100644 --- a/contracts/solana/cryptopets/scripts/set-config.ts +++ b/contracts/solana/cryptopets/scripts/set-config.ts @@ -13,7 +13,7 @@ // battleCooldownSeconds — cooldown between battles (default: 5) // trainCooldownSeconds — cooldown between trains (default: 60) // trainXp — XP granted per train (default: 100) -// levelBandWidth — max level gap between battle participants (default: 100) +// levelBandWidth — retired with the on-chain battle path; nothing reads it // maxLevel — hard level cap (default: 100) // generationCap — max breeding generation (default: 20) // newbornCooldownSeconds — post-breed battle lockout (default: 60) diff --git a/contracts/solana/cryptopets/tests/cryptopets.ts b/contracts/solana/cryptopets/tests/cryptopets.ts index 0c4824e6..de623fe9 100644 --- a/contracts/solana/cryptopets/tests/cryptopets.ts +++ b/contracts/solana/cryptopets/tests/cryptopets.ts @@ -8,9 +8,9 @@ // // Covers the v2 instruction set (plan-contract-upgrade.md) that doesn't // depend on a Switchboard On-Demand randomness commit/reveal cycle: -// initialize, pause/unpause, and the SetConfig setters. The gacha mint, breed, -// and battle commit/settle flows -- and anything downstream of them (pets, -// marriage, fee withdrawals) -- require minting a randomness account and +// initialize, pause/unpause, and the SetConfig setters. The gacha mint and +// breed flows -- and anything downstream of them (pets, marriage, fee +// withdrawals) -- require minting a randomness account and // driving it through Switchboard's on-chain commit/reveal, which needs the // `@switchboard-xyz/on-demand` JS SDK wired into the local validator; that // infrastructure doesn't exist yet, so those flows aren't covered here. @@ -138,9 +138,9 @@ describe("cryptopets", () => { // Audit finding: unlike every other SetConfig setter, set_level_band_width // has no MAX_* bounds check (config.rs), so any u16 is accepted. Low - // priority -- an oversized band width just disables level-gating in - // commit_battle, it doesn't brick anything -- but documented here so a - // future bounds check (and this test) can be added together. + // priority -- nothing reads level_band_width since the on-chain battle path + // was retired, so an oversized value cannot brick anything -- but documented + // here so a future bounds check (and this test) can be added together. it("set_level_band_width accepts any u16 (no bounds check)", async () => { const value = 65535; @@ -172,10 +172,10 @@ describe("cryptopets", () => { }); // TODO (plan §4.3/§4.4): gacha mint (commit_mint/settle_mint), breeding - // (commit_breed/settle_breed), battling (commit_battle/settle_battle), and - // everything that depends on an existing pet (level_up, train, rename_pet, - // set_open_to_challenges, marriage, cancel_mint/cancel_breed/cancel_battle, - // clear_stale_marriage, withdraw_stud_fees, sync_metadata). All of these + // (commit_breed/settle_breed), and everything that depends on an existing pet + // (level_up, train, rename_pet, set_open_to_challenges, marriage, + // cancel_mint/cancel_breed, clear_stale_marriage, withdraw_stud_fees, + // sync_metadata). All of these // need a pet, which only comes from settle_mint/settle_breed minting a // Metaplex Core asset after a Switchboard On-Demand randomness reveal -- // build that test harness (Randomness.create/commitIx/revealIx from diff --git a/contracts/solana/cryptopets/tests/utils.ts b/contracts/solana/cryptopets/tests/utils.ts index 17c1b99e..87cb8ac3 100644 --- a/contracts/solana/cryptopets/tests/utils.ts +++ b/contracts/solana/cryptopets/tests/utils.ts @@ -10,7 +10,6 @@ export const GLOBAL_STATE_SEED = Buffer.from("global-state"); export const PLAYER_PROFILE_SEED = Buffer.from("player-profile"); export const PET_SEED = Buffer.from("pet"); export const BREED_REQUEST_SEED = Buffer.from("breed-request"); -export const BATTLE_REQUEST_SEED = Buffer.from("battle-request"); export const MINT_REQUEST_SEED = Buffer.from("mint-request"); export const MARRIAGE_PROPOSAL_SEED = Buffer.from("marriage-proposal"); export const STUD_FEE_SEED = Buffer.from("stud-fee"); @@ -51,13 +50,6 @@ export function breedRequestPda(programId: anchor.web3.PublicKey, owner: anchor. ); } -export function battleRequestPda(programId: anchor.web3.PublicKey, owner: anchor.web3.PublicKey) { - return anchor.web3.PublicKey.findProgramAddressSync( - [BATTLE_REQUEST_SEED, owner.toBuffer()], - programId, - ); -} - export function studFeeAccountPda(programId: anchor.web3.PublicKey, owner: anchor.web3.PublicKey) { return anchor.web3.PublicKey.findProgramAddressSync( [STUD_FEE_SEED, owner.toBuffer()], diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx index 3e17053f..f047ab28 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx @@ -140,7 +140,7 @@ const SKILL_EMOJI: Record = { * One fighter's HUD plate: name/level, a glowing HP gauge, rarity/skill-archetype * ("personality" — same terminology as the pet-gallery card)/record pills, and the * DNA-derived combat stats (plan §3.1 — pure function of dna/rarity/level, same - * values CombatSim.simulate uses; no chain read needed). `pet` accepts an + * values the combat engine simulates from; no chain read needed). `pet` accepts an * OpponentPet too since it structurally extends Pet. */ const FighterPlate: React.FC<{ diff --git a/shared/src/hooks/chains/ethereum/gasLimits.ts b/shared/src/hooks/chains/ethereum/gasLimits.ts index c63d81d0..4444a49d 100644 --- a/shared/src/hooks/chains/ethereum/gasLimits.ts +++ b/shared/src/hooks/chains/ethereum/gasLimits.ts @@ -2,7 +2,7 @@ // The VRF/Entropy request and settle txs can't be gas-estimated by the RPC — // estimateGas returns the block limit ("gas limit too high") — so each gets an // explicit, empirically sized limit. Centralized here so values shared across -// hooks (e.g. settleBattle in usePendingBattle and useEvmBattleFlow) stay in +// hooks (e.g. settleBreed in usePendingBreed and useBreedPets) stay in // lockstep instead of drifting as separate literals. export const EVM_GAS_LIMITS = { // PetCore @@ -15,11 +15,6 @@ export const EVM_GAS_LIMITS = { requestMintStarter: 500_000n, settleMint: 500_000n, - // GameLogic — async battle (request → VRF → settle) - requestBattle: 800_000n, - settleBattle: 800_000n, - cancelBattle: 200_000n, - // GameLogic — async breed (requestCreateFromDNA → VRF → settle) requestBreed: 800_000n, settleBreed: 800_000n, diff --git a/shared/src/hooks/chains/ethereum/useWatchEntropyFulfillment.ts b/shared/src/hooks/chains/ethereum/useWatchEntropyFulfillment.ts index 30f46b8f..12a10e8c 100644 --- a/shared/src/hooks/chains/ethereum/useWatchEntropyFulfillment.ts +++ b/shared/src/hooks/chains/ethereum/useWatchEntropyFulfillment.ts @@ -30,18 +30,16 @@ type UseWatchEntropyFulfillmentParams = { /** requestId (= entropy sequenceNumber as uint256) to wait on; null disables the watch. */ requestId: bigint | null; /** Fired once `Revealed` lands for `requestId` called by our GameLogic. `randomNumber` - * is the raw revealed word (same 32 bytes GameLogic stores as `uint256(randomNumber)` - * and CombatSim.simulate's `seed` — this is what lets the client run the same - * deterministic sim locally the moment reveal happens, plan-realtime-battle-impl.md - * Phase 4, without waiting for settleBattle to be mined). */ + * is the raw revealed word — the same 32 bytes GameLogic stores as + * `uint256(randomNumber)` and settles the request from. */ onFulfilled?: (requestId: bigint, randomNumber: `0x${string}`) => void; }; /** - * Resolves when Pyth Entropy reveals randomness for `requestId`. Used by the - * mint flow (analogous to `useWatchVrfFulfillment` for battle/breed), but watches - * the Entropy contract's `Revealed` event filtered by `caller = gameLogicAddress` - * and `sequenceNumber = requestId` (the two are the same value, different types). + * Resolves when Pyth Entropy reveals randomness for `requestId`. Used by the mint + * and breed flows, watching the Entropy contract's `Revealed` event filtered by + * `caller = gameLogicAddress` and `sequenceNumber = requestId` (the two are the + * same value, different types). */ export const useWatchEntropyFulfillment = ({ entropyAddress, diff --git a/shared/src/utils/solana/index.ts b/shared/src/utils/solana/index.ts index e7de85df..e9eaf889 100644 --- a/shared/src/utils/solana/index.ts +++ b/shared/src/utils/solana/index.ts @@ -5,7 +5,6 @@ export { playerProfilePda, petPdaByAsset, breedRequestPda, - battleRequestPda, marriageProposalPda, feeVaultPda, mintRequestPda, diff --git a/shared/src/utils/solana/pdas.ts b/shared/src/utils/solana/pdas.ts index 68daf56e..789e28ee 100644 --- a/shared/src/utils/solana/pdas.ts +++ b/shared/src/utils/solana/pdas.ts @@ -5,7 +5,6 @@ const GLOBAL_STATE_SEED = Buffer.from('global-state'); const PLAYER_PROFILE_SEED = Buffer.from('player-profile'); const PET_SEED = Buffer.from('pet'); const BREED_REQUEST_SEED = Buffer.from('breed-request'); -const BATTLE_REQUEST_SEED = Buffer.from('battle-request'); const MARRIAGE_PROPOSAL_SEED = Buffer.from('marriage-proposal'); const FEE_VAULT_SEED = Buffer.from('fee-vault'); const MINT_REQUEST_SEED = Buffer.from('mint-request'); @@ -24,11 +23,6 @@ export const breedRequestPda = (programId: PublicKey, owner: PublicKey): [Public return PublicKey.findProgramAddressSync([BREED_REQUEST_SEED, owner.toBuffer()], programId); }; -/** Pending battle PDA (one per attacker wallet). */ -export const battleRequestPda = (programId: PublicKey, attacker: PublicKey): [PublicKey, number] => { - return PublicKey.findProgramAddressSync([BATTLE_REQUEST_SEED, attacker.toBuffer()], programId); -}; - /** v2.1 pet PDA keyed by Metaplex Core asset address: seeds ["pet", asset_pubkey]. */ export const petPdaByAsset = (programId: PublicKey, assetKey: string): [PublicKey, number] => { return PublicKey.findProgramAddressSync([PET_SEED, new PublicKey(assetKey).toBuffer()], programId); diff --git a/shared/tests/utils/solana/pdas.test.ts b/shared/tests/utils/solana/pdas.test.ts index 8652a0fd..7dd0f721 100644 --- a/shared/tests/utils/solana/pdas.test.ts +++ b/shared/tests/utils/solana/pdas.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import { Keypair, PublicKey } from '@solana/web3.js'; import { - battleRequestPda, breedRequestPda, globalStatePda, playerProfilePda, @@ -30,10 +29,9 @@ describe('solana PDAs', () => { const global = globalStatePda(programId)[0]; const profile = playerProfilePda(programId, owner)[0]; const breed = breedRequestPda(programId, owner)[0]; - const battle = battleRequestPda(programId, owner)[0]; - const all = [global, profile, breed, battle].map((k) => k.toBase58()); - expect(new Set(all).size).toBe(4); + const all = [global, profile, breed].map((k) => k.toBase58()); + expect(new Set(all).size).toBe(3); }); it('ties the player profile PDA to its owner', () => { From b9cffe457fd24da224dae9a51e431cfe7f79d758 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 20:53:16 -0400 Subject: [PATCH 52/76] feat(backend,shared): merge backend battle progression into pet reads --- CLAUDE.md | 13 ++ backend/src/graphql/resolvers.ts | 47 +++++- backend/src/graphql/schema.ts | 26 ++++ .../repositories/battleProgress.overlay.ts | 117 +++++++++++++++ backend/tests/graphql/resolvers.test.ts | 6 + backend/tests/graphql/schema.test.ts | 12 +- .../battleProgress.overlay.test.ts | 89 +++++++++++ shared/src/hooks/adapters/types.ts | 140 +++++++++--------- shared/src/hooks/adapters/useSolanaAdapter.ts | 2 +- shared/src/hooks/index.ts | 3 + shared/src/hooks/useBattleProgress.ts | 95 ++++++++++++ shared/src/hooks/usePetList.ts | 16 +- shared/tests/hooks/useBattleProgress.test.ts | 61 ++++++++ shared/tests/hooks/usePetList.test.ts | 40 +++++ 14 files changed, 591 insertions(+), 76 deletions(-) create mode 100644 backend/src/repositories/battleProgress.overlay.ts create mode 100644 backend/tests/repositories/battleProgress.overlay.test.ts create mode 100644 shared/src/hooks/useBattleProgress.ts create mode 100644 shared/tests/hooks/useBattleProgress.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f2443db3..5170d523 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,19 @@ flow (see the stale-script note in Commands above). Solana breeding and minting use Switchboard On-Demand (commit then settle), also async. Battles use neither: they are seeded from a committed drand round by the backend (§E). +### Pet stats: chain state plus backend progression, merged at the read +On-chain `level`/`xp`/`winCount`/`lossCount` are **frozen** for battles — nothing has written them since §L Phase 6 — while the live record accumulates in `pet_battle_progress`. Neither table alone is right: the roster misses every backend battle, and progress rows only exist for pets that have fought. + +The rule, applied in two places because there are two read paths: +- **Backend-served pets** (opponents, `pet`, `searchPets`, `allPets`): merged server-side in the GraphQL resolvers via `backend/src/repositories/battleProgress.overlay.ts`. +- **A player's own pets**: read straight off PetCore / the Solana program by the chain adapter, so the *client* merges, in `usePetList` via `shared/src/hooks/useBattleProgress.ts` (backed by the `battleProgress(chain, petIds)` GraphQL field). + +Both apply the same rule: a pet with a progress row shows backend progression, one without shows chain truth, and `readyAt` takes the **later** of the two cooldowns (breeding still writes the on-chain one; battles write the backend one). Progress rows are seeded from on-chain level on a pet's first battle, so the two agree the moment a row appears. + +The merge is deliberately **not** in `roster.repository.ts`. `snapshot.builder.ts` seeds a first progress row from the roster's on-chain level and `intent.service.ts` checks ownership there; merging in the repository would feed overlaid progression back into the thing that produces it. + +Two known gaps, both in matchmaking rather than display: `opponents` post-filters on the merged cooldown, so a page can be shorter than `pageSize` and `total` is an upper bound; and `minLevel` still bands on **on-chain** level, so a pet that climbed through backend battles can still be offered to a low-level challenger. Closing that needs the band pushed into the query as a join. + ### Known v1 contract limitations (design context, not regressions to "fix") `contracts/plan-contract-upgrade.md` documents intentional v1 gaps that v2 is designed around: no battle authorization (anyone can call `battle()`/`attack()` on anyone's pets), an EVM `changeDna` cheat that lets a level-20 pet set arbitrary DNA, and a Solana `create_starter_pet` that accepts client-supplied dna/rarity. v2 plan: EVM moves to UUPS proxies (`PetCoreProxy` + `GameLogicProxy`, with `CombatSimV1` deployed as a separate contract to stay under the 24KB bytecode ceiling); Solana adds versioned/reserved-space accounts and migrates pets to Metaplex Core NFTs. This is a plan doc; check current contract source before assuming any of it is implemented. diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 8a068a64..d0b7740e 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -1,9 +1,12 @@ import { findReadyOpponents, getAllPets, getPetById, searchPets, type RosterPet } from '@repositories/roster.repository'; +import { findBattleProgress, withBattleProgress } from '@repositories/battleProgress.overlay'; import { tryGrpcEstimateWin } from '@grpc-client/estimateWin'; import { isSupportedChain, SUPPORTED_CHAINS } from '@typings/chain'; const DEFAULT_PAGE_SIZE = 20; const MAX_PAGE_SIZE = 50; +/** Upper bound on pet ids per battleProgress call. */ +const MAX_PROGRESS_PET_IDS = 200; /** Upper bound on requested sim seeds — keeps a single call from pinning indexer-go. */ const MAX_WIN_SAMPLES = 10_000; @@ -14,6 +17,11 @@ interface OpponentsArgs { pageSize?: number | null; } +interface BattleProgressArgs { + chain: string; + petIds: string[]; +} + interface WinEstimateArgs { chain: string; petId1: string; @@ -76,8 +84,20 @@ export const rootValue = { pageSize, }); + // The query filters and bands on chain truth, which no longer moves for battles. + // Overlaying progression here fixes the levels shown, and drops anyone still on a + // backend cooldown that `pet_roster.ready_at` knows nothing about. Two knock-on + // effects, both deliberate: + // - a page can come back shorter than `pageSize`, and `total` is an upper bound. + // Matchmaking is a browse, not a ledger; a short page costs nothing. + // - `minLevel` still bands on the pet's on-chain level, so a pet that has climbed + // through backend battles can still be offered to a low-level challenger. Fixing + // that needs the band pushed into the query as a join, not a post-filter. + const overlaid = await withBattleProgress(args.chain, rows); + const now = BigInt(Math.floor(Date.now() / 1000)); + return { - opponents: rows.map(toOpponentPet), + opponents: overlaid.filter((pet) => pet.readyAt <= now).map(toOpponentPet), total, page, pageSize, @@ -91,7 +111,7 @@ export const rootValue = { const limit = Math.min(20, Math.max(1, args.limit ?? 10)); const rows = await searchPets({ chain: args.chain, query: args.query, limit }); - return rows.map(toOpponentPet); + return (await withBattleProgress(args.chain, rows)).map(toOpponentPet); }, allPets: async (args: AllPetsArgs) => { @@ -100,7 +120,7 @@ export const rootValue = { } const limit = Math.min(500, Math.max(1, args.limit ?? 200)); const rows = await getAllPets(args.chain, limit); - return rows.map(toOpponentPet); + return (await withBattleProgress(args.chain, rows)).map(toOpponentPet); }, pet: async (args: PetArgs) => { @@ -109,7 +129,26 @@ export const rootValue = { } const row = await getPetById(args.chain, args.id); - return row ? toOpponentPet(row) : null; + if (!row) return null; + const [overlaid] = await withBattleProgress(args.chain, [row]); + return toOpponentPet(overlaid ?? row); + }, + + battleProgress: async (args: BattleProgressArgs) => { + if (!isSupportedChain(args.chain)) { + throw new Error(`chain must be one of: ${SUPPORTED_CHAINS.join(', ')}`); + } + + // Bounded so one call cannot ask for the whole table. A player's own roster is + // far below this; anything larger is not a pet list. + const petIds = args.petIds.slice(0, MAX_PROGRESS_PET_IDS); + const rows = await findBattleProgress(args.chain, petIds); + + return rows.map(({ petId: id, readyAt, ...rest }) => ({ + id, + ...rest, + readyAt: Number(readyAt), + })); }, winEstimate: async (args: WinEstimateArgs) => { diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 7e4a8335..74a48a7b 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -36,6 +36,24 @@ export const schema = buildSchema(` asset: String! } + """ + A pet's backend battle progression, for pets read straight from the chain. + + The opponents/pet/searchPets/allPets reads already have this merged in. This is for + the one surface that cannot: a player's own pet list, which the client reads from + PetCore/the Solana program directly and so only ever sees frozen chain values. + """ + type PetBattleProgress { + "Pet id as a decimal string." + id: String! + level: Int! + xp: Int! + winCount: Int! + lossCount: Int! + "Unix seconds this pet is next battle-ready per the backend cooldown." + readyAt: Float! + } + type OpponentsPage { opponents: [OpponentPet!]! total: Int! @@ -88,6 +106,14 @@ export const schema = buildSchema(` """ pet(chain: String!, id: String!): OpponentPet + """ + Backend battle progression for specific pets. Pets that have never fought a + backend battle are omitted rather than returned as zeroes — absence means "no + backend record, chain state is the whole truth", which a zeroed row could not + distinguish from a pet that has fought and lost everything. + """ + battleProgress(chain: String!, petIds: [String!]!): [PetBattleProgress!]! + """ Pre-fight win probability for pet1 vs pet2. Returns null when the estimate is unavailable (indexer link off or roster cache still cold) so diff --git a/backend/src/repositories/battleProgress.overlay.ts b/backend/src/repositories/battleProgress.overlay.ts new file mode 100644 index 00000000..3deeacfc --- /dev/null +++ b/backend/src/repositories/battleProgress.overlay.ts @@ -0,0 +1,117 @@ +import { chainFamily, type ChainId } from '@cryptopets/protocol'; + +import { prisma } from '@config/prisma'; +import { servedChainIds, servedDeploymentId } from '@features/battle-ledger/domain'; +import type { RosterPet } from './roster.repository'; +import type { Chain } from '@typings/chain'; + +/** + * Overlays backend battle progression onto indexed chain state, for display. + * + * `pet_roster` is what the chain says. Since battles stopped settling on chain (§L Phase + * 6) its `level`/`xp`/`winCount`/`lossCount` are frozen at whatever the retired path left + * behind, while the real record accumulates in `pet_battle_progress`. Reading either + * alone is wrong: the roster misses every backend battle, and progress rows only exist + * for pets that have fought at all. + * + * So: a pet with a progress row shows its backend progression; a pet without one shows + * chain truth. That is not a fallback but the same rule stated twice — a progress row is + * seeded from the pet's on-chain level the first time it fights (see + * `battle-ledger/snapshot.builder.ts`), so the two agree at the moment the row appears + * and diverge only as backend battles are actually won. + * + * Cooldown is the exception: `readyAt` takes the *later* of the two. They are independent + * locks with different owners — breeding still writes the on-chain one (`newbornCooldown` + * bars a newborn from fighting), battles write the backend one — and a pet is only + * available when neither is holding it. + * + * This is deliberately not done in `roster.repository.ts`. That layer is the projection + * of chain state, and two callers need it to stay exactly that: `snapshot.builder.ts` + * seeds a pet's first progress row from its on-chain level, and `intent.service.ts` + * checks ownership. Merging in the repository would feed overlaid progression back into + * the thing that produced it. + */ + +/** The `pet_battle_progress` columns that shadow a roster row. */ +export interface ProgressRow { + petId: string; + level: number; + xp: number; + winCount: number; + lossCount: number; + readyAt: bigint; +} + +/** + * Applies one pet's progression. Pure, so the merge rule is testable without a database. + * `progress` being undefined means the pet has never fought a backend battle. + */ +export function overlayRosterPet(pet: RosterPet, progress: ProgressRow | undefined): RosterPet { + if (!progress) { + return pet; + } + return { + ...pet, + level: progress.level, + xp: progress.xp, + winCount: progress.winCount, + lossCount: progress.lossCount, + readyAt: progress.readyAt > pet.readyAt ? progress.readyAt : pet.readyAt, + }; +} + +/** + * The served `ChainId` for a roster chain family, or null if this deployment serves none. + * + * `pet_roster` is keyed by family (`evm`), `pet_battle_progress` by the specific chain + * (`eip155:84532`), because one deployment can serve several chains of a family whose + * pet-id namespaces are unrelated. Returning null when the family is unserved is correct + * rather than defensive: there is no progression to show for a chain this process does + * not run battles for. + */ +function servedChainIdForFamily(chain: Chain): ChainId | null { + const matches = servedChainIds().filter((chainId) => chainFamily(chainId) === chain); + return matches.length === 1 ? (matches[0] ?? null) : null; +} + +/** + * Overlays progression onto a batch of pets in one query. + * + * Returns the input unchanged when the family is unserved or ambiguous — see + * `servedChainIdForFamily`. Note that mixing chains in one call is not supported; every + * pet is expected to come from a single-chain read, which is what every caller does. + */ +export async function withBattleProgress(chain: Chain, pets: RosterPet[]): Promise { + if (pets.length === 0) { + return pets; + } + + const chainId = servedChainIdForFamily(chain); + if (!chainId) { + return pets; + } + + const rows = await fetchProgress(chainId, pets.map((pet) => pet.petId)); + const byPetId = new Map(rows.map((row) => [row.petId, row])); + return pets.map((pet) => overlayRosterPet(pet, byPetId.get(pet.petId))); +} + +/** + * The progression rows themselves, for pets the client read straight from the chain and + * therefore has to merge itself (a player's own pet list). Pets with no row are simply + * absent from the result — see the GraphQL field's own note on why that is not a zero. + */ +export async function findBattleProgress(chain: Chain, petIds: string[]): Promise { + if (petIds.length === 0) { + return []; + } + const chainId = servedChainIdForFamily(chain); + return chainId ? fetchProgress(chainId, petIds) : []; +} + +function fetchProgress(chainId: ChainId, petIds: string[]): Promise { + return prisma.petBattleProgress.findMany({ + where: { chainId, deploymentId: servedDeploymentId(), petId: { in: petIds } }, + select: { petId: true, level: true, xp: true, winCount: true, lossCount: true, readyAt: true }, + }); +} diff --git a/backend/tests/graphql/resolvers.test.ts b/backend/tests/graphql/resolvers.test.ts index e6bb005b..6aebdfc0 100644 --- a/backend/tests/graphql/resolvers.test.ts +++ b/backend/tests/graphql/resolvers.test.ts @@ -7,6 +7,12 @@ vi.mock('@repositories/roster.repository', () => ({ vi.mock('../../src/grpc/estimateWin', () => ({ tryGrpcEstimateWin: vi.fn(), })); +// The overlay's own merge rule is covered in repositories/battleProgress.overlay.test.ts; +// here it is stubbed to a pass-through so these tests stay about resolver shaping. +vi.mock('@repositories/battleProgress.overlay', () => ({ + withBattleProgress: vi.fn(async (_chain: unknown, pets: unknown[]) => pets), + findBattleProgress: vi.fn(async () => []), +})); import { rootValue } from '../../src/graphql/resolvers'; import { findReadyOpponents, getPetById } from '@repositories/roster.repository'; diff --git a/backend/tests/graphql/schema.test.ts b/backend/tests/graphql/schema.test.ts index 369c90ef..c242450c 100644 --- a/backend/tests/graphql/schema.test.ts +++ b/backend/tests/graphql/schema.test.ts @@ -21,8 +21,16 @@ function fieldsOf(typeName: string): Record { describe('GraphQL schema — Query surface', () => { const query = fieldsOf('Query'); - it('exposes opponents, pet, searchPets, allPets, and winEstimate', () => { - expect(Object.keys(query).sort()).toEqual(['allPets', 'opponents', 'pet', 'searchPets', 'winEstimate']); + it('exposes opponents, pet, searchPets, allPets, battleProgress, and winEstimate', () => { + expect(Object.keys(query).sort()).toEqual([ + 'allPets', 'battleProgress', 'opponents', 'pet', 'searchPets', 'winEstimate', + ]); + }); + + it('returns a non-null list of PetBattleProgress from battleProgress', () => { + // Non-null list, but pets without a backend record are simply absent from it — + // absence is the signal that chain state is the whole truth for that pet. + expect(query.battleProgress?.type).toBe('[PetBattleProgress!]!'); }); it('returns a non-null OpponentsPage from opponents', () => { diff --git a/backend/tests/repositories/battleProgress.overlay.test.ts b/backend/tests/repositories/battleProgress.overlay.test.ts new file mode 100644 index 00000000..80c35850 --- /dev/null +++ b/backend/tests/repositories/battleProgress.overlay.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { overlayRosterPet } from '../../src/repositories/battleProgress.overlay'; +import type { RosterPet } from '../../src/repositories/roster.repository'; + +/** + * The merge rule itself. `overlayRosterPet` is pure precisely so this can be pinned + * without a database: it decides what level every player sees, and getting it backwards + * (chain wins over backend) would silently undo every backend battle ever fought. + */ + +const chainPet: RosterPet = { + chain: 'evm', + petId: '7', + owner: '0xabc', + name: 'Rex', + level: 3, + rarity: 2, + dna: '1234567890123456', + winCount: 1, + lossCount: 0, + readyAt: 100n, + xp: 40, + generation: 0, + parent1Id: '0', + parent2Id: '0', + breedCount: 0, + speciesId: 5, + spouseId: '0', + breedReadyAt: 0n, + trainReadyAt: 0n, + asset: '', +}; + +const progress = { + petId: '7', + level: 12, + xp: 340, + winCount: 25, + lossCount: 4, + readyAt: 500n, +}; + +describe('overlayRosterPet', () => { + it('leaves a pet that has never fought a backend battle on chain truth', () => { + expect(overlayRosterPet(chainPet, undefined)).toBe(chainPet); + }); + + it('takes level, xp and the win/loss record from backend progression', () => { + const merged = overlayRosterPet(chainPet, progress); + + expect(merged.level).toBe(12); + expect(merged.xp).toBe(340); + expect(merged.winCount).toBe(25); + expect(merged.lossCount).toBe(4); + }); + + it('leaves chain-owned fields alone', () => { + // Everything the chain still writes — ownership, DNA, lineage, breed/train + // cooldowns — must survive the overlay untouched. + const merged = overlayRosterPet(chainPet, progress); + + expect(merged.owner).toBe('0xabc'); + expect(merged.dna).toBe('1234567890123456'); + expect(merged.rarity).toBe(2); + expect(merged.speciesId).toBe(5); + expect(merged.breedReadyAt).toBe(0n); + expect(merged.trainReadyAt).toBe(0n); + }); + + it('takes the later cooldown when the backend one is still running', () => { + expect(overlayRosterPet(chainPet, progress).readyAt).toBe(500n); + }); + + it('takes the later cooldown when the on-chain one is still running', () => { + // A pet bred moments ago carries a newborn lockout the backend knows nothing + // about. Taking the backend value blindly would let it fight through it. + const newborn = { ...chainPet, readyAt: 9_000n }; + + expect(overlayRosterPet(newborn, progress).readyAt).toBe(9_000n); + }); + + it('does not mutate the pet it was given', () => { + overlayRosterPet(chainPet, progress); + + expect(chainPet.level).toBe(3); + expect(chainPet.readyAt).toBe(100n); + }); +}); diff --git a/shared/src/hooks/adapters/types.ts b/shared/src/hooks/adapters/types.ts index 5cc33dfd..60f2c916 100644 --- a/shared/src/hooks/adapters/types.ts +++ b/shared/src/hooks/adapters/types.ts @@ -1,68 +1,72 @@ -import type { Pet } from '../../types/pet'; - -export type TxPhase = - | 'idle' - | 'awaiting-wallet' - | 'confirming' - | 'awaiting-vrf' - | 'success' - | 'error'; - -export interface TxLifecycle { - phase: TxPhase; - hash?: string; - error: Error | null; - reset(): void; -} - -export interface AdapterMutation { - mutateAsync(args: TArgs): Promise; - lifecycle: TxLifecycle; - isPending: boolean; -} - -export interface ChainCapabilities { - chainLabel: string; - address: { - label: string; - placeholder: string; - isValid(value: string): boolean; - }; - /** null when the action is free on this chain. */ - levelUpFee: { amount: string; symbol: string } | null; - /** Minimum pet level before rename is allowed. */ - renameMinLevel: number; - randomness: { - /** null when no chain is active (disconnected). */ - provider: 'chainlink' | 'switchboard' | null; - appliesTo: ('battle' | 'breed')[]; - }; - explorerTxUrl(hash: string): string | null; - parseError(error: unknown, fallback: string): { message: string; isUserRejection: boolean; isContractError: boolean }; -} - -export interface ChainAdapter { - kind: 'evm' | 'solana' | 'none'; - address: string | null; - isConnected: boolean; - capabilities: ChainCapabilities; - - pets: { - data: Pet[]; - isLoading: boolean; - error: Error | null; - refetch(): void; - }; - - // petId is always string; adapters convert to bigint/number internally. - // DNA/rarity are derived from VRF randomness at settle time on both chains, - // so mint takes only a name. - createPet: AdapterMutation<{ name: string }>; - levelUpPet: AdapterMutation<{ petId: string }>; - /** v2 train: pay a level-scaled fee for flat XP. */ - trainPet: AdapterMutation<{ petId: string }>; - renamePet: AdapterMutation<{ petId: string; name: string }>; - transferPet: AdapterMutation<{ petId: string; to: string }>; - // crossOwner adds the stud fee (EVM married cross-owner breeding); ignored on Solana. - breedPets: AdapterMutation<{ parentId1: string; parentId2: string; name: string; crossOwner?: boolean }>; -} +import type { Pet } from '../../types/pet'; + +export type TxPhase = + | 'idle' + | 'awaiting-wallet' + | 'confirming' + | 'awaiting-vrf' + | 'success' + | 'error'; + +export interface TxLifecycle { + phase: TxPhase; + hash?: string; + error: Error | null; + reset(): void; +} + +export interface AdapterMutation { + mutateAsync(args: TArgs): Promise; + lifecycle: TxLifecycle; + isPending: boolean; +} + +export interface ChainCapabilities { + chainLabel: string; + address: { + label: string; + placeholder: string; + isValid(value: string): boolean; + }; + /** null when the action is free on this chain. */ + levelUpFee: { amount: string; symbol: string } | null; + /** Minimum pet level before rename is allowed. */ + renameMinLevel: number; + randomness: { + /** null when no chain is active (disconnected). */ + provider: 'chainlink' | 'switchboard' | null; + /** + * Which flows still draw randomness from the chain. Battles never do: they are + * seeded from a committed drand round by the backend (§E), on either chain. + */ + appliesTo: 'breed'[]; + }; + explorerTxUrl(hash: string): string | null; + parseError(error: unknown, fallback: string): { message: string; isUserRejection: boolean; isContractError: boolean }; +} + +export interface ChainAdapter { + kind: 'evm' | 'solana' | 'none'; + address: string | null; + isConnected: boolean; + capabilities: ChainCapabilities; + + pets: { + data: Pet[]; + isLoading: boolean; + error: Error | null; + refetch(): void; + }; + + // petId is always string; adapters convert to bigint/number internally. + // DNA/rarity are derived from VRF randomness at settle time on both chains, + // so mint takes only a name. + createPet: AdapterMutation<{ name: string }>; + levelUpPet: AdapterMutation<{ petId: string }>; + /** v2 train: pay a level-scaled fee for flat XP. */ + trainPet: AdapterMutation<{ petId: string }>; + renamePet: AdapterMutation<{ petId: string; name: string }>; + transferPet: AdapterMutation<{ petId: string; to: string }>; + // crossOwner adds the stud fee (EVM married cross-owner breeding); ignored on Solana. + breedPets: AdapterMutation<{ parentId1: string; parentId2: string; name: string; crossOwner?: boolean }>; +} diff --git a/shared/src/hooks/adapters/useSolanaAdapter.ts b/shared/src/hooks/adapters/useSolanaAdapter.ts index 5ca55e8b..f0476434 100644 --- a/shared/src/hooks/adapters/useSolanaAdapter.ts +++ b/shared/src/hooks/adapters/useSolanaAdapter.ts @@ -19,7 +19,7 @@ export const SOLANA_CAPABILITIES: ChainCapabilities = { }, levelUpFee: null, renameMinLevel: 1, - randomness: { provider: 'switchboard', appliesTo: ['battle', 'breed'] }, + randomness: { provider: 'switchboard', appliesTo: ['breed'] }, explorerTxUrl: () => null, parseError: (err, fallback) => { const message = formatSolanaActionError(err, fallback); diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index f8594097..c4796cc5 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -26,6 +26,9 @@ export { useActiveChain, type ActiveChain } from './useActiveChain'; export { useChainCapabilities, type ChainContext } from './useChainCapabilities'; export type { TxLifecycle, TxPhase, ChainCapabilities } from './adapters/types'; export { usePetList, type PetListResult } from './usePetList'; +// Backend battle progression. usePetList already applies it to a player's own pets; +// exported for anything reading pets from the chain by another route. +export { useBattleProgress, mergeBattleProgress } from './useBattleProgress'; export { useCreatePet, type CreatePetArgs, diff --git a/shared/src/hooks/useBattleProgress.ts b/shared/src/hooks/useBattleProgress.ts new file mode 100644 index 00000000..ca12352f --- /dev/null +++ b/shared/src/hooks/useBattleProgress.ts @@ -0,0 +1,95 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useApiClient } from '../contexts/ApiClientContext'; +import { useAuth } from '../contexts/AuthContext'; +import type { Pet, PetChain } from '../types/pet'; + +const BATTLE_PROGRESS_QUERY = ` + query BattleProgress($chain: String!, $petIds: [String!]!) { + battleProgress(chain: $chain, petIds: $petIds) { + id level xp winCount lossCount readyAt + } + } +`; + +interface ProgressDto { + id: string; + level: number; + xp: number; + winCount: number; + lossCount: number; + readyAt: number; +} + +interface GraphQLResponse { + data?: { battleProgress: ProgressDto[] }; + errors?: { message: string }[]; +} + +/** + * Applies one pet's backend progression, if it has any. + * + * Exported for the merge test: the rule is small but it is the whole reason a player's + * level stops being wrong, so it is pinned rather than left implicit in a `useMemo`. + */ +export const mergeBattleProgress = (pet: Pet, progress: ProgressDto | undefined): Pet => { + if (!progress) { + return pet; + } + return { + ...pet, + level: progress.level, + xp: progress.xp, + winCount: progress.winCount, + lossCount: progress.lossCount, + // Independent cooldowns with different owners: breeding writes the on-chain one, + // battles write the backend one. The pet is ready only when neither holds it. + readyAt: Math.max(pet.readyAt, progress.readyAt), + }; +}; + +/** + * Merges backend battle progression into pets read straight from the chain. + * + * A player's own pet list comes from PetCore / the Solana program directly, and battles + * stopped writing there when they moved off chain (§L Phase 6). Without this a player who + * has won fifty backend battles still sees the level, XP and win/loss they minted with. + * + * The backend's own reads (opponents, pet detail, search) already merge this server-side; + * this hook exists solely for the one surface that cannot, because the client — not the + * backend — is the one holding the chain data. + * + * Degrades to unmerged chain values on any failure. That is the honest fallback: stale + * progression is a worse number, an error is a missing pet list. + */ +export const useBattleProgress = (chain: PetChain | null, pets: Pet[]): Pet[] => { + const apiClient = useApiClient(); + const { isAuthenticated } = useAuth(); + const baseURL = apiClient.defaults.baseURL ?? ''; + + // Sorted so the key is stable under pet-list reordering, which would otherwise refetch. + const petIds = useMemo(() => pets.map((pet) => pet.id).sort(), [pets]); + + const query = useQuery({ + queryKey: ['battleProgress', baseURL, chain, petIds], + enabled: chain != null && isAuthenticated && petIds.length > 0, + queryFn: async () => { + const { data } = await apiClient.post('/graphql', { + query: BATTLE_PROGRESS_QUERY, + variables: { chain, petIds }, + }); + if (data.errors?.length) { + throw new Error(data.errors.map((e) => e.message).join('; ')); + } + return data.data?.battleProgress ?? []; + }, + }); + + return useMemo(() => { + if (!query.data?.length) { + return pets; + } + const byPetId = new Map(query.data.map((row) => [row.id, row])); + return pets.map((pet) => mergeBattleProgress(pet, byPetId.get(pet.id))); + }, [pets, query.data]); +}; diff --git a/shared/src/hooks/usePetList.ts b/shared/src/hooks/usePetList.ts index 48e193ca..11690c2e 100644 --- a/shared/src/hooks/usePetList.ts +++ b/shared/src/hooks/usePetList.ts @@ -1,4 +1,6 @@ import { useChainAdapter } from './adapters/useChainAdapter'; +import { useActiveChain } from './useActiveChain'; +import { useBattleProgress } from './useBattleProgress'; import type { Pet } from '../types/pet'; export interface PetListResult { @@ -8,10 +10,22 @@ export interface PetListResult { refetch: () => void; } +/** + * The player's own pets, as the UI should show them. + * + * The adapter returns chain truth. Battles stopped settling on chain (§L Phase 6), so + * level, XP and win/loss come from the backend's progression record instead — see + * {@link useBattleProgress}. This is the single seam where the two are combined, so every + * surface listing a player's pets shows the same numbers as the opponent list, which the + * backend already merges server-side. + */ export const usePetList = (): PetListResult => { const { pets } = useChainAdapter(); + const chain = useActiveChain(); + const merged = useBattleProgress(chain.kind === 'none' ? null : chain.kind, pets.data); + return { - pets: pets.data, + pets: merged, isLoading: pets.isLoading, error: pets.error, refetch: pets.refetch, diff --git a/shared/tests/hooks/useBattleProgress.test.ts b/shared/tests/hooks/useBattleProgress.test.ts new file mode 100644 index 00000000..08c8f073 --- /dev/null +++ b/shared/tests/hooks/useBattleProgress.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeBattleProgress } from '../../src/hooks/useBattleProgress'; +import type { Pet } from '../../src/types/pet'; + +/** + * The client-side half of the same merge the backend applies to opponents + * (`battleProgress.overlay.ts`). Both must agree, or a player's own pet would show a + * different level in their gallery than it does in someone else's opponent list. + */ + +const chainPet: Pet = { + id: '7', + chain: 'evm', + name: 'Rex', + dna: 1234567890123456n, + level: 3, + rarity: 2, + winCount: 1, + lossCount: 0, + readyAt: 100, + xp: 40, +}; + +const progress = { id: '7', level: 12, xp: 340, winCount: 25, lossCount: 4, readyAt: 500 }; + +describe('mergeBattleProgress', () => { + it('leaves a pet with no backend record on chain truth', () => { + expect(mergeBattleProgress(chainPet, undefined)).toBe(chainPet); + }); + + it('takes level, xp and the win/loss record from backend progression', () => { + const merged = mergeBattleProgress(chainPet, progress); + + expect(merged.level).toBe(12); + expect(merged.xp).toBe(340); + expect(merged.winCount).toBe(25); + expect(merged.lossCount).toBe(4); + }); + + it('leaves chain-owned fields alone', () => { + const merged = mergeBattleProgress(chainPet, progress); + + expect(merged.dna).toBe(1234567890123456n); + expect(merged.rarity).toBe(2); + expect(merged.name).toBe('Rex'); + }); + + it('takes the later of the two cooldowns', () => { + expect(mergeBattleProgress(chainPet, progress).readyAt).toBe(500); + // A newly bred pet's on-chain newborn lockout outlives its backend cooldown. + expect(mergeBattleProgress({ ...chainPet, readyAt: 9000 }, progress).readyAt).toBe(9000); + }); + + it('does not mutate the pet it was given', () => { + mergeBattleProgress(chainPet, progress); + + expect(chainPet.level).toBe(3); + expect(chainPet.readyAt).toBe(100); + }); +}); diff --git a/shared/tests/hooks/usePetList.test.ts b/shared/tests/hooks/usePetList.test.ts index 0353b104..1ef6f023 100644 --- a/shared/tests/hooks/usePetList.test.ts +++ b/shared/tests/hooks/usePetList.test.ts @@ -12,6 +12,16 @@ vi.mock('../../src/hooks/adapters/useChainAdapter', () => ({ useChainAdapter: () => ({ pets }), })); +const activeChain = { kind: 'evm' as 'evm' | 'solana' | 'none' }; +vi.mock('../../src/hooks/useActiveChain', () => ({ + useActiveChain: () => activeChain, +})); + +const useBattleProgress = vi.fn((_chain: unknown, list: Pet[]) => list); +vi.mock('../../src/hooks/useBattleProgress', () => ({ + useBattleProgress: (chain: unknown, list: Pet[]) => useBattleProgress(chain, list), +})); + import { usePetList } from '../../src/hooks/usePetList'; const pet = { @@ -33,6 +43,9 @@ beforeEach(() => { pets.isLoading = false; pets.error = null; pets.refetch = vi.fn(); + activeChain.kind = 'evm'; + useBattleProgress.mockClear(); + useBattleProgress.mockImplementation((_chain, list) => list); }); describe('usePetList', () => { @@ -59,4 +72,31 @@ describe('usePetList', () => { expect(pets.refetch).toHaveBeenCalledOnce(); }); + + it('returns pets with backend progression applied, not the raw chain read', () => { + // The whole point of the seam: battles no longer move on-chain stats, so what the + // adapter hands back is stale for any pet that has fought. + pets.data = [pet]; + const levelled = { ...pet, level: 12, winCount: 25 }; + useBattleProgress.mockReturnValue([levelled]); + + expect(usePetList().pets).toEqual([levelled]); + }); + + it('passes the active chain through so progression is looked up on the right one', () => { + activeChain.kind = 'solana'; + pets.data = [pet]; + + usePetList(); + + expect(useBattleProgress).toHaveBeenCalledWith('solana', [pet]); + }); + + it('asks for no progression while disconnected', () => { + activeChain.kind = 'none'; + + usePetList(); + + expect(useBattleProgress).toHaveBeenCalledWith(null, []); + }); }); From 695daad6ffd026055d26b870a76585a61e58b109 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 21:09:41 -0400 Subject: [PATCH 53/76] fix(backend): band matchmaking on merged progression, not frozen chain level --- CLAUDE.md | 2 +- backend/src/config/env.ts | 11 +- backend/src/graphql/resolvers.ts | 16 +-- backend/src/grpc/rosterReads.ts | 71 +++--------- .../repositories/battleProgress.overlay.ts | 2 +- backend/src/repositories/roster.repository.ts | 108 +++++++++++++++--- backend/tests/grpc/rosterReads.test.ts | 11 +- .../repositories/roster.repository.test.ts | 108 ++++++++++++++++-- 8 files changed, 212 insertions(+), 117 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5170d523..c56ce46c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,7 +162,7 @@ Both apply the same rule: a pet with a progress row shows backend progression, o The merge is deliberately **not** in `roster.repository.ts`. `snapshot.builder.ts` seeds a first progress row from the roster's on-chain level and `intent.service.ts` checks ownership there; merging in the repository would feed overlaid progression back into the thing that produces it. -Two known gaps, both in matchmaking rather than display: `opponents` post-filters on the merged cooldown, so a page can be shorter than `pageSize` and `total` is an upper bound; and `minLevel` still bands on **on-chain** level, so a pet that climbed through backend battles can still be offered to a low-level challenger. Closing that needs the band pushed into the query as a join. +Matchmaking is the exception to the two-site split above: `findReadyOpponents` filters, bands and orders on level and cooldown, so it merges in the query itself (a raw `LEFT JOIN` against `pet_battle_progress`) rather than being overlaid afterwards. A post-filter can only drop rows a page already holds, which fixes the cooldown and leaves the level band reading frozen values. The cost is that this one query has **no gRPC fast path**: indexer-go's cache holds chain state and has no view of `pet_battle_progress`, a backend-owned table, so it can no longer answer it correctly. `getPetById` keeps its cache path, because there the resolver does the merge. ### Known v1 contract limitations (design context, not regressions to "fix") `contracts/plan-contract-upgrade.md` documents intentional v1 gaps that v2 is designed around: no battle authorization (anyone can call `battle()`/`attack()` on anyone's pets), an EVM `changeDna` cheat that lets a level-20 pet set arbitrary DNA, and a Solana `create_starter_pet` that accepts client-supplied dna/rarity. v2 plan: EVM moves to UUPS proxies (`PetCoreProxy` + `GameLogicProxy`, with `CombatSimV1` deployed as a separate contract to stay under the 24KB bytecode ceiling); Solana adds versioned/reserved-space accounts and migrates pets to Metaplex Core NFTs. This is a plan doc; check current contract source before assuming any of it is implemented. diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index eb4bedd8..db10d6ac 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -70,10 +70,13 @@ export const env = { }, /** - * Where roster reads (matchmaking) are answered: 'grpc' = indexer-go's - * RAM cache with automatic Prisma fallback; 'postgres' (default) = Prisma - * only. The instant kill switch for the milestone 8 read path — flip back - * without redeploying indexer-go. + * Where roster reads are answered: 'grpc' = indexer-go's RAM cache with automatic + * Prisma fallback; 'postgres' (default) = Prisma only. The instant kill switch for the + * milestone 8 read path — flip back without redeploying indexer-go. + * + * No longer covers matchmaking. `findReadyOpponents` filters and bands on merged + * backend progression, which the cache has no view of, so it always reads Postgres + * regardless of this setting. Pet-detail reads still honour it. */ rosterReadSource: process.env.ROSTER_READ_SOURCE?.trim().toLowerCase() === 'grpc' ? 'grpc' : 'postgres', diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index d0b7740e..87ea771c 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -84,20 +84,10 @@ export const rootValue = { pageSize, }); - // The query filters and bands on chain truth, which no longer moves for battles. - // Overlaying progression here fixes the levels shown, and drops anyone still on a - // backend cooldown that `pet_roster.ready_at` knows nothing about. Two knock-on - // effects, both deliberate: - // - a page can come back shorter than `pageSize`, and `total` is an upper bound. - // Matchmaking is a browse, not a ledger; a short page costs nothing. - // - `minLevel` still bands on the pet's on-chain level, so a pet that has climbed - // through backend battles can still be offered to a low-level challenger. Fixing - // that needs the band pushed into the query as a join, not a post-filter. - const overlaid = await withBattleProgress(args.chain, rows); - const now = BigInt(Math.floor(Date.now() / 1000)); - + // No overlay here: unlike the other pet reads, `findReadyOpponents` filters, bands + // and orders on level and cooldown, so it merges progression in the query itself. return { - opponents: overlaid.filter((pet) => pet.readyAt <= now).map(toOpponentPet), + opponents: rows.map(toOpponentPet), total, page, pageSize, diff --git a/backend/src/grpc/rosterReads.ts b/backend/src/grpc/rosterReads.ts index 56988f68..919e59ca 100644 --- a/backend/src/grpc/rosterReads.ts +++ b/backend/src/grpc/rosterReads.ts @@ -2,16 +2,20 @@ import * as grpc from '@grpc/grpc-js'; import { env } from '@config/env'; import { loadGameDataService } from './gameData'; import { createCircuitBreaker } from './circuitBreaker'; -import type { FindOpponentsParams, RosterPet } from '@repositories/roster.repository'; +import type { RosterPet } from '@repositories/roster.repository'; import { mapPetWireToRosterPet, type PetWire } from '@repositories/roster.mapping'; import type { Chain } from '@typings/chain'; /** - * gRPC-backed roster reads from indexer-go's write-through cache. Fail-open by - * contract: every error, timeout, or UNAVAILABLE returns null and the caller - * falls back to Prisma — killing indexer-go must never take reads down. A - * small circuit breaker stops a dead Go process from adding the deadline to - * every read. + * gRPC-backed roster reads from indexer-go's write-through cache. Fail-open by contract: + * every error, timeout, or UNAVAILABLE returns null and the caller falls back to Prisma — + * killing indexer-go must never take reads down. A small circuit breaker stops a dead Go + * process from adding the deadline to every read. + * + * Only the single-pet read is left. Matchmaking stopped using the cache when it began + * banding on backend progression (`roster.repository.ts`), which the cache cannot see. + * indexer-go still serves `ListReadyOpponents` and its tests still cover it; nothing in + * this repo calls it now. */ /** Per-call deadline. The cache answers from RAM; anything slower is a fault. */ @@ -21,17 +25,7 @@ const BREAKER_THRESHOLD = 3; /** How long an open breaker skips gRPC before probing again. */ const BREAKER_COOLDOWN_MS = 30_000; -interface OpponentsWire { - pets: PetWire[]; - total: number; -} - type RosterClient = grpc.Client & { - listReadyOpponents( - request: Record, - options: grpc.CallOptions, - callback: (err: grpc.ServiceError | null, res: OpponentsWire) => void, - ): void; getPetState( request: Record, options: grpc.CallOptions, @@ -58,48 +52,9 @@ function getClient(): RosterClient | null { } /** - * Matchmaking read via indexer-go. Returns null whenever Prisma should answer - * instead (feature off, breaker open, timeout, any error). - */ -export function tryGrpcFindReadyOpponents( - params: FindOpponentsParams, -): Promise<{ rows: RosterPet[]; total: number } | null> { - if (env.rosterReadSource !== 'grpc' || !breaker.allows()) return Promise.resolve(null); - const rosterClient = getClient(); - if (!rosterClient) return Promise.resolve(null); - - return new Promise((resolve) => { - const deadline = new Date(Date.now() + DEADLINE_MS); - rosterClient.listReadyOpponents( - { - chain: params.chain, - excludeOwner: params.excludeOwner, - minLevel: params.minLevel, - page: params.page, - pageSize: params.pageSize, - }, - { deadline }, - (err, res) => { - if (err) { - breaker.recordFailure(err.message); - resolve(null); - return; - } - breaker.recordSuccess(); - resolve({ - rows: res.pets.map(mapPetWireToRosterPet), - total: res.total, - }); - }, - ); - }); -} - -/** - * Single-pet read via indexer-go (pet-detail). Same fail-open contract as the - * matchmaking read: returns null whenever Prisma should answer instead (feature - * off, breaker open, timeout, any error) or when the cache has no such pet (an - * empty row — distinguished by the absent id). + * Single-pet read via indexer-go (pet-detail). Fail-open: returns null whenever Prisma + * should answer instead (feature off, breaker open, timeout, any error) or when the cache + * has no such pet (an empty row — distinguished by the absent id). */ export function tryGrpcGetPetState(chain: Chain, petId: string): Promise { if (env.rosterReadSource !== 'grpc' || !breaker.allows()) return Promise.resolve(null); diff --git a/backend/src/repositories/battleProgress.overlay.ts b/backend/src/repositories/battleProgress.overlay.ts index 3deeacfc..225dd387 100644 --- a/backend/src/repositories/battleProgress.overlay.ts +++ b/backend/src/repositories/battleProgress.overlay.ts @@ -69,7 +69,7 @@ export function overlayRosterPet(pet: RosterPet, progress: ProgressRow | undefin * rather than defensive: there is no progression to show for a chain this process does * not run battles for. */ -function servedChainIdForFamily(chain: Chain): ChainId | null { +export function servedChainIdForFamily(chain: Chain): ChainId | null { const matches = servedChainIds().filter((chainId) => chainFamily(chainId) === chain); return matches.length === 1 ? (matches[0] ?? null) : null; } diff --git a/backend/src/repositories/roster.repository.ts b/backend/src/repositories/roster.repository.ts index 3b7201d0..f8d377a0 100644 --- a/backend/src/repositories/roster.repository.ts +++ b/backend/src/repositories/roster.repository.ts @@ -1,12 +1,18 @@ import { prisma } from '@config/prisma'; -import { tryGrpcFindReadyOpponents, tryGrpcGetPetState } from '@grpc-client/rosterReads'; -import { mapRosterRowToRosterPet } from './roster.mapping'; +import { tryGrpcGetPetState } from '@grpc-client/rosterReads'; +import { mapRosterRowToRosterPet, type PetRosterRow } from './roster.mapping'; +import { servedChainIdForFamily } from './battleProgress.overlay'; +import { servedDeploymentId } from '@features/battle-ledger/domain'; import type { Chain } from '@typings/chain'; /** - * Read access layer for the `pet_roster` table. indexer-go is the sole writer - * now (it owns event decoding + the write-through cache), so the backend only - * reads here — the matchmaking query, with a gRPC-cache fast path. + * Read access layer for the `pet_roster` table. indexer-go is the sole writer now (it owns + * event decoding + the write-through cache), so the backend only reads here. + * + * Everything but `findReadyOpponents` returns chain state unchanged — callers that display + * pets merge backend progression on top (`battleProgress.overlay.ts`), and two callers + * (`snapshot.builder.ts`, `intent.service.ts`) specifically need the unmerged values. + * `findReadyOpponents` is the exception, for the reason given on it. */ /** A roster row (the shape indexer-go writes; the read paths project to it). */ @@ -45,24 +51,93 @@ export interface FindOpponentsParams { } /** - * Battle-ready opponents the caller does not own: off cooldown - * (`readyAt <= now`), excluding `excludeOwner`, optionally above a level, paged. + * Battle-ready opponents the caller does not own: off cooldown, excluding + * `excludeOwner`, optionally above a level, paged. + * + * Unlike every other read here this one is *merged*, not a projection of chain + * state, and it has to be: level and cooldown are what it filters, bands and + * orders on, and both moved to `pet_battle_progress` when battles left the chain + * (§L Phase 6). Filtering on the roster's frozen columns would offer a pet that + * climbed to level 20 through backend battles to a level-3 challenger, and would + * offer a pet that fought thirty seconds ago as available. So the join happens in + * the query, where the filter can see the merged values — a post-filter can only + * drop rows a page already contains, which fixes the cooldown and not the band. * - * With ROSTER_READ_SOURCE=grpc this is answered from indexer-go's RAM cache - * first (taking the hottest read off the connection-limited Postgres); any - * gRPC failure silently falls back to the Prisma query below — fail-open. + * Two consequences of that: + * - There is no gRPC fast path. indexer-go's cache holds chain state and has no + * view of `pet_battle_progress` (a backend-owned table it has no business + * reading), so it can no longer answer this question correctly. The other + * reads here keep theirs: they return chain truth and are merged by the caller. + * - When this deployment serves no chain of `params.chain`'s family there is no + * progression to join, so the plain roster query below is exactly right. */ export async function findReadyOpponents( params: FindOpponentsParams ): Promise<{ rows: RosterPet[]; total: number }> { - const viaGrpc = await tryGrpcFindReadyOpponents(params); - if (viaGrpc) return viaGrpc; + const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); + const chainId = servedChainIdForFamily(params.chain); + if (!chainId) { + return findReadyOpponentsFromChainState(params, nowSeconds); + } + + const deploymentId = servedDeploymentId(); + const skip = params.page * params.pageSize; + + // COALESCE, not a merge of individual columns: a progress row supplies all four + // progression values or none of them, matching `overlayRosterPet`. + const [rows, counted] = await Promise.all([ + prisma.$queryRaw` + SELECT r.chain, r.pet_id AS "petId", r.owner, r.name, r.rarity, r.dna, + COALESCE(p.level, r.level) AS level, + COALESCE(p.xp, r.xp) AS xp, + COALESCE(p.win_count, r.win_count) AS "winCount", + COALESCE(p.loss_count, r.loss_count) AS "lossCount", + GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) AS "readyAt", + r.generation, r.parent1_id AS "parent1Id", r.parent2_id AS "parent2Id", + r.breed_count AS "breedCount", r.species_id AS "speciesId", + r.spouse_id AS "spouseId", r.breed_ready_at AS "breedReadyAt", + r.train_ready_at AS "trainReadyAt", r.asset + 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} + AND r.owner <> ${params.excludeOwner} + AND GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) <= ${nowSeconds} + AND COALESCE(p.level, r.level) >= ${params.minLevel} + ORDER BY COALESCE(p.level, r.level) ASC, r.pet_id ASC + LIMIT ${params.pageSize} OFFSET ${skip} + `, + prisma.$queryRaw<{ total: bigint }[]>` + SELECT COUNT(*) AS total + 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} + AND r.owner <> ${params.excludeOwner} + AND GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) <= ${nowSeconds} + AND COALESCE(p.level, r.level) >= ${params.minLevel} + `, + ]); - const nowSeconds = Math.floor(Date.now() / 1000); + return { + rows: rows.map(mapRosterRowToRosterPet), + total: Number(counted[0]?.total ?? 0), + }; +} + +/** The same query without progression, for a chain family this deployment does not serve. */ +async function findReadyOpponentsFromChainState( + params: FindOpponentsParams, + nowSeconds: bigint, +): Promise<{ rows: RosterPet[]; total: number }> { const where = { chain: params.chain, owner: { not: params.excludeOwner }, - readyAt: { lte: BigInt(nowSeconds) }, + readyAt: { lte: nowSeconds }, ...(params.minLevel > 0 ? { level: { gte: params.minLevel } } : {}), }; @@ -76,10 +151,7 @@ export async function findReadyOpponents( prisma.petRoster.count({ where }), ]); - return { - rows: rows.map(mapRosterRowToRosterPet), - total, - }; + return { rows: rows.map(mapRosterRowToRosterPet), total }; } export interface SearchPetsParams { diff --git a/backend/tests/grpc/rosterReads.test.ts b/backend/tests/grpc/rosterReads.test.ts index e65d51ac..9af6d6ff 100644 --- a/backend/tests/grpc/rosterReads.test.ts +++ b/backend/tests/grpc/rosterReads.test.ts @@ -5,16 +5,7 @@ vi.mock('@config/env', () => ({ })); vi.mock('../../src/grpc/gameData', () => ({ loadGameDataService: vi.fn() })); -import { tryGrpcFindReadyOpponents, tryGrpcGetPetState } from '../../src/grpc/rosterReads'; - -describe('tryGrpcFindReadyOpponents', () => { - it('returns null when no gRPC address is configured', async () => { - const result = await tryGrpcFindReadyOpponents({ - chain: 'evm', excludeOwner: '0x', minLevel: 0, page: 0, pageSize: 20, - }); - expect(result).toBeNull(); - }); -}); +import { tryGrpcGetPetState } from '../../src/grpc/rosterReads'; describe('tryGrpcGetPetState', () => { it('returns null when no gRPC address is configured', async () => { diff --git a/backend/tests/repositories/roster.repository.test.ts b/backend/tests/repositories/roster.repository.test.ts index 4f274c19..77449bc6 100644 --- a/backend/tests/repositories/roster.repository.test.ts +++ b/backend/tests/repositories/roster.repository.test.ts @@ -7,13 +7,18 @@ vi.mock('@config/prisma', () => ({ count: vi.fn(), findUnique: vi.fn(), }, + $queryRaw: vi.fn(), }, })); vi.mock('../../src/grpc/rosterReads', () => ({ - tryGrpcFindReadyOpponents: vi.fn().mockResolvedValue(null), tryGrpcGetPetState: vi.fn().mockResolvedValue(null), })); +const servedChainIdForFamily = vi.fn(() => 'eip155:31337' as string | null); +vi.mock('../../src/repositories/battleProgress.overlay', () => ({ + servedChainIdForFamily: (chain: string) => servedChainIdForFamily(chain), +})); + import { findReadyOpponents, getPetById } from '../../src/repositories/roster.repository'; import { prisma } from '@config/prisma'; @@ -40,12 +45,27 @@ const rosterRow = { asset: '', }; -beforeEach(() => { vi.clearAllMocks(); }); +/** `$queryRaw` is called twice per lookup: the page, then its count. */ +function mockJoinQuery(rows: unknown[], total: number) { + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce(rows as never) + .mockResolvedValueOnce([{ total: BigInt(total) }] as never); +} + +/** The SQL text of the nth `$queryRaw` call, whitespace-collapsed for matching. */ +function sqlOfCall(index: number): string { + const [template] = vi.mocked(prisma.$queryRaw).mock.calls[index] as unknown as [string[]]; + return template.join(' ? ').replace(/\s+/g, ' '); +} + +beforeEach(() => { + vi.clearAllMocks(); + servedChainIdForFamily.mockReturnValue('eip155:31337'); +}); describe('findReadyOpponents', () => { - it('returns Prisma rows when gRPC is unavailable', async () => { - vi.mocked(prisma.petRoster.findMany).mockResolvedValue([rosterRow] as never); - vi.mocked(prisma.petRoster.count).mockResolvedValue(1); + it('returns the joined rows and their count', async () => { + mockJoinQuery([rosterRow], 1); const result = await findReadyOpponents({ chain: 'evm', @@ -60,24 +80,77 @@ describe('findReadyOpponents', () => { expect(result.rows[0].readyAt).toBe(0n); }); - it('excludes minLevel filter when minLevel is 0', async () => { - vi.mocked(prisma.petRoster.findMany).mockResolvedValue([]); - vi.mocked(prisma.petRoster.count).mockResolvedValue(0); + it('bands and orders on the merged level, not the frozen on-chain one', async () => { + // The whole point of doing this in SQL: a pet that climbed through backend battles + // must be banded at the level it actually reached. + mockJoinQuery([], 0); + + await findReadyOpponents({ chain: 'evm', excludeOwner: '0x', minLevel: 3, page: 0, pageSize: 10 }); + + const sql = sqlOfCall(0); + expect(sql).toContain('COALESCE(p.level, r.level) >='); + expect(sql).toContain('ORDER BY COALESCE(p.level, r.level) ASC'); + expect(sql).not.toMatch(/WHERE[\s\S]*r\.level >=/); + }); + + it('filters on the later of the two cooldowns', async () => { + // Breeding writes the on-chain lockout, battles write the backend one; a pet held + // by either is not available. + mockJoinQuery([], 0); await findReadyOpponents({ chain: 'evm', excludeOwner: '0x', minLevel: 0, page: 0, pageSize: 10 }); + expect(sqlOfCall(0)).toContain('GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) <='); + }); + + it('counts with the same filter it pages with', async () => { + // A count over a different predicate would page past the end of the real result. + mockJoinQuery([], 0); + + await findReadyOpponents({ chain: 'evm', excludeOwner: '0x', minLevel: 3, page: 0, pageSize: 10 }); + + const page = sqlOfCall(0); + const count = sqlOfCall(1); + for (const clause of [ + 'COALESCE(p.level, r.level) >=', + 'GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) <=', + 'r.owner <>', + ]) { + expect(page).toContain(clause); + expect(count).toContain(clause); + } + }); + + it('falls back to the plain roster query for an unserved chain family', async () => { + // Nothing to join: no progression exists for a chain this deployment does not run + // battles for, so the frozen columns are the whole truth. + servedChainIdForFamily.mockReturnValue(null); + vi.mocked(prisma.petRoster.findMany).mockResolvedValue([rosterRow] as never); + vi.mocked(prisma.petRoster.count).mockResolvedValue(1); + + const result = await findReadyOpponents({ + chain: 'solana', + excludeOwner: '0x', + minLevel: 3, + page: 0, + pageSize: 10, + }); + + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + expect(result.total).toBe(1); const where = vi.mocked(prisma.petRoster.findMany).mock.calls[0][0].where; - expect(where).not.toHaveProperty('level'); + expect(where.level).toEqual({ gte: 3 }); }); - it('includes level filter when minLevel > 0', async () => { + it('omits the level filter entirely when minLevel is 0 on the fallback path', async () => { + servedChainIdForFamily.mockReturnValue(null); vi.mocked(prisma.petRoster.findMany).mockResolvedValue([]); vi.mocked(prisma.petRoster.count).mockResolvedValue(0); - await findReadyOpponents({ chain: 'evm', excludeOwner: '0x', minLevel: 3, page: 0, pageSize: 10 }); + await findReadyOpponents({ chain: 'solana', excludeOwner: '0x', minLevel: 0, page: 0, pageSize: 10 }); const where = vi.mocked(prisma.petRoster.findMany).mock.calls[0][0].where; - expect(where.level).toEqual({ gte: 3 }); + expect(where).not.toHaveProperty('level'); }); }); @@ -93,4 +166,15 @@ describe('getPetById', () => { vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(null); expect(await getPetById('evm', '99')).toBeNull(); }); + + it('returns chain state unmerged, for callers that need it that way', async () => { + // snapshot.builder.ts seeds a pet's first progress row from this level; merging + // here would feed backend progression back into its own source. + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(rosterRow as never); + + const result = await getPetById('evm', '1'); + + expect(result?.level).toBe(5); + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + }); }); From 2418845bf1c5218807aad643d60e965b5e7ed143 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 26 Jul 2026 21:35:08 -0400 Subject: [PATCH 54/76] fix(backend): record battle history from the receipt, not the client --- CLAUDE.md | 9 + backend/src/config/env.ts | 8 +- .../src/features/battle-worker/sign.worker.ts | 21 ++ backend/src/features/dialogue/context.ts | 26 ++- backend/src/features/dialogue/llm/render.ts | 11 +- backend/src/features/dialogue/recording.ts | 52 +---- .../dialogue/result/result.service.ts | 53 ++--- backend/src/features/dialogue/result/turns.ts | 6 +- backend/src/features/settle-keeper/index.ts | 5 +- backend/src/grpc/battleStream.ts | 195 ------------------ .../src/repositories/history.repository.ts | 112 +++++++--- backend/src/server.ts | 4 - .../battle-worker/sign.worker.test.ts | 29 +++ .../tests/features/dialogue/context.test.ts | 32 +-- .../dialogue/llm/renderSummary.test.ts | 14 +- .../tests/features/dialogue/recording.test.ts | 41 +--- .../dialogue/result/result.service.test.ts | 13 +- backend/tests/grpc/battleStream.test.ts | 17 -- .../repositories/history.repository.test.ts | 67 +++++- 19 files changed, 308 insertions(+), 407 deletions(-) delete mode 100644 backend/src/grpc/battleStream.ts delete mode 100644 backend/tests/grpc/battleStream.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c56ce46c..5bc223e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,6 +164,15 @@ The merge is deliberately **not** in `roster.repository.ts`. `snapshot.builder.t Matchmaking is the exception to the two-site split above: `findReadyOpponents` filters, bands and orders on level and cooldown, so it merges in the query itself (a raw `LEFT JOIN` against `pet_battle_progress`) rather than being overlaid afterwards. A post-filter can only drop rows a page already holds, which fixes the cooldown and leaves the level band reading frozen values. The cost is that this one query has **no gRPC fast path**: indexer-go's cache holds chain state and has no view of `pet_battle_progress`, a backend-owned table, so it can no longer answer it correctly. `getPetById` keeps its cache path, because there the resolver does the merge. +### battle_history comes from the receipt now, not the indexer +`battle_history` feeds the AI dialogue service's rivalry / head-to-head context. It used to be written by the indexer from on-chain settle events, with the dialogue endpoint filling gaps from the client's own result report. Both sources are gone: there are no settle events, and a client-reported result must never be able to restate what was signed. + +The battle worker writes the row from the signed receipt, in the **same transaction** as the receipt itself, so a battle cannot be recorded without its receipt or the reverse. `foughtAt` is unix seconds, taken from `receipt.createdAt` — the removed client-report path wrote `Date.now()` milliseconds, so any row predating this sorts far in the future in `getRecentForm`. + +The dialogue endpoint's anti-forgery guard still exists but now compares the client's claimed winner against that recorded row rather than against a chain event. It stays permissive when the battle is not yet on record (dialogue can be requested before the receipt commits), which is acceptable because it now only protects the dialogue cache — the battle record itself is no longer writable from that path. + +Consequently `src/grpc/battleStream.ts` is deleted: nothing published to it after indexer-go stopped ingesting battles, and nothing read from it after this. `INDEXER_GRPC_ADDR` still matters for pet-state reads and win estimates. indexer-go still serves `StreamLiveBattles` and `ListReadyOpponents`; neither has a caller in this repo. + ### Known v1 contract limitations (design context, not regressions to "fix") `contracts/plan-contract-upgrade.md` documents intentional v1 gaps that v2 is designed around: no battle authorization (anyone can call `battle()`/`attack()` on anyone's pets), an EVM `changeDna` cheat that lets a level-20 pet set arbitrary DNA, and a Solana `create_starter_pet` that accepts client-supplied dna/rarity. v2 plan: EVM moves to UUPS proxies (`PetCoreProxy` + `GameLogicProxy`, with `CombatSimV1` deployed as a separate contract to stay under the 24KB bytecode ceiling); Solana adds versioned/reserved-space accounts and migrates pets to Metaplex Core NFTs. This is a plan doc; check current contract source before assuming any of it is implemented. diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index db10d6ac..55ca9d16 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -59,8 +59,12 @@ export const env = { }, /** - * indexer-go gRPC link (StreamLiveBattles — chain-truth battle pushes). - * Optional: unset = feature off, the webhook/poll paths still work. + * indexer-go gRPC link (pet-state reads and win estimates). Optional: unset = those + * fall back to Postgres and to "odds unavailable" respectively. + * + * No longer carries battles. `StreamLiveBattles` pushed chain-truth settle events, + * which stopped existing with on-chain battles (§L Phase 6); the backend's own signed + * receipt is the record now. */ indexerGrpc: { /** e.g. localhost:50051. */ diff --git a/backend/src/features/battle-worker/sign.worker.ts b/backend/src/features/battle-worker/sign.worker.ts index df01869f..3e783c6a 100644 --- a/backend/src/features/battle-worker/sign.worker.ts +++ b/backend/src/features/battle-worker/sign.worker.ts @@ -1,6 +1,7 @@ import { type BattleReceipt, type BattleSnapshot, + chainFamily, hashBattleReceipt, type Hex, type ProgressionDelta, @@ -12,6 +13,7 @@ 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 { recordBattleFromReceipt } from '@repositories/history.repository'; import { notifyBattleRoomIfPresent } from '@ws/battleRoomSocket'; /** @@ -191,6 +193,25 @@ export async function processSignMessage(message: ClaimedMessage, nowSeconds: nu }, }); await applyProgression(tx, battle, progression, signed.digest, nowSeconds); + // Rivalry context for the dialogue service (`battle_history`). Written + // here, from the receipt, because the indexer that used to fill this + // table decoded on-chain settle events and there are none any more. + await recordBattleFromReceipt(tx, { + chain: chainFamily(battle.chainId as never), + battleId: battle.battleId, + attacker: battle.attackerPetId, + defender: battle.defenderPetId, + // From the receipt, not the ledger row it was built from: the + // receipt is what was signed, so it is what the record should agree + // with, and its result fields are non-null by construction. + attackerWon: receipt.result.attackerWon, + foughtAt: receipt.createdAt, + seed: receipt.seed, + rounds: receipt.result.rounds, + winnerHpRemaining: receipt.result.winnerHpRemaining, + attackerXp: progression.attacker.xpAwarded, + defenderXp: progression.defender.xpAwarded, + }); }, outbox: [{ battleId: battle.battleId, topic: OUTBOX_TOPICS.publish }], }); diff --git a/backend/src/features/dialogue/context.ts b/backend/src/features/dialogue/context.ts index 78f62bfc..1d4f155a 100644 --- a/backend/src/features/dialogue/context.ts +++ b/backend/src/features/dialogue/context.ts @@ -1,6 +1,5 @@ -import { getHeadToHead, getRecentForm } from '@repositories/history.repository'; +import { getBattleSummary, getHeadToHead, getRecentForm } from '@repositories/history.repository'; import { getRecentBanter } from '@repositories/conversation.repository'; -import { getChainSettledBattle } from '@grpc-client/battleStream'; import type { Chain } from '@typings/chain'; import { withFallback } from '@utils'; import { buildBanterContext, buildBattleSummaryContext, buildRivalryContext } from './llm/render'; @@ -62,14 +61,21 @@ export function buildRivalry( } /** - * How the settled fight actually went (rounds / surviving HP / XP swing), for - * the result prompt only. Read synchronously from the live battle stream's - * chain-truth record — returns '' when the stream is off or hasn't seen this - * battle, so generation proceeds unchanged. The taunt path never calls this: - * pre-fight banter must stay outcome-free. + * How the settled fight actually went (rounds / surviving HP / XP swing), for the result + * prompt only. Read from `battle_history`, which the battle worker writes from the signed + * receipt — so this is the receipt's own account of the fight, not the client's. + * + * Returns '' when the battle is not on record, so generation proceeds unchanged. The taunt + * path never calls this: pre-fight banter must stay outcome-free. */ -export function buildBattleIntensity(chain: Chain, battleId?: string): string { +export async function buildBattleIntensity(chain: Chain, battleId?: string): Promise { if (!battleId) return ''; - const settled = getChainSettledBattle(chain, battleId); - return settled ? buildBattleSummaryContext(settled) : ''; + return withFallback( + '[dialogue] battle summary lookup failed, continuing without it:', + async () => { + const summary = await getBattleSummary(chain, battleId); + return summary ? buildBattleSummaryContext(summary) : ''; + }, + '', + ); } diff --git a/backend/src/features/dialogue/llm/render.ts b/backend/src/features/dialogue/llm/render.ts index ef0aabe7..49632969 100644 --- a/backend/src/features/dialogue/llm/render.ts +++ b/backend/src/features/dialogue/llm/render.ts @@ -1,5 +1,4 @@ import type { HeadToHead, RecentForm } from '@repositories/history.repository'; -import type { SettledBattle } from '@grpc-client/battleStream'; import type { DialogueTurn } from '../dialogue.types'; /** A blowout ends fast; a nail-biter drags. These bound the round-count flavor. */ @@ -52,7 +51,15 @@ export function buildBanterContext(turns: DialogueTurn[]): string { * block already fixes that; this only colors how hard-won it was. Returns '' when * there's nothing meaningful to say (all defaults / a v1 row). */ -export function buildBattleSummaryContext(settled: SettledBattle): string { +/** Just the fields this renders; any row carrying them will do. */ +export interface BattleSummary { + rounds: number; + winnerHpRemaining: number; + xpWin: number; + xpLoss: number; +} + +export function buildBattleSummaryContext(settled: BattleSummary): string { const intensity = settled.rounds <= 0 ? '' diff --git a/backend/src/features/dialogue/recording.ts b/backend/src/features/dialogue/recording.ts index f1ea0231..c7a9ae61 100644 --- a/backend/src/features/dialogue/recording.ts +++ b/backend/src/features/dialogue/recording.ts @@ -1,14 +1,18 @@ -import { recordBattle } from '@repositories/history.repository'; import { recordConversation } from '@repositories/conversation.repository'; -import { getChainSettledBattle } from '@grpc-client/battleStream'; import type { Chain } from '@typings/chain'; import { withFallback } from '@utils'; import type { DialogueTurn, GenerateDialogueInput } from './dialogue.types'; /** - * Best-effort persistence side effects for the dialogue feature. Every write here - * is non-blocking: a failure is logged and swallowed so it never breaks the - * response the player is waiting on. + * Best-effort persistence side effects for the dialogue feature. Every write here is + * non-blocking: a failure is logged and swallowed so it never breaks the response the + * player is waiting on. + * + * `battle_history` is deliberately not written from here any more. It used to be, from the + * client's own result report, back when the indexer wrote the authoritative row from an + * on-chain settle event and this only filled gaps. The battle worker now writes that row + * from the signed receipt, in the receipt's own transaction, so writing here would let a + * client's claim overwrite what was actually signed. */ /** Persist transcript lines, swallowing failures so generation is never blocked. */ @@ -32,41 +36,3 @@ export function recordResultLines(input: GenerateDialogueInput, turns: DialogueT resultTurns, ); } - -/** - * Record the settled battle into `battle_history`. The winner is mapped from the - * attacker/defender role to the concrete pet id so head-to-head tallies stay - * correct when the pets swap roles across battles. Best-effort: a failure here - * must not stop us from returning the dialogue. - * - * The client report carries no combat-sim outputs. But if the battle stream has - * already seen this battle settle on-chain, we carry its authoritative sim - * fields (seed/rounds/hp/xp + true fought-at) onto the row; otherwise they're - * omitted so the upsert leaves any indexer-written values untouched and falls - * back to the schema defaults on a fresh row. - */ -export function recordBattleHistory(input: GenerateDialogueInput): Promise { - const winnerPetId = input.winner === 'attacker' ? input.attacker.petId : input.defender.petId; - const settled = input.battleId ? getChainSettledBattle(input.chain, input.battleId) : undefined; - return withFallback( - '[dialogue] failed to record battle history:', - () => - recordBattle({ - chain: input.chain, - battleId: input.battleId, - attacker: input.attacker.petId, - defender: input.defender.petId, - winnerPetId, - foughtAt: settled ? BigInt(settled.foughtAt) : BigInt(Date.now()), - ...(settled && { - loserPetId: settled.loserPet, - seed: settled.seed, - rounds: settled.rounds, - winnerHpRemaining: settled.winnerHpRemaining, - xpWin: settled.xpWin, - xpLoss: settled.xpLoss, - }), - }), - undefined, - ); -} diff --git a/backend/src/features/dialogue/result/result.service.ts b/backend/src/features/dialogue/result/result.service.ts index 9f7284d0..91c3c1da 100644 --- a/backend/src/features/dialogue/result/result.service.ts +++ b/backend/src/features/dialogue/result/result.service.ts @@ -3,8 +3,8 @@ import { buildPersona } from '../llm/persona'; import { generateTurns, ensureResultCoverage } from './turns'; import { getPregenStore } from '@repositories/pregen.repository'; import { matchupKey } from '@typings/pregen'; -import { recordBattleHistory, recordResultLines } from '../recording'; -import { getChainSettledWinner } from '@grpc-client/battleStream'; +import { recordResultLines } from '../recording'; +import { getSettledWinner } from '@repositories/history.repository'; import type { DialogueResult, DialogueTurn, GenerateDialogueInput } from '../dialogue.types'; /** @@ -13,7 +13,7 @@ import type { DialogueResult, DialogueTurn, GenerateDialogueInput } from '../dia * The chain decides the winner; we only narrate toward it (see AI_BATTLE_DIALOGUE.md). */ export async function getOrGenerateDialogue(input: GenerateDialogueInput): Promise { - verifyAgainstChainTruth(input); + await verifyAgainstRecordedResult(input); // Build personas before the cache check so we can supplement cached turns too. const attacker = buildPersona(input.attacker); @@ -37,31 +37,39 @@ export async function getOrGenerateDialogue(input: GenerateDialogueInput): Promi return finalizeDialogue(input, turns, model); } -/** Thrown by {@link verifyAgainstChainTruth} when the battle stream has already seen this - * battle settle on-chain with a different winner than the client is claiming. */ +/** Thrown by {@link verifyAgainstRecordedResult} when this battle is already on record with + * a different winner than the client is claiming. */ export class ChainTruthMismatchError extends Error { constructor(public readonly chainWinner: string) { - super(`client-reported winner contradicts chain truth (${chainWinner})`); + super(`client-reported winner contradicts the signed result (${chainWinner})`); this.name = 'ChainTruthMismatchError'; } } /** - * When the battle stream has seen this battle settle on-chain, the client-reported - * winner must match. Chain truth is the actual authority here — a mismatch means the - * client is either buggy or forging a result, and letting it through would poison - * `battle_history` and the cached dialogue via the idempotent first-write-wins cache - * in {@link finalizeDialogue}. No-op when the stream is off or the battle hasn't been - * seen yet (the common case — the stream lags settlement). + * The client-reported winner must match the one on record. + * + * The record is `battle_history`, written by the battle worker from the signed receipt — + * so this compares the client's claim against what was actually signed, where it used to + * compare against an on-chain settle event seen by the indexer stream. + * + * A mismatch means the client is buggy or forging, and letting it through would poison the + * cached dialogue via the first-write-wins cache in {@link finalizeDialogue}. It can no + * longer poison `battle_history` itself: this path stopped writing that table when the + * worker took it over. + * + * No-op when the battle is not on record. That is permissive by design — dialogue may be + * requested before the receipt commits — and it is why this guard protects the dialogue + * cache rather than the battle record. */ -function verifyAgainstChainTruth(input: GenerateDialogueInput): void { +async function verifyAgainstRecordedResult(input: GenerateDialogueInput): Promise { if (!input.battleId) return; - const chainWinner = getChainSettledWinner(input.chain, input.battleId); - if (!chainWinner) return; + const recordedWinner = await getSettledWinner(input.chain, input.battleId); + if (!recordedWinner) return; const claimed = input.winner === 'attacker' ? input.attacker.petId : input.defender.petId; - if (claimed !== chainWinner) { - throw new ChainTruthMismatchError(chainWinner); + if (claimed !== recordedWinner) { + throw new ChainTruthMismatchError(recordedWinner); } } @@ -82,18 +90,17 @@ async function consumePregen( } /** - * Persist a settled battle's dialogue and return the response. Records the battle - * to history (for future rivalry context) and appends the result lines to the - * rolling transcript — both idempotent and best-effort, never blocking the - * response. Shared by the on-demand and pre-generated paths. + * Persist a settled battle's dialogue and return the response, appending the result lines + * to the rolling transcript — idempotent and best-effort, never blocking the response. + * Shared by the on-demand and pre-generated paths. + * + * No `battle_history` write: the battle worker records that from the receipt. */ async function finalizeDialogue( input: GenerateDialogueInput, turns: DialogueTurn[], model: string, ): Promise { - await recordBattleHistory(input); - await saveDialogue({ chain: input.chain, battleId: input.battleId, diff --git a/backend/src/features/dialogue/result/turns.ts b/backend/src/features/dialogue/result/turns.ts index da3fb397..459f9069 100644 --- a/backend/src/features/dialogue/result/turns.ts +++ b/backend/src/features/dialogue/result/turns.ts @@ -37,9 +37,9 @@ export async function generateTurns( buildRivalry(chain, attackerId, defenderId, excludeBattleId), opts?.banterOverride ?? buildBanter(chain, attackerId, defenderId, excludeBattleId), ]); - // How this specific battle went, when the stream has settled it (synchronous, - // in-memory) — colors the result reactions without changing the fixed outcome. - const intensity = buildBattleIntensity(chain, input.battleId || undefined); + // How this specific battle went, per its signed receipt — colors the result + // reactions without changing the fixed outcome. + const intensity = await buildBattleIntensity(chain, input.battleId || undefined); const turns = await requestDialogue(input, attacker, defender, rivalry, banter, intensity); diff --git a/backend/src/features/settle-keeper/index.ts b/backend/src/features/settle-keeper/index.ts index 61b1852f..f2240ed9 100644 --- a/backend/src/features/settle-keeper/index.ts +++ b/backend/src/features/settle-keeper/index.ts @@ -8,9 +8,8 @@ import { startKeeper, type SettleKeeperHandle } from './keeper'; * docs/plan-realtime-battle-impl.md Phase 2 for the original design; battles no * longer take this path at all (§L Phase 6), breed and mint still do. * - * Off unless KEEPER_ENABLED=true, mirroring the indexer-go gRPC stream - * (src/grpc/battleStream.ts): the feature simply doesn't start rather than - * failing, so local dev / CI without a configured keeper wallet is unaffected. + * Off unless KEEPER_ENABLED=true: the feature simply doesn't start rather than failing, + * so local dev / CI without a configured keeper wallet is unaffected. */ let handle: SettleKeeperHandle | null = null; diff --git a/backend/src/grpc/battleStream.ts b/backend/src/grpc/battleStream.ts deleted file mode 100644 index 62be7574..00000000 --- a/backend/src/grpc/battleStream.ts +++ /dev/null @@ -1,195 +0,0 @@ -import * as grpc from '@grpc/grpc-js'; -import { env } from '@config/env'; -import { loadGameDataService } from './gameData'; - -/** - * StreamLiveBattles client: subscribes to indexer-go's chain-truth battle - * push. Off unless INDEXER_GRPC_ADDR is set; battle recording still works - * without it (the indexer writes battle_history directly) — what this adds is - * the live signal plus an in-memory chain-truth map the dialogue flow can - * check client-reported results against. - * - * Delivery is at-least-once: on reconnect the client passes the last seen - * version per chain (Solana slot / EVM block timestamp) and the server - * replays anything missed from battle_history. - */ - -export interface SettledBattle { - chain: string; - battleId: string; - attackerPet: string; - defenderPet: string; - winnerPet: string; - version: bigint; - foughtAt: number; - // v2 round-based combat sim outputs (plan §3.3). The seed re-runs the sim - // client-side for blow-by-blow replay; rounds / hp / xp flavor the result. - loserPet: string; - seed: string; // 0x-hex 32-byte combat seed - rounds: number; - winnerHpRemaining: number; - xpWin: number; - xpLoss: number; -} - -/** Wire shape with proto-loader { longs: String } — uint64/int64 arrive as strings. */ -interface BattleEventWire { - chain: string; - battleId: string; - attackerPet: string; - defenderPet: string; - winnerPet: string; - version: string; - foughtAt: string; - // v2 sim outputs. loserPet/seed are strings; the uint32 counters are numbers. - loserPet: string; - seed: string; - rounds: number; - winnerHpRemaining: number; - xpWin: number; - xpLoss: number; -} - -const RECONNECT_BASE_MS = 1_000; -const RECONNECT_CAP_MS = 30_000; -/** Bounded memory: roughly a day of battles at game scale. */ -const CHAIN_TRUTH_MAX = 2_000; - -/** chain:battleId → settled battle (incl. sim outputs), insertion-ordered for cheap eviction. */ -const chainTruth = new Map(); -/** chain → last seen version, the per-chain resume cursor. */ -const lastVersion = new Map(); - -let activeStream: grpc.ClientReadableStream | null = null; -let client: grpc.Client | null = null; -let reconnectTimer: NodeJS.Timeout | null = null; -let attempt = 0; -let stopped = false; - -/** - * The chain-settled winner for a battle, if the stream has seen it. Used to - * verify client-reported results (shadow check — see milestone 7). - */ -export function getChainSettledWinner(chain: string, battleId: string): string | undefined { - return chainTruth.get(`${chain}:${battleId}`)?.winnerPet; -} - -/** - * The full chain-settled battle for a battle id, if the stream has seen it. - * Carries the v2 sim outputs (`seed`, `rounds`, `winnerHpRemaining`, xp) so a - * live battle UI can replay the fight client-side from the seed. - */ -export function getChainSettledBattle(chain: string, battleId: string): SettledBattle | undefined { - return chainTruth.get(`${chain}:${battleId}`); -} - -export function startBattleStream(): void { - const { addr } = env.indexerGrpc; - if (!addr) { - console.log('[battle-stream] INDEXER_GRPC_ADDR not set; stream disabled'); - return; - } - - stopped = false; - try { - const Service = loadGameDataService(); - client = new Service(addr, grpc.credentials.createInsecure()); - } catch (err) { - // Missing proto / bad INDEXER_PROTO_PATH must not take down the HTTP API. - console.error( - '[battle-stream] failed to load GameDataService; stream disabled:', - err instanceof Error ? err.message : err, - ); - return; - } - connect(); - console.log(`[battle-stream] subscribing to ${addr}`); -} - -export function stopBattleStream(): void { - stopped = true; - if (reconnectTimer) clearTimeout(reconnectTimer); - activeStream?.cancel(); - client?.close(); - activeStream = null; - client = null; -} - -type StreamingClient = grpc.Client & { - streamLiveBattles(request: { - afterVersion: Record; - }): grpc.ClientReadableStream; -}; - -function connect(): void { - if (stopped || !client) return; - - const afterVersion: Record = {}; - for (const [chain, version] of lastVersion) { - afterVersion[chain] = version.toString(); - } - - const stream = (client as StreamingClient).streamLiveBattles({ afterVersion }); - activeStream = stream; - - let finished = false; // 'error' and 'end' can both fire; reconnect once - const onDone = (reason: string): void => { - if (finished) return; - finished = true; - scheduleReconnect(reason); - }; - - stream.on('metadata', () => { - attempt = 0; - console.log(`[battle-stream] connected to ${env.indexerGrpc.addr}`); - }); - stream.on('data', (wire: BattleEventWire) => { - record(wire); - }); - stream.on('error', (err: Error) => onDone(err.message)); - stream.on('end', () => onDone('stream ended by server')); -} - -function record(wire: BattleEventWire): void { - const version = BigInt(wire.version); - const seen = lastVersion.get(wire.chain); - if (seen === undefined || version > seen) { - lastVersion.set(wire.chain, version); - } - - chainTruth.set(`${wire.chain}:${wire.battleId}`, { - chain: wire.chain, - battleId: wire.battleId, - attackerPet: wire.attackerPet, - defenderPet: wire.defenderPet, - winnerPet: wire.winnerPet, - version, - foughtAt: Number(wire.foughtAt), - loserPet: wire.loserPet, - seed: wire.seed, - rounds: wire.rounds, - winnerHpRemaining: wire.winnerHpRemaining, - xpWin: wire.xpWin, - xpLoss: wire.xpLoss, - }); - while (chainTruth.size > CHAIN_TRUTH_MAX) { - const oldest = chainTruth.keys().next().value; - if (oldest === undefined) break; - chainTruth.delete(oldest); - } - - console.log( - `[battle-stream] ${wire.chain} battle ${wire.battleId}: ` + - `${wire.attackerPet} vs ${wire.defenderPet} → winner ${wire.winnerPet}`, - ); -} - -function scheduleReconnect(reason: string): void { - if (stopped) return; - attempt += 1; - const delay = - Math.min(RECONNECT_BASE_MS * 2 ** (attempt - 1), RECONNECT_CAP_MS) + - Math.floor(Math.random() * 500); - console.warn(`[battle-stream] disconnected (${reason}); reconnecting in ${delay}ms`); - reconnectTimer = setTimeout(connect, delay); -} diff --git a/backend/src/repositories/history.repository.ts b/backend/src/repositories/history.repository.ts index b0d82ba4..7a1cbac4 100644 --- a/backend/src/repositories/history.repository.ts +++ b/backend/src/repositories/history.repository.ts @@ -1,30 +1,22 @@ import { prisma } from '@config/prisma'; +import type { Prisma } from '@generated/prisma/client'; import type { Chain } from '@typings/chain'; /** - * Data-access layer for `battle_history`. The indexer writes settled battles - * here (authoritative, from on-chain events); the dialogue service reads - * head-to-head / recent form to give the LLM rivalry context. + * Data-access layer for `battle_history`, the record of settled battles that the dialogue + * service reads for head-to-head / recent-form rivalry context. + * + * Rows come from the signed receipt now, written by the battle worker in the same + * transaction as the receipt itself (§L Phase 6). Before that they came from the indexer, + * decoding on-chain settle events; that path is gone with on-chain battles, and the + * indexer no longer writes here at all. + * + * `foughtAt` is unix **seconds**. Rows written by the dialogue client-report path before + * this change stored `Date.now()` milliseconds, so any such row sorts far in the future + * against a receipt-written one — relevant only to `getRecentForm`'s ordering, and only + * for pre-existing rows. */ -export interface BattleRecord { - chain: Chain; - battleId: string; - attacker: string; - defender: string; - winnerPetId: string; - foughtAt: bigint; - // v2 round-based combat sim outputs (plan §3.3). Optional: the dialogue - // client-report path predates settlement and has nothing to write here, so - // the columns keep their schema defaults on that write. - loserPetId?: string; - seed?: string; // 0x-hex 32-byte combat seed; replays the sim off-chain - rounds?: number; - winnerHpRemaining?: number; - xpWin?: number; - xpLoss?: number; -} - /** Head-to-head summary between two specific pets. */ export interface HeadToHead { total: number; @@ -38,12 +30,80 @@ export interface RecentForm { losses: number; } -/** Idempotent insert keyed by (chain, battleId) — safe to replay events. */ -export async function recordBattle(rec: BattleRecord): Promise { - await prisma.battleHistory.upsert({ +/** What a signed receipt contributes to the record of a settled battle. */ +export interface ReceiptBattleRecord { + chain: Chain; + battleId: string; + attacker: string; + defender: string; + attackerWon: boolean; + /** Unix seconds, from the receipt's own `createdAt`. */ + foughtAt: number; + seed: string; + rounds: number; + winnerHpRemaining: number; + /** XP awarded to each pet, from the receipt's progression delta. */ + attackerXp: number; + defenderXp: number; +} + +/** + * Records a settled battle from its receipt, on the caller's transaction. + * + * Takes a `Prisma.TransactionClient` rather than reaching for the global client so this + * commits with the receipt that produced it: a battle can never end up in the history + * without its receipt, or the reverse. + * + * Idempotent by (chain, battleId). A receipt is written once, but the outbox that drives + * this delivers at least once, so a replay must not fail or double-count. + */ +export async function recordBattleFromReceipt( + tx: Prisma.TransactionClient, + rec: ReceiptBattleRecord, +): Promise { + // Winner/loser as absolute pet ids, not roles: head-to-head tallies have to stay + // correct when the same two pets meet again with the roles swapped. + const winnerPetId = rec.attackerWon ? rec.attacker : rec.defender; + const loserPetId = rec.attackerWon ? rec.defender : rec.attacker; + const data = { + chain: rec.chain, + battleId: rec.battleId, + attacker: rec.attacker, + defender: rec.defender, + winnerPetId, + loserPetId, + seed: rec.seed, + rounds: rec.rounds, + winnerHpRemaining: rec.winnerHpRemaining, + xpWin: rec.attackerWon ? rec.attackerXp : rec.defenderXp, + xpLoss: rec.attackerWon ? rec.defenderXp : rec.attackerXp, + foughtAt: BigInt(rec.foughtAt), + }; + + await tx.battleHistory.upsert({ where: { chain_battleId: { chain: rec.chain, battleId: rec.battleId } }, - create: rec, - update: rec, + create: data, + update: data, + }); +} + +/** The recorded winner's pet id, or null when the battle is not on record. */ +export async function getSettledWinner(chain: Chain, battleId: string): Promise { + const row = await prisma.battleHistory.findUnique({ + where: { chain_battleId: { chain, battleId } }, + select: { winnerPetId: true }, + }); + return row?.winnerPetId ?? null; +} + +/** How the fight went, for the result prompt. Null when the battle is not on record. */ +export async function getBattleSummary( + chain: Chain, + battleId: string, +): Promise<{ rounds: number; winnerHpRemaining: number; xpWin: number; xpLoss: number } | null> { + return prisma.battleHistory.findUnique({ + where: { chain_battleId: { chain, battleId } }, + select: { rounds: true, winnerHpRemaining: true, xpWin: true, xpLoss: true }, }); } diff --git a/backend/src/server.ts b/backend/src/server.ts index bf1dc3d4..fe938c3b 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,7 +2,6 @@ import './register-path-aliases'; import { env } from '@config/env'; import { prisma } from '@config/prisma'; import app from './app'; -import { startBattleStream, stopBattleStream } from '@grpc-client/battleStream'; import { configureSigner, loadPersistedSigningKeys } from '@features/battle-signer'; import { startSettleKeeper, stopSettleKeeper } from '@features/settle-keeper'; import { type BattleWorkerHandle, startBattleWorker } from '@features/battle-worker'; @@ -24,8 +23,6 @@ const server = app.listen(env.port, '0.0.0.0', () => { // Notification-only per-room channel for backend-authoritative battles (§J). Always on; // a client only gets pushed to if it connected with a roomId it already knows about. startBattleRoomSocket(server); - // indexer-go battle push (chain-truth settles). No-op unless INDEXER_GRPC_ADDR is set. - startBattleStream(); // Settles GameLogic battle/breed/mint requests once entropy reveals. No-op unless // KEEPER_ENABLED is set. startSettleKeeper(); @@ -76,7 +73,6 @@ async function shutdown(signal: NodeJS.Signals): Promise { }, SHUTDOWN_TIMEOUT_MS); forceExit.unref(); // don't let the failsafe itself keep the process alive - stopBattleStream(); stopSettleKeeper(); battleWorker?.stop(); stopBatchAnchor(); diff --git a/backend/tests/features/battle-worker/sign.worker.test.ts b/backend/tests/features/battle-worker/sign.worker.test.ts index 0151fa3e..768d8ea0 100644 --- a/backend/tests/features/battle-worker/sign.worker.test.ts +++ b/backend/tests/features/battle-worker/sign.worker.test.ts @@ -161,6 +161,8 @@ function fakeTx() { return { battleReceipt: { create: vi.fn().mockResolvedValue({}) }, petBattleProgress: { update: vi.fn().mockResolvedValue({}) }, + // The rivalry record for the dialogue service, written on the same transaction. + battleHistory: { upsert: vi.fn().mockResolvedValue({}) }, }; } @@ -269,6 +271,33 @@ describe('the happy path', () => { expect(attackerUpdate.data.lastReceiptHash).toBe(`0x${'dd'.repeat(32)}`); }); + it('records the battle for rivalry context, from the receipt and on the same transaction', async () => { + // The indexer used to write `battle_history` from an on-chain settle event. With no + // such event left, the receipt is the only authority for what happened. + 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); + + expect(tx.battleHistory.upsert).toHaveBeenCalledTimes(1); + const { create, where } = tx.battleHistory.upsert.mock.calls[0]![0]; + expect(where.chain_battleId).toEqual({ chain: 'evm', battleId: BATTLE.battleId }); + expect(create.attacker).toBe('1'); + expect(create.defender).toBe('2'); + // Unix seconds, matching every other row. The removed client-report path wrote + // Date.now() milliseconds here. + expect(create.foughtAt).toBe(BigInt(NOW)); + expect(create.seed).toBe(BATTLE.seed); + + // Winner and loser as absolute pet ids, so head-to-head survives a role swap. + const [winner, loser] = outcome.result.firstWins ? ['1', '2'] : ['2', '1']; + expect(create.winnerPetId).toBe(winner); + expect(create.loserPetId).toBe(loser); + }); + it('credits a win to the winner and a loss to the loser', async () => { const tx = fakeTx(); vi.mocked(applyTransition).mockImplementationOnce((async (req: { onApplied?: (tx: unknown) => Promise }) => { diff --git a/backend/tests/features/dialogue/context.test.ts b/backend/tests/features/dialogue/context.test.ts index adccb0ba..69d9f252 100644 --- a/backend/tests/features/dialogue/context.test.ts +++ b/backend/tests/features/dialogue/context.test.ts @@ -3,14 +3,11 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; vi.mock('@repositories/history.repository', () => ({ getHeadToHead: vi.fn().mockResolvedValue({ wins: 0, losses: 0 }), getRecentForm: vi.fn().mockResolvedValue([]), + getBattleSummary: vi.fn().mockResolvedValue(null), })); vi.mock('@repositories/conversation.repository', () => ({ getRecentBanter: vi.fn().mockResolvedValue([]), })); -vi.mock('../../../src/grpc/battleStream', () => ({ - getChainSettledBattle: vi.fn().mockReturnValue(null), - getChainSettledWinner: vi.fn().mockReturnValue(null), -})); vi.mock('../../../src/features/dialogue/llm/render', () => ({ buildBanterContext: vi.fn().mockReturnValue('banter-ctx'), buildRivalryContext: vi.fn().mockReturnValue('rivalry-ctx'), @@ -18,12 +15,12 @@ vi.mock('../../../src/features/dialogue/llm/render', () => ({ })); import { buildBanter, buildRivalry, buildBattleIntensity } from '../../../src/features/dialogue/context'; -import { getChainSettledBattle } from '../../../src/grpc/battleStream'; +import { getBattleSummary } from '@repositories/history.repository'; import { getRecentBanter } from '@repositories/conversation.repository'; beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getChainSettledBattle).mockReturnValue(null); + vi.mocked(getBattleSummary).mockResolvedValue(null); vi.mocked(getRecentBanter).mockResolvedValue([]); }); @@ -66,19 +63,24 @@ describe('buildRivalry', () => { }); describe('buildBattleIntensity', () => { - it('returns empty string when battleId is missing', () => { - expect(buildBattleIntensity('evm', undefined)).toBe(''); + it('returns empty string when battleId is missing', async () => { + await expect(buildBattleIntensity('evm', undefined)).resolves.toBe(''); + expect(getBattleSummary).not.toHaveBeenCalled(); }); - it('returns empty string when battle stream has no record', () => { - expect(buildBattleIntensity('evm', 'battle1')).toBe(''); + it('returns empty string when the battle is not on record', async () => { + await expect(buildBattleIntensity('evm', 'battle1')).resolves.toBe(''); }); - it('returns summary when battle is settled on-chain', () => { - vi.mocked(getChainSettledBattle).mockReturnValue({ - winnerPet: 'p1', loserPet: 'p2', foughtAt: 0, seed: 0n, + it('renders the summary recorded from the receipt', async () => { + vi.mocked(getBattleSummary).mockResolvedValue({ rounds: 5, winnerHpRemaining: 10, xpWin: 20, xpLoss: 5, - } as never); - expect(buildBattleIntensity('evm', 'battle1')).toBe('summary-ctx'); + }); + await expect(buildBattleIntensity('evm', 'battle1')).resolves.toBe('summary-ctx'); + }); + + it('degrades to no intensity when the lookup fails, rather than failing generation', async () => { + vi.mocked(getBattleSummary).mockRejectedValue(new Error('db down')); + await expect(buildBattleIntensity('evm', 'battle1')).resolves.toBe(''); }); }); diff --git a/backend/tests/features/dialogue/llm/renderSummary.test.ts b/backend/tests/features/dialogue/llm/renderSummary.test.ts index 6f57f851..15ebeac0 100644 --- a/backend/tests/features/dialogue/llm/renderSummary.test.ts +++ b/backend/tests/features/dialogue/llm/renderSummary.test.ts @@ -1,14 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { buildBattleSummaryContext } from '../../../../src/features/dialogue/llm/render'; -import type { SettledBattle } from '../../../../src/grpc/battleStream'; - -function battle(overrides: Partial): SettledBattle { - return { - chain: 'evm', battleId: 'b1', attackerPet: 'p1', defenderPet: 'p2', - winnerPet: 'p1', loserPet: 'p2', version: 1n, foughtAt: 0, - seed: '0x', rounds: 5, winnerHpRemaining: 10, xpWin: 20, xpLoss: 5, - ...overrides, - }; +import { type BattleSummary, buildBattleSummaryContext } from '../../../../src/features/dialogue/llm/render'; + +function battle(overrides: Partial): BattleSummary { + return { rounds: 5, winnerHpRemaining: 10, xpWin: 20, xpLoss: 5, ...overrides }; } describe('buildBattleSummaryContext', () => { diff --git a/backend/tests/features/dialogue/recording.test.ts b/backend/tests/features/dialogue/recording.test.ts index 8c349b72..2cd5165e 100644 --- a/backend/tests/features/dialogue/recording.test.ts +++ b/backend/tests/features/dialogue/recording.test.ts @@ -1,15 +1,9 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -vi.mock('@repositories/history.repository', () => ({ recordBattle: vi.fn().mockResolvedValue(undefined) })); vi.mock('@repositories/conversation.repository', () => ({ recordConversation: vi.fn().mockResolvedValue(undefined) })); -vi.mock('../../../src/grpc/battleStream', () => ({ - getChainSettledBattle: vi.fn().mockReturnValue(null), -})); -import { recordConversationSafe, recordResultLines, recordBattleHistory } from '../../../src/features/dialogue/recording'; -import { getChainSettledBattle } from '../../../src/grpc/battleStream'; +import { recordConversationSafe, recordResultLines } from '../../../src/features/dialogue/recording'; import { recordConversation } from '@repositories/conversation.repository'; -import { recordBattle } from '@repositories/history.repository'; import type { DialogueTurn, GenerateDialogueInput } from '../../../src/features/dialogue/dialogue.types'; const baseTurns: DialogueTurn[] = [ @@ -27,7 +21,6 @@ const baseInput: GenerateDialogueInput = { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getChainSettledBattle).mockReturnValue(null); }); describe('recordConversationSafe', () => { @@ -55,35 +48,3 @@ describe('recordResultLines', () => { expect(calledTurns[0].phase).toBe('result'); }); }); - -describe('recordBattleHistory', () => { - it('records the attacker as winner when winner=attacker', async () => { - await recordBattleHistory(baseInput); - expect(recordBattle).toHaveBeenCalledWith( - expect.objectContaining({ winnerPetId: 'p1' }), - ); - }); - - it('records the defender as winner when winner=defender', async () => { - await recordBattleHistory({ ...baseInput, winner: 'defender' }); - expect(recordBattle).toHaveBeenCalledWith( - expect.objectContaining({ winnerPetId: 'p2' }), - ); - }); - - it('augments record with chain truth when battle stream has the battle', async () => { - vi.mocked(getChainSettledBattle).mockReturnValue({ - winnerPet: 'p1', loserPet: 'p2', foughtAt: 1000000, - seed: 42n, rounds: 6, winnerHpRemaining: 8, xpWin: 30, xpLoss: 10, - } as never); - await recordBattleHistory(baseInput); - expect(recordBattle).toHaveBeenCalledWith( - expect.objectContaining({ seed: 42n, rounds: 6 }), - ); - }); - - it('swallows repository errors', async () => { - recordBattle.mockRejectedValueOnce(new Error('db down')); - await expect(recordBattleHistory(baseInput)).resolves.toBeUndefined(); - }); -}); diff --git a/backend/tests/features/dialogue/result/result.service.test.ts b/backend/tests/features/dialogue/result/result.service.test.ts index b35bdd4c..9b664071 100644 --- a/backend/tests/features/dialogue/result/result.service.test.ts +++ b/backend/tests/features/dialogue/result/result.service.test.ts @@ -17,11 +17,10 @@ vi.mock('../../../../src/features/dialogue/result/turns', () => ({ ensureResultCoverage: vi.fn((_t: DialogueTurn[]) => _t), })); vi.mock('../../../../src/features/dialogue/recording', () => ({ - recordBattleHistory: vi.fn().mockResolvedValue(undefined), recordResultLines: vi.fn().mockResolvedValue(undefined), })); -vi.mock('../../../../src/grpc/battleStream', () => ({ - getChainSettledWinner: vi.fn().mockReturnValue(null), +vi.mock('@repositories/history.repository', () => ({ + getSettledWinner: vi.fn().mockResolvedValue(null), })); vi.mock('@typings/pregen', () => ({ matchupKey: vi.fn((_chain: string, a: string, b: string) => `${a}-${b}`), @@ -88,10 +87,10 @@ describe('getOrGenerateDialogue', () => { expect(result.turns).toBe(defenderWins); }); - it('rejects when client-reported winner contradicts chain truth', async () => { - const { getChainSettledWinner } = await import('../../../../src/grpc/battleStream'); - // chain says p2 (defender) won, but input claims attacker (p1) — must reject. - vi.mocked(getChainSettledWinner).mockReturnValue('p2'); + it('rejects when the client-reported winner contradicts the recorded result', async () => { + const { getSettledWinner } = await import('@repositories/history.repository'); + // The receipt-written record says p2 (defender) won; the client claims p1. + vi.mocked(getSettledWinner).mockResolvedValue('p2'); await expect(getOrGenerateDialogue(input)).rejects.toThrow(ChainTruthMismatchError); expect(generateTurns).not.toHaveBeenCalled(); diff --git a/backend/tests/grpc/battleStream.test.ts b/backend/tests/grpc/battleStream.test.ts deleted file mode 100644 index f2b50fd0..00000000 --- a/backend/tests/grpc/battleStream.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -// getChainSettledWinner and getChainSettledBattle read from the module-level Map. -// They are pure getters — no gRPC connection required. -import { getChainSettledWinner, getChainSettledBattle } from '../../src/grpc/battleStream'; - -describe('getChainSettledWinner', () => { - it('returns undefined when no battle has been seen', () => { - expect(getChainSettledWinner('evm', 'never-seen')).toBeUndefined(); - }); -}); - -describe('getChainSettledBattle', () => { - it('returns undefined when no battle has been seen', () => { - expect(getChainSettledBattle('evm', 'never-seen')).toBeUndefined(); - }); -}); diff --git a/backend/tests/repositories/history.repository.test.ts b/backend/tests/repositories/history.repository.test.ts index ff895e94..9a54b7c6 100644 --- a/backend/tests/repositories/history.repository.test.ts +++ b/backend/tests/repositories/history.repository.test.ts @@ -4,20 +4,73 @@ vi.mock('@config/prisma', () => ({ prisma: { battleHistory: { upsert: vi.fn(), findMany: vi.fn() } }, })); -import { recordBattle, getHeadToHead, getRecentForm } from '../../../src/repositories/history.repository'; +import { getHeadToHead, getRecentForm, recordBattleFromReceipt } from '../../../src/repositories/history.repository'; import { prisma } from '@config/prisma'; beforeEach(() => { vi.clearAllMocks(); }); -describe('recordBattle', () => { - it('upserts keyed by chain+battleId', async () => { - vi.mocked(prisma.battleHistory.upsert).mockResolvedValue({} as never); - const rec = { chain: 'evm' as const, battleId: 'b1', attacker: 'p1', defender: 'p2', winnerPetId: 'p1', foughtAt: 1000n }; - await recordBattle(rec); - expect(prisma.battleHistory.upsert).toHaveBeenCalledWith( +describe('recordBattleFromReceipt', () => { + const receipt = { + chain: 'evm' as const, + battleId: 'b1', + attacker: 'p1', + defender: 'p2', + attackerWon: true, + foughtAt: 1000, + seed: '0xseed', + rounds: 5, + winnerHpRemaining: 12, + attackerXp: 20, + defenderXp: 4, + }; + + function fakeTx() { + return { battleHistory: { upsert: vi.fn().mockResolvedValue({}) } }; + } + + it('upserts keyed by chain+battleId, so an outbox replay cannot double-count', async () => { + const tx = fakeTx(); + await recordBattleFromReceipt(tx as never, receipt); + expect(tx.battleHistory.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { chain_battleId: { chain: 'evm', battleId: 'b1' } } }), ); }); + + it('resolves winner and loser to absolute pet ids', async () => { + // Head-to-head tallies have to survive the same two pets meeting with the roles + // swapped, so the row stores ids rather than "attacker won". + const tx = fakeTx(); + await recordBattleFromReceipt(tx as never, receipt); + const { create } = tx.battleHistory.upsert.mock.calls[0]![0]; + expect(create.winnerPetId).toBe('p1'); + expect(create.loserPetId).toBe('p2'); + expect(create.xpWin).toBe(20); + expect(create.xpLoss).toBe(4); + }); + + it('flips winner, loser and the xp split when the defender won', async () => { + const tx = fakeTx(); + await recordBattleFromReceipt(tx as never, { ...receipt, attackerWon: false }); + const { create } = tx.battleHistory.upsert.mock.calls[0]![0]; + expect(create.winnerPetId).toBe('p2'); + expect(create.loserPetId).toBe('p1'); + expect(create.xpWin).toBe(4); + expect(create.xpLoss).toBe(20); + }); + + it('stores foughtAt as unix seconds', async () => { + const tx = fakeTx(); + await recordBattleFromReceipt(tx as never, receipt); + const { create } = tx.battleHistory.upsert.mock.calls[0]![0]; + expect(create.foughtAt).toBe(1000n); + }); + + it('writes on the caller transaction, never the global client', async () => { + // The row has to commit with the receipt that produced it. + const tx = fakeTx(); + await recordBattleFromReceipt(tx as never, receipt); + expect(prisma.battleHistory.upsert).not.toHaveBeenCalled(); + }); }); describe('getHeadToHead', () => { From 2a0ae7a371adbc3e5ee4b0437711805c91513289 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Mon, 27 Jul 2026 22:26:22 -0400 Subject: [PATCH 55/76] refactor(contracts): drop GameLogic's retired battle storage --- contracts/ethereum/scripts/deploy.ts | 9 ++++---- .../ethereum/scripts/upgrade-game-config.ts | 18 +++++++-------- contracts/ethereum/src/GameLogic.sol | 23 ++++++++----------- contracts/ethereum/src/README.md | 13 +++++++---- 4 files changed, 32 insertions(+), 31 deletions(-) diff --git a/contracts/ethereum/scripts/deploy.ts b/contracts/ethereum/scripts/deploy.ts index 6201aaee..ebc10cb1 100644 --- a/contracts/ethereum/scripts/deploy.ts +++ b/contracts/ethereum/scripts/deploy.ts @@ -117,7 +117,6 @@ async function injectContractAddresses(network: NetworkSpec): Promise { const petCoreAddress = deployedAddresses['CryptoPetsV2Live#PetCoreProxy'] as string | undefined; const gameLogicAddress = deployedAddresses['CryptoPetsV2Live#GameLogicProxy'] as string | undefined; const gameConfigAddress = deployedAddresses['CryptoPetsV2Live#GameConfig'] as string | undefined; - const combatSimAddress = deployedAddresses['CryptoPetsV2Live#CombatSim'] as string | undefined; if (!petCoreAddress) { console.error('❌ PetCore proxy not found in deployed_addresses.json'); @@ -127,7 +126,6 @@ async function injectContractAddresses(network: NetworkSpec): Promise { console.log(`📝 PetCore: ${petCoreAddress}`); console.log(`📝 GameLogic: ${gameLogicAddress ?? '(not found)'}`); console.log(`📝 GameConfig: ${gameConfigAddress ?? '(not found)'}`); - console.log(`📝 CombatSim: ${combatSimAddress ?? '(not found)'}`); const frontendEnvLocalPath = join(process.cwd(), '..', '..', 'frontend', '.env.local'); @@ -142,12 +140,15 @@ async function injectContractAddresses(network: NetworkSpec): Promise { else { lines.push(`${key}=${value}`); } } - const lines = envContent.split('\n').filter((l) => !l.startsWith('VITE_VRF_COORDINATOR=')); + // Drop vars for contracts this stack no longer deploys, so an existing .env.local + // does not keep pointing the frontend at a dead address: VITE_VRF_COORDINATOR from + // the Chainlink era, VITE_COMBATSIM_ADDRESS since battles left the chain (§L Phase 6). + const STALE_ENV_KEYS = ['VITE_VRF_COORDINATOR=', 'VITE_COMBATSIM_ADDRESS=']; + const lines = envContent.split('\n').filter((l) => !STALE_ENV_KEYS.some((k) => l.startsWith(k))); upsertEnvLine(lines, 'VITE_PETCORE_ADDRESS', petCoreAddress); if (gameLogicAddress) upsertEnvLine(lines, 'VITE_GAMELOGIC_ADDRESS', gameLogicAddress); if (gameConfigAddress) upsertEnvLine(lines, 'VITE_GAMECONFIG_ADDRESS', gameConfigAddress); - if (combatSimAddress) upsertEnvLine(lines, 'VITE_COMBATSIM_ADDRESS', combatSimAddress); if (!lines.some((l) => l.startsWith('VITE_API_URL='))) { lines.push('VITE_API_URL=http://localhost:3001'); diff --git a/contracts/ethereum/scripts/upgrade-game-config.ts b/contracts/ethereum/scripts/upgrade-game-config.ts index 98ada7af..d4a57476 100644 --- a/contracts/ethereum/scripts/upgrade-game-config.ts +++ b/contracts/ethereum/scripts/upgrade-game-config.ts @@ -1,7 +1,7 @@ #!/usr/bin/env tsx /** - * Migrate to a new GameConfig (e.g. after adding a tunable like battleFee) without losing - * any prior on-chain tuning, and without changing GameLogicProxy/PetCoreProxy addresses. + * Migrate to a new GameConfig (e.g. after adding or removing a tunable) without losing any + * prior on-chain tuning, and without changing GameLogicProxy/PetCoreProxy addresses. * * GameConfig is NOT behind a proxy (see its own doc comment) — adding a field means * deploying a fresh instance, which resets every tunable back to its Solidity constructor @@ -11,9 +11,12 @@ * setGameConfig() setter to repoint at the new instance, so this upgrades both proxies' * implementations first (same upgradeTo pattern as upgrade-game-logic.ts). * - * battleCooldown has no setter (see GameConfig.sol) — it's fixed at construction from - * source, so the new instance already carries whatever the current source defines; there - * is nothing to replay for it. + * Only fields the CURRENT GameConfig still declares are replayed. The artifact ABI is used + * to read the old instance as well as write the new one, so a field dropped from source + * (`battleFee`, `battleCooldown`, `combatSim`, all retired with the on-chain battle path in + * §L Phase 6) is simply not read — adding it back here would fail on the ABI, not on chain. + * Anything without a setter is likewise unreplayable: the new instance carries whatever its + * constructor defines. * * Usage: * pnpm --prefix contracts/ethereum exec tsx scripts/upgrade-game-config.ts --network=base-sepolia @@ -106,8 +109,6 @@ async function main() { console.log(`\nReplaying tunables from old GameConfig (${oldConfigAddress}) onto the new one...`); const oldAbi = gameConfigArtifact.abi; - const combatSim = await read<`0x${string}`>(oldConfigAddress!, oldAbi, 'combatSim'); - await write(newConfigAddress, oldAbi, 'setCombatSim', [combatSim]); const singleFieldSetters: [string, string][] = [ ['levelUpFee', 'setLevelUpFee'], @@ -143,9 +144,6 @@ async function main() { await write(newConfigAddress, oldAbi, 'setPoolSize', [tier, size]); } - // battleFee has no old value to replay — the new GameConfig's own constructor default - // applies (see GameConfig.sol; tune via setBattleFee afterward if the default is wrong). - console.log(`\nDeploying fresh GameLogic + PetCore implementations (both need setGameConfig)...`); const newGameLogicImpl = await deploy(gameLogicArtifact); console.log(`- new GameLogic implementation: ${newGameLogicImpl}`); diff --git a/contracts/ethereum/src/GameLogic.sol b/contracts/ethereum/src/GameLogic.sol index 81693582..1ab1c312 100644 --- a/contracts/ethereum/src/GameLogic.sol +++ b/contracts/ethereum/src/GameLogic.sol @@ -31,7 +31,7 @@ import "./DnaLib.sol"; */ contract GameLogic is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, IEntropyConsumer { - string public constant VERSION = "1.1.0"; + string public constant VERSION = "2.0.0"; // ─── events ─────────────────────────────────────────────────────────────── @@ -77,11 +77,14 @@ contract GameLogic is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, address otherOwner; // recipient of studFee at settle; address(0) for same-owner breeds } - /// @dev `Battle` is retired (§L Phase 6) but kept in place: removing it would renumber - /// `Mint`, and this enum's values are persisted in `_requestTypes`. - enum RequestType { None, Breed, RetiredBattle, Mint } + enum RequestType { None, Breed, Mint } - // ─── storage (layout append-only) ──────────────────────────────────────── + // ─── storage ───────────────────────────────────────────────────────────── + // + // Append-only from here on. The 2.0.0 layout dropped the retired battle slots outright + // rather than parking them, which is a BREAKING change. Every slot after the removed + // pair shifts, so upgrading an existing proxy onto this would reinterpret live breed + // and mint state. 2.0.0 is a fresh deployment, not an upgrade target. PetCore public petCore; GameConfig public gameConfig; @@ -91,12 +94,6 @@ contract GameLogic is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, mapping(uint256 => uint256) public petBreedRequestId; mapping(uint256 => RequestType) private _requestTypes; - /// @dev Retired with the on-chain battle path (§L Phase 6). The slots stay declared and - /// unused rather than deleted: this contract sits behind a UUPS proxy, and removing a - /// storage variable shifts every slot after it, which would silently reinterpret live - /// breeding and mint state on the next upgrade. Never reuse these. - mapping(uint256 => uint256) private __retired_battleRequests; - mapping(uint256 => uint256) private __retired_petBattleRequestId; // Stud fees owed to the non-initiating owner of a cross-owner breed (plan §4.4), // released as a pull payment via withdrawStudFees(). @@ -105,8 +102,8 @@ contract GameLogic is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, // Pending starter mints (plan §4.3): DNA is fixed by the Entropy reveal, not at request. mapping(uint256 => MintRequest) private _mintRequests; - // Reserve 40 slots: 10 declared above (through _mintRequests) + 40 gap = 50 total. - uint256[40] private __gap; + // Reserve 42 slots: 8 declared above (through _mintRequests) + 42 gap = 50 total. + uint256[42] private __gap; // ─── modifiers ──────────────────────────────────────────────────────────── diff --git a/contracts/ethereum/src/README.md b/contracts/ethereum/src/README.md index 7c964713..121fe220 100644 --- a/contracts/ethereum/src/README.md +++ b/contracts/ethereum/src/README.md @@ -8,14 +8,19 @@ Two generations of contracts live in this package. See | File | Role | | --- | --- | | `PetCore.sol` | UUPS proxy implementation: ERC-721 + pet storage (DNA, stats, lineage, cooldowns) + marriage records | -| `GameLogic.sol` | UUPS proxy implementation: battle/breed/train mechanics, Pyth Entropy request → store → settle | +| `GameLogic.sol` | UUPS proxy implementation: breed/mint/train mechanics, Pyth Entropy request → store → settle | | `GameConfig.sol` | Plain (non-proxy) contract holding every tunable; swap by deploying a new one and re-pointing | -| `CombatSim.sol` | Stateless pure combat simulator; balance patches deploy `CombatSimV2` and call `GameConfig.setCombatSim` | +| `CombatSim.sol` | Stateless pure combat simulator. **Frozen and no longer deployed** (§L Phase 6): nothing on chain calls it, and it stays only as the Solidity leg of the golden-vector parity check, which deploys it per test run | | `DnaLib.sol` | Internal library: DNA → attributes/rarity/element derivation (must stay bit-identical with Solana) | | `TestDeployer.sol` | Single-tx local deployer for the proxy stack (tests only) | **Why the `V1` suffix on v2-architecture contracts?** It versions the *implementation behind the proxy*, not the game. The first upgrade deploys a -`PetCoreV2` implementation into the same `PetCoreProxy`; old `CombatSim` -stays on-chain so historical battles remain replayable. This follows the +`PetCoreV2` implementation into the same `PetCoreProxy`. This follows the plan's own naming (§2.1). + +Battles no longer settle on chain at all (§L Phase 6). `GameLogic` keeps its +retired battle storage slots declared but unused, because it sits behind a proxy +and deleting a slot re-lays out everything after it; `PetCore.Pet` keeps +`winCount`/`lossCount`/`lastOpponentId`/`sameOpponentStreak` for the same reason. +Read those as a frozen record of whatever the last on-chain battle left behind. From 060cc16b6570845a28575b503b6e9ca36ea145a9 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Mon, 27 Jul 2026 22:38:15 -0400 Subject: [PATCH 56/76] refactor(contracts,indexer): drop the retired battle record from PetCore --- contracts/ethereum/src/PetCore.sol | 45 +++++++------------- contracts/ethereum/subgraph/schema.graphql | 8 ---- contracts/ethereum/subgraph/src/pet.ts | 2 - frontend/src/chains/ethereum/petCoreAbi.json | 34 +-------------- indexer-go/internal/evm/client.go | 4 +- indexer-go/internal/evm/indexer_test.go | 2 +- indexer-go/internal/evm/mapping.go | 6 ++- shared/src/utils/pets/mapEvmPet.ts | 9 ++-- shared/tests/utils/pets/mapEvmPet.test.ts | 22 +++++----- 9 files changed, 40 insertions(+), 92 deletions(-) diff --git a/contracts/ethereum/src/PetCore.sol b/contracts/ethereum/src/PetCore.sol index 74522d4b..f7526cc1 100644 --- a/contracts/ethereum/src/PetCore.sol +++ b/contracts/ethereum/src/PetCore.sol @@ -12,10 +12,12 @@ import "./DnaLib.sol"; * @title PetCore * @dev UUPS-upgradeable ERC-721 that owns all pet data (DNA, stats, lineage, cooldowns). * Exposes mutator methods callable only by the authorized GameLogic proxy (or owner). - * Storage layout must be append-only from this point forward. + * Storage layout is append-only from 2.0.0 forward. 2.0.0 itself is not: it drops the + * retired battle members from `Pet`, which re-lays out every entry in the `_pets` + * mapping, so an existing proxy cannot be upgraded onto it. It is a fresh deployment. */ contract PetCore is ERC721PausableUpgradeable, UUPSUpgradeable, OwnableUpgradeable { - string public constant VERSION = "1.0.0"; + string public constant VERSION = "2.0.0"; event NewPet(uint256 indexed petId, string name, uint256 dna, uint8 rarity); event PetLevelUp(uint256 indexed petId, uint32 newLevel); @@ -28,27 +30,19 @@ contract PetCore is ERC721PausableUpgradeable, UUPSUpgradeable, OwnableUpgradeab event CallerRevoked(address indexed caller); event GameConfigUpdated(address config); - /// @dev `winCount`, `lossCount`, `lastOpponentId`, and `sameOpponentStreak` are retired - /// with the on-chain battle path (§L Phase 6): nothing writes them any more, because - /// backend battles keep progression in `pet_battle_progress`, keyed separately so - /// on-chain and off-chain state can never be mistaken for each other. + /// @dev No battle record here. Win/loss counts and the same-opponent decay state + /// (`lastOpponentId`, `sameOpponentStreak`) lived on this struct when battles settled + /// on chain; they are gone as of §L Phase 6, because nothing would ever write them + /// again. A pet's battle record lives in the backend's `pet_battle_progress`. /// - /// They stay declared, in place, because this contract is behind a UUPS proxy and - /// `Pet` lives in a mapping: removing or reordering a member re-lays out every pet - /// already minted. Read them as a frozen record of whatever the last on-chain battle - /// left behind — zero for a pet that never fought on chain. - /// - /// `readyTime` is **not** retired. Breeding still writes it (`setCooldown` applies - /// `newbornCooldown` to offspring), and the backend honours it through the indexed - /// `pet_roster.ready_at`, so a newborn is still barred from fighting. What changed is - /// only that battles no longer *set* it. + /// `readyTime` stays. Breeding writes it (`setCooldown` applies `newbornCooldown` to + /// offspring) and the backend honours it through the indexed `pet_roster.ready_at`, + /// so a newborn is still barred from fighting. Only battles stopped setting it. struct Pet { string name; uint256 dna; uint32 level; uint32 readyTime; - uint16 winCount; - uint16 lossCount; uint8 rarity; uint32 xp; // XP toward next level; auto-levels at 100 * currentLevel uint8 generation; // 0 = starter; N = N breeding events from starters @@ -58,8 +52,6 @@ contract PetCore is ERC721PausableUpgradeable, UUPSUpgradeable, OwnableUpgradeab uint16 speciesId; // resolved at mint from DNA + rarity tier (plan §3.7) uint256 parent1Id; // 0 for gen-0 pets uint256 parent2Id; // 0 for gen-0 pets - uint256 lastOpponentId; // 0 = no battles yet (plan §3.4 same-opponent decay) - uint8 sameOpponentStreak; // consecutive battles vs lastOpponentId; halves XP each time } // Marriage record (plan §4.4): written for both pets at accept time (mutual). @@ -425,9 +417,9 @@ contract PetCore is ERC721PausableUpgradeable, UUPSUpgradeable, OwnableUpgradeab function getPetStats( uint256 petId - ) external view entryExists(petId) returns (uint32, uint16, uint16, uint8) { + ) external view entryExists(petId) returns (uint32 level, uint8 rarity) { Pet memory p = _pets[petId]; - return (p.level, p.winCount, p.lossCount, p.rarity); + return (p.level, p.rarity); } function getBreedInfo( @@ -482,12 +474,7 @@ contract PetCore is ERC721PausableUpgradeable, UUPSUpgradeable, OwnableUpgradeab name: name_, dna: dna, level: 1, - // Retired battle field (§L Phase 6). Zeroed at mint rather than seeded from a - // cooldown that no longer exists: nothing on chain reads it, and backend battles - // track readiness in `pet_battle_progress`. - readyTime: 0, - winCount: 0, - lossCount: 0, + readyTime: 0, // battle-ready immediately; only breeding sets this now rarity: rarity, xp: 0, generation: generation, @@ -496,9 +483,7 @@ contract PetCore is ERC721PausableUpgradeable, UUPSUpgradeable, OwnableUpgradeab trainReadyAt: 0, // train-ready immediately speciesId: _resolveSpecies(dna, rarity), parent1Id: parent1Id, - parent2Id: parent2Id, - lastOpponentId: 0, - sameOpponentStreak: 0 + parent2Id: parent2Id }); emit NewPet(newId, name_, dna, rarity); return newId; diff --git a/contracts/ethereum/subgraph/schema.graphql b/contracts/ethereum/subgraph/schema.graphql index 849db762..90050157 100644 --- a/contracts/ethereum/subgraph/schema.graphql +++ b/contracts/ethereum/subgraph/schema.graphql @@ -18,14 +18,6 @@ type Pet @entity(immutable: false) { level: Int! rarity: Int! - """ - Lifetime on-chain record. Frozen at whatever the retired on-chain battle path left - behind: nothing writes these any more, and backend battle records live in - `pet_battle_progress` instead. Kept because indexer-go's selection set reads them. - """ - winCount: Int! - lossCount: Int! - """ Unix seconds the pet is next battle-ready (Pet.readyTime). Only breeding writes it now — a newborn is barred from fighting until its cooldown expires — but the backend diff --git a/contracts/ethereum/subgraph/src/pet.ts b/contracts/ethereum/subgraph/src/pet.ts index 4bf9a39e..42deb1e6 100644 --- a/contracts/ethereum/subgraph/src/pet.ts +++ b/contracts/ethereum/subgraph/src/pet.ts @@ -29,8 +29,6 @@ export function refreshPet(petId: BigInt, updatedAt: BigInt): void { pet.dna = p.dna; pet.level = p.level.toI32(); pet.rarity = p.rarity; - pet.winCount = p.winCount; - pet.lossCount = p.lossCount; pet.readyAt = p.readyTime; pet.updatedAt = updatedAt; diff --git a/frontend/src/chains/ethereum/petCoreAbi.json b/frontend/src/chains/ethereum/petCoreAbi.json index 434d1ae1..dc98a51c 100644 --- a/frontend/src/chains/ethereum/petCoreAbi.json +++ b/frontend/src/chains/ethereum/petCoreAbi.json @@ -759,16 +759,6 @@ "name": "readyTime", "type": "uint32" }, - { - "internalType": "uint16", - "name": "winCount", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "lossCount", - "type": "uint16" - }, { "internalType": "uint8", "name": "rarity", @@ -813,16 +803,6 @@ "internalType": "uint256", "name": "parent2Id", "type": "uint256" - }, - { - "internalType": "uint256", - "name": "lastOpponentId", - "type": "uint256" - }, - { - "internalType": "uint8", - "name": "sameOpponentStreak", - "type": "uint8" } ], "internalType": "struct PetCore.Pet", @@ -845,22 +825,12 @@ "outputs": [ { "internalType": "uint32", - "name": "", + "name": "level", "type": "uint32" }, - { - "internalType": "uint16", - "name": "", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "", - "type": "uint16" - }, { "internalType": "uint8", - "name": "", + "name": "rarity", "type": "uint8" } ], diff --git a/indexer-go/internal/evm/client.go b/indexer-go/internal/evm/client.go index 5d2da7f9..31a02064 100644 --- a/indexer-go/internal/evm/client.go +++ b/indexer-go/internal/evm/client.go @@ -15,7 +15,7 @@ import ( // ready the moment they deploy. petFields is shared so the full and // incremental queries can never drift. const ( - petFields = `id owner name dna level rarity winCount lossCount readyAt updatedAt + petFields = `id owner name dna level rarity readyAt updatedAt xp generation parent1Id parent2Id breedCount speciesId spouseId breedReadyAt trainReadyAt` fullSyncQuery = ` @@ -43,8 +43,6 @@ type subgraphPet struct { DNA string `json:"dna"` Level uint32 `json:"level"` Rarity uint32 `json:"rarity"` - WinCount uint32 `json:"winCount"` - LossCount uint32 `json:"lossCount"` ReadyAt string `json:"readyAt"` UpdatedAt string `json:"updatedAt"` diff --git a/indexer-go/internal/evm/indexer_test.go b/indexer-go/internal/evm/indexer_test.go index 8de3cb8f..a300c3aa 100644 --- a/indexer-go/internal/evm/indexer_test.go +++ b/indexer-go/internal/evm/indexer_test.go @@ -105,7 +105,7 @@ func (f *fakeSubgraph) serveBattles(w http.ResponseWriter, sinceStr string, firs func pet(id, owner string, level uint32, updatedAt string) subgraphPet { return subgraphPet{ ID: id, Owner: owner, Name: "pet-" + id, DNA: "12345", - Level: level, Rarity: 2, WinCount: 3, LossCount: 1, + Level: level, Rarity: 2, ReadyAt: "1770000000", UpdatedAt: updatedAt, } } diff --git a/indexer-go/internal/evm/mapping.go b/indexer-go/internal/evm/mapping.go index 680abe91..57dee04b 100644 --- a/indexer-go/internal/evm/mapping.go +++ b/indexer-go/internal/evm/mapping.go @@ -35,11 +35,13 @@ func (ix *Indexer) toUpdate(pet subgraphPet) (indexer.RosterUpdate, error) { Level: pet.Level, Rarity: pet.Rarity, DNA: pet.DNA, - WinCount: pet.WinCount, - LossCount: pet.LossCount, ReadyAt: readyAt, Version: updatedAt, + // WinCount/LossCount stay zero: PetCore stopped carrying a battle record when + // battles moved off chain (§L Phase 6), so there is nothing on chain to mirror. + // A pet's real record is the backend's `pet_battle_progress`. + // // v2 fields. EVM has no Metaplex Core asset (ERC-721 token id IS the // pet id), so Asset stays empty. XP: pet.XP, diff --git a/shared/src/utils/pets/mapEvmPet.ts b/shared/src/utils/pets/mapEvmPet.ts index 23b583b9..3f068d7a 100644 --- a/shared/src/utils/pets/mapEvmPet.ts +++ b/shared/src/utils/pets/mapEvmPet.ts @@ -5,8 +5,6 @@ export interface EvmRawPet { dna: bigint; level: number | bigint; readyTime: bigint; - winCount: number | bigint; - lossCount: number | bigint; rarity: number | bigint; // v2 fields (PetCore getPet); optional for back-compat with v1 reads. xp?: number | bigint; @@ -28,8 +26,11 @@ export const mapEvmPet = (raw: EvmRawPet, tokenId: bigint): Pet => { dna: BigInt(raw.dna), level: Number(raw.level), rarity: Number(raw.rarity), - winCount: Number(raw.winCount), - lossCount: Number(raw.lossCount), + // PetCore carries no battle record (§L Phase 6). A pet's real win/loss comes from + // the backend, merged over this by `useBattleProgress`; zero here means "no backend + // record yet", which is exactly true for a pet that has never fought. + winCount: 0, + lossCount: 0, readyAt: Number(raw.readyTime), xp: num(raw.xp), generation: num(raw.generation), diff --git a/shared/tests/utils/pets/mapEvmPet.test.ts b/shared/tests/utils/pets/mapEvmPet.test.ts index 4a9a7264..c6332775 100644 --- a/shared/tests/utils/pets/mapEvmPet.test.ts +++ b/shared/tests/utils/pets/mapEvmPet.test.ts @@ -6,8 +6,6 @@ const raw: EvmRawPet = { dna: 1234567890123456789n, level: 3, readyTime: 1_700_000_000n, - winCount: 5, - lossCount: 2, rarity: 4, }; @@ -21,8 +19,9 @@ describe('mapEvmPet', () => { dna: 1234567890123456789n, level: 3, rarity: 4, - winCount: 5, - lossCount: 2, + // PetCore has no battle record; the backend's is merged on later. + winCount: 0, + lossCount: 0, readyAt: 1_700_000_000, }); }); @@ -35,17 +34,20 @@ describe('mapEvmPet', () => { }); it('coerces bigint-valued numeric fields to numbers', () => { - const pet = mapEvmPet( - { ...raw, level: 9n, winCount: 100n, lossCount: 1n, rarity: 5n }, - 1n, - ); + const pet = mapEvmPet({ ...raw, level: 9n, rarity: 5n }, 1n); expect(pet.level).toBe(9); - expect(pet.winCount).toBe(100); - expect(pet.lossCount).toBe(1); expect(pet.rarity).toBe(5); expect(typeof pet.level).toBe('number'); }); + it('zeroes the battle record rather than reading one off the chain', () => { + // PetCore stopped carrying win/loss when battles moved off chain, so there is + // nothing to read. useBattleProgress merges the backend's record over this. + const pet = mapEvmPet(raw, 1n); + expect(pet.winCount).toBe(0); + expect(pet.lossCount).toBe(0); + }); + it('maps v2 fields when present and coerces them to numbers', () => { const pet = mapEvmPet( { ...raw, xp: 40n, generation: 2n, breedCount: 1n, speciesId: 17n, breedReadyAt: 1_700_000_500n, trainReadyAt: 1_700_000_900n }, From d9cf7fa8feaaf78f47299e7fb7a437436f87c1ed Mon Sep 17 00:00:00 2001 From: heyradcode Date: Mon, 27 Jul 2026 22:50:40 -0400 Subject: [PATCH 57/76] refactor(contracts): delete CombatSim and its Solidity vector suite --- AGENTS.md | 4 +- CLAUDE.md | 17 +- contracts/ethereum/hardhat.config.ts | 8 +- .../ignition/modules/CryptoPetsV2Live.ts | 6 +- .../ethereum/scripts/gen-battle-vectors.ts | 110 --------- contracts/ethereum/src/CombatSim.sol | 209 ------------------ contracts/ethereum/src/GameConfig.sol | 23 +- contracts/ethereum/src/GameLogic.sol | 6 +- contracts/ethereum/src/README.md | 1 - .../ethereum/test/CombatGoldenVectors.test.ts | 55 ----- contracts/ethereum/test/CryptoPetsV2.test.ts | 70 +----- .../src/chains/ethereum/gameConfigAbi.json | 55 ----- 12 files changed, 38 insertions(+), 526 deletions(-) delete mode 100644 contracts/ethereum/scripts/gen-battle-vectors.ts delete mode 100644 contracts/ethereum/src/CombatSim.sol delete mode 100644 contracts/ethereum/test/CombatGoldenVectors.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6b0424ee..11c2d8dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Normative language: `MUST`/`MUST NOT` are mandatory. `SHOULD`/`SHOULD NOT` are e ## Non-Negotiables -- `MUST NOT` change the on-chain combat ports. `contracts/ethereum/src/CombatSim.sol` and Solana's `combat.rs` are **frozen** as of §L Phase 6 (see `docs/plan-backend-battle-architecture.md`). Every battle they ever settled is a permanent on-chain record, and those records have to stay replayable forever, so editing either one silently rewrites history rather than fixing anything. A bug found in them is fixed forward in the live ports below, under a new `rulesetVersion`, never by patching the frozen ones. +- `MUST NOT` change Solana's frozen combat port (`game/battle_sim.rs`, `game/xp.rs`). It has no caller left in the program, but its golden-vector tests are what still prove `contracts/test-vectors/{battle,xp}.json` describe what actually settled on that chain. A bug found there is fixed forward in the live ports below, under a new `rulesetVersion`, never by patching the frozen one. **The Solidity port is gone**: `CombatSim.sol` was deleted once it had no on-chain caller, which also removed `battle.json`'s Solidity generator and validator. `battle.json` itself is unchanged and still gates the live ports. - `MUST` keep the two **live** combat ports in step with each other and with the golden vectors: `protocol/src/combat/` (the canonical engine, re-exported from `shared/src/utils/combat` for existing importers) and `indexer-go/internal/combat/` (the independent verifier). Changing one without the other re-breaks the circuit breaker in §F, whose whole value is that the two were written to disagree if either drifts. This covers XP and level progression too (`protocol/src/combat/xp.ts`, validated against `contracts/test-vectors/xp.json`), so an XP or decay change is a both-ports change. `indexer-go/internal/combat/xp.go` still covers the formula and the decay but not level-up. - `MUST NOT` edit `contracts/test-vectors/{battle,xp}.json` to make a failing test pass — this holds more strongly now, not less. The vectors are the only mechanical link left between the frozen ports and the live ones. A live port that fails them has drifted away from the rules real battles were settled under. - `MUST NOT` assume the `ChainAdapter` interface (`shared/src/hooks/adapters/`) covers more than pet-action mutations and reads. It is a real, shared interface (`useEvmAdapter`/`useSolanaAdapter` both implement it) and every public pet-action hook consumes it chain-blind, but the low-level chain wiring in `frontend/src/chains/{ethereum,solana}/`, the async battle/breed VRF flows, and the combat simulator remain intentionally separate per chain. See CLAUDE.md's cross-chain interfaces section for the exact boundary. @@ -46,6 +46,6 @@ Full per-package lint/test/build matrix and single-test syntax: see [CLAUDE.md]( Mechanical checks over prose, where they exist: - ESLint per package (`frontend`, `shared`, `website`, `mobile`), plus a custom CSS-naming check in `frontend` (`lint:css`). -- Golden test vectors (`contracts/test-vectors/{battle,xp}.json`), run by Hardhat, Anchor, `indexer-go`'s `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts` (Vitest), are the cross-language enforcement for combat-simulator parity. All four suites keep running after the freeze: the two frozen ports prove the vectors still describe what really settled on chain, and the two live ports prove they have not drifted from it. +- Golden test vectors (`contracts/test-vectors/{battle,xp}.json`), run by Anchor, `indexer-go`'s `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts` (Vitest), are the cross-language enforcement for combat-simulator parity. Anchor's frozen suite proves the vectors still describe what really settled on Solana; the two live ports prove they have not drifted from it. Hardhat no longer checks `battle.json` — that leg went with `CombatSim.sol`. - CI coverage workflow (`.github/workflows/coverage.yml`) runs frontend/backend/shared vitest coverage on every PR and posts a combined comment. The verifier workflow (`.github/workflows/verifier.yml`) replays a committed receipt corpus through the standalone verifier, and asserts a tampered corpus is rejected. - There is no repo-wide `agents:check` or module-boundary lint yet. Rely on the per-package commands above and the golden vectors until one exists. diff --git a/CLAUDE.md b/CLAUDE.md index 5bc223e0..94f5206f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,17 +99,18 @@ Note: `docs/README.md` and `docs/architecture.md` link to `indexer-go/ARCHITECTU ### Cross-chain interfaces: one thin adapter layer, plus logic that stays deliberately separate `shared/src/hooks/adapters/` (`ChainAdapter` in `types.ts`) is a real, shared TypeScript interface. `useEvmAdapter` and `useSolanaAdapter` both implement it, `useChainAdapter` returns whichever is active, and every public pet-action hook (`useCreatePet`, `useLevelUpPet`, `useTrainPet`, `useRenamePet`, `useTransferPet`, `useBattlePets`, `useBreedPets`, the pet-list read) consumes it chain-blind. Check `adapters/types.ts` before assuming this doesn't exist. -What that adapter does NOT unify: `frontend/src/chains/ethereum/` (wagmi client, in-tree ABI JSONs: `combatSimAbi.json`, `gameConfigAbi.json`, `gameLogicAbi.json`, `petCoreAbi.json`) and `frontend/src/chains/solana/` (Anchor wallet/provider/signer) are still separate, low-level wiring with no shared interface between them, each adapter reaches into its own directly. The async battle/breed VRF flows (`useEvmBattleFlow.ts`, `battleWithSwitchboardVrf.ts`) and the combat simulator itself are also not unified; see the next section. Treat the adapter as a thin, uniform shape over pet-action mutations and reads, not a claim that the underlying chain logic is shared. +What that adapter does NOT unify: `frontend/src/chains/ethereum/` (wagmi client, in-tree ABI JSONs: `gameConfigAbi.json`, `gameLogicAbi.json`, `petCoreAbi.json`) and `frontend/src/chains/solana/` (Anchor wallet/provider/signer) are still separate, low-level wiring with no shared interface between them, each adapter reaches into its own directly. The async battle/breed VRF flows (`useEvmBattleFlow.ts`, `battleWithSwitchboardVrf.ts`) and the combat simulator itself are also not unified; see the next section. Treat the adapter as a thin, uniform shape over pet-action mutations and reads, not a claim that the underlying chain logic is shared. -### Combat simulator: two frozen ports, two live ones, one set of golden vectors -The battle/combat logic exists in four independent implementations, and as of §L Phase 6 they are **no longer peers**: +### Combat simulator: one frozen port, two live ones, one set of golden vectors +The battle/combat logic began as four independent implementations. As of §L Phase 6 they are no longer peers, and the Solidity one is gone: -- **Frozen** — `contracts/ethereum/src/CombatSim.sol` and Solana's `combat.rs`. These settled real battles whose results are permanent on-chain records, so they have to keep replaying those records forever. **Do not change them.** A bug found here is fixed forward in the live ports under a new `rulesetVersion`; patching a frozen port silently rewrites history instead of fixing anything. +- **Frozen** — Solana's `game/battle_sim.rs` and `game/xp.rs`. No caller left in the program, kept because their golden-vector tests are what still tie the vectors to what really settled on that chain. **Do not change them.** A bug is fixed forward in the live ports under a new `rulesetVersion`. +- **Deleted** — `contracts/ethereum/src/CombatSim.sol`. Removed once nothing on chain called it, together with `gen-battle-vectors.ts` (which generated `battle.json` from it) and `CombatGoldenVectors.test.ts`. `battle.json` is unchanged and still gates the live ports; what is gone is the ability to regenerate it, and the Solidity leg of the parity check. - **Live** — `protocol/src/combat/` (the canonical engine, re-exported from `shared/src/utils/combat` for existing importers) and `indexer-go/internal/combat/` (the independent verifier). These two `MUST` change together. §F's circuit breaker only has value because they were written to disagree if either drifts, so updating one alone quietly disarms it. -All four are still validated against the same golden vectors at `contracts/test-vectors/{battle,xp}.json`, run by Hardhat, Anchor, `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts`. Keeping the frozen suites running is the point: they prove the vectors still describe what actually settled on chain, and the live suites prove the current engine has not drifted from it. +The remaining three are validated against the same golden vectors at `contracts/test-vectors/{battle,xp}.json`, run by Anchor, `combat_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts`. Keeping Anchor's frozen suite running is the point: it proves the vectors still describe what actually settled on Solana, and the live suites prove the current engine has not drifted from it. Hardhat no longer checks `battle.json` at all — that leg went with `CombatSim.sol`. `contracts/ethereum/test/XpFormula.test.ts` still runs, but note it is a pure-TypeScript reimplementation of the formula checked against the fixture; it does not call a contract, and did not before this change either. -Hashing uses **legacy Keccak-256** (`keccak256(abi.encodePacked(...))` byte layout); a SHA3-vs-Keccak mismatch fails every vector. The TS port covers XP and level progression as well as fight math: `protocol/src/combat/xp.ts` mirrors `GameLogic._calcXp` / `PetCore.addXp` / `PetCore.recordBattleOpponent` and is validated against `contracts/test-vectors/xp.json`, with the snapshot-shaped wrapper in `protocol/src/progression/` (vectors: `protocol-progression.json`). This became portable once `lastOpponentId`/`streak` were frozen into the battle snapshot; before that the client had no way to know the streak state XP depends on. Note the decay shift **must be clamped to 31** in TS: JavaScript's `>>` masks the shift count to 5 bits, so an unclamped `200 >> 32` returns 200 where Solidity, Rust, and Go all return 0. `indexer-go/internal/combat/xp.go` still covers only the formula and decay, not level-up. +Hashing uses **legacy Keccak-256** (`keccak256(abi.encodePacked(...))` byte layout); a SHA3-vs-Keccak mismatch fails every vector. The TS port covers XP and level progression as well as fight math: `protocol/src/combat/xp.ts` mirrors the XP rules the chains settled under (`GameLogic._calcXp` / `PetCore.addXp`, plus the same-opponent decay that `PetCore` no longer carries) and is validated against `contracts/test-vectors/xp.json`, with the snapshot-shaped wrapper in `protocol/src/progression/` (vectors: `protocol-progression.json`). This became portable once `lastOpponentId`/`streak` were frozen into the battle snapshot; before that the client had no way to know the streak state XP depends on. Note the decay shift **must be clamped to 31** in TS: JavaScript's `>>` masks the shift count to 5 bits, so an unclamped `200 >> 32` returns 200 where Solidity, Rust, and Go all return 0. `indexer-go/internal/combat/xp.go` still covers only the formula and decay, not level-up. **If a golden vector test fails, a live port has drifted from the rules real battles were settled under. Fix the drifted port, never edit the vector.** A *frozen* port failing a vector means something worse — the vectors or the contract source no longer match what is deployed — and is an incident, not a test failure. @@ -177,8 +178,8 @@ Consequently `src/grpc/battleStream.ts` is deleted: nothing published to it afte `contracts/plan-contract-upgrade.md` documents intentional v1 gaps that v2 is designed around: no battle authorization (anyone can call `battle()`/`attack()` on anyone's pets), an EVM `changeDna` cheat that lets a level-20 pet set arbitrary DNA, and a Solana `create_starter_pet` that accepts client-supplied dna/rarity. v2 plan: EVM moves to UUPS proxies (`PetCoreProxy` + `GameLogicProxy`, with `CombatSimV1` deployed as a separate contract to stay under the 24KB bytecode ceiling); Solana adds versioned/reserved-space accounts and migrates pets to Metaplex Core NFTs. This is a plan doc; check current contract source before assuming any of it is implemented. ### Hardhat specifics worth knowing -- Contract sources live in `contracts/ethereum/src/` (not `contracts/`): `PetCore.sol`, `GameLogic.sol`, `GameConfig.sol`, `CombatSim.sol`, `DnaLib.sol`, `TestDeployer.sol`. -- Both compiler profiles (`default` and `production`) are pinned to `viaIR` explicitly. Hardhat Ignition silently drops viaIR/optimizer settings from a flat config, and `CombatSim.sol` hits "stack too deep" without it. +- Contract sources live in `contracts/ethereum/src/` (not `contracts/`): `PetCore.sol`, `GameLogic.sol`, `GameConfig.sol`, `DnaLib.sol`, `TestDeployer.sol`, plus `BattleBatchRegistry.sol` / `SeasonRewardDistributor.sol` for the backend-battle anchor and rewards. +- Both compiler profiles (`default` and `production`) are pinned to `viaIR` explicitly, because Hardhat Ignition silently drops viaIR/optimizer settings from a flat config and the two profiles must match. `CombatSim.sol`'s "stack too deep" was the original reason; with it deleted the remaining sources compile without viaIR, so the setting is now an optimizer choice rather than a requirement. - The `localhost` network hardcodes the 5 standard Hardhat dev private keys; only live networks (Sepolia, Base Sepolia, see `scripts/networks.ts`) read `PRIVATE_KEY` from env. - Deployment is Hardhat Ignition-based (`ignition/modules/CryptoPetsV2Live.ts`); use `pnpm --prefix contracts/ethereum deploy:status` / `deploy:visualize` to inspect. diff --git a/contracts/ethereum/hardhat.config.ts b/contracts/ethereum/hardhat.config.ts index 87d3001d..7c761ca5 100644 --- a/contracts/ethereum/hardhat.config.ts +++ b/contracts/ethereum/hardhat.config.ts @@ -43,8 +43,12 @@ const config: HardhatUserConfig = { // `ignition deploy` always compiles with the "production" profile, which // by default drops viaIR/optimizer settings from a flat `version` + // `settings` config (only the compiler version carries over). Define - // both profiles explicitly so contracts that need viaIR (e.g. - // CombatSim's "stack too deep") compile under `ignition deploy` too. + // both profiles explicitly so the two stay identical. + // + // viaIR was originally required by CombatSim's "stack too deep"; with that + // contract gone the remaining sources compile without it. It stays on as an + // optimizer choice, not a workaround — turning it off is a real bytecode and + // gas change, so it belongs to a deployment decision rather than a cleanup. profiles: { default: { version: "0.8.24", diff --git a/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts b/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts index ac966d40..5b1e7957 100644 --- a/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts +++ b/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts @@ -9,10 +9,8 @@ import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; * (GameConfig, PetCore proxy, GameLogic proxy) and as the UUPS upgrade * authority for both proxies. * - * CombatSim is deliberately **not** deployed (§L Phase 6). Battles are settled by - * the backend, so nothing on chain calls the simulator; the Solidity source stays - * in the repository only as the fourth leg of the golden-vector parity check, which - * deploys it locally per test run. + * There is no combat simulator here (§L Phase 6). Battles are settled by the backend, so + * nothing on chain calls one and the Solidity implementation has been removed. * * The `entropyAddress` parameter (Pyth Entropy V2 contract) must be supplied * via a parameters file, which `scripts/deploy.ts` generates from per-network diff --git a/contracts/ethereum/scripts/gen-battle-vectors.ts b/contracts/ethereum/scripts/gen-battle-vectors.ts deleted file mode 100644 index 663e7e23..00000000 --- a/contracts/ethereum/scripts/gen-battle-vectors.ts +++ /dev/null @@ -1,110 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { network } from "hardhat"; - -// Generates contracts/test-vectors/battle.json by running CombatSim.simulate -// against a curated set of inputs (plan §7 cross-chain golden vectors). The -// output file is the source of truth for Hardhat, Anchor, and indexer-go tests -// — re-run this script (`pnpm hh run scripts/gen-battle-vectors.ts`) whenever -// CombatSim / combat.rs intentionally change. - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -// Matches GameConfig's default skill balance (plan §3.7), mirrored by -// combat::SkillConfig::default() on Solana. -const SKILL_CONFIG = { - tankHpMult: 120, - shellDefMult: 125, - swiftCritBonus: 50, - cunningCritCap: 4000, - furyDmgMult: 130, - furyHpThreshold: 3000, - sageMdefMult: 125, - bloodlustBps: 150, -}; - -// Sentinel: matches none of the 0-7 skill archetype branches (mirrors -// combat::NO_SKILL = 8 on Solana; EVM tests commonly use 99). -const NO_SKILL = 99; - -const SEED_MAX = (1n << 256n) - 1n; - -const dnaA = 1234567890123456n; // pair0=56 -> element 56%6=2 -const dnaB = 9876543210987654n; // pair0=54 -> element 54%6=0 -const dnaC = 1111111111111111n; // pair0=11 -> element 11%6=5 -const dnaE = 1234567890123412n; // pair0=12 -> element 12%6=0 ("next" of dnaC's element 5) - -interface VectorCase { - name: string; - dna1: bigint; - rarity1: number; - level1: number; - skill1: number; - dna2: bigint; - rarity2: number; - level2: number; - skill2: number; - seed: bigint; -} - -const cases: VectorCase[] = [ - { name: "baseline-no-skill", dna1: dnaA, rarity1: 1, level1: 20, skill1: NO_SKILL, dna2: dnaB, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 1n }, - { name: "seed-zero", dna1: dnaA, rarity1: 1, level1: 20, skill1: NO_SKILL, dna2: dnaB, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 0n }, - { name: "seed-max", dna1: dnaA, rarity1: 1, level1: 20, skill1: NO_SKILL, dna2: dnaB, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: SEED_MAX }, - { name: "tank-skill", dna1: dnaA, rarity1: 1, level1: 20, skill1: 0, dna2: dnaB, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 2n }, - { name: "shell-skill", dna1: dnaA, rarity1: 1, level1: 20, skill1: 1, dna2: dnaB, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 3n }, - { name: "swift-skill", dna1: dnaA, rarity1: 1, level1: 20, skill1: 2, dna2: dnaB, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 4n }, - { name: "cunning-skill", dna1: dnaA, rarity1: 1, level1: 20, skill1: 3, dna2: dnaB, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 5n }, - { name: "fury-skill", dna1: dnaA, rarity1: 1, level1: 20, skill1: 4, dna2: dnaB, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 6n }, - { name: "sage-skill", dna1: dnaA, rarity1: 1, level1: 20, skill1: 5, dna2: dnaB, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 7n }, - { name: "rebirth-skill", dna1: dnaA, rarity1: 1, level1: 20, skill1: 6, dna2: dnaB, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 8n }, - { name: "bloodlust-skill", dna1: dnaA, rarity1: 1, level1: 20, skill1: 7, dna2: dnaB, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 9n }, - { name: "level-gap-max", dna1: dnaA, rarity1: 5, level1: 100, skill1: NO_SKILL, dna2: dnaB, rarity2: 1, level2: 1, skill2: NO_SKILL, seed: 10n }, - { name: "element-wheel-next", dna1: dnaC, rarity1: 1, level1: 20, skill1: NO_SKILL, dna2: dnaE, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 11n }, - { name: "mirror-tie", dna1: dnaA, rarity1: 1, level1: 20, skill1: NO_SKILL, dna2: dnaA, rarity2: 1, level2: 20, skill2: NO_SKILL, seed: 12n }, -]; - -async function main() { - const { viem } = await network.connect(); - const combatSim = await viem.deployContract("CombatSim"); - - const vectors = []; - for (const c of cases) { - const result = await combatSim.read.simulate([ - c.dna1, c.rarity1, c.level1, c.skill1, - c.dna2, c.rarity2, c.level2, c.skill2, - c.seed, SKILL_CONFIG, - ]); - vectors.push({ - name: c.name, - dna1: c.dna1.toString(), - rarity1: c.rarity1, - level1: c.level1, - skill1: c.skill1, - dna2: c.dna2.toString(), - rarity2: c.rarity2, - level2: c.level2, - skill2: c.skill2, - seed: c.seed.toString(), - expected: { - firstWins: result.firstWins, - rounds: Number(result.rounds), - winnerHpRemaining: Number(result.winnerHpRemaining), - }, - }); - } - - const out = { - description: "CombatSim.simulate golden vectors (plan §7). NO_SKILL = 99 (any value outside 0-7).", - skillConfig: SKILL_CONFIG, - cases: vectors, - }; - - const outPath = path.resolve(__dirname, "../../test-vectors/battle.json"); - fs.writeFileSync(outPath, JSON.stringify(out, null, 2) + "\n"); - console.log(`Wrote ${vectors.length} vectors to ${outPath}`); -} - -await main(); diff --git a/contracts/ethereum/src/CombatSim.sol b/contracts/ethereum/src/CombatSim.sol deleted file mode 100644 index 23ef40dc..00000000 --- a/contracts/ethereum/src/CombatSim.sol +++ /dev/null @@ -1,209 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import "./DnaLib.sol"; - -/** - * @title CombatSim - * @dev Stateless, pure battle simulator — deploys as a standalone contract so a balance - * patch is "deploy a new CombatSim, setCombatSim()" with no proxy upgrade required and - * the old sim stays on-chain for historical replay. - * - * Round model (plan §3.3): - * initiative: higher INT acts first each round; tie → attacker (pet 1). - * Shell overrides: always strikes second. - * Swift: wins all initiative ties. - * strike type per attack: pMagicBps = 10000 * INT / (ATK + INT) - * physical: max(1, ATK * 100 / (100 + DEF)) - * magic: max(1, INT * 100 / (100 + MDEF)) - * element modifier ±15% applied to either type. - * crit: critBps = min(500 + 25*INT, 3000); multiplier 1.5×. - * round cap 30; tie → higher remaining HP bps; exact tie → defender (pet 2). - * RNG per strike: keccak256(seed ‖ roundIndex ‖ slotOffset) — bit-identical cross-chain. - * - * Skill archetypes (index = speciesId % 8, plan §3.7): - * 0 Tank +tankHpMult% HP (pre-battle) - * 1 Shell +shellDefMult% DEF; always strikes second - * 2 Swift wins initiative ties; +swiftCritBonus bps to crit base - * 3 Cunning crit cap raised to cunningCritCap bps (default 4000 = 40%) - * 4 Fury +furyDmgMult% damage while own HP < furyHpThreshold bps of start - * 5 Sage +sageMdefMult% MDEF; magic strikes ignore element penalty - * 6 Rebirth once per battle, survive a killing blow at 1 HP - * 7 Bloodlust heals bloodlustBps/10000 of physical damage dealt - */ -contract CombatSim { - string public constant VERSION = "1.0.0"; - - struct BattleResult { - bool firstWins; - uint8 rounds; - uint16 winnerHpRemaining; - } - - // Skill balance values — read from GameConfig and passed in by GameLogic (plan §3.7). - struct SkillConfig { - uint16 tankHpMult; // ×/100, e.g. 120 = +20% HP - uint16 shellDefMult; // ×/100, e.g. 125 = +25% DEF - uint16 swiftCritBonus; // bps added to crit base, e.g. 50 = +0.5% - uint16 cunningCritCap; // bps cap, e.g. 4000 = 40% - uint16 furyDmgMult; // ×/100 when triggered, e.g. 130 = +30% - uint16 furyHpThreshold; // bps of startHP to trigger, e.g. 3000 = 30% - uint16 sageMdefMult; // ×/100, e.g. 125 = +25% MDEF - uint16 bloodlustBps; // bps of physical dmg healed, e.g. 150 = 15% - } - - /// @notice Deterministically simulate a battle between two pets (see contract header for the round model). - /// @param seed Randomness source; identical inputs (incl. seed) always yield the same result cross-chain. - /// @param sc Skill balance values sourced from GameConfig. - /// @return result Winner flag, round count, and the winner's remaining HP. - function simulate( - uint256 dna1, uint8 rarity1, uint32 level1, uint8 skill1, - uint256 dna2, uint8 rarity2, uint32 level2, uint8 skill2, - uint256 seed, - SkillConfig calldata sc - ) external pure returns (BattleResult memory result) { - DnaLib.Attrs memory a = DnaLib.extract(dna1, rarity1, level1); - DnaLib.Attrs memory b = DnaLib.extract(dna2, rarity2, level2); - - // Pre-battle skill modifiers (Tank, Shell, Sage) - if (skill1 == 0) a.hp = uint16(uint256(a.hp) * uint256(sc.tankHpMult) / 100); - if (skill2 == 0) b.hp = uint16(uint256(b.hp) * uint256(sc.tankHpMult) / 100); - if (skill1 == 1) a.def = uint16(uint256(a.def) * uint256(sc.shellDefMult) / 100); - if (skill2 == 1) b.def = uint16(uint256(b.def) * uint256(sc.shellDefMult) / 100); - if (skill1 == 5) a.mdef = uint16(uint256(a.mdef) * uint256(sc.sageMdefMult) / 100); - if (skill2 == 5) b.mdef = uint16(uint256(b.mdef) * uint256(sc.sageMdefMult) / 100); - - uint32 hpA = a.hp; - uint32 hpB = b.hp; - uint32 startHpA = a.hp; - uint32 startHpB = b.hp; - - uint256 elemAB = DnaLib.elementMod(a.element, b.element); // A attacks B - uint256 elemBA = DnaLib.elementMod(b.element, a.element); // B attacks A - - bool rebirthUsed1; - bool rebirthUsed2; - - uint8 r; - for (r = 0; r < 30 && hpA > 0 && hpB > 0; r++) { - uint256 rs = uint256(keccak256(abi.encodePacked(seed, r))); - - // Initiative (plan §3.3, §3.7) - bool aFirst; - if (skill1 == 1 && skill2 != 1) { - aFirst = false; // Shell A: A always second - } else if (skill2 == 1 && skill1 != 1) { - aFirst = true; // Shell B: B always second = A goes first - } else if (a.intl != b.intl) { - aFirst = a.intl > b.intl; - } else { - // Tie: Swift wins; both-Swift or no-Swift → attacker (A) wins - aFirst = (skill1 == 2) || (skill2 != 2); - } - - uint32 healA; - uint32 healB; - if (aFirst) { - (hpB, healA) = _strike(a, skill1, hpA, startHpA, b.def, b.mdef, hpB, elemAB, rs, 0, sc); - hpA = _addHeal(hpA, healA, startHpA); - if (hpB == 0 && skill2 == 6 && !rebirthUsed2) { hpB = 1; rebirthUsed2 = true; } - if (hpB > 0) { - (hpA, healB) = _strike(b, skill2, hpB, startHpB, a.def, a.mdef, hpA, elemBA, rs, 2, sc); - hpB = _addHeal(hpB, healB, startHpB); - if (hpA == 0 && skill1 == 6 && !rebirthUsed1) { hpA = 1; rebirthUsed1 = true; } - } - } else { - (hpA, healB) = _strike(b, skill2, hpB, startHpB, a.def, a.mdef, hpA, elemBA, rs, 0, sc); - hpB = _addHeal(hpB, healB, startHpB); - if (hpA == 0 && skill1 == 6 && !rebirthUsed1) { hpA = 1; rebirthUsed1 = true; } - if (hpA > 0) { - (hpB, healA) = _strike(a, skill1, hpA, startHpA, b.def, b.mdef, hpB, elemAB, rs, 2, sc); - hpA = _addHeal(hpA, healA, startHpA); - if (hpB == 0 && skill2 == 6 && !rebirthUsed2) { hpB = 1; rebirthUsed2 = true; } - } - } - } - - bool firstWins; - if (hpA > 0 && hpB == 0) { - firstWins = true; - } else if (hpB > 0 && hpA == 0) { - firstWins = false; - } else { - uint256 bpsA = uint256(hpA) * 10000 / startHpA; - uint256 bpsB = uint256(hpB) * 10000 / startHpB; - firstWins = bpsA > bpsB; // exact tie → false → defender (pet 2) wins - } - - result.firstWins = firstWins; - result.rounds = r; - result.winnerHpRemaining = uint16( - (firstWins ? hpA : hpB) > type(uint16).max - ? type(uint16).max - : (firstWins ? hpA : hpB) - ); - } - - // Execute one strike. Returns (newHpDef, atkHeal) where atkHeal is Bloodlust lifesteal. - function _strike( - DnaLib.Attrs memory atk, - uint8 atkSkill, - uint32 hpAtk, - uint32 startHpAtk, - uint16 defDef, - uint16 defMdef, - uint32 hpDef, - uint256 elemMult, - uint256 roundSeed, - uint8 slotOffset, - SkillConfig memory sc - ) private pure returns (uint32 newHpDef, uint32 atkHeal) { - uint256 total = uint256(atk.atk) + uint256(atk.intl); - uint256 pMagicBps = 10000 * uint256(atk.intl) / total; - uint256 typeRoll = uint256(keccak256(abi.encodePacked(roundSeed, slotOffset))) % 10000; - - bool isMagic = typeRoll < pMagicBps; - uint256 dmg; - if (isMagic) { - dmg = uint256(atk.intl) * 100 / (100 + uint256(defMdef)); - } else { - dmg = uint256(atk.atk) * 100 / (100 + uint256(defDef)); - } - if (dmg == 0) dmg = 1; - - // Element modifier; Sage ignores penalty on magic strikes - uint256 effElem = elemMult; - if (atkSkill == 5 && isMagic && elemMult < 100) effElem = 100; - dmg = dmg * effElem / 100; - - // Fury: +furyDmgMult% while own HP < furyHpThreshold bps of start - if (atkSkill == 4 && startHpAtk > 0) { - if (uint256(hpAtk) * 10000 / uint256(startHpAtk) < uint256(sc.furyHpThreshold)) { - dmg = dmg * uint256(sc.furyDmgMult) / 100; - } - } - - // Crit - uint256 critCap = (atkSkill == 3) ? uint256(sc.cunningCritCap) : 3000; - uint256 critBase = 500 + ((atkSkill == 2) ? uint256(sc.swiftCritBonus) : 0); - uint256 critBps = critBase + 25 * uint256(atk.intl); - if (critBps > critCap) critBps = critCap; - uint256 critRoll = uint256(keccak256(abi.encodePacked(roundSeed, uint8(slotOffset + 1)))) % 10000; - if (critRoll < critBps) dmg = dmg * 150 / 100; - - if (dmg == 0) dmg = 1; - newHpDef = hpDef > uint32(dmg) ? hpDef - uint32(dmg) : 0; - - // Bloodlust: heal attacker for bloodlustBps/10000 of physical damage dealt - if (atkSkill == 7 && !isMagic) { - atkHeal = uint32(dmg * uint256(sc.bloodlustBps) / 10000); - } - } - - // Safe HP add, capped at startHp (prevents overheal). - function _addHeal(uint32 hp, uint32 heal, uint32 startHp) private pure returns (uint32) { - if (heal == 0) return hp; - uint256 result = uint256(hp) + uint256(heal); - return uint32(result > uint256(startHp) ? uint256(startHp) : result); - } -} diff --git a/contracts/ethereum/src/GameConfig.sol b/contracts/ethereum/src/GameConfig.sol index 0c1d658f..b9e70d1f 100644 --- a/contracts/ethereum/src/GameConfig.sol +++ b/contracts/ethereum/src/GameConfig.sol @@ -3,12 +3,11 @@ pragma solidity ^0.8.24; import "@openzeppelin/contracts/access/Ownable.sol"; -import "./CombatSim.sol"; /** * @title GameConfig * @dev Single source of truth for all tunables. Not behind a proxy — upgrading the - * config means deploying a new GameConfig and calling setCombatSim/setGameConfig + * config means deploying a new GameConfig and calling setGameConfig * on the proxy. Owned by the same Safe/timelock that owns the proxies. */ contract GameConfig is Ownable { @@ -39,7 +38,10 @@ contract GameConfig is Ownable { // Species pool sizes per rarity tier (1-5); speciesId = digitPair % poolSizes[rarity] (§3.7). mapping(uint8 => uint8) public poolSizes; - // Skill archetype balance values, passed to CombatSim.simulate() (§3.7). + // Skill archetype balance values (§3.7). Owner-tunable, but nothing reads them: they + // parameterized the on-chain simulator, and the live values now travel in the backend's + // signed ruleset (`protocol/src/ruleset/`) so a receipt names the balance it was fought + // under. Kept as tunables pending a decision on where balance should live. uint16 public tankHpMult = 120; // Tank: ×/100 HP uint16 public shellDefMult = 125; // Shell: ×/100 DEF uint16 public swiftCritBonus = 50; // Swift: + bps to crit base @@ -196,19 +198,4 @@ contract GameConfig is Ownable { bloodlustBps = value; emit BloodlustBpsUpdated(value); } - - // ─── views ──────────────────────────────────────────────────────────────── - - function getSkillConfig() external view returns (CombatSim.SkillConfig memory) { - return CombatSim.SkillConfig({ - tankHpMult: tankHpMult, - shellDefMult: shellDefMult, - swiftCritBonus: swiftCritBonus, - cunningCritCap: cunningCritCap, - furyDmgMult: furyDmgMult, - furyHpThreshold: furyHpThreshold, - sageMdefMult: sageMdefMult, - bloodlustBps: bloodlustBps - }); - } } diff --git a/contracts/ethereum/src/GameLogic.sol b/contracts/ethereum/src/GameLogic.sol index 1ab1c312..78884b12 100644 --- a/contracts/ethereum/src/GameLogic.sol +++ b/contracts/ethereum/src/GameLogic.sol @@ -25,9 +25,9 @@ import "./DnaLib.sol"; * **Battles are no longer settled here** (§L Phase 6). They run through the * backend-authoritative path — signed intent, committed drand round, signed receipt, * Merkle batch anchored by `BattleBatchRegistry` — so this contract no longer runs the - * combat simulator or mutates pet battle state. `CombatSim.sol` remains in the - * repository as the Solidity leg of the cross-language golden-vector check, but is not - * deployed and has no on-chain caller. + * combat simulator or mutates pet battle state. The Solidity simulator is deleted + * outright: it had no on-chain caller left, and the live engines are + * `protocol/src/combat/` and `indexer-go/internal/combat/`. */ contract GameLogic is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable, IEntropyConsumer { diff --git a/contracts/ethereum/src/README.md b/contracts/ethereum/src/README.md index 121fe220..16272f8c 100644 --- a/contracts/ethereum/src/README.md +++ b/contracts/ethereum/src/README.md @@ -10,7 +10,6 @@ Two generations of contracts live in this package. See | `PetCore.sol` | UUPS proxy implementation: ERC-721 + pet storage (DNA, stats, lineage, cooldowns) + marriage records | | `GameLogic.sol` | UUPS proxy implementation: breed/mint/train mechanics, Pyth Entropy request → store → settle | | `GameConfig.sol` | Plain (non-proxy) contract holding every tunable; swap by deploying a new one and re-pointing | -| `CombatSim.sol` | Stateless pure combat simulator. **Frozen and no longer deployed** (§L Phase 6): nothing on chain calls it, and it stays only as the Solidity leg of the golden-vector parity check, which deploys it per test run | | `DnaLib.sol` | Internal library: DNA → attributes/rarity/element derivation (must stay bit-identical with Solana) | | `TestDeployer.sol` | Single-tx local deployer for the proxy stack (tests only) | diff --git a/contracts/ethereum/test/CombatGoldenVectors.test.ts b/contracts/ethereum/test/CombatGoldenVectors.test.ts deleted file mode 100644 index 8c70cb9a..00000000 --- a/contracts/ethereum/test/CombatGoldenVectors.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; -import { describe, it } from "node:test"; -import { fileURLToPath } from "node:url"; - -import { network } from "hardhat"; - -// Cross-chain parity fixture (plan §7): contracts/test-vectors/battle.json is generated by -// scripts/gen-battle-vectors.ts and re-checked here against CombatSim.simulate. The same -// file is the reference for combat::simulate on Solana and for indexer-go. - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -interface VectorCase { - name: string; - dna1: string; - rarity1: number; - level1: number; - skill1: number; - dna2: string; - rarity2: number; - level2: number; - skill2: number; - seed: string; - expected: { - firstWins: boolean; - rounds: number; - winnerHpRemaining: number; - }; -} - -const fixture = JSON.parse( - fs.readFileSync(path.resolve(__dirname, "../../test-vectors/battle.json"), "utf-8") -) as { skillConfig: Record; cases: VectorCase[] }; - -describe("CombatSim golden vectors (plan §7)", async function () { - const { viem } = await network.connect(); - - for (const c of fixture.cases) { - it(`matches recorded result for "${c.name}"`, async function () { - const combatSim = await viem.deployContract("CombatSim"); - - const result = await combatSim.read.simulate([ - BigInt(c.dna1), c.rarity1, c.level1, c.skill1, - BigInt(c.dna2), c.rarity2, c.level2, c.skill2, - BigInt(c.seed), fixture.skillConfig, - ]); - - assert.equal(result.firstWins, c.expected.firstWins, "firstWins"); - assert.equal(result.rounds, c.expected.rounds, "rounds"); - assert.equal(result.winnerHpRemaining, c.expected.winnerHpRemaining, "winnerHpRemaining"); - }); - } -}); diff --git a/contracts/ethereum/test/CryptoPetsV2.test.ts b/contracts/ethereum/test/CryptoPetsV2.test.ts index 89d20c59..fd0422a5 100644 --- a/contracts/ethereum/test/CryptoPetsV2.test.ts +++ b/contracts/ethereum/test/CryptoPetsV2.test.ts @@ -879,67 +879,19 @@ describe("CryptoPetsV2 (UUPS proxies)", async function () { assert.equal(pet.speciesId, 0, "speciesId should be 0 when the rarity tier's pool size is 0"); }); - it("Should expose default skill config values via getSkillConfig()", async function () { + it("Should expose the default skill balance values", async function () { + // getSkillConfig() went with CombatSim (it returned that contract's struct). The + // individual tunables remain, so the defaults are still pinned here. const { config } = await deployV2(); - const sc = await config.read.getSkillConfig(); - - assert.equal(sc.tankHpMult, 120); - assert.equal(sc.shellDefMult, 125); - assert.equal(sc.swiftCritBonus, 50); - assert.equal(sc.cunningCritCap, 4000); - assert.equal(sc.furyDmgMult, 130); - assert.equal(sc.furyHpThreshold, 3000); - assert.equal(sc.sageMdefMult, 125); - assert.equal(sc.bloodlustBps, 150); - }); - - it("Should apply the Tank skill's pre-battle HP bonus in CombatSim.simulate", async function () { - const { config } = await deployV2(); - const combatSim = await viem.deployContract("CombatSim"); - const sc = await config.read.getSkillConfig(); - - const dna1 = 1234567890123456n; // level-50 attacker, far stronger than dna2 - const dna2 = 9876543210987654n; // level-1 defender - const seed = 42n; - const NO_SKILL = 99; // sentinel: matches none of the 0-7 archetype branches - - const withTank = await combatSim.read.simulate([ - dna1, 1, 50, 0, // pet1: rarity 1, level 50, Tank - dna2, 1, 1, NO_SKILL, // pet2: rarity 1, level 1, no skill - seed, sc, - ]); - const withoutTank = await combatSim.read.simulate([ - dna1, 1, 50, NO_SKILL, - dna2, 1, 1, NO_SKILL, - seed, sc, - ]); - assert.equal(withTank.firstWins, true, "pet1 should win regardless of Tank"); - assert.equal(withoutTank.firstWins, true, "pet1 should win regardless of Tank"); - assert( - withTank.winnerHpRemaining > withoutTank.winnerHpRemaining, - "Tank's +20% starting HP should leave more HP remaining after an identical fight" - ); - }); - - it("Should run CombatSim.simulate without reverting for every skill archetype (0-7)", async function () { - const { config } = await deployV2(); - const combatSim = await viem.deployContract("CombatSim"); - const sc = await config.read.getSkillConfig(); - - const dna1 = 1234567890123456n; - const dna2 = 9876543210987654n; - - for (let skill = 0; skill < 8; skill++) { - const result = await combatSim.read.simulate([ - dna1, 3, 20, skill, - dna2, 3, 20, skill, - BigInt(skill) + 1n, - sc, - ]); - assert(result.rounds >= 1 && result.rounds <= 30, `skill ${skill}: rounds in range`); - assert(result.winnerHpRemaining <= 65535, `skill ${skill}: winnerHpRemaining within uint16`); - } + assert.equal(await config.read.tankHpMult(), 120); + assert.equal(await config.read.shellDefMult(), 125); + assert.equal(await config.read.swiftCritBonus(), 50); + assert.equal(await config.read.cunningCritCap(), 4000); + assert.equal(await config.read.furyDmgMult(), 130); + assert.equal(await config.read.furyHpThreshold(), 3000); + assert.equal(await config.read.sageMdefMult(), 125); + assert.equal(await config.read.bloodlustBps(), 150); }); const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; diff --git a/frontend/src/chains/ethereum/gameConfigAbi.json b/frontend/src/chains/ethereum/gameConfigAbi.json index 740db4ee..4bdbfc0f 100644 --- a/frontend/src/chains/ethereum/gameConfigAbi.json +++ b/frontend/src/chains/ethereum/gameConfigAbi.json @@ -461,61 +461,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "getSkillConfig", - "outputs": [ - { - "components": [ - { - "internalType": "uint16", - "name": "tankHpMult", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "shellDefMult", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "swiftCritBonus", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "cunningCritCap", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "furyDmgMult", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "furyHpThreshold", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "sageMdefMult", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "bloodlustBps", - "type": "uint16" - } - ], - "internalType": "struct CombatSim.SkillConfig", - "name": "", - "type": "tuple" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "levelBandWidth", From 9f9c196e9190958aea947d9ef2ba78a76993de08 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 28 Jul 2026 08:51:55 -0400 Subject: [PATCH 58/76] feat(contracts): deploy BattleBatchRegistry and SeasonRewardDistributor --- .../ignition/modules/CryptoPetsV2Live.ts | 22 +- contracts/ethereum/scripts/deploy.ts | 11 + indexer-go/pb/cryptopets.pb.go | 469 ++---------------- indexer-go/pb/cryptopets_grpc.pb.go | 116 +---- proto/cryptopets.proto | 55 +- 5 files changed, 103 insertions(+), 570 deletions(-) diff --git a/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts b/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts index 5b1e7957..8ea16d21 100644 --- a/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts +++ b/contracts/ethereum/ignition/modules/CryptoPetsV2Live.ts @@ -54,10 +54,30 @@ const CryptoPetsV2LiveModule = buildModule("CryptoPetsV2Live", (m) => { id: "GameLogic", }); + // ── backend-battle contracts (§I) ──────────────────────────────────────── + // Neither is a proxy, and neither is upgradeable, on purpose: the registry records + // history, so being able to rewrite the thing that records it would defeat the point. + // + // The registry is where the backend anchors Merkle roots of signed receipts, and the + // distributor is the claim path for a season's rewards. Both are deployed here so a + // fresh network comes up complete; anchoring still no-ops until the backend's + // BATTLE_ANCHOR_* vars point at the registry address this writes out. + const batchRegistry = m.contract("BattleBatchRegistry", [deployer], { + id: "BattleBatchRegistry", + }); + const rewardDistributor = m.contract("SeasonRewardDistributor", [deployer], { + id: "SeasonRewardDistributor", + }); + // ── wire up ────────────────────────────────────────────────────────────── m.call(petCore, "authorizeCaller", [gameLogicProxy]); + // Publishing rights for the deployer, so a local stack can anchor immediately. This + // grants nothing the owner did not already have (it can call setPublisher at will). + // A real deployment should rotate this to the backend's own anchor wallet — the key + // in BATTLE_ANCHOR_PRIVATE_KEY — and revoke the deployer. + m.call(batchRegistry, "setPublisher", [deployer, true]); - return { config, petCore, gameLogic }; + return { config, petCore, gameLogic, batchRegistry, rewardDistributor }; }); export default CryptoPetsV2LiveModule; diff --git a/contracts/ethereum/scripts/deploy.ts b/contracts/ethereum/scripts/deploy.ts index ebc10cb1..2edc54e7 100644 --- a/contracts/ethereum/scripts/deploy.ts +++ b/contracts/ethereum/scripts/deploy.ts @@ -117,6 +117,8 @@ async function injectContractAddresses(network: NetworkSpec): Promise { const petCoreAddress = deployedAddresses['CryptoPetsV2Live#PetCoreProxy'] as string | undefined; const gameLogicAddress = deployedAddresses['CryptoPetsV2Live#GameLogicProxy'] as string | undefined; const gameConfigAddress = deployedAddresses['CryptoPetsV2Live#GameConfig'] as string | undefined; + const batchRegistryAddress = deployedAddresses['CryptoPetsV2Live#BattleBatchRegistry'] as string | undefined; + const rewardDistributorAddress = deployedAddresses['CryptoPetsV2Live#SeasonRewardDistributor'] as string | undefined; if (!petCoreAddress) { console.error('❌ PetCore proxy not found in deployed_addresses.json'); @@ -126,6 +128,15 @@ async function injectContractAddresses(network: NetworkSpec): Promise { console.log(`📝 PetCore: ${petCoreAddress}`); console.log(`📝 GameLogic: ${gameLogicAddress ?? '(not found)'}`); console.log(`📝 GameConfig: ${gameConfigAddress ?? '(not found)'}`); + console.log(`📝 BattleBatchRegistry: ${batchRegistryAddress ?? '(not found)'}`); + console.log(`📝 SeasonRewardDistributor: ${rewardDistributorAddress ?? '(not found)'}`); + + // These two are read by the backend, not the frontend, so they are printed for the + // operator to copy rather than written into frontend/.env.local. + if (batchRegistryAddress) { + console.log(` + backend/.env: BATTLE_ANCHOR_REGISTRY_ADDRESS=${batchRegistryAddress}`); + } const frontendEnvLocalPath = join(process.cwd(), '..', '..', 'frontend', '.env.local'); diff --git a/indexer-go/pb/cryptopets.pb.go b/indexer-go/pb/cryptopets.pb.go index 93f7c7c2..e9992699 100644 --- a/indexer-go/pb/cryptopets.pb.go +++ b/indexer-go/pb/cryptopets.pb.go @@ -283,134 +283,6 @@ func (x *PetResponse) GetAsset() string { return "" } -type OpponentsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Chain string `protobuf:"bytes,1,opt,name=chain,proto3" json:"chain,omitempty"` - ExcludeOwner string `protobuf:"bytes,2,opt,name=exclude_owner,json=excludeOwner,proto3" json:"exclude_owner,omitempty"` // normalized address / pubkey - MinLevel uint32 `protobuf:"varint,3,opt,name=min_level,json=minLevel,proto3" json:"min_level,omitempty"` - Page uint32 `protobuf:"varint,4,opt,name=page,proto3" json:"page,omitempty"` - PageSize uint32 `protobuf:"varint,5,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *OpponentsRequest) Reset() { - *x = OpponentsRequest{} - mi := &file_cryptopets_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *OpponentsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OpponentsRequest) ProtoMessage() {} - -func (x *OpponentsRequest) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use OpponentsRequest.ProtoReflect.Descriptor instead. -func (*OpponentsRequest) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{2} -} - -func (x *OpponentsRequest) GetChain() string { - if x != nil { - return x.Chain - } - return "" -} - -func (x *OpponentsRequest) GetExcludeOwner() string { - if x != nil { - return x.ExcludeOwner - } - return "" -} - -func (x *OpponentsRequest) GetMinLevel() uint32 { - if x != nil { - return x.MinLevel - } - return 0 -} - -func (x *OpponentsRequest) GetPage() uint32 { - if x != nil { - return x.Page - } - return 0 -} - -func (x *OpponentsRequest) GetPageSize() uint32 { - if x != nil { - return x.PageSize - } - return 0 -} - -type OpponentsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Pets []*PetResponse `protobuf:"bytes,1,rep,name=pets,proto3" json:"pets,omitempty"` - Total uint32 `protobuf:"varint,2,opt,name=total,proto3" json:"total,omitempty"` // total matches before paging (pagination parity) - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *OpponentsResponse) Reset() { - *x = OpponentsResponse{} - mi := &file_cryptopets_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *OpponentsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OpponentsResponse) ProtoMessage() {} - -func (x *OpponentsResponse) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use OpponentsResponse.ProtoReflect.Descriptor instead. -func (*OpponentsResponse) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{3} -} - -func (x *OpponentsResponse) GetPets() []*PetResponse { - if x != nil { - return x.Pets - } - return nil -} - -func (x *OpponentsResponse) GetTotal() uint32 { - if x != nil { - return x.Total - } - return 0 -} - type WinRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Chain string `protobuf:"bytes,1,opt,name=chain,proto3" json:"chain,omitempty"` @@ -423,7 +295,7 @@ type WinRequest struct { func (x *WinRequest) Reset() { *x = WinRequest{} - mi := &file_cryptopets_proto_msgTypes[4] + mi := &file_cryptopets_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -435,7 +307,7 @@ func (x *WinRequest) String() string { func (*WinRequest) ProtoMessage() {} func (x *WinRequest) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[4] + mi := &file_cryptopets_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -448,7 +320,7 @@ func (x *WinRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WinRequest.ProtoReflect.Descriptor instead. func (*WinRequest) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{4} + return file_cryptopets_proto_rawDescGZIP(), []int{2} } func (x *WinRequest) GetChain() string { @@ -489,7 +361,7 @@ type WinResponse struct { func (x *WinResponse) Reset() { *x = WinResponse{} - mi := &file_cryptopets_proto_msgTypes[5] + mi := &file_cryptopets_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -501,7 +373,7 @@ func (x *WinResponse) String() string { func (*WinResponse) ProtoMessage() {} func (x *WinResponse) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[5] + mi := &file_cryptopets_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -514,7 +386,7 @@ func (x *WinResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WinResponse.ProtoReflect.Descriptor instead. func (*WinResponse) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{5} + return file_cryptopets_proto_rawDescGZIP(), []int{3} } func (x *WinResponse) GetWinProbability() float64 { @@ -551,7 +423,7 @@ type VerifyPetInputs struct { func (x *VerifyPetInputs) Reset() { *x = VerifyPetInputs{} - mi := &file_cryptopets_proto_msgTypes[6] + mi := &file_cryptopets_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -563,7 +435,7 @@ func (x *VerifyPetInputs) String() string { func (*VerifyPetInputs) ProtoMessage() {} func (x *VerifyPetInputs) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[6] + mi := &file_cryptopets_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -576,7 +448,7 @@ func (x *VerifyPetInputs) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyPetInputs.ProtoReflect.Descriptor instead. func (*VerifyPetInputs) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{6} + return file_cryptopets_proto_rawDescGZIP(), []int{4} } func (x *VerifyPetInputs) GetPetId() string { @@ -652,7 +524,7 @@ type VerifySkillConfig struct { func (x *VerifySkillConfig) Reset() { *x = VerifySkillConfig{} - mi := &file_cryptopets_proto_msgTypes[7] + mi := &file_cryptopets_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -664,7 +536,7 @@ func (x *VerifySkillConfig) String() string { func (*VerifySkillConfig) ProtoMessage() {} func (x *VerifySkillConfig) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[7] + mi := &file_cryptopets_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -677,7 +549,7 @@ func (x *VerifySkillConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifySkillConfig.ProtoReflect.Descriptor instead. func (*VerifySkillConfig) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{7} + return file_cryptopets_proto_rawDescGZIP(), []int{5} } func (x *VerifySkillConfig) GetTankHpMult() uint32 { @@ -749,7 +621,7 @@ type VerifyBattleRequest struct { func (x *VerifyBattleRequest) Reset() { *x = VerifyBattleRequest{} - mi := &file_cryptopets_proto_msgTypes[8] + mi := &file_cryptopets_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -761,7 +633,7 @@ func (x *VerifyBattleRequest) String() string { func (*VerifyBattleRequest) ProtoMessage() {} func (x *VerifyBattleRequest) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[8] + mi := &file_cryptopets_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -774,7 +646,7 @@ func (x *VerifyBattleRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyBattleRequest.ProtoReflect.Descriptor instead. func (*VerifyBattleRequest) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{8} + return file_cryptopets_proto_rawDescGZIP(), []int{6} } func (x *VerifyBattleRequest) GetAttacker() *VerifyPetInputs { @@ -836,7 +708,7 @@ type VerifyStrikeLogEntry struct { func (x *VerifyStrikeLogEntry) Reset() { *x = VerifyStrikeLogEntry{} - mi := &file_cryptopets_proto_msgTypes[9] + mi := &file_cryptopets_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -848,7 +720,7 @@ func (x *VerifyStrikeLogEntry) String() string { func (*VerifyStrikeLogEntry) ProtoMessage() {} func (x *VerifyStrikeLogEntry) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[9] + mi := &file_cryptopets_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -861,7 +733,7 @@ func (x *VerifyStrikeLogEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyStrikeLogEntry.ProtoReflect.Descriptor instead. func (*VerifyStrikeLogEntry) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{9} + return file_cryptopets_proto_rawDescGZIP(), []int{7} } func (x *VerifyStrikeLogEntry) GetRound() uint32 { @@ -959,7 +831,7 @@ type VerifyPetProgression struct { func (x *VerifyPetProgression) Reset() { *x = VerifyPetProgression{} - mi := &file_cryptopets_proto_msgTypes[10] + mi := &file_cryptopets_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -971,7 +843,7 @@ func (x *VerifyPetProgression) String() string { func (*VerifyPetProgression) ProtoMessage() {} func (x *VerifyPetProgression) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[10] + mi := &file_cryptopets_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -984,7 +856,7 @@ func (x *VerifyPetProgression) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyPetProgression.ProtoReflect.Descriptor instead. func (*VerifyPetProgression) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{10} + return file_cryptopets_proto_rawDescGZIP(), []int{8} } func (x *VerifyPetProgression) GetPetId() string { @@ -1066,7 +938,7 @@ type VerifyBattleResponse struct { func (x *VerifyBattleResponse) Reset() { *x = VerifyBattleResponse{} - mi := &file_cryptopets_proto_msgTypes[11] + mi := &file_cryptopets_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1078,7 +950,7 @@ func (x *VerifyBattleResponse) String() string { func (*VerifyBattleResponse) ProtoMessage() {} func (x *VerifyBattleResponse) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[11] + mi := &file_cryptopets_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1091,7 +963,7 @@ func (x *VerifyBattleResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyBattleResponse.ProtoReflect.Descriptor instead. func (*VerifyBattleResponse) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{11} + return file_cryptopets_proto_rawDescGZIP(), []int{9} } func (x *VerifyBattleResponse) GetFirstWins() bool { @@ -1150,194 +1022,6 @@ func (x *VerifyBattleResponse) GetDefender() *VerifyPetProgression { return nil } -type StreamRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Resume point per chain so a reconnecting client misses nothing: - // chain -> last seen version (Solana slot / EVM block timestamp). - // Missing chains replay nothing and stream live-only. - AfterVersion map[string]uint64 `protobuf:"bytes,1,rep,name=after_version,json=afterVersion,proto3" json:"after_version,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StreamRequest) Reset() { - *x = StreamRequest{} - mi := &file_cryptopets_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StreamRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StreamRequest) ProtoMessage() {} - -func (x *StreamRequest) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StreamRequest.ProtoReflect.Descriptor instead. -func (*StreamRequest) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{12} -} - -func (x *StreamRequest) GetAfterVersion() map[string]uint64 { - if x != nil { - return x.AfterVersion - } - return nil -} - -type BattleEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - Chain string `protobuf:"bytes,1,opt,name=chain,proto3" json:"chain,omitempty"` // "evm" | "solana" - BattleId string `protobuf:"bytes,2,opt,name=battle_id,json=battleId,proto3" json:"battle_id,omitempty"` // settle sig (solana) / txHash-logIndex (evm) - AttackerPet string `protobuf:"bytes,3,opt,name=attacker_pet,json=attackerPet,proto3" json:"attacker_pet,omitempty"` - DefenderPet string `protobuf:"bytes,4,opt,name=defender_pet,json=defenderPet,proto3" json:"defender_pet,omitempty"` - WinnerPet string `protobuf:"bytes,5,opt,name=winner_pet,json=winnerPet,proto3" json:"winner_pet,omitempty"` // absolute pet id — matches battle_history.winner_pet_id - Version uint64 `protobuf:"varint,6,opt,name=version,proto3" json:"version,omitempty"` // feed back as after_version on reconnect - FoughtAt int64 `protobuf:"varint,7,opt,name=fought_at,json=foughtAt,proto3" json:"fought_at,omitempty"` // unix seconds - // v2 round-based combat sim outputs (plan §3.3). Append-only field numbers. - LoserPet string `protobuf:"bytes,8,opt,name=loser_pet,json=loserPet,proto3" json:"loser_pet,omitempty"` // absolute pet id of the loser - Seed string `protobuf:"bytes,9,opt,name=seed,proto3" json:"seed,omitempty"` // 0x-hex 32-byte combat seed; replays the sim off-chain - Rounds uint32 `protobuf:"varint,10,opt,name=rounds,proto3" json:"rounds,omitempty"` - WinnerHpRemaining uint32 `protobuf:"varint,11,opt,name=winner_hp_remaining,json=winnerHpRemaining,proto3" json:"winner_hp_remaining,omitempty"` - XpWin uint32 `protobuf:"varint,12,opt,name=xp_win,json=xpWin,proto3" json:"xp_win,omitempty"` - XpLoss uint32 `protobuf:"varint,13,opt,name=xp_loss,json=xpLoss,proto3" json:"xp_loss,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *BattleEvent) Reset() { - *x = BattleEvent{} - mi := &file_cryptopets_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *BattleEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BattleEvent) ProtoMessage() {} - -func (x *BattleEvent) ProtoReflect() protoreflect.Message { - mi := &file_cryptopets_proto_msgTypes[13] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BattleEvent.ProtoReflect.Descriptor instead. -func (*BattleEvent) Descriptor() ([]byte, []int) { - return file_cryptopets_proto_rawDescGZIP(), []int{13} -} - -func (x *BattleEvent) GetChain() string { - if x != nil { - return x.Chain - } - return "" -} - -func (x *BattleEvent) GetBattleId() string { - if x != nil { - return x.BattleId - } - return "" -} - -func (x *BattleEvent) GetAttackerPet() string { - if x != nil { - return x.AttackerPet - } - return "" -} - -func (x *BattleEvent) GetDefenderPet() string { - if x != nil { - return x.DefenderPet - } - return "" -} - -func (x *BattleEvent) GetWinnerPet() string { - if x != nil { - return x.WinnerPet - } - return "" -} - -func (x *BattleEvent) GetVersion() uint64 { - if x != nil { - return x.Version - } - return 0 -} - -func (x *BattleEvent) GetFoughtAt() int64 { - if x != nil { - return x.FoughtAt - } - return 0 -} - -func (x *BattleEvent) GetLoserPet() string { - if x != nil { - return x.LoserPet - } - return "" -} - -func (x *BattleEvent) GetSeed() string { - if x != nil { - return x.Seed - } - return "" -} - -func (x *BattleEvent) GetRounds() uint32 { - if x != nil { - return x.Rounds - } - return 0 -} - -func (x *BattleEvent) GetWinnerHpRemaining() uint32 { - if x != nil { - return x.WinnerHpRemaining - } - return 0 -} - -func (x *BattleEvent) GetXpWin() uint32 { - if x != nil { - return x.XpWin - } - return 0 -} - -func (x *BattleEvent) GetXpLoss() uint32 { - if x != nil { - return x.XpLoss - } - return 0 -} - var File_cryptopets_proto protoreflect.FileDescriptor const file_cryptopets_proto_rawDesc = "" + @@ -1377,16 +1061,7 @@ const file_cryptopets_proto_rawDesc = "" + "\tspouse_id\x18\x12 \x01(\tR\bspouseId\x12$\n" + "\x0ebreed_ready_at\x18\x13 \x01(\x03R\fbreedReadyAt\x12$\n" + "\x0etrain_ready_at\x18\x14 \x01(\x03R\ftrainReadyAt\x12\x14\n" + - "\x05asset\x18\x15 \x01(\tR\x05asset\"\x9b\x01\n" + - "\x10OpponentsRequest\x12\x14\n" + - "\x05chain\x18\x01 \x01(\tR\x05chain\x12#\n" + - "\rexclude_owner\x18\x02 \x01(\tR\fexcludeOwner\x12\x1b\n" + - "\tmin_level\x18\x03 \x01(\rR\bminLevel\x12\x12\n" + - "\x04page\x18\x04 \x01(\rR\x04page\x12\x1b\n" + - "\tpage_size\x18\x05 \x01(\rR\bpageSize\"V\n" + - "\x11OpponentsResponse\x12+\n" + - "\x04pets\x18\x01 \x03(\v2\x17.cryptopets.PetResponseR\x04pets\x12\x14\n" + - "\x05total\x18\x02 \x01(\rR\x05total\"n\n" + + "\x05asset\x18\x15 \x01(\tR\x05asset\"n\n" + "\n" + "WinRequest\x12\x14\n" + "\x05chain\x18\x01 \x01(\tR\x05chain\x12\x17\n" + @@ -1456,32 +1131,9 @@ const file_cryptopets_proto_rawDesc = "" + "\tstart_hp2\x18\x05 \x01(\rR\bstartHp2\x122\n" + "\x03log\x18\x06 \x03(\v2 .cryptopets.VerifyStrikeLogEntryR\x03log\x12<\n" + "\battacker\x18\a \x01(\v2 .cryptopets.VerifyPetProgressionR\battacker\x12<\n" + - "\bdefender\x18\b \x01(\v2 .cryptopets.VerifyPetProgressionR\bdefender\"\xa2\x01\n" + - "\rStreamRequest\x12P\n" + - "\rafter_version\x18\x01 \x03(\v2+.cryptopets.StreamRequest.AfterVersionEntryR\fafterVersion\x1a?\n" + - "\x11AfterVersionEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\x04R\x05value:\x028\x01\"\x85\x03\n" + - "\vBattleEvent\x12\x14\n" + - "\x05chain\x18\x01 \x01(\tR\x05chain\x12\x1b\n" + - "\tbattle_id\x18\x02 \x01(\tR\bbattleId\x12!\n" + - "\fattacker_pet\x18\x03 \x01(\tR\vattackerPet\x12!\n" + - "\fdefender_pet\x18\x04 \x01(\tR\vdefenderPet\x12\x1d\n" + - "\n" + - "winner_pet\x18\x05 \x01(\tR\twinnerPet\x12\x18\n" + - "\aversion\x18\x06 \x01(\x04R\aversion\x12\x1b\n" + - "\tfought_at\x18\a \x01(\x03R\bfoughtAt\x12\x1b\n" + - "\tloser_pet\x18\b \x01(\tR\bloserPet\x12\x12\n" + - "\x04seed\x18\t \x01(\tR\x04seed\x12\x16\n" + - "\x06rounds\x18\n" + - " \x01(\rR\x06rounds\x12.\n" + - "\x13winner_hp_remaining\x18\v \x01(\rR\x11winnerHpRemaining\x12\x15\n" + - "\x06xp_win\x18\f \x01(\rR\x05xpWin\x12\x17\n" + - "\axp_loss\x18\r \x01(\rR\x06xpLoss2\x82\x03\n" + - "\x0fGameDataService\x12I\n" + - "\x11StreamLiveBattles\x12\x19.cryptopets.StreamRequest\x1a\x17.cryptopets.BattleEvent0\x01\x12>\n" + - "\vGetPetState\x12\x16.cryptopets.PetRequest\x1a\x17.cryptopets.PetResponse\x12Q\n" + - "\x12ListReadyOpponents\x12\x1c.cryptopets.OpponentsRequest\x1a\x1d.cryptopets.OpponentsResponse\x12>\n" + + "\bdefender\x18\b \x01(\v2 .cryptopets.VerifyPetProgressionR\bdefender2\xe4\x01\n" + + "\x0fGameDataService\x12>\n" + + "\vGetPetState\x12\x16.cryptopets.PetRequest\x1a\x17.cryptopets.PetResponse\x12>\n" + "\vEstimateWin\x12\x16.cryptopets.WinRequest\x1a\x17.cryptopets.WinResponse\x12Q\n" + "\fVerifyBattle\x12\x1f.cryptopets.VerifyBattleRequest\x1a .cryptopets.VerifyBattleResponseB.Z,github.com/radcrew/do-not-stop/indexer-go/pbb\x06proto3" @@ -1497,48 +1149,37 @@ func file_cryptopets_proto_rawDescGZIP() []byte { return file_cryptopets_proto_rawDescData } -var file_cryptopets_proto_msgTypes = make([]protoimpl.MessageInfo, 15) +var file_cryptopets_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_cryptopets_proto_goTypes = []any{ (*PetRequest)(nil), // 0: cryptopets.PetRequest (*PetResponse)(nil), // 1: cryptopets.PetResponse - (*OpponentsRequest)(nil), // 2: cryptopets.OpponentsRequest - (*OpponentsResponse)(nil), // 3: cryptopets.OpponentsResponse - (*WinRequest)(nil), // 4: cryptopets.WinRequest - (*WinResponse)(nil), // 5: cryptopets.WinResponse - (*VerifyPetInputs)(nil), // 6: cryptopets.VerifyPetInputs - (*VerifySkillConfig)(nil), // 7: cryptopets.VerifySkillConfig - (*VerifyBattleRequest)(nil), // 8: cryptopets.VerifyBattleRequest - (*VerifyStrikeLogEntry)(nil), // 9: cryptopets.VerifyStrikeLogEntry - (*VerifyPetProgression)(nil), // 10: cryptopets.VerifyPetProgression - (*VerifyBattleResponse)(nil), // 11: cryptopets.VerifyBattleResponse - (*StreamRequest)(nil), // 12: cryptopets.StreamRequest - (*BattleEvent)(nil), // 13: cryptopets.BattleEvent - nil, // 14: cryptopets.StreamRequest.AfterVersionEntry + (*WinRequest)(nil), // 2: cryptopets.WinRequest + (*WinResponse)(nil), // 3: cryptopets.WinResponse + (*VerifyPetInputs)(nil), // 4: cryptopets.VerifyPetInputs + (*VerifySkillConfig)(nil), // 5: cryptopets.VerifySkillConfig + (*VerifyBattleRequest)(nil), // 6: cryptopets.VerifyBattleRequest + (*VerifyStrikeLogEntry)(nil), // 7: cryptopets.VerifyStrikeLogEntry + (*VerifyPetProgression)(nil), // 8: cryptopets.VerifyPetProgression + (*VerifyBattleResponse)(nil), // 9: cryptopets.VerifyBattleResponse } var file_cryptopets_proto_depIdxs = []int32{ - 1, // 0: cryptopets.OpponentsResponse.pets:type_name -> cryptopets.PetResponse - 6, // 1: cryptopets.VerifyBattleRequest.attacker:type_name -> cryptopets.VerifyPetInputs - 6, // 2: cryptopets.VerifyBattleRequest.defender:type_name -> cryptopets.VerifyPetInputs - 7, // 3: cryptopets.VerifyBattleRequest.skill_config:type_name -> cryptopets.VerifySkillConfig - 9, // 4: cryptopets.VerifyBattleResponse.log:type_name -> cryptopets.VerifyStrikeLogEntry - 10, // 5: cryptopets.VerifyBattleResponse.attacker:type_name -> cryptopets.VerifyPetProgression - 10, // 6: cryptopets.VerifyBattleResponse.defender:type_name -> cryptopets.VerifyPetProgression - 14, // 7: cryptopets.StreamRequest.after_version:type_name -> cryptopets.StreamRequest.AfterVersionEntry - 12, // 8: cryptopets.GameDataService.StreamLiveBattles:input_type -> cryptopets.StreamRequest - 0, // 9: cryptopets.GameDataService.GetPetState:input_type -> cryptopets.PetRequest - 2, // 10: cryptopets.GameDataService.ListReadyOpponents:input_type -> cryptopets.OpponentsRequest - 4, // 11: cryptopets.GameDataService.EstimateWin:input_type -> cryptopets.WinRequest - 8, // 12: cryptopets.GameDataService.VerifyBattle:input_type -> cryptopets.VerifyBattleRequest - 13, // 13: cryptopets.GameDataService.StreamLiveBattles:output_type -> cryptopets.BattleEvent - 1, // 14: cryptopets.GameDataService.GetPetState:output_type -> cryptopets.PetResponse - 3, // 15: cryptopets.GameDataService.ListReadyOpponents:output_type -> cryptopets.OpponentsResponse - 5, // 16: cryptopets.GameDataService.EstimateWin:output_type -> cryptopets.WinResponse - 11, // 17: cryptopets.GameDataService.VerifyBattle:output_type -> cryptopets.VerifyBattleResponse - 13, // [13:18] is the sub-list for method output_type - 8, // [8:13] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 4, // 0: cryptopets.VerifyBattleRequest.attacker:type_name -> cryptopets.VerifyPetInputs + 4, // 1: cryptopets.VerifyBattleRequest.defender:type_name -> cryptopets.VerifyPetInputs + 5, // 2: cryptopets.VerifyBattleRequest.skill_config:type_name -> cryptopets.VerifySkillConfig + 7, // 3: cryptopets.VerifyBattleResponse.log:type_name -> cryptopets.VerifyStrikeLogEntry + 8, // 4: cryptopets.VerifyBattleResponse.attacker:type_name -> cryptopets.VerifyPetProgression + 8, // 5: cryptopets.VerifyBattleResponse.defender:type_name -> cryptopets.VerifyPetProgression + 0, // 6: cryptopets.GameDataService.GetPetState:input_type -> cryptopets.PetRequest + 2, // 7: cryptopets.GameDataService.EstimateWin:input_type -> cryptopets.WinRequest + 6, // 8: cryptopets.GameDataService.VerifyBattle:input_type -> cryptopets.VerifyBattleRequest + 1, // 9: cryptopets.GameDataService.GetPetState:output_type -> cryptopets.PetResponse + 3, // 10: cryptopets.GameDataService.EstimateWin:output_type -> cryptopets.WinResponse + 9, // 11: cryptopets.GameDataService.VerifyBattle:output_type -> cryptopets.VerifyBattleResponse + 9, // [9:12] is the sub-list for method output_type + 6, // [6:9] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_cryptopets_proto_init() } @@ -1552,7 +1193,7 @@ func file_cryptopets_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_cryptopets_proto_rawDesc), len(file_cryptopets_proto_rawDesc)), NumEnums: 0, - NumMessages: 15, + NumMessages: 10, NumExtensions: 0, NumServices: 1, }, diff --git a/indexer-go/pb/cryptopets_grpc.pb.go b/indexer-go/pb/cryptopets_grpc.pb.go index 2fda2e72..d403d996 100644 --- a/indexer-go/pb/cryptopets_grpc.pb.go +++ b/indexer-go/pb/cryptopets_grpc.pb.go @@ -23,30 +23,20 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - GameDataService_StreamLiveBattles_FullMethodName = "/cryptopets.GameDataService/StreamLiveBattles" - GameDataService_GetPetState_FullMethodName = "/cryptopets.GameDataService/GetPetState" - GameDataService_ListReadyOpponents_FullMethodName = "/cryptopets.GameDataService/ListReadyOpponents" - GameDataService_EstimateWin_FullMethodName = "/cryptopets.GameDataService/EstimateWin" - GameDataService_VerifyBattle_FullMethodName = "/cryptopets.GameDataService/VerifyBattle" + GameDataService_GetPetState_FullMethodName = "/cryptopets.GameDataService/GetPetState" + GameDataService_EstimateWin_FullMethodName = "/cryptopets.GameDataService/EstimateWin" + GameDataService_VerifyBattle_FullMethodName = "/cryptopets.GameDataService/VerifyBattle" ) // GameDataServiceClient is the client API for GameDataService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type GameDataServiceClient interface { - // Server streaming: indexer-go pushes settled battles (both chains) the - // moment they index. Delivery is at-least-once — consumers must be - // idempotent by (chain, battle_id). - StreamLiveBattles(ctx context.Context, in *StreamRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[BattleEvent], error) - // RAM reads from the write-through roster cache (coherent because - // indexer-go is the sole writer of pet_roster after promotion). Both - // return UNAVAILABLE until warm-up completes — callers fall back to - // their own database read. + // RAM read from the write-through roster cache (coherent because + // indexer-go is the sole writer of pet_roster after promotion). Returns + // UNAVAILABLE until warm-up completes — callers fall back to their own + // database read. GetPetState(ctx context.Context, in *PetRequest, opts ...grpc.CallOption) (*PetResponse, error) - // Mirrors the backend's findReadyOpponents matchmaking query: - // battle-ready (ready_at <= now), not owned by the caller, optional - // minimum level, ordered by (level, pet_id), paged. - ListReadyOpponents(ctx context.Context, in *OpponentsRequest, opts ...grpc.CallOption) (*OpponentsResponse, error) // Pre-fight win estimate (plan §3.3): runs the round-based combat sim over // many seeds for pet_id1 vs pet_id2 (both read from the warm roster cache) // and returns pet_id1's win probability. UNAVAILABLE until the cache is warm. @@ -70,25 +60,6 @@ func NewGameDataServiceClient(cc grpc.ClientConnInterface) GameDataServiceClient return &gameDataServiceClient{cc} } -func (c *gameDataServiceClient) StreamLiveBattles(ctx context.Context, in *StreamRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[BattleEvent], error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &GameDataService_ServiceDesc.Streams[0], GameDataService_StreamLiveBattles_FullMethodName, cOpts...) - if err != nil { - return nil, err - } - x := &grpc.GenericClientStream[StreamRequest, BattleEvent]{ClientStream: stream} - if err := x.ClientStream.SendMsg(in); err != nil { - return nil, err - } - if err := x.ClientStream.CloseSend(); err != nil { - return nil, err - } - return x, nil -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type GameDataService_StreamLiveBattlesClient = grpc.ServerStreamingClient[BattleEvent] - func (c *gameDataServiceClient) GetPetState(ctx context.Context, in *PetRequest, opts ...grpc.CallOption) (*PetResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(PetResponse) @@ -99,16 +70,6 @@ func (c *gameDataServiceClient) GetPetState(ctx context.Context, in *PetRequest, return out, nil } -func (c *gameDataServiceClient) ListReadyOpponents(ctx context.Context, in *OpponentsRequest, opts ...grpc.CallOption) (*OpponentsResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(OpponentsResponse) - err := c.cc.Invoke(ctx, GameDataService_ListReadyOpponents_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - func (c *gameDataServiceClient) EstimateWin(ctx context.Context, in *WinRequest, opts ...grpc.CallOption) (*WinResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(WinResponse) @@ -133,19 +94,11 @@ func (c *gameDataServiceClient) VerifyBattle(ctx context.Context, in *VerifyBatt // All implementations must embed UnimplementedGameDataServiceServer // for forward compatibility. type GameDataServiceServer interface { - // Server streaming: indexer-go pushes settled battles (both chains) the - // moment they index. Delivery is at-least-once — consumers must be - // idempotent by (chain, battle_id). - StreamLiveBattles(*StreamRequest, grpc.ServerStreamingServer[BattleEvent]) error - // RAM reads from the write-through roster cache (coherent because - // indexer-go is the sole writer of pet_roster after promotion). Both - // return UNAVAILABLE until warm-up completes — callers fall back to - // their own database read. + // RAM read from the write-through roster cache (coherent because + // indexer-go is the sole writer of pet_roster after promotion). Returns + // UNAVAILABLE until warm-up completes — callers fall back to their own + // database read. GetPetState(context.Context, *PetRequest) (*PetResponse, error) - // Mirrors the backend's findReadyOpponents matchmaking query: - // battle-ready (ready_at <= now), not owned by the caller, optional - // minimum level, ordered by (level, pet_id), paged. - ListReadyOpponents(context.Context, *OpponentsRequest) (*OpponentsResponse, error) // Pre-fight win estimate (plan §3.3): runs the round-based combat sim over // many seeds for pet_id1 vs pet_id2 (both read from the warm roster cache) // and returns pet_id1's win probability. UNAVAILABLE until the cache is warm. @@ -169,15 +122,9 @@ type GameDataServiceServer interface { // pointer dereference when methods are called. type UnimplementedGameDataServiceServer struct{} -func (UnimplementedGameDataServiceServer) StreamLiveBattles(*StreamRequest, grpc.ServerStreamingServer[BattleEvent]) error { - return status.Error(codes.Unimplemented, "method StreamLiveBattles not implemented") -} func (UnimplementedGameDataServiceServer) GetPetState(context.Context, *PetRequest) (*PetResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetPetState not implemented") } -func (UnimplementedGameDataServiceServer) ListReadyOpponents(context.Context, *OpponentsRequest) (*OpponentsResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListReadyOpponents not implemented") -} func (UnimplementedGameDataServiceServer) EstimateWin(context.Context, *WinRequest) (*WinResponse, error) { return nil, status.Error(codes.Unimplemented, "method EstimateWin not implemented") } @@ -205,17 +152,6 @@ func RegisterGameDataServiceServer(s grpc.ServiceRegistrar, srv GameDataServiceS s.RegisterService(&GameDataService_ServiceDesc, srv) } -func _GameDataService_StreamLiveBattles_Handler(srv interface{}, stream grpc.ServerStream) error { - m := new(StreamRequest) - if err := stream.RecvMsg(m); err != nil { - return err - } - return srv.(GameDataServiceServer).StreamLiveBattles(m, &grpc.GenericServerStream[StreamRequest, BattleEvent]{ServerStream: stream}) -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type GameDataService_StreamLiveBattlesServer = grpc.ServerStreamingServer[BattleEvent] - func _GameDataService_GetPetState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(PetRequest) if err := dec(in); err != nil { @@ -234,24 +170,6 @@ func _GameDataService_GetPetState_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } -func _GameDataService_ListReadyOpponents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(OpponentsRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(GameDataServiceServer).ListReadyOpponents(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: GameDataService_ListReadyOpponents_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(GameDataServiceServer).ListReadyOpponents(ctx, req.(*OpponentsRequest)) - } - return interceptor(ctx, in, info, handler) -} - func _GameDataService_EstimateWin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(WinRequest) if err := dec(in); err != nil { @@ -299,10 +217,6 @@ var GameDataService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetPetState", Handler: _GameDataService_GetPetState_Handler, }, - { - MethodName: "ListReadyOpponents", - Handler: _GameDataService_ListReadyOpponents_Handler, - }, { MethodName: "EstimateWin", Handler: _GameDataService_EstimateWin_Handler, @@ -312,12 +226,6 @@ var GameDataService_ServiceDesc = grpc.ServiceDesc{ Handler: _GameDataService_VerifyBattle_Handler, }, }, - Streams: []grpc.StreamDesc{ - { - StreamName: "StreamLiveBattles", - Handler: _GameDataService_StreamLiveBattles_Handler, - ServerStreams: true, - }, - }, + Streams: []grpc.StreamDesc{}, Metadata: "cryptopets.proto", } diff --git a/proto/cryptopets.proto b/proto/cryptopets.proto index 6a8dcdf2..c94b076c 100644 --- a/proto/cryptopets.proto +++ b/proto/cryptopets.proto @@ -8,20 +8,11 @@ package cryptopets; option go_package = "github.com/radcrew/do-not-stop/indexer-go/pb"; service GameDataService { - // Server streaming: indexer-go pushes settled battles (both chains) the - // moment they index. Delivery is at-least-once — consumers must be - // idempotent by (chain, battle_id). - rpc StreamLiveBattles(StreamRequest) returns (stream BattleEvent); - - // RAM reads from the write-through roster cache (coherent because - // indexer-go is the sole writer of pet_roster after promotion). Both - // return UNAVAILABLE until warm-up completes — callers fall back to - // their own database read. + // RAM read from the write-through roster cache (coherent because + // indexer-go is the sole writer of pet_roster after promotion). Returns + // UNAVAILABLE until warm-up completes — callers fall back to their own + // database read. rpc GetPetState(PetRequest) returns (PetResponse); - // Mirrors the backend's findReadyOpponents matchmaking query: - // battle-ready (ready_at <= now), not owned by the caller, optional - // minimum level, ordered by (level, pet_id), paged. - rpc ListReadyOpponents(OpponentsRequest) returns (OpponentsResponse); // Pre-fight win estimate (plan §3.3): runs the round-based combat sim over // many seeds for pet_id1 vs pet_id2 (both read from the warm roster cache) @@ -71,19 +62,6 @@ message PetResponse { string asset = 21; // Metaplex Core asset pubkey (Solana only); "" on EVM } -message OpponentsRequest { - string chain = 1; - string exclude_owner = 2; // normalized address / pubkey - uint32 min_level = 3; - uint32 page = 4; - uint32 page_size = 5; -} - -message OpponentsResponse { - repeated PetResponse pets = 1; - uint32 total = 2; // total matches before paging (pagination parity) -} - message WinRequest { string chain = 1; string pet_id1 = 2; // the pet whose win probability is returned @@ -173,28 +151,3 @@ message VerifyBattleResponse { VerifyPetProgression attacker = 7; VerifyPetProgression defender = 8; } - -message StreamRequest { - // Resume point per chain so a reconnecting client misses nothing: - // chain -> last seen version (Solana slot / EVM block timestamp). - // Missing chains replay nothing and stream live-only. - map after_version = 1; -} - -message BattleEvent { - string chain = 1; // "evm" | "solana" - string battle_id = 2; // settle sig (solana) / txHash-logIndex (evm) - string attacker_pet = 3; - string defender_pet = 4; - string winner_pet = 5; // absolute pet id — matches battle_history.winner_pet_id - uint64 version = 6; // feed back as after_version on reconnect - int64 fought_at = 7; // unix seconds - - // v2 round-based combat sim outputs (plan §3.3). Append-only field numbers. - string loser_pet = 8; // absolute pet id of the loser - string seed = 9; // 0x-hex 32-byte combat seed; replays the sim off-chain - uint32 rounds = 10; - uint32 winner_hp_remaining = 11; - uint32 xp_win = 12; - uint32 xp_loss = 13; -} From 04e838a88e8a02103ab13db6b4b273c236e16150 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 28 Jul 2026 09:20:48 -0400 Subject: [PATCH 59/76] refactor(indexer-go,proto): remove the dead battle pipeline --- CLAUDE.md | 4 +- backend/src/config/env.ts | 2 +- backend/src/grpc/rosterReads.ts | 5 +- indexer-go/cmd/indexer/app.go | 37 +---- indexer-go/cmd/indexer/scan.go | 3 +- indexer-go/cmd/indexer/storage.go | 22 ++- indexer-go/internal/battlebus/bus.go | 74 ---------- indexer-go/internal/battlebus/bus_test.go | 69 --------- indexer-go/internal/cache/roster.go | 48 ------- indexer-go/internal/cache/roster_test.go | 45 ------ indexer-go/internal/evm/indexer.go | 11 +- indexer-go/internal/evm/indexer_test.go | 2 +- indexer-go/internal/grpcsrv/proto.go | 18 --- indexer-go/internal/grpcsrv/reads.go | 21 --- indexer-go/internal/grpcsrv/reads_test.go | 41 +----- indexer-go/internal/grpcsrv/server.go | 31 ++-- indexer-go/internal/grpcsrv/server_test.go | 149 +------------------- indexer-go/internal/grpcsrv/stream.go | 66 --------- indexer-go/internal/grpcsrv/verify.go | 2 +- indexer-go/internal/grpcsrv/verify_test.go | 5 +- indexer-go/internal/indexer/types.go | 23 +-- indexer-go/internal/solana/indexer_test.go | 6 +- indexer-go/internal/solana/notifications.go | 1 - indexer-go/internal/solana/session.go | 11 +- indexer-go/internal/store/pg.go | 71 ---------- indexer-go/internal/store/pg_test.go | 57 -------- indexer-go/internal/store/writer.go | 45 ++---- indexer-go/internal/store/writer_test.go | 53 ++----- 28 files changed, 69 insertions(+), 853 deletions(-) delete mode 100644 indexer-go/internal/battlebus/bus.go delete mode 100644 indexer-go/internal/battlebus/bus_test.go delete mode 100644 indexer-go/internal/grpcsrv/stream.go diff --git a/CLAUDE.md b/CLAUDE.md index 94f5206f..8d210530 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,7 +172,7 @@ The battle worker writes the row from the signed receipt, in the **same transact The dialogue endpoint's anti-forgery guard still exists but now compares the client's claimed winner against that recorded row rather than against a chain event. It stays permissive when the battle is not yet on record (dialogue can be requested before the receipt commits), which is acceptable because it now only protects the dialogue cache — the battle record itself is no longer writable from that path. -Consequently `src/grpc/battleStream.ts` is deleted: nothing published to it after indexer-go stopped ingesting battles, and nothing read from it after this. `INDEXER_GRPC_ADDR` still matters for pet-state reads and win estimates. indexer-go still serves `StreamLiveBattles` and `ListReadyOpponents`; neither has a caller in this repo. +Consequently `src/grpc/battleStream.ts` is deleted: nothing published to it after indexer-go stopped ingesting battles, and nothing read from it after this. `INDEXER_GRPC_ADDR` still matters for pet-state reads and win estimates. `StreamLiveBattles` and `ListReadyOpponents` are gone from `proto/cryptopets.proto` too, along with indexer-go's whole `BattleEvent` pipeline — no adapter had published one since battles left the chain. ### Known v1 contract limitations (design context, not regressions to "fix") `contracts/plan-contract-upgrade.md` documents intentional v1 gaps that v2 is designed around: no battle authorization (anyone can call `battle()`/`attack()` on anyone's pets), an EVM `changeDna` cheat that lets a level-20 pet set arbitrary DNA, and a Solana `create_starter_pet` that accepts client-supplied dna/rarity. v2 plan: EVM moves to UUPS proxies (`PetCoreProxy` + `GameLogicProxy`, with `CombatSimV1` deployed as a separate contract to stay under the 24KB bytecode ceiling); Solana adds versioned/reserved-space accounts and migrates pets to Metaplex Core NFTs. This is a plan doc; check current contract source before assuming any of it is implemented. @@ -187,7 +187,7 @@ Consequently `src/grpc/battleStream.ts` is deleted: nothing published to it afte `contracts/solana/docker-compose.yml` runs two services: `solana-dev` (the validator itself, ports 8899/8900/9900) and an **ngrok tunnel** service exposing the local RPC (needs `NGROK_AUTHTOKEN`, ngrok web UI on 4040). This is how mobile/on-device testing reaches a local validator (`pnpm sol:inject-ngrok`), and it isn't documented in `DEVELOPMENT.md`. ### indexer-go internals -Two chain adapters (Solana WS push, EVM subgraph pull) behind a `ChainIndexer` interface feed a single version-guarded pgx batch writer into Postgres, plus a gRPC push path. Layout: `cmd/indexer` (binary, supports `-scan-once`), `internal/{indexer,evm,solana,store,combat,battlebus,grpcsrv}`, `pb/` (buf-generated). An optional in-memory read cache (`ROSTER_CACHE_ENABLED`) is write-through and version-guarded; it's only coherent while `indexer-go` is the sole writer, so it should stay off during shadow-mode (dual-indexer) operation and only be enabled at promotion. +Two chain adapters (Solana WS push, EVM subgraph pull) behind a `ChainIndexer` interface feed a single version-guarded pgx batch writer into Postgres. Layout: `cmd/indexer` (binary, supports `-scan-once`), `internal/{indexer,evm,solana,store,combat,grpcsrv}`, `pb/` (buf-generated). It indexes the roster only: the battle pipeline (`battlebus`, `BattleEvent`, `InsertBattles`) is gone, and `battle_history` is written by the backend from signed receipts. An optional in-memory read cache (`ROSTER_CACHE_ENABLED`) is write-through and version-guarded; it's only coherent while `indexer-go` is the sole writer, so it should stay off during shadow-mode (dual-indexer) operation and only be enabled at promotion. ### Auth Backend auth is nonce, then wallet-signature, then JWT (`backend/README.md`), guarding a single `/graphql` endpoint; the authenticated wallet becomes the matchmaking `caller` context (`backend/API.md`). Roster/battle reads are read-only projections of what the indexer(s) wrote; the backend no longer decodes contract events itself. `winEstimate` returns `null` (not an error) when unavailable, so treat that as a degraded UI state, not a failure. diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 55ca9d16..3bdcde50 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -64,7 +64,7 @@ export const env = { * * No longer carries battles. `StreamLiveBattles` pushed chain-truth settle events, * which stopped existing with on-chain battles (§L Phase 6); the backend's own signed - * receipt is the record now. + * receipt is the record now, and the RPC is gone from the proto contract. */ indexerGrpc: { /** e.g. localhost:50051. */ diff --git a/backend/src/grpc/rosterReads.ts b/backend/src/grpc/rosterReads.ts index 919e59ca..08bf081b 100644 --- a/backend/src/grpc/rosterReads.ts +++ b/backend/src/grpc/rosterReads.ts @@ -13,9 +13,8 @@ import type { Chain } from '@typings/chain'; * process from adding the deadline to every read. * * Only the single-pet read is left. Matchmaking stopped using the cache when it began - * banding on backend progression (`roster.repository.ts`), which the cache cannot see. - * indexer-go still serves `ListReadyOpponents` and its tests still cover it; nothing in - * this repo calls it now. + * banding on backend progression (`roster.repository.ts`), which the cache cannot see, so + * `ListReadyOpponents` was dropped from the proto contract entirely. */ /** Per-call deadline. The cache answers from RAM; anything slower is a fault. */ diff --git a/indexer-go/cmd/indexer/app.go b/indexer-go/cmd/indexer/app.go index 620470f3..c67115a3 100644 --- a/indexer-go/cmd/indexer/app.go +++ b/indexer-go/cmd/indexer/app.go @@ -11,7 +11,6 @@ import ( "syscall" "time" - "github.com/radcrew/do-not-stop/indexer-go/internal/battlebus" "github.com/radcrew/do-not-stop/indexer-go/internal/config" "github.com/radcrew/do-not-stop/indexer-go/internal/grpcsrv" "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" @@ -42,13 +41,8 @@ func run() error { } roster := make(chan indexer.RosterUpdate, 256) - adapterBattles := make(chan indexer.BattleEvent, 64) // adapters → tee - writerBattles := make(chan indexer.BattleEvent, 64) // tee → storage - bus := battlebus.New() - go teeBattles(ctx, bus, adapterBattles, writerBattles) - - st, err := startStorage(ctx, cfg, roster, writerBattles) + st, err := startStorage(ctx, cfg, roster) if err != nil { return err } @@ -56,7 +50,7 @@ func run() error { grpcErr := make(chan error, 1) go func() { - if err := grpcsrv.New(bus, st.replayer, st.rosterCache).Serve(ctx, cfg.GRPCAddr); err != nil { + if err := grpcsrv.New(st.rosterCache).Serve(ctx, cfg.GRPCAddr); err != nil { grpcErr <- err } }() @@ -66,7 +60,7 @@ func run() error { wg.Add(1) go func(a indexer.ChainIndexer) { defer wg.Done() - if err := a.Run(ctx, roster, adapterBattles); err != nil { + if err := a.Run(ctx, roster); err != nil { slog.Error("adapter exited", "chain", a.Chain(), "err", err) } }(adapter) @@ -119,31 +113,6 @@ func run() error { return nil } -// teeBattles forwards every settled battle to storage AND to gRPC -// subscribers. The bus never blocks (slow consumers are dropped to -// reconnect+replay), so publishing ahead of the storage send is safe. -func teeBattles( - ctx context.Context, - bus *battlebus.Bus, - in <-chan indexer.BattleEvent, - out chan<- indexer.BattleEvent, -) { - for { - select { - case <-ctx.Done(): - return - case b := <-in: - metrics.Battle(b.Chain) - bus.Publish(b) - select { - case out <- b: - case <-ctx.Done(): - return - } - } - } -} - func healthMux() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { diff --git a/indexer-go/cmd/indexer/scan.go b/indexer-go/cmd/indexer/scan.go index c001b54d..e464c914 100644 --- a/indexer-go/cmd/indexer/scan.go +++ b/indexer-go/cmd/indexer/scan.go @@ -39,12 +39,11 @@ func runScanOnce(cfg *config.Config) error { defer pg.Close() roster := make(chan indexer.RosterUpdate, 256) - battles := make(chan indexer.BattleEvent, 64) writerCtx, stopWriter := context.WithCancel(ctx) writerDone := make(chan struct{}) go func() { defer close(writerDone) - if err := store.NewWriter(pg).Run(writerCtx, roster, battles); err != nil { + if err := store.NewWriter(pg).Run(writerCtx, roster); err != nil { slog.Error("writer exited", "err", err) } }() diff --git a/indexer-go/cmd/indexer/storage.go b/indexer-go/cmd/indexer/storage.go index 62e0364f..28eb8928 100644 --- a/indexer-go/cmd/indexer/storage.go +++ b/indexer-go/cmd/indexer/storage.go @@ -6,7 +6,6 @@ import ( "github.com/radcrew/do-not-stop/indexer-go/internal/cache" "github.com/radcrew/do-not-stop/indexer-go/internal/config" - "github.com/radcrew/do-not-stop/indexer-go/internal/grpcsrv" "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" "github.com/radcrew/do-not-stop/indexer-go/internal/store" ) @@ -14,9 +13,8 @@ import ( // storage is the persistence side of the pipeline, or its log-only stand-in // when DATABASE_URL is unset. type storage struct { - replayer grpcsrv.Replayer // nil → stream replay disabled - rosterCache *cache.Roster // nil → read RPCs disabled - writerDone <-chan struct{} // closed after the writer's final drain + rosterCache *cache.Roster // nil → read RPCs disabled + writerDone <-chan struct{} // closed after the writer's final drain close func() } @@ -26,11 +24,10 @@ func startStorage( ctx context.Context, cfg *config.Config, roster chan indexer.RosterUpdate, - battles chan indexer.BattleEvent, ) (*storage, error) { if cfg.DatabaseURL == "" { - slog.Warn("DATABASE_URL not set; draining pipeline to logs only (stream replay disabled)") - go drainSink(ctx, roster, battles) + slog.Warn("DATABASE_URL not set; draining pipeline to logs only") + go drainSink(ctx, roster) return &storage{close: func() {}}, nil } @@ -61,30 +58,27 @@ func startStorage( done := make(chan struct{}) go func() { defer close(done) - if err := writer.Run(ctx, roster, battles); err != nil { + if err := writer.Run(ctx, roster); err != nil { slog.Error("writer exited", "err", err) } }() return &storage{ - replayer: pg, rosterCache: rosterCache, writerDone: done, close: pg.Close, }, nil } -// drainSink discards roster/battle updates to logs when no database is -// configured, so the channels never block the adapters. -func drainSink(ctx context.Context, roster <-chan indexer.RosterUpdate, battles <-chan indexer.BattleEvent) { +// drainSink discards roster updates to logs when no database is configured, so the +// channel never blocks the adapters. +func drainSink(ctx context.Context, roster <-chan indexer.RosterUpdate) { for { select { case <-ctx.Done(): return case u := <-roster: slog.Debug("roster update (drained)", "chain", u.Chain, "pet", u.PetID, "version", u.Version) - case b := <-battles: - slog.Debug("battle event (drained)", "chain", b.Chain, "battle", b.BattleID) } } } diff --git a/indexer-go/internal/battlebus/bus.go b/indexer-go/internal/battlebus/bus.go deleted file mode 100644 index 0483d5fa..00000000 --- a/indexer-go/internal/battlebus/bus.go +++ /dev/null @@ -1,74 +0,0 @@ -// Package battlebus fans settled battles out from the indexing pipeline to -// gRPC stream subscribers. Delivery is best-effort by design: a subscriber -// that cannot keep up is disconnected (its channel closed), which forces the -// client to reconnect with its after_version cursor and replay what it -// missed from battle_history — at-least-once end to end, with the database -// as the source of truth. -package battlebus - -import ( - "sync" - - "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" - "github.com/radcrew/do-not-stop/indexer-go/internal/metrics" -) - -const subscriberBuffer = 256 - -type Bus struct { - mu sync.Mutex - subs map[int]chan indexer.BattleEvent - nextID int -} - -func New() *Bus { - return &Bus{subs: make(map[int]chan indexer.BattleEvent)} -} - -// Subscribe registers a consumer. The returned cancel is idempotent and safe -// to call after the bus has already dropped the subscriber. -func (b *Bus) Subscribe() (<-chan indexer.BattleEvent, func()) { - b.mu.Lock() - defer b.mu.Unlock() - - id := b.nextID - b.nextID++ - ch := make(chan indexer.BattleEvent, subscriberBuffer) - b.subs[id] = ch - metrics.SetStreamSubscribers(len(b.subs)) - - cancel := func() { - b.mu.Lock() - defer b.mu.Unlock() - if sub, ok := b.subs[id]; ok { - delete(b.subs, id) - close(sub) - metrics.SetStreamSubscribers(len(b.subs)) - } - } - return ch, cancel -} - -// Publish delivers to every subscriber without blocking the indexing -// pipeline. A full buffer means the subscriber is too slow — drop it. -func (b *Bus) Publish(event indexer.BattleEvent) { - b.mu.Lock() - defer b.mu.Unlock() - - for id, ch := range b.subs { - select { - case ch <- event: - default: - delete(b.subs, id) - close(ch) - metrics.SetStreamSubscribers(len(b.subs)) - } - } -} - -// Subscribers reports the current consumer count (metrics/tests). -func (b *Bus) Subscribers() int { - b.mu.Lock() - defer b.mu.Unlock() - return len(b.subs) -} diff --git a/indexer-go/internal/battlebus/bus_test.go b/indexer-go/internal/battlebus/bus_test.go deleted file mode 100644 index acd1c702..00000000 --- a/indexer-go/internal/battlebus/bus_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package battlebus - -import ( - "testing" - - "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" -) - -func event(id string, version uint64) indexer.BattleEvent { - return indexer.BattleEvent{Chain: "evm", BattleID: id, Version: version} -} - -func TestPublishFansOutToAllSubscribers(t *testing.T) { - bus := New() - a, cancelA := bus.Subscribe() - b, cancelB := bus.Subscribe() - defer cancelA() - defer cancelB() - - bus.Publish(event("x", 1)) - - if got := <-a; got.BattleID != "x" { - t.Errorf("sub a got %+v", got) - } - if got := <-b; got.BattleID != "x" { - t.Errorf("sub b got %+v", got) - } -} - -func TestCancelStopsDeliveryAndIsIdempotent(t *testing.T) { - bus := New() - ch, cancel := bus.Subscribe() - - cancel() - cancel() // second cancel must not panic (double close) - - if _, ok := <-ch; ok { - t.Error("cancelled subscriber still received an event") - } - if n := bus.Subscribers(); n != 0 { - t.Errorf("subscribers = %d, want 0", n) - } - - bus.Publish(event("x", 1)) // must not panic with no subscribers -} - -func TestSlowConsumerIsDroppedNotBlocking(t *testing.T) { - bus := New() - ch, cancel := bus.Subscribe() - defer cancel() - - // Fill the buffer past capacity without reading; Publish must never block. - for i := 0; i <= subscriberBuffer; i++ { - bus.Publish(event("x", uint64(i))) - } - - if n := bus.Subscribers(); n != 0 { - t.Fatalf("slow subscriber still registered (%d subs)", n) - } - - // Drain: buffered events then channel close. - count := 0 - for range ch { - count++ - } - if count != subscriberBuffer { - t.Errorf("delivered %d events before drop, want %d", count, subscriberBuffer) - } -} diff --git a/indexer-go/internal/cache/roster.go b/indexer-go/internal/cache/roster.go index 01c92779..0bc6e91f 100644 --- a/indexer-go/internal/cache/roster.go +++ b/indexer-go/internal/cache/roster.go @@ -9,7 +9,6 @@ package cache import ( - "sort" "sync" "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" @@ -21,16 +20,6 @@ type petKey struct { petID string } -// OpponentsQuery mirrors the backend's findReadyOpponents parameters. -type OpponentsQuery struct { - Chain string - ExcludeOwner string - MinLevel uint32 - NowUnix int64 // ready_at <= now - Page int - PageSize int -} - type Roster struct { mu sync.RWMutex pets map[petKey]indexer.RosterUpdate @@ -95,40 +84,3 @@ func (r *Roster) Size() int { defer r.mu.RUnlock() return len(r.pets) } - -// ListReadyOpponents answers the matchmaking query from RAM with the same -// semantics as the Prisma implementation: battle-ready, not owned by the -// caller, optional level floor, ordered by (level, petId), paged, with the -// pre-paging total. -func (r *Roster) ListReadyOpponents(q OpponentsQuery) (pets []indexer.RosterUpdate, total int) { - r.mu.RLock() - var matched []indexer.RosterUpdate - for k, u := range r.pets { - if k.chain != q.Chain || u.Owner == q.ExcludeOwner { - continue - } - if u.ReadyAt > q.NowUnix { - continue - } - if q.MinLevel > 0 && u.Level < q.MinLevel { - continue - } - matched = append(matched, u) - } - r.mu.RUnlock() - - sort.Slice(matched, func(i, j int) bool { - if matched[i].Level != matched[j].Level { - return matched[i].Level < matched[j].Level - } - return matched[i].PetID < matched[j].PetID - }) - - total = len(matched) - start := q.Page * q.PageSize - if start >= total { - return nil, total - } - end := min(start+q.PageSize, total) - return matched[start:end], total -} diff --git a/indexer-go/internal/cache/roster_test.go b/indexer-go/internal/cache/roster_test.go index 1ef249a2..048b2355 100644 --- a/indexer-go/internal/cache/roster_test.go +++ b/indexer-go/internal/cache/roster_test.go @@ -55,48 +55,3 @@ func TestApplyIsVersionGuarded(t *testing.T) { t.Errorf("fresh write not applied: %+v", got) } } - -func TestListReadyOpponentsMatchesPrismaSemantics(t *testing.T) { - r := NewRoster() - r.WarmUp([]indexer.RosterUpdate{ - pet("evm", "1", "0xcaller", 3, 100, 1), // caller's own pet — excluded - pet("evm", "2", "0xb", 7, 100, 1), // ready, level 7 - pet("evm", "3", "0xc", 2, 100, 1), // ready, level 2 - pet("evm", "4", "0xd", 5, 9999, 1), // on cooldown — excluded - pet("evm", "5", "0xe", 1, 100, 1), // ready, level 1 (below floor) - pet("solana", "6", "Pub", 9, 100, 1), // other chain — excluded - }) - - q := OpponentsQuery{Chain: "evm", ExcludeOwner: "0xcaller", MinLevel: 2, NowUnix: 500, Page: 0, PageSize: 10} - pets, total := r.ListReadyOpponents(q) - - if total != 2 { - t.Fatalf("total = %d, want 2", total) - } - // Ordered by (level, petId): level 2 then level 7. - if pets[0].PetID != "3" || pets[1].PetID != "2" { - t.Errorf("order = %s,%s — want 3,2", pets[0].PetID, pets[1].PetID) - } -} - -func TestListReadyOpponentsPages(t *testing.T) { - r := NewRoster() - r.WarmUp([]indexer.RosterUpdate{ - pet("evm", "1", "0xa", 1, 0, 1), - pet("evm", "2", "0xb", 2, 0, 1), - pet("evm", "3", "0xc", 3, 0, 1), - }) - - q := OpponentsQuery{Chain: "evm", ExcludeOwner: "0xz", NowUnix: 500, Page: 1, PageSize: 2} - pets, total := r.ListReadyOpponents(q) - if total != 3 || len(pets) != 1 || pets[0].PetID != "3" { - t.Errorf("page 1 = %+v total=%d, want [3] total 3", pets, total) - } - - // Past the end: empty page, correct total. - q.Page = 5 - pets, total = r.ListReadyOpponents(q) - if total != 3 || len(pets) != 0 { - t.Errorf("page 5 = %+v total=%d, want [] total 3", pets, total) - } -} diff --git a/indexer-go/internal/evm/indexer.go b/indexer-go/internal/evm/indexer.go index 1a796bbb..5e903a21 100644 --- a/indexer-go/internal/evm/indexer.go +++ b/indexer-go/internal/evm/indexer.go @@ -98,14 +98,9 @@ func (ix *Indexer) sync(ctx context.Context, roster chan<- indexer.RosterUpdate) // // Battles are no longer ingested (§L Phase 6): GameLogic has no requestBattle / // settleBattle and the subgraph no longer emits a Battle entity, so there is nothing on -// chain left to index. The `battles` channel is kept in the signature because the -// delivery path behind it (battle_history, the bus, StreamLiveBattles) is still wired and -// would be the place to feed backend-resolved receipts if they are ever mirrored here. -func (ix *Indexer) Run( - ctx context.Context, - roster chan<- indexer.RosterUpdate, - _ chan<- indexer.BattleEvent, -) error { +// chain left to index. `battle_history` is written by the backend from its own signed +// receipts now, so this indexer carries no battle path at all. +func (ix *Indexer) Run(ctx context.Context, roster chan<- indexer.RosterUpdate) error { if scanned, err := ix.Scan(ctx, roster); err != nil { if ctx.Err() != nil { return nil diff --git a/indexer-go/internal/evm/indexer_test.go b/indexer-go/internal/evm/indexer_test.go index a300c3aa..896bf069 100644 --- a/indexer-go/internal/evm/indexer_test.go +++ b/indexer-go/internal/evm/indexer_test.go @@ -229,7 +229,7 @@ func TestRunRecoversAfterFailedInitialScan(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) - go func() { done <- ix.Run(ctx, ch, nil) }() + go func() { done <- ix.Run(ctx, ch) }() // Initial scan fails; heal the endpoint and wait for the sweep // (watermark 0 → updatedAt_gt: 0 matches everything). diff --git a/indexer-go/internal/grpcsrv/proto.go b/indexer-go/internal/grpcsrv/proto.go index 38a711ff..03a4e51d 100644 --- a/indexer-go/internal/grpcsrv/proto.go +++ b/indexer-go/internal/grpcsrv/proto.go @@ -30,21 +30,3 @@ func petToProto(u indexer.RosterUpdate) *pb.PetResponse { Asset: u.Asset, } } - -func battleToProto(e indexer.BattleEvent) *pb.BattleEvent { - return &pb.BattleEvent{ - Chain: e.Chain, - BattleId: e.BattleID, - AttackerPet: e.Attacker, - DefenderPet: e.Defender, - WinnerPet: e.WinnerPetID, - Version: e.Version, - FoughtAt: e.FoughtAt, - LoserPet: e.LoserPetID, - Seed: e.Seed, - Rounds: e.Rounds, - WinnerHpRemaining: e.WinnerHpRemaining, - XpWin: e.XPWin, - XpLoss: e.XPLoss, - } -} diff --git a/indexer-go/internal/grpcsrv/reads.go b/indexer-go/internal/grpcsrv/reads.go index 269e416a..3b20449c 100644 --- a/indexer-go/internal/grpcsrv/reads.go +++ b/indexer-go/internal/grpcsrv/reads.go @@ -4,12 +4,10 @@ import ( "context" "fmt" "strconv" - "time" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "github.com/radcrew/do-not-stop/indexer-go/internal/cache" "github.com/radcrew/do-not-stop/indexer-go/internal/combat" "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" "github.com/radcrew/do-not-stop/indexer-go/pb" @@ -37,25 +35,6 @@ func (s *Server) GetPetState(_ context.Context, req *pb.PetRequest) (*pb.PetResp return petToProto(pet), nil } -func (s *Server) ListReadyOpponents(_ context.Context, req *pb.OpponentsRequest) (*pb.OpponentsResponse, error) { - if err := s.readable(); err != nil { - return nil, err - } - pets, total := s.roster.ListReadyOpponents(cache.OpponentsQuery{ - Chain: req.GetChain(), - ExcludeOwner: req.GetExcludeOwner(), - MinLevel: req.GetMinLevel(), - NowUnix: time.Now().Unix(), - Page: int(req.GetPage()), - PageSize: int(req.GetPageSize()), - }) - out := &pb.OpponentsResponse{Total: uint32(total)} - for _, p := range pets { - out.Pets = append(out.Pets, petToProto(p)) - } - return out, nil -} - // EstimateWin runs the combat sim over many seeds and returns pet_id1's win // probability against pet_id2, both read from the warm roster cache. func (s *Server) EstimateWin(_ context.Context, req *pb.WinRequest) (*pb.WinResponse, error) { diff --git a/indexer-go/internal/grpcsrv/reads_test.go b/indexer-go/internal/grpcsrv/reads_test.go index 685fdb80..0a246888 100644 --- a/indexer-go/internal/grpcsrv/reads_test.go +++ b/indexer-go/internal/grpcsrv/reads_test.go @@ -8,7 +8,6 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "github.com/radcrew/do-not-stop/indexer-go/internal/battlebus" "github.com/radcrew/do-not-stop/indexer-go/internal/cache" "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" "github.com/radcrew/do-not-stop/indexer-go/pb" @@ -23,7 +22,7 @@ func cachedPet(chain, id, owner string, level uint32, readyAt int64) indexer.Ros } func TestReadsUnavailableWithoutCache(t *testing.T) { - client := startServer(t, battlebus.New(), nil, nil) + client := startServer(t, nil) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -35,20 +34,20 @@ func TestReadsUnavailableWithoutCache(t *testing.T) { func TestReadsUnavailableUntilWarm(t *testing.T) { roster := cache.NewRoster() // never warmed - client := startServer(t, battlebus.New(), nil, roster) + client := startServer(t, roster) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := client.ListReadyOpponents(ctx, &pb.OpponentsRequest{Chain: "evm", PageSize: 10}) + _, err := client.GetPetState(ctx, &pb.PetRequest{Chain: "evm", PetId: "1"}) if status.Code(err) != codes.Unavailable { - t.Errorf("cold-cache ListReadyOpponents code = %v, want Unavailable", status.Code(err)) + t.Errorf("cold-cache GetPetState code = %v, want Unavailable", status.Code(err)) } } func TestGetPetStateServesFromCache(t *testing.T) { roster := cache.NewRoster() roster.WarmUp([]indexer.RosterUpdate{cachedPet("solana", "42", "Pub1", 12, 100)}) - client := startServer(t, battlebus.New(), nil, roster) + client := startServer(t, roster) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -74,7 +73,7 @@ func TestEstimateWinFromCache(t *testing.T) { {Chain: "evm", PetID: "1", Owner: "0xa", Name: "strong", Level: 100, Rarity: 5, DNA: "1234567890123456", ReadyAt: 0, Version: 1}, {Chain: "evm", PetID: "2", Owner: "0xb", Name: "weak", Level: 1, Rarity: 1, DNA: "9876543210987654", ReadyAt: 0, Version: 1}, }) - client := startServer(t, battlebus.New(), nil, roster) + client := startServer(t, roster) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -116,7 +115,7 @@ func TestEstimateWinIsChainAgnostic(t *testing.T) { mk("evm", "1", dnaA, 3, 25), mk("evm", "2", dnaB, 2, 20), mk("solana", "1", dnaA, 3, 25), mk("solana", "2", dnaB, 2, 20), }) - client := startServer(t, battlebus.New(), nil, roster) + client := startServer(t, roster) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -135,29 +134,3 @@ func TestEstimateWinIsChainAgnostic(t *testing.T) { } } -func TestListReadyOpponentsServesFromCache(t *testing.T) { - roster := cache.NewRoster() - roster.WarmUp([]indexer.RosterUpdate{ - cachedPet("evm", "1", "0xcaller", 5, 0), // excluded: caller's own - cachedPet("evm", "2", "0xb", 3, 0), - cachedPet("evm", "3", "0xc", 8, 0), - }) - client := startServer(t, battlebus.New(), nil, roster) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - res, err := client.ListReadyOpponents(ctx, &pb.OpponentsRequest{ - Chain: "evm", ExcludeOwner: "0xcaller", PageSize: 10, - }) - if err != nil { - t.Fatalf("ListReadyOpponents: %v", err) - } - if res.GetTotal() != 2 || len(res.GetPets()) != 2 { - t.Fatalf("total=%d pets=%d, want 2/2", res.GetTotal(), len(res.GetPets())) - } - if res.GetPets()[0].GetPetId() != "2" || res.GetPets()[1].GetPetId() != "3" { - t.Errorf("order = %s,%s — want 2,3 (by level)", - res.GetPets()[0].GetPetId(), res.GetPets()[1].GetPetId()) - } -} diff --git a/indexer-go/internal/grpcsrv/server.go b/indexer-go/internal/grpcsrv/server.go index b67bec60..976e937b 100644 --- a/indexer-go/internal/grpcsrv/server.go +++ b/indexer-go/internal/grpcsrv/server.go @@ -1,11 +1,14 @@ -// Package grpcsrv serves GameDataService. StreamLiveBattles is the backend's -// live push path: subscribe first (no gap), replay battle_history from the -// client's per-chain cursor, then stream live events, deduping the overlap -// by version. +// Package grpcsrv serves GameDataService: RAM reads off the write-through roster +// cache, plus the independent battle verifier the backend cross-checks against. // -// The service is split across files: server.go (type + lifecycle), stream.go -// (StreamLiveBattles), reads.go (GetPetState/ListReadyOpponents/EstimateWin), -// and proto.go (domain → protobuf mappers). +// The service is split across files: server.go (type + lifecycle), reads.go +// (GetPetState/EstimateWin), verify.go (VerifyBattle), and proto.go +// (domain → protobuf mappers). +// +// It no longer streams battles or serves matchmaking. StreamLiveBattles pushed +// chain-truth settle events, which stopped existing when battles moved off chain; +// ListReadyOpponents could not answer correctly once the backend began banding on +// progression this process has no view of. package grpcsrv import ( @@ -15,27 +18,17 @@ import ( "google.golang.org/grpc" - "github.com/radcrew/do-not-stop/indexer-go/internal/battlebus" "github.com/radcrew/do-not-stop/indexer-go/internal/cache" - "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" "github.com/radcrew/do-not-stop/indexer-go/pb" ) -// Replayer reads chain-indexed battles newer than a version cursor. -// *store.PgFlusher implements it; nil disables replay (live-only streams). -type Replayer interface { - BattlesSince(ctx context.Context, chain string, after uint64) ([]indexer.BattleEvent, error) -} - type Server struct { pb.UnimplementedGameDataServiceServer - bus *battlebus.Bus - replay Replayer roster *cache.Roster // nil = read RPCs disabled (pre-promotion) } -func New(bus *battlebus.Bus, replay Replayer, roster *cache.Roster) *Server { - return &Server{bus: bus, replay: replay, roster: roster} +func New(roster *cache.Roster) *Server { + return &Server{roster: roster} } // Serve blocks until ctx ends, then stops gracefully. diff --git a/indexer-go/internal/grpcsrv/server_test.go b/indexer-go/internal/grpcsrv/server_test.go index becefe72..d3c2abdc 100644 --- a/indexer-go/internal/grpcsrv/server_test.go +++ b/indexer-go/internal/grpcsrv/server_test.go @@ -2,46 +2,26 @@ package grpcsrv import ( "context" - "errors" - "fmt" "net" "testing" - "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/test/bufconn" - "github.com/radcrew/do-not-stop/indexer-go/internal/battlebus" "github.com/radcrew/do-not-stop/indexer-go/internal/cache" - "github.com/radcrew/do-not-stop/indexer-go/internal/indexer" - "github.com/radcrew/do-not-stop/indexer-go/internal/testutil" "github.com/radcrew/do-not-stop/indexer-go/pb" ) -type fakeReplayer struct { - byChain map[string][]indexer.BattleEvent -} - -func (f *fakeReplayer) BattlesSince(_ context.Context, chain string, after uint64) ([]indexer.BattleEvent, error) { - var out []indexer.BattleEvent - for _, e := range f.byChain[chain] { - if e.Version > after { - out = append(out, e) - } - } - return out, nil -} - // startServer runs the service on an in-memory bufconn listener (no real // sockets — see the windows/386 note in the evm tests) and returns a // connected client. -func startServer(t *testing.T, bus *battlebus.Bus, replay Replayer, roster *cache.Roster) pb.GameDataServiceClient { +func startServer(t *testing.T, roster *cache.Roster) pb.GameDataServiceClient { t.Helper() lis := bufconn.Listen(1 << 20) srv := grpc.NewServer() - pb.RegisterGameDataServiceServer(srv, New(bus, replay, roster)) + pb.RegisterGameDataServiceServer(srv, New(roster)) go func() { _ = srv.Serve(lis) }() t.Cleanup(srv.Stop) @@ -58,128 +38,3 @@ func startServer(t *testing.T, bus *battlebus.Bus, replay Replayer, roster *cach return pb.NewGameDataServiceClient(conn) } - -func recvOne(t *testing.T, stream grpc.ServerStreamingClient[pb.BattleEvent]) *pb.BattleEvent { - t.Helper() - type result struct { - event *pb.BattleEvent - err error - } - ch := make(chan result, 1) - go func() { - e, err := stream.Recv() - ch <- result{e, err} - }() - select { - case r := <-ch: - if r.err != nil { - t.Fatalf("Recv: %v", r.err) - } - return r.event - case <-time.After(5 * time.Second): - t.Fatal("Recv timed out") - return nil - } -} - -func battle(chain, id string, version uint64) indexer.BattleEvent { - return indexer.BattleEvent{ - Chain: chain, BattleID: id, Attacker: "1", Defender: "2", - WinnerPetID: "1", Version: version, FoughtAt: int64(version), - } -} - -func TestStreamReplaysThenGoesLive(t *testing.T) { - bus := battlebus.New() - replay := &fakeReplayer{byChain: map[string][]indexer.BattleEvent{ - "evm": {battle("evm", "0xa-1", 100), battle("evm", "0xb-2", 200)}, - }} - client := startServer(t, bus, replay, nil) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - stream, err := client.StreamLiveBattles(ctx, &pb.StreamRequest{ - AfterVersion: map[string]uint64{"evm": 100}, - }) - if err != nil { - t.Fatalf("StreamLiveBattles: %v", err) - } - - // Replay: only the battle after the cursor. - if e := recvOne(t, stream); e.GetBattleId() != "0xb-2" || e.GetVersion() != 200 { - t.Errorf("replayed = %v, want 0xb-2@200", e) - } - - // Wait for the live subscription to be registered, then publish: a stale - // event (must be deduped) and a fresh one. - testutil.WaitFor(t, "subscriber registered", func() bool { return bus.Subscribers() == 1 }) - bus.Publish(battle("evm", "0xb-2", 200)) // overlap with replay - bus.Publish(battle("evm", "0xc-3", 300)) - - if e := recvOne(t, stream); e.GetBattleId() != "0xc-3" { - t.Errorf("live = %v, want 0xc-3 (stale event deduped)", e) - } -} - -func TestStreamLiveOnlyWithoutCursor(t *testing.T) { - bus := battlebus.New() - client := startServer(t, bus, nil, nil) // no replayer at all - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - stream, err := client.StreamLiveBattles(ctx, &pb.StreamRequest{}) - if err != nil { - t.Fatalf("StreamLiveBattles: %v", err) - } - - testutil.WaitFor(t, "subscriber registered", func() bool { return bus.Subscribers() == 1 }) - bus.Publish(battle("solana", "sig1", 9000)) - - if e := recvOne(t, stream); e.GetBattleId() != "sig1" || e.GetChain() != "solana" { - t.Errorf("live = %v, want sig1", e) - } -} - -func TestSlowConsumerEndsStreamCleanly(t *testing.T) { - bus := battlebus.New() - client := startServer(t, bus, nil, nil) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - stream, err := client.StreamLiveBattles(ctx, &pb.StreamRequest{}) - if err != nil { - t.Fatalf("StreamLiveBattles: %v", err) - } - testutil.WaitFor(t, "subscriber registered", func() bool { return bus.Subscribers() == 1 }) - - // Saturate the subscriber without the client reading fast enough; the bus - // drops it and the server ends the stream (client should reconnect). - for i := 0; i < 5000; i++ { - bus.Publish(battle("evm", fmt.Sprintf("0x%d", i), uint64(i+1))) - } - testutil.WaitFor(t, "subscriber dropped", func() bool { return bus.Subscribers() == 0 }) - - // Drain until the clean end-of-stream. - deadline := time.After(5 * time.Second) - for { - type result struct { - err error - } - ch := make(chan result, 1) - go func() { - _, err := stream.Recv() - ch <- result{err} - }() - select { - case r := <-ch: - if r.err != nil { - if errors.Is(r.err, context.DeadlineExceeded) { - t.Fatalf("stream ended with %v, want clean EOF", r.err) - } - return // io.EOF (clean end) — what we want - } - case <-deadline: - t.Fatal("stream never ended after subscriber drop") - } - } -} diff --git a/indexer-go/internal/grpcsrv/stream.go b/indexer-go/internal/grpcsrv/stream.go deleted file mode 100644 index 46ad71a3..00000000 --- a/indexer-go/internal/grpcsrv/stream.go +++ /dev/null @@ -1,66 +0,0 @@ -package grpcsrv - -import ( - "log/slog" - - "google.golang.org/grpc" - - "github.com/radcrew/do-not-stop/indexer-go/pb" -) - -func (s *Server) StreamLiveBattles(req *pb.StreamRequest, stream grpc.ServerStreamingServer[pb.BattleEvent]) error { - ctx := stream.Context() - - // grpc-go defers response headers until the first Send, so an idle stream - // (no replay cursor, no battles yet) would leave the subscriber without a - // connection ack. Flush headers now so clients can log "connected". - if err := stream.SendHeader(nil); err != nil { - return err - } - - // Subscribe before replaying so nothing settles in the gap between the - // two; the version dedupe below absorbs the overlap instead. - live, cancel := s.bus.Subscribe() - defer cancel() - - lastSent := make(map[string]uint64, len(req.GetAfterVersion())) - for chain, after := range req.GetAfterVersion() { - if s.replay == nil { - slog.Warn("stream requested replay but no store is configured", "chain", chain) - continue - } - events, err := s.replay.BattlesSince(ctx, chain, after) - if err != nil { - return err - } - for _, e := range events { - if err := stream.Send(battleToProto(e)); err != nil { - return err - } - lastSent[e.Chain] = e.Version - } - if _, ok := lastSent[chain]; !ok { - lastSent[chain] = after // nothing newer: still dedupe live ≤ cursor - } - } - - for { - select { - case <-ctx.Done(): - return nil - case e, ok := <-live: - if !ok { - // Dropped as a slow consumer: end the stream so the client - // reconnects and replays from its cursor. - slog.Warn("stream subscriber dropped (slow consumer)") - return nil - } - if seen, ok := lastSent[e.Chain]; ok && e.Version <= seen { - continue // already covered by replay - } - if err := stream.Send(battleToProto(e)); err != nil { - return err - } - } - } -} diff --git a/indexer-go/internal/grpcsrv/verify.go b/indexer-go/internal/grpcsrv/verify.go index a5899d55..0c1f6acb 100644 --- a/indexer-go/internal/grpcsrv/verify.go +++ b/indexer-go/internal/grpcsrv/verify.go @@ -14,7 +14,7 @@ import ( // VerifyBattle independently recomputes a backend-authoritative battle result // (docs/plan-backend-battle-architecture.md §F). Unlike GetPetState/ -// ListReadyOpponents/EstimateWin, it reads nothing from the roster cache and +// EstimateWin, it reads nothing from the roster cache and // needs no warm-up: every input arrives in the request, which is what makes // this a genuine second implementation of the computation rather than a // second call into the first. diff --git a/indexer-go/internal/grpcsrv/verify_test.go b/indexer-go/internal/grpcsrv/verify_test.go index 4246ba8f..48bdb2ea 100644 --- a/indexer-go/internal/grpcsrv/verify_test.go +++ b/indexer-go/internal/grpcsrv/verify_test.go @@ -12,17 +12,16 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "github.com/radcrew/do-not-stop/indexer-go/internal/battlebus" "github.com/radcrew/do-not-stop/indexer-go/pb" ) // VerifyBattle needs no cache and no warm-up: everything arrives in the -// request, unlike GetPetState/ListReadyOpponents/EstimateWin. Passing a nil +// request, unlike GetPetState/EstimateWin. Passing a nil // roster (the pre-promotion, cache-disabled state those RPCs refuse) proves // that independently. func verifyClient(t *testing.T) pb.GameDataServiceClient { t.Helper() - return startServer(t, battlebus.New(), nil, nil) + return startServer(t, nil) } func TestVerifyBattleWorksWithNoCache(t *testing.T) { diff --git a/indexer-go/internal/indexer/types.go b/indexer-go/internal/indexer/types.go index aa87e789..201047fd 100644 --- a/indexer-go/internal/indexer/types.go +++ b/indexer-go/internal/indexer/types.go @@ -36,27 +36,6 @@ type RosterUpdate struct { Asset string // Metaplex Core asset pubkey (Solana only, §2.3); "" on EVM / pre-Core } -// BattleEvent is one settled battle, headed for battle_history and the -// StreamLiveBattles gRPC feed. -type BattleEvent struct { - Chain string - BattleID string // settle sig (solana) / txHash-logIndex (evm) - Attacker string - Defender string - WinnerPetID string // absolute pet id — head-to-head survives role swaps - Version uint64 - FoughtAt int64 // unix seconds - - // v2 fields from the round-based combat sim (plan §3.3). The seed makes a - // battle replayable: the frontend re-runs the sim locally to animate it. - LoserPetID string // absolute pet id of the loser - Seed string // 0x-prefixed 32-byte combat seed (hex), chain-agnostic - Rounds uint32 // rounds the sim ran - WinnerHpRemaining uint32 // winner's HP at the final round - XPWin uint32 // XP credited to the winner (level-diff scaled, §3.4) - XPLoss uint32 // XP credited to the loser -} - // ChainIndexer is one roster source, any chain. type ChainIndexer interface { Chain() string @@ -68,5 +47,5 @@ type ChainIndexer interface { // watermark polling). Blocks until ctx is done; returns nil on clean // shutdown. Transient source errors are logged and retried internally, // never returned. - Run(ctx context.Context, roster chan<- RosterUpdate, battles chan<- BattleEvent) error + Run(ctx context.Context, roster chan<- RosterUpdate) error } diff --git a/indexer-go/internal/solana/indexer_test.go b/indexer-go/internal/solana/indexer_test.go index 6e9668a2..76e64ecb 100644 --- a/indexer-go/internal/solana/indexer_test.go +++ b/indexer-go/internal/solana/indexer_test.go @@ -222,12 +222,11 @@ func TestSessionStreamsAccountNotifications(t *testing.T) { ix, _ := newTestIndexer(t, rpc, conn) roster := make(chan indexer.RosterUpdate, 10) - battles := make(chan indexer.BattleEvent, 10) ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) - go func() { done <- ix.Run(ctx, roster, battles) }() + go func() { done <- ix.Run(ctx, roster) }() conn.push(t, programNotification(1234, petData)) select { @@ -257,12 +256,11 @@ func TestRunRedialsAfterConnectionLoss(t *testing.T) { ix, dials := newTestIndexer(t, rpc, conn1, conn2) roster := make(chan indexer.RosterUpdate, 10) - battles := make(chan indexer.BattleEvent, 10) ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) - go func() { done <- ix.Run(ctx, roster, battles) }() + go func() { done <- ix.Run(ctx, roster) }() testutil.WaitFor(t, "first dial", func() bool { return dials.Load() >= 1 }) conn1.Close() // simulate connection drop diff --git a/indexer-go/internal/solana/notifications.go b/indexer-go/internal/solana/notifications.go index 78d82c34..a37e7548 100644 --- a/indexer-go/internal/solana/notifications.go +++ b/indexer-go/internal/solana/notifications.go @@ -27,7 +27,6 @@ func (ix *Indexer) handleMessage( ctx context.Context, msg []byte, roster chan<- indexer.RosterUpdate, - _ chan<- indexer.BattleEvent, ) { var note wsNotification if err := json.Unmarshal(msg, ¬e); err != nil { diff --git a/indexer-go/internal/solana/session.go b/indexer-go/internal/solana/session.go index fc463e4f..58bdf0e8 100644 --- a/indexer-go/internal/solana/session.go +++ b/indexer-go/internal/solana/session.go @@ -19,11 +19,7 @@ const ( // Run maintains the subscription session forever: dial, subscribe, catch up, // stream; on any failure, back off and start over. Returns nil only when ctx // ends. -func (ix *Indexer) Run( - ctx context.Context, - roster chan<- indexer.RosterUpdate, - battles chan<- indexer.BattleEvent, -) error { +func (ix *Indexer) Run(ctx context.Context, roster chan<- indexer.RosterUpdate) error { attempt := 0 for { if ctx.Err() != nil { @@ -43,7 +39,7 @@ func (ix *Indexer) Run( continue } - subscribed, err := ix.session(ctx, conn, roster, battles) + subscribed, err := ix.session(ctx, conn, roster) _ = conn.Close() if ctx.Err() != nil { return nil @@ -67,7 +63,6 @@ func (ix *Indexer) session( ctx context.Context, conn wsConn, roster chan<- indexer.RosterUpdate, - battles chan<- indexer.BattleEvent, ) (subscribed bool, err error) { if err := ix.subscribe(conn); err != nil { return false, fmt.Errorf("subscribe: %w", err) @@ -116,7 +111,7 @@ func (ix *Indexer) session( slog.Info("solana reconciliation scan", "scanned", scanned) } case msg := <-msgs: - ix.handleMessage(ctx, msg, roster, battles) + ix.handleMessage(ctx, msg, roster) } } } diff --git a/indexer-go/internal/store/pg.go b/indexer-go/internal/store/pg.go index 5f1a9393..9421c48f 100644 --- a/indexer-go/internal/store/pg.go +++ b/indexer-go/internal/store/pg.go @@ -51,25 +51,6 @@ type petRosterRow struct { func (petRosterRow) TableName() string { return "pet_roster" } -// battleRow maps indexer.BattleEvent onto battle_history. created_at is omitted -// so the table's CURRENT_TIMESTAMP default fills it (matching the old INSERT). -type battleRow struct { - Chain string `gorm:"column:chain;primaryKey"` - BattleID string `gorm:"column:battle_id;primaryKey"` - AttackerPetID string `gorm:"column:attacker_pet_id"` - DefenderPetID string `gorm:"column:defender_pet_id"` - WinnerPetID string `gorm:"column:winner_pet_id"` - LoserPetID string `gorm:"column:loser_pet_id"` - Seed string `gorm:"column:seed"` - Rounds int32 `gorm:"column:rounds"` - WinnerHpRemaining int32 `gorm:"column:winner_hp_remaining"` - XPWin int32 `gorm:"column:xp_win"` - XPLoss int32 `gorm:"column:xp_loss"` - FoughtAt int64 `gorm:"column:fought_at"` - Version int64 `gorm:"column:version"` -} - -func (battleRow) TableName() string { return "battle_history" } // rosterUpdateColumns are every non-key column, set to EXCLUDED. on // conflict — the upsert's "freshest write wins" body (guarded by last_version). @@ -146,32 +127,6 @@ func (f *PgFlusher) FlushRoster(ctx context.Context, batch []indexer.RosterUpdat return nil } -// InsertBattles appends settled battles. battle_id is the settle signature / -// txHash-logIndex, so DO NOTHING makes at-least-once delivery idempotent. -func (f *PgFlusher) InsertBattles(ctx context.Context, events []indexer.BattleEvent) error { - if len(events) == 0 { - return nil - } - - rows := make([]battleRow, len(events)) - for i, e := range events { - rows[i] = battleRow{ - Chain: e.Chain, BattleID: e.BattleID, AttackerPetID: e.Attacker, DefenderPetID: e.Defender, - WinnerPetID: e.WinnerPetID, LoserPetID: e.LoserPetID, Seed: e.Seed, - Rounds: int32(e.Rounds), WinnerHpRemaining: int32(e.WinnerHpRemaining), - XPWin: int32(e.XPWin), XPLoss: int32(e.XPLoss), FoughtAt: e.FoughtAt, Version: int64(e.Version), - } - } - - err := f.db.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "chain"}, {Name: "battle_id"}}, - DoNothing: true, - }).Create(&rows).Error - if err != nil { - return fmt.Errorf("store: battle insert (%d rows): %w", len(events), err) - } - return nil -} // LoadRoster reads the whole pet_roster table — the cache warm-up source // (the table is the persistent copy of the exact data the cache mirrors). @@ -196,29 +151,3 @@ func (f *PgFlusher) LoadRoster(ctx context.Context) ([]indexer.RosterUpdate, err return pets, nil } -// BattlesSince reads chain-indexed battles newer than `after` for the gRPC -// replay path, oldest first. Rows with version 0 (client-reported via the -// dialogue path, never chain-indexed) are excluded by the strict inequality -// when after >= 0 — exactly the rows a resuming stream consumer already has -// no cursor for. -func (f *PgFlusher) BattlesSince(ctx context.Context, chain string, after uint64) ([]indexer.BattleEvent, error) { - var rows []battleRow - err := f.db.WithContext(ctx). - Where("chain = ? AND version > ?", chain, after). - Order("version ASC"). - Find(&rows).Error - if err != nil { - return nil, fmt.Errorf("store: battles since: %w", err) - } - - events := make([]indexer.BattleEvent, len(rows)) - for i, r := range rows { - events[i] = indexer.BattleEvent{ - Chain: r.Chain, BattleID: r.BattleID, Attacker: r.AttackerPetID, Defender: r.DefenderPetID, - WinnerPetID: r.WinnerPetID, LoserPetID: r.LoserPetID, Seed: r.Seed, - Rounds: uint32(r.Rounds), WinnerHpRemaining: uint32(r.WinnerHpRemaining), - XPWin: uint32(r.XPWin), XPLoss: uint32(r.XPLoss), FoughtAt: r.FoughtAt, Version: uint64(r.Version), - } - } - return events, nil -} diff --git a/indexer-go/internal/store/pg_test.go b/indexer-go/internal/store/pg_test.go index c4fbf032..6bf3834d 100644 --- a/indexer-go/internal/store/pg_test.go +++ b/indexer-go/internal/store/pg_test.go @@ -164,60 +164,3 @@ func TestRosterUpsertIsIdempotentAndVersionGuarded(t *testing.T) { t.Errorf("stale write applied: %+v, want untouched alice/6/200", r) } } - -func TestBattleInsertIsIdempotent(t *testing.T) { - f := newTestFlusher(t) - ctx := context.Background() - - events := []indexer.BattleEvent{ - {Chain: "solana", BattleID: "sig1", Attacker: "p1", Defender: "p2", WinnerPetID: "p1", Version: 10, FoughtAt: 1770000100}, - {Chain: "solana", BattleID: "sig2", Attacker: "p2", Defender: "p1", WinnerPetID: "p2", Version: 11, FoughtAt: 1770000200}, - } - - if err := f.InsertBattles(ctx, events); err != nil { - t.Fatalf("insert: %v", err) - } - // At-least-once delivery: replay one old event alongside a new one. - if err := f.InsertBattles(ctx, []indexer.BattleEvent{ - events[0], - {Chain: "solana", BattleID: "sig3", Attacker: "p1", Defender: "p2", WinnerPetID: "p2", Version: 12, FoughtAt: 1770000300}, - }); err != nil { - t.Fatalf("replay insert: %v", err) - } - - if n := f.countRows(t, "battle_history"); n != 3 { - t.Errorf("battle rows = %d, want 3 (replay deduped)", n) - } -} - -func TestBattlesSinceReplaysFromCursor(t *testing.T) { - f := newTestFlusher(t) - ctx := context.Background() - - if err := f.InsertBattles(ctx, []indexer.BattleEvent{ - {Chain: "evm", BattleID: "0xa-1", Attacker: "1", Defender: "2", WinnerPetID: "1", Version: 100, FoughtAt: 100}, - {Chain: "evm", BattleID: "0xb-2", Attacker: "2", Defender: "3", WinnerPetID: "3", Version: 200, FoughtAt: 200}, - {Chain: "solana", BattleID: "sigX", Attacker: "5", Defender: "6", WinnerPetID: "5", Version: 9000, FoughtAt: 150}, - // Client-reported row (dialogue path): version 0, never replayed. - {Chain: "evm", BattleID: "0xclient", Attacker: "7", Defender: "8", WinnerPetID: "7", Version: 0, FoughtAt: 50}, - }); err != nil { - t.Fatalf("insert: %v", err) - } - - events, err := f.BattlesSince(ctx, "evm", 100) - if err != nil { - t.Fatalf("BattlesSince: %v", err) - } - if len(events) != 1 || events[0].BattleID != "0xb-2" || events[0].Version != 200 { - t.Errorf("replay = %+v, want only 0xb-2", events) - } - - // Cursor 0 replays every chain-indexed row but not client-reported ones. - events, err = f.BattlesSince(ctx, "evm", 0) - if err != nil { - t.Fatalf("BattlesSince: %v", err) - } - if len(events) != 2 { - t.Errorf("replay from 0 = %d rows, want 2 (version-0 row excluded)", len(events)) - } -} diff --git a/indexer-go/internal/store/writer.go b/indexer-go/internal/store/writer.go index 661e9623..652b0f2e 100644 --- a/indexer-go/internal/store/writer.go +++ b/indexer-go/internal/store/writer.go @@ -1,7 +1,7 @@ -// Package store is the single ordered writer: every RosterUpdate and -// BattleEvent from every chain adapter funnels through one goroutine into -// batched, version-guarded Postgres writes. Concurrency lives upstream -// (decode, fetch) — ordering and idempotency are enforced here. +// Package store is the single ordered writer: every RosterUpdate from every chain +// adapter funnels through one goroutine into batched, version-guarded Postgres +// writes. Concurrency lives upstream (decode, fetch) — ordering and idempotency +// are enforced here. package store import ( @@ -27,7 +27,6 @@ const ( // is unit-testable without Postgres; pgFlusher is the real implementation. type flusher interface { FlushRoster(ctx context.Context, batch []indexer.RosterUpdate) error - InsertBattles(ctx context.Context, events []indexer.BattleEvent) error } type petKey struct { @@ -48,8 +47,7 @@ type Writer struct { // Pending state is owned exclusively by the Run goroutine. // pendingRoster coalesces by pet — only the highest version survives — // so a flush failure can never grow memory past the roster size. - pendingRoster map[petKey]indexer.RosterUpdate - pendingBattles []indexer.BattleEvent + pendingRoster map[petKey]indexer.RosterUpdate } func NewWriter(f flusher) *Writer { @@ -61,13 +59,9 @@ func NewWriter(f flusher) *Writer { } } -// Run drains both channels until ctx is done, then performs a final flush on +// Run drains the roster channel until ctx is done, then performs a final flush on // a fresh deadline so in-flight batches survive shutdown. -func (w *Writer) Run( - ctx context.Context, - roster <-chan indexer.RosterUpdate, - battles <-chan indexer.BattleEvent, -) error { +func (w *Writer) Run(ctx context.Context, roster <-chan indexer.RosterUpdate) error { ticker := time.NewTicker(w.flushEvery) defer ticker.Stop() @@ -76,7 +70,7 @@ func (w *Writer) Run( case <-ctx.Done(): drainCtx, cancel := context.WithTimeout(context.Background(), drainTimeout) defer cancel() - w.flushAll(drainCtx) + w.flushRoster(drainCtx) return nil case u := <-roster: @@ -85,12 +79,8 @@ func (w *Writer) Run( w.flushRoster(ctx) } - case b := <-battles: - w.pendingBattles = append(w.pendingBattles, b) - w.flushBattles(ctx) - case <-ticker.C: - w.flushAll(ctx) + w.flushRoster(ctx) } } } @@ -129,20 +119,3 @@ func (w *Writer) flushRoster(ctx context.Context) { clear(w.pendingRoster) } -// flushBattles attempts to insert all pending battles. ON CONFLICT DO NOTHING -// downstream makes retries harmless. -func (w *Writer) flushBattles(ctx context.Context) { - if len(w.pendingBattles) == 0 { - return - } - if err := w.flusher.InsertBattles(ctx, w.pendingBattles); err != nil { - slog.Error("battle insert failed; retained for retry", "events", len(w.pendingBattles), "err", err) - return - } - w.pendingBattles = w.pendingBattles[:0] -} - -func (w *Writer) flushAll(ctx context.Context) { - w.flushRoster(ctx) - w.flushBattles(ctx) -} diff --git a/indexer-go/internal/store/writer_test.go b/indexer-go/internal/store/writer_test.go index 7aaf1060..4604b8fb 100644 --- a/indexer-go/internal/store/writer_test.go +++ b/indexer-go/internal/store/writer_test.go @@ -15,7 +15,6 @@ import ( type fakeFlusher struct { mu sync.Mutex rosterCalls [][]indexer.RosterUpdate - battleCalls [][]indexer.BattleEvent fail bool } @@ -29,15 +28,6 @@ func (f *fakeFlusher) FlushRoster(_ context.Context, batch []indexer.RosterUpdat return nil } -func (f *fakeFlusher) InsertBattles(_ context.Context, events []indexer.BattleEvent) error { - f.mu.Lock() - defer f.mu.Unlock() - if f.fail { - return errors.New("insert refused") - } - f.battleCalls = append(f.battleCalls, append([]indexer.BattleEvent(nil), events...)) - return nil -} func (f *fakeFlusher) setFail(v bool) { f.mu.Lock() @@ -55,15 +45,6 @@ func (f *fakeFlusher) allRosterRows() []indexer.RosterUpdate { return all } -func (f *fakeFlusher) allBattles() []indexer.BattleEvent { - f.mu.Lock() - defer f.mu.Unlock() - var all []indexer.BattleEvent - for _, c := range f.battleCalls { - all = append(all, c...) - } - return all -} func update(petID string, version uint64, level uint32) indexer.RosterUpdate { return indexer.RosterUpdate{Chain: "evm", PetID: petID, Level: level, Version: version} @@ -71,19 +52,18 @@ func update(petID string, version uint64, level uint32) indexer.RosterUpdate { // runWriter starts the writer and returns channels plus a stop function that // cancels and waits for the final drain. -func runWriter(t *testing.T, w *Writer) (chan indexer.RosterUpdate, chan indexer.BattleEvent, func()) { +func runWriter(t *testing.T, w *Writer) (chan indexer.RosterUpdate, func()) { t.Helper() roster := make(chan indexer.RosterUpdate, 256) - battles := make(chan indexer.BattleEvent, 64) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) - if err := w.Run(ctx, roster, battles); err != nil { + if err := w.Run(ctx, roster); err != nil { t.Errorf("Run: %v", err) } }() - return roster, battles, func() { + return roster, func() { cancel() select { case <-done: @@ -99,7 +79,7 @@ func TestFlushesWhenBatchSizeReached(t *testing.T) { w.batchSize = 3 w.flushEvery = time.Hour // ticker out of the picture - roster, _, stop := runWriter(t, w) + roster, stop := runWriter(t, w) defer stop() for i := 0; i < 3; i++ { @@ -114,7 +94,7 @@ func TestFlushesOnTicker(t *testing.T) { w := NewWriter(f) w.flushEvery = 10 * time.Millisecond - roster, _, stop := runWriter(t, w) + roster, stop := runWriter(t, w) defer stop() roster <- update("1", 1, 1) @@ -151,7 +131,7 @@ func TestFailedFlushRetainsAndRetries(t *testing.T) { w := NewWriter(f) w.flushEvery = 10 * time.Millisecond - roster, _, stop := runWriter(t, w) + roster, stop := runWriter(t, w) defer stop() roster <- update("1", 1, 1) @@ -164,29 +144,17 @@ func TestFailedFlushRetainsAndRetries(t *testing.T) { testutil.WaitFor(t, "retry after failure clears", func() bool { return len(f.allRosterRows()) == 1 }) } -func TestBattlesInsertImmediately(t *testing.T) { - f := &fakeFlusher{} - w := NewWriter(f) - w.flushEvery = time.Hour - - _, battles, stop := runWriter(t, w) - defer stop() - - battles <- indexer.BattleEvent{Chain: "solana", BattleID: "sig1"} - testutil.WaitFor(t, "immediate battle insert", func() bool { return len(f.allBattles()) == 1 }) -} func TestFinalDrainOnShutdown(t *testing.T) { f := &fakeFlusher{} w := NewWriter(f) w.flushEvery = time.Hour - roster, battles, stop := runWriter(t, w) + roster, stop := runWriter(t, w) roster <- update("1", 1, 1) - battles <- indexer.BattleEvent{Chain: "evm", BattleID: "0xdead-1"} - // Give the loop a moment to buffer both, then cancel before any flush. - testutil.WaitFor(t, "events buffered", func() bool { + // Give the loop a moment to buffer it, then cancel before any flush. + testutil.WaitFor(t, "update buffered", func() bool { return len(f.allRosterRows()) == 0 // nothing flushed yet — buffered only }) time.Sleep(20 * time.Millisecond) @@ -195,7 +163,4 @@ func TestFinalDrainOnShutdown(t *testing.T) { if got := len(f.allRosterRows()); got != 1 { t.Errorf("final drain flushed %d roster rows, want 1", got) } - if got := len(f.allBattles()); got != 1 { - t.Errorf("final drain flushed %d battles, want 1", got) - } } From fd6ccc87a959a9d24e95f719f5f2286b7fb31f46 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 28 Jul 2026 09:25:18 -0400 Subject: [PATCH 60/76] refactor(contracts): drop GameConfig.levelBandWidth --- .../ethereum/scripts/upgrade-game-config.ts | 1 - contracts/ethereum/src/GameConfig.sol | 6 --- .../src/chains/ethereum/gameConfigAbi.json | 39 ------------------- 3 files changed, 46 deletions(-) diff --git a/contracts/ethereum/scripts/upgrade-game-config.ts b/contracts/ethereum/scripts/upgrade-game-config.ts index d4a57476..4ca56cc6 100644 --- a/contracts/ethereum/scripts/upgrade-game-config.ts +++ b/contracts/ethereum/scripts/upgrade-game-config.ts @@ -121,7 +121,6 @@ async function main() { ['trainCooldown', 'setTrainCooldown'], ['trainXp', 'setTrainXp'], ['maxLevel', 'setMaxLevel'], - ['levelBandWidth', 'setLevelBandWidth'], ['studFee', 'setStudFee'], ['marriageCooldown', 'setMarriageCooldown'], ['proposalTTL', 'setProposalTTL'], diff --git a/contracts/ethereum/src/GameConfig.sol b/contracts/ethereum/src/GameConfig.sol index b9e70d1f..1e321d4a 100644 --- a/contracts/ethereum/src/GameConfig.sol +++ b/contracts/ethereum/src/GameConfig.sol @@ -29,7 +29,6 @@ contract GameConfig is Ownable { uint32 public trainXp = 100; // flat XP per train (§3.4) uint32 public maxLevel = 100; // hard cap; no XP/level-up beyond this (§3.4) - uint32 public levelBandWidth = 100; // ±N level gap allowed for battle (§3.4 dev: 100=off, prod: 10) uint256 public studFee = 0.001 ether; // cross-owner breed: payer → other parent's owner (§4.4) uint256 public marriageCooldown = 60 seconds; // lockout after divorce/stale (§5 dev: 60s, prod: 24h) @@ -61,7 +60,6 @@ contract GameConfig is Ownable { event TrainCooldownUpdated(uint256 cooldown); event TrainXpUpdated(uint32 xp); event MaxLevelUpdated(uint32 level); - event LevelBandWidthUpdated(uint32 width); event StudFeeUpdated(uint256 fee); event MarriageCooldownUpdated(uint256 cooldown); event ProposalTTLUpdated(uint256 ttl); @@ -133,10 +131,6 @@ contract GameConfig is Ownable { emit MaxLevelUpdated(level); } - function setLevelBandWidth(uint32 width) external onlyOwner { - levelBandWidth = width; - emit LevelBandWidthUpdated(width); - } function setStudFee(uint256 fee) external onlyOwner { studFee = fee; diff --git a/frontend/src/chains/ethereum/gameConfigAbi.json b/frontend/src/chains/ethereum/gameConfigAbi.json index 4bdbfc0f..34cc74de 100644 --- a/frontend/src/chains/ethereum/gameConfigAbi.json +++ b/frontend/src/chains/ethereum/gameConfigAbi.json @@ -137,19 +137,6 @@ "name": "GenerationCapUpdated", "type": "event" }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "uint32", - "name": "width", - "type": "uint32" - } - ], - "name": "LevelBandWidthUpdated", - "type": "event" - }, { "anonymous": false, "inputs": [ @@ -461,19 +448,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "levelBandWidth", - "outputs": [ - { - "internalType": "uint32", - "name": "", - "type": "uint32" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [], "name": "levelUpFee", @@ -708,19 +682,6 @@ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "uint32", - "name": "width", - "type": "uint32" - } - ], - "name": "setLevelBandWidth", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { From 41449db5eba91a6872cc36a71423afab60ddc8dc Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 28 Jul 2026 09:36:39 -0400 Subject: [PATCH 61/76] docs: correct the reference docs after the battle-path removals --- DEVELOPMENT.md | 5 +++-- backend/API.md | 16 ++++++++++------ indexer-go/README.md | 19 +++++++++++-------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 79876fb8..8df893f1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -61,8 +61,9 @@ do-not-stop/ ``` The Go indexer mirrors the backend's `RosterIndexer` (EVM subgraph polling + -Solana WebSocket push) into `pet_roster`/`battle_history`, and streams settled -battles to the backend over gRPC (`StreamLiveBattles`). Build/test/runbook: +Solana WebSocket push) into `pet_roster`, and serves pet-state reads and win +estimates to the backend over gRPC. It no longer touches `battle_history`, which +the backend writes from signed receipts. Build/test/runbook: `indexer-go/README.md`. It is optional in local dev — the Node indexers cover everything until promotion. diff --git a/backend/API.md b/backend/API.md index 313ced30..b1e4c2c5 100644 --- a/backend/API.md +++ b/backend/API.md @@ -219,13 +219,17 @@ matchup UI degrades to "odds unavailable". Intended for a single confirmed matchup, not per opponents row. Optional `samples` arg overrides the server default (clamped to 10,000). -### v2 battle data +### Battle data -Settled battles carry the round-based combat-sim outputs `indexer-go` now emits — -`loserPetId, seed (0x-hex), rounds, winnerHpRemaining, xpWin, xpLoss`. These flow -through the live `StreamLiveBattles` chain-truth feed (the `seed` re-runs the sim -client-side for blow-by-blow replay) and are persisted on `battle_history`; the -AI battle dialogue uses `rounds`/HP/XP to flavor its narration. +`battle_history` carries `loserPetId, seed (0x-hex), rounds, winnerHpRemaining, +xpWin, xpLoss` for every settled battle. The battle worker writes the row from the +signed receipt, in the receipt's own transaction, so a battle cannot be recorded +without its receipt. The AI battle dialogue reads `rounds`/HP/XP to flavor its +narration, and head-to-head/recent form for rivalry context. + +There is no chain-truth feed behind this any more: `indexer-go` stopped decoding +settle events when battles left the chain, and its `StreamLiveBattles` push is +gone. `foughtAt` is unix seconds. ### Settle keeper diff --git a/indexer-go/README.md b/indexer-go/README.md index 43c1db2d..fd3c481a 100644 --- a/indexer-go/README.md +++ b/indexer-go/README.md @@ -5,8 +5,12 @@ Unified cross-chain indexer for Cryptopets (see two chain adapters behind the `ChainIndexer` interface — Solana push (WebSocket subscriptions + backfill) and EVM pull (subgraph watermark polling) — feeding a single version-guarded writer into -the Prisma-owned Postgres, plus a `StreamLiveBattles` gRPC push to the Node -backend. +the Prisma-owned Postgres, plus a gRPC read path for the Node backend. + +It indexes the **roster only**. Battles left the chain in §L Phase 6, so there +are no settle events to decode; `battle_history` is written by the backend from +its own signed receipts. The `StreamLiveBattles` push and the whole `BattleEvent` +pipeline behind it are gone. ## Build & test @@ -32,9 +36,9 @@ connection settings is skipped, so adapters roll out independently. | Variable | Purpose | | --- | --- | | `DATABASE_URL` | Postgres (schema owned by `backend/prisma` — run migrations there first) | -| `EVM_SUBGRAPH_URL` | The Graph query endpoint (needs the `Battle` entity deployed) | +| `EVM_SUBGRAPH_URL` | The Graph query endpoint (needs the `Pet` entity deployed) | | `SOLANA_WS_URL` / `SOLANA_RPC_URL` / `SOLANA_PROGRAM_ID` | Helius endpoints + program id | -| `GRPC_ADDR` | StreamLiveBattles bind address (default `localhost:50051`) | +| `GRPC_ADDR` | GameDataService bind address (default `localhost:50051`) | | `EVM_POLL_INTERVAL` / `RECONCILE_INTERVAL` | pull tick / reconciliation scan | Prereq migrations (from `backend/`): `npx prisma migrate dev`. Beyond @@ -68,7 +72,7 @@ promotion. Connection env vars are set in the Render dashboard. ## Read cache (milestone 8) -`ROSTER_CACHE_ENABLED=true` serves `GetPetState` / `ListReadyOpponents` from a +`ROSTER_CACHE_ENABLED=true` serves `GetPetState` / `EstimateWin` from a write-through RAM copy of `pet_roster`: warmed from the table at startup, updated commit-then-cache by the single writer, version-guarded like the SQL. **Coherent only while indexer-go is the sole writer of the table** — enable at @@ -124,11 +128,10 @@ around the formula (not just the formula itself) is cross-language locked too. ``` cmd/indexer/ binary: adapters + writer + gRPC, or -scan-once internal/indexer/ ChainIndexer contract + pipeline types -internal/evm/ subgraph watermark adapter (pets + battles) +internal/evm/ subgraph watermark adapter (pets) internal/solana/ WS push adapter, Borsh decode, reconnect/backfill internal/store/ single version-guarded batch writer (pgx) internal/combat/ pure Go combat sim + independent verify (cross-chain parity via golden vectors) -internal/battlebus/ fan-out to gRPC stream subscribers -internal/grpcsrv/ StreamLiveBattles + reads + EstimateWin + VerifyBattle server +internal/grpcsrv/ GetPetState + EstimateWin + VerifyBattle server pb/ generated stubs (buf generate ../proto) ``` From 74c55508f0847f46222695512cb5b522a7a98b79 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 28 Jul 2026 09:54:06 -0400 Subject: [PATCH 62/76] feat(contracts): warn before reconciling onto an existing deployment --- contracts/ethereum/scripts/deploy.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/contracts/ethereum/scripts/deploy.ts b/contracts/ethereum/scripts/deploy.ts index 2edc54e7..7c5dcc16 100644 --- a/contracts/ethereum/scripts/deploy.ts +++ b/contracts/ethereum/scripts/deploy.ts @@ -81,7 +81,32 @@ async function deployToNetwork(networkName: string): Promise { JSON.stringify({ CryptoPetsV2Live: { entropyAddress: entropy.entropyAddress } }, null, 2) ); - const deployCmd = `pnpm hh ignition deploy ignition/modules/CryptoPetsV2Live.ts --network ${network.name} --parameters ${paramsPath}`; + // Ignition keys deployment state by id, defaulting to `chain-`. Re-running against + // an existing one RECONCILES with it: futures already deployed are kept, so the old + // proxies survive. That is wrong for the 2.0.0 contracts, whose storage layout is + // deliberately breaking (see GameLogic/PetCore) — reusing a proxy from before would + // leave it pointing at an implementation that reads its slots differently. + // + // So warn loudly and let the operator pass an explicit id, rather than guessing which + // they meant. + const deploymentId = process.argv.find((a) => a.startsWith('--deployment-id='))?.split('=')[1]; + const existingDeployment = join(process.cwd(), 'ignition', 'deployments', `chain-${network.chainId}`); + if (!deploymentId && existsSync(existingDeployment)) { + console.warn( + `\n⚠️ A deployment already exists for chain ${network.chainId}.\n` + + ` Ignition reconciles against it and keeps the existing proxies.\n` + + ` The 2.0.0 contracts change storage layout and are NOT upgrade-compatible,\n` + + ` so an existing proxy must not be reused.\n\n` + + ` For a fresh stack: pnpm deploy:${network.name} --deployment-id=\n` + ); + } + + const deployCmd = [ + 'pnpm hh ignition deploy ignition/modules/CryptoPetsV2Live.ts', + `--network ${network.name}`, + `--parameters ${paramsPath}`, + ...(deploymentId ? [`--deployment-id ${deploymentId}`] : []), + ].join(' '); console.log(`\n🚀 Deploying to ${networkName} (chain ${network.chainId})...`); try { From c441e24378f2a3adcacd8ec5fa1f611ce86ab49a Mon Sep 17 00:00:00 2001 From: heyradcode Date: Wed, 29 Jul 2026 17:18:31 -0400 Subject: [PATCH 63/76] cleanup: remove dead metrics and correct references to deleted code --- CLAUDE.md | 2 +- DEVELOPMENT.md | 13 ++++---- backend/env.example | 30 +++---------------- backend/prisma/schema.prisma | 5 ++-- contracts/ethereum/test/XpFormula.test.ts | 16 ++++++++-- frontend/src/hooks/battle/useBattlePanel.ts | 2 +- .../hooks/battle/useLiveBattleAnimation.ts | 2 +- indexer-go/internal/metrics/metrics.go | 9 +----- indexer-go/internal/metrics/metrics_test.go | 4 --- render.yaml | 12 ++++---- 10 files changed, 38 insertions(+), 57 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8d210530..c01b72b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,7 @@ pnpm build # compile contracts + build backend + frontend + we | `proto` | Protobuf/Buf | gRPC contract (`GameDataService`) between `indexer-go` and `backend` | ### Data flow -On-chain events (EVM via subgraph watermark polling, Solana via WebSocket push + backfill) are mirrored into Prisma-owned Postgres (`pet_roster`, `battle_history`) by **two parallel indexers**: the backend's built-in Node `RosterIndexer`, and the optional Go `indexer-go`. The Node indexer is the source of truth in local dev; `indexer-go` is promotable later and can additionally stream settled battles straight to the backend over gRPC (`StreamLiveBattles`, defined in `proto/cryptopets.proto`). If `indexer-go` is down, the backend circuit-breaks back to reading Postgres directly (`ROSTER_READ_SOURCE` env var controls `grpc` vs `postgres`). Frontend, mobile, and website all talk to the backend via REST + GraphQL; none of them read chain state directly. See `docs/architecture.md`, `backend/API.md`, `indexer-go/README.md`. +On-chain pet state (EVM via subgraph watermark polling, Solana via WebSocket push + backfill) is mirrored into Prisma-owned Postgres (`pet_roster`) by **`indexer-go`, which is the only indexer**. The backend's built-in Node `RosterIndexer` no longer exists — nothing in `backend/src` indexes chain state, so a local stack that needs a populated roster has to run `indexer-go`. `battle_history` is not indexed at all: the backend writes it from its own signed receipts. `indexer-go` also answers pet-state reads and win estimates over gRPC; if it is down the backend falls back to reading Postgres directly (`ROSTER_READ_SOURCE` controls `grpc` vs `postgres`, and matchmaking always reads Postgres). Frontend, mobile, and website all talk to the backend via REST + GraphQL; none of them read chain state directly. See `docs/architecture.md`, `backend/API.md`, `indexer-go/README.md`. Note: `docs/README.md` and `docs/architecture.md` link to `indexer-go/ARCHITECTURE.md`, which doesn't exist. The real doc is `indexer-go/README.md`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8df893f1..bfee2ec1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -60,12 +60,13 @@ do-not-stop/ └── scripts/ # Deployment automation ``` -The Go indexer mirrors the backend's `RosterIndexer` (EVM subgraph polling + -Solana WebSocket push) into `pet_roster`, and serves pet-state reads and win -estimates to the backend over gRPC. It no longer touches `battle_history`, which -the backend writes from signed receipts. Build/test/runbook: -`indexer-go/README.md`. It is optional in local dev — the Node indexers cover -everything until promotion. +The Go indexer (EVM subgraph polling + Solana WebSocket push) fills `pet_roster` +and serves pet-state reads and win estimates to the backend over gRPC. It does +not touch `battle_history`, which the backend writes from signed receipts. +Build/test/runbook: `indexer-go/README.md`. + +It is **not** optional if you need a populated roster: the backend's own +`RosterIndexer` is gone, so nothing else writes `pet_roster`. ## Development Workflow diff --git a/backend/env.example b/backend/env.example index 6fd5e133..f18ae3ed 100644 --- a/backend/env.example +++ b/backend/env.example @@ -18,32 +18,10 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # Local Postgres alternative (no SSL): # DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cryptopets?schema=public -# --- PvP roster indexer (→ pet_roster) --- -# Enable/disable the background indexer (default: true). -# INDEXER_ENABLED=true -# How often the periodic reconciliation scan runs, in ms (default: 30000). -# INDEXER_INTERVAL_MS=30000 - -# EVM: Substreams-powered subgraph on The Graph. Deploy from backend/indexing/evm/subgraph/. -# SUBGRAPH_URL_EVM=https://api.studio.thegraph.com/query//cryptopets-evm/ -# Legacy alias for SUBGRAPH_URL_EVM: -# SUBGRAPH_URL=https://api.studio.thegraph.com/query//cryptopets-evm/ -# EVM contract address — used by prepare-subgraph.mjs to generate subgraph config. -# If not set, the script auto-reads from Hardhat ignition/deployments. -# CRYPTOPETS_ADDRESS=0xd68c951132fda610062a03252d303f80cdb2379f - -# Solana: indexed directly via Helius (RPC reconciliation scan + push webhook), -# no separate indexer service. Set both to enable Solana indexing. -# HELIUS_RPC_URL : full Helius RPC URL incl. ?api-key= (devnet or mainnet host). -# SOLANA_PROGRAM_ID : CryptoPets program id (base58) whose accounts we index. -# HELIUS_RPC_URL=https://devnet.helius-rpc.com/?api-key= -# SOLANA_PROGRAM_ID=EVzXwxHqwbTLMxfTG3amCb2Sjwmy5A7hqR59GbrvEyV1 -# -# Shared secret Helius sends in the webhook Authorization header — set it both -# here and in the Helius webhook's "Authorization Header" so POST /api/webhooks/ -# helius can reject forged calls. Required in production when HELIUS_RPC_URL is -# set (the server refuses to boot otherwise); unset = accept all in dev only. -# HELIUS_WEBHOOK_SECRET= +# --- PvP roster (→ pet_roster) --- +# Nothing here: the backend has no indexer. `indexer-go` is the only writer of +# pet_roster and carries its own config (see indexer-go/README.md). The backend +# only reads the table, optionally through indexer-go's gRPC cache below. # --- AI battle dialogue (→ battle_dialogue) --- # Generates in-character pre-fight taunts + result banter via the Hugging Face diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 063bdaf4..29ba322a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -79,8 +79,9 @@ model BattleHistory { xpWin Int @default(0) @map("xp_win") xpLoss Int @default(0) @map("xp_loss") foughtAt BigInt @map("fought_at") // unix seconds (block/tx time) - /// Monotonic source version (Solana slot / EVM block timestamp). The - /// StreamLiveBattles gRPC replay resumes from `WHERE version > after_version`. + /// Monotonic source version, from when the indexer wrote these rows from chain + /// events. Receipt-written rows leave it at 0: nothing resumes from it any more + /// (the gRPC replay it served is gone), and the receipt chain is the ordering. version BigInt @default(0) createdAt DateTime @default(now()) @map("created_at") diff --git a/contracts/ethereum/test/XpFormula.test.ts b/contracts/ethereum/test/XpFormula.test.ts index 874c17c2..3099e748 100644 --- a/contracts/ethereum/test/XpFormula.test.ts +++ b/contracts/ethereum/test/XpFormula.test.ts @@ -5,9 +5,19 @@ import { describe, it } from "node:test"; import { fileURLToPath } from "node:url"; // Cross-chain parity fixture (plan §3.4, §7): contracts/test-vectors/xp.json pins the XP -// formula and same-opponent decay sequences shared by GameLogic._calcXp / -// PetCore.recordBattleOpponent (EVM) and settle_battle::calc_xp / -// PetAccount::record_battle_opponent (Solana). +// formula and the same-opponent decay sequences. +// +// Read what this actually does before trusting it. It reimplements the formula in +// TypeScript below and checks that against the fixture — it calls no contract, and cannot: +// `GameLogic._calcXp` is internal, and `PetCore.recordBattleOpponent` was removed with the +// on-chain battle path (§L Phase 6). So this pins the fixture's internal consistency, not +// the EVM implementation. +// +// The real validators of xp.json are `@cryptopets/protocol`'s +// tests/combat/xpGoldenVectors.test.ts (the live TS engine), indexer-go's +// combat_golden_test.go (the live Go verifier), and Anchor's frozen suite over +// `PetAccount::record_battle_opponent`, which is the only remaining port that actually +// settled battles on chain. const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/frontend/src/hooks/battle/useBattlePanel.ts b/frontend/src/hooks/battle/useBattlePanel.ts index 51ed1d2c..2b4ffd65 100644 --- a/frontend/src/hooks/battle/useBattlePanel.ts +++ b/frontend/src/hooks/battle/useBattlePanel.ts @@ -108,7 +108,7 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat const pendingBattleStartRef = useRef(false); // Latest live-replay outcome, read by handleSuccess (defined before `battle` // exists) for the mismatch check. Assigned during render each time `battle` - // updates, mirroring the onResolvedRef pattern in useEvmBattleFlow. + // updates, keeping the callback identity stable across renders. const liveReplayRef = useRef(null); const activeChainKind = capabilities.activeKind; diff --git a/frontend/src/hooks/battle/useLiveBattleAnimation.ts b/frontend/src/hooks/battle/useLiveBattleAnimation.ts index fd647b78..71a90223 100644 --- a/frontend/src/hooks/battle/useLiveBattleAnimation.ts +++ b/frontend/src/hooks/battle/useLiveBattleAnimation.ts @@ -22,7 +22,7 @@ export interface LiveBattleAnimationState { } /** - * Plays a combat-sim log (@cryptopets/protocol's combat engine, via useEvmBattleFlow's + * Plays a combat-sim log (@cryptopets/protocol's combat engine, via the verified receipt's * `liveReplay`) one strike at a time, exposing HP percentages and a flavor * line for the fighting scene. Presentation only — see useBattlePanel.ts for * the gate that keeps the result card off this animation and the diff --git a/indexer-go/internal/metrics/metrics.go b/indexer-go/internal/metrics/metrics.go index dcf926e0..d4b1f70d 100644 --- a/indexer-go/internal/metrics/metrics.go +++ b/indexer-go/internal/metrics/metrics.go @@ -1,6 +1,6 @@ // Package metrics is a minimal Prometheus-text-format registry for the // handful of signals the runbook watches: pipeline throughput per chain, -// flush health, reconnects, per-chain version lag, cache and stream state. +// flush health, reconnects, per-chain version lag, and cache state. // Hand-rolled on purpose — a few atomics beat pulling in client_golang for // a free-tier worker, and the exposition format is trivially stable. package metrics @@ -45,7 +45,6 @@ func (c *series) snapshot() map[string]int64 { var ( rosterUpdates = newSeries() // by chain - battles = newSeries() // by chain flushes = newSeries() flushRows = newSeries() flushErrors = newSeries() @@ -54,18 +53,15 @@ var ( cacheSize atomic.Int64 cacheWarm atomic.Int64 - streamSubscribers atomic.Int64 ) func RosterUpdate(chain string) { rosterUpdates.get(chain).Add(1) } -func Battle(chain string) { battles.get(chain).Add(1) } func Flush(rows int) { flushes.get("").Add(1); flushRows.get("").Add(int64(rows)) } func FlushError() { flushErrors.get("").Add(1) } func WSReconnect() { wsReconnects.get("").Add(1) } func SetLastVersion(chain string, v uint64) { lastVersion.get(chain).Store(int64(v)) } func SetCacheSize(n int) { cacheSize.Store(int64(n)) } func SetCacheWarm(warm bool) { cacheWarm.Store(b2i(warm)) } -func SetStreamSubscribers(n int) { streamSubscribers.Store(int64(n)) } func b2i(b bool) int64 { if b { @@ -81,8 +77,6 @@ func Handler() http.HandlerFunc { writeLabelled(w, "indexer_roster_updates_total", "counter", "Roster updates emitted into the pipeline", rosterUpdates) - writeLabelled(w, "indexer_battles_total", "counter", - "Settled battles emitted into the pipeline", battles) writeLabelled(w, "indexer_flushes_total", "counter", "Successful roster batch flushes", flushes) writeLabelled(w, "indexer_flush_rows_total", "counter", @@ -96,7 +90,6 @@ func Handler() http.HandlerFunc { writeGauge(w, "indexer_cache_pets", "Pets held in the roster read cache", cacheSize.Load()) writeGauge(w, "indexer_cache_warm", "1 when the read cache serves traffic", cacheWarm.Load()) - writeGauge(w, "indexer_stream_subscribers", "Live StreamLiveBattles consumers", streamSubscribers.Load()) } } diff --git a/indexer-go/internal/metrics/metrics_test.go b/indexer-go/internal/metrics/metrics_test.go index 99ac3d7b..bcb47011 100644 --- a/indexer-go/internal/metrics/metrics_test.go +++ b/indexer-go/internal/metrics/metrics_test.go @@ -12,14 +12,12 @@ func TestHandlerExposesAllSeries(t *testing.T) { RosterUpdate("evm") RosterUpdate("evm") RosterUpdate("solana") - Battle("solana") Flush(64) FlushError() WSReconnect() SetLastVersion("solana", 12345) SetCacheSize(7) SetCacheWarm(true) - SetStreamSubscribers(2) rec := httptest.NewRecorder() Handler()(rec, httptest.NewRequest("GET", "/metrics", nil)) @@ -28,7 +26,6 @@ func TestHandlerExposesAllSeries(t *testing.T) { for _, want := range []string{ `indexer_roster_updates_total{chain="evm"} 2`, `indexer_roster_updates_total{chain="solana"} 1`, - `indexer_battles_total{chain="solana"} 1`, "indexer_flushes_total 1", "indexer_flush_rows_total 64", "indexer_flush_errors_total 1", @@ -36,7 +33,6 @@ func TestHandlerExposesAllSeries(t *testing.T) { `indexer_last_version{chain="solana"} 12345`, "indexer_cache_pets 7", "indexer_cache_warm 1", - "indexer_stream_subscribers 2", } { if !strings.Contains(body, want) { t.Errorf("missing series %q in:\n%s", want, body) diff --git a/render.yaml b/render.yaml index e25d3e9c..53e3b81e 100644 --- a/render.yaml +++ b/render.yaml @@ -43,13 +43,15 @@ services: # Deployed as a web service (not a worker) so it stays on the free plan — # /healthz answers Render's checks and /metrics rides the same port via the # PORT env convention. Trade-off: free web services sleep when idle; the - # reconnect/backfill machinery is designed to recover the gap on wake, and - # the Node indexers remain the source of truth until promotion anyway. + # reconnect/backfill machinery is designed to recover the gap on wake. # - # gRPC (StreamLiveBattles / roster reads) between the two services needs + # This is the ONLY writer of pet_roster — the backend has no indexer — so an + # idle-sleeping instance means a stale roster, not a redundant one. + # + # gRPC (pet-state reads / win estimates) between the two services needs # private networking; if unavailable on this plan, leave INDEXER_GRPC_ADDR - # unset on the API service — indexing still works, the database is the - # contract. Do not copy localhost:50051 from a local .env onto the API. + # unset on the API service — the backend reads the database directly and + # matchmaking always does. Do not copy localhost:50051 from a local .env. # Set indexer connection env vars in the dashboard: # DATABASE_URL, EVM_SUBGRAPH_URL, SOLANA_WS_URL, SOLANA_RPC_URL, # SOLANA_PROGRAM_ID — and at promotion: ROSTER_CACHE_ENABLED=true. From 3af8404b8bee2d2c22960895aa0b9f899efe803c Mon Sep 17 00:00:00 2001 From: heyradcode Date: Wed, 29 Jul 2026 17:46:52 -0400 Subject: [PATCH 64/76] refactor(solana): delete open_to_challenges and level_band_width --- CLAUDE.md | 8 +- .../src/instructions/admin/config.rs | 11 --- .../src/instructions/admin/initialize.rs | 3 +- .../cryptopets/src/instructions/battle/mod.rs | 11 --- .../battle/set_open_to_challenges.rs | 51 ---------- .../src/instructions/breeding/settle_breed.rs | 1 - .../src/instructions/mint/settle_mint.rs | 1 - .../cryptopets/src/instructions/mod.rs | 2 - .../cryptopets/programs/cryptopets/src/lib.rs | 8 -- .../programs/cryptopets/src/state/global.rs | 22 ++--- .../programs/cryptopets/src/state/mod.rs | 10 +- .../programs/cryptopets/src/state/pet.rs | 6 -- .../solana/cryptopets/scripts/set-config.ts | 2 - .../solana/cryptopets/tests/cryptopets.ts | 19 +--- .../panels/battle/parts/battle-setup.tsx | 6 -- .../parts/open-to-challenges-toggle.tsx | 36 ------- indexer-go/internal/solana/decode_test.go | 14 +-- .../internal/solana/idl/cryptopets.json | 6 +- .../src/hooks/chains/solana/usePetActions.ts | 14 --- shared/src/hooks/index.ts | 2 - shared/src/hooks/useSetOpenToChallenges.ts | 39 -------- shared/src/types/pet.ts | 2 - shared/src/utils/pets/mapSolanaPet.ts | 1 - .../hooks/useSetOpenToChallenges.test.tsx | 93 ------------------- shared/tests/utils/pets/mapSolanaPet.test.ts | 8 -- 25 files changed, 31 insertions(+), 345 deletions(-) delete mode 100644 contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/mod.rs delete mode 100644 contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/set_open_to_challenges.rs delete mode 100644 frontend/src/components/pet/interactions/panels/battle/parts/open-to-challenges-toggle.tsx delete mode 100644 shared/src/hooks/useSetOpenToChallenges.ts delete mode 100644 shared/tests/hooks/useSetOpenToChallenges.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index c01b72b5..1c9bec9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,9 +130,13 @@ The EVM settle keeper (above) sends `settleBattle` from its own wallet, but unti ### Solana battles are retired too (§L Phase 6) `commit_battle`/`settle_battle`/`cancel_battle`, the `BattleRequest` account, the Solana settle keeper (`backend/src/features/settle-keeper-solana/`), and `GlobalState.battle_fee_lamports` are all gone. Solana battles now take the same backend-authoritative path as EVM ones, so `settle_breed`/`settle_mint` are the only remaining commit/settle flows, and both still require the player's own signature (their Metaplex Core mint CPI needs a real payer signature — see `docs/plan-realtime-battle-solana.md` Workstream S2 for why the keeper never generalized to them). -Two things deliberately stayed. `game/battle_sim.rs` and `game/xp.rs` have no caller left in the program but are **frozen, not deleted**: their golden-vector tests are what prove `contracts/test-vectors/{battle,xp}.json` still describe what actually settled on this chain. And `set_open_to_challenges` plus `PetAccount.open_to_challenges` remain as the owner's stated defender-consent preference — the program no longer reads the flag, so **nothing enforces it until the backend matchmaker does**. +One thing deliberately stayed: `game/battle_sim.rs` and `game/xp.rs` have no caller left in the program but are **frozen, not deleted**. With `CombatSim.sol` gone, their golden-vector tests are the *only* remaining independent witness that `contracts/test-vectors/{battle,xp}.json` describe what actually settled on chain — the two live ports are the things those vectors check, so they cannot vouch for them. -Account layout: removing `battle_fee_lamports` grew `GlobalState._reserved` back from 16 to 24 bytes, so `GlobalState::SPACE` and every preceding field offset are unchanged and no `CURRENT_ACCOUNT_VERSION` bump is needed. A live account reads the old fee value back as padding. `ErrorCode` did renumber, though: `#[error_code]` assigns codes sequentially from 6000, so dropping the battle variants shifted every code after them. +`set_open_to_challenges` and `PetAccount.open_to_challenges` were **deleted** (v7). `commit_battle` was the flag's only reader, and defender consent is now a wallet-signed `DefenseAuthorization` (§D) that `accept.service.ts` requires per battle — covering pet, level band, ruleset hash, validity window and daily cap. The flag protected nothing while a UI toggle still implied it did, which is worse than dead code. `GlobalState.level_band_width` went with it, matching the EVM removal of `GameConfig.levelBandWidth`. + +Both sat mid-struct, so every field after them moved: `CURRENT_ACCOUNT_VERSION` is now **7**, and it needs a redeploy plus `GlobalState` reinit and re-minted pets. `indexer-go/internal/solana/idl/cryptopets.json` is hand-edited to match, because it drives **positional Borsh decoding** — an IDL still listing `openToChallenges` would misalign every field from `xp` onward and corrupt silently. Re-diff it against the IDL `anchor build` generates. + +`GlobalState._reserved` has absorbed both removals — 16 → 24 for `battle_fee_lamports`, 24 → 26 for `level_band_width` — so `GlobalState::SPACE` never changes and the account's rent-exempt size stays put. `PetAccount::SPACE` shrank by 1 byte. `ErrorCode` renumbered when the battle variants went: `#[error_code]` assigns codes sequentially from 6000, so every code after them shifted. **Rust/Anchor changes here were written without a local toolchain (no `cargo`/`anchor`/`rustc`/`solana` on PATH in this environment) — run `anchor build` / `anchor test` before trusting them.** diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/config.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/config.rs index d5492202..adb25108 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/config.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/config.rs @@ -45,12 +45,6 @@ pub fn set_max_level(ctx: Context, value: u16) -> Result<()> { Ok(()) } -pub fn set_level_band_width(ctx: Context, value: u16) -> Result<()> { - ctx.accounts.global_state.level_band_width = value; - emit!(LevelBandWidthUpdated { value }); - Ok(()) -} - pub fn set_generation_cap(ctx: Context, value: u8) -> Result<()> { require!( (1..=MAX_GENERATION_CAP).contains(&value), @@ -188,11 +182,6 @@ pub struct MaxLevelUpdated { pub value: u16, } -#[event] -pub struct LevelBandWidthUpdated { - pub value: u16, -} - #[event] pub struct GenerationCapUpdated { pub value: u8, diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/initialize.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/initialize.rs index 1862003e..f9d484d6 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/initialize.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/admin/initialize.rs @@ -3,7 +3,7 @@ use crate::{ state::{ GlobalState, PetAccount, CURRENT_ACCOUNT_VERSION, DEFAULT_BASE_MINT_FEE_LAMPORTS, DEFAULT_BATTLE_COOLDOWN_SECONDS, DEFAULT_BREED_COOLDOWN_BASE_SECONDS, - DEFAULT_BREED_FEE_LAMPORTS, DEFAULT_GENERATION_CAP, DEFAULT_LEVEL_BAND_WIDTH, + DEFAULT_BREED_FEE_LAMPORTS, DEFAULT_GENERATION_CAP, DEFAULT_MARRIAGE_COOLDOWN_SECONDS, DEFAULT_MAX_LEVEL, DEFAULT_NEWBORN_COOLDOWN_SECONDS, DEFAULT_POOL_SIZE, DEFAULT_PROPOSAL_TTL_SECONDS, DEFAULT_RANDOMNESS_EXPIRY_SLOTS, DEFAULT_STUD_FEE_LAMPORTS, DEFAULT_TRAIN_COOLDOWN_SECONDS, DEFAULT_TRAIN_FEE_LAMPORTS, @@ -21,7 +21,6 @@ pub fn handler(ctx: Context, level_up_fee_lamports: u64) -> Result<( global_state.battle_cooldown_seconds = DEFAULT_BATTLE_COOLDOWN_SECONDS; global_state.randomness_expiry_slots = DEFAULT_RANDOMNESS_EXPIRY_SLOTS; global_state.max_level = DEFAULT_MAX_LEVEL; - global_state.level_band_width = DEFAULT_LEVEL_BAND_WIDTH; global_state.generation_cap = DEFAULT_GENERATION_CAP; global_state.breed_cooldown_base_seconds = DEFAULT_BREED_COOLDOWN_BASE_SECONDS; global_state.newborn_cooldown_seconds = DEFAULT_NEWBORN_COOLDOWN_SECONDS; diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/mod.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/mod.rs deleted file mode 100644 index e9137a75..00000000 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! The defender-consent `open_to_challenges` toggle (plan §3.5). -//! -//! The commit/settle/cancel battle instructions that used to live here are gone: battles -//! are resolved by the backend against a committed drand round and published as signed -//! receipts (docs/plan-backend-battle-architecture.md §L Phase 6), never on chain. The -//! combat simulator itself (`game::battle_sim`) stays, frozen, so every battle this -//! program did settle remains replayable. - -pub mod set_open_to_challenges; - -pub use set_open_to_challenges::*; diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/set_open_to_challenges.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/set_open_to_challenges.rs deleted file mode 100644 index 04c03d00..00000000 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/battle/set_open_to_challenges.rs +++ /dev/null @@ -1,51 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::{errors::ErrorCode, utils::metadata::core_asset_owner, state::PetAccount}; - -/// Defender consent (§3.5/§6 Solana #3): lets a pet's owner opt their pet out of (or -/// back into) being targeted as a defender. -/// -/// The on-chain battle path that enforced this flag is retired (§L Phase 6), so the -/// program itself no longer reads it. The flag stays as the owner's stated preference, -/// published on the pet account for the backend matchmaker to honour. -pub fn handler(ctx: Context, value: bool) -> Result<()> { - require_keys_eq!( - core_asset_owner(&ctx.accounts.pet_asset.to_account_info())?, - ctx.accounts.owner.key(), - ErrorCode::Unauthorized - ); - - let pet = &mut ctx.accounts.pet; - pet.open_to_challenges = value; - - emit!(OpenToChallengesUpdated { - pet_id: pet.id, - owner: ctx.accounts.owner.key(), - value, - }); - - Ok(()) -} - -#[event] -pub struct OpenToChallengesUpdated { - pub pet_id: u32, - pub owner: Pubkey, - pub value: bool, -} - -#[derive(Accounts)] -pub struct SetOpenToChallenges<'info> { - /// CHECK: this pet's Metaplex Core asset account; PDA seed for `pet` and source of - /// truth for ownership (plan §2.3/v2.1 Phase A). - #[account(owner = mpl_core::ID)] - pub pet_asset: UncheckedAccount<'info>, - - #[account( - mut, - seeds = [PetAccount::SEED, pet_asset.key().as_ref()], - bump = pet.bump, - )] - pub pet: Account<'info, PetAccount>, - pub owner: Signer<'info>, -} diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/breeding/settle_breed.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/breeding/settle_breed.rs index 5d16e9fb..4b7fb970 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/breeding/settle_breed.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/breeding/settle_breed.rs @@ -104,7 +104,6 @@ pub fn handler(ctx: Context) -> Result<()> { child.loss_count = 0; child.version = CURRENT_ACCOUNT_VERSION; child.bump = ctx.bumps.child; - child.open_to_challenges = true; child.set_name(&breed_request.name())?; // Phase 3 lineage (plan §4.2): record parentage and generation. The child starts diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/settle_mint.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/settle_mint.rs index 1833eccc..3c2033ab 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/settle_mint.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/settle_mint.rs @@ -60,7 +60,6 @@ pub fn handler(ctx: Context) -> Result<()> { pet.loss_count = 0; pet.version = CURRENT_ACCOUNT_VERSION; pet.bump = ctx.bumps.pet; - pet.open_to_challenges = true; pet.set_name(&mint_request.name())?; pet.generation = 0; diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mod.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mod.rs index 05f320e1..f56bf12f 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mod.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mod.rs @@ -4,14 +4,12 @@ //! exactly as it did when this directory was flat — no IDL or call-site impact. pub mod admin; -pub mod battle; pub mod breeding; pub mod marriage; pub mod mint; pub mod pet; pub use admin::*; -pub use battle::*; pub use breeding::*; pub use marriage::*; pub use mint::*; diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs b/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs index a382d957..002aca85 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs @@ -33,10 +33,6 @@ pub mod cryptopets { transfer_pet::handler(ctx) } - pub fn set_open_to_challenges(ctx: Context, value: bool) -> Result<()> { - set_open_to_challenges::handler(ctx, value) - } - pub fn pause(ctx: Context) -> Result<()> { pause::handler(ctx) } @@ -97,10 +93,6 @@ pub mod cryptopets { config::set_max_level(ctx, value) } - pub fn set_level_band_width(ctx: Context, value: u16) -> Result<()> { - config::set_level_band_width(ctx, value) - } - pub fn set_generation_cap(ctx: Context, value: u8) -> Result<()> { config::set_generation_cap(ctx, value) } diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/state/global.rs b/contracts/solana/cryptopets/programs/cryptopets/src/state/global.rs index f660a434..26e44ddb 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/state/global.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/state/global.rs @@ -18,10 +18,6 @@ pub const DEFAULT_RANDOMNESS_EXPIRY_SLOTS: u64 = 150; /// granting levels once a pet reaches this; `level_up` rejects further fee-paid levels too. pub const DEFAULT_MAX_LEVEL: u16 = 100; -/// Max allowed level gap between battle participants (§3.4, mirrors EVM -/// `GameConfig.levelBandWidth`). 100 effectively disables the check during dev/testing. -pub const DEFAULT_LEVEL_BAND_WIDTH: u16 = 100; - /// Max child generation for breeding (plan §4.1, mirrors EVM `GameConfig.generationCap`). pub const DEFAULT_GENERATION_CAP: u8 = 20; @@ -101,7 +97,6 @@ pub struct GlobalState { pub bump: u8, pub randomness_expiry_slots: u64, pub max_level: u16, - pub level_band_width: u16, /// Max child generation for breeding (plan §4.1, mirrors EVM `generationCap`). pub generation_cap: u8, /// Breed cooldown base in seconds; doubles per `breed_count`, capped at @@ -141,11 +136,15 @@ pub struct GlobalState { /// and `settle_breed` CPI into `mpl-core` to mint pet assets into it. pub collection: Pubkey, /// Reserved padding for fields added by future upgrades without moving any of the - /// above. It grew back from 16 to 24 when `battle_fee_lamports` was removed with the - /// on-chain battle path (§L Phase 6): the field sat immediately before this, so - /// reclaiming its 8 bytes here keeps every preceding offset and [`GlobalState::SPACE`] - /// exactly as deployed. A live account simply reads the old fee back as padding. - pub _reserved: [u8; 24], + /// above. Grown as retired fields were reclaimed — 16 → 24 for `battle_fee_lamports`, + /// 24 → 26 for `level_band_width` — so [`GlobalState::SPACE`] never changes and the + /// account's rent-exempt size stays put. + /// + /// The two removals differ in blast radius. `battle_fee_lamports` sat immediately before + /// this, so reclaiming it preserved every preceding offset. `level_band_width` sat + /// mid-struct, so every field after it moved: this account must be re-initialized + /// (see `CURRENT_ACCOUNT_VERSION` v7). + pub _reserved: [u8; 26], } impl GlobalState { @@ -160,7 +159,6 @@ impl GlobalState { + 1 /* bump */ + 8 /* randomness_expiry_slots */ + 2 /* max_level */ - + 2 /* level_band_width */ + 1 /* generation_cap */ + 8 /* breed_cooldown_base_seconds */ + 8 /* newborn_cooldown_seconds */ @@ -174,7 +172,7 @@ impl GlobalState { + 8 /* marriage_cooldown_seconds */ + 8 /* proposal_ttl_seconds */ + 32 /* collection */ - + 24; /* reserved */ + + 26; /* reserved */ } #[account] diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/state/mod.rs b/contracts/solana/cryptopets/programs/cryptopets/src/state/mod.rs index cfbb38a2..a648c892 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/state/mod.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/state/mod.rs @@ -42,7 +42,15 @@ pub use requests::*; /// ownership transfers happen as standard Core asset transfers through any wallet. Bumps /// `PetAccount::SPACE` (+32 bytes). Breaking; requires redeploy + reinit of pet accounts /// (`GlobalState`/`PlayerProfile` layouts unchanged). -pub const CURRENT_ACCOUNT_VERSION: u8 = 6; +/// v7: removes retired battle config and consent state. `PetAccount.open_to_challenges` +/// is gone — `commit_battle` was its only reader, and defender consent is now a +/// wallet-signed `DefenseAuthorization` (§D), so the flag protected nothing while still +/// appearing to. `GlobalState.level_band_width` goes with it, matching the EVM removal of +/// `GameConfig.levelBandWidth`. Both sat mid-struct, so every field after them moves: +/// breaking for `PetAccount` and `GlobalState` alike, requiring redeploy plus reinit of +/// `GlobalState` and re-mint of pets. `PetAccount::SPACE` shrinks 1 byte; +/// `GlobalState::SPACE` is held constant by growing its `_reserved`. +pub const CURRENT_ACCOUNT_VERSION: u8 = 7; /// PDA seed for the lamport-only fee vault (§6 Solana #5). Holds `level_up_fee_lamports` /// and future protocol fees; swept via `withdraw_fees`. diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/state/pet.rs b/contracts/solana/cryptopets/programs/cryptopets/src/state/pet.rs index 8e223c88..55b2c0b3 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/state/pet.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/state/pet.rs @@ -20,10 +20,6 @@ pub struct PetAccount { pub bump: u8, pub name: [u8; PetAccount::MAX_NAME_LEN], pub name_len: u8, - /// Interim defender-consent fix (§3.5/§6 Solana #3): when false, this pet cannot be - /// targeted as a defender. Owner-toggleable, defaults to true. Enforced by the - /// backend matchmaker, not by this program (§L Phase 6). - pub open_to_challenges: bool, /// XP toward the next level (§3.4); auto-levels via [`PetAccount::add_xp`] at `100 * level`. pub xp: u32, /// Most recent opponent's pet id (§3.4 same-opponent decay); `0` = no battles yet, since @@ -87,7 +83,6 @@ impl PetAccount { + 1 /* bump */ + Self::MAX_NAME_LEN /* name */ + 1 /* name_len */ - + 1 /* open_to_challenges */ + 4 /* xp */ + 4 /* last_opponent_id */ + 1 /* same_opponent_streak */ @@ -251,7 +246,6 @@ mod tests { bump: 0, name: [0u8; PetAccount::MAX_NAME_LEN], name_len: 0, - open_to_challenges: true, xp: 0, last_opponent_id: 0, same_opponent_streak: 0, diff --git a/contracts/solana/cryptopets/scripts/set-config.ts b/contracts/solana/cryptopets/scripts/set-config.ts index e518ca0b..571d2891 100644 --- a/contracts/solana/cryptopets/scripts/set-config.ts +++ b/contracts/solana/cryptopets/scripts/set-config.ts @@ -13,7 +13,6 @@ // battleCooldownSeconds — cooldown between battles (default: 5) // trainCooldownSeconds — cooldown between trains (default: 60) // trainXp — XP granted per train (default: 100) -// levelBandWidth — retired with the on-chain battle path; nothing reads it // maxLevel — hard level cap (default: 100) // generationCap — max breeding generation (default: 20) // newbornCooldownSeconds — post-breed battle lockout (default: 60) @@ -37,7 +36,6 @@ const KEY_TO_INSTRUCTION: Record = { battleCooldownSeconds: "setBattleCooldownSeconds", trainCooldownSeconds: "setTrainCooldownSeconds", trainXp: "setTrainXp", - levelBandWidth: "setLevelBandWidth", maxLevel: "setMaxLevel", generationCap: "setGenerationCap", newbornCooldownSeconds: "setNewbornCooldownSeconds", diff --git a/contracts/solana/cryptopets/tests/cryptopets.ts b/contracts/solana/cryptopets/tests/cryptopets.ts index de623fe9..6dd58f47 100644 --- a/contracts/solana/cryptopets/tests/cryptopets.ts +++ b/contracts/solana/cryptopets/tests/cryptopets.ts @@ -135,23 +135,6 @@ describe("cryptopets", () => { } expect(threw).to.be.true; }); - - // Audit finding: unlike every other SetConfig setter, set_level_band_width - // has no MAX_* bounds check (config.rs), so any u16 is accepted. Low - // priority -- nothing reads level_band_width since the on-chain battle path - // was retired, so an oversized value cannot brick anything -- but documented - // here so a future bounds check (and this test) can be added together. - it("set_level_band_width accepts any u16 (no bounds check)", async () => { - const value = 65535; - - await program.methods - .setLevelBandWidth(value) - .accounts({ globalState, admin: wallet.publicKey }) - .rpc(); - - const gs = await program.account.globalState.fetch(globalState); - expect(gs.levelBandWidth).to.equal(value); - }); }); describe("withdraw_fees", () => { @@ -173,7 +156,7 @@ describe("cryptopets", () => { // TODO (plan §4.3/§4.4): gacha mint (commit_mint/settle_mint), breeding // (commit_breed/settle_breed), and everything that depends on an existing pet - // (level_up, train, rename_pet, set_open_to_challenges, marriage, + // (level_up, train, rename_pet, marriage, // cancel_mint/cancel_breed, clear_stale_marriage, withdraw_stud_fees, // sync_metadata). All of these // need a pet, which only comes from settle_mint/settle_breed minting a diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx index 9d1025c0..e2fb1063 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx @@ -15,7 +15,6 @@ import { import { Tones } from '@constants/tones'; import { AuthActionButton } from '@components/common'; import Icon, { BattleIcon } from '@components/ui/icon'; -import OpenToChallengesToggle from './open-to-challenges-toggle'; import { opponentKey, shortAddress } from '../battle-utils'; import styles from '../index.module.css'; @@ -250,11 +249,6 @@ const BattleSetup: React.FC = ({ - -
⚔ {battleButtonLabel} diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/open-to-challenges-toggle.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/open-to-challenges-toggle.tsx deleted file mode 100644 index c34aa8e7..00000000 --- a/frontend/src/components/pet/interactions/panels/battle/parts/open-to-challenges-toggle.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { useChainCapabilities, useSetOpenToChallenges } from '@shared/core'; -import { useTxErrorToast } from '@hooks/useTxErrorToast'; - -type OpenToChallengesToggleProps = { - /** The selected fighter's pet id. */ - petId?: string; - /** Current openToChallenges value from the mapped Pet. undefined = not loaded yet. */ - currentValue?: boolean; -}; - -/** - * Lets the owner opt their Solana pet in or out of being targeted as a - * defender. Only rendered on Solana — EVM has no defender consent. - */ -const OpenToChallengesToggle: React.FC = ({ petId, currentValue }) => { - const { activeKind } = useChainCapabilities(); - const { toggle, isPending, error } = useSetOpenToChallenges(); - useTxErrorToast(error); - - if (activeKind !== 'solana' || !petId || currentValue === undefined) return null; - - return ( - - ); -}; - -export default OpenToChallengesToggle; diff --git a/indexer-go/internal/solana/decode_test.go b/indexer-go/internal/solana/decode_test.go index 03340c15..5b6e33b7 100644 --- a/indexer-go/internal/solana/decode_test.go +++ b/indexer-go/internal/solana/decode_test.go @@ -29,7 +29,6 @@ func TestBase58Encode(t *testing.T) { const ( fxVersion = 2 fxBump = 7 - fxOpenToChallenges = true fxXP = 250 fxLastOpponentID = 17 fxSameOpponentStrk = 1 @@ -71,8 +70,9 @@ func buildPetAccount(t *testing.T, id uint32, owner [32]byte, dna uint64, rarity copy(nameBuf[:], name) buf.Write(nameBuf[:]) buf.WriteByte(uint8(len(name))) - // v2 fields, in struct order. - writeBool(&buf, fxOpenToChallenges) + // v2 fields, in struct order. No open_to_challenges byte: the flag was removed with + // the on-chain battle path (§L Phase 6), so writing one here would shift every field + // after it and silently misalign the decode. _ = binary.Write(&buf, binary.LittleEndian, uint32(fxXP)) _ = binary.Write(&buf, binary.LittleEndian, uint32(fxLastOpponentID)) buf.WriteByte(fxSameOpponentStrk) @@ -96,14 +96,6 @@ func buildPetAccount(t *testing.T, id uint32, owner [32]byte, dna uint64, rarity return data } -func writeBool(buf *bytes.Buffer, v bool) { - if v { - buf.WriteByte(1) - return - } - buf.WriteByte(0) -} - func TestDecodePetAccount(t *testing.T) { layout, err := resolvePetLayout() if err != nil { diff --git a/indexer-go/internal/solana/idl/cryptopets.json b/indexer-go/internal/solana/idl/cryptopets.json index a3b400b0..6b88bb3e 100644 --- a/indexer-go/internal/solana/idl/cryptopets.json +++ b/indexer-go/internal/solana/idl/cryptopets.json @@ -81,10 +81,6 @@ "name": "nameLen", "type": "u8" }, - { - "name": "openToChallenges", - "type": "bool" - }, { "name": "xp", "type": "u32" @@ -154,4 +150,4 @@ } } ] -} \ No newline at end of file +} diff --git a/shared/src/hooks/chains/solana/usePetActions.ts b/shared/src/hooks/chains/solana/usePetActions.ts index 4de23904..e86be3c1 100644 --- a/shared/src/hooks/chains/solana/usePetActions.ts +++ b/shared/src/hooks/chains/solana/usePetActions.ts @@ -176,19 +176,6 @@ export const usePetActions = () => { }, }); - const setOpenToChallenges = useMutation({ - mutationFn: async (args: { petId: number; assetKey: string; value: boolean }) => { - const { program, programId, owner } = requireReady(); - const petAsset = new PublicKey(args.assetKey); - const [pet] = petPdaByAsset(programId, args.assetKey); - return program.methods - .setOpenToChallenges(args.value) - .accounts({ petAsset, pet, owner }) - .rpc(); - }, - onSuccess: invalidateProgramQueries, - }); - /** * Breed via Switchboard On-Demand VRF (commit + reveal), matching the EVM Chainlink flow. * For cross-owner breeding, pass `parent2AssetKey` and `parent2Owner`; for same-wallet @@ -237,7 +224,6 @@ export const usePetActions = () => { transferPet, withdrawStudFees, syncMetadata, - setOpenToChallenges, breedPets, breedSubPhase, walletPublicKey: signingWallet?.publicKey ?? null, diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index c4796cc5..f428eb90 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -12,8 +12,6 @@ export { usePendingBreed, type PendingBreed } from './chains/ethereum/usePending export { useBreedRelationCheck, type BreedRelationCheck } from './chains/ethereum/useBreedRelationCheck'; // Solana pending VRF requests — auto-resumes on next action; cancel available after randomness expiry. export { usePendingSolanaBreed, type PendingSolanaBreed } from './chains/solana/usePendingSolanaBreed'; -// Solana defender-consent toggle (openToChallenges). No-op on EVM. -export { useSetOpenToChallenges, type UseSetOpenToChallengesResult } from './useSetOpenToChallenges'; // Solana NFT metadata sync — re-publishes on-chain state to Metaplex Core attributes. No-op on EVM. export { useSyncMetadata, type UseSyncMetadataResult } from './useSyncMetadata'; // Solana stud fee earnings: balance query + withdraw_stud_fees action. diff --git a/shared/src/hooks/useSetOpenToChallenges.ts b/shared/src/hooks/useSetOpenToChallenges.ts deleted file mode 100644 index 58a7f87a..00000000 --- a/shared/src/hooks/useSetOpenToChallenges.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useCallback } from 'react'; -import { useChainCapabilities } from './useChainCapabilities'; -import { usePetActions } from './chains/solana/usePetActions'; -import { usePetList } from './usePetList'; - -export interface UseSetOpenToChallengesResult { - /** Toggle the pet's open-to-challenges flag. No-op on EVM. */ - toggle(petId: string, currentValue: boolean): Promise; - isPending: boolean; - error: Error | null; -} - -/** - * Wraps the Solana `setOpenToChallenges` program instruction. - * Looks up the pet's asset key from the local pet list. No-op on EVM — - * EVM has no defender consent; all pets are challengeable by default. - */ -export const useSetOpenToChallenges = (): UseSetOpenToChallengesResult => { - const { activeKind } = useChainCapabilities(); - const actions = usePetActions(); - const { pets } = usePetList(); - - const toggle = useCallback(async (petId: string, currentValue: boolean) => { - if (activeKind !== 'solana') return; - const pet = pets.find((p) => p.id === petId); - if (!pet?.assetKey) throw new Error(`Asset key not found for pet #${petId} — refresh and retry`); - await actions.setOpenToChallenges.mutateAsync({ - petId: Number(petId), - assetKey: pet.assetKey, - value: !currentValue, - }); - }, [activeKind, pets, actions.setOpenToChallenges]); - - return { - toggle, - isPending: actions.setOpenToChallenges.isPending, - error: actions.setOpenToChallenges.error as Error | null, - }; -}; diff --git a/shared/src/types/pet.ts b/shared/src/types/pet.ts index 43a63d70..ddeabbed 100644 --- a/shared/src/types/pet.ts +++ b/shared/src/types/pet.ts @@ -38,8 +38,6 @@ export interface Pet { spouseId?: number; /** Unix seconds until this pet may remarry after a divorce. Solana only. */ marriageCooldownUntil?: number; - /** Whether this pet can be targeted as a defender. Solana only; EVM has no defender consent. */ - openToChallenges?: boolean; } /** diff --git a/shared/src/utils/pets/mapSolanaPet.ts b/shared/src/utils/pets/mapSolanaPet.ts index 2cde3cca..9a177ba7 100644 --- a/shared/src/utils/pets/mapSolanaPet.ts +++ b/shared/src/utils/pets/mapSolanaPet.ts @@ -74,6 +74,5 @@ export const mapSolanaPet = (row: SolanaPetAccountRow): Pet => { trainReadyAt: toNumber(a.trainReadyTime) || undefined, spouseId: spouseId !== 0 ? spouseId : undefined, marriageCooldownUntil: toNumber(a.marriageCooldownUntil) || undefined, - openToChallenges: typeof a.openToChallenges === 'boolean' ? a.openToChallenges : undefined, }; }; diff --git a/shared/tests/hooks/useSetOpenToChallenges.test.tsx b/shared/tests/hooks/useSetOpenToChallenges.test.tsx deleted file mode 100644 index 7c81125d..00000000 --- a/shared/tests/hooks/useSetOpenToChallenges.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; - -// ---------- stubs ---------- -const setOpenToChallenges = { - mutateAsync: vi.fn().mockResolvedValue(undefined), - isPending: false, - error: null as Error | null, -}; -const actions = { setOpenToChallenges }; - -let activeKind: string = 'solana'; - -vi.mock('../../src/hooks/useChainCapabilities', () => ({ - useChainCapabilities: () => ({ activeKind }), -})); -vi.mock('../../src/hooks/chains/solana/usePetActions', () => ({ - usePetActions: () => actions, -})); - -const testPets = [ - { id: '1', assetKey: 'asset-key-1', name: 'Alpha' }, - { id: '2', assetKey: undefined, name: 'NoKey' }, -]; -vi.mock('../../src/hooks/usePetList', () => ({ - usePetList: () => ({ pets: testPets }), -})); - -import { useSetOpenToChallenges } from '../../src/hooks/useSetOpenToChallenges'; - -beforeEach(() => { - vi.clearAllMocks(); - activeKind = 'solana'; - setOpenToChallenges.mutateAsync.mockResolvedValue(undefined); - setOpenToChallenges.isPending = false; - setOpenToChallenges.error = null; -}); - -describe('useSetOpenToChallenges', () => { - it('calls setOpenToChallenges.mutateAsync with inverted value on Solana', async () => { - const { result } = renderHook(() => useSetOpenToChallenges()); - await act(async () => { await result.current.toggle('1', false); }); - expect(setOpenToChallenges.mutateAsync).toHaveBeenCalledWith({ - petId: 1, - assetKey: 'asset-key-1', - value: true, - }); - }); - - it('inverts currentValue=true to false', async () => { - const { result } = renderHook(() => useSetOpenToChallenges()); - await act(async () => { await result.current.toggle('1', true); }); - expect(setOpenToChallenges.mutateAsync).toHaveBeenCalledWith({ - petId: 1, - assetKey: 'asset-key-1', - value: false, - }); - }); - - it('is a no-op on EVM chain', async () => { - activeKind = 'evm'; - const { result } = renderHook(() => useSetOpenToChallenges()); - await act(async () => { await result.current.toggle('1', false); }); - expect(setOpenToChallenges.mutateAsync).not.toHaveBeenCalled(); - }); - - it('throws when assetKey is not found', async () => { - const { result } = renderHook(() => useSetOpenToChallenges()); - await expect( - act(async () => { await result.current.toggle('2', false); }) - ).rejects.toThrow(/asset key not found/i); - }); - - it('throws when petId is unknown', async () => { - const { result } = renderHook(() => useSetOpenToChallenges()); - await expect( - act(async () => { await result.current.toggle('999', false); }) - ).rejects.toThrow(/asset key not found/i); - }); - - it('reflects isPending from actions', () => { - setOpenToChallenges.isPending = true; - const { result } = renderHook(() => useSetOpenToChallenges()); - expect(result.current.isPending).toBe(true); - }); - - it('reflects error from actions', () => { - setOpenToChallenges.error = new Error('tx failed'); - const { result } = renderHook(() => useSetOpenToChallenges()); - expect(result.current.error?.message).toBe('tx failed'); - }); -}); diff --git a/shared/tests/utils/pets/mapSolanaPet.test.ts b/shared/tests/utils/pets/mapSolanaPet.test.ts index 364e7568..058c7831 100644 --- a/shared/tests/utils/pets/mapSolanaPet.test.ts +++ b/shared/tests/utils/pets/mapSolanaPet.test.ts @@ -118,12 +118,4 @@ describe('mapSolanaPet', () => { expect(pet.name).toBe(''); }); - it('maps openToChallenges boolean', () => { - expect(mapSolanaPet(row({ openToChallenges: true })).openToChallenges).toBe(true); - expect(mapSolanaPet(row({ openToChallenges: false })).openToChallenges).toBe(false); - }); - - it('omits openToChallenges when absent from account', () => { - expect(mapSolanaPet(row({})).openToChallenges).toBeUndefined(); - }); }); From 3e51f6121bd56e22eab0002666e6e861d002abff Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 1 Aug 2026 16:31:13 -0400 Subject: [PATCH 65/76] fix: read back the deployment just written, not chain- --- contracts/ethereum/scripts/deploy.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/contracts/ethereum/scripts/deploy.ts b/contracts/ethereum/scripts/deploy.ts index 7c5dcc16..53ba591c 100644 --- a/contracts/ethereum/scripts/deploy.ts +++ b/contracts/ethereum/scripts/deploy.ts @@ -112,7 +112,7 @@ async function deployToNetwork(networkName: string): Promise { try { execSync(deployCmd, { stdio: 'inherit', env: ignitionEnv }); console.log(`✅ ${networkName} deployed.`); - await injectContractAddresses(network); + await injectContractAddresses(network, deploymentId); } catch (error) { console.error( `❌ Deploy to ${networkName} failed:`, @@ -122,13 +122,17 @@ async function deployToNetwork(networkName: string): Promise { } } -async function injectContractAddresses(network: NetworkSpec): Promise { +async function injectContractAddresses(network: NetworkSpec, deploymentId?: string): Promise { try { + // Must read back the deployment we just wrote, not `chain-`. With an explicit + // --deployment-id those are different directories, and the default one holds the + // stack this deploy exists to replace — injecting from it would point the frontend + // at the dead pre-2.0.0 proxies while reporting success. const deployedAddressesPath = join( process.cwd(), 'ignition', 'deployments', - `chain-${network.chainId}`, + deploymentId ?? `chain-${network.chainId}`, 'deployed_addresses.json' ); From f1afe24fb4824767235ac345281059a5c1e67c85 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 1 Aug 2026 16:46:23 -0400 Subject: [PATCH 66/76] fix(subgraph): resolve addresses from the deployment actually in use --- contracts/ethereum/subgraph/scripts/prepare-subgraph.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/contracts/ethereum/subgraph/scripts/prepare-subgraph.mjs b/contracts/ethereum/subgraph/scripts/prepare-subgraph.mjs index 56f6b67a..8013fc99 100644 --- a/contracts/ethereum/subgraph/scripts/prepare-subgraph.mjs +++ b/contracts/ethereum/subgraph/scripts/prepare-subgraph.mjs @@ -10,6 +10,7 @@ * SUBGRAPH_START_BLOCK=12345678 # the v2 deploy block (the indexer reindexes from here) * PETCORE_ADDRESS=0x... # PetCore proxy * GAMELOGIC_ADDRESS=0x... # GameLogic proxy + * IGNITION_DEPLOYMENT_ID=name # deployment dir to read, when not the default chain- * * The v2 stack (PetCore + GameLogic) is two UUPS proxies — index the proxy * addresses, not the implementations. See ignition/modules/CryptoPetsV2Live.ts. @@ -62,16 +63,22 @@ function copyAbi(contractName, destName = contractName) { } // Reads a proxy address from the ignition deployment for the given module key. +// +// A chain can hold several deployments. `chain-` is only the default one, and on a +// chain that has been redeployed under an explicit --deployment-id it is the superseded +// stack — indexing those addresses yields a subgraph that syncs a dead contract and +// reports no error. IGNITION_DEPLOYMENT_ID selects the directory in that case. function loadIgnitionAddress(network, key) { const chainIds = { sepolia: 11155111, mainnet: 1, base: 8453, 'base-sepolia': 84532, localhost: 31337, hardhat: 31337 }; const chainId = chainIds[network]; if (!chainId) return null; + const deploymentDir = process.env.IGNITION_DEPLOYMENT_ID?.trim() || `chain-${chainId}`; const deployedPath = path.join( CONTRACTS_DIR, 'ignition', 'deployments', - `chain-${chainId}`, + deploymentDir, 'deployed_addresses.json' ); if (!fs.existsSync(deployedPath)) return null; From 6ea6eac6e78ac1a3df3d474f34709d62acb27409 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 1 Aug 2026 18:49:13 -0400 Subject: [PATCH 67/76] feat(backend): add a dev script to grant standing defence consent --- .../scripts/grant-defense-authorization.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 backend/scripts/grant-defense-authorization.ts diff --git a/backend/scripts/grant-defense-authorization.ts b/backend/scripts/grant-defense-authorization.ts new file mode 100644 index 00000000..823596f4 --- /dev/null +++ b/backend/scripts/grant-defense-authorization.ts @@ -0,0 +1,154 @@ +/** + * Dev tool: grant a standing DefenseAuthorization (§D) from a raw private key. + * + * The client half of standing consent does not exist yet, so a defender has no way to + * authorize anyone from the app, and every /accept fails with `no-authorization`. This + * signs the same EIP-712 payload the UI will eventually sign and posts it, so backend + * battles can be exercised end to end in the meantime. + * + * Not a substitute for the real flow: it needs the defender's raw key, which a player + * never hands over. Delete this once `useDefenseAuthorization` lands. + * + * Usage (from backend/): + * DEFENDER_PRIVATE_KEY=0x... pnpm tsx scripts/grant-defense-authorization.ts --pets 1 + * DEFENDER_PRIVATE_KEY=0x... pnpm tsx scripts/grant-defense-authorization.ts --all-pets + * + * Options: + * --pets 1,2 pet ids to authorize (omit with --all-pets) + * --all-pets authorize every pet the wallet owns + * --days 30 validity window, default 30 + * --max-per-day 50 daily battle cap, default 50 + * --min-level 1 lowest attacker level accepted, default 1 + * --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'; + +interface Options { + petIds: string[]; + allPets: boolean; + days: number; + maxPerDay: number; + minLevel: number; + maxLevel: number; + api: string; +} + +function parseArgs(argv: string[]): Options { + const get = (flag: string): string | undefined => { + const i = argv.indexOf(flag); + return i >= 0 ? argv[i + 1] : undefined; + }; + const allPets = argv.includes('--all-pets'); + const petsArg = get('--pets'); + if (!allPets && !petsArg) { + throw new Error('pass --pets or --all-pets'); + } + return { + petIds: petsArg ? petsArg.split(',').map((s) => s.trim()).filter(Boolean) : [], + allPets, + days: Number(get('--days') ?? '30'), + maxPerDay: Number(get('--max-per-day') ?? '50'), + minLevel: Number(get('--min-level') ?? '1'), + maxLevel: Number(get('--max-level') ?? '100'), + api: get('--api') ?? 'http://localhost:3001', + }; +} + +async function json(res: Response, what: string): Promise { + const body = await res.text(); + if (!res.ok) throw new Error(`${what} failed (${res.status}): ${body}`); + return JSON.parse(body) as T; +} + +/** Nonce, wallet signature, JWT — the same handshake the browser does. */ +async function authenticate(api: string, wallet: Wallet): Promise { + const nonceRes = await fetch(`${api}/api/auth/nonce`); + const { nonce } = await json<{ nonce: string }>(nonceRes, 'GET /api/auth/nonce'); + + const signature = await wallet.signMessage(`Sign this message to authenticate: ${nonce}`); + + const verifyRes = await fetch(`${api}/api/auth/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: wallet.address, signature, nonce }), + }); + const { token } = await json<{ token: string }>(verifyRes, 'POST /api/auth/verify'); + return token; +} + +async function main(): Promise { + const opts = parseArgs(process.argv.slice(2)); + + const key = process.env.DEFENDER_PRIVATE_KEY?.trim(); + if (!key) throw new Error('DEFENDER_PRIVATE_KEY is required'); + const wallet = new Wallet(key); + + // The ruleset hash and deployment must match what the server serves, or the + // authorization is rejected as wrong-deployment / never covers anything. + const configRes = await fetch(`${opts.api}/api/battle/config`); + const config = await json<{ + chainIds: string[]; + deploymentId: string; + 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 now = Math.floor(Date.now() / 1000); + const authorization = { + chainId, + deploymentId: config.deploymentId, + defenderOwner: wallet.address, + allPets: opts.allPets, + petIds: opts.allPets ? [] : opts.petIds, + rulesetHash: config.ruleset.hash, + minLevel: opts.minLevel, + maxLevel: opts.maxLevel, + maxBattlesPerDay: opts.maxPerDay, + notBefore: now, + expiresAt: now + opts.days * 86400, + revocationNonce: 0, + }; + + const typed = defenseAuthorizationTypedData({ + domain: { chainId, deploymentId: config.deploymentId }, + defenderOwner: wallet.address, + scope: opts.allPets + ? { kind: 'allPets' } + : { kind: 'pets', petIds: opts.petIds.map((id) => BigInt(id)) }, + rulesetHash: config.ruleset.hash as `0x${string}`, + minLevel: opts.minLevel, + maxLevel: opts.maxLevel, + maxBattlesPerDay: opts.maxPerDay, + notBefore: authorization.notBefore, + expiresAt: authorization.expiresAt, + revocationNonce: 0, + }); + const signature = await wallet.signTypedData(typed.domain, typed.types, typed.message); + + const token = await authenticate(opts.api, wallet); + const res = await fetch(`${opts.api}/api/battle/authorizations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ authorization, signature, signatureFormat: 'eip712' }), + }); + const { authorizationHash } = await json<{ authorizationHash: string }>( + res, + 'POST /api/battle/authorizations', + ); + + console.log(`granted by ${wallet.address}`); + console.log(` scope ${opts.allPets ? 'all pets' : `pets ${opts.petIds.join(', ')}`}`); + console.log(` levels ${opts.minLevel}-${opts.maxLevel}, max ${opts.maxPerDay}/day`); + console.log(` valid ${opts.days} days (until ${new Date(authorization.expiresAt * 1000).toISOString()})`); + console.log(` ruleset ${config.ruleset.hash}`); + console.log(` hash ${authorizationHash}`); +} + +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); From 741d756ae57658445ca027d8de4b99dfb77c0d24 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sat, 1 Aug 2026 18:59:33 -0400 Subject: [PATCH 68/76] feat(frontend): let owners grant standing defence consent from the app --- .../panels/defense/index.module.css | 43 ++++ .../pet/interactions/panels/defense/index.tsx | 130 +++++++++++ frontend/src/components/ui/icon/index.tsx | 2 + frontend/src/constants/interactionRoutes.ts | 10 +- frontend/src/pages/defense/index.tsx | 12 + frontend/src/router/app-routes/index.tsx | 2 + shared/src/hooks/index.ts | 4 + shared/src/hooks/useDefenseAuthorization.ts | 212 ++++++++++++++++++ .../hooks/useDefenseAuthorization.test.tsx | 186 +++++++++++++++ 9 files changed, 600 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/pet/interactions/panels/defense/index.module.css create mode 100644 frontend/src/components/pet/interactions/panels/defense/index.tsx create mode 100644 frontend/src/pages/defense/index.tsx create mode 100644 shared/src/hooks/useDefenseAuthorization.ts create mode 100644 shared/tests/hooks/useDefenseAuthorization.test.tsx diff --git a/frontend/src/components/pet/interactions/panels/defense/index.module.css b/frontend/src/components/pet/interactions/panels/defense/index.module.css new file mode 100644 index 00000000..ceea6e47 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/defense/index.module.css @@ -0,0 +1,43 @@ +/* CSS Module — class names are local; reference via `import styles from './index.module.css'`. + Defence-consent panel — pet checklist plus the terms being signed (emerald accent). + Shared chrome (.interface/.picker/.field/.action-controls) stays global in interactions.css. */ +.petList { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 220px; + overflow-y: auto; + margin-top: 10px; + padding: 10px; + border-radius: 10px; + border: 1px solid rgb(52 211 153 / 22%); + background: var(--cp-surface); +} + +.petRow { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.empty { + margin: 0; + opacity: 0.7; +} + +/* The window and cap are fixed rather than editable: this is the one prompt where the + owner decides who may challenge them, so it states the terms plainly instead of + offering knobs nobody asked for. */ +.terms { + margin: 14px 0 0; + font-size: 0.85rem; + line-height: 1.5; + opacity: 0.75; +} + +.error { + margin: 10px 0 0; + color: rgb(251 113 133); + font-size: 0.85rem; +} diff --git a/frontend/src/components/pet/interactions/panels/defense/index.tsx b/frontend/src/components/pet/interactions/panels/defense/index.tsx new file mode 100644 index 00000000..733c7e3e --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/defense/index.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import NeonButton from '@components/ui/neon-button'; +import { useChainCapabilities, useDefenseAuthorization, usePetList } from '@shared/core'; +import { useNotifyError } from '@hooks/useNotifyError'; +import Icon, { CheckIcon } from '@components/ui/icon'; +import { Tones } from '@constants/tones'; +import styles from './index.module.css'; + +export type DefensePanelProps = { + isStandaloneView?: boolean; +}; + +/** + * Standing defence consent (§D). + * + * Without a grant here a pet cannot be challenged at all — the backend refuses every + * battle whose defender has no covering authorization. It is signed once rather than + * per battle so opponents do not have to be online, and it is bound to the current + * ruleset, so a balance patch invalidates it and asks again. + */ +const DefensePanel: React.FC = ({ isStandaloneView = true }) => { + const { isConnected } = useChainCapabilities(); + const { pets } = usePetList(); + const notifyError = useNotifyError(); + const { grant, revoke, isPending, error } = useDefenseAuthorization(); + + const [allPets, setAllPets] = useState(true); + const [selected, setSelected] = useState([]); + const [success, setSuccess] = useState(null); + + const toggle = (id: string) => + setSelected((prev) => (prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id])); + + const handleGrant = async () => { + if (!isConnected) { + notifyError('Please connect your wallet first', undefined, 'defense-validation'); + return; + } + setSuccess(null); + const hash = await grant(allPets ? { allPets: true } : { petIds: selected }); + if (hash) { + setSuccess( + allPets + ? 'Every pet you own can now be challenged.' + : `${selected.length} pet${selected.length === 1 ? '' : 's'} can now be challenged.`, + ); + } + }; + + const handleRevoke = async () => { + setSuccess(null); + if (await revoke()) { + setSuccess('Consent withdrawn. Your pets can no longer be challenged.'); + } + }; + + const nothingChosen = !allPets && selected.length === 0; + + return ( + <> +
+ {!isStandaloneView && ( + <> +

🛡️ Allow Challenges

+

Let other players battle your pets while you are away.

+ + )} + +
+
+ +
+ + {!allPets && ( +
+ {pets.map((pet) => ( + + ))} + {pets.length === 0 &&

No pets to authorize yet.

} +
+ )} +
+ +

+ Valid 30 days, up to 50 battles per day. You can withdraw at any time, and a + rules change ends it automatically. +

+ +
+ + {isPending ? 'Signing...' : 'Allow Challenges'} + + + Withdraw + +
+ + {error &&

{error.message}

} +
+ + {success && ( +
+ + {success} +
+ )} + + ); +}; + +export default DefensePanel; diff --git a/frontend/src/components/ui/icon/index.tsx b/frontend/src/components/ui/icon/index.tsx index 6e8204cc..2ab064d3 100644 --- a/frontend/src/components/ui/icon/index.tsx +++ b/frontend/src/components/ui/icon/index.tsx @@ -12,6 +12,7 @@ import { GiPawPrint, GiQuillInk, GiSandsOfTime, + GiShield, GiSparkles, GiSpellBook, GiUpgrade, @@ -92,6 +93,7 @@ export { GiPawPrint as PawIcon, GiQuillInk as QuillIcon, GiSandsOfTime as HourglassIcon, + GiShield as ShieldIcon, GiSparkles as SparklesIcon, GiSpellBook as SpellbookIcon, GiUpgrade as LevelUpIcon, diff --git a/frontend/src/constants/interactionRoutes.ts b/frontend/src/constants/interactionRoutes.ts index 626f2d46..4fa48983 100644 --- a/frontend/src/constants/interactionRoutes.ts +++ b/frontend/src/constants/interactionRoutes.ts @@ -5,6 +5,7 @@ import { LevelUpIcon, MarriageIcon, QuillIcon, + ShieldIcon, TrainIcon, } from '@components/ui/icon'; @@ -15,7 +16,8 @@ export type InteractionAction = | 'levelup' | 'train' | 'marriage' - | 'changename'; + | 'changename' + | 'defense'; export type StandaloneInteractionHeader = { Icon: ComponentType<{ size?: number | string }>; @@ -43,6 +45,11 @@ export const STANDALONE_INTERACTION_HEADERS: Record< sub: 'Marry two pets to unlock cross-owner breeding', }, changename: { Icon: QuillIcon, label: 'Rename Pet', sub: "Change your pet's name" }, + defense: { + Icon: ShieldIcon, + label: 'Allow Challenges', + sub: 'Let others battle your pets while you are away', + }, }; /** Dashboard home (idle gallery). */ @@ -55,3 +62,4 @@ export const LEVELUP_PATH = '/levelup'; export const TRAIN_PATH = '/train'; export const MARRIAGE_PATH = '/marriage'; export const RENAME_PATH = '/rename'; +export const DEFENSE_PATH = '/defense'; diff --git a/frontend/src/pages/defense/index.tsx b/frontend/src/pages/defense/index.tsx new file mode 100644 index 00000000..03f4f47b --- /dev/null +++ b/frontend/src/pages/defense/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import InteractionStandalone from '@components/pet/interactions/standalone'; +import DefensePanel from '@components/pet/interactions/panels/defense'; + +/** Top-level `/defense` page — standing defence consent (standalone UI). */ +const DefensePage: React.FC = () => ( + + + +); + +export default DefensePage; diff --git a/frontend/src/router/app-routes/index.tsx b/frontend/src/router/app-routes/index.tsx index 0e31c219..2d29326a 100644 --- a/frontend/src/router/app-routes/index.tsx +++ b/frontend/src/router/app-routes/index.tsx @@ -13,6 +13,7 @@ const LevelUpPage = lazy(() => import('@pages/level-up')); const TrainPage = lazy(() => import('@pages/train')); const MarriagePage = lazy(() => import('@pages/marriage')); const RenamePage = lazy(() => import('@pages/rename')); +const DefensePage = lazy(() => import('@pages/defense')); // SCRATCH — remove after visual verification. const BattleOverlayPreview = lazy(() => import('@pages/__preview/battle-overlay-preview')); @@ -38,6 +39,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index f428eb90..bf190aad 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -75,6 +75,10 @@ export { type AcceptedBattle, type SubmitBattleIntentVars, } from './useSubmitBattleIntent'; +export { + useDefenseAuthorization, + type GrantDefenseVars, +} from './useDefenseAuthorization'; export { battleStateQueryKey, useBackendBattle, diff --git a/shared/src/hooks/useDefenseAuthorization.ts b/shared/src/hooks/useDefenseAuthorization.ts new file mode 100644 index 00000000..b3b58d95 --- /dev/null +++ b/shared/src/hooks/useDefenseAuthorization.ts @@ -0,0 +1,212 @@ +import { + defenseAuthorizationSolanaMessageBytes, + defenseAuthorizationTypedData, + type ChainId, + type DefenseAuthorization, +} from '@cryptopets/protocol'; +import { useCallback, useState } from 'react'; +import { useSignTypedData } from 'wagmi'; + +import { getSolanaAuthSigner } from '../auth/solanaAuthStore'; +import { useApiClient } from '../contexts/ApiClientContext'; +import { normalizeSolanaSignatureToBase58 } from '../utils/solana/signatureAuthCodec'; + +import { useActiveChain } from './useActiveChain'; +import { useBattleConfig } from './useBattleConfig'; + +/** + * Grants and withdraws standing defence consent (§D). + * + * Without one of these a pet cannot be challenged at all: `accept` refuses every battle + * whose defender has no covering authorization. It is signed once and lasts, rather than + * per battle, because demanding a live signature would restrict play to opponents who + * happen to be online. + * + * What the wallet shows is the full authorization, not a digest, because this is the one + * prompt where an owner decides who may challenge them, under which rules, and for how + * long. `@cryptopets/protocol` builds it: EIP-712 for EVM, a labelled message for Solana. + * + * Consent is bound to `rulesetHash`, so a balance patch invalidates outstanding grants + * instead of silently reinterpreting them. Expect to re-grant after one; that is what + * makes "I agreed to the old rules" not a dispute. + */ + +export interface GrantDefenseVars { + /** Pet ids to cover. Ignored when `allPets` is set. */ + petIds?: string[]; + /** Cover every pet the wallet owns, including ones acquired later. */ + allPets?: boolean; + /** Inclusive attacker-level band the defender accepts. Defaults to the full range. */ + minLevel?: number; + maxLevel?: number; + /** Ceiling on battles per day against this authorization. */ + maxBattlesPerDay?: number; + /** How long the grant stays valid. */ + days?: number; +} + +const DEFAULTS = { + minLevel: 1, + maxLevel: 100, + maxBattlesPerDay: 50, + days: 30, +} as const; + +interface GrantResponse { + authorizationHash: string; +} + +export function useDefenseAuthorization() { + const apiClient = useApiClient(); + const activeChain = useActiveChain(); + const { data: config } = useBattleConfig(); + const { signTypedDataAsync } = useSignTypedData(); + + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + const grant = useCallback( + async (vars: GrantDefenseVars): Promise => { + if (activeChain.kind === 'none') { + setError(new Error('connect a wallet before granting consent')); + return null; + } + if (!config) { + // Signing against a guessed ruleset would produce a grant that covers nothing: + // the hash is part of what is signed, and `accept` matches on it exactly. + setError(new Error('battle configuration is not loaded yet')); + return null; + } + const allPets = vars.allPets ?? false; + const petIds = allPets ? [] : (vars.petIds ?? []); + if (!allPets && petIds.length === 0) { + setError(new Error('choose at least one pet, or grant for all pets')); + return null; + } + + setIsPending(true); + setError(null); + try { + const chainId = chainIdFor(activeChain.kind, config.chainIds); + const now = Math.floor(Date.now() / 1000); + const authorization: DefenseAuthorization = { + domain: { chainId, deploymentId: config.deploymentId }, + defenderOwner: activeChain.address, + scope: allPets + ? { kind: 'allPets' } + : { kind: 'pets', petIds: petIds.map((id) => BigInt(id)) }, + rulesetHash: config.ruleset.hash as `0x${string}`, + minLevel: vars.minLevel ?? DEFAULTS.minLevel, + maxLevel: vars.maxLevel ?? DEFAULTS.maxLevel, + maxBattlesPerDay: vars.maxBattlesPerDay ?? DEFAULTS.maxBattlesPerDay, + notBefore: now, + expiresAt: now + (vars.days ?? DEFAULTS.days) * 86400, + revocationNonce: 0, + }; + + const { signature, signatureFormat } = + activeChain.kind === 'evm' + ? { signature: await signEvmAuthorization(authorization, signTypedDataAsync), signatureFormat: 'eip712' as const } + : { signature: await signSolanaAuthorization(authorization), signatureFormat: 'solana-message' as const }; + + const { data } = await apiClient.post('/api/battle/authorizations', { + authorization: toWire(authorization), + signature, + signatureFormat, + }); + return data.authorizationHash; + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + return null; + } finally { + setIsPending(false); + } + }, + [activeChain, apiClient, config, signTypedDataAsync], + ); + + /** + * Withdraws every authorization this wallet holds on the active chain. + * + * Deliberately unsigned: the failure mode of an unauthorized revocation is fewer + * battles, never more, and requiring a signature would strand an owner who lost + * their signing device. + */ + const revoke = useCallback(async (): Promise => { + if (activeChain.kind === 'none' || !config) { + setError(new Error('connect a wallet first')); + return false; + } + setIsPending(true); + setError(null); + try { + const chainId = chainIdFor(activeChain.kind, config.chainIds); + await apiClient.delete(`/api/battle/authorizations?chainId=${encodeURIComponent(chainId)}`); + return true; + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + return false; + } finally { + setIsPending(false); + } + }, [activeChain, apiClient, config]); + + return { grant, revoke, isPending, error }; +} + +/** Picks the served chain id matching the connected wallet's family. */ +function chainIdFor(kind: 'evm' | 'solana', servedChainIds: string[]): ChainId { + const prefix = kind === 'evm' ? 'eip155:' : 'solana:'; + const match = servedChainIds.find((candidate) => candidate.startsWith(prefix)); + if (!match) { + throw new Error(`this deployment serves no ${kind} chain (has ${servedChainIds.join(', ') || 'none'})`); + } + return match as ChainId; +} + +async function signEvmAuthorization( + authorization: DefenseAuthorization, + signTypedDataAsync: ReturnType['signTypedDataAsync'], +): Promise { + const typed = defenseAuthorizationTypedData(authorization); + return signTypedDataAsync({ + domain: typed.domain, + types: typed.types, + primaryType: typed.primaryType, + // Same narrowing as the intent's: the protocol types accounts as plain `string` so + // one shape can carry base58 Solana addresses, while wagmi wants EIP-712 `address` + // fields as `0x${string}`. This branch only runs for EVM, and the protocol builder + // throws for any other chain family, so these are 0x addresses by now. + message: typed.message as typeof typed.message & { + defenderOwner: `0x${string}`; + rulesetHash: `0x${string}`; + }, + }); +} + +async function signSolanaAuthorization(authorization: DefenseAuthorization): Promise { + const signer = getSolanaAuthSigner(); + if (!signer) { + throw new Error('no Solana signer is connected'); + } + const signed = await signer.signMessage(defenseAuthorizationSolanaMessageBytes(authorization)); + return normalizeSolanaSignatureToBase58(signed); +} + +/** Serializes an authorization for the wire: bigints become decimal strings, as JSON requires. */ +function toWire(authorization: DefenseAuthorization) { + return { + chainId: authorization.domain.chainId, + deploymentId: authorization.domain.deploymentId, + defenderOwner: authorization.defenderOwner, + allPets: authorization.scope.kind === 'allPets', + petIds: authorization.scope.kind === 'pets' ? authorization.scope.petIds.map((id) => id.toString()) : [], + rulesetHash: authorization.rulesetHash, + minLevel: authorization.minLevel, + maxLevel: authorization.maxLevel, + maxBattlesPerDay: authorization.maxBattlesPerDay, + notBefore: authorization.notBefore, + expiresAt: authorization.expiresAt, + revocationNonce: authorization.revocationNonce, + }; +} diff --git a/shared/tests/hooks/useDefenseAuthorization.test.tsx b/shared/tests/hooks/useDefenseAuthorization.test.tsx new file mode 100644 index 00000000..74e5ea71 --- /dev/null +++ b/shared/tests/hooks/useDefenseAuthorization.test.tsx @@ -0,0 +1,186 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { hashRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; + +const RULESET_HASH = hashRuleset(SOURCE_DEFAULT_RULESET); +const CONFIG = { + deploymentId: 'base-sepolia-v2', + chainIds: ['eip155:84532', 'solana:devnet'], + ruleset: { hash: RULESET_HASH, version: SOURCE_DEFAULT_RULESET.version }, +}; + +const chain = vi.hoisted(() => ({ + current: { kind: 'evm' as 'evm' | 'solana' | 'none', address: '0xabcdef0123456789abcdef0123456789abcdef01' }, +})); +const DEFENDER = '0xabcdef0123456789abcdef0123456789abcdef01'; +const signTypedDataAsync = vi.hoisted(() => vi.fn()); +const solanaSigner = vi.hoisted(() => ({ + current: null as null | { getAddress: () => string; signMessage: (m: Uint8Array) => Promise }, +})); +const post = vi.hoisted(() => vi.fn()); +const del = vi.hoisted(() => vi.fn()); +const configQuery = vi.hoisted(() => ({ current: undefined as unknown })); + +vi.mock('../../src/hooks/useActiveChain', () => ({ useActiveChain: () => chain.current })); +vi.mock('wagmi', () => ({ useSignTypedData: () => ({ signTypedDataAsync }) })); +vi.mock('../../src/auth/solanaAuthStore', () => ({ getSolanaAuthSigner: () => solanaSigner.current })); +vi.mock('../../src/contexts/ApiClientContext', () => ({ + useApiClient: () => ({ post, delete: del, get: vi.fn() }), +})); +vi.mock('../../src/hooks/useBattleConfig', () => ({ useBattleConfig: () => ({ data: configQuery.current }) })); + +import { useDefenseAuthorization } from '../../src/hooks/useDefenseAuthorization'; + +const AUTH_HASH = `0x${'55'.repeat(32)}`; + +beforeEach(() => { + vi.clearAllMocks(); + chain.current = { kind: 'evm', address: DEFENDER }; + configQuery.current = CONFIG; + solanaSigner.current = null; + signTypedDataAsync.mockResolvedValue(`0x${'33'.repeat(65)}`); + post.mockResolvedValue({ data: { authorizationHash: AUTH_HASH } }); + del.mockResolvedValue({ data: {} }); +}); + +describe('granting consent', () => { + it('signs the authorization and posts it, returning the hash', async () => { + const { result } = renderHook(() => useDefenseAuthorization()); + + let hash: string | null = null; + await act(async () => { + hash = await result.current.grant({ petIds: ['1'] }); + }); + + expect(hash).toBe(AUTH_HASH); + expect(signTypedDataAsync).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith('/api/battle/authorizations', expect.objectContaining({ + signatureFormat: 'eip712', + signature: `0x${'33'.repeat(65)}`, + })); + }); + + it('binds the grant to the served ruleset and deployment, never a guess', async () => { + const { result } = renderHook(() => useDefenseAuthorization()); + await act(async () => { + await result.current.grant({ petIds: ['1'] }); + }); + + const [, body] = post.mock.calls[0] as [string, { authorization: Record }]; + expect(body.authorization.rulesetHash).toBe(RULESET_HASH); + expect(body.authorization.deploymentId).toBe('base-sepolia-v2'); + expect(body.authorization.chainId).toBe('eip155:84532'); + expect(body.authorization.defenderOwner).toBe(DEFENDER); + }); + + it('serializes pet ids as decimal strings, since JSON has no bigint', async () => { + const { result } = renderHook(() => useDefenseAuthorization()); + await act(async () => { + await result.current.grant({ petIds: ['1', '42'] }); + }); + + const [, body] = post.mock.calls[0] as [string, { authorization: Record }]; + expect(body.authorization.petIds).toEqual(['1', '42']); + expect(body.authorization.allPets).toBe(false); + }); + + it('sends an empty pet list for an all-pets grant', async () => { + const { result } = renderHook(() => useDefenseAuthorization()); + await act(async () => { + await result.current.grant({ allPets: true }); + }); + + const [, body] = post.mock.calls[0] as [string, { authorization: Record }]; + expect(body.authorization.allPets).toBe(true); + expect(body.authorization.petIds).toEqual([]); + }); + + it('refuses an empty grant rather than signing one that covers nothing', async () => { + const { result } = renderHook(() => useDefenseAuthorization()); + await act(async () => { + expect(await result.current.grant({ petIds: [] })).toBeNull(); + }); + + expect(signTypedDataAsync).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + expect(result.current.error?.message).toMatch(/at least one pet/); + }); + + it('refuses before the config loads, so nothing is signed against a guessed ruleset', async () => { + configQuery.current = undefined; + const { result } = renderHook(() => useDefenseAuthorization()); + await act(async () => { + expect(await result.current.grant({ petIds: ['1'] })).toBeNull(); + }); + + expect(signTypedDataAsync).not.toHaveBeenCalled(); + expect(result.current.error?.message).toMatch(/configuration is not loaded/); + }); + + it('refuses without a connected wallet', async () => { + chain.current = { kind: 'none', address: '' }; + const { result } = renderHook(() => useDefenseAuthorization()); + await act(async () => { + expect(await result.current.grant({ petIds: ['1'] })).toBeNull(); + }); + + expect(post).not.toHaveBeenCalled(); + expect(result.current.error?.message).toMatch(/connect a wallet/); + }); + + it('surfaces a rejected signature instead of posting an unsigned grant', async () => { + signTypedDataAsync.mockRejectedValue(new Error('user rejected')); + const { result } = renderHook(() => useDefenseAuthorization()); + await act(async () => { + expect(await result.current.grant({ petIds: ['1'] })).toBeNull(); + }); + + expect(post).not.toHaveBeenCalled(); + expect(result.current.error?.message).toBe('user rejected'); + }); +}); + +describe('solana', () => { + it('signs a labelled message rather than typed data', async () => { + chain.current = { kind: 'solana', address: 'So11111111111111111111111111111111111111112' }; + solanaSigner.current = { + getAddress: () => 'So11111111111111111111111111111111111111112', + signMessage: vi.fn().mockResolvedValue(new Uint8Array(64).fill(7)), + }; + + const { result } = renderHook(() => useDefenseAuthorization()); + await act(async () => { + await result.current.grant({ petIds: ['1'] }); + }); + + expect(signTypedDataAsync).not.toHaveBeenCalled(); + expect(solanaSigner.current.signMessage).toHaveBeenCalledTimes(1); + const [, body] = post.mock.calls[0] as [string, { signatureFormat: string }]; + expect(body.signatureFormat).toBe('solana-message'); + }); + + it('fails clearly when no Solana signer is connected', async () => { + chain.current = { kind: 'solana', address: 'So11111111111111111111111111111111111111112' }; + solanaSigner.current = null; + + const { result } = renderHook(() => useDefenseAuthorization()); + await act(async () => { + expect(await result.current.grant({ petIds: ['1'] })).toBeNull(); + }); + expect(result.current.error?.message).toMatch(/no Solana signer/); + }); +}); + +describe('revoking', () => { + it('withdraws consent for the active chain without a signature', async () => { + const { result } = renderHook(() => useDefenseAuthorization()); + await act(async () => { + expect(await result.current.revoke()).toBe(true); + }); + + expect(signTypedDataAsync).not.toHaveBeenCalled(); + expect(del).toHaveBeenCalledWith('/api/battle/authorizations?chainId=eip155%3A84532'); + }); +}); From 13811e9e4bde2cd13c37389395135eb257fb4659 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 2 Aug 2026 09:17:13 -0400 Subject: [PATCH 69/76] fix(backend): keep the signing key window at its first-signing time --- .../features/battle-signer/signer.service.ts | 12 ++ .../battle-signer/signer.persistence.test.ts | 103 ++++++++++++++++++ frontend/src/hooks/battle/useBattlePanel.ts | 9 +- .../tests/hooks/battle/useBattlePanel.test.ts | 2 + image-generator/src/integration.test.ts | 4 +- image-generator/src/solana.test.ts | 12 +- image-generator/src/solana.ts | 1 - mobile/__mocks__/walletconnectCompat.js | 9 ++ mobile/__tests__/App.test.tsx | 25 +++++ mobile/jest.config.js | 5 + shared/src/hooks/useSubmitBattleIntent.ts | 7 +- shared/src/utils/battleFailureMessage.ts | 98 +++++++++++++++++ shared/src/utils/index.ts | 1 + .../tests/utils/battleFailureMessage.test.ts | 66 +++++++++++ 14 files changed, 343 insertions(+), 11 deletions(-) create mode 100644 backend/tests/features/battle-signer/signer.persistence.test.ts create mode 100644 mobile/__mocks__/walletconnectCompat.js create mode 100644 shared/src/utils/battleFailureMessage.ts create mode 100644 shared/tests/utils/battleFailureMessage.test.ts diff --git a/backend/src/features/battle-signer/signer.service.ts b/backend/src/features/battle-signer/signer.service.ts index 4be80e73..ae181d49 100644 --- a/backend/src/features/battle-signer/signer.service.ts +++ b/backend/src/features/battle-signer/signer.service.ts @@ -118,6 +118,18 @@ export async function loadPersistedSigningKeys(): Promise { for (const key of stored) { if (key.keyId !== active?.keyId) { rotatedKeys.push(key); + continue; + } + // Adopt the persisted `notBefore` for the active key. `configureSigner` stamps it + // with the current time, because a brand new key really does become valid now — but + // on every restart after the first that is a *later* time than the key actually + // started signing, and `persistSigningKey` deliberately never overwrites the stored + // one. Publishing the in-memory value instead would move the key's validity window + // 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 } }; } } } diff --git a/backend/tests/features/battle-signer/signer.persistence.test.ts b/backend/tests/features/battle-signer/signer.persistence.test.ts new file mode 100644 index 00000000..36b18f99 --- /dev/null +++ b/backend/tests/features/battle-signer/signer.persistence.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const envMock = vi.hoisted(() => ({ + isProduction: false, + battleSigner: { + keyId: 'battle-signer-test', + privateKey: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as string | undefined, + kmsProvider: undefined as string | undefined, + requiredAttesters: ['typescript-engine'] as string[], + }, +})); + +vi.mock('@config/env', () => ({ env: envMock })); +vi.mock('@config/prisma', () => ({ + prisma: { battleSigningKey: { upsert: vi.fn(), findMany: vi.fn() } }, +})); + +import { prisma } from '@config/prisma'; +import { + activeSigningKey, + configureSigner, + listSigningKeys, + loadPersistedSigningKeys, + resetSigner, +} from '@features/battle-signer'; + +/** When this deployment first started signing — well before any of the restarts below. */ +const FIRST_BOOT = 1_700_000_000; +const MUCH_LATER = FIRST_BOOT + 90_000; + +function storedRow(overrides: Record = {}) { + return { + keyId: 'battle-signer-test', + algorithm: 'secp256k1', + publicKey: `0x04${'11'.repeat(64)}`, + address: `0x${'ab'.repeat(20)}`, + notBefore: BigInt(FIRST_BOOT), + notAfter: null, + compromised: false, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + resetSigner(); + vi.mocked(prisma.battleSigningKey.upsert).mockResolvedValue({} as never); +}); + +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); + + vi.mocked(prisma.battleSigningKey.findMany).mockResolvedValue([storedRow()] as never); + await loadPersistedSigningKeys(); + + expect(activeSigningKey()?.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 loadPersistedSigningKeys(); + + const published = listSigningKeys().find((k) => k.keyId === 'battle-signer-test')!; + // The check a verifier runs: was the key valid when the receipt was created? + expect(signedAt).toBeGreaterThanOrEqual(published.notBefore); + }); + + 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 loadPersistedSigningKeys(); + + expect(activeSigningKey()?.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 loadPersistedSigningKeys(); + + expect(activeSigningKey()?.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 loadPersistedSigningKeys(); + + expect(vi.mocked(prisma.battleSigningKey.upsert)).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/hooks/battle/useBattlePanel.ts b/frontend/src/hooks/battle/useBattlePanel.ts index 2b4ffd65..fe8cc13c 100644 --- a/frontend/src/hooks/battle/useBattlePanel.ts +++ b/frontend/src/hooks/battle/useBattlePanel.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { getReadyPetsUnified, + isBattleRejection, useChainCapabilities, useBattlePets, useBattleTaunts, @@ -217,7 +218,13 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat }); // Receipt errors are folded into `battle.error` by the chain adapter. - usePetErrorToast(battle.error, null, validationError, BATTLE_FAIL_MESSAGE); + // + // A server refusal goes in through the validation slot rather than the mutation one: + // `usePetError` returns a validation message verbatim, while a mutation error is run + // through the chain adapter's parser, which rewrites anything it does not recognise + // into a generic "Transaction failed" and loses the reason the server gave. + const rejectionMessage = isBattleRejection(battle.error) ? battle.error.message : null; + usePetErrorToast(battle.error, null, validationError ?? rejectionMessage, BATTLE_FAIL_MESSAGE); const canRandomMatch = Boolean(selectedFighter) && opponents.length > 0 && !opponentsLoading; // Chain-blind: a battle is seeded from a committed drand round on either chain, so diff --git a/frontend/tests/hooks/battle/useBattlePanel.test.ts b/frontend/tests/hooks/battle/useBattlePanel.test.ts index cdb1f0ea..f3d3bab0 100644 --- a/frontend/tests/hooks/battle/useBattlePanel.test.ts +++ b/frontend/tests/hooks/battle/useBattlePanel.test.ts @@ -40,6 +40,8 @@ const pets = [{ id: 'p1', name: 'Rex', level: 3, winCount: 1, lossCount: 0, chai const opponents = [{ id: 'opp1', name: 'Blaze', owner: '0xopp', level: 2 }]; vi.mock('@shared/core', () => ({ + isBattleRejection: (e: unknown) => + typeof e === 'object' && e !== null && (e as { isBattleRejection?: unknown }).isBattleRejection === true, getReadyPetsUnified: (p: { id: string }[]) => p.map((x) => ({ id: x.id, pet: x })), useChainCapabilities: () => ({ activeKind: 'evm', randomness: { provider: 'vrf' } }), usePetList: () => ({ pets, refetch: vi.fn(), isLoading: false }), diff --git a/image-generator/src/integration.test.ts b/image-generator/src/integration.test.ts index 250925f5..76229388 100644 --- a/image-generator/src/integration.test.ts +++ b/image-generator/src/integration.test.ts @@ -120,8 +120,8 @@ describe('solana, over a real socket', () => { expect(calls[0]!.method).toBe('getProgramAccounts'); expect((calls[0]!.params[1] as { filters: unknown }).filters).toEqual([ - { dataSize: 224 }, - { memcmp: { offset: 184, bytes: ASSET } }, + { dataSize: 223 }, + { memcmp: { offset: 183, bytes: ASSET } }, ]); }); diff --git a/image-generator/src/solana.test.ts b/image-generator/src/solana.test.ts index 7d0408fd..677cb47e 100644 --- a/image-generator/src/solana.test.ts +++ b/image-generator/src/solana.test.ts @@ -66,8 +66,8 @@ const accountsFor = (data: Buffer) => [{ account: { data: [data.toString('base64 describe('PetAccount layout', () => { // Pinned against contracts/solana/.../state/pet.rs's PetAccount::SPACE. If // this fails the Rust struct changed, and every offset below it has moved. - it('totals the 224 bytes the Rust SPACE constant declares', () => { - expect(PET_ACCOUNT_SPACE).toBe(224); + it('totals the 223 bytes the Rust SPACE constant declares', () => { + expect(PET_ACCOUNT_SPACE).toBe(223); }); it('places the fields the decoder reads where the Rust struct puts them', () => { @@ -77,8 +77,8 @@ describe('PetAccount layout', () => { expect(OFFSET.dna).toBe(44); expect(OFFSET.rarity).toBe(52); expect(OFFSET.name).toBe(69); - expect(OFFSET.speciesId).toBe(138); - expect(OFFSET.asset).toBe(184); + expect(OFFSET.speciesId).toBe(137); + expect(OFFSET.asset).toBe(183); }); it('transcribes every field, so no gap is silently skipped', () => { @@ -143,8 +143,8 @@ describe('SolanaPetReader', () => { expect(body.method).toBe('getProgramAccounts'); expect(body.params[0]).toBe(PROGRAM); expect(body.params[1].filters).toEqual([ - { dataSize: 224 }, - { memcmp: { offset: 184, bytes: ASSET } }, + { dataSize: 223 }, + { memcmp: { offset: 183, bytes: ASSET } }, ]); }); diff --git a/image-generator/src/solana.ts b/image-generator/src/solana.ts index f325efe6..640fc576 100644 --- a/image-generator/src/solana.ts +++ b/image-generator/src/solana.ts @@ -52,7 +52,6 @@ export const FIELD_SIZES: readonly (readonly [name: string, bytes: number])[] = ['bump', 1], ['name', 32], ['nameLen', 1], - ['openToChallenges', 1], ['xp', 4], ['lastOpponentId', 4], ['sameOpponentStreak', 1], diff --git a/mobile/__mocks__/walletconnectCompat.js b/mobile/__mocks__/walletconnectCompat.js new file mode 100644 index 00000000..23314169 --- /dev/null +++ b/mobile/__mocks__/walletconnectCompat.js @@ -0,0 +1,9 @@ +/** + * Stub for `@walletconnect/react-native-compat`. + * + * That package is side-effect-only polyfills (TextEncoder, crypto.getRandomValues, + * URL, Buffer) shipped as ESM importing a `.ts` path, which Metro bundles but jest + * cannot parse. Nothing under test reads from it, so a stub is enough — and it keeps + * the alternative, transforming a dependency's TypeScript, out of the test setup. + */ +module.exports = {}; diff --git a/mobile/__tests__/App.test.tsx b/mobile/__tests__/App.test.tsx index e532f701..2bcd04de 100644 --- a/mobile/__tests__/App.test.tsx +++ b/mobile/__tests__/App.test.tsx @@ -1,9 +1,34 @@ /** * @format + * + * App.tsx is provider composition and nothing else, so rendering it for real would boot + * wagmi, AppKit, @solana/web3.js and the whole shared stack — none of which parse under + * jest without transforming a large slice of node_modules. The providers are stubbed to + * pass children through, which leaves the thing actually worth checking here: that App's + * imports all resolve and its tree renders without throwing. */ import React from 'react'; import ReactTestRenderer from 'react-test-renderer'; + +const passthrough = ({children}: {children?: React.ReactNode}) => <>{children}; + +jest.mock('@reown/appkit-react-native', () => ({AppKitProvider: passthrough})); +jest.mock('wagmi', () => ({WagmiProvider: passthrough})); +jest.mock('@tanstack/react-query', () => ({QueryClientProvider: passthrough})); +jest.mock('@shared/core', () => ({ + queryClient: {}, + ApiClientProvider: passthrough, + AuthProvider: passthrough, +})); +jest.mock('../src/AppKitConfig', () => ({appKit: {}, wagmiConfig: {}})); +jest.mock('../src/solana/SolanaAppKitAnchorBridge', () => ({ + SolanaAppKitAnchorBridge: passthrough, +})); +jest.mock('../src/AppContent.tsx', () => () => null); +// Reaches AsyncStorage (a native module) at import time, just to read API_URL. +jest.mock('../config', () => ({API_URL: 'http://localhost:3001'})); + import App from '../App'; test('renders correctly', async () => { diff --git a/mobile/jest.config.js b/mobile/jest.config.js index 8eb675e9..80b7ab06 100644 --- a/mobile/jest.config.js +++ b/mobile/jest.config.js @@ -1,3 +1,8 @@ module.exports = { preset: 'react-native', + moduleNameMapper: { + // Side-effect-only polyfills shipped as ESM importing a `.ts` path: Metro bundles + // it, jest cannot parse it, and nothing under test reads from it. + '^@walletconnect/react-native-compat$': '/__mocks__/walletconnectCompat.js', + }, }; diff --git a/shared/src/hooks/useSubmitBattleIntent.ts b/shared/src/hooks/useSubmitBattleIntent.ts index a80693d6..e84da6fb 100644 --- a/shared/src/hooks/useSubmitBattleIntent.ts +++ b/shared/src/hooks/useSubmitBattleIntent.ts @@ -9,6 +9,7 @@ import { useSignTypedData } from 'wagmi'; import { getSolanaAuthSigner } from '../auth/solanaAuthStore'; import { useApiClient } from '../contexts/ApiClientContext'; +import { toBattleRejection } from '../utils/battleFailureMessage'; import { saveBattleEvidence, type BattleEvidence } from '../utils/battleEvidence'; import { normalizeSolanaSignatureToBase58 } from '../utils/solana/signatureAuthCodec'; @@ -119,7 +120,11 @@ export function useSubmitBattleIntent() { return accepted; } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); + // Both controllers answer with a precise reason. Surfacing it here rather + // than letting the raw Axios error through is what lets the UI say which + // of a dozen refusals happened — they are otherwise indistinguishable. + const rejection = toBattleRejection(err); + setError(rejection ?? (err instanceof Error ? err : new Error(String(err)))); return null; } finally { setIsPending(false); diff --git a/shared/src/utils/battleFailureMessage.ts b/shared/src/utils/battleFailureMessage.ts new file mode 100644 index 00000000..2479f909 --- /dev/null +++ b/shared/src/utils/battleFailureMessage.ts @@ -0,0 +1,98 @@ +/** + * Turns a battle submit/accept rejection into something a player can act on. + * + * The backend already answers with a precise reason (`no-authorization`, + * `attacker-level-below-band`, …) and the right status code. Without this the whole + * payload is buried inside an Axios error, every failure looks identical on screen, + * and the only way to tell "this opponent has not allowed challenges" from "your pet + * is on cooldown" is to read the server's database. + * + * Reasons come from `intent.controller.ts` and `accept.controller.ts`. An unmapped + * code falls through to its own text rather than a generic message, so a reason added + * server-side degrades to something still diagnosable instead of disappearing. + */ + +const MESSAGES: Record = { + // Submitting the intent. + 'malformed-intent': 'That battle request was malformed. Try again.', + 'wrong-deployment': 'This app is pointed at a different deployment than the server. Reload the page.', + 'wallet-mismatch': 'The signing wallet does not match the one you are signed in with.', + 'wrong-signature-format': 'That signature format was not recognised.', + 'bad-signature': 'The signature could not be verified. Try again.', + 'unknown-pet': 'One of those pets is not on record yet.', + 'not-pet-owner': 'You can only attack with a pet you own.', + 'self-battle': 'A pet cannot battle itself.', + 'nonce-already-used': 'That battle request was already used. Try again.', + 'duplicate-intent': 'That battle request was already submitted.', + + // Accepting it. + 'intent-already-consumed': 'That battle has already started.', + 'attacker-not-ready': 'Your pet is still on cooldown.', + 'defender-not-ready': 'That opponent is still on cooldown.', + 'not-yet-valid': 'This opponent is not accepting challenges yet.', + expired: 'That battle request expired. Try again.', + 'no-authorization': "This opponent's owner has not allowed challenges yet.", + 'pet-not-covered': "That pet is not covered by its owner's challenge settings.", + 'attacker-level-below-band': 'Your pet is too low level for this opponent.', + 'attacker-level-above-band': 'Your pet is too high level for this opponent.', + 'ruleset-mismatch': "This opponent's consent was signed under older rules. They need to re-allow challenges.", + revoked: 'This opponent has withdrawn consent to be challenged.', + 'daily-cap-reached': 'This opponent has hit their battle limit for today.', +}; + +/** Shape of the error body both battle controllers return. */ +interface RejectionBody { + error?: unknown; + detail?: unknown; +} + +function rejectionCode(err: unknown): string | null { + if (typeof err !== 'object' || err === null) return null; + const response = (err as { response?: { data?: RejectionBody } }).response; + const code = response?.data?.error; + return typeof code === 'string' && code.length > 0 ? code : null; +} + +/** + * A refusal the server explained, as opposed to a chain or network failure. + * + * Tagged rather than a bare Error because the EVM adapter's `parseError` rewrites any + * message it does not recognise into a generic "Transaction failed", which would throw + * away the reason. UIs test for this and show `message` directly. + */ +export class BattleRejectionError extends Error { + readonly isBattleRejection = true as const; + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'BattleRejectionError'; + this.code = code; + } +} + +/** Structural guard — survives duplicate module instances, unlike `instanceof`. */ +export function isBattleRejection(err: unknown): err is BattleRejectionError { + return ( + typeof err === 'object' && + err !== null && + (err as { isBattleRejection?: unknown }).isBattleRejection === true + ); +} + +/** + * Converts a failed battle request into an explained rejection, or `null` when the + * failure is not a server refusal (a dropped connection, a wallet the user closed) + * and the caller's own fallback is the better text. + */ +export function toBattleRejection(err: unknown): BattleRejectionError | null { + const code = rejectionCode(err); + if (!code) return null; + return new BattleRejectionError(code, MESSAGES[code] ?? `Battle refused: ${code}`); +} + +/** True when the failure is one the defender's owner fixes by granting consent. */ +export function isConsentFailure(err: unknown): boolean { + const code = isBattleRejection(err) ? err.code : rejectionCode(err); + return code === 'no-authorization' || code === 'pet-not-covered' || code === 'revoked'; +} diff --git a/shared/src/utils/index.ts b/shared/src/utils/index.ts index 5be354e6..3c1690da 100644 --- a/shared/src/utils/index.ts +++ b/shared/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './battleEvidence'; +export * from './battleFailureMessage'; export * from './common'; export * from './ethereum'; export * from './solana'; diff --git a/shared/tests/utils/battleFailureMessage.test.ts b/shared/tests/utils/battleFailureMessage.test.ts new file mode 100644 index 00000000..30c1aaaa --- /dev/null +++ b/shared/tests/utils/battleFailureMessage.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { + isBattleRejection, + isConsentFailure, + toBattleRejection, +} from '../../src/utils/battleFailureMessage'; + +/** Shaped like the Axios error the api client throws for a 4xx. */ +const rejection = (code: string) => ({ response: { data: { error: code, detail: 'why' } } }); + +describe('explaining a server refusal', () => { + it('maps the reason a defender has not granted consent', () => { + const err = toBattleRejection(rejection('no-authorization')); + expect(err?.message).toBe("This opponent's owner has not allowed challenges yet."); + expect(err?.code).toBe('no-authorization'); + }); + + it('distinguishes refusals that look identical without the code', () => { + const messages = ['attacker-not-ready', 'defender-not-ready', 'no-authorization', 'revoked'] + .map((code) => toBattleRejection(rejection(code))?.message); + expect(new Set(messages).size).toBe(4); + }); + + it('keeps an unmapped code visible rather than hiding it behind a generic message', () => { + const err = toBattleRejection(rejection('some-future-reason')); + expect(err?.message).toBe('Battle refused: some-future-reason'); + }); + + it('returns null for a failure the server did not explain', () => { + expect(toBattleRejection(new Error('Network Error'))).toBeNull(); + expect(toBattleRejection({ response: { data: {} } })).toBeNull(); + expect(toBattleRejection(null)).toBeNull(); + expect(toBattleRejection(undefined)).toBeNull(); + }); +}); + +describe('tagging', () => { + it('is recognisable without instanceof, so a duplicate module copy still matches', () => { + const err = toBattleRejection(rejection('expired')); + expect(isBattleRejection(err)).toBe(true); + expect(isBattleRejection(new Error('expired'))).toBe(false); + expect(isBattleRejection(null)).toBe(false); + }); + + it('is a real Error, so existing error plumbing keeps working', () => { + const err = toBattleRejection(rejection('expired')); + expect(err).toBeInstanceOf(Error); + expect(err?.name).toBe('BattleRejectionError'); + }); +}); + +describe('consent failures', () => { + it('flags the ones the defender fixes by granting consent', () => { + for (const code of ['no-authorization', 'pet-not-covered', 'revoked']) { + expect(isConsentFailure(rejection(code))).toBe(true); + expect(isConsentFailure(toBattleRejection(rejection(code)))).toBe(true); + } + }); + + it('does not flag refusals consent cannot fix', () => { + for (const code of ['attacker-not-ready', 'expired', 'self-battle']) { + expect(isConsentFailure(rejection(code))).toBe(false); + } + }); +}); From 39574be3dd1f6c082c1325b57672d3a4b02673f1 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Sun, 2 Aug 2026 09:55:07 -0400 Subject: [PATCH 70/76] feat(contracts): add the CPET reward token --- contracts/ethereum/src/CryptoPetsToken.sol | 50 +++++++ .../ethereum/test/CryptoPetsToken.test.ts | 124 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 contracts/ethereum/src/CryptoPetsToken.sol create mode 100644 contracts/ethereum/test/CryptoPetsToken.test.ts diff --git a/contracts/ethereum/src/CryptoPetsToken.sol b/contracts/ethereum/src/CryptoPetsToken.sol new file mode 100644 index 00000000..8f91691f --- /dev/null +++ b/contracts/ethereum/src/CryptoPetsToken.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @title CryptoPetsToken + * @notice The CPET reward token. Fixed supply, minted once at deployment. + * @dev Funds `SeasonRewardDistributor` (docs/plan-backend-battle-architecture.md §I). + * + * **There is no mint function, and no owner.** §I's whole argument is that rewards are + * bounded: the distributor caps what one wallet and one season may pay, and those caps + * bound what a bad root can cost. A token that could be minted later would leave the + * real ceiling as "whatever the key holder decides", which makes the on-chain caps a + * statement about arithmetic rather than about supply. Fixing it here means the total + * is checkable by anyone, forever, without trusting anybody to refrain. + * + * The cost of that is real and deliberate: seasons must be funded from a supply that + * already exists, so running out means running out. That is the intended failure — + * a season that cannot be funded is refused before its root is posted + * (`season.open.ts` checks the distributor's balance), which is a far better outcome + * than paying early claimants and reverting on the rest. + * + * Ownable is deliberately absent for the same reason. With supply fixed and no + * privileged transfer path, an owner would hold authority over nothing, and an + * owner key that controls nothing is a key that can still be stolen and still cause + * alarm. Distribution is a matter of moving tokens the deployer already holds. + * + * Not upgradeable, for the obvious reason: a reward token whose rules can be rewritten + * is not a fixed supply. + */ +contract CryptoPetsToken is ERC20 { + error InvalidHolder(); + error InvalidSupply(); + + /** + * @param initialHolder Receives the entire supply. Expected to be the treasury that + * funds each season's distributor balance, not the distributor + * itself — the distributor should hold only what an open season + * can pay. + * @param initialSupply Total supply, in wei (18 decimals). Fixed forever at this value. + */ + constructor(address initialHolder, uint256 initialSupply) ERC20("CryptoPets", "CPET") { + if (initialHolder == address(0)) revert InvalidHolder(); + // A zero supply would deploy a token that can never pay a season, and would only be + // discovered when the first season was refused for underfunding. + if (initialSupply == 0) revert InvalidSupply(); + _mint(initialHolder, initialSupply); + } +} diff --git a/contracts/ethereum/test/CryptoPetsToken.test.ts b/contracts/ethereum/test/CryptoPetsToken.test.ts new file mode 100644 index 00000000..ed90cd3d --- /dev/null +++ b/contracts/ethereum/test/CryptoPetsToken.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { network } from "hardhat"; +import { getAddress, toFunctionSelector } from "viem"; + +/** + * CryptoPetsToken (CPET). + * + * The properties worth testing here are the ones that are absent rather than present: there + * is no mint path, no owner, and no privileged transfer. A plain ERC-20 needs no test of its + * own — OpenZeppelin's is not this repo's job — so what is checked is that supply really is + * fixed at construction and that nothing on the contract can change it afterwards. + */ +async function rejectsWithError(promise: Promise, signature: string): Promise { + const name = signature.slice(0, signature.indexOf("(")); + const selector = toFunctionSelector(signature); + await assert.rejects(promise, (error: unknown) => { + const text = String(error); + assert.ok( + text.includes(name) || text.includes(selector), + `expected a revert with ${signature} (${selector}), got:\n${text}`, + ); + return true; + }); +} + +describe("CryptoPetsToken", async function () { + const { viem } = await network.connect(); + + const SUPPLY = 100_000_000n * 10n ** 18n; + + async function deploy() { + const [treasury, alice] = await viem.getWalletClients(); + const token = await viem.deployContract("CryptoPetsToken", [treasury.account.address, SUPPLY]); + return { token, treasury, alice }; + } + + describe("deployment", function () { + it("is named CPET with the usual 18 decimals", async function () { + const { token } = await deploy(); + assert.equal(await token.read.name(), "CryptoPets"); + assert.equal(await token.read.symbol(), "CPET"); + assert.equal(await token.read.decimals(), 18); + }); + + it("mints the whole supply to the initial holder, and only there", async function () { + const { token, treasury, alice } = await deploy(); + assert.equal(await token.read.totalSupply(), SUPPLY); + assert.equal(await token.read.balanceOf([getAddress(treasury.account.address)]), SUPPLY); + assert.equal(await token.read.balanceOf([getAddress(alice.account.address)]), 0n); + }); + + it("refuses to burn the supply into an unreachable address", async function () { + await rejectsWithError( + viem.deployContract("CryptoPetsToken", [ + "0x0000000000000000000000000000000000000000", + SUPPLY, + ]), + "InvalidHolder()", + ); + }); + + // A zero-supply token deploys fine and then cannot fund any season; the failure + // would surface much later, as a season refused for underfunding. + it("refuses a zero supply rather than deploying a token that can never pay", async function () { + await rejectsWithError( + viem.deployContract("CryptoPetsToken", [ + (await viem.getWalletClients())[0]!.account.address, + 0n, + ]), + "InvalidSupply()", + ); + }); + }); + + describe("supply is fixed", function () { + // The point of the whole design: §I's caps bound what a season pays, and this bounds + // what could ever exist to pay it. Both are needed for "bounded" to mean anything. + it("exposes no way to mint more", async function () { + const { token } = await deploy(); + const names = token.abi + .filter((entry) => entry.type === "function") + .map((entry) => (entry as { name: string }).name); + assert.ok(!names.some((n) => /mint/i.test(n)), `unexpected mint-like function: ${names.join(", ")}`); + }); + + it("has no owner or other privileged role", async function () { + const { token } = await deploy(); + const names = token.abi + .filter((entry) => entry.type === "function") + .map((entry) => (entry as { name: string }).name); + for (const forbidden of ["owner", "transferOwnership", "renounceOwnership", "pause"]) { + assert.ok(!names.includes(forbidden), `unexpected privileged function: ${forbidden}`); + } + }); + + it("keeps total supply constant across transfers", async function () { + const { token, treasury, alice } = await deploy(); + await token.write.transfer([getAddress(alice.account.address), 500n], { + account: treasury.account, + }); + assert.equal(await token.read.totalSupply(), SUPPLY); + assert.equal(await token.read.balanceOf([getAddress(alice.account.address)]), 500n); + }); + }); + + describe("funding a distributor", function () { + // How a season is actually funded: the treasury moves tokens it already holds. + it("transfers to the distributor like any other holder", async function () { + const { token, treasury } = await deploy(); + const distributor = await viem.deployContract("SeasonRewardDistributor", [ + treasury.account.address, + ]); + + await token.write.transfer([distributor.address, 26n * 10n ** 18n], { + account: treasury.account, + }); + + assert.equal(await token.read.balanceOf([distributor.address]), 26n * 10n ** 18n); + assert.equal(await token.read.totalSupply(), SUPPLY); + }); + }); +}); From 284c81fdfea90dd59ed7130b211455b37ec0ff68 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Mon, 3 Aug 2026 20:46:19 -0400 Subject: [PATCH 71/76] ci: run the golden-vector suites, and fix a filter that hid a real bug --- .github/workflows/image-generator.yml | 13 ++++ .github/workflows/parity.yml | 98 +++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 .github/workflows/parity.yml diff --git a/.github/workflows/image-generator.yml b/.github/workflows/image-generator.yml index 54a75cdc..cdc2e9fc 100644 --- a/.github/workflows/image-generator.yml +++ b/.github/workflows/image-generator.yml @@ -4,17 +4,30 @@ name: Image generator # image-generator/README.md), so the root `pnpm lint` / `pnpm test` aggregates and # the Coverage workflow do not reach it. Without this workflow nothing runs its # suite at all. +# The path filter must also list the files `src/solanaLayout.test.ts` reads, not just +# this package. That suite pins the PetAccount byte layout against the Anchor IDL and +# `pet.rs`, so a Solana account change breaks it while touching nothing under +# `image-generator/`. Filtered on this package alone the suite simply does not run, which +# is how `open_to_challenges` was removed from the program while the decoder kept its +# byte — every field after it misaligned, and a pet rendered as a different pet. +# +# The two lists below are duplicated on purpose: GitHub Actions does not support YAML +# anchors, so factoring them out would silently disable the filter rather than share it. on: pull_request: branches: [main] paths: - 'image-generator/**' - '.github/workflows/image-generator.yml' + - 'contracts/solana/cryptopets/programs/cryptopets/src/state/**' + - 'indexer-go/internal/solana/idl/**' push: branches: [main] paths: - 'image-generator/**' - '.github/workflows/image-generator.yml' + - 'contracts/solana/cryptopets/programs/cryptopets/src/state/**' + - 'indexer-go/internal/solana/idl/**' permissions: contents: read diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml new file mode 100644 index 00000000..90b3c42c --- /dev/null +++ b/.github/workflows/parity.yml @@ -0,0 +1,98 @@ +name: Combat parity + +# The golden vectors in `contracts/test-vectors/` are what AGENTS.md calls the +# cross-language enforcement for combat-simulator parity — and until this workflow +# existed, nothing ran them. Coverage covers backend/frontend/shared, Verifier covers +# `verifier`, and the three suites that actually replay the vectors were covered by +# neither: +# +# protocol tests/combat/goldenVectors.test.ts (the canonical TS engine) +# indexer-go internal/combat/combat_golden_test.go (the independent Go port) +# contracts/ethereum test/XpFormula.test.ts (the XP fixture) +# +# §F's circuit breaker only has value while the TS and Go ports are independent and both +# match the vectors. A drift that CI never runs is a circuit breaker nobody armed. +# +# Deliberately not path-filtered. A parity break is caused precisely by changing one side +# and not the other, so filtering on either side's paths would skip the run that matters. +# See the image-generator workflow for what path filtering costs here. +# +# Anchor's frozen Rust suite is the fourth witness and is NOT run here: it needs a Solana +# toolchain this runner does not have. That gap is real — those tests are the only +# remaining independent evidence that the vectors describe what actually settled on chain. +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: parity-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + typescript: + name: protocol + contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The canonical engine, including tests/combat/goldenVectors.test.ts. + - name: Protocol tests + run: pnpm --filter @cryptopets/protocol test + + - name: Protocol lint + if: always() + run: pnpm --filter @cryptopets/protocol lint + + # MIT boundary: protocol must not import from a PolyForm package, or the public + # verifier that depends on it cannot be distributed. Enforced by its own test, run + # above — this step exists so the typecheck failure is separately legible. + - name: Protocol typecheck + if: always() + run: pnpm --filter @cryptopets/protocol typecheck + + - name: Compile contracts + if: always() + run: pnpm --prefix contracts/ethereum compile + + - name: Contract tests + run: pnpm --prefix contracts/ethereum test + + go: + name: indexer-go + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: indexer-go/go.mod + cache-dependency-path: indexer-go/go.sum + + - name: Vet + working-directory: indexer-go + run: go vet ./... + + # Unit tests only. The Postgres-backed tests are gated on TEST_DATABASE_URL and + # truncate tables, so they are deliberately not given one here. + - name: Test + working-directory: indexer-go + run: go test ./... + + - name: Build + working-directory: indexer-go + run: go build -o /dev/null ./cmd/indexer From 82a675fb56965dfa4c8139999f8ecbee363f8d58 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Mon, 3 Aug 2026 21:22:33 -0400 Subject: [PATCH 72/76] fix(backend): stop battles ignoring levels bought on chain after first fight --- .../battle-ledger/snapshot.builder.ts | 13 +++++ .../repositories/battleProgress.overlay.ts | 33 ++++++++---- backend/src/repositories/roster.repository.ts | 16 +++--- .../battle-ledger/snapshot.builder.test.ts | 52 ++++++++++++++++++- .../battleProgress.overlay.test.ts | 14 +++++ .../repositories/roster.repository.test.ts | 14 ++--- 6 files changed, 118 insertions(+), 24 deletions(-) diff --git a/backend/src/features/battle-ledger/snapshot.builder.ts b/backend/src/features/battle-ledger/snapshot.builder.ts index 9cf7a857..478004ca 100644 --- a/backend/src/features/battle-ledger/snapshot.builder.ts +++ b/backend/src/features/battle-ledger/snapshot.builder.ts @@ -72,6 +72,19 @@ async function getOrInitProgress( const existing = await prisma.petBattleProgress.findUnique({ where: key }); if (existing) { + // Adopt a higher on-chain level before fighting. Battles stopped writing chain + // level (§L Phase 6), but paid train()/levelUp() did not retire, so the roster + // can move ahead of a row seeded at first battle — and a snapshot built from the + // stale row would have the pet fight as if those purchases never happened. + // Persisted (not just read as a max) because this level goes into the signed + // snapshot, and the receipt's progression must replay from the same number the + // fight was computed at. Backend xp is kept: it counts toward the next level's + // threshold, which only grows with the adopted level, so no clamp is needed. + // The write is idempotent under a concurrent-accept race — both setters + // compute the same max from the same roster row. + if (seed.level > existing.level) { + return prisma.petBattleProgress.update({ where: key, data: { level: seed.level } }); + } return existing; } diff --git a/backend/src/repositories/battleProgress.overlay.ts b/backend/src/repositories/battleProgress.overlay.ts index 225dd387..9c1f2318 100644 --- a/backend/src/repositories/battleProgress.overlay.ts +++ b/backend/src/repositories/battleProgress.overlay.ts @@ -9,21 +9,23 @@ import type { Chain } from '@typings/chain'; * Overlays backend battle progression onto indexed chain state, for display. * * `pet_roster` is what the chain says. Since battles stopped settling on chain (§L Phase - * 6) its `level`/`xp`/`winCount`/`lossCount` are frozen at whatever the retired path left - * behind, while the real record accumulates in `pet_battle_progress`. Reading either - * alone is wrong: the roster misses every backend battle, and progress rows only exist - * for pets that have fought at all. + * 6) its `winCount`/`lossCount` are frozen at whatever the retired path left behind, + * while the real record accumulates in `pet_battle_progress`. Level and xp are NOT + * frozen: `train()` and `levelUp()` retired with nothing — they are live, paid actions + * that still raise both. Reading either table alone is wrong: the roster misses every + * backend battle, and progress rows only exist for pets that have fought at all. * * So: a pet with a progress row shows its backend progression; a pet without one shows * chain truth. That is not a fallback but the same rule stated twice — a progress row is * seeded from the pet's on-chain level the first time it fights (see - * `battle-ledger/snapshot.builder.ts`), so the two agree at the moment the row appears - * and diverge only as backend battles are actually won. + * `battle-ledger/snapshot.builder.ts`), so the two agree at the moment the row appears. * - * Cooldown is the exception: `readyAt` takes the *later* of the two. They are independent - * locks with different owners — breeding still writes the on-chain one (`newbornCooldown` - * bars a newborn from fighting), battles write the backend one — and a pet is only - * available when neither is holding it. + * Two stats merge instead of choosing a side, because both systems keep writing them: + * `readyAt` takes the *later* of the two (independent locks with different owners — + * breeding still writes the on-chain one, battles write the backend one — and a pet is + * only available when neither is holding it), and `level` takes the *greater* (backend + * battles raise the row, paid on-chain train/level-up raise the roster, and both are + * monotone, so the max is the only reading that loses neither). * * This is deliberately not done in `roster.repository.ts`. That layer is the projection * of chain state, and two callers need it to stay exactly that: `snapshot.builder.ts` @@ -52,7 +54,16 @@ export function overlayRosterPet(pet: RosterPet, progress: ProgressRow | undefin } return { ...pet, - level: progress.level, + // The greater of the two, for the same reason readyAt takes the later: battles + // stopped writing chain level (§L Phase 6), but train() and levelUp() did not + // retire — they are live, paid actions that still raise it, and preferring the + // row outright would erase upgrades the owner paid real fees for. Win/loss stay + // row-only (nothing on chain writes them any more). Chain xp is also still + // written by train(), but is deliberately NOT merged: xp counts toward the + // threshold of the level it was earned at, so comparing it across the two + // systems compares incommensurate numbers — the level jump it produces is the + // part that must survive, and the max above carries it. + level: progress.level > pet.level ? progress.level : pet.level, xp: progress.xp, winCount: progress.winCount, lossCount: progress.lossCount, diff --git a/backend/src/repositories/roster.repository.ts b/backend/src/repositories/roster.repository.ts index f8d377a0..15785f7f 100644 --- a/backend/src/repositories/roster.repository.ts +++ b/backend/src/repositories/roster.repository.ts @@ -83,12 +83,16 @@ export async function findReadyOpponents( const deploymentId = servedDeploymentId(); const skip = params.page * params.pageSize; - // COALESCE, not a merge of individual columns: a progress row supplies all four - // progression values or none of them, matching `overlayRosterPet`. + // COALESCE for xp/win/loss: a progress row supplies those wholesale, matching + // `overlayRosterPet`. Level and ready_at instead MERGE — GREATEST — because both + // systems keep writing them: battles raise the row, while paid on-chain + // train()/levelUp() raise the roster (and breeding still writes ready_at). A + // COALESCE on level would band and order a battled pet at its stale row level, + // hiding every level its owner has bought since its first fight. const [rows, counted] = await Promise.all([ prisma.$queryRaw` SELECT r.chain, r.pet_id AS "petId", r.owner, r.name, r.rarity, r.dna, - COALESCE(p.level, r.level) AS level, + GREATEST(r.level, COALESCE(p.level, 0)) AS level, COALESCE(p.xp, r.xp) AS xp, COALESCE(p.win_count, r.win_count) AS "winCount", COALESCE(p.loss_count, r.loss_count) AS "lossCount", @@ -105,8 +109,8 @@ export async function findReadyOpponents( WHERE r.chain = ${params.chain} AND r.owner <> ${params.excludeOwner} AND GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) <= ${nowSeconds} - AND COALESCE(p.level, r.level) >= ${params.minLevel} - ORDER BY COALESCE(p.level, r.level) ASC, r.pet_id ASC + AND GREATEST(r.level, COALESCE(p.level, 0)) >= ${params.minLevel} + ORDER BY GREATEST(r.level, COALESCE(p.level, 0)) ASC, r.pet_id ASC LIMIT ${params.pageSize} OFFSET ${skip} `, prisma.$queryRaw<{ total: bigint }[]>` @@ -119,7 +123,7 @@ export async function findReadyOpponents( WHERE r.chain = ${params.chain} AND r.owner <> ${params.excludeOwner} AND GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) <= ${nowSeconds} - AND COALESCE(p.level, r.level) >= ${params.minLevel} + AND GREATEST(r.level, COALESCE(p.level, 0)) >= ${params.minLevel} `, ]); diff --git a/backend/tests/features/battle-ledger/snapshot.builder.test.ts b/backend/tests/features/battle-ledger/snapshot.builder.test.ts index 5324b989..cc156496 100644 --- a/backend/tests/features/battle-ledger/snapshot.builder.test.ts +++ b/backend/tests/features/battle-ledger/snapshot.builder.test.ts @@ -7,7 +7,7 @@ vi.mock('@config/env', () => ({ vi.mock('@config/prisma', () => ({ prisma: { petRoster: { findUnique: vi.fn() }, - petBattleProgress: { findUnique: vi.fn(), create: vi.fn() }, + petBattleProgress: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() }, }, })); @@ -112,6 +112,56 @@ describe('merging roster and progress', () => { }); }); +describe('paid on-chain upgrades after the first battle', () => { + it('adopts a higher on-chain level into the row before fighting', async () => { + // The row was seeded at first battle, then the owner paid train()/levelUp() on + // chain. The snapshot must carry the bought level — and persist it, because the + // receipt's progression replays from the signed snapshot. + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(ROSTER_ROW as never); // level 40 + vi.mocked(prisma.petBattleProgress.findUnique).mockResolvedValue({ + level: 5, + xp: 80, + lastOpponentId: '7', + streak: 1, + readyAt: 0n, + } as never); + vi.mocked(prisma.petBattleProgress.update).mockResolvedValue({ + level: 40, + xp: 80, + lastOpponentId: '7', + streak: 1, + readyAt: 0n, + } as never); + + const snapshot = await buildPetSnapshot('eip155:84532', '1'); + + expect(prisma.petBattleProgress.update).toHaveBeenCalledWith({ + where: expect.anything(), + data: { level: 40 }, + }); + expect(snapshot!.level).toBe(40); + // Backend xp and streak survive the adoption; only the level moves. + expect(snapshot!.xp).toBe(80); + expect(snapshot!.streak).toBe(1); + }); + + it('leaves the row alone when backend battles are already ahead of the chain', async () => { + vi.mocked(prisma.petRoster.findUnique).mockResolvedValue(ROSTER_ROW as never); // level 40 + vi.mocked(prisma.petBattleProgress.findUnique).mockResolvedValue({ + level: 45, + xp: 10, + lastOpponentId: '0', + streak: 0, + readyAt: 0n, + } as never); + + const snapshot = await buildPetSnapshot('eip155:84532', '1'); + + expect(prisma.petBattleProgress.update).not.toHaveBeenCalled(); + expect(snapshot!.level).toBe(45); + }); +}); + describe('first backend battle for a pet', () => { it('seeds progress from on-chain level, zeroes XP, and starts with no opponent history', async () => { // A level-40 pet's first backend battle starts at level 40, not level 1. XP starts a diff --git a/backend/tests/repositories/battleProgress.overlay.test.ts b/backend/tests/repositories/battleProgress.overlay.test.ts index 80c35850..fe31cf36 100644 --- a/backend/tests/repositories/battleProgress.overlay.test.ts +++ b/backend/tests/repositories/battleProgress.overlay.test.ts @@ -80,6 +80,20 @@ describe('overlayRosterPet', () => { expect(overlayRosterPet(newborn, progress).readyAt).toBe(9_000n); }); + it('takes the greater level when paid on-chain upgrades moved ahead of the row', () => { + // The row is seeded at first battle and battles stopped writing chain level, + // but train()/levelUp() are live paid actions. A pet that battled at level 1 + // and was then levelled to 20 on chain must not keep fighting — or being + // displayed and matchmade — as level 1. + const upgraded = { ...chainPet, level: 20 }; + + expect(overlayRosterPet(upgraded, progress).level).toBe(20); + }); + + it('keeps the backend level when battles moved ahead of the chain', () => { + expect(overlayRosterPet(chainPet, progress).level).toBe(12); + }); + it('does not mutate the pet it was given', () => { overlayRosterPet(chainPet, progress); diff --git a/backend/tests/repositories/roster.repository.test.ts b/backend/tests/repositories/roster.repository.test.ts index 77449bc6..c9b0b72d 100644 --- a/backend/tests/repositories/roster.repository.test.ts +++ b/backend/tests/repositories/roster.repository.test.ts @@ -80,17 +80,19 @@ describe('findReadyOpponents', () => { expect(result.rows[0].readyAt).toBe(0n); }); - it('bands and orders on the merged level, not the frozen on-chain one', async () => { + it('bands and orders on the merged level, taking the greater of the two sources', async () => { // The whole point of doing this in SQL: a pet that climbed through backend battles - // must be banded at the level it actually reached. + // must be banded at the level it actually reached — and one whose owner paid for + // on-chain train/level-up after its first battle must not be banded at the stale + // row level either. GREATEST, mirroring the ready_at merge, loses neither. mockJoinQuery([], 0); await findReadyOpponents({ chain: 'evm', excludeOwner: '0x', minLevel: 3, page: 0, pageSize: 10 }); const sql = sqlOfCall(0); - expect(sql).toContain('COALESCE(p.level, r.level) >='); - expect(sql).toContain('ORDER BY COALESCE(p.level, r.level) ASC'); - expect(sql).not.toMatch(/WHERE[\s\S]*r\.level >=/); + expect(sql).toContain('GREATEST(r.level, COALESCE(p.level, 0)) >='); + expect(sql).toContain('ORDER BY GREATEST(r.level, COALESCE(p.level, 0)) ASC'); + expect(sql).not.toMatch(/WHERE[\s\S]*COALESCE\(p\.level, r\.level\) >=/); }); it('filters on the later of the two cooldowns', async () => { @@ -112,7 +114,7 @@ describe('findReadyOpponents', () => { const page = sqlOfCall(0); const count = sqlOfCall(1); for (const clause of [ - 'COALESCE(p.level, r.level) >=', + 'GREATEST(r.level, COALESCE(p.level, 0)) >=', 'GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) <=', 'r.owner <>', ]) { From 7adf5ba41919f1803eabdf2abb8e724fc55e29bb Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 4 Aug 2026 16:51:32 -0400 Subject: [PATCH 73/76] fix(backend): hide opponents whose owner has not consented to defend --- backend/src/repositories/roster.repository.ts | 45 ++++++++++++ .../repositories/roster.repository.test.ts | 70 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/backend/src/repositories/roster.repository.ts b/backend/src/repositories/roster.repository.ts index 15785f7f..f42d1ee8 100644 --- a/backend/src/repositories/roster.repository.ts +++ b/backend/src/repositories/roster.repository.ts @@ -1,3 +1,6 @@ +import { hashRuleset, SOURCE_DEFAULT_RULESET } from '@cryptopets/protocol'; +import { Prisma } from '@generated/prisma/client'; + import { prisma } from '@config/prisma'; import { tryGrpcGetPetState } from '@grpc-client/rosterReads'; import { mapRosterRowToRosterPet, type PetRosterRow } from './roster.mapping'; @@ -70,6 +73,20 @@ export interface FindOpponentsParams { * reads here keep theirs: they return chain truth and are merged by the caller. * - When this deployment serves no chain of `params.chain`'s family there is no * progression to join, so the plain roster query below is exactly right. + * + * It also drops pets whose owner has granted no standing defence consent (§D). + * Without that filter matchmaking offers opponents that `acceptBattle` will always + * refuse with 403 `no-authorization` — and it refuses *after* the attacker has + * signed the intent, so the player pays a wallet prompt to learn the fight was + * never possible. + * + * The filter is deliberately weaker than `authorizationCovers`, which stays the + * only thing that authorizes a battle. It checks what does not depend on the + * attacker (a live, unrevoked, in-window grant covering this pet under the current + * ruleset) and leaves the level band and the daily cap to accept time, where the + * attacker is known. So it can still list a pet that then refuses this particular + * challenger — it can never list one that refuses everybody. Narrowing only: + * nothing here can permit a battle the protocol rule would not. */ export async function findReadyOpponents( params: FindOpponentsParams @@ -81,8 +98,34 @@ export async function findReadyOpponents( } const deploymentId = servedDeploymentId(); + const rulesetHash = hashRuleset(SOURCE_DEFAULT_RULESET); const skip = params.page * params.pageSize; + // `normalizeAccount` lowercases EVM addresses and leaves base58 Solana pubkeys + // alone, so only the EVM side is folded — indexer-go is not guaranteed to write + // the roster in the same case. Folding base58 too could match two distinct + // pubkeys and list a pet whose owner never consented. + const ownerMatch = + params.chain === 'evm' + ? Prisma.sql`LOWER(r.owner) = a.defender_owner` + : Prisma.sql`r.owner = a.defender_owner`; + + // A live grant covering this pet, under the ruleset battles are currently settled + // under. Level band and daily cap are not here on purpose — see the header. + const hasConsent = Prisma.sql` + EXISTS ( + SELECT 1 FROM defense_authorization a + WHERE a.chain_id = ${chainId} + AND a.deployment_id = ${deploymentId} + AND a.ruleset_hash = ${rulesetHash} + AND a.revoked_at IS NULL + AND a.not_before <= ${nowSeconds} + AND a.expires_at > ${nowSeconds} + AND ${ownerMatch} + AND (a.all_pets OR a.pet_ids @> to_jsonb(r.pet_id)) + ) + `; + // COALESCE for xp/win/loss: a progress row supplies those wholesale, matching // `overlayRosterPet`. Level and ready_at instead MERGE — GREATEST — because both // systems keep writing them: battles raise the row, while paid on-chain @@ -110,6 +153,7 @@ export async function findReadyOpponents( AND r.owner <> ${params.excludeOwner} AND GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) <= ${nowSeconds} AND GREATEST(r.level, COALESCE(p.level, 0)) >= ${params.minLevel} + AND ${hasConsent} ORDER BY GREATEST(r.level, COALESCE(p.level, 0)) ASC, r.pet_id ASC LIMIT ${params.pageSize} OFFSET ${skip} `, @@ -124,6 +168,7 @@ export async function findReadyOpponents( AND r.owner <> ${params.excludeOwner} AND GREATEST(r.ready_at, COALESCE(p.ready_at, 0::bigint)) <= ${nowSeconds} AND GREATEST(r.level, COALESCE(p.level, 0)) >= ${params.minLevel} + AND ${hasConsent} `, ]); diff --git a/backend/tests/repositories/roster.repository.test.ts b/backend/tests/repositories/roster.repository.test.ts index c9b0b72d..95cfbad1 100644 --- a/backend/tests/repositories/roster.repository.test.ts +++ b/backend/tests/repositories/roster.repository.test.ts @@ -58,6 +58,22 @@ function sqlOfCall(index: number): string { return template.join(' ? ').replace(/\s+/g, ' '); } +/** + * The SQL of any `Prisma.Sql` fragments interpolated into the nth call. + * + * `sqlOfCall` only sees the literal chunks; a nested fragment arrives as a value, so + * the consent clause is invisible to it. `$queryRaw` is mocked, so the nesting is + * never flattened and the fragments are still separate objects here. + */ +function fragmentsOfCall(index: number): string { + const [, ...values] = vi.mocked(prisma.$queryRaw).mock.calls[index] as unknown as [string[], ...unknown[]]; + return values + .filter((value): value is { sql: string } => typeof (value as { sql?: unknown })?.sql === 'string') + .map((fragment) => fragment.sql) + .join(' ') + .replace(/\s+/g, ' '); +} + beforeEach(() => { vi.clearAllMocks(); servedChainIdForFamily.mockReturnValue('eip155:31337'); @@ -123,6 +139,60 @@ describe('findReadyOpponents', () => { } }); + it('drops pets whose owner granted no live defence consent', async () => { + // Without this the list offers opponents `acceptBattle` always refuses with 403 + // no-authorization — and it refuses after the attacker has signed, so the player + // pays a wallet prompt to find out the fight was never possible. + mockJoinQuery([], 0); + + await findReadyOpponents({ chain: 'evm', excludeOwner: '0x', minLevel: 0, page: 0, pageSize: 10 }); + + const consent = fragmentsOfCall(0); + expect(consent).toContain('EXISTS'); + expect(consent).toContain('defense_authorization'); + expect(consent).toContain('a.revoked_at IS NULL'); + expect(consent).toContain('a.all_pets OR a.pet_ids @>'); + }); + + 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 + // reimplementing them here could only diverge from the protocol rule. + mockJoinQuery([], 0); + + await findReadyOpponents({ chain: 'evm', excludeOwner: '0x', minLevel: 0, page: 0, pageSize: 10 }); + + const consent = fragmentsOfCall(0); + expect(consent).not.toContain('min_level'); + expect(consent).not.toContain('max_level'); + expect(consent).not.toContain('max_battles_per_day'); + }); + + it('folds owner case on EVM only', async () => { + // normalizeAccount lowercases EVM addresses and leaves base58 alone; folding a + // base58 pubkey could match a different owner entirely. + mockJoinQuery([], 0); + await findReadyOpponents({ chain: 'evm', excludeOwner: '0x', minLevel: 0, page: 0, pageSize: 10 }); + expect(fragmentsOfCall(0)).toContain('LOWER(r.owner) = a.defender_owner'); + + vi.clearAllMocks(); + servedChainIdForFamily.mockReturnValue('solana:devnet'); + mockJoinQuery([], 0); + await findReadyOpponents({ chain: 'solana', excludeOwner: 'Bhp', minLevel: 0, page: 0, pageSize: 10 }); + const solana = fragmentsOfCall(0); + expect(solana).toContain('r.owner = a.defender_owner'); + expect(solana).not.toContain('LOWER('); + }); + + it('counts consent with the same clause it pages with', async () => { + // A count that ignored consent would page past the end of the real result. + mockJoinQuery([], 0); + + await findReadyOpponents({ chain: 'evm', excludeOwner: '0x', minLevel: 0, page: 0, pageSize: 10 }); + + expect(fragmentsOfCall(1)).toContain('defense_authorization'); + }); + it('falls back to the plain roster query for an unserved chain family', async () => { // Nothing to join: no progression exists for a chain this deployment does not run // battles for, so the frozen columns are the whole truth. From ab644f07e4ac4a3b7a1f34b5c2ebbbf52baefdc9 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 4 Aug 2026 17:00:23 -0400 Subject: [PATCH 74/76] fix(frontend): link the battle to the room it minted --- frontend/src/hooks/battle/useBattlePanel.ts | 25 ++++++++++--- .../tests/hooks/battle/useBattlePanel.test.ts | 36 +++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/battle/useBattlePanel.ts b/frontend/src/hooks/battle/useBattlePanel.ts index fe8cc13c..600bc966 100644 --- a/frontend/src/hooks/battle/useBattlePanel.ts +++ b/frontend/src/hooks/battle/useBattlePanel.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { getReadyPetsUnified, isBattleRejection, @@ -14,6 +14,7 @@ import { type BattleResolvedResult, type SimOutcome, } from '@shared/core'; +import { BATTLE_ROOM_WS_URL } from '../../config'; import { BATTLE_PATH, DASHBOARD_HOME } from '@constants/interactionRoutes'; import { formatTxHashHint } from '@hooks/usePetError'; import { usePetErrorToast } from '@hooks/usePetErrorToast'; @@ -73,6 +74,11 @@ export interface UseBattlePanel { export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBattlePanel => { const navigate = useNavigate(); const location = useLocation(); + // The room this battle is being watched through (§J). The URL is the source of + // truth rather than a second piece of state: it is what handleBattle sets when + // the room is minted, what a spectator opens, and what survives a reload — all + // three have to agree, and duplicating it in state is how they stop agreeing. + const { roomId = null } = useParams<{ roomId?: string }>(); const capabilities = useChainCapabilities(); const { pets, refetch } = usePetList(); // Pre-select the pet the player clicked "Battle" on from its gallery card @@ -145,7 +151,15 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat [outcome, refetch, refetchOpponents], ); - const battle = useBattlePets({ onSuccess: handleSuccess }); + // The room is passed here, not just put in the URL: `accept` records it on the + // ledger row, which is what makes the backend notify that room on every state + // change. Without it the socket has nothing to join and this client falls back + // to polling, while spectators holding the link are never told anything at all. + const battle = useBattlePets({ + onSuccess: handleSuccess, + roomId, + roomSocketUrl: BATTLE_ROOM_WS_URL, + }); liveReplayRef.current = battle.liveReplay; // Deliberately not gated on overlayOpen: the animation must keep progressing @@ -281,8 +295,11 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat chain: activeChainKind, attackerPetId: selectedFighter.id, defenderPetId: opponent.id, - }).then((roomId) => { - if (roomId) navigate(`${BATTLE_PATH}/${roomId}`, { replace: true }); + }).then((mintedRoomId) => { + // Navigate either way. On failure that clears a previous battle's room from + // the URL, which would otherwise be handed to this battle's accept call and + // push its updates to a room full of the wrong spectators. + navigate(mintedRoomId ? `${BATTLE_PATH}/${mintedRoomId}` : BATTLE_PATH, { replace: true }); setOverlayOpen(true); const personas = { diff --git a/frontend/tests/hooks/battle/useBattlePanel.test.ts b/frontend/tests/hooks/battle/useBattlePanel.test.ts index f3d3bab0..1500e6b2 100644 --- a/frontend/tests/hooks/battle/useBattlePanel.test.ts +++ b/frontend/tests/hooks/battle/useBattlePanel.test.ts @@ -4,11 +4,13 @@ import { act, renderHook } from '@testing-library/react'; const mocks = vi.hoisted(() => ({ navigate: vi.fn(), locationState: null as { petId?: string } | null, + params: {} as { roomId?: string }, })); vi.mock('react-router-dom', () => ({ useNavigate: () => mocks.navigate, useLocation: () => ({ state: mocks.locationState }), + useParams: () => mocks.params, })); vi.mock('@constants/interactionRoutes', () => ({ DASHBOARD_HOME: '/dashboard', BATTLE_PATH: '/battle' })); vi.mock('@hooks/usePetError', () => ({ formatTxHashHint: vi.fn(() => null) })); @@ -35,18 +37,25 @@ const battle = { mutate: vi.fn(), clearErrors: vi.fn(), isPending: false, isConf const taunts = { generate: vi.fn(), reset: vi.fn(), isLoading: false, turns: [] as unknown[] }; const createRoom = vi.fn().mockResolvedValue(null); let capturedOnSuccess: ((r: unknown) => void) | undefined; +/** Options the hook handed `useBattlePets` on the latest render. */ +let capturedBattleOptions: { roomId?: string | null; roomSocketUrl?: string } | undefined; const pets = [{ id: 'p1', name: 'Rex', level: 3, winCount: 1, lossCount: 0, chain: 'evm', readyAt: 0n }]; const opponents = [{ id: 'opp1', name: 'Blaze', owner: '0xopp', level: 2 }]; vi.mock('@shared/core', () => ({ + // `src/config.ts` calls both of these at import time, and the hook now imports it + // for BATTLE_ROOM_WS_URL. Stubs, not behaviour under test. + setStorageAdapter: vi.fn(), + setTokenSuccessCallback: vi.fn(), isBattleRejection: (e: unknown) => typeof e === 'object' && e !== null && (e as { isBattleRejection?: unknown }).isBattleRejection === true, getReadyPetsUnified: (p: { id: string }[]) => p.map((x) => ({ id: x.id, pet: x })), useChainCapabilities: () => ({ activeKind: 'evm', randomness: { provider: 'vrf' } }), usePetList: () => ({ pets, refetch: vi.fn(), isLoading: false }), - useBattlePets: (opts: { onSuccess?: (r: unknown) => void }) => { + useBattlePets: (opts: { onSuccess?: (r: unknown) => void; roomId?: string | null; roomSocketUrl?: string }) => { capturedOnSuccess = opts?.onSuccess; + capturedBattleOptions = opts; return battle; }, useBattleTaunts: () => taunts, @@ -62,7 +71,9 @@ beforeEach(() => { Object.assign(battle, { isPending: false, isConfirming: false, error: null, hash: undefined, phase: null, liveReplay: null }); Object.assign(taunts, { isLoading: false, turns: [] }); mocks.locationState = null; + mocks.params = {}; capturedOnSuccess = undefined; + capturedBattleOptions = undefined; }); describe('useBattlePanel', () => { @@ -115,18 +126,37 @@ describe('useBattlePanel', () => { expect(mocks.navigate).toHaveBeenCalledWith('/battle/room-123', { replace: true }); }); - it('still starts the battle (no navigate) when room creation fails', async () => { + it('still starts the battle when room creation fails, clearing any stale room', async () => { + // The room in the URL is what gets sent to `accept`, so a previous battle's room + // left there would push this battle's updates to the wrong spectators. Failing to + // mint means no room, and the URL has to say so. + mocks.params = { roomId: 'room-from-a-previous-battle' }; createRoom.mockResolvedValueOnce(null); const { result } = renderHook(() => useBattlePanel({ isStandaloneView: false })); act(() => { result.current.setup.onSelectFighter('p1'); }); act(() => { result.current.setup.onSelectOpponent('0xopp:opp1'); }); await act(async () => { result.current.setup.onBattle(); }); - expect(mocks.navigate).not.toHaveBeenCalled(); + expect(mocks.navigate).toHaveBeenCalledWith('/battle', { replace: true }); expect(result.current.overlay.open).toBe(true); expect(taunts.generate).toHaveBeenCalled(); }); + it('passes the room from the URL to useBattlePets, with the socket endpoint', () => { + // Without this the room is only ever cosmetic: `accept` never records it, so the + // backend notifies nobody and this client falls back to polling. + mocks.params = { roomId: 'room-123' }; + renderHook(() => useBattlePanel({ isStandaloneView: false })); + + expect(capturedBattleOptions?.roomId).toBe('room-123'); + expect(capturedBattleOptions?.roomSocketUrl).toMatch(/^wss?:\/\/.*\/ws\/battle-room$/); + }); + + it('passes a null room when the URL carries none', () => { + renderHook(() => useBattlePanel({ isStandaloneView: false })); + expect(capturedBattleOptions?.roomId).toBeNull(); + }); + it('holds off opening the overlay/generating taunts until room creation settles', () => { createRoom.mockReturnValueOnce(new Promise(() => {})); // never resolves const { result } = renderHook(() => useBattlePanel({ isStandaloneView: false })); From 763f47d08b97674cd87a2ac48146754d831ec3a2 Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 4 Aug 2026 17:06:11 -0400 Subject: [PATCH 75/76] fix(frontend): drop an opponent that refuses on consent, and re-read the list --- frontend/src/hooks/battle/useBattlePanel.ts | 17 +++++ .../tests/hooks/battle/useBattlePanel.test.ts | 63 ++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/battle/useBattlePanel.ts b/frontend/src/hooks/battle/useBattlePanel.ts index 600bc966..7aba92fa 100644 --- a/frontend/src/hooks/battle/useBattlePanel.ts +++ b/frontend/src/hooks/battle/useBattlePanel.ts @@ -3,6 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { getReadyPetsUnified, isBattleRejection, + isConsentFailure, useChainCapabilities, useBattlePets, useBattleTaunts, @@ -402,6 +403,22 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat } }, [taunts, battle.error, showResult]); + // A consent failure means this opponent's owner has no standing authorization + // covering the fight (§D) — they never granted one, revoked it, or scoped it to + // other pets. Matchmaking already excludes all three server-side, so this is the + // narrow race where the grant died between the list being built and the battle + // being accepted. Re-reading the list drops the opponent, and clearing the + // selection stops the player re-picking the one choice that cannot succeed. + // + // Deliberately not every rejection: a level-band or daily-cap refusal is about + // this attacker or today, not the opponent's willingness, and dropping them from + // the list over it would be wrong. + useEffect(() => { + if (!isConsentFailure(battle.error)) return; + setSelectedOpponent(''); + void refetchOpponents(); + }, [battle.error, refetchOpponents]); + // Once the tx hash exists, retain it as the stable battleId for the result // read. EVM clears battle.hash on receipt completion, so capture it here. // Result dialogue was already pre-generated at taunt time (matchup-keyed), so diff --git a/frontend/tests/hooks/battle/useBattlePanel.test.ts b/frontend/tests/hooks/battle/useBattlePanel.test.ts index 1500e6b2..ee3b76cf 100644 --- a/frontend/tests/hooks/battle/useBattlePanel.test.ts +++ b/frontend/tests/hooks/battle/useBattlePanel.test.ts @@ -36,6 +36,7 @@ vi.mock('@hooks/battle/useResultDialogue', () => ({ useResultDialogue: () => res const battle = { mutate: vi.fn(), clearErrors: vi.fn(), isPending: false, isConfirming: false, error: null, hash: undefined as string | undefined, phase: null, liveReplay: null as null | { result: { firstWins: boolean }; log: unknown[]; startHp1: bigint; startHp2: bigint }, lifecycle: { phase: 'idle' } }; const taunts = { generate: vi.fn(), reset: vi.fn(), isLoading: false, turns: [] as unknown[] }; const createRoom = vi.fn().mockResolvedValue(null); +const refetchOpponents = vi.fn(); let capturedOnSuccess: ((r: unknown) => void) | undefined; /** Options the hook handed `useBattlePets` on the latest render. */ let capturedBattleOptions: { roomId?: string | null; roomSocketUrl?: string } | undefined; @@ -50,6 +51,12 @@ vi.mock('@shared/core', () => ({ setTokenSuccessCallback: vi.fn(), isBattleRejection: (e: unknown) => typeof e === 'object' && e !== null && (e as { isBattleRejection?: unknown }).isBattleRejection === true, + // Mirrors the real predicate: the three refusals that mean the defender's owner + // is not willing, as opposed to a band/cap refusal about this attacker or today. + isConsentFailure: (e: unknown) => { + const code = (e as { code?: string } | null)?.code; + return code === 'no-authorization' || code === 'pet-not-covered' || code === 'revoked'; + }, getReadyPetsUnified: (p: { id: string }[]) => p.map((x) => ({ id: x.id, pet: x })), useChainCapabilities: () => ({ activeKind: 'evm', randomness: { provider: 'vrf' } }), usePetList: () => ({ pets, refetch: vi.fn(), isLoading: false }), @@ -60,7 +67,9 @@ vi.mock('@shared/core', () => ({ }, useBattleTaunts: () => taunts, useCreateBattleRoom: () => ({ createRoom, isLoading: false }), - useOpponents: () => ({ opponents, isLoading: false, isFetching: false, refetch: vi.fn() }), + // Stable identity, matching react-query's own refetch — and so the consent-failure + // effect below can be asserted on across renders. + useOpponents: () => ({ opponents, isLoading: false, isFetching: false, refetch: refetchOpponents }), useWinEstimate: () => ({ winProbability: null, isLoading: false, samples: null }), })); @@ -203,6 +212,58 @@ describe('useBattlePanel', () => { expect(result2.current.overlay.open).toBe(false); }); + it('drops the opponent and re-reads the list on a consent failure', async () => { + // The narrow race the server-side filter cannot close: the grant died between + // the list being built and the battle being accepted. Re-reading removes the + // opponent, and clearing the selection stops the player re-picking it. + const rejection = Object.assign(new Error('not allowed'), { + isBattleRejection: true, + code: 'no-authorization', + }); + const { result, rerender } = renderHook(() => useBattlePanel({ isStandaloneView: false })); + act(() => { result.current.setup.onSelectOpponent('0xopp:opp1'); }); + expect(result.current.setup.selectedOpponentKey).toBe('0xopp:opp1'); + + battle.error = rejection as unknown as null; + await act(async () => { rerender(); }); + + expect(result.current.setup.selectedOpponentKey).toBe(''); + expect(refetchOpponents).toHaveBeenCalledTimes(1); + }); + + it('re-reads the list once per failure, not on every render', async () => { + // Clearing the selection re-renders. This only stays a single refetch while + // both effect deps keep a stable identity, which is what `useOpponents` + // returning react-query's own `refetch` buys — so it is worth pinning. + const rejection = Object.assign(new Error('revoked'), { + isBattleRejection: true, + code: 'revoked', + }); + battle.error = rejection as unknown as null; + const { rerender } = renderHook(() => useBattlePanel({ isStandaloneView: false })); + await act(async () => { rerender(); }); + await act(async () => { rerender(); }); + + expect(refetchOpponents).toHaveBeenCalledTimes(1); + }); + + it('keeps the opponent selected when the refusal is not about consent', async () => { + // A band or cap refusal is about this attacker or today, not the opponent's + // willingness — dropping them from the list over it would be wrong. + const rejection = Object.assign(new Error('too low level'), { + isBattleRejection: true, + code: 'attacker-level-below-band', + }); + const { result, rerender } = renderHook(() => useBattlePanel({ isStandaloneView: false })); + act(() => { result.current.setup.onSelectOpponent('0xopp:opp1'); }); + + battle.error = rejection as unknown as null; + await act(async () => { rerender(); }); + + expect(result.current.setup.selectedOpponentKey).toBe('0xopp:opp1'); + expect(refetchOpponents).not.toHaveBeenCalled(); + }); + it('hashHint is null when provider is not switchboard', () => { const { result } = renderHook(() => useBattlePanel({ isStandaloneView: false })); expect(result.current.hashHint).toBeNull(); From 3248a5c62b34202d7f304ef1b15e7f7703817d1d Mon Sep 17 00:00:00 2001 From: heyradcode Date: Tue, 4 Aug 2026 17:23:42 -0400 Subject: [PATCH 76/76] ci: run the contracts job on Node 22 --- .github/workflows/parity.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 90b3c42c..16d4a379 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -44,7 +44,11 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + # Hardhat 3 needs >= 22.10. It calls `.flatMap` on the iterator from + # `Map.values()`, which is an Iterator Helpers method that does not exist + # before Node 22, so `compile` dies with a TypeError rather than a version + # check. Do not drop this back to 20 to match the other workflows. + node-version: 22 cache: pnpm - name: Install dependencies