From 5e3a16ddb91b2fc0da66ba5d08fcddea08cee015 Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 6 Jun 2026 00:12:53 +0200 Subject: [PATCH 01/10] docs(helios): verified deep-dive + runnable walkaway spike (R2) Pin the real Helios API against a16z/helios@0.11.1 (depend on helios-ethereum, git-only; alloy unifies to one alloy-primitives 1.6.0), prove the walkaway beat on mainnet, and rewrite 20-helios-sidecar.md with measured numbers, the failover design, provider/privacy findings, and an app-integration section. spikes/helios-walkaway/ (standalone crate) embeds Helios, serves a verified mainnet balance, and headless-PASSes two scenarios: - availability: cut the primary EL on camera, keep serving verified reads via a second EL (the cut primary's own head still returns from the CL cache, proving head is EL-independent) - integrity: point at a lying RPC (tampered eth_getProof balance) and refuse the read (invalid account proof) where a centralized wallet would display the lie Measured (M-series, mainnet): cold ~11s, warm ~2s, cut->failover <=1 block. CL finding: only Nimbus + dRPC actually drive a Helios sync (Lodestar/PublicNode return 200 but fail). Decisions: Kurtosis deferred off the v1 critical path; public CLs for the hero. Cross-doc notes added to 00/30/README. Reviewed by a Codex adversarial pass: all API claims confirmed against source; overclaims scoped and two spike bugs fixed (fake EL-independent head proof; lie scenario now asserts a proof rejection specifically). --- docs/build/00-test-harness.md | 4 +- docs/build/20-helios-sidecar.md | 140 +++++++--- docs/build/30-mcp-shape.md | 2 + docs/build/README.md | 12 +- spikes/helios-walkaway/.gitignore | 2 + spikes/helios-walkaway/Cargo.toml | 43 +++ spikes/helios-walkaway/README.md | 78 ++++++ spikes/helios-walkaway/src/main.rs | 324 ++++++++++++++++++++++ spikes/helios-walkaway/src/proxy.rs | 199 +++++++++++++ spikes/helios-walkaway/src/read_status.rs | 55 ++++ spikes/helios-walkaway/src/upstreams.rs | 151 ++++++++++ 11 files changed, 972 insertions(+), 38 deletions(-) create mode 100644 spikes/helios-walkaway/.gitignore create mode 100644 spikes/helios-walkaway/Cargo.toml create mode 100644 spikes/helios-walkaway/README.md create mode 100644 spikes/helios-walkaway/src/main.rs create mode 100644 spikes/helios-walkaway/src/proxy.rs create mode 100644 spikes/helios-walkaway/src/read_status.rs create mode 100644 spikes/helios-walkaway/src/upstreams.rs diff --git a/docs/build/00-test-harness.md b/docs/build/00-test-harness.md index 7de104a..d0b0051 100644 --- a/docs/build/00-test-harness.md +++ b/docs/build/00-test-harness.md @@ -102,7 +102,9 @@ helios ethereum --network kurtosis \ --checkpoint # Helios serves a verified local JSON-RPC on http://127.0.0.1:8545 ``` -⚠ **unverified:** that the chosen Kurtosis CL client serves the **light-client beaconchain API** out of the box — Lighthouse gates this behind `--light-client-server` (and the EL needs the light-client execution API). The harness's `kurtosis.rs` must set the CL/EL flags to enable both, and assert Helios reaches `synced` before proceeding. Spike this on day one of the Helios lane; if a client won't serve it, fall back to Lane C (Sepolia) for the walkaway integration. +> **Update (cross-doc, from `20-helios-sidecar.md`):** two things below are now stale. (1) **LC support is resolved** — Lighthouse/Nimbus/Lodestar serve the light-client API **on by default** and ethereum-package runs all forks from genesis (no `--light-client-server` needed on current Lighthouse; it's disable-only now). (2) **Kurtosis is DEFERRED off the v1 critical path** — the mainnet spike proved the walkaway without it, so Lane B is a post-demo hermetic-CI nice-to-have, not a gate; v1 runs on mainnet + Sepolia (Lane C). The original text below is kept for when the Kurtosis lane is picked up. + +⚠ ~~**unverified:** that the chosen Kurtosis CL client serves the **light-client beaconchain API** out of the box~~ (resolved — see note above) — Lighthouse gates this behind `--light-client-server` (and the EL needs the light-client execution API). The harness's `kurtosis.rs` must set the CL/EL flags to enable both, and assert Helios reaches `synced` before proceeding. Spike this on day one of the Helios lane; if a client won't serve it, fall back to Lane C (Sepolia) for the walkaway integration. ### Lane C — Sepolia diff --git a/docs/build/20-helios-sidecar.md b/docs/build/20-helios-sidecar.md index d9dd8e4..e3165ed 100644 --- a/docs/build/20-helios-sidecar.md +++ b/docs/build/20-helios-sidecar.md @@ -1,14 +1,24 @@ # Helios Light-Client Sidecar -> Embed a16z Helios so every read is verified locally, and to power the demo's WALKAWAY beat (cut the centralized RPC on camera, keep working). Serves demo beat 3 + acceptance step 3. This is risk **R2**. Status: **spike proven on mainnet** (cold ≈11s, warm ≈2s, cut→failover ≤1 block; runnable spike in `spikes/helios-walkaway/`). Part of the Deckard build docs. +> Embed a16z Helios so every read is verified locally, and to power the demo's WALKAWAY beat (cut the centralized RPC on camera, keep working). Serves demo beat 3 + acceptance step 3. This is risk **R2**. Status: **core mechanism proven on mainnet** — the spike shows verified reads survive a cut EL (failover) and a lying RPC is rejected (cold ≈11s, warm ≈2s, cut→failover ≤1 block; `spikes/helios-walkaway/`). **App integration is still unbuilt** (EIP-1193 provider for Railgun, ReadStatus on the wire, CL-rebuild, receive-watcher, `simulate`) — see "Integration into the app." Part of the Deckard build docs. > > **Verification note (2026-06-05):** every API/architecture claim below was re-derived from the actual a16z/helios source at tag `0.11.1` (ref `204c998a`) and adversarially re-checked by a second pass — *not* from memory. The numbers come from a runnable spike that actually syncs mainnet and survives a cut EL on this desktop. Anything still unverifiable is flagged ⚠. ## Why this exists (concrete) -Deckard today reads chain state from whatever RPC it's pointed at — a trusted-server assumption Deckard's whole pitch rejects. [Helios](https://github.com/a16z/helios) (a16z, Rust, MIT) turns an *untrusted* execution-layer RPC into a *verified* local endpoint by checking EL state against the consensus-layer sync committee. We embed it as a Rust library and point **all** of Deckard's reads at the local verified client; the demo then cuts the upstream RPC on camera and Deckard keeps serving verified balances. Without this, beat 3 ("works even if Infura — or the EF — disappears") is theater, not a property. +Deckard today reads chain state from whatever RPC it's pointed at and **believes the answer** — a trusted-server assumption Deckard's whole pitch rejects. [Helios](https://github.com/a16z/helios) (a16z, Rust, MIT) turns an *untrusted* execution-layer RPC into a *verified* local endpoint: it re-derives every balance from a Merkle proof and checks it against the consensus-layer sync committee, so **the RPC cannot lie to you.** We embed it as a Rust library and route **all** of Deckard's reads through it. -**This is now proven, not hoped.** The spike in `spikes/helios-walkaway/` syncs a real mainnet Helios client, serves the verified deposit-contract balance (86,313,877.35 ETH), then cuts the primary EL RPC and keeps returning that verified balance via a second EL — headless, exit-coded PASS. +**The core property is integrity, not availability.** Deckard can even *ship its own default RPC* and the user need not trust it — every instance runs Helios locally and verifies, so a Deckard-hosted (or any) RPC is a convenience, not a trust dependency (and the user can point at their own RPC with one setting). That is the moat: self-custody of *keys* is half the story; Helios makes your *view of the chain* self-custodial too. + +Two demonstrations of the one property, both proven by the spike in `spikes/helios-walkaway/`: +- **Integrity (the moat) — `SCENARIO=lie`:** point Helios at a *malicious* RPC that rewrites the balance in every `eth_getProof`. Deckard **refuses the read** (`invalid account proof`) instead of showing the fake 1,000,000,000 ETH a centralized wallet would display. *No centralized wallet can do this.* +- **Availability (the beat) — default scenario:** sync a real mainnet client, serve the verified deposit-contract balance (≈86.3M ETH), then **cut the primary EL RPC** on camera and keep returning that verified balance via a second EL. Headless, exit-coded PASS. + +## A naming caveat: our "walkaway beat" ≠ Vitalik's "walkaway test" + +Vitalik's **"walkaway test"** (X, Jan 2026) is a property of the **protocol**: Ethereum should be able to *ossify* — keep running safely and stay useful **even if core developers stop shipping upgrades** — which is why he frames **quantum resistance** as urgent (be safe for decades before a crisis forces rushed changes). That is *not* what Deckard's demo "walkaway beat" means. + +Deckard's beat is the **user-side analogue**: you don't depend on any *particular* infrastructure operator (RPC vendor / the EF's endpoints) to use or verify Ethereum. The two rhyme — both are "the system survives if a privileged party walks away" — and Helios is in fact a *component* of Vitalik's vision: an ossified chain only stays usable for normal people if anyone can **verify it cheaply without trusting operators**, which is exactly the light-client thesis ("don't trust, verify"). So position Deckard as **walkaway-test-*aligned*** (verify-it-yourself reads, no operator dependence, quantum-readiness on the roadmap via Kohaku's PQ account — see `06-privacy.md`) — **not** as "the walkaway test." To avoid the clash, prefer naming the beat **"verified reads / no-trusted-RPC"** (integrity) with **"cut-the-RPC"** as its availability demo; keep "walkaway" as an internal nickname only. ## Where it sits — Depends on / Unblocks (cross-doc + demo) @@ -93,23 +103,70 @@ These four facts dictate the entire failover design and the demo's behavior. The **Chosen shape: (A) two synced clients + a supervisor.** Build `primary` (EL #1 = the "centralized" one we cut) and `secondary` (EL #2 = independent EL), both verifying against the same CL + checkpoint, both already synced. The supervisor routes reads to `primary`; on error/timeout it fails over to `secondary` and the first success becomes active. Both clients are equally trustless — failover re-derives the proof from an independent untrusted EL and re-verifies; it is **not** a cached stale value. This is `spikes/helios-walkaway/src/upstreams.rs`. We rejected shape (B) (tear down + rebuild on EL #2) because (A) needs no rebuild and the second client is already at the head. **Cut the EL, not the CL — that's where the property lives.** Because the head is CL-driven and cached: -- **Cut EL #1 (CL stays up):** the head keeps advancing and `get_block_number()` *still returns* (from cache, proven: `head after cut = 25252835 ✓` with EL1 dead). State reads fail on EL1 and recover on EL2. This is the demoable beat: `Verified → Degraded{failover} → Verified`. +- **Cut EL #1 (CL stays up):** the head keeps advancing and `get_block_number()` *still returns* — proven by reading the **cut primary's own client** after the cut (`head_of_primary` returns from the CL-pushed cache with its EL dead). State reads fail on EL1 and recover on EL2. The transition is `Verified → Degraded{failover→EL2}` and it **stays Degraded on the backup** — the supervisor does not auto-probe back to the primary yet (recovery-to-`Verified` is a TODO, see Integration). For the demo that's fine: the balance is still verified the whole time; only the trust label reads "degraded/failover." - **Cut the CL instead:** the head freezes; after 60 s every `Latest`-tag read hard-fails `OutOfSync` and `syncing()` flips to `Info`. **And Helios does not self-heal a dead CL** — when the consensus channel closes, the node logs *"consensus client stopped, shut Helios down manually"* and stops (`core/src/client/node.rs`); transient CL blips are retried inside the consensus loop, but a sustained CL death requires Deckard to **rebuild** the client against CL #2 (warm-start from the cached checkpoint, ~2 s). So cutting the CL is the *graceful-degradation* path ("verified locally, head frozen → NOT VERIFIED"), not a "keeps working" beat. **Don't cut the CL on camera.** **The cache cushion (measured, important for the shoot).** After the EL cut, reads stay `Verified` from the per-block proof cache until the head advances to a *new* block, which forces a cache-miss `eth_getProof` → that's when failover actually fires. So the **cut→failover wall-clock is gated by the block cadence (0–12 s), not the supervisor** (which adds ~250–500 ms once a real EL read is attempted). Two spike runs bracketed this exactly: **1998 ms** (cut landed late in a slot) and **14744 ms** (cut landed just after a block). On camera this reads *well*: the verified balance never blinks — it holds through the cut and re-verifies via the backup within a block. If you want an instant visible flip, the supervisor can proactively issue a `get_proof` on cut-detection instead of waiting for the cached read to expire. -**`ReadStatus` transitions, mapped to real Helios observables:** +**`ReadStatus` transitions, mapped to real Helios observables** (this is the **target** contract; what the spike implements today is noted per row): | State | Condition (observable) | Demo meaning | |---|---|---| -| `Verified` | `syncing()==None` (head age ≤60 s) **and** served by the primary EL | trustless, happy path | -| `Degraded { reason: "failover→EL2" }` | primary EL read errored, secondary EL read succeeded; head still fresh | **the walkaway** — re-verified via backup, balance unchanged | -| `Degraded { reason: "checkpoint:community" }` | running on `load_external_fallback` (ethPandaOps) checkpoint | verified, but checkpoint source untrusted — show a trust note | -| `Unsynced { reason: "head frozen…" }` | every EL failed **and** `syncing()==Info` (head age >60 s, CL dark) | NOT VERIFIED — never serve raw RPC | -| `Unsynced { reason: "all EL upstreams down" }` | every EL failed but head still fresh | NOT VERIFIED — can't produce a proof | -| `Unsynced { reason: "checkpoint too old" }` | `strict_checkpoint_age` rejects a >14 d checkpoint at build/sync | NOT VERIFIED — re-bootstrap from a fresh checkpoint | +| State | Condition (observable) | Demo meaning | Spike today | +|---|---|---|---| +| `Verified` | served by the primary EL (head fresh) | trustless, happy path | ✅ implemented | +| `Degraded { reason: "failover→EL2" }` | primary EL read errored, secondary EL read succeeded; head still fresh | **the walkaway** — re-verified via backup, balance unchanged (stays Degraded; no auto-probe back) | ✅ implemented | +| `Degraded { reason: "checkpoint:community" }` | running on `load_external_fallback` (ethPandaOps) checkpoint | verified, but checkpoint source untrusted — show a trust note | ⛔ not yet — daemon build task | +| `Unsynced { reason: "head frozen…" }` | every EL failed **and** `syncing()==Info` (head age >60 s, CL dark) | NOT VERIFIED — never serve raw RPC | ✅ classified via `syncing()` | +| `Unsynced { reason: "all EL upstreams down" }` | every EL failed but head still fresh | NOT VERIFIED — can't produce a proof | ✅ implemented | +| `Unsynced { reason: "checkpoint too old" }` | `strict_checkpoint_age` rejects a >14 d checkpoint at build/sync | NOT VERIFIED — re-bootstrap from a fresh checkpoint | ⛔ not yet (the builder *can* fail; not surfaced as a status) | + +(The spike uses `Verified` as "served by primary," not literally `syncing()==None` on every read; for the demo the two coincide. The checkpoint-status rows are the daemon's job, not the spike's.) -Hard rule (unchanged): **never silently fall back to a raw untrusted RPC.** Verified-or-visibly-degraded, never quietly-trusted. The exact wire shape of how `ReadStatus` rides on a read `Decision` is owned by `30-mcp-shape.md`. +Hard rule (unchanged): **never silently fall back to a raw untrusted RPC.** Verified-or-visibly-degraded, never quietly-trusted. The wire shape of how `ReadStatus` rides on a read response is **proposed, not yet frozen** in `30-mcp-shape.md` (see "Integration into the app"). + +## Integration into the app (how this wires in) + +> Status: **designed here, not yet built** — the spike is standalone and `src/wallet.rs` is still a plaintext EOA (per `30`). This section closes the cross-doc seam the README lists as "20 provides the EIP-1193 provider + ReadStatus," and resolves the two open placement questions. + +**One read module, in the daemon, key-less.** A single `Upstreams` supervisor (the Shape-A failover wrapper, which itself holds 1–2 `EthereumClient`s) lives inside `deckard-signerd` as a **read-only module with no handle to the key.** This resolves the "daemon read path vs MCP `Decision` resolver" question in favor of the daemon — matching `30`'s lean (*"simulate in the daemon so the approval card and the agent see identical numbers"*). Helios is read-only and never touches the keystore (already a stated dependency), so co-locating the read module with the signer adds no key-access path; its only outbound traffic goes to the already-untrusted EL/CL over the private/proxied upstreams. + +``` + ┌─────────────── deckard-signerd (one process) ───────────────┐ + │ key module (isolated) read module (NO key) │ + │ sign / policy gate Upstreams (Helios) │ + └──────────────▲──────────────────────────────▲──────────────┘ + UDS: propose/ │ UDS: wallet_balance/simulate │ (key-less, + execute │ + ReadStatus on every read │ ReadStatus-tagged) + ┌──────────────┴───────────┐ ┌────────────────┴───┐ ┌────────┴──────────────────┐ + │ deckard-mcp (thin shell) │ │ GPUI app (UI badge)│ │ Railgun shield (EIP-1193) │ + └──────────────────────────┘ └────────────────────┘ └───────────────────────────┘ +``` + +**Three consumers, two read paths:** +1. **Daemon socket reads** (`wallet_balance`, `simulate`) — typed `HeliosApi` calls through the supervisor, so they get EL-cut failover **and** a `ReadStatus`. The GPUI UI badge and the MCP agent both consume these → one source of truth, identical numbers. +2. **Railgun's chain reads** (UTXO/TXID sync, balance, state) — Railgun wants `RailgunBuilder::new(chain, impl IntoEip1193Provider)`, but `EthereumClient` exposes the typed `HeliosApi`, **not** an EIP-1193 `request(method, params)` JSON interface. Decision: + - **v1 (demo) — Helios's built-in localhost JSON-RPC server.** Build the primary client with `.rpc_address(127.0.0.1:)` (verified: `EthereumClientBuilder::rpc_address(SocketAddr)`; `HeliosClient::new` then spawns `jsonrpc::start`, which serves the `eth_*` subset Helios implements — the methods Railgun needs for live/state reads, all proof-checked; it is **not** a full JSON-RPC surface, so ⚠ confirm Railgun only calls served methods) and hand Railgun an **alloy HTTP provider** pointed at it. Least code, reuses Helios's own correct mapping. Accepted tradeoffs: (i) a loopback hop + a port (bind `127.0.0.1`, same-uid only); (ii) the server is per-`EthereumClient`, so Railgun's reads hit the primary only and do **not** get the supervisor's EL-cut failover — fine, because the shield completes *before* the on-camera cut and Railgun's reads are never the thing being cut. ⚠ verify Kohaku's `IntoEip1193Provider` accepts an alloy HTTP provider (10's open seam). Historical UTXO ranges go to Subsquid, not Helios (10). + - **production — a thin Rust adapter.** `struct HeliosEip1193(Arc)` implementing the provider trait by mapping `eth_*` → `HeliosApi` calls. Removes the loopback hop and puts Railgun's reads behind the same failover + `ReadStatus`. Build post-demo; keep it the single place a Helios↔Railgun API change touches. + +**`ReadStatus` on the wire — cross-doc proposal to `30` (it owns the contract).** For "every read carries Verified|Degraded|Unsynced" to be enforceable, `ReadStatus` must live in `deckard-contract` (the shared type home `30` owns) and ride on the read responses. Proposed delta: +- define `enum ReadStatus { Verified, Degraded{reason}, Unsynced{reason} }` in `deckard-contract` — **20 owns the semantics/transitions** (table above); the **type lives with the contract** so it can serialize on the wire. +- `wallet_balance` → `{ public_wei, shielded_wei, token_balances[], read_status }` +- `simulate` → `{ asset_changes[], gas, warnings[], read_status }` + +Today `30`'s read responses omit `read_status`; without it the "never silently trust" rule can't be enforced at the wire. (30 owns the final shape — this is the ask.) + +**CL-death handling (build task).** The supervisor gains a frozen-head detector: when `syncing()` flips to `Info` (head age >60 s) and no EL failover recovers it, **rebuild** the client against CL #2 (warm from the cached checkpoint, ~2 s), surfacing `Unsynced{reason:"reconnecting CL"}` in the gap. Never serve a raw read while reconnecting. + +**Deckard-side file layout:** +``` +src/chain/helios.rs # EthereumClient wrapper: build, wait_synced → servable, shutdown +src/chain/upstreams.rs # Upstreams supervisor (Shape A) + CL-rebuild-on-frozen +src/chain/read_status.rs # ReadStatus (re-exported from deckard-contract) +src/chain/eip1193.rs # v1: localhost-server wiring · prod: HeliosEip1193 adapter +~/.../Deckard/helios/ # data_dir: cached finalized checkpoint (with_file_db) +``` +The spike (`spikes/helios-walkaway/`) already implements `read_status.rs` + `upstreams.rs` in portable form — lift them in, add `helios.rs` (build/servable wrapper) and `eip1193.rs`. ## Inputs, trust, and the checkpoint @@ -131,14 +188,18 @@ A provider qualifies only if it serves the `/eth/v1/beacon/light_client/*` REST |---|---|---|---| | `http://testing.mainnet.beacon-api.nimbus.team` (Nimbus) | yes | **yes (verified — cold 11 s, warm 2 s)** | Helios's shipped mainnet default backend. Plain HTTP, no SLA, team "testing" box. **Use this for the spike.** | | `https://lodestar-mainnet.chainsafe.io` (ChainSafe) | yes | **NO in our test** — head stuck at timestamp 0 (`out of sync`) | Routes return 200 but Helios couldn't derive a fresh execution head against it on 2026-06-05. ⚠ re-test before relying. | -| `https://ethereum-beacon-api.publicnode.com` (PublicNode) | yes (`/updates` `count` param buggy) | not run in spike | keyless, HTTPS, no-log policy. `/updates` over-delivers — Helios tolerates bounded over-delivery, but flag. | -| `https://eth-beacon-chain.drpc.org` (dRPC) | yes | not run in spike | keyless, HTTPS. | +| `https://ethereum-beacon-api.publicnode.com` (PublicNode) | yes (`/updates` `count` param buggy) | **NO** — `sync failed: invalid sync committee period` | keyless, HTTPS, no-log policy, but the `/updates` bug breaks Helios bootstrap. Don't use as a Helios CL. | +| `https://eth-beacon-chain.drpc.org` (dRPC) | yes | **yes (verified — cold 10.4 s, head 25253907)** | keyless, HTTPS. **The proven public second CL.** | | `https://www.lightclientdata.org` (a16z old default) | **503** | — | down. | | beaconcha.in / checkpoint-sync hosts (sigp, attestant, ethpandaops) | 404 on LC routes | — | checkpoint-sync only; **not** an LC API. | -**Most commercial EL-RPC providers do NOT expose the light-client subset** (Ankr's beacon endpoint 404s on `light_client/*`; QuickNode serves it only if you provision your own Lighthouse-backed beacon endpoint; Chainstack/Blockdaemon/Nodereal unconfirmed). The reliably-working keyless mainnet LC servers are Nimbus-testing, PublicNode, and dRPC (Lodestar serves the routes but failed Helios sync in our test). +**Most commercial EL-RPC providers do NOT expose the light-client subset** (Ankr's beacon endpoint 404s on `light_client/*`; QuickNode serves it only if you provision your own Lighthouse-backed beacon endpoint; Chainstack/Blockdaemon/Nodereal unconfirmed). Of the keyless mainnet LC servers, only two are **proven to actually drive a Helios sync**: **Nimbus-testing** and **dRPC**. Lodestar and PublicNode return 200 on the routes but fail Helios sync (timestamp-0 head; `invalid sync committee period`, respectively). 200 ≠ syncs. + +**Chosen approach for the hero (CEO review): public CLs — and the prerequisite is now met.** Two independent, keyless, proven-to-sync public CLs: +- **Primary: Nimbus** `http://testing.mainnet.beacon-api.nimbus.team` (cold ~11 s). Plain HTTP, no-SLA team box. +- **Second: dRPC** `https://eth-beacon-chain.drpc.org` (cold ~10.4 s, verified 2026-06-05). HTTPS, keyless. -**Recommendation for the hero:** primary CL = the Nimbus endpoint that's proven to sync (or self-host); redundant second = PublicNode or dRPC, but **re-verify each candidate actually drives a Helios sync, not just returns 200.** Honest caveat: these are best-effort, **no-SLA** hosts; integrity is still guaranteed by the sync committee + checkpoint regardless of which CL you use — only **liveness** and **metadata** depend on the provider. +Self-hosting a Lighthouse CL stays as the fallback only if a pre-shoot rehearsal shows both publics are flaky. Honest caveat: both are best-effort, **no-SLA** hosts; integrity is guaranteed by the sync committee + checkpoint regardless of which CL you use — only **liveness** and **metadata** depend on the provider. Still do a health-check of both in the hour before the take, and only ever cut the EL on camera, never the CL. **Self-host fallback (smallest path).** The `light_client/*` namespace is standard ([beacon-APIs spec](https://github.com/ethereum/beacon-APIs)). Which CLs serve it: @@ -165,9 +226,11 @@ So spend the privacy budget on the **EL**; the CL needs IP hygiene only, not add ## Measured (M-series desktop, mainnet, 2026-06-05, from the spike) +> These are **observed values from real runs on this build host**, reproducible via the spike's commands — they are **not** asserted in CI or stored as committed artifacts. Re-measure on the actual demo machine. The spike prints current-run values; only the PASS/FAIL verdict is asserted. + | Metric | Number | Notes | |---|---|---| -| **Cold sync** (build → first servable verified head) | **≈ 10.9 s** | fresh community checkpoint + sync; includes the ~12 s-bounded wait for the first execution head push | +| **Cold sync** (build → first servable verified head) | **≈ 10.9 s** | with `strict_checkpoint_age` + no user pin, the stale built-in default is rejected so `load_external_fallback` fetches a fresh community checkpoint; includes the ~12 s-bounded wait for the first execution head push. (`FileDB` otherwise falls back to the *built-in default* checkpoint, not the community one — the external fallback is conditional.) | | **Warm sync** (cached `data_dir/checkpoint`) | **≈ 2.2 s** | ~5× faster; this is the demo-day number — **pre-sync, ship warm** | | **Cut → failover (wall-clock)** | **≈ 2–15 s** | gated by block cadence (per-block proof cache), **not** the mechanism | | **Failover mechanism alone** | ~250–500 ms | one failed EL attempt + one success on EL2, once a real `eth_getProof` is forced | @@ -177,9 +240,11 @@ So spend the privacy budget on the **EL**; the CL needs IP hygiene only, not add Implication for the demo: the beat is **"warm-start instant"** (pre-sync to ~2 s) and the cut keeps the balance verified through one block. Cold start (~11 s) is a "syncing…" state if ever shown un-pre-synced. -## Local end-to-end testing (Kurtosis) — the answer to the gating question +## Local end-to-end testing (Kurtosis) — DEFERRED (not v1-critical) -A plain **anvil** node has no consensus layer, so Helios cannot verify against it. The open question was whether the Kurtosis `ethpandaops/ethereum-package` CL serves the LC API out of the box. **Answer: yes, with zero/near-zero flags** — Lighthouse, Nimbus, Lodestar all serve the LC API **on by default**, and ethereum-package runs **all forks from genesis** (Altair + sync committee live at slot 0). Minimal config: +> **Decision (CEO review):** Kurtosis is **deferred off the v1 critical path.** The mainnet spike already proves the whole R2 beat (sync, verified balance, cut-the-EL failover, refuse-a-lie) with **zero** Kurtosis, so a local devnet is not required to ship the demo. Its only added value is a *fully offline, deterministic CI lane where you own the CL* (no public-beacon flakiness in tests) — a post-demo hardening nice-to-have, not a gate. v1 testing runs on mainnet + Sepolia public endpoints. **TODO (post-demo): build the hermetic Kurtosis CI lane** (needs the hand-written Helios devnet `Config` below). The findings below are kept so that build is cheap when we pick it up. Note: Kurtosis is *not* a wallet feature and is *not* mainnet — it's a private throwaway devnet (a few GB, laptop-fine) used only for testing; it can't be shipped to users and can't replace Helios (it's the thing Helios verifies *against* in a test). + +A plain **anvil** node has no consensus layer, so Helios cannot verify against it. The (now-answered) gating question was whether the Kurtosis `ethpandaops/ethereum-package` CL serves the LC API out of the box. **Answer: yes, with zero/near-zero flags** — Lighthouse, Nimbus, Lodestar all serve the LC API **on by default**, and ethereum-package runs **all forks from genesis** (Altair + sync committee live at slot 0). Minimal config: ```yaml # lc-devnet.yaml — CL answers the light_client/* routes out of the box @@ -190,7 +255,7 @@ participants: ``` `kurtosis run github.com/ethpandaops/ethereum-package --args-file lc-devnet.yaml`, then point Helios's `consensus_rpc`/`execution_rpc` at the enclave's mapped CL/EL ports. -- **Option A (recommended local gate):** the full Kurtosis devnet — CL and EL are internally consistent, so you can literally cut the EL on camera against a CL you control. **Requires a hand-built Helios `Config`** (the `Network` enum hardcodes mainnet's CL and the testnets are `None`) with the devnet `chain_id`, both RPCs, and a fresh checkpoint (genesis/first-finalized root). This config does not exist yet — it's a build task that gates Lane B. (`00-test-harness.md` owns it.) +- **Option A (the deferred hermetic-CI lane):** the full Kurtosis devnet — CL and EL are internally consistent, so you can cut the EL against a CL you fully control with no public dependency. **Requires a hand-built Helios `Config`** (the `Network` enum hardcodes mainnet's CL and the testnets are `None`) with the devnet `chain_id`, both RPCs, and a fresh checkpoint (genesis/first-finalized root). Not built (deferred). When picked up, coordinate with `00-test-harness.md`. - **Option B (anvil-fork EL + real mainnet CL) does NOT work** — and it's a trap worth stating: Helios verifies EL responses against the `state_root` the mainnet CL header attests to. A forked anvil matches that root only at the exact fork block with zero mutations; the instant it advances/mines, the root diverges and Helios's verification **fails** (not "works with stale data"). Plus the mainnet CL head keeps advancing while the fork doesn't, tripping the 60 s gate. Don't build the walkaway on it. - **Gotchas:** `finality_update` only returns meaningfully after ~2 epochs finalize (~12.8 min at 12 s slots) — don't assert on it immediately post-`kurtosis run`. Keep all fork epochs at 0 (default). For the EL-only failover logic, unit-test the supervisor with mocked clients (no real verify) — the spike already isolates it in `upstreams.rs`. @@ -199,30 +264,37 @@ participants: A standalone crate (own `[workspace]`, not part of deck's build) that proves the beat headless and prints the measurements above. Files mirror Deckard's intended layout: - `read_status.rs` — `ReadStatus { Verified | Degraded | Unsynced }` (Deckard-owned). - `upstreams.rs` — the failover supervisor (Shape A): `get_balance` with failover, `head()` (EL-independent), outage classification via `syncing()`. -- `proxy.rs` — a killable HTTP/1.1 reverse proxy = the on-camera "cut" (one `AtomicBool`). -- `main.rs` — the scenario + cold/warm/failover measurements; exit 0 = PASS. +- `proxy.rs` — a killable **and optionally lying** HTTP/1.1 reverse proxy: the kill switch is the on-camera "cut"; `lie=true` rewrites the `balance` in every `eth_getProof` response (a malicious RPC). +- `main.rs` — two scenarios + measurements; exit 0 = PASS. + +Run: +- **Availability (cut-the-RPC):** `cargo run --release` (warm) / `WIPE=1 cargo run --release` (cold). Defaults to the privacy-correct posture (publicnode proxied + dRPC failover + Nimbus CL). +- **Integrity (refuse a lie):** `SCENARIO=lie WIPE=1 cargo run --release`. Proven result: malicious RPC claims **1,000,000,000 ETH**, Deckard returns `REJECTED — invalid account proof` (a centralized wallet would show the billion). Note it still *syncs and serves the head through the lying RPC* — only the proof-bearing balance read catches the lie, because the head is CL-verified. -Run: `cargo run --release` (warm) or `WIPE=1 cargo run --release` (cold). Defaults to the privacy-correct posture (publicnode proxied + dRPC failover + Nimbus CL). See its README for the CL-choice and key-restricted-EL caveats. +See the README for the CL-choice and key-restricted-EL caveats. **Acceptance test (the R2 slice; the spike implements steps 1–3):** ``` -Scenario "Helios verified reads + walkaway" (mainnet hero): +Scenario "Helios verified reads" (mainnet hero): 1. build EthereumClient(EL1,CL,checkpoint); wait_synced(); poll until head servable assert: first servable head within the pre-sync window (cold ~11s / warm ~2s) 2. read a KNOWN value (deposit contract balance) at the head assert: get_balance matches an independent source; ReadStatus == Verified - 3. WALKAWAY: cut EL1 (kill the proxy) + 3. INTEGRITY (the moat): point Helios at a MALICIOUS RPC (tampered eth_getProof balance) + assert: get_balance REJECTS the read (invalid account proof); never returns the fake value + (head still syncs through the liar — only the proof-bearing read catches it) + 4. AVAILABILITY (cut-the-RPC): cut EL1 (kill the proxy) assert: supervisor fails over to EL2, returns a VERIFIED balance, head still advances - (Verified -> Degraded{failover} -> Verified), within ≤1 block + mechanism - 4. STALE CHECKPOINT: start with a >14d checkpoint + strict_checkpoint_age + (Verified -> Degraded{failover→EL2}; stays Degraded on the backup), within ≤1 block + mechanism + 5. STALE CHECKPOINT: start with a >14d checkpoint + strict_checkpoint_age assert: build/sync FAILS visibly (Unsynced); NEVER silently serves raw RPC ``` -Steps 1–3 are the on-camera beat; the same headless run + screen capture is the cut. +Steps 2–4 are the on-camera beats (verified read, refuse-a-lie, cut-the-RPC); the same headless run + screen capture is the cut. The spike implements steps 1–4 across its two scenarios (default = 1/2/4, `SCENARIO=lie` = 3); step 5 is an unwritten guard test. ## Risks & fallbacks -- **R2 — no native EL/CL failover (verified).** Live "cut and continue" needs our supervisor (Shape A). *Status: proven on mainnet.* Fallback for the EL: "verified locally, head frozen" badge if even (A) misbehaves. -- **The CL is the fragile, least-redundant dependency.** A single keyless no-SLA CL stalling >60 s on camera hard-fails *every* `Latest`-tag read — looks like a crash. And Helios doesn't auto-recover a dead CL (requires a rebuild against CL #2). *Mitigations:* self-host a Lighthouse CL as primary (removes the third-party SPOF), pre-stage CL #2 + a rebuild-on-frozen path, rehearse in the hour before, and **only ever cut the EL on camera, never the CL.** Cheapest de-risk for rehearsal: run the beat against a local Kurtosis devnet (Option A) where you own the CL. +- **R2 — no native EL/CL failover (verified).** Live "cut and continue" needs our supervisor (Shape A). *Status: the core is proven on mainnet (balance-read failover + lying-RPC rejection); the app-level wiring (EIP-1193, ReadStatus-on-wire, CL-rebuild, receive-watcher, simulate) is unbuilt.* Fallback for the EL: "verified locally, head frozen" badge if even (A) misbehaves. +- **The CL is the fragile, no-SLA dependency** (but redundancy is now real). A single CL stalling >60 s on camera hard-fails *every* `Latest`-tag read — looks like a crash. And Helios doesn't auto-recover a dead CL (requires a rebuild against CL #2). *Chosen mitigation (CEO review): public CLs, redundancy proven* — **Nimbus primary + dRPC second, both verified to drive a Helios sync** (Nimbus ~11 s, dRPC ~10.4 s). Rehearse/health-check both in the hour before, and **only ever cut the EL on camera, never the CL.** Self-host a Lighthouse CL only as the fallback if both publics look flaky at rehearsal. (Lodestar + PublicNode beacon return 200 but fail Helios sync — don't use them as CLs.) - **Cache cushion shifts failover timing.** Cut→visible-failover is ≤1 block because of the per-block proof cache; the balance holds verified through the cut (good), but the visible `Degraded` flip waits for the next block. Force it with a proactive `get_proof` on cut-detection if an instant flip is wanted. - **Checkpoint trust / community fallback "not secure."** Ship a recent pinned default + user override; run `strict_checkpoint_age`; mark community-sourced checkpoints `Degraded`. - **API churn / git-pin.** Pre-1.0, git-pinned. Keep the thin wrapper (`upstreams.rs`/`read_status.rs`) so a Helios API change touches one place. Re-verify builder method names at each bump. @@ -233,9 +305,11 @@ Steps 1–3 are the on-camera beat; the same headless run + screen capture is th - ~~Cold vs warm sync time; real failover latency~~ → **measured** (≈11 s / ≈2 s; failover ≤1 block). Re-measure on the actual demo machine. - ~~Published crates.io release?~~ → **no**; `helios-ethereum` git-only at `0.11.1` (crates.io stale at 0.1.0). - ~~alloy alignment~~ → **resolved**; unifies to one `alloy-primitives 1.6.0`. -- **Best CL provider for the hero (+ redundant second):** narrowed to Nimbus-testing (proven-syncs), PublicNode, dRPC; **Lodestar returns 200 but failed Helios sync in our test — re-verify.** Strongly consider self-hosting Lighthouse for the hero to remove the no-SLA SPOF. Resolve the **Teku** default-flag contradiction or avoid Teku. -- **Does Deckard auto-rebuild on a dead CL?** Helios won't self-heal; Deckard needs a frozen-head detector → rebuild against CL #2 (warm, ~2 s). Build task — coordinate with the read path. -- **Failover (Shape A) in the daemon read path vs behind the MCP read `Decision` resolver?** Coordinate the boundary with `30-mcp-shape.md`. (Cross-doc need — not resolved here.) +- ~~CL approach + redundant second~~ → **decided + proven:** public CLs, **Nimbus primary + dRPC second** (both verified to drive a Helios sync, ~11 s / ~10.4 s). Lodestar + PublicNode beacon fail (200 but no sync). Self-host = flaky-rehearsal fallback only. Remaining minor: resolve the **Teku** default-flag contradiction or just avoid Teku. +- ~~Does Deckard auto-rebuild on a dead CL?~~ → **specced** as a supervisor build task (frozen-head detector → rebuild against CL #2, ~2 s warm) in "Integration into the app." Not yet built. +- ~~Failover (Shape A) in the daemon read path vs MCP `Decision` resolver?~~ → **decided:** one key-less `Upstreams` in `deckard-signerd` (see "Integration into the app"). Matches `30`'s "daemon so the numbers match" lean. +- **EIP-1193 adapter for Railgun:** v1 = Helios localhost JSON-RPC server + alloy HTTP provider; production = `HeliosEip1193` Rust adapter over the supervisor. ⚠ verify Kohaku's `IntoEip1193Provider` accepts the alloy HTTP provider (10's seam). +- **`read_status` on `30`'s read responses:** proposed (define `ReadStatus` in `deckard-contract`, add the field to `wallet_balance`/`simulate`). Needs `30`'s sign-off — it owns the contract. ## Sources (repos + docs) diff --git a/docs/build/30-mcp-shape.md b/docs/build/30-mcp-shape.md index cebe8c1..ff46351 100644 --- a/docs/build/30-mcp-shape.md +++ b/docs/build/30-mcp-shape.md @@ -136,6 +136,8 @@ Read tools (no approval, key-less, safe to call freely — the "observe" half, 0 | `simulate` | local eth_call/fork against Helios state | `{ asset_changes[], gas, warnings[] }` (Tenderly-style preview, 05 [13]) | none | | `policy_get` | `SignerRequest::PolicyGet` | `Policy` snapshot | none | +> ⚠ **Cross-doc need (from `20-helios-sidecar.md` "Integration into the app"):** the `wallet_balance` and `simulate` responses must carry a `read_status` field (`ReadStatus { Verified | Degraded | Unsynced }`), and `ReadStatus` should be defined in `deckard-contract` (here) since it rides the wire. Without it the "never silently serve an untrusted read" rule isn't enforceable at the contract level. `20` owns the semantics/transitions; `30` owns the final type + field placement. + Write tools (route through `propose` → `Decision`; "execute validated intents, not raw LLM suggestions", 05 [10]): | Tool | Builds | Approval | diff --git a/docs/build/README.md b/docs/build/README.md index 1de1cd2..0bba887 100644 --- a/docs/build/README.md +++ b/docs/build/README.md @@ -51,10 +51,14 @@ against this; the harness's `FakeModel` exercises it before any LLM is in the lo - **`deckard-contract` crate** — the types above. (30 owns; 00/10/20 reference.) - **`fixtures/addresses.mainnet.json`** — Railgun + USDC + whale addresses. (00 hosts; 10 fills Railgun set.) -- **EIP-1193 provider** — Helios plugs into `RailgunBuilder::new(chain, impl IntoEip1193Provider)`; the same - in-process Helios client serves the receive-watcher's verified `eth_getLogs`. (20 provides; 10 + T-Core consume.) +- **EIP-1193 provider** — Helios plugs into `RailgunBuilder::new(chain, impl IntoEip1193Provider)`. Note (per `20` + "Integration into the app"): `EthereumClient` is *not* EIP-1193 natively, so v1 = Helios's localhost JSON-RPC + server on the primary client (no supervisor failover for Railgun's reads), prod = a `HeliosEip1193` adapter over + the supervisor. The daemon's own `wallet_balance`/`simulate` reads use the typed supervisor (with failover). + (20 provides; 10 + T-Core consume.) - **`ReadStatus { Verified | Degraded | Unsynced }`** — attached to every read; the UI/agent must see it; - **never silently fall back to untrusted RPC.** (20 owns.) + **never silently fall back to untrusted RPC.** (20 owns the semantics; the type belongs in `deckard-contract` and + the `read_status` field on `wallet_balance`/`simulate` is **proposed, not yet frozen** in `30`.) ## The two hero-beat spikes @@ -76,7 +80,7 @@ still-verified`) that an AI coding agent runs to self-verify. Green on Lane A/C ## Tracked cross-doc open questions -- ~~Does the Kurtosis CL serve the light-client beacon API out of the box, or need flags?~~ **Resolved in `20`:** yes, OOTB — Lighthouse/Nimbus/Lodestar serve LC by default and ethereum-package runs all forks from genesis (use `cl_type: lighthouse`; Teku needs a flag, avoid Grandine). Remaining `00` task: build the devnet `Config` (the `Network` enum hardcodes mainnet CL; testnets are `None`). — `00` +- ~~Does the Kurtosis CL serve the light-client beacon API out of the box, or need flags?~~ **Resolved + DEFERRED in `20`:** yes, OOTB (Lighthouse/Nimbus/Lodestar serve LC by default; use `cl_type: lighthouse`). But the mainnet spike proved R2 **without** Kurtosis, so the Kurtosis lane is **deferred off the v1 critical path** (post-demo hermetic-CI nice-to-have). v1 tests on mainnet + Sepolia public endpoints. — `20`/`00` - Does Helios's EIP-1193 provider serve the log ranges Railgun UTXO sync needs, or does Subsquid carry history? — `10`/`20` - `simulate` in the MCP binary (key-less, calls Helios) vs in the daemon (agent + approval card see identical numbers)? — `30`/`20` - `railgun` crate license inheritance vs Deckard's 0BSD posture. — `10` diff --git a/spikes/helios-walkaway/.gitignore b/spikes/helios-walkaway/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/spikes/helios-walkaway/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/spikes/helios-walkaway/Cargo.toml b/spikes/helios-walkaway/Cargo.toml new file mode 100644 index 0000000..10fb6ea --- /dev/null +++ b/spikes/helios-walkaway/Cargo.toml @@ -0,0 +1,43 @@ +# Deckard R2 spike — embed Helios, serve a verified mainnet balance, prove the +# "cut the centralized EL RPC on camera → keep serving verified reads" walkaway beat. +# +# Standalone crate (NOT a member of deck's package) so it can pull the heavy +# Helios dependency tree (revm, bls12_381, alloy 1.0.37) without bloating the app. +[package] +name = "helios-walkaway" +version = "0.1.0" +edition = "2021" +publish = false + +# Standalone — the parent dir (deck) is a [package], not a [workspace], so an empty +# [workspace] table here keeps this spike's heavy Helios deps out of deck's build. +[workspace] + +[[bin]] +name = "helios-walkaway" +path = "src/main.rs" + +[dependencies] +# Depend on `helios-ethereum` directly — NOT the umbrella `helios` crate. The +# umbrella pulls in `helios-opstack` → libp2p → a YANKED `core2 0.4.0`, which fails +# to resolve today; ethereum-only avoids opstack/linea/libp2p entirely (smaller tree, +# no p2p stack). Git-only at 0.11.1 (crates.io `helios-ethereum` is stale at 0.1.0, +# Oct 2024). Tag is "0.11.1" — NOT "v0.11.1". +helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1" } + +# Match Helios's alloy pin exactly so Address/B256/U256/BlockId unify to ONE +# alloy-primitives in the tree (no duplicate-types mismatch at the builder boundary). +alloy = { version = "1.0.37", features = ["eips", "rpc-types"] } + +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync"] } +reqwest = { version = "0.12", features = ["json"] } +eyre = "0.6" +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Helios's own workspace patches ethereum_hashing to ncitron's fork. `[patch]` does +# NOT inherit through a git dependency, so we mirror it here or the consensus crates +# fail to build. (Verified: this exact patch is in a16z/helios root Cargo.toml.) +[patch.crates-io] +ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" } diff --git a/spikes/helios-walkaway/README.md b/spikes/helios-walkaway/README.md new file mode 100644 index 0000000..c727005 --- /dev/null +++ b/spikes/helios-walkaway/README.md @@ -0,0 +1,78 @@ +# helios-walkaway — Deckard R2 spike + +Proves the **walkaway beat** on Ethereum **mainnet**, headless: embed Helios as a +library, sync a verified light client, serve a verified `eth_getBalance`, then +**cut the centralized EL RPC on camera and keep serving verified reads** by failing +over to an independent second EL. The head never freezes (it's consensus-driven); +only state reads drop and recover. + +It also **measures** the two numbers the demo timing hinges on: cold vs warm sync, +and cut→failover latency. + +## Why this is honest + +Verified against `a16z/helios @ 0.11.1` source (not docs): + +- Helios has **no native multi-EL/CL failover** — one `EthereumClient` = one EL + + one CL. The failover here is **Deckard's own supervisor** (`src/upstreams.rs`). +- The consensus client pushes each sync-committee-verified execution header into + the execution provider's cache, so **`get_block_number`/head is EL-independent** + (served from cache). Only `get_balance`→`eth_getProof` hits the EL. That's *why* + cutting EL1 leaves the head live and a second already-synced client recovers + state reads instantly — both clients re-verify against the same CL + checkpoint, + so failover is honest re-derivation, never a cached stale value. + +## Run + +Defaults are the two keyless, no-log public ELs (`publicnode` proxied/cuttable + +`drpc` failover) and the Nimbus light-client beacon API — which is exactly the +privacy-correct demo posture (no IP↔address-correlating vendor in the read path): + +```bash +# Availability (cut-the-RPC): sync, cut the primary EL, keep serving verified reads +cargo run --release # warm if a cached checkpoint exists, else cold +WIPE=1 cargo run --release # force a COLD start (wipe cached checkpoint) + +# Integrity (the moat): point Helios at a MALICIOUS RPC, watch it refuse the lie +SCENARIO=lie WIPE=1 cargo run --release +# malicious RPC claims 1,000,000,000 ETH → Deckard: REJECTED (invalid account proof) +# (a centralized wallet would display the billion; Deckard verifies the proof) +``` + +Measured on this M-series desktop (mainnet, 2026-06-05): **cold ≈ 11s, warm ≈ 2s**, +cut→failover ≈ one block (≈2–15s, gated by the per-block proof cache; the supervisor +mechanism itself is ~250–500ms). Verified deposit-contract balance ≈ 86,313,877 ETH. + +> ⚠ **CL choice matters — 200 ≠ syncs.** `CL` must serve the `light_client/*` routes +> **and** full `/eth/v2/beacon/blocks/{slot}` blocks whose `tree_hash_root` matches the +> verified header. Verified against Helios sync (2026-06-05): +> - ✅ **Nimbus** `http://testing.mainnet.beacon-api.nimbus.team` (~11 s) — HTTP, no-SLA team box +> - ✅ **dRPC** `https://eth-beacon-chain.drpc.org` (~10.4 s) — HTTPS, keyless (the proven second CL) +> - ❌ **Lodestar** `lodestar-mainnet.chainsafe.io` — 200 but head stuck at timestamp 0 +> - ❌ **PublicNode** `ethereum-beacon-api.publicnode.com` — 200 but `invalid sync committee period` + +> ⚠ **Key-restricted ELs don't work through the proxy.** An Alchemy/Infura key with +> an origin/IP allowlist returns `-32600 "origin not on whitelist"` for proxied (and +> off-allowlist) requests. Use keyless no-log providers, or a key allowed for your IP. + +Env: + +| var | meaning | default | +|--------------|--------------------------------------------|---------| +| `EL1` | primary EL (proxied + cuttable) | `https://ethereum-rpc.publicnode.com` | +| `EL2` | independent failover EL | `https://eth.drpc.org` | +| `CL` | beacon **light-client API** endpoint | `http://testing.mainnet.beacon-api.nimbus.team` | +| `CHECKPOINT` | pinned weak-subjectivity root (`0x..` B256) | community fallback (ethPandaOps) if unset | +| `DATA_DIR` | FileDB dir (warm-start cache lives here) | `$TMPDIR/deckard-helios-spike` | +| `WIPE` | set to force a COLD start | unset | +| `ADDR` | address to read | deposit contract | + +Exit code 0 = PASS (`ReadStatus` went `Verified → Degraded{failover}` and the +post-cut balance is still verified). + +## Files + +- `read_status.rs` — `ReadStatus { Verified | Degraded | Unsynced }` (Deckard-owned). +- `upstreams.rs` — the failover supervisor (Shape A). +- `proxy.rs` — a killable HTTP/1.1 reverse proxy = the on-camera "cut". +- `main.rs` — the scenario + measurements. diff --git a/spikes/helios-walkaway/src/main.rs b/spikes/helios-walkaway/src/main.rs new file mode 100644 index 0000000..c8807df --- /dev/null +++ b/spikes/helios-walkaway/src/main.rs @@ -0,0 +1,324 @@ +//! Deckard R2 spike — the Helios walkaway beat, end to end on mainnet. +//! +//! What it proves, headless: +//! 1. Embed Helios as a library, sync a verified mainnet client, serve a +//! verified `get_balance` (the deposit contract by default). +//! 2. WALKAWAY: cut the primary (centralized) EL RPC on camera and keep serving +//! a *verified* balance by failing over to an independent second EL — the +//! head never freezes (it's consensus-driven), only state reads fail and +//! recover. ReadStatus transitions Verified → Degraded{failover}. +//! 3. Measure: cold vs warm sync time, and cut→failover latency. +//! +//! Run (env-configurable): +//! EL1= EL2= +//! CL= [CHECKPOINT=<0x.. B256>] [WIPE=1 for cold] +//! cargo run --release +//! +//! Defaults wire EL2/CL to verified-live public endpoints; pass EL1 (e.g. your +//! Alchemy key) via env. See README.md. + +mod proxy; +mod read_status; +mod upstreams; + +use std::path::PathBuf; +use std::str::FromStr; +use std::time::{Duration, Instant}; + +use alloy::eips::BlockNumberOrTag; +use alloy::primitives::{utils::format_ether, Address, B256, U256}; +use eyre::{eyre, Result}; +use helios_ethereum::config::networks::Network; +use helios_ethereum::database::FileDB; +use helios_ethereum::{EthereumClient, EthereumClientBuilder}; +use tracing::info; +use tracing_subscriber::filter::{EnvFilter, LevelFilter}; + +use read_status::ReadStatus; +use upstreams::{Upstream, Upstreams}; + +struct Cfg { + el1: String, + el2: String, + cl: String, + checkpoint: Option, + data_dir: PathBuf, + addr: Address, + wipe: bool, + proxy_bind: String, +} + +fn cfg() -> Result { + let env = |k: &str| std::env::var(k).ok().filter(|s| !s.is_empty()); + Ok(Cfg { + // EL1 is proxied + cuttable. Pass your own (e.g. Alchemy) for the hero. + el1: env("EL1").unwrap_or_else(|| "https://ethereum-rpc.publicnode.com".into()), + // EL2 is the independent failover EL, wired straight through. + el2: env("EL2").unwrap_or_else(|| "https://eth.drpc.org".into()), + // Beacon light-client API. Verified to actually drive a Helios sync: Nimbus-testing + // (default) + dRPC (eth-beacon-chain.drpc.org). Lodestar/PublicNode return 200 but + // fail Helios sync — don't use them as CL. + cl: env("CL").unwrap_or_else(|| "http://testing.mainnet.beacon-api.nimbus.team".into()), + checkpoint: match env("CHECKPOINT") { + Some(s) => Some(B256::from_str(s.trim_start_matches("0x").trim_start_matches("0X")) + .or_else(|_| B256::from_str(&s)) + .map_err(|e| eyre!("bad CHECKPOINT: {e}"))?), + None => None, + }, + data_dir: env("DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("deckard-helios-spike")), + // Deposit contract — a large, stable, well-known balance. + addr: Address::from_str( + &env("ADDR").unwrap_or_else(|| "0x00000000219ab540356cBB839Cbe05303d7705Fa".into()), + )?, + wipe: env("WIPE").is_some(), + proxy_bind: env("PROXY_BIND").unwrap_or_else(|| "127.0.0.1:18545".into()), + }) +} + +/// `wait_synced()` only blocks until the **consensus** checkpoint is bootstrapped; +/// the latest **execution** head isn't pushed into the cache until the next +/// optimistic update (~one slot, ≤12s). Until then `get_block_number` fails the 60s +/// head-age gate. Poll until a fresh head is actually servable — this is the honest +/// "ready to serve verified reads" moment. +async fn wait_until_serving(client: &EthereumClient, label: &str, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + match client.get_block_number().await { + Ok(h) => return Ok(h), + Err(e) => { + if Instant::now() > deadline { + return Err(eyre!("{label}: no fresh head within {timeout:?}: {e}")); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + } +} + +/// Build a verified Helios mainnet client (FileDB → warm starts via cached checkpoint). +fn build_client(cl: &str, el: &str, checkpoint: Option, data_dir: PathBuf) -> Result { + let b = EthereumClientBuilder::::new() + .network(Network::Mainnet) + .consensus_rpc(cl)? + .execution_rpc(el)? + .data_dir(data_dir) + // strict: refuse a too-old checkpoint instead of warning → surfaces as a + // hard failure rather than a silent stale read (demo-honest). + .strict_checkpoint_age(); + let b = match checkpoint { + Some(cp) => b.checkpoint(cp), + // No user-pinned checkpoint → community fallback (ethPandaOps). Honest spike + // default; in Deckard this read path is labeled Degraded (untrusted source). + None => b.load_external_fallback(), + }; + b.with_file_db().build() +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(), + ) + .init(); + + let cfg = cfg()?; + + // SCENARIO=lie → the integrity demo (a malicious RPC, Deckard refuses the lie). + // default → the walkaway/availability demo (cut the RPC, keep serving verified). + if std::env::var("SCENARIO").ok().as_deref() == Some("lie") { + if cfg.wipe { + let _ = std::fs::remove_dir_all(&cfg.data_dir); + } + return scenario_lie(&cfg).await; + } + + let primary_dir = cfg.data_dir.join("primary"); + let secondary_dir = cfg.data_dir.join("secondary"); + + if cfg.wipe { + let _ = std::fs::remove_dir_all(&cfg.data_dir); + info!("WIPE=1 → cleared {} (forcing COLD start)", cfg.data_dir.display()); + } + let warm = primary_dir.join("checkpoint").exists(); + info!(start = if warm { "WARM (cached checkpoint present)" } else { "COLD (no cached checkpoint)" }, ""); + + // ── Step 0: stand up the killable proxy in front of EL1 ─────────────────── + let proxied_el1 = format!("http://{}", cfg.proxy_bind); + let kill = proxy::spawn(&cfg.proxy_bind, cfg.el1.clone(), false).await?; + info!(primary_el = %redact(&cfg.el1), via = %proxied_el1, secondary_el = %redact(&cfg.el2), cl = %cfg.cl, "upstreams"); + + // ── Step 1: build + sync the primary (proxied EL1), measure sync time ───── + let t = Instant::now(); + let primary = build_client(&cfg.cl, &proxied_el1, cfg.checkpoint, primary_dir.clone())?; + primary.wait_synced().await?; + let head0 = wait_until_serving(&primary, "primary", Duration::from_secs(45)).await?; + let sync_secs = t.elapsed().as_secs_f64(); // build → first verified head servable + info!( + sync_secs = format!("{sync_secs:.1}"), + kind = if warm { "warm" } else { "cold" }, + head = %head0, + "primary ready (verified head servable)" + ); + + // Secondary on the independent EL2 (wired straight through, not proxied). + let secondary = build_client(&cfg.cl, &cfg.el2, cfg.checkpoint, secondary_dir.clone())?; + secondary.wait_synced().await?; + wait_until_serving(&secondary, "secondary", Duration::from_secs(45)).await?; + info!("secondary ready"); + + let sup = Upstreams::new( + vec![ + Upstream { label: "EL1(primary)".into(), client: primary }, + Upstream { label: "EL2(failover)".into(), client: secondary }, + ], + Duration::from_secs(8), + ); + + // ── Step 2: verified read from the primary ──────────────────────────────── + let r = sup.get_balance(cfg.addr).await; + let pre = r.value.ok_or_else(|| eyre!("no verified balance before cut"))?; + assert_eq!(r.status, ReadStatus::Verified, "expected Verified before the cut"); + info!( + addr = %cfg.addr, + balance_eth = %format_ether(pre), + status = %r.status, + served_by = %r.served_by.clone().unwrap_or_default(), + "STEP 2 — verified balance (primary)" + ); + + // ── Step 3: WALKAWAY — cut EL1, keep serving verified reads via EL2 ─────── + info!("STEP 3 — ✂️ CUTTING primary EL RPC (on camera)…"); + let cut_at = Instant::now(); + kill.cut(); + + // Head keeps advancing (consensus-driven, EL-independent): prove liveness. + // State reads fail on EL1 and recover on EL2. + let deadline = Instant::now() + Duration::from_secs(30); + let (failover_latency, post, final_status) = loop { + let r = sup.get_balance(cfg.addr).await; + match (&r.status, r.value) { + (ReadStatus::Degraded { .. }, Some(v)) => { + let latency = cut_at.elapsed(); + info!( + failover_ms = latency.as_millis() as u64, + served_by = %r.served_by.clone().unwrap_or_default(), + status = %r.status, + balance_eth = %format_ether(v), + "STEP 3 — failover read is VERIFIED (recovered after the cut)" + ); + break (latency, v, r.status); + } + (status, _) => { + info!(status = %status, "…primary down, failing over"); + } + } + if Instant::now() > deadline { + return Err(eyre!("failover did not produce a verified read within 30s")); + } + tokio::time::sleep(Duration::from_millis(250)).await; + }; + + // Head still live after the cut — proves it's consensus-driven, not EL-derived. + // Read it from the PRIMARY (whose EL we just cut): get_block_number returns from + // the CL-pushed cache, so the dead-EL client still serves a fresh head. + let head1 = sup.head_of_primary().await.unwrap_or(head0); + + // ── Summary ─────────────────────────────────────────────────────────────── + println!("\n──────────────── Deckard R2 walkaway — RESULT ────────────────"); + println!(" start mode : {}", if warm { "WARM (cached checkpoint)" } else { "COLD (fresh checkpoint)" }); + println!(" primary sync time : {sync_secs:.1}s"); + println!(" pre-cut balance : {} ETH [{}]", format_ether(pre), ReadStatus::Verified); + println!(" cut→failover latency: {} ms", failover_latency.as_millis()); + println!(" post-cut balance : {} ETH [{}]", format_ether(post), final_status); + println!(" head at sync : {head0}"); + println!(" primary head post-cut: {head1} ({} — the EL1-cut client still serves the head from CL cache)", + if head1 >= head0 { "EL-independent ✓" } else { "?" }); + let drift = if post >= pre { post - pre } else { pre - post }; + let ok = matches!(final_status, ReadStatus::Degraded { .. }) && post > U256::ZERO; + println!(" balance drift : {} wei (deposits between blocks; verified either way)", drift); + println!(" VERDICT : {}", if ok { "PASS ✅ cut the RPC, still serving verified reads" } else { "FAIL ❌" }); + println!("──────────────────────────────────────────────────────────────\n"); + + if ok { Ok(()) } else { Err(eyre!("walkaway scenario failed")) } +} + +/// The integrity demo: point Helios at a MALICIOUS RPC (a proxy that rewrites the +/// balance in every `eth_getProof`) and show Deckard **refuses the lie** — because +/// it verifies the proof against the CL-signed state root, it never returns the fake +/// number. A centralized wallet would just display it. This is the real moat; +/// "cut the cable" (the default scenario) is the availability sibling of this. +async fn scenario_lie(cfg: &Cfg) -> Result<()> { + let dir = cfg.data_dir.join("liar"); + let bind = "127.0.0.1:18547"; + let lying_el = format!("http://{bind}"); + + // The proxy forwards to a REAL EL but tampers eth_getProof balances (lie = true). + let _kill = proxy::spawn(bind, cfg.el1.clone(), true).await?; + info!(real_el = %redact(&cfg.el1), via_lying_proxy = %lying_el, cl = %cfg.cl, "MALICIOUS-RPC scenario"); + + let client = build_client(&cfg.cl, &lying_el, cfg.checkpoint, dir)?; + client.wait_synced().await?; + let head = wait_until_serving(&client, "client", Duration::from_secs(45)).await?; + info!(head = %head, "synced + head servable THROUGH the lying RPC (head is CL-verified, not from the EL)"); + + // What the malicious RPC claims (ask it directly). + let claimed = raw_get_proof_balance(&lying_el, cfg.addr) + .await + .unwrap_or_else(|| "".into()); + + // What Deckard does: verify the proof → reject the tampered account. + // Tighten the assertion: only a PROOF rejection counts as a pass. An unrelated + // transport/sync error must NOT be mistaken for "caught the lie." + let (verdict, rejected) = match client.get_balance(cfg.addr, BlockNumberOrTag::Latest.into()).await { + Ok(v) => (format!("LEAKED {} ETH — verification FAILED to catch the lie!", format_ether(v)), false), + Err(e) => { + let msg = e.to_string().to_lowercase(); + let is_proof_rejection = msg.contains("proof"); + if is_proof_rejection { + (format!("REJECTED — {e}"), true) + } else { + (format!("errored for an UNRELATED reason (not a proof rejection): {e}"), false) + } + } + }; + + println!("\n──────────── Deckard: malicious-RPC detection (integrity) ────────────"); + println!(" address : {}", cfg.addr); + println!(" malicious RPC claims : {claimed} ETH (balance rewritten in eth_getProof; proof left intact)"); + println!(" Deckard get_balance : {verdict}"); + println!(" a centralized wallet : would display {claimed} ETH (no proof to check — trusts the RPC)"); + println!(" VERDICT : {}", if rejected { "PASS ✅ — Deckard refuses to be lied to" } else { "FAIL ❌" }); + println!("──────────────────────────────────────────────────────────────────────\n"); + + if rejected { Ok(()) } else { Err(eyre!("Deckard did not reject the lie")) } +} + +/// Ask an RPC directly for an address's balance via `eth_getProof` (used to show +/// what the malicious proxy claims, for contrast with what Deckard accepts). +async fn raw_get_proof_balance(url: &str, addr: Address) -> Option { + let req = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "eth_getProof", + "params": [addr.to_string(), [], "latest"], + }); + let resp = reqwest::Client::new().post(url).json(&req).send().await.ok()?; + let v: serde_json::Value = resp.json().await.ok()?; + let hex = v.get("result")?.get("balance")?.as_str()?; + let wei = U256::from_str_radix(hex.trim_start_matches("0x"), 16).ok()?; + Some(format_ether(wei).to_string()) +} + +/// Hide API keys in logs (path segments after the host). +fn redact(url: &str) -> String { + match url.split_once("://") { + Some((scheme, rest)) => { + let host = rest.split('/').next().unwrap_or(rest); + format!("{scheme}://{host}/…") + } + None => url.to_string(), + } +} diff --git a/spikes/helios-walkaway/src/proxy.rs b/spikes/helios-walkaway/src/proxy.rs new file mode 100644 index 0000000..2967aa0 --- /dev/null +++ b/spikes/helios-walkaway/src/proxy.rs @@ -0,0 +1,199 @@ +//! A tiny, *killable* — and optionally *lying* — HTTP/1.1 reverse proxy. +//! +//! Two roles, both used to demonstrate what embedding Helios actually buys: +//! +//! 1. **Killable** ("cut the centralized RPC on camera"): Helios's primary EL is +//! pointed at `http://127.0.0.1:` instead of straight at the upstream; +//! flipping the kill switch makes every request to that port fail at the +//! transport layer — a yanked cable / revoked key / firewall rule. (Availability.) +//! +//! 2. **Lying** (`lie = true`): the proxy tampers the `balance` field of every +//! `eth_getProof` response before returning it. This is a malicious/compromised +//! RPC. Helios rebuilds the account from that tampered balance, RLP-encodes it, +//! and checks it against the Merkle proof under the CL-signed state root — the +//! check **fails** (`InvalidAccountProof`) and the read is **refused**. A +//! centralized wallet would just display the lie. (Integrity — the real moat.) + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use eyre::{eyre, Result}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +/// Flip `.cut` to true to sever the proxied endpoint. +#[derive(Clone)] +pub struct KillSwitch { + cut: Arc, +} + +impl KillSwitch { + pub fn cut(&self) { + self.cut.store(true, Ordering::SeqCst); + } + #[allow(dead_code)] + pub fn is_cut(&self) -> bool { + self.cut.load(Ordering::SeqCst) + } +} + +/// Bind a proxy on `bind_addr` forwarding JSON-RPC POSTs to `upstream`. If `lie`, +/// it tampers the balance in every `eth_getProof` response (a malicious RPC). +/// Returns once the listener is bound, so the caller can immediately build a Helios +/// client against the local address. +pub async fn spawn(bind_addr: &str, upstream: String, lie: bool) -> Result { + let listener = TcpListener::bind(bind_addr) + .await + .map_err(|e| eyre!("proxy bind {bind_addr} failed: {e}"))?; + let cut = Arc::new(AtomicBool::new(false)); + let switch = KillSwitch { cut: cut.clone() }; + + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build()?; + + tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((sock, _peer)) => { + let cut = cut.clone(); + let http = http.clone(); + let upstream = upstream.clone(); + tokio::spawn(async move { + let _ = handle_conn(sock, cut, http, upstream, lie).await; + }); + } + Err(_) => break, + } + } + }); + + Ok(switch) +} + +async fn handle_conn( + mut sock: TcpStream, + cut: Arc, + http: reqwest::Client, + upstream: String, + lie: bool, +) -> Result<()> { + loop { + // Read one HTTP/1.1 request (headers + Content-Length body). + let req = match read_request(&mut sock).await? { + Some(r) => r, + None => return Ok(()), // peer closed cleanly + }; + + // THE CUT: once severed, drop every request at the transport layer. + if cut.load(Ordering::SeqCst) { + let _ = sock.shutdown().await; + return Ok(()); + } + + let is_get_proof = lie && find_subsequence(&req.body, b"eth_getProof").is_some(); + + let resp = http + .post(&upstream) + .header("content-type", "application/json") + .body(req.body) + .send() + .await; + + let mut body = match resp { + Ok(r) => r.bytes().await.unwrap_or_default().to_vec(), + // Upstream itself erred — surface a transport close so the client fails over. + Err(_) => { + let _ = sock.shutdown().await; + return Ok(()); + } + }; + + // THE LIE: tamper the proven balance. Helios will reject it. + if is_get_proof { + if let Some(tampered) = tamper_balance(&body) { + body = tampered; + } + } + + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n", + body.len() + ); + sock.write_all(head.as_bytes()).await?; + sock.write_all(&body).await?; + sock.flush().await?; + } +} + +struct ParsedRequest { + body: Vec, +} + +/// Minimal HTTP/1.1 request reader: reads until the header terminator, parses +/// `Content-Length`, then reads exactly that many body bytes. Helios's reqwest +/// client always sends Content-Length JSON POSTs (never chunked), so this is +/// sufficient. Returns `None` if the peer closed before sending anything. +async fn read_request(sock: &mut TcpStream) -> Result> { + let mut buf = Vec::with_capacity(2048); + let mut tmp = [0u8; 2048]; + + // Read until we have the full header block. + let header_end = loop { + if let Some(pos) = find_subsequence(&buf, b"\r\n\r\n") { + break pos + 4; + } + let n = sock.read(&mut tmp).await?; + if n == 0 { + return Ok(if buf.is_empty() { None } else { Some(ParsedRequest { body: Vec::new() }) }); + } + buf.extend_from_slice(&tmp[..n]); + }; + + let content_length = parse_content_length(&buf[..header_end]).unwrap_or(0); + + // Read the remaining body bytes. + while buf.len() < header_end + content_length { + let n = sock.read(&mut tmp).await?; + if n == 0 { + break; + } + buf.extend_from_slice(&tmp[..n]); + } + + let body = buf[header_end..(header_end + content_length).min(buf.len())].to_vec(); + Ok(Some(ParsedRequest { body })) +} + +fn parse_content_length(header_bytes: &[u8]) -> Option { + let text = String::from_utf8_lossy(header_bytes); + for line in text.split("\r\n") { + if let Some((k, v)) = line.split_once(':') { + if k.trim().eq_ignore_ascii_case("content-length") { + return v.trim().parse::().ok(); + } + } + } + None +} + +fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|w| w == needle) +} + +/// The bogus balance a malicious RPC claims: 1,000,000,000 ETH (1e27 wei). +pub const LIE_BALANCE_HEX: &str = "0x33b2e3c9fd0803ce8000000"; + +/// Rewrite `result.balance` in an `eth_getProof` JSON response to [`LIE_BALANCE_HEX`], +/// leaving the (real) Merkle proof untouched — so Helios's proof check will reject it. +fn tamper_balance(body: &[u8]) -> Option> { + let mut v: serde_json::Value = serde_json::from_slice(body).ok()?; + let bal = v.get_mut("result")?.get_mut("balance")?; + if !bal.is_string() { + return None; + } + *bal = serde_json::Value::String(LIE_BALANCE_HEX.to_string()); + serde_json::to_vec(&v).ok() +} diff --git a/spikes/helios-walkaway/src/read_status.rs b/spikes/helios-walkaway/src/read_status.rs new file mode 100644 index 0000000..1397a89 --- /dev/null +++ b/spikes/helios-walkaway/src/read_status.rs @@ -0,0 +1,55 @@ +//! `ReadStatus` — Deckard-owned trust label attached to every chain read. +//! +//! This is the contract the UI and the MCP agent surface see. The hard rule: +//! **never silently serve an untrusted read.** A read is either verified, or +//! visibly degraded/unsynced — never quietly trusted. +//! +//! The three states map onto *observable* Helios behavior (verified against +//! a16z/helios @ 0.11.1, `core/src/client/node.rs`): +//! +//! - `Verified` — `syncing()` returns `SyncStatus::None` (head age ≤ 60s, the +//! hard `check_head_age` gate) and the read came back from the +//! primary upstream. +//! - `Degraded` — still cryptographically verified, but off the happy path: +//! we failed over to a secondary EL, or we're on a community +//! fallback checkpoint. Trust note shown. +//! - `Unsynced` — cannot produce a verified read: sync not finished, head +//! stale past the 60s gate (CL dark / "head frozen"), or all +//! upstreams down. The UI shows a hard "NOT VERIFIED" state. + +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReadStatus { + /// Helios head fresh, reading from the primary upstream. Fully trustless. + Verified, + /// Still verified, but not on the primary path. `reason` is shown to the user. + Degraded { reason: String }, + /// No verified read is possible right now. `reason` is shown to the user. + /// Deckard MUST NOT fall back to a raw untrusted RPC to fill this gap. + Unsynced { reason: String }, +} + +impl ReadStatus { + pub fn degraded(reason: impl Into) -> Self { + ReadStatus::Degraded { reason: reason.into() } + } + pub fn unsynced(reason: impl Into) -> Self { + ReadStatus::Unsynced { reason: reason.into() } + } + /// True only when a real, verified value backs the read. (Deckard-facing API.) + #[allow(dead_code)] + pub fn is_trustworthy(&self) -> bool { + matches!(self, ReadStatus::Verified | ReadStatus::Degraded { .. }) + } +} + +impl fmt::Display for ReadStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReadStatus::Verified => write!(f, "VERIFIED"), + ReadStatus::Degraded { reason } => write!(f, "DEGRADED ({reason})"), + ReadStatus::Unsynced { reason } => write!(f, "NOT VERIFIED ({reason})"), + } + } +} diff --git a/spikes/helios-walkaway/src/upstreams.rs b/spikes/helios-walkaway/src/upstreams.rs new file mode 100644 index 0000000..e0505c0 --- /dev/null +++ b/spikes/helios-walkaway/src/upstreams.rs @@ -0,0 +1,151 @@ +//! `Upstreams` — Deckard's own multi-instance supervisor over N Helios clients. +//! +//! Helios has **no native multi-EL failover**: one `EthereumClient` is wired to +//! exactly one execution RPC and one consensus RPC (verified: `execution_rpc`/ +//! `consensus_rpc` on `EthereumClientBuilder` are single `Url`s). So the walkaway +//! beat ("cut the centralized EL RPC and keep serving verified reads") is *our* +//! logic, not Helios's. +//! +//! Design — **Shape A (multi-instance + supervisor)**, chosen because of how +//! Helios is built (verified against `core/src/client/node.rs`): +//! +//! * The **consensus** client pushes each sync-committee-verified execution +//! header into the execution provider's in-memory cache. So `get_block_number` +//! / head is **CL-driven and served from cache — it does NOT touch the EL RPC.** +//! * Only **state reads** (`get_balance` → `get_account` → `eth_getProof`) hit +//! the EL RPC, then verify the returned account against the cached header's +//! state root. +//! +//! Therefore cutting the EL leaves the head live and advancing while only state +//! reads fail — and a *second, already-synced* client recovers them instantly by +//! re-deriving the proof from an independent untrusted EL and re-verifying against +//! the same CL+checkpoint. Both clients are equally trustless; failover is honest +//! re-verification, not a cached stale value. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use alloy::eips::BlockNumberOrTag; +use alloy::primitives::{Address, U256}; +use alloy::rpc::types::SyncStatus; +use helios_ethereum::EthereumClient; + +use crate::read_status::ReadStatus; + +/// A labeled Helios client + the human name of the EL it talks to. +pub struct Upstream { + pub label: String, + pub client: EthereumClient, +} + +pub struct Upstreams { + upstreams: Vec, + active: AtomicUsize, + /// Per-read timeout. After this, a hung EL is treated as down and we fail over. + read_timeout: Duration, +} + +/// What a supervised read returns: the value (if any), its trust label, and the +/// upstream that actually served it. +pub struct Read { + pub value: Option, + pub status: ReadStatus, + pub served_by: Option, +} + +impl Upstreams { + pub fn new(upstreams: Vec, read_timeout: Duration) -> Self { + Self { + upstreams, + active: AtomicUsize::new(0), + read_timeout, + } + } + + #[allow(dead_code)] // Deckard-facing API (which upstream is live), shown in UI. + pub fn active_label(&self) -> &str { + &self.upstreams[self.active.load(Ordering::SeqCst)].label + } + + /// Read a balance with failover. Tries the active upstream first; on error or + /// timeout, walks the remaining upstreams. The first success wins and becomes + /// the new active upstream. If every upstream fails, classifies the outage as + /// `Unsynced` — and never returns an untrusted value. + pub async fn get_balance(&self, addr: Address) -> Read { + let n = self.upstreams.len(); + let start = self.active.load(Ordering::SeqCst); + let block = BlockNumberOrTag::Latest.into(); + + for offset in 0..n { + let idx = (start + offset) % n; + let up = &self.upstreams[idx]; + + let attempt = tokio::time::timeout(self.read_timeout, up.client.get_balance(addr, block)).await; + + match attempt { + Ok(Ok(value)) => { + self.active.store(idx, Ordering::SeqCst); + let status = if offset == 0 && idx == 0 { + ReadStatus::Verified + } else { + ReadStatus::degraded(format!("failover→{}", up.label)) + }; + return Read { value: Some(value), status, served_by: Some(up.label.clone()) }; + } + Ok(Err(e)) => { + tracing::warn!(upstream = %up.label, error = %e, "read failed, trying next upstream"); + } + Err(_) => { + tracing::warn!(upstream = %up.label, timeout_ms = self.read_timeout.as_millis() as u64, "read timed out, trying next upstream"); + } + } + } + + // Every EL upstream failed. Classify the outage honestly using the + // consensus-side observable: is the head itself stale (CL dark / frozen), + // or are just the ELs down? + let reason = self.classify_outage().await; + Read { value: None, status: ReadStatus::unsynced(reason), served_by: None } + } + + /// Current verified head (block number), with failover — the daemon's UI head. + /// (The spike proves EL-independence with `head_of_primary` instead; this is the + /// general failover variant the real app would use.) + #[allow(dead_code)] + pub async fn head(&self) -> Option { + let n = self.upstreams.len(); + let start = self.active.load(Ordering::SeqCst); + for offset in 0..n { + let up = &self.upstreams[(start + offset) % n]; + if let Ok(Ok(h)) = tokio::time::timeout(self.read_timeout, up.client.get_block_number()).await { + return Some(h); + } + } + None + } + + /// Head from the **primary** (index 0) client specifically, regardless of which + /// upstream is currently active. After the EL cut this STILL returns — because + /// `get_block_number` reads the CL-pushed cache, not the EL — which is the honest + /// proof that the head is EL-independent (the dead-EL client still knows the head). + pub async fn head_of_primary(&self) -> Option { + let up = self.upstreams.first()?; + tokio::time::timeout(self.read_timeout, up.client.get_block_number()) + .await + .ok()? + .ok() + } + + /// Distinguish "head frozen" (CL not delivering → `syncing()` reports Info, + /// `check_head_age` past its 60s gate) from "all ELs down but head still fresh". + async fn classify_outage(&self) -> String { + for up in &self.upstreams { + if let Ok(Ok(status)) = tokio::time::timeout(self.read_timeout, up.client.syncing()).await { + if let SyncStatus::Info(_) = status { + return "head frozen (consensus upstream not delivering)".to_string(); + } + } + } + "all execution upstreams down".to_string() + } +} From 3dc0631a31de0f8279904f7d3b577a069ae50e5c Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 6 Jun 2026 00:20:55 +0200 Subject: [PATCH 02/10] docs(helios): spike prompt for the Railgun EIP-1193 provider (T-Trustless #3) Self-contained brief a parallel agent can run to prove/disprove that Helios's localhost JSON-RPC server satisfies Kohaku railgun's IntoEip1193Provider and serves every method the read/sync path calls. Standalone, read-only, does not touch the app crates. --- spikes/eip1193-railgun-spike.md | 117 ++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 spikes/eip1193-railgun-spike.md diff --git a/spikes/eip1193-railgun-spike.md b/spikes/eip1193-railgun-spike.md new file mode 100644 index 0000000..4c376cc --- /dev/null +++ b/spikes/eip1193-railgun-spike.md @@ -0,0 +1,117 @@ +# Spike prompt: Helios as Railgun's EIP-1193 provider (Deckard T-Trustless #3) + +> Feed this whole file to a fresh coding agent. It is self-contained. Goal: prove +> (or disprove) that an embedded Helios light client can serve as the EIP-1193 +> provider Kohaku's `railgun` crate reads through, so Deckard's shield path gets +> **verified** chain reads instead of trusting a raw vendor RPC. Standalone spike — +> do **not** touch the app crates (`crates/`). + +## Context you can trust (already verified against source — don't re-litigate) + +- Helios is consumed as **`helios-ethereum`** (NOT the umbrella `helios` crate — the + umbrella pulls `helios-opstack` → libp2p → a yanked `core2 0.4.0` that won't resolve). + Git-only, tag `0.11.1` (no `v`). crates.io is stale at 0.1.0. + ```toml + helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1" } + alloy = { version = "1.0.37", features = ["eips", "rpc-types", "provider-http", "network"] } + [patch.crates-io] + ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" } + ``` + Mirror the `[patch]` or the consensus crates fail to build. `alloy` unifies to one + `alloy-primitives 1.6.0`, so types match across helios + your spike. Add an empty + `[workspace]` table to the spike's `Cargo.toml` so it stays standalone (the repo root + is a workspace). +- **Helios ships a localhost JSON-RPC server.** `EthereumClientBuilder::rpc_address(SocketAddr)` + + `with_file_db()` → on `build()`, `HeliosClient::new` spawns `jsonrpc::start(inner, addr)` + serving a **verified** local endpoint at `http://`. It serves the `eth_*` subset in + Helios's `core/src/jsonrpc/mod.rs` / `rpc.md` (getBalance, call, getCode, getLogs, + getBlockByNumber, getTransactionReceipt, getProof, chainId, blockNumber, estimateGas, + sendRawTransaction, subscribe(newHeads only), …) — **not** a full JSON-RPC surface. +- **`wait_synced()` ≠ ready.** After it returns, poll `get_block_number()` until `Ok` + (the first execution head lands ~1 slot later, ≤12s; before that every `Latest` read + fails the 60s `check_head_age` gate). +- **CL choice matters — 200 ≠ syncs.** On mainnet only **Nimbus** + (`http://testing.mainnet.beacon-api.nimbus.team`) and **dRPC** + (`https://eth-beacon-chain.drpc.org`) actually drive a Helios sync; Lodestar + + PublicNode return 200 but fail. For **Sepolia** you must supply a Sepolia beacon + light-client endpoint (`Network::Sepolia` has `consensus_rpc = None`). +- Working reference: `spikes/helios-walkaway/` already embeds Helios, syncs mainnet, and + has a killable proxy + supervisor. Reuse its `Cargo.toml` shape and the + build-then-poll-until-servable pattern. + +## The two approaches (the spike must settle which one v1 uses) + +- **v1 (least code): Helios localhost server + an alloy HTTP/EIP-1193 provider.** Build + Helios with `.rpc_address(127.0.0.1:)`, then hand `RailgunBuilder::new(chain, …)` + an alloy provider pointed at `http://127.0.0.1:`. Tradeoff: a loopback hop, and the + server is per-`EthereumClient` (no failover supervisor in front). +- **production: a Rust adapter** `struct HeliosEip1193(EthereumClient/Upstreams)` implementing + whatever trait `RailgunBuilder::new` wants, mapping `request(method, params)` → the typed + `HeliosApi`. No hop, behind the supervisor. + +The spike's job is to **prove v1 works end to end** and to discover exactly what (if anything) +forces the adapter. + +## Tasks (do in order) + +1. **Pin Kohaku's `railgun` API against source.** Read + `github.com/ethereum/kohaku/tree/master/crates/railgun/src` (esp. `builder.rs`/`provider.rs`). + Confirm the EXACT signature of `RailgunBuilder::new` and **what `impl IntoEip1193Provider` + actually is** — is it alloy's provider trait, an `ethers`-style provider, or Kohaku's own + trait? Write down the concrete trait + which provider types satisfy it. (This is the crux; + everything else depends on it.) Note the crate is alpha (`0.1.0`/`rlib`) and Sepolia-oriented. +2. **Stand up a verified Helios localhost endpoint.** Bin that builds `helios-ethereum` + `EthereumClientBuilder::::new().network(...).consensus_rpc(cl)?.execution_rpc(el)?` + `.checkpoint(b256)`/`.load_external_fallback().rpc_address("127.0.0.1:0".parse()?)` + `.with_file_db().build()?`, `wait_synced()`, poll until servable. Confirm + `curl http://127.0.0.1:` answers `eth_getBalance`/`eth_chainId` correctly (verified). + Prefer **Sepolia** if that's where the Railgun contracts/tests live (check task 1); else mainnet. +3. **Wire Railgun to it.** Construct the alloy provider over the localhost URL and pass it to + `RailgunBuilder::new(chain, provider)`. Build the Railgun client and perform the smallest + read it supports (e.g. balance / a UTXO or pool-state sync init). Confirm the read resolves + **through Helios** (watch Helios logs / the localhost server) and returns sane data. +4. **Enumerate the methods Railgun calls.** Instrument the localhost server (or a thin logging + pass-through in front of it) to record every JSON-RPC `method` Railgun invokes during + register/sync/balance. Cross-check each against Helios's served set. **Flag any method + Helios does NOT serve** (likely heavy `eth_getLogs` ranges for historical UTXO scan — note + `10-kohaku-shield.md` says Subsquid carries history, so confirm whether Railgun hits Helios + for logs at all, or only Subsquid). +5. **Decide + measure.** Does the v1 localhost path work unmodified? If a method is missing or + the trait doesn't accept an alloy HTTP provider, write the minimal adapter and note it's + required for v1 (not just prod). Measure the per-call overhead of the loopback hop vs a + direct typed `HeliosApi` call (rough is fine). + +## Constraints + +- Standalone crate at `spikes/eip1193-railgun/` with its own `[workspace]`; `.gitignore` + `target/` + `Cargo.lock`. Do **not** edit anything under `crates/` or the app. +- Read-API only — no signing, no broadcasting, no real funds. Reads/sync only. +- If the Railgun crate can't be driven standalone from Rust (alpha API), say so plainly and + fall back to: stand up the Helios localhost server, point a generic alloy provider at it, + and prove the *provider* works for the eth_* methods Railgun's docs say it uses — i.e. + de-risk the seam even if the full RailgunBuilder path is blocked. + +## Success criteria (what "done" means) + +- A clear YES/NO on: **"Does Helios's localhost server satisfy Railgun's `IntoEip1193Provider` + and serve every method Railgun's read/sync path calls?"** with the exact trait + a method list. +- A runnable spike that boots Helios + a verified localhost endpoint and drives at least one + Railgun (or generic alloy) read through it. +- A short report: v1 works as-is? / adapter required (and why)? / methods missing (→ Subsquid)? + / loopback overhead. End with a recommendation for `20-helios-sidecar.md`'s "Integration" + section (v1 localhost vs forced-adapter). + +## Report format (return this) + +``` + + v1 localhost path: WORKS / NEEDS ADAPTER / BLOCKED + + + + + + + + +``` From a24f62cdfee1de0b81ea7b15ae9628512e00d3cd Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 6 Jun 2026 01:21:00 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(signerd):=20deckard-signerd=20?= =?UTF-8?q?=E2=80=94=20process-isolated=20signer=20daemon=20+=20policy=20g?= =?UTF-8?q?ate=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator spine: a separate process owns the decrypted key, runs the real policy gate, signs + broadcasts Sends, and answers STOP. The app (and the future deckard-mcp) become key-less clients over a same-uid UDS. Contract (extends the frozen wire, in lockstep with MockSigner + 30-mcp-shape.md): - SignerRequest += Unlock{passphrase}/Lock/Resolve{request_id,approved}; SignerResponse += Unlock(UnlockOutcome{Unlocked{address}|BadPassphrase|NoVault}). - New pure policy::evaluate(&Intent,&Policy)->Decision — the ONE decision function; MockSigner now calls it (no duplicated logic; parity is unit-asserted). deckard-signerd (AGPL-3.0-or-later), lib + bin: - UDS server: length-delimited CBOR frames (4-byte BE len, max 1 MiB); SO_PEERCRED/ LOCAL_PEERCRED same-uid auth; socket 0600 in a 0700 dir; single-instance flock. - Locked<->Unlocked{vault} state machine over deckard-core's keystore (reused, not rebuilt). Unlock decrypts + holds the key (passphrase moved into Zeroizing, frame scrubbed, never logged); Lock/RevokeAll zeroize -> Locked + deny in-flight; re-unlock re-arms with a clean session. - Send-only propose/execute: process pre-checks (locked/chain_mismatch/unsupported_v1/ erc20) then evaluate; EIP-1559 sign + broadcast via config RPC; deterministic keccak(intent) request id so an Allow is executable; Resolve approval loop; TTL. - TOCTOU + spend guards at sign time: STOP denies a pre-approved request; an auto-allow is re-capped against current spend (daily cap can't be bypassed by batching); a human-approved overage is honored; Pending AND Allowed expire; broadcast is bounded by a timeout so a hung RPC can't wedge the daemon. Re-propose is idempotent. - Signer version bridge: extract the version-stable B256 scalar from core's alloy-signer-local 2.0.5 signer and reconstruct it in the daemon's 1.8.3 alloy stack. App: spawns + supervises the daemon (restart-on-crash, kill-on-exit), unlocks OVER the socket (no in-process UnlockedVault/PrivateKeySigner for signing); onboarding still seals + writes the vault, then unlocks via the daemon. Shared config dir resolver in deckard-core so app + daemon never drift. Tests (75 workspace; anvil broadcast tests skip if foundry absent, run in CI): unlock outcomes, propose matrix, off-allowlist, resolve/TTL, STOP zeroize + re-arm, TOCTOU, socket perms, mock<->evaluate parity, peer-cred check, passphrase Zeroizing, + anvil-fork sign/broadcast/receipt and the daily-cap-at-execute regression. Closes #4 --- .github/workflows/ci.yml | 6 + Cargo.lock | 21 + Cargo.toml | 8 +- crates/deckard-app/Cargo.toml | 12 + crates/deckard-app/src/main.rs | 17 +- crates/deckard-app/src/shell.rs | 271 +++++++----- crates/deckard-app/src/signer.rs | 198 +++++++++ crates/deckard-app/src/wallet.rs | 11 +- crates/deckard-contract/README.md | 9 +- crates/deckard-contract/src/lib.rs | 23 +- crates/deckard-contract/src/mock.rs | 136 +++--- crates/deckard-contract/src/policy.rs | 83 ++++ crates/deckard-contract/src/rpc.rs | 37 +- crates/deckard-contract/src/signer.rs | 9 +- crates/deckard-core/Cargo.toml | 4 + crates/deckard-core/src/config.rs | 33 ++ crates/deckard-core/src/lib.rs | 6 +- crates/deckard-signerd/Cargo.toml | 44 ++ crates/deckard-signerd/src/auth.rs | 46 ++ crates/deckard-signerd/src/client.rs | 146 +++++++ crates/deckard-signerd/src/config.rs | 111 +++++ crates/deckard-signerd/src/daemon.rs | 464 +++++++++++++++++++++ crates/deckard-signerd/src/frame.rs | 107 +++++ crates/deckard-signerd/src/lib.rs | 37 ++ crates/deckard-signerd/src/main.rs | 31 ++ crates/deckard-signerd/src/policy_store.rs | 113 +++++ crates/deckard-signerd/src/request_id.rs | 85 ++++ crates/deckard-signerd/src/server.rs | 95 +++++ crates/deckard-signerd/src/signing.rs | 64 +++ crates/deckard-signerd/src/socket.rs | 122 ++++++ crates/deckard-signerd/src/supervise.rs | 159 +++++++ crates/deckard-signerd/tests/anvil_e2e.rs | 188 +++++++++ crates/deckard-signerd/tests/common/mod.rs | 219 ++++++++++ crates/deckard-signerd/tests/daemon_e2e.rs | 386 +++++++++++++++++ crates/deckard-signerd/tests/parity.rs | 132 ++++++ docs/build/30-mcp-shape.md | 43 +- justfile | 5 + 37 files changed, 3265 insertions(+), 216 deletions(-) create mode 100644 crates/deckard-app/src/signer.rs create mode 100644 crates/deckard-core/src/config.rs create mode 100644 crates/deckard-signerd/Cargo.toml create mode 100644 crates/deckard-signerd/src/auth.rs create mode 100644 crates/deckard-signerd/src/client.rs create mode 100644 crates/deckard-signerd/src/config.rs create mode 100644 crates/deckard-signerd/src/daemon.rs create mode 100644 crates/deckard-signerd/src/frame.rs create mode 100644 crates/deckard-signerd/src/lib.rs create mode 100644 crates/deckard-signerd/src/main.rs create mode 100644 crates/deckard-signerd/src/policy_store.rs create mode 100644 crates/deckard-signerd/src/request_id.rs create mode 100644 crates/deckard-signerd/src/server.rs create mode 100644 crates/deckard-signerd/src/signing.rs create mode 100644 crates/deckard-signerd/src/socket.rs create mode 100644 crates/deckard-signerd/src/supervise.rs create mode 100644 crates/deckard-signerd/tests/anvil_e2e.rs create mode 100644 crates/deckard-signerd/tests/common/mod.rs create mode 100644 crates/deckard-signerd/tests/daemon_e2e.rs create mode 100644 crates/deckard-signerd/tests/parity.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b327f5..26e34d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,10 @@ jobs: # source of truth. rustup (preinstalled on GitHub runners) auto-installs the # pinned version and its clippy/rustfmt components on the first cargo call. - uses: Swatinem/rust-cache@v2 + # Foundry gives the integration tests a local `anvil` to broadcast against + # (deckard-signerd's anvil-fork lane). The daemon tests skip gracefully if it's absent, + # but CI installs it so the broadcast path is actually exercised. + - uses: foundry-rs/foundry-toolchain@v1 - run: cargo build --workspace - run: cargo build -p deckard-app --features tray - run: cargo test --workspace @@ -58,6 +62,8 @@ jobs: libfontconfig1-dev libfreetype6-dev \ libssl-dev \ libgtk-3-dev libayatana-appindicator3-dev libxdo-dev + # Foundry (anvil) for the deckard-signerd broadcast integration tests. + - uses: foundry-rs/foundry-toolchain@v1 - run: cargo build --workspace - run: cargo build -p deckard-app --features tray - run: cargo test --workspace diff --git a/Cargo.lock b/Cargo.lock index ebe8538..83e2ac1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2916,8 +2916,10 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-signer-local 2.0.5", + "anyhow", "deckard-contract", "deckard-core", + "deckard-signerd", "directories", "gpui", "gpui-component", @@ -2929,6 +2931,7 @@ dependencies = [ "qrcode", "serde", "serde_json", + "tokio", "tray-icon", "zeroize", ] @@ -2954,12 +2957,30 @@ dependencies = [ "argon2", "bip39", "chacha20poly1305", + "directories", "flume", "rand 0.8.6", "tokio", "zeroize", ] +[[package]] +name = "deckard-signerd" +version = "0.1.0" +dependencies = [ + "alloy", + "alloy-primitives", + "anyhow", + "ciborium", + "deckard-contract", + "deckard-core", + "nix 0.29.0", + "serde", + "serde_json", + "tokio", + "zeroize", +] + [[package]] name = "deflate64" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index c28441d..f817cd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,11 +4,17 @@ # - deckard-app the GPUI desktop app (binary `deckard`) # - deckard-core the headless engine (Ethereum provider, balances, keystore) # - deckard-contract the frozen wire contract (Intent / Decision / Policy / RPC) +# - deckard-signerd the process-isolated signer daemon (owns the key; UDS server) # # `cargo run` from the repo root still launches the app via `default-members`. [workspace] resolver = "2" -members = ["crates/deckard-app", "crates/deckard-core", "crates/deckard-contract"] +members = [ + "crates/deckard-app", + "crates/deckard-core", + "crates/deckard-contract", + "crates/deckard-signerd", +] default-members = ["crates/deckard-app"] [workspace.dependencies] diff --git a/crates/deckard-app/Cargo.toml b/crates/deckard-app/Cargo.toml index f9edaca..8b5d624 100644 --- a/crates/deckard-app/Cargo.toml +++ b/crates/deckard-app/Cargo.toml @@ -18,6 +18,10 @@ deckard-core = { path = "../deckard-core" } # The frozen wire contract (Intent / Decision / Policy / RPC + Signer + MockSigner). # The app builds the native approval card against these types; carries zero key material. deckard-contract = { path = "../deckard-contract" } +# The signer daemon, used as a LIBRARY here: the app spawns + supervises the daemon binary +# and talks to it over the socket (unlock / propose / execute). The app holds NO key — only +# the client + the supervisor. The daemon binary is built as a sibling and launched at start. +deckard-signerd = { path = "../deckard-signerd" } # Fresh GPUI, straight from Zed's git — the DEFAULT channel (Metal on macOS, wgpu on # Linux). `gpui-component` is developed against Zed's gpui HEAD, so the only way to pair @@ -37,6 +41,9 @@ gpui-component-assets = { git = "https://github.com/longbridge/gpui-component" } serde = { version = "1", features = ["derive"] } serde_json = "1" directories = "5" +# Error type that flows from deckard-core (keystore) + the signer client through the app's +# background tasks. +anyhow = "1" qrcode = "0.14" # Scrub the pending recovery phrase from memory after onboarding. zeroize = "1" @@ -49,6 +56,11 @@ tray-icon = { version = "0.24", optional = true } alloy-signer-local = { version = "2.0.5", features = ["mnemonic"] } alloy-primitives = "1.6.0" +[dev-dependencies] +# The #9 test stands up a tiny recording UDS server to prove the app's send path issues +# Propose/Execute over the socket (and signs nothing in-process). +tokio = { version = "1", features = ["rt", "net", "io-util", "macros"] } + [features] default = [] tray = ["dep:tray-icon", "dep:objc2", "dep:objc2-app-kit", "dep:objc2-foundation"] diff --git a/crates/deckard-app/src/main.rs b/crates/deckard-app/src/main.rs index 581d06c..84f3564 100644 --- a/crates/deckard-app/src/main.rs +++ b/crates/deckard-app/src/main.rs @@ -7,15 +7,16 @@ //! Fork checklist: rename the crate in `Cargo.toml`, change `APP_NAME` and the //! bundle identifier, swap `assets/icon.png`, then start editing the views. +mod onboarding; +mod palette; +mod receive; mod settings; mod settings_view; mod shell; +mod signer; mod theme; #[cfg(feature = "tray")] mod tray; -mod onboarding; -mod palette; -mod receive; mod wallet; mod welcome; @@ -35,7 +36,15 @@ pub const APP_NAME: &str = "Deckard"; // to, hang a menu item off of, and handle in a view or globally. Add your own here. gpui::actions!( deckard, - [Quit, About, OpenSettings, ToggleTheme, NewItem, GoBack, TogglePalette] + [ + Quit, + About, + OpenSettings, + ToggleTheme, + NewItem, + GoBack, + TogglePalette + ] ); fn main() { diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index f8e2e50..47566df 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -15,13 +15,21 @@ use gpui_component::{ v_flex, ActiveTheme, IconName, TitleBar, }; -use deckard_core::{Address, EthProvider, KdfParams, Portfolio, UnlockedVault, Vault, WordCount}; +use deckard_core::{Address, EthProvider, KdfParams, Portfolio, Vault, WordCount}; use zeroize::Zeroizing; +use deckard_signerd::SignerClient; + use crate::settings::{Settings, ThemeModePref}; +use crate::signer::{self, AppSigner}; use crate::theme::{self, Accent}; use crate::wallet; -use crate::{GoBack, NewItem, OpenSettings, ToggleTheme, TogglePalette, APP_NAME}; +use crate::{GoBack, NewItem, OpenSettings, TogglePalette, ToggleTheme, APP_NAME}; + +/// The chain the supervised daemon signs for. v1 is mainnet-first (the default RPC is +/// mainnet); multi-chain app config that re-points both the reader and the daemon is a +/// fast-follow. +const DAEMON_CHAIN_ID: u64 = 1; /// Trim a noisy provider error down to one short line for the UI. fn short_err(e: impl std::fmt::Display) -> String { @@ -30,6 +38,20 @@ fn short_err(e: impl std::fmt::Display) -> String { line.chars().take(140).collect() } +/// Run `prework` (which seals + writes the keystore for create/import/migrate, or is a no-op +/// for a plain unlock), then unlock OVER THE DAEMON SOCKET — the key is decrypted in the +/// daemon, never here. Returns the wallet address or a one-line, user-facing error. Always +/// called from a background thread (it blocks on Argon2 + a socket round-trip). +fn write_then_unlock( + client: &SignerClient, + passphrase: &str, + prework: impl FnOnce() -> anyhow::Result<()>, +) -> Result { + prework().map_err(short_err)?; + let outcome = client.unlock_blocking(passphrase).map_err(short_err)?; + signer::address_or_error(outcome) +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum Route { Welcome, @@ -72,10 +94,13 @@ pub struct Shell { pub auth_error: Option, /// True while an Argon2 create/unlock runs on a background thread. pub auth_busy: bool, - /// The unlocked wallet's own address (for Receive / copy). `None` until unlocked. + /// The unlocked wallet's own address (for Receive / copy). `None` until unlocked. This is + /// the ONLY wallet identity the app holds — the key lives in the daemon, never here. pub wallet_address: Option
, - /// The in-memory unlocked wallet; dropped (and zeroized) on lock. - unlocked: Option, + /// The key-less bridge to the process-isolated signer daemon: the app spawns + supervises + /// it and talks over the socket (unlock / propose / execute). Unlock happens *in the + /// daemon*; the app only learns the address. Dropping this kills the daemon child. + signer: AppSigner, /// During create: the sealed-but-unwritten vault and its phrase, pending backup. pending_vault: Option, pub pending_phrase: Option>, @@ -142,18 +167,21 @@ impl Shell { .placeholder("https://… (default: bundled public RPC)") .default_value(settings.rpc_url.clone()) }); - cx.subscribe(&rpc_input, |this, state, event: &InputEvent, cx| match event { - InputEvent::Change => { - this.settings.rpc_url = state.read(cx).value().to_string(); - this.settings.save(); - } - InputEvent::Blur => { - this.settings.rpc_url = state.read(cx).value().to_string(); - this.settings.save(); - this.respawn_provider(cx); - } - _ => {} - }) + cx.subscribe( + &rpc_input, + |this, state, event: &InputEvent, cx| match event { + InputEvent::Change => { + this.settings.rpc_url = state.read(cx).value().to_string(); + this.settings.save(); + } + InputEvent::Blur => { + this.settings.rpc_url = state.read(cx).value().to_string(); + this.settings.save(); + this.respawn_provider(cx); + } + _ => {} + }, + ) .detach(); // Watch address / ENS: persist as typed; re-target the portfolio on blur. @@ -162,18 +190,21 @@ impl Shell { .placeholder("0x… or name.eth (blank = your wallet)") .default_value(settings.watch_address.clone()) }); - cx.subscribe(&watch_input, |this, state, event: &InputEvent, cx| match event { - InputEvent::Change => { - this.settings.watch_address = state.read(cx).value().to_string(); - this.settings.save(); - } - InputEvent::Blur => { - this.settings.watch_address = state.read(cx).value().to_string(); - this.settings.save(); - this.retarget(cx); - } - _ => {} - }) + cx.subscribe( + &watch_input, + |this, state, event: &InputEvent, cx| match event { + InputEvent::Change => { + this.settings.watch_address = state.read(cx).value().to_string(); + this.settings.save(); + } + InputEvent::Blur => { + this.settings.watch_address = state.read(cx).value().to_string(); + this.settings.save(); + this.retarget(cx); + } + _ => {} + }, + ) .detach(); // Auth inputs — passphrases are masked and NEVER persisted to disk. @@ -185,10 +216,12 @@ impl Shell { let create_pass2 = masked(window, cx, "Confirm passphrase"); let import_pass = masked(window, cx, "Choose a passphrase (min 8 characters)"); let pass_input = masked(window, cx, "Passphrase"); - let confirm_words = - cx.new(|cx| InputState::new(window, cx).placeholder("the requested words, space-separated")); - let import_secret = cx - .new(|cx| InputState::new(window, cx).placeholder("12 / 24-word phrase, or a 0x private key")); + let confirm_words = cx.new(|cx| { + InputState::new(window, cx).placeholder("the requested words, space-separated") + }); + let import_secret = cx.new(|cx| { + InputState::new(window, cx).placeholder("12 / 24-word phrase, or a 0x private key") + }); // Submit-on-Enter for each auth field (keyboard-first). cx.subscribe(&create_pass2, |this, _, event: &InputEvent, cx| { @@ -231,6 +264,11 @@ impl Shell { let current_rpc = settings.effective_rpc(); let eth = EthProvider::spawn(current_rpc.clone()); + // Spawn + supervise the process-isolated signer daemon. It owns the key; the app is a + // key-less client that unlocks/signs over the socket. The daemon broadcasts via the + // same RPC the app reads from. + let signer = AppSigner::launch(current_rpc.clone(), DAEMON_CHAIN_ID); + Self { focus_handle, route: Route::Welcome, @@ -244,7 +282,7 @@ impl Shell { auth_error: None, auth_busy: false, wallet_address: None, - unlocked: None, + signer, pending_vault: None, pending_phrase: None, pending_pass: None, @@ -303,9 +341,14 @@ impl Shell { } } - /// Lock the wallet: drop (zeroize) the unlocked secret and return to the unlock gate. + /// Lock the wallet: tell the daemon to zeroize the key (best-effort, off the UI thread) + /// and return to the unlock gate. The app held no key to drop — locking is the daemon's job. pub fn lock(&mut self, cx: &mut Context) { - self.unlocked = None; + let client = self.signer.client(); + cx.background_spawn(async move { + let _ = client.lock_blocking(); + }) + .detach(); self.wallet_address = None; self.portfolio = None; self.auth = AuthStep::Unlock; @@ -401,24 +444,24 @@ impl Shell { cx.notify(); return; }; + let client = self.signer.client(); let task = cx.background_spawn(async move { - vault.write_atomic(&path)?; - vault.unlock(pass.as_str()) + write_then_unlock(&client, pass.as_str(), move || vault.write_atomic(&path)) }); cx.spawn(async move |this, cx| { let res = task.await; this.update(cx, |this, cx| { this.auth_busy = false; match res { - Ok(unlocked) => { + Ok(addr) => { wallet::delete_legacy_key(); this.pending_phrase = None; this.pending_pass = None; this.pending_vault = None; - this.finish_unlock(unlocked, cx); + this.finish_unlock(addr, cx); } - Err(e) => { - this.auth_error = Some(short_err(e)); + Err(msg) => { + this.auth_error = Some(msg); cx.notify(); } } @@ -456,34 +499,38 @@ impl Shell { }; let secret = Zeroizing::new(secret); let pass = Zeroizing::new(pass); + let seal_pass = pass.clone(); + let client = self.signer.client(); let task = cx.background_spawn(async move { - let trimmed = secret.trim(); - // Route by shape, not word count: a pure-hex string (optional 0x) is a raw key; - // anything with spaces/words is a mnemonic, so a short/long phrase gets a real - // BIP-39 error rather than a misleading "must be 32 bytes". - let h = trimmed.strip_prefix("0x").unwrap_or(trimmed); - let looks_like_hex_key = !trimmed.contains(char::is_whitespace) - && !h.is_empty() - && h.chars().all(|c| c.is_ascii_hexdigit()); - let vault = if looks_like_hex_key { - Vault::import_raw_key(trimmed, pass.as_str(), KdfParams::PRODUCTION)? - } else { - Vault::import_mnemonic(trimmed, pass.as_str(), KdfParams::PRODUCTION)? - }; - vault.write_atomic(&path)?; - vault.unlock(pass.as_str()) + write_then_unlock(&client, pass.as_str(), move || { + let trimmed = secret.trim(); + // Route by shape, not word count: a pure-hex string (optional 0x) is a raw + // key; anything with spaces/words is a mnemonic, so a short/long phrase gets a + // real BIP-39 error rather than a misleading "must be 32 bytes". + let h = trimmed.strip_prefix("0x").unwrap_or(trimmed); + let looks_like_hex_key = !trimmed.contains(char::is_whitespace) + && !h.is_empty() + && h.chars().all(|c| c.is_ascii_hexdigit()); + let vault = if looks_like_hex_key { + Vault::import_raw_key(trimmed, seal_pass.as_str(), KdfParams::PRODUCTION)? + } else { + Vault::import_mnemonic(trimmed, seal_pass.as_str(), KdfParams::PRODUCTION)? + }; + vault.write_atomic(&path)?; + Ok(()) + }) }); cx.spawn(async move |this, cx| { let res = task.await; this.update(cx, |this, cx| { this.auth_busy = false; match res { - Ok(unlocked) => { + Ok(addr) => { wallet::delete_legacy_key(); - this.finish_unlock(unlocked, cx); + this.finish_unlock(addr, cx); } - Err(e) => { - this.auth_error = Some(short_err(e)); + Err(msg) => { + this.auth_error = Some(msg); cx.notify(); } } @@ -507,25 +554,19 @@ impl Shell { self.auth_error = None; self.auth_busy = true; cx.notify(); - let Some(path) = wallet::vault_path() else { - self.auth_error = Some("no config directory available".into()); - self.auth_busy = false; - cx.notify(); - return; - }; + // No vault write: the daemon reads the existing keystore and decrypts it. let pass = Zeroizing::new(pass); - let task = cx.background_spawn(async move { - let vault = Vault::read(&path)?; - vault.unlock(pass.as_str()) - }); + let client = self.signer.client(); + let task = cx + .background_spawn(async move { write_then_unlock(&client, pass.as_str(), || Ok(())) }); cx.spawn(async move |this, cx| { let res = task.await; this.update(cx, |this, cx| { this.auth_busy = false; match res { - Ok(unlocked) => this.finish_unlock(unlocked, cx), - Err(e) => { - this.auth_error = Some(short_err(e)); + Ok(addr) => this.finish_unlock(addr, cx), + Err(msg) => { + this.auth_error = Some(msg); cx.notify(); } } @@ -561,23 +602,28 @@ impl Shell { return; }; let pass = Zeroizing::new(pass); + let seal_pass = pass.clone(); let hex = Zeroizing::new(hex); + let client = self.signer.client(); let task = cx.background_spawn(async move { - let vault = Vault::import_raw_key(hex.as_str(), pass.as_str(), KdfParams::PRODUCTION)?; - vault.write_atomic(&path)?; - vault.unlock(pass.as_str()) + write_then_unlock(&client, pass.as_str(), move || { + let vault = + Vault::import_raw_key(hex.as_str(), seal_pass.as_str(), KdfParams::PRODUCTION)?; + vault.write_atomic(&path)?; + Ok(()) + }) }); cx.spawn(async move |this, cx| { let res = task.await; this.update(cx, |this, cx| { this.auth_busy = false; match res { - Ok(unlocked) => { + Ok(addr) => { wallet::delete_legacy_key(); - this.finish_unlock(unlocked, cx); + this.finish_unlock(addr, cx); } - Err(e) => { - this.auth_error = Some(short_err(e)); + Err(msg) => { + this.auth_error = Some(msg); cx.notify(); } } @@ -587,27 +633,21 @@ impl Shell { .detach(); } - /// Land in the unlocked app: stash the wallet, derive its address, fetch the portfolio. - fn finish_unlock(&mut self, unlocked: UnlockedVault, cx: &mut Context) { - match unlocked.primary_address() { - Ok(addr) => { - self.wallet_address = Some(addr); - self.unlocked = Some(unlocked); - self.auth = AuthStep::Ready; - self.auth_error = None; - self.route = Route::Welcome; - self.retarget(cx); - } - Err(e) => { - self.auth_error = Some(short_err(e)); - cx.notify(); - } - } + /// Land in the unlocked app: stash the address the daemon returned and fetch the + /// portfolio. The key stays in the daemon — the app only holds this address. + fn finish_unlock(&mut self, address: Address, cx: &mut Context) { + self.wallet_address = Some(address); + self.auth = AuthStep::Ready; + self.auth_error = None; + self.route = Route::Welcome; + self.retarget(cx); } /// The unlocked wallet's own address as an EIP-55 string (empty until unlocked). pub fn wallet_address_string(&self) -> String { - self.wallet_address.map(|a| a.to_string()).unwrap_or_default() + self.wallet_address + .map(|a| a.to_string()) + .unwrap_or_default() } /// Auto-focus the primary input for the current auth step (so the user — and the @@ -715,20 +755,21 @@ impl Shell { return; } match res { - Ok(Ok(addr)) => { - this.display_address = addr; - this.refresh_portfolio(cx); - } - Ok(Err(e)) => { - this.portfolio_loading = false; - this.portfolio_error = Some(format!("couldn't resolve name — {}", short_err(e))); - cx.notify(); - } - Err(_) => { - this.portfolio_loading = false; - this.portfolio_error = Some("network worker stopped".into()); - cx.notify(); - } + Ok(Ok(addr)) => { + this.display_address = addr; + this.refresh_portfolio(cx); + } + Ok(Err(e)) => { + this.portfolio_loading = false; + this.portfolio_error = + Some(format!("couldn't resolve name — {}", short_err(e))); + cx.notify(); + } + Err(_) => { + this.portfolio_loading = false; + this.portfolio_error = Some("network worker stopped".into()); + cx.notify(); + } } }) .ok(); @@ -739,6 +780,12 @@ impl Shell { /// Re-spawn the network worker against the RPC URL, but only if it actually changed — /// so a no-op blur of the RPC field doesn't tear down the live worker and refetch. + /// + /// v1 limitation: this re-points only the *reader*. The signer daemon's RPC + chain are + /// fixed at launch (mainnet-first), so changing the RPC here does NOT re-point where the + /// daemon would broadcast. There is no send UI yet (T-UX), so nothing broadcasts through a + /// diverged endpoint; re-pointing the daemon (and forcing a re-unlock) lands with the send + /// screen. pub fn respawn_provider(&mut self, cx: &mut Context) { let url = self.settings.effective_rpc(); if url == self.current_rpc { diff --git a/crates/deckard-app/src/signer.rs b/crates/deckard-app/src/signer.rs new file mode 100644 index 0000000..a236711 --- /dev/null +++ b/crates/deckard-app/src/signer.rs @@ -0,0 +1,198 @@ +//! The app's key-less bridge to `deckard-signerd`. +//! +//! This is the whole signing story for the GUI: the app **spawns + supervises** the daemon +//! and talks to it over the socket. It holds NO key material — no `UnlockedVault`, no +//! `PrivateKeySigner`. Unlock happens *in the daemon* (the app sends the passphrase and gets +//! back only an address); the send path sends an `Intent` and gets back a `Decision`/tx hash. +//! The keystore is only ever touched in-process by *onboarding* (to write `vault.bin`), never +//! to sign. + +use alloy_primitives::{Address, B256}; +use deckard_contract::{Decision, ExecuteResult, Intent, RequestId, UnlockOutcome}; +use deckard_signerd::{DaemonSupervisor, SignerClient}; + +/// Result of the app's send path (propose, then execute on `Allow`). The path is implemented +/// and unit-tested here; the GUI send screen that calls it is T-UX (out of scope), so no view +/// invokes it yet. +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq)] +pub enum SendOutcome { + /// Signed + broadcast by the daemon. + Broadcast { tx_hash: B256 }, + /// Over cap / approval-required: a card must be approved, then `execute(request_id)`. + NeedsApproval { request_id: RequestId }, + /// Refused (locked, off-allowlist, chain mismatch, …). + Denied { reason: String }, +} + +/// Owns the supervised daemon child and a client to its socket. Dropping it stops the +/// supervisor and kills the daemon. +pub struct AppSigner { + _supervisor: DaemonSupervisor, + client: SignerClient, +} + +impl AppSigner { + /// Launch + supervise the daemon and return a key-less handle to it. `rpc_url`/`chain_id` + /// are passed to the daemon so it broadcasts on the same chain the app reads from. + pub fn launch(rpc_url: String, chain_id: u64) -> Self { + let socket_path = deckard_signerd::socket::default_socket_path(); + let supervisor = DaemonSupervisor::spawn(socket_path.clone(), rpc_url, chain_id); + let client = SignerClient::new(socket_path); + Self { + _supervisor: supervisor, + client, + } + } + + /// A cloneable client for background tasks (the supervisor stays owned by the app). The + /// shell uses this for unlock/lock/send so the work runs off the UI thread. + pub fn client(&self) -> SignerClient { + self.client.clone() + } +} + +/// The send path, key-less: `propose` → on `Allow`, `execute`. Never signs in-process — it +/// only issues `Propose`/`Execute` over the socket. Free function over a [`SignerClient`] so +/// background threads can call it with a cheap clone. (Awaiting the T-UX send screen; proven +/// by the unit test below.) +#[allow(dead_code)] +pub fn send_blocking(client: &SignerClient, intent: &Intent) -> anyhow::Result { + use deckard_contract::SignerRequest; + + let decision = match client.request_blocking(&SignerRequest::Propose { + intent: intent.clone(), + })? { + deckard_contract::SignerResponse::Decision(d) => d, + other => anyhow::bail!("unexpected propose response: {other:?}"), + }; + match decision { + Decision::Deny { reason } => Ok(SendOutcome::Denied { reason }), + Decision::NeedsApproval { request_id } => Ok(SendOutcome::NeedsApproval { request_id }), + Decision::Allow => { + // The daemon assigns a deterministic id; derive it locally to execute the Allow. + let id = SignerClient::request_id_for_intent(intent); + match client.request_blocking(&SignerRequest::Execute { request_id: id })? { + deckard_contract::SignerResponse::Execute(ExecuteResult::Broadcast { tx_hash }) => { + Ok(SendOutcome::Broadcast { tx_hash }) + } + deckard_contract::SignerResponse::Execute(ExecuteResult::Denied { reason }) => { + Ok(SendOutcome::Denied { reason }) + } + other => anyhow::bail!("unexpected execute response: {other:?}"), + } + } + } +} + +/// Interpret an [`UnlockOutcome`] into either the wallet address or a user-facing error. +pub fn address_or_error(outcome: UnlockOutcome) -> Result { + match outcome { + UnlockOutcome::Unlocked { address } => Ok(address), + UnlockOutcome::BadPassphrase => { + Err("Wrong passphrase, or the vault was tampered with".to_string()) + } + UnlockOutcome::NoVault => Err("No wallet found — create or import one first".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Bytes, U256}; + use deckard_contract::{ExecuteResult, IntentKind, SignerRequest, SignerResponse}; + use deckard_signerd::frame; + use std::sync::mpsc; + use std::sync::{Arc, Mutex}; + + fn send_intent() -> Intent { + Intent { + chain_id: 31337, + to: Address::repeat_byte(0x22), + token: None, + value: U256::from(1_000u64), + calldata: Bytes::new(), + kind: IntentKind::Send, + } + } + + /// #9: the app's send path issues `Propose` then `Execute` over the socket — proving it + /// signs nothing in-process (it holds no key; it only speaks the wire). A tiny recording + /// UDS server stands in for the daemon and replies `Allow` then `Broadcast`. + #[test] + fn send_path_issues_propose_then_execute_over_the_socket() { + let dir = + std::env::temp_dir().join(format!("deckard-appsigner-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let sock = dir.join("signerd.sock"); + + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seen_srv = Arc::clone(&seen); + let sock_srv = sock.clone(); + let (ready_tx, ready_rx) = mpsc::channel(); + + // Recording server on its own current-thread runtime; handles two per-call + // connections (Propose, then Execute). + let server = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = tokio::net::UnixListener::bind(&sock_srv).unwrap(); + ready_tx.send(()).unwrap(); + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let buf = frame::read_frame(&mut stream).await.unwrap().unwrap(); + let req: SignerRequest = frame::decode(&buf).unwrap(); + let resp = match &req { + SignerRequest::Propose { .. } => { + seen_srv.lock().unwrap().push("Propose".into()); + SignerResponse::Decision(Decision::Allow) + } + SignerRequest::Execute { .. } => { + seen_srv.lock().unwrap().push("Execute".into()); + SignerResponse::Execute(ExecuteResult::Broadcast { + tx_hash: B256::repeat_byte(0xAB), + }) + } + other => panic!("unexpected request on the wire: {other:?}"), + }; + let body = frame::encode(&resp).unwrap(); + frame::write_frame(&mut stream, &body).await.unwrap(); + } + }); + }); + + ready_rx.recv().unwrap(); + + let client = SignerClient::new(sock); + let outcome = send_blocking(&client, &send_intent()).unwrap(); + + assert_eq!( + outcome, + SendOutcome::Broadcast { + tx_hash: B256::repeat_byte(0xAB) + } + ); + assert_eq!( + *seen.lock().unwrap(), + vec!["Propose".to_string(), "Execute".to_string()] + ); + + server.join().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unlock_outcomes_map_to_address_or_message() { + let addr = Address::repeat_byte(0x11); + assert_eq!( + address_or_error(UnlockOutcome::Unlocked { address: addr }), + Ok(addr) + ); + assert!(address_or_error(UnlockOutcome::BadPassphrase).is_err()); + assert!(address_or_error(UnlockOutcome::NoVault).is_err()); + } +} diff --git a/crates/deckard-app/src/wallet.rs b/crates/deckard-app/src/wallet.rs index d979336..8500e33 100644 --- a/crates/deckard-app/src/wallet.rs +++ b/crates/deckard-app/src/wallet.rs @@ -7,19 +7,18 @@ use std::fs; use std::path::PathBuf; -use directories::ProjectDirs; - -/// The platform config dir (`~/Library/Application Support/com.deckard.Deckard` on macOS). +/// The platform config dir (`~/Library/Application Support/com.deckard.Deckard` on macOS), +/// created if missing. The path itself is resolved by `deckard-core` so the app, onboarding, +/// and the signer daemon all agree on where `vault.bin` lives. fn config_dir() -> Option { - let dirs = ProjectDirs::from("com", "deckard", "Deckard")?; - let dir = dirs.config_dir().to_path_buf(); + let dir = deckard_core::config_dir()?; fs::create_dir_all(&dir).ok()?; Some(dir) } /// Where the encrypted keystore lives. pub fn vault_path() -> Option { - Some(config_dir()?.join("vault.bin")) + Some(config_dir()?.join(deckard_core::config::VAULT_FILE)) } /// The legacy plaintext key from the pre-keystore build (raw 32-byte hex). diff --git a/crates/deckard-contract/README.md b/crates/deckard-contract/README.md index c193929..f7ac14b 100644 --- a/crates/deckard-contract/README.md +++ b/crates/deckard-contract/README.md @@ -7,13 +7,14 @@ This crate is the single source of truth for the wire every Deckard process spea - **`Intent`** — the only thing that crosses `deckard-mcp → deckard-signerd` for a write. Carries `chain_id` (multi-chain ready); the daemon owns the nonce. - **`Decision`** — the daemon's verdict from `propose`: `Allow` / `Deny{reason}` / `NeedsApproval{request_id}`. - **`Policy`** — the agent-readable spending fence (caps, allowlist, approval mode, `revoked`). -- **RPC enums** (`SignerRequest` / `SignerResponse` / `ExecuteResult` / `ApprovalStatus` / `BalanceReport`) — the daemon socket API. serde-derived → CBOR (ciborium) on the UDS, JSON for MCP. -- **`Signer`** — a *sync* trait; the real UDS client does a fast blocking round-trip off the UI thread (an async wrapper is the daemon ticket's call). -- **`MockSigner`** — an in-memory, deterministic implementation so T-Agent, T-UX, and the test harness can build and run the acceptance scenario **before** the real signer daemon exists. +- **`evaluate(&Intent, &Policy) -> Decision`** — the **one** pure decision function. Both `MockSigner` and the real `deckard-signerd` call it, so the verdict can never drift between the mock and the daemon (parity is unit-asserted). It returns `RequestId::ZERO` as a placeholder for `NeedsApproval`; the stateful caller mints the real id. +- **RPC enums** (`SignerRequest` / `SignerResponse` / `ExecuteResult` / `ApprovalStatus` / `BalanceReport` / `UnlockOutcome`) — the daemon socket API. `SignerRequest` includes `Unlock{passphrase}` / `Lock` / `Resolve{request_id, approved}` for the daemon's lock state machine + approval loop (`Unlock` → `SignerResponse::Unlock(UnlockOutcome)`; `Lock`/`Resolve` → `Ack`). serde-derived → CBOR (ciborium) on the UDS, JSON for MCP. +- **`Signer`** — a *sync* trait (`unlock`/`lock`/`resolve`/`address`/`balance`/`policy`/`propose`/`execute`/`status`/`revoke_all`); the real UDS client does a fast blocking round-trip off the UI thread (an async wrapper is the daemon ticket's call). +- **`MockSigner`** — an in-memory, deterministic implementation (calls `evaluate`, no duplicated decision logic) so T-Agent, T-UX, and the test harness can build and run the acceptance scenario **before** the real signer daemon exists. ## Zero key material -This crate carries **no key material at all** — types + a trait + a mock. It never signs, never holds a key. The key boundary is the daemon's process (`deckard-signerd`, owned by `docs/build/00-test-harness.md`), not this crate. +This crate carries **no key material at all** — types + a trait + a mock. It never signs, never holds a key. The key boundary is the daemon's process (`crates/deckard-signerd`; cross-process red-team owned by `docs/build/00-test-harness.md`), not this crate. ## Deterministic mock diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index 932227e..3cf7df1 100644 --- a/crates/deckard-contract/src/lib.rs +++ b/crates/deckard-contract/src/lib.rs @@ -32,8 +32,10 @@ pub mod signer; pub use decision::{Decision, RequestId}; pub use intent::{Intent, IntentKind}; pub use mock::MockSigner; -pub use policy::{ApprovalMode, Policy}; -pub use rpc::{ApprovalStatus, BalanceReport, ExecuteResult, SignerRequest, SignerResponse}; +pub use policy::{evaluate, ApprovalMode, Policy}; +pub use rpc::{ + ApprovalStatus, BalanceReport, ExecuteResult, SignerRequest, SignerResponse, UnlockOutcome, +}; pub use signer::Signer; #[cfg(test)] @@ -143,6 +145,18 @@ mod roundtrip_tests { #[test] fn signer_request_roundtrip() { + roundtrip(&SignerRequest::Unlock { + passphrase: "correct horse battery staple".into(), + }); + roundtrip(&SignerRequest::Lock); + roundtrip(&SignerRequest::Resolve { + request_id: B256::repeat_byte(0x04), + approved: true, + }); + roundtrip(&SignerRequest::Resolve { + request_id: B256::repeat_byte(0x05), + approved: false, + }); roundtrip(&SignerRequest::Propose { intent: sample_intent(IntentKind::Shield), }); @@ -161,6 +175,11 @@ mod roundtrip_tests { #[test] fn signer_response_roundtrip() { + roundtrip(&SignerResponse::Unlock(UnlockOutcome::Unlocked { + address: Address::repeat_byte(0x11), + })); + roundtrip(&SignerResponse::Unlock(UnlockOutcome::BadPassphrase)); + roundtrip(&SignerResponse::Unlock(UnlockOutcome::NoVault)); roundtrip(&SignerResponse::Decision(Decision::Allow)); roundtrip(&SignerResponse::Execute(ExecuteResult::Broadcast { tx_hash: B256::repeat_byte(0xAB), diff --git a/crates/deckard-contract/src/mock.rs b/crates/deckard-contract/src/mock.rs index 0479304..6078ea6 100644 --- a/crates/deckard-contract/src/mock.rs +++ b/crates/deckard-contract/src/mock.rs @@ -12,9 +12,9 @@ use std::sync::Mutex; use alloy_primitives::{Address, B256, U256}; use crate::decision::{Decision, RequestId}; -use crate::intent::{Intent, IntentKind}; -use crate::policy::{ApprovalMode, Policy}; -use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult}; +use crate::intent::Intent; +use crate::policy::{self, Policy}; +use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult, UnlockOutcome}; use crate::signer::Signer; /// One tracked proposal. `status` is the wire-visible approval state; `broadcast` is `Some` @@ -37,6 +37,11 @@ struct Requests { /// An in-memory signer. The `policy` and `requests` locks are always acquired **policy /// before requests**, so the pair can never deadlock; `balance` is only ever taken alone. +/// +/// The mock holds no real key, so its `Locked`/`Unlocked` state is modelled by the +/// `Policy::revoked` brake: `lock`/`revoke_all` trip it (deny everything), `unlock` clears +/// it (re-arm). This mirrors the daemon, where `Lock` and `RevokeAll` both reach `Locked` +/// and only a fresh `Unlock` re-arms. #[derive(Debug)] pub struct MockSigner { policy: Mutex, @@ -78,14 +83,9 @@ impl MockSigner { } /// Test helper: flip a `Pending` request to `Allowed`, simulating the human tapping - /// Approve on the native card. No-op for any other state. + /// Approve on the native card. Thin wrapper over [`Signer::resolve`]. pub fn approve(&self, request_id: RequestId) { - let mut reqs = self.requests.lock().expect("mock requests mutex poisoned"); - if let Some(req) = reqs.by_id.get_mut(&request_id) { - if req.status == ApprovalStatus::Pending { - req.status = ApprovalStatus::Allowed; - } - } + self.resolve(request_id, true); } /// Test helper: the id of the most recently minted request, or `None` if none yet. @@ -113,20 +113,44 @@ impl MockSigner { } } -/// Mock decodability rule. The real adapter calldata is validated by `deckard-signerd` -/// (`10-kohaku-shield.md`); this just checks the shape matches the kind. -fn calldata_ok(intent: &Intent) -> bool { - match intent.kind { - // A plain send carries no calldata. - IntentKind::Send => intent.calldata.is_empty(), - // A generic contract write needs calldata to call. - IntentKind::ContractCall => !intent.calldata.is_empty(), - // Railgun deposit/withdraw: the mock accepts whatever calldata it is handed. - IntentKind::Shield | IntentKind::Unshield => true, +impl Signer for MockSigner { + fn unlock(&self, _passphrase: &str) -> UnlockOutcome { + // The mock holds no real keystore, so any passphrase "unlocks" it. A fresh unlock + // re-arms the session by clearing the `revoked` brake (mirrors the daemon's + // "re-unlock to re-arm"). + self.policy + .lock() + .expect("mock policy mutex poisoned") + .revoked = false; + UnlockOutcome::Unlocked { + address: Self::mock_address(), + } + } + + fn lock(&self) { + // Lock the session (trip the brake) and deny everything in flight — same as the + // daemon's `Lock`, which reaches `Locked` exactly like `RevokeAll`. + let mut policy = self.policy.lock().expect("mock policy mutex poisoned"); + let mut reqs = self.requests.lock().expect("mock requests mutex poisoned"); + policy.revoked = true; + deny_pending(&mut reqs); + } + + fn resolve(&self, request_id: RequestId, approved: bool) { + let mut reqs = self.requests.lock().expect("mock requests mutex poisoned"); + if let Some(req) = reqs.by_id.get_mut(&request_id) { + if req.status == ApprovalStatus::Pending { + req.status = if approved { + ApprovalStatus::Allowed + } else { + ApprovalStatus::Denied { + reason: "user_denied".into(), + } + }; + } + } } -} -impl Signer for MockSigner { fn address(&self) -> Address { Self::mock_address() } @@ -146,46 +170,20 @@ impl Signer for MockSigner { } fn propose(&self, intent: &Intent) -> Decision { - let needs_card; - { + // The verdict comes from the ONE shared decision function — no logic lives here. + // (`revoked`, the mock's lock state, is one of the checks `evaluate` makes.) + let needs_card = { let policy = self.policy.lock().expect("mock policy mutex poisoned"); - - // 1. STOP overrides everything. - if policy.revoked { - return Decision::Deny { - reason: "revoked".into(), - }; + match policy::evaluate(intent, &policy) { + // Terminal verdicts return straight through. + deny @ Decision::Deny { .. } => return deny, + Decision::Allow => false, + Decision::NeedsApproval { .. } => true, } - // 2. Allowlist (empty = any address). - if !policy.allow_to.is_empty() && !policy.allow_to.contains(&intent.to) { - return Decision::Deny { - reason: "off_allowlist".into(), - }; - } - // 3. Calldata must be decodable for the kind. - if !calldata_ok(intent) { - return Decision::Deny { - reason: "undecodable".into(), - }; - } - // 4. Cap check: spent_today + value vs the per-tx and daily caps. - let projected = policy.spent_today_wei.saturating_add(intent.value); - let over = projected > policy.per_tx_cap_wei || projected > policy.daily_cap_wei; - - needs_card = match policy.require_approval { - ApprovalMode::Never => false, - ApprovalMode::OverCap => over, - ApprovalMode::Always => true, - }; - - // Never raises no card, so an over-cap write has nothing to authorise it → deny. - if over && matches!(policy.require_approval, ApprovalMode::Never) { - return Decision::Deny { - reason: "over_cap".into(), - }; - } - } // policy lock released before taking the requests lock (preserves lock order) + }; // policy lock released before taking the requests lock (preserves lock order) + // Mint the real, trackable id (replacing `evaluate`'s placeholder) and store the + // pending record under it. let mut reqs = self.requests.lock().expect("mock requests mutex poisoned"); let id = Self::mint_id(&mut reqs); let status = if needs_card { @@ -268,16 +266,22 @@ impl Signer for MockSigner { } fn revoke_all(&self) { + // STOP: trip the policy brake, then deny everything in flight. // Same lock order as execute(): policy before requests. let mut policy = self.policy.lock().expect("mock policy mutex poisoned"); let mut reqs = self.requests.lock().expect("mock requests mutex poisoned"); policy.revoked = true; - for req in reqs.by_id.values_mut() { - if req.status == ApprovalStatus::Pending { - req.status = ApprovalStatus::Denied { - reason: "revoked".into(), - }; - } + deny_pending(&mut reqs); + } +} + +/// Flip every still-`Pending` request to `Denied{revoked}` — shared by `lock`/`revoke_all`. +fn deny_pending(reqs: &mut Requests) { + for req in reqs.by_id.values_mut() { + if req.status == ApprovalStatus::Pending { + req.status = ApprovalStatus::Denied { + reason: "revoked".into(), + }; } } } @@ -285,6 +289,8 @@ impl Signer for MockSigner { #[cfg(test)] mod tests { use super::*; + use crate::intent::IntentKind; + use crate::policy::ApprovalMode; use alloy_primitives::Bytes; // --- builders ------------------------------------------------------------------- diff --git a/crates/deckard-contract/src/policy.rs b/crates/deckard-contract/src/policy.rs index 1c3a2d7..6e76eb7 100644 --- a/crates/deckard-contract/src/policy.rs +++ b/crates/deckard-contract/src/policy.rs @@ -1,9 +1,15 @@ //! The spending fence the agent is allowed to READ (so it can stay inside the fence) but //! never write. The daemon enforces it; `MockSigner` enforces the same rules in memory. +//! +//! [`evaluate`] is **the one decision function** — both `MockSigner` and the real +//! `deckard-signerd` call it, so there is no mock⇄daemon drift in the verdict logic. use alloy_primitives::{Address, U256}; use serde::{Deserialize, Serialize}; +use crate::decision::{Decision, RequestId}; +use crate::intent::{Intent, IntentKind}; + /// The agent-readable policy. All caps are in wei. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Policy { @@ -34,3 +40,80 @@ pub enum ApprovalMode { /// Always raise a card, even within cap. Always, } + +/// **The** decision function. A *pure* `(Intent, Policy) -> Decision` with no I/O, no +/// signing, no state — both [`MockSigner`](crate::MockSigner) and `deckard-signerd` call +/// it so the verdict can never drift between the mock and the real daemon. +/// +/// It owns the policy-level checks (`revoked`, allowlist, calldata shape, the caps × mode +/// matrix). Process-level pre-checks that the policy can't express — the daemon being +/// `Locked`, a `chain_id` mismatch, an unsupported `IntentKind` — are the daemon's job and +/// run *before* this function (the mock has none of those states, so feeding both the same +/// `(Intent, Policy)` yields identical `Decision`s; this is the parity contract). +/// +/// For [`Decision::NeedsApproval`] the returned `request_id` is the **placeholder** +/// [`RequestId::ZERO`](alloy_primitives::B256::ZERO): minting a real, trackable id is the +/// stateful caller's job (it stores the pending record under that id). Callers must replace +/// it before returning the decision on the wire. +pub fn evaluate(intent: &Intent, policy: &Policy) -> Decision { + // 1. STOP / revoked overrides everything. + if policy.revoked { + return Decision::Deny { + reason: "revoked".into(), + }; + } + // 2. Allowlist (empty = any address). + if !policy.allow_to.is_empty() && !policy.allow_to.contains(&intent.to) { + return Decision::Deny { + reason: "off_allowlist".into(), + }; + } + // 3. Calldata must be decodable for the kind. + if !calldata_ok(intent) { + return Decision::Deny { + reason: "undecodable".into(), + }; + } + // 4. Cap check: spent_today + value vs the per-tx and daily caps. + let projected = policy.spent_today_wei.saturating_add(intent.value); + let over = projected > policy.per_tx_cap_wei || projected > policy.daily_cap_wei; + + match policy.require_approval { + // Never raises no card, so an over-cap write has nothing to authorise it → deny. + ApprovalMode::Never => { + if over { + Decision::Deny { + reason: "over_cap".into(), + } + } else { + Decision::Allow + } + } + ApprovalMode::OverCap => { + if over { + Decision::NeedsApproval { + request_id: RequestId::ZERO, + } + } else { + Decision::Allow + } + } + ApprovalMode::Always => Decision::NeedsApproval { + request_id: RequestId::ZERO, + }, + } +} + +/// Shape check: does the calldata match the kind? The real Railgun adapter calldata is +/// validated downstream (`10-kohaku-shield.md`); this only enforces the coarse invariant +/// the policy gate relies on. +fn calldata_ok(intent: &Intent) -> bool { + match intent.kind { + // A plain send carries no calldata (the daemon builds the tx from to/value/token). + IntentKind::Send => intent.calldata.is_empty(), + // A generic contract write needs calldata to call. + IntentKind::ContractCall => !intent.calldata.is_empty(), + // Railgun deposit/withdraw: accept whatever calldata is handed over. + IntentKind::Shield | IntentKind::Unshield => true, + } +} diff --git a/crates/deckard-contract/src/rpc.rs b/crates/deckard-contract/src/rpc.rs index ed67b12..b2f8669 100644 --- a/crates/deckard-contract/src/rpc.rs +++ b/crates/deckard-contract/src/rpc.rs @@ -12,13 +12,29 @@ use crate::policy::Policy; /// `deckard-mcp` → `deckard-signerd`. The key-less client only proposes; it never signs. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum SignerRequest { + /// Unlock the vault: the daemon reads the keystore, decrypts with `passphrase`, and + /// holds the key for the session → [`SignerResponse::Unlock`]\([`UnlockOutcome`]\). + /// + /// The wire passphrase is a plain `String` because `zeroize::Zeroizing` does + /// not derive `Serialize`. The daemon moves it into `Zeroizing` the instant the frame + /// is decoded and never retains the raw buffer; it never echoes the passphrase back. + Unlock { passphrase: String }, + /// Lock: zeroize + drop the held key → `Locked`, and deny every in-flight approval. + /// Re-arm only via a fresh [`Unlock`](Self::Unlock). → `Ack`. + Lock, + /// Close an approval loop opened by a `NeedsApproval`: flip the `Pending` record to + /// `Allowed` (`approved: true`) or `Denied` (`approved: false`). → `Ack`. + Resolve { + request_id: RequestId, + approved: bool, + }, /// Policy check, NO signing yet → [`Decision`]. Propose { intent: Intent }, /// Sign + broadcast, only if `Allow`/approved → [`ExecuteResult`]. Execute { request_id: RequestId }, /// Poll for the native-card result → [`ApprovalStatus`]. Status { request_id: RequestId }, - /// STOP: set `policy.revoked`, drop in-flight approvals → `Ack`. + /// STOP: zeroize the key, lock the daemon, drop in-flight approvals → `Ack`. RevokeAll, /// Read-only snapshot for the agent → [`Policy`]. PolicyGet, @@ -31,16 +47,30 @@ pub enum SignerRequest { /// `deckard-signerd` → `deckard-mcp`. One variant per request shape. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum SignerResponse { + /// Reply to `Unlock`. + Unlock(UnlockOutcome), Decision(Decision), Execute(ExecuteResult), Status(ApprovalStatus), - /// Reply to `RevokeAll`. + /// Reply to `Lock`, `Resolve`, and `RevokeAll`. Ack, Policy(Policy), Address(Address), Balance(BalanceReport), } +/// Outcome of `Unlock`. Carries the wallet address on success — never any key material, +/// never the passphrase. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum UnlockOutcome { + /// Decrypted; the daemon now holds the key. `address` is the primary account. + Unlocked { address: Address }, + /// The passphrase was wrong (or the vault was tampered with). No key is held. + BadPassphrase, + /// No keystore file exists yet — onboarding must create one first. + NoVault, +} + /// Outcome of `execute`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum ExecuteResult { @@ -55,7 +85,8 @@ pub enum ExecuteResult { pub enum ApprovalStatus { /// Awaiting the human on the native card. Pending, - /// The human approved; `execute` will sign (subject to a fresh `revoked` re-check). + /// Approved (by a human, or auto within cap); `execute` will sign — subject to fresh + /// re-checks at sign time (revoked, TTL expiry, and the spend caps for an auto-allow). Allowed, /// Terminal denial. Denied { reason: String }, diff --git a/crates/deckard-contract/src/signer.rs b/crates/deckard-contract/src/signer.rs index 07c18e2..5136d62 100644 --- a/crates/deckard-contract/src/signer.rs +++ b/crates/deckard-contract/src/signer.rs @@ -7,12 +7,19 @@ use alloy_primitives::Address; use crate::decision::{Decision, RequestId}; use crate::intent::Intent; use crate::policy::Policy; -use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult}; +use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult, UnlockOutcome}; /// The daemon-socket API expressed as a trait, so callers can hold a `Box` and /// swap the mock for the real UDS client without changing a line. Object-safe: every method /// takes `&self` and returns owned values. pub trait Signer { + /// Unlock the vault for the session (the daemon decrypts + holds the key). Returns the + /// wallet address on success — never key material. + fn unlock(&self, passphrase: &str) -> UnlockOutcome; + /// Lock: zeroize + drop the held key and deny in-flight approvals. Re-arm via `unlock`. + fn lock(&self); + /// Close an approval loop: flip a `Pending` request to `Allowed`/`Denied`. + fn resolve(&self, request_id: RequestId, approved: bool); /// The wallet's public address (key-less to read). fn address(&self) -> Address; /// Public + shielded balances. `shielded` mirrors the wire request; the report carries diff --git a/crates/deckard-core/Cargo.toml b/crates/deckard-core/Cargo.toml index 44721be..8b467f4 100644 --- a/crates/deckard-core/Cargo.toml +++ b/crates/deckard-core/Cargo.toml @@ -31,5 +31,9 @@ bip39 = { version = "2.2", features = ["zeroize"] } zeroize = "1" rand = "0.8" +# Resolve the platform config dir (the keystore + signer policy live there). Single-sourced +# here so the GUI app, onboarding, and the signer daemon all agree on the path. +directories = "5" + [dev-dependencies] tokio = { version = "1", features = ["rt", "macros", "sync", "rt-multi-thread"] } diff --git a/crates/deckard-core/src/config.rs b/crates/deckard-core/src/config.rs new file mode 100644 index 0000000..d3f98d4 --- /dev/null +++ b/crates/deckard-core/src/config.rs @@ -0,0 +1,33 @@ +//! Where Deckard keeps per-user state on disk. The encrypted keystore (`vault.bin`) and the +//! signer policy (`policy.json`) live in the platform config dir; the GUI app, onboarding, +//! and the signer daemon all resolve the **same** path through here so they never drift. +//! +//! This is a pure resolver — it does not create the directory. The writer (`Vault::write_atomic`) +//! creates the parent as needed; readers treat a missing file as "not set up yet." + +use std::path::PathBuf; + +use directories::ProjectDirs; + +/// The encrypted keystore filename inside [`config_dir`]. +pub const VAULT_FILE: &str = "vault.bin"; +/// The signer policy filename inside [`config_dir`]. +pub const POLICY_FILE: &str = "policy.json"; + +/// The platform config dir: `~/Library/Application Support/com.deckard.Deckard` on macOS, +/// `$XDG_CONFIG_HOME/deckard` (or `~/.config/deckard`) on Linux. `None` only if the OS has +/// no home directory at all. +pub fn config_dir() -> Option { + let dirs = ProjectDirs::from("com", "deckard", "Deckard")?; + Some(dirs.config_dir().to_path_buf()) +} + +/// The encrypted keystore path (`/vault.bin`). +pub fn vault_path() -> Option { + Some(config_dir()?.join(VAULT_FILE)) +} + +/// The signer policy path (`/policy.json`). +pub fn policy_path() -> Option { + Some(config_dir()?.join(POLICY_FILE)) +} diff --git a/crates/deckard-core/src/lib.rs b/crates/deckard-core/src/lib.rs index d317ebd..0d4fd81 100644 --- a/crates/deckard-core/src/lib.rs +++ b/crates/deckard-core/src/lib.rs @@ -10,15 +10,15 @@ //! GUI thread never blocks and never touches tokio. pub mod balances; +pub mod config; pub mod eth; pub mod keystore; pub mod tokens; pub use balances::{fetch_portfolio, format_amount, Portfolio, TokenBalance}; +pub use config::{config_dir, policy_path, vault_path}; pub use eth::{EthProvider, DEFAULT_RPC}; -pub use keystore::{ - random_word_positions, KdfParams, SecretKind, UnlockedVault, Vault, WordCount, -}; +pub use keystore::{random_word_positions, KdfParams, SecretKind, UnlockedVault, Vault, WordCount}; pub use tokens::{TokenInfo, DEFAULT_TOKENS}; // Re-export the alloy primitive types the UI renders, so the app layer doesn't diff --git a/crates/deckard-signerd/Cargo.toml b/crates/deckard-signerd/Cargo.toml new file mode 100644 index 0000000..a19c378 --- /dev/null +++ b/crates/deckard-signerd/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "deckard-signerd" +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-or-later" +description = "Deckard's process-isolated signer daemon: owns the decrypted key, runs the policy gate, signs + broadcasts, and answers STOP — over a same-uid Unix-domain socket. The app and the future MCP sidecar are key-less clients." + +# lib + bin: the lib holds the wire framing, socket/peer-cred plumbing, the daemon state +# machine, and the client + supervisor the GUI app reuses; the bin is the daemon entry point. +[lib] +name = "deckard_signerd" +path = "src/lib.rs" + +[[bin]] +name = "deckard-signerd" +path = "src/main.rs" + +[dependencies] +# The frozen wire contract (Intent / Decision / Policy / RPC + the shared `evaluate`). +deckard-contract = { path = "../deckard-contract" } +# The headless engine: the keystore (`Vault`/`UnlockedVault`) we reuse — never rebuilt here. +deckard-core = { path = "../deckard-core" } + +# Async UDS server + framing. multi-thread rt so the Argon2 unlock can run on the blocking +# pool without starving the reactor. +tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "sync", "time", "io-util"] } +# CBOR on the wire (matches the contract crate's encoding); JSON for the policy file. +serde = { workspace = true } +ciborium = "0.2" +serde_json = "1" + +# alloy for the broadcast path: HTTP provider + recommended fillers (nonce/gas/chain-id) + +# wallet signing. The signer is reconstructed from the raw scalar into THIS alloy stack +# (the keystore's signer is a different alloy-signer-local version; only the version-stable +# B256 scalar crosses the boundary). Features mirror deckard-core (default features on, which +# already bring the rustls TLS backend) so the workspace shares ONE alloy build + TLS stack — +# no second TLS backend, no feature drift. +alloy = { version = "1", features = ["provider-http", "network", "rpc-types", "signer-local"] } +alloy-primitives = { workspace = true } + +# Peer-cred uid (geteuid) + the single-instance flock. +nix = { version = "0.29", features = ["fs", "user"] } +zeroize = "1" +anyhow = "1" diff --git a/crates/deckard-signerd/src/auth.rs b/crates/deckard-signerd/src/auth.rs new file mode 100644 index 0000000..ec21763 --- /dev/null +++ b/crates/deckard-signerd/src/auth.rs @@ -0,0 +1,46 @@ +//! Caller authentication: only a process with the **same uid** as the daemon may connect. +//! +//! We use tokio's built-in peer-cred (`SO_PEERCRED` on Linux, `getpeereid(2)` / +//! `LOCAL_PEERCRED` on macOS) — verified to return the peer's effective uid on both — and +//! compare it against our own *effective* uid. The decision itself is a pure function so it +//! can be unit-tested without a live different-uid connection. + +use tokio::net::UnixStream; + +/// The peer's (effective) uid for a connected stream. +pub fn peer_uid(stream: &UnixStream) -> std::io::Result { + Ok(stream.peer_cred()?.uid()) +} + +/// Our own effective uid (paired with `peer_cred`'s effective semantics). +pub fn our_uid() -> u32 { + nix::unistd::geteuid().as_raw() +} + +/// The whole authorization rule, pure and testable: a connection is allowed iff the peer +/// runs as the same uid as the daemon. +#[inline] +pub fn same_uid(peer_uid: u32, our_uid: u32) -> bool { + peer_uid == our_uid +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_a_different_uid() { + let ours = 501; + // A foreign uid is refused (the load-bearing check) ... + assert!(!same_uid(ours + 1, ours)); + assert!(!same_uid(0, ours)); // even root, if it isn't us + // ... and the same uid is accepted. + assert!(same_uid(ours, ours)); + } + + #[test] + fn our_uid_is_stable() { + // geteuid is infallible and constant within a process. + assert_eq!(our_uid(), our_uid()); + } +} diff --git a/crates/deckard-signerd/src/client.rs b/crates/deckard-signerd/src/client.rs new file mode 100644 index 0000000..62cbc6e --- /dev/null +++ b/crates/deckard-signerd/src/client.rs @@ -0,0 +1,146 @@ +//! The key-less client the GUI app (and, later, `deckard-mcp`) use to talk to the daemon. +//! +//! One request → one response over a fresh connection (the daemon serializes everything +//! behind its state, so per-call connections are correct and simple at this call frequency). +//! [`SignerClient::request`] is async; [`SignerClient::request_blocking`] wraps it in a +//! short-lived current-thread runtime for callers without one (the app's background thread). + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use tokio::net::UnixStream; + +use deckard_contract::{ + Decision, ExecuteResult, Intent, RequestId, SignerRequest, SignerResponse, UnlockOutcome, +}; + +use crate::frame; +use crate::request_id::request_id_for; + +/// How long to keep retrying `connect` before giving up — covers the brief window where the +/// app has spawned the daemon but it hasn't bound the socket yet. +const CONNECT_DEADLINE: Duration = Duration::from_secs(3); + +/// A handle to the daemon socket. Cheap to clone; holds only the path. +#[derive(Clone, Debug)] +pub struct SignerClient { + path: PathBuf, +} + +impl SignerClient { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + /// The socket path this client dials. + pub fn path(&self) -> &Path { + &self.path + } + + /// Send one request and read one response (connecting with a short retry so a just-spawned + /// daemon is given a moment to bind). + pub async fn request(&self, req: &SignerRequest) -> anyhow::Result { + let mut stream = self.connect().await?; + let body = frame::encode(req)?; + frame::write_frame(&mut stream, &body).await?; + let resp = frame::read_frame(&mut stream) + .await? + .ok_or_else(|| anyhow::anyhow!("daemon closed without responding"))?; + frame::decode(&resp) + } + + /// Connect, retrying with capped backoff until [`CONNECT_DEADLINE`] — so the first call + /// right after the app spawns the daemon doesn't lose a startup race. + async fn connect(&self) -> anyhow::Result { + let deadline = Instant::now() + CONNECT_DEADLINE; + let mut delay = Duration::from_millis(25); + loop { + match UnixStream::connect(&self.path).await { + Ok(stream) => return Ok(stream), + Err(e) => { + if Instant::now() >= deadline { + return Err(anyhow::anyhow!("connect {}: {e}", self.path.display())); + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_millis(200)); + } + } + } + } + + /// Blocking convenience for callers without a tokio runtime (e.g. a GUI background + /// thread). Spins a short-lived current-thread runtime for the round-trip. + pub fn request_blocking(&self, req: &SignerRequest) -> anyhow::Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| anyhow::anyhow!("build runtime: {e}"))?; + rt.block_on(self.request(req)) + } + + // --- typed helpers over `request` (used by the app + tests) --------------------------- + + /// Unlock the vault over the socket (the app's lock screen sends this; the key never + /// enters the app's address space — only the returned address does). + pub async fn unlock(&self, passphrase: &str) -> anyhow::Result { + match self + .request(&SignerRequest::Unlock { + passphrase: passphrase.to_string(), + }) + .await? + { + SignerResponse::Unlock(outcome) => Ok(outcome), + other => Err(unexpected("Unlock", other)), + } + } + + /// Blocking [`unlock`](Self::unlock). + pub fn unlock_blocking(&self, passphrase: &str) -> anyhow::Result { + match self.request_blocking(&SignerRequest::Unlock { + passphrase: passphrase.to_string(), + })? { + SignerResponse::Unlock(outcome) => Ok(outcome), + other => Err(unexpected("Unlock", other)), + } + } + + /// Lock the session (STOP-lite): zeroize the key, deny in-flight approvals. + pub fn lock_blocking(&self) -> anyhow::Result<()> { + match self.request_blocking(&SignerRequest::Lock)? { + SignerResponse::Ack => Ok(()), + other => Err(unexpected("Lock", other)), + } + } + + /// Propose an intent → a `Decision`. Note: the returned `request_id` for an `Allow` is + /// derivable locally via [`request_id_for_intent`](Self::request_id_for_intent). + pub async fn propose(&self, intent: &Intent) -> anyhow::Result { + match self + .request(&SignerRequest::Propose { + intent: intent.clone(), + }) + .await? + { + SignerResponse::Decision(d) => Ok(d), + other => Err(unexpected("Propose", other)), + } + } + + /// Execute a previously-proposed request id → sign + broadcast (or denial). + pub async fn execute(&self, request_id: RequestId) -> anyhow::Result { + match self.request(&SignerRequest::Execute { request_id }).await? { + SignerResponse::Execute(r) => Ok(r), + other => Err(unexpected("Execute", other)), + } + } + + /// The deterministic request id for an intent — lets a client `execute` an `Allow` it + /// derived locally (the daemon assigns the very same id). + pub fn request_id_for_intent(intent: &Intent) -> RequestId { + request_id_for(intent) + } +} + +fn unexpected(req: &str, got: SignerResponse) -> anyhow::Error { + anyhow::anyhow!("daemon returned an unexpected response to {req}: {got:?}") +} diff --git a/crates/deckard-signerd/src/config.rs b/crates/deckard-signerd/src/config.rs new file mode 100644 index 0000000..35e76cd --- /dev/null +++ b/crates/deckard-signerd/src/config.rs @@ -0,0 +1,111 @@ +//! Daemon configuration, all environment-driven so CI/tests point at a local anvil and +//! production points at Sepolia/mainnet by config. +//! +//! - `DECKARD_RPC_URL` — JSON-RPC endpoint to broadcast through (default: the public RPC). +//! - `DECKARD_CHAIN_ID` — the chain the daemon signs for (default: 1 = mainnet). A +//! `propose` whose `intent.chain_id` differs is denied `chain_mismatch`. +//! - `DECKARD_CONFIG_DIR` — where `vault.bin` + `policy.json` live (default: the platform +//! config dir, shared with the GUI app via `deckard_core::config`). Tests set this. +//! - `DECKARD_SOCKET_PATH`— explicit UDS path (default: the per-uid runtime path). Tests + +//! the app set this so both ends agree. + +use std::path::PathBuf; + +/// Resolved daemon configuration. +#[derive(Clone, Debug)] +pub struct Config { + pub rpc_url: String, + pub chain_id: u64, + pub config_dir: PathBuf, + pub socket_path: PathBuf, +} + +impl Config { + /// Resolve the config from the environment, applying the documented defaults. + pub fn from_env() -> anyhow::Result { + let rpc_url = std::env::var("DECKARD_RPC_URL") + .unwrap_or_else(|_| deckard_core::DEFAULT_RPC.to_string()); + + let chain_id = match std::env::var("DECKARD_CHAIN_ID") { + Ok(s) => s + .trim() + .parse::() + .map_err(|_| anyhow::anyhow!("DECKARD_CHAIN_ID must be a u64, got {s:?}"))?, + Err(_) => 1, + }; + + let config_dir = match std::env::var_os("DECKARD_CONFIG_DIR") { + Some(d) => PathBuf::from(d), + None => deckard_core::config_dir() + .ok_or_else(|| anyhow::anyhow!("no platform config directory available"))?, + }; + + let socket_path = match std::env::var_os("DECKARD_SOCKET_PATH") { + Some(p) => PathBuf::from(p), + None => crate::socket::default_socket_path(), + }; + + Ok(Self { + rpc_url, + chain_id, + config_dir, + socket_path, + }) + } + + /// The encrypted keystore path the daemon reads on `Unlock`. + pub fn vault_path(&self) -> PathBuf { + self.config_dir.join(deckard_core::config::VAULT_FILE) + } + + /// The signer policy path (a sane default is used if absent). + pub fn policy_path(&self) -> PathBuf { + self.config_dir.join(deckard_core::config::POLICY_FILE) + } + + /// The RPC endpoint with any embedded credentials/host elided — safe to log. + pub fn redacted_rpc(&self) -> String { + redact_url(&self.rpc_url) + } +} + +/// Reduce an RPC URL to `scheme://host[:port]` so an embedded API key (e.g. an Infura +/// project secret in the path/query) never reaches a log line. +fn redact_url(url: &str) -> String { + let (scheme, rest) = match url.split_once("://") { + Some(parts) => parts, + None => return "".to_string(), + }; + let authority = rest + .split(['/', '?', '#']) + .next() + .unwrap_or("") + // strip any userinfo (user:pass@host) + .rsplit('@') + .next() + .unwrap_or(""); + if authority.is_empty() { + "".to_string() + } else { + format!("{scheme}://{authority}") + } +} + +#[cfg(test)] +mod tests { + use super::redact_url; + + #[test] + fn redaction_drops_paths_and_userinfo() { + assert_eq!( + redact_url("https://mainnet.infura.io/v3/SECRETKEY"), + "https://mainnet.infura.io" + ); + assert_eq!(redact_url("http://127.0.0.1:8545"), "http://127.0.0.1:8545"); + assert_eq!( + redact_url("https://user:pass@rpc.example.com/path?token=abc"), + "https://rpc.example.com" + ); + assert_eq!(redact_url("not-a-url"), ""); + } +} diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs new file mode 100644 index 0000000..3e2ffb9 --- /dev/null +++ b/crates/deckard-signerd/src/daemon.rs @@ -0,0 +1,464 @@ +//! The daemon state machine: `Locked` ⇄ `Unlocked { vault }`, the in-flight request table, +//! and the handlers for every [`SignerRequest`]. The verdict for a `propose` comes from the +//! ONE shared [`deckard_contract::evaluate`] — the daemon adds only the process-level +//! pre-checks the policy can't express (`Locked`, `chain_mismatch`, unsupported kind). +//! +//! All requests are serialized behind a single [`Daemon`] (the server holds it in a +//! `tokio::sync::Mutex`), so `propose`/`execute` can never race. `execute` holds that lock +//! across the broadcast — acceptable for v1 (anvil is instant); a STOP arriving *during* an +//! in-progress broadcast can't unsend a tx already on the wire, but the TOCTOU guard refuses +//! any execute whose STOP landed first. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use alloy_primitives::{Address, B256, U256}; +use zeroize::Zeroizing; + +use deckard_contract::{ + evaluate, ApprovalStatus, BalanceReport, Decision, ExecuteResult, Intent, IntentKind, Policy, + RequestId, SignerRequest, SignerResponse, UnlockOutcome, +}; +use deckard_core::{UnlockedVault, Vault}; + +use crate::config::Config; +use crate::policy_store::{self, current_utc_day}; +use crate::request_id::request_id_for; +use crate::signing; + +/// Default lifetime of a `NeedsApproval` before `status`/`execute` report `Expired`. +/// Overridable via `DECKARD_APPROVAL_TTL_SECS` (used by tests to exercise expiry quickly). +const APPROVAL_TTL: Duration = Duration::from_secs(120); + +/// Resolve the approval TTL: `DECKARD_APPROVAL_TTL_SECS` if set + parseable, else the default. +fn approval_ttl() -> Duration { + std::env::var("DECKARD_APPROVAL_TTL_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .map(Duration::from_secs) + .unwrap_or(APPROVAL_TTL) +} + +/// `Locked` holds no key; `Unlocked` owns the decrypted vault (dropped — and zeroized — on +/// lock/STOP) plus its cached primary address. +enum VaultState { + Locked, + Unlocked { + vault: UnlockedVault, + address: Address, + }, +} + +/// One tracked proposal. `status` is the wire-visible approval state; `broadcast` is `Some` +/// once `execute` has signed it (so a second `execute` is idempotently refused). `approved` +/// is `true` only once a human `Resolve`d it — an *auto*-allow (within-cap) is re-checked +/// against the caps at execute time, while a human-approved overage is not. +struct PendingReq { + intent: Intent, + status: ApprovalStatus, + expires_at: Instant, + broadcast: Option, + approved: bool, +} + +/// Upper bound on a single broadcast round-trip. A hung/blackholed RPC fails after this +/// rather than wedging the daemon (and STOP) forever behind the held state lock. +const BROADCAST_TIMEOUT: Duration = Duration::from_secs(30); + +/// The whole daemon: config, the lock state, the live policy (with in-memory daily spend), +/// and the request table. +pub struct Daemon { + cfg: Config, + state: VaultState, + policy: Policy, + /// UTC day of the current `spent_today_wei` window (for the midnight rollover). + spent_day: u64, + /// Lifetime of a `NeedsApproval` record. + approval_ttl: Duration, + requests: HashMap, +} + +impl Daemon { + /// Build a `Locked` daemon, loading the policy (or its safe default) up front. + pub fn new(cfg: Config) -> Self { + let policy = policy_store::load_policy(&cfg.policy_path()); + Self { + cfg, + state: VaultState::Locked, + policy, + spent_day: current_utc_day(), + approval_ttl: approval_ttl(), + requests: HashMap::new(), + } + } + + /// Dispatch one request to one response. `async` because `execute`/`balance` do network + /// I/O and `unlock` runs Argon2 on the blocking pool. + pub async fn handle(&mut self, req: SignerRequest) -> SignerResponse { + match req { + SignerRequest::Unlock { passphrase } => { + SignerResponse::Unlock(self.unlock(passphrase).await) + } + // Lock and RevokeAll are the same act in v1: zeroize the key → Locked, deny + // everything in flight. Only a fresh Unlock re-arms. + SignerRequest::Lock | SignerRequest::RevokeAll => { + self.lock(); + SignerResponse::Ack + } + SignerRequest::Resolve { + request_id, + approved, + } => { + self.resolve(request_id, approved); + SignerResponse::Ack + } + SignerRequest::Propose { intent } => SignerResponse::Decision(self.propose(&intent)), + SignerRequest::Execute { request_id } => { + SignerResponse::Execute(self.execute(request_id).await) + } + SignerRequest::Status { request_id } => SignerResponse::Status(self.status(request_id)), + SignerRequest::PolicyGet => { + self.rollover(); + SignerResponse::Policy(self.policy.clone()) + } + SignerRequest::Address => match &self.state { + VaultState::Unlocked { address, .. } => SignerResponse::Address(*address), + // No Address-specific error variant exists; signal locked Deny-style. + VaultState::Locked => SignerResponse::Decision(Decision::Deny { + reason: "locked".into(), + }), + }, + SignerRequest::Balance { shielded } => { + SignerResponse::Balance(self.balance(shielded).await) + } + } + } + + /// Read the keystore, decrypt under `passphrase`, and hold the key. The raw passphrase is + /// moved into `Zeroizing` immediately and never echoed or logged. + async fn unlock(&mut self, passphrase: String) -> UnlockOutcome { + let pass = Zeroizing::new(passphrase); + let vault_path = self.cfg.vault_path(); + if !vault_path.exists() { + return UnlockOutcome::NoVault; + } + // Argon2id is CPU-heavy: read + unlock on the blocking pool so the reactor stays free. + let pass_for_blocking = pass.clone(); + let result = tokio::task::spawn_blocking(move || { + let vault = Vault::read(&vault_path)?; + vault.unlock(pass_for_blocking.as_str()) + }) + .await; + + match result { + Ok(Ok(unlocked)) => match unlocked.primary_address() { + Ok(address) => { + self.state = VaultState::Unlocked { + vault: unlocked, + address, + }; + self.policy.revoked = false; // a fresh unlock re-arms + self.requests.clear(); // fresh session: no stale approvals survive a re-unlock + UnlockOutcome::Unlocked { address } + } + // A successfully decrypted vault that can't derive an address is corrupt; + // treat as a failed unlock rather than holding an unusable key. + Err(_) => UnlockOutcome::BadPassphrase, + }, + // Wrong passphrase, a tampered vault, or a read error: one generic outcome, no + // oracle, no key held. + Ok(Err(_)) | Err(_) => UnlockOutcome::BadPassphrase, + } + } + + /// Zeroize + drop the key → `Locked`, deny EVERY non-broadcast approval (both `Pending` + /// and already-`Allowed`, so an approval granted before STOP can never execute — even via + /// `status` polling), and trip the policy brake (so `PolicyGet` honestly reports + /// `revoked`). Shared by `Lock` and `RevokeAll`. + fn lock(&mut self) { + self.state = VaultState::Locked; // dropping UnlockedVault zeroizes the secret + self.policy.revoked = true; + for req in self.requests.values_mut() { + if req.broadcast.is_none() + && matches!( + req.status, + ApprovalStatus::Pending | ApprovalStatus::Allowed + ) + { + req.status = ApprovalStatus::Denied { + reason: "revoked".into(), + }; + } + } + } + + /// Close an approval loop: a human (or a test) flips a `Pending` record to + /// `Allowed`/`Denied`. No-op for any other state (already resolved/expired). + fn resolve(&mut self, request_id: RequestId, approved: bool) { + self.expire_stale(); + if let Some(req) = self.requests.get_mut(&request_id) { + if req.status == ApprovalStatus::Pending { + if approved { + req.status = ApprovalStatus::Allowed; + req.approved = true; // explicit human consent: not re-capped at execute + } else { + req.status = ApprovalStatus::Denied { + reason: "user_denied".into(), + }; + } + } + } + } + + /// Policy check only — NEVER signs. Process-level pre-checks first, then the shared + /// `evaluate`. On `NeedsApproval`/`Allow` a pending record is stored under the intent's + /// deterministic id; on `Deny` nothing is stored. + fn propose(&mut self, intent: &Intent) -> Decision { + self.rollover(); + self.expire_stale(); + + // Pre-checks the Policy can't express (the mock has none of these states, which is + // why feeding both the same (Intent, Policy) yields identical decisions — the parity + // contract). These run before `evaluate`. + if matches!(self.state, VaultState::Locked) { + return Decision::Deny { + reason: "locked".into(), + }; + } + if intent.chain_id != self.cfg.chain_id { + return Decision::Deny { + reason: "chain_mismatch".into(), + }; + } + if intent.kind != IntentKind::Send { + return Decision::Deny { + reason: "unsupported_v1".into(), + }; + } + // v1 spine is native ETH only; an ERC-20 (`token = Some`) Send is a fast-follow. + if intent.token.is_some() { + return Decision::Deny { + reason: "erc20_unsupported_v1".into(), + }; + } + + let id = request_id_for(intent); + + // Idempotent re-propose: an identical intent maps to the same id, so an existing record + // is returned AS-IS — a re-propose can't reset a `Pending` card's TTL, downgrade a + // human approval, or re-raise a `Denied`/`Expired` request. Retrying a terminal intent + // needs a fresh session (`Unlock` clears the table). + if let Some(existing) = self.requests.get(&id) { + return match &existing.status { + _ if existing.broadcast.is_some() => Decision::Deny { + reason: "already_executed".into(), + }, + ApprovalStatus::Pending => Decision::NeedsApproval { request_id: id }, + ApprovalStatus::Allowed => Decision::Allow, + ApprovalStatus::Denied { reason } => Decision::Deny { + reason: reason.clone(), + }, + ApprovalStatus::Expired => Decision::Deny { + reason: "expired".into(), + }, + }; + } + + // No record yet: the ONE shared decision function decides. + let status = match evaluate(intent, &self.policy) { + deny @ Decision::Deny { .. } => return deny, + Decision::Allow => ApprovalStatus::Allowed, + Decision::NeedsApproval { .. } => ApprovalStatus::Pending, + }; + self.requests.insert( + id, + PendingReq { + intent: intent.clone(), + status: status.clone(), + expires_at: Instant::now() + self.approval_ttl, + broadcast: None, + approved: false, + }, + ); + + match status { + ApprovalStatus::Allowed => Decision::Allow, + _ => Decision::NeedsApproval { request_id: id }, + } + } + + /// Sign + broadcast, only for an `Allowed` request that survives the sign-time re-check. + async fn execute(&mut self, request_id: RequestId) -> ExecuteResult { + self.rollover(); + self.expire_stale(); + + // Phase 1 (lock held): TOCTOU re-check + eligibility, then extract tx params and the + // raw scalar (transiently, into `Zeroizing`). Borrows end before the await. + let (to, value, scalar) = { + let vault = match &self.state { + // STOP landed first — refuse even a previously-approved request. + VaultState::Locked => { + return ExecuteResult::Denied { + reason: "revoked".into(), + } + } + VaultState::Unlocked { vault, .. } => vault, + }; + let req = match self.requests.get(&request_id) { + None => { + return ExecuteResult::Denied { + reason: "unknown_request".into(), + } + } + Some(req) => req, + }; + if req.broadcast.is_some() { + return ExecuteResult::Denied { + reason: "already_executed".into(), + }; + } + match &req.status { + // The only state that signs (covers within-cap Allow + human-approved over-cap). + ApprovalStatus::Allowed => {} + ApprovalStatus::Pending => { + return ExecuteResult::Denied { + reason: "not_approved".into(), + } + } + ApprovalStatus::Denied { reason } => { + return ExecuteResult::Denied { + reason: reason.clone(), + } + } + ApprovalStatus::Expired => { + return ExecuteResult::Denied { + reason: "expired".into(), + } + } + } + // Spend TOCTOU: an *auto*-allow must still be within policy at sign time, so two + // within-cap proposals can't both execute past the daily cap (`spent_today` only + // grows on prior executes). A human-APPROVED request carries explicit consent for + // its overage and is not re-capped. + if !req.approved && evaluate(&req.intent, &self.policy) != Decision::Allow { + return ExecuteResult::Denied { + reason: "cap_exceeded".into(), + }; + } + let signer = match vault.account_signer(0) { + Ok(s) => s, + Err(e) => { + return ExecuteResult::Denied { + reason: format!("signer_error: {e}"), + } + } + }; + // Only the version-stable raw scalar crosses into our alloy stack; zeroized on drop. + let scalar = Zeroizing::new(signer.to_bytes().0); + (req.intent.to, req.intent.value, scalar) + }; + + // Phase 2: sign + broadcast (lock held — serialized; acceptable for v1). A bounded + // timeout keeps a hung RPC from wedging the daemon (and STOP) behind the held lock. + let broadcast = signing::broadcast_native_send( + scalar.as_slice(), + &self.cfg.rpc_url, + self.cfg.chain_id, + to, + value, + ); + let tx_hash = match tokio::time::timeout(BROADCAST_TIMEOUT, broadcast).await { + Ok(Ok(hash)) => hash, + Ok(Err(e)) => { + return ExecuteResult::Denied { + reason: format!("broadcast_failed: {}", one_line(&e)), + } + } + Err(_elapsed) => { + return ExecuteResult::Denied { + reason: "broadcast_timeout".into(), + } + } + }; + + // Phase 3: record the broadcast + bump the daily spend. + if let Some(req) = self.requests.get_mut(&request_id) { + req.broadcast = Some(tx_hash); + } + self.policy.spent_today_wei = self.policy.spent_today_wei.saturating_add(value); + ExecuteResult::Broadcast { tx_hash } + } + + /// Poll an approval handle. Unknown ids report `Denied{unknown_request}` (matching the + /// mock); a `Pending` past its TTL reports `Expired`. + fn status(&mut self, request_id: RequestId) -> ApprovalStatus { + self.expire_stale(); + match self.requests.get(&request_id) { + Some(req) => req.status.clone(), + None => ApprovalStatus::Denied { + reason: "unknown_request".into(), + }, + } + } + + /// Public balance via the RPC (key-less). `shielded_wei` is 0 until T-Privacy. A locked + /// daemon reports zeros (it doesn't know which address to read). + async fn balance(&mut self, _shielded: bool) -> BalanceReport { + self.rollover(); + let addr = match &self.state { + VaultState::Unlocked { address, .. } => *address, + VaultState::Locked => { + return BalanceReport { + public_wei: U256::ZERO, + shielded_wei: U256::ZERO, + } + } + }; + let public_wei = signing::read_balance(&self.cfg.rpc_url, addr) + .await + .unwrap_or(U256::ZERO); + BalanceReport { + public_wei, + shielded_wei: U256::ZERO, + } + } + + /// Expire any non-broadcast request past its TTL — both `Pending` (the card was never + /// answered) and `Allowed` (an approval/auto-allow that went stale). So a stale id can + /// never be executed later, matching the frozen `ApprovalStatus::Expired` guarantee. + fn expire_stale(&mut self) { + let now = Instant::now(); + for req in self.requests.values_mut() { + if req.broadcast.is_none() + && matches!( + req.status, + ApprovalStatus::Pending | ApprovalStatus::Allowed + ) + && now >= req.expires_at + { + req.status = ApprovalStatus::Expired; + } + } + } + + /// Reset the daily spend window when the UTC day ticks over. + fn rollover(&mut self) { + let today = current_utc_day(); + if today != self.spent_day { + self.spent_day = today; + self.policy.spent_today_wei = U256::ZERO; + } + } +} + +/// Collapse a multi-line error into one short line for a `reason` string (never includes a +/// secret — broadcast/signing errors carry only addresses/amounts/RPC text). +fn one_line(e: &anyhow::Error) -> String { + e.to_string() + .lines() + .next() + .unwrap_or("") + .chars() + .take(160) + .collect() +} diff --git a/crates/deckard-signerd/src/frame.rs b/crates/deckard-signerd/src/frame.rs new file mode 100644 index 0000000..e7c17b8 --- /dev/null +++ b/crates/deckard-signerd/src/frame.rs @@ -0,0 +1,107 @@ +//! Length-delimited CBOR framing for the UDS wire: a **4-byte big-endian length prefix** +//! followed by the CBOR body, one request/response per frame. Frames over [`MAX_FRAME`] +//! (1 MiB) are rejected — a hostile or buggy client can't make the daemon allocate +//! unbounded memory. + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +/// Hard cap on a single frame body. The 4-byte prefix can express up to 4 GiB; we refuse +/// anything past 1 MiB (the largest legitimate frame — a big `calldata` — is far smaller). +pub const MAX_FRAME: usize = 1024 * 1024; + +/// Read one frame. Returns `Ok(None)` on a clean EOF (peer closed between frames) so the +/// connection loop can exit quietly; any other short read is an error. +pub async fn read_frame(r: &mut R) -> anyhow::Result>> +where + R: AsyncReadExt + Unpin, +{ + let mut len_buf = [0u8; 4]; + match r.read_exact(&mut len_buf).await { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e.into()), + } + let len = u32::from_be_bytes(len_buf) as usize; + anyhow::ensure!( + len <= MAX_FRAME, + "frame too large: {len} bytes > {MAX_FRAME}" + ); + let mut body = vec![0u8; len]; + r.read_exact(&mut body).await?; + Ok(Some(body)) +} + +/// Write one frame (length prefix + body), then flush. +pub async fn write_frame(w: &mut W, body: &[u8]) -> anyhow::Result<()> +where + W: AsyncWriteExt + Unpin, +{ + anyhow::ensure!( + body.len() <= MAX_FRAME, + "frame too large: {} bytes", + body.len() + ); + w.write_all(&(body.len() as u32).to_be_bytes()).await?; + w.write_all(body).await?; + w.flush().await?; + Ok(()) +} + +/// CBOR-encode a value into a frame body. +pub fn encode(value: &T) -> anyhow::Result> { + let mut buf = Vec::new(); + ciborium::into_writer(value, &mut buf).map_err(|e| anyhow::anyhow!("cbor encode: {e}"))?; + anyhow::ensure!( + buf.len() <= MAX_FRAME, + "encoded frame too large: {} bytes", + buf.len() + ); + Ok(buf) +} + +/// CBOR-decode a frame body into a value. +pub fn decode(bytes: &[u8]) -> anyhow::Result { + ciborium::from_reader(bytes).map_err(|e| anyhow::anyhow!("cbor decode: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn round_trips_a_frame() { + let payload = b"hello deckard".to_vec(); + let mut buf: Vec = Vec::new(); + write_frame(&mut buf, &payload).await.unwrap(); + // 4-byte prefix + body. + assert_eq!(buf.len(), 4 + payload.len()); + assert_eq!(&buf[0..4], &(payload.len() as u32).to_be_bytes()); + + let mut cursor = std::io::Cursor::new(buf); + let got = read_frame(&mut cursor).await.unwrap().unwrap(); + assert_eq!(got, payload); + } + + #[tokio::test] + async fn clean_eof_is_none() { + let mut cursor = std::io::Cursor::new(Vec::::new()); + assert!(read_frame(&mut cursor).await.unwrap().is_none()); + } + + #[tokio::test] + async fn oversize_length_is_rejected() { + // A 4-byte prefix claiming > 1 MiB must be refused before allocating the body. + let mut framed = ((MAX_FRAME as u32) + 1).to_be_bytes().to_vec(); + framed.extend_from_slice(&[0u8; 8]); // some body bytes (never fully read) + let mut cursor = std::io::Cursor::new(framed); + assert!(read_frame(&mut cursor).await.is_err()); + } + + #[tokio::test] + async fn cbor_encode_decode_round_trips() { + let value = ("send", 42u64, true); + let body = encode(&value).unwrap(); + let back: (String, u64, bool) = decode(&body).unwrap(); + assert_eq!(back, ("send".to_string(), 42, true)); + } +} diff --git a/crates/deckard-signerd/src/lib.rs b/crates/deckard-signerd/src/lib.rs new file mode 100644 index 0000000..1f2405d --- /dev/null +++ b/crates/deckard-signerd/src/lib.rs @@ -0,0 +1,37 @@ +//! # deckard-signerd +//! +//! The process-isolated signer daemon — Deckard's operator spine. It owns the decrypted key +//! in its own address space, runs the real policy gate ([`deckard_contract::evaluate`]), +//! signs + broadcasts `Send` transactions, and answers STOP. The GUI app and the future +//! `deckard-mcp` are **key-less clients** that reach it over a same-uid Unix-domain socket +//! (4-byte-BE-length CBOR frames). This crate is a `lib` + `bin`: the library carries the +//! wire framing, socket/peer-cred plumbing, the daemon state machine, and the client + +//! supervisor the app reuses; `main.rs` is the daemon entry point. +//! +//! ## Security model +//! - **Key isolation:** only this process ever holds an [`deckard_core::UnlockedVault`]. The +//! app/MCP never receive key bytes — only an [`deckard_contract::Address`] and decisions. +//! - **Caller auth:** every connection is gated on `SO_PEERCRED`/`LOCAL_PEERCRED` same-uid +//! ([`auth`]); the socket is `0600` inside a `0700` dir ([`socket`]). +//! - **STOP = zeroize:** `Lock`/`RevokeAll` drop the `UnlockedVault` (zeroizing the secret) +//! → `Locked`; re-arm only via a fresh `Unlock` ([`daemon`]). +//! - **TOCTOU:** `execute` re-checks `Locked` at sign time, so an approval granted before a +//! STOP is still refused. + +pub mod auth; +pub mod client; +pub mod config; +pub mod daemon; +pub mod frame; +pub mod policy_store; +pub mod request_id; +pub mod server; +pub mod signing; +pub mod socket; +pub mod supervise; + +pub use client::SignerClient; +pub use config::Config; +pub use daemon::Daemon; +pub use request_id::request_id_for; +pub use supervise::DaemonSupervisor; diff --git a/crates/deckard-signerd/src/main.rs b/crates/deckard-signerd/src/main.rs new file mode 100644 index 0000000..c184ef3 --- /dev/null +++ b/crates/deckard-signerd/src/main.rs @@ -0,0 +1,31 @@ +//! `deckard-signerd` — the process-isolated signer daemon entry point. +//! +//! Resolves config from the environment, prepares the `0700` runtime dir, takes the +//! single-instance lock, binds the `0600` socket, and serves the CBOR socket API until +//! killed. See the crate docs (`lib.rs`) for the security model. + +use std::sync::Arc; + +use tokio::sync::Mutex; + +use deckard_signerd::{config::Config, daemon::Daemon, server, socket}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cfg = Config::from_env()?; + eprintln!( + "signerd: starting · socket={} · chain_id={} · rpc={}", + cfg.socket_path.display(), + cfg.chain_id, + cfg.redacted_rpc(), + ); + + socket::prepare_parent(&cfg.socket_path)?; + // Hold the single-instance lock for the whole process lifetime. + let _lock = socket::single_instance(&cfg.socket_path)?; + let listener = socket::bind(&cfg.socket_path)?; + eprintln!("signerd: listening (same-uid only)"); + + let daemon = Arc::new(Mutex::new(Daemon::new(cfg))); + server::serve(listener, daemon).await +} diff --git a/crates/deckard-signerd/src/policy_store.rs b/crates/deckard-signerd/src/policy_store.rs new file mode 100644 index 0000000..ac92a22 --- /dev/null +++ b/crates/deckard-signerd/src/policy_store.rs @@ -0,0 +1,113 @@ +//! Loading the signer [`Policy`] and tracking the daily spend. +//! +//! The policy is read from `policy.json` in the config dir; a **sane default** is used if the +//! file is absent or malformed (fail-safe: a tight cap, no allowlist, approval-over-cap). +//! `spent_today_wei` is **in-memory only**, rolls over at UTC midnight, and resets on daemon +//! restart — cross-restart persistence is a documented v1 limitation / fast-follow. There is +//! no `SetPolicy` mutation API yet. + +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use alloy_primitives::U256; + +use deckard_contract::{ApprovalMode, Policy}; + +/// Default per-tx cap: 0.05 ETH. +pub const DEFAULT_PER_TX_CAP_WEI: u128 = 50_000_000_000_000_000; +/// Default rolling daily cap: 0.2 ETH. +pub const DEFAULT_DAILY_CAP_WEI: u128 = 200_000_000_000_000_000; +/// Default auto-shield threshold: 0.01 ETH. +pub const DEFAULT_AUTO_SHIELD_MIN_WEI: u128 = 10_000_000_000_000_000; + +/// The fail-safe default policy used when `policy.json` is absent or unreadable. +pub fn default_policy() -> Policy { + Policy { + per_tx_cap_wei: U256::from(DEFAULT_PER_TX_CAP_WEI), + daily_cap_wei: U256::from(DEFAULT_DAILY_CAP_WEI), + spent_today_wei: U256::ZERO, + allow_to: vec![], // empty = any recipient; the caps still apply + auto_shield_min_wei: U256::from(DEFAULT_AUTO_SHIELD_MIN_WEI), + require_approval: ApprovalMode::OverCap, + revoked: false, + } +} + +/// Load the policy from `path`, falling back to [`default_policy`] on any problem. +/// +/// `spent_today_wei` and `revoked` are forced to their fresh-start values regardless of what +/// the file says: spend tracking is in-memory, and a daemon boots *armed* (the brake is a +/// live STOP, not a persisted flag). +pub fn load_policy(path: &Path) -> Policy { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(_) => return default_policy(), + }; + match serde_json::from_slice::(&bytes) { + Ok(mut p) => { + p.spent_today_wei = U256::ZERO; + p.revoked = false; + p + } + Err(_) => default_policy(), + } +} + +/// Days since the Unix epoch in UTC — the rollover key for `spent_today_wei`. (Chrono-free: +/// integer division of the wall-clock seconds.) +pub fn current_utc_day() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() / 86_400) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn absent_file_yields_default() { + let p = load_policy(Path::new("/nonexistent/deckard/policy.json")); + assert_eq!(p.per_tx_cap_wei, U256::from(DEFAULT_PER_TX_CAP_WEI)); + assert_eq!(p.daily_cap_wei, U256::from(DEFAULT_DAILY_CAP_WEI)); + assert!(!p.revoked); + assert!(p.allow_to.is_empty()); + } + + #[test] + fn load_resets_spent_and_revoked() { + let dir = std::env::temp_dir().join(format!("deckard-policy-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("policy.json"); + // A file that (maliciously or staleley) claims spent + revoked. + let on_disk = Policy { + spent_today_wei: U256::from(999u64), + revoked: true, + ..default_policy() + }; + std::fs::write(&path, serde_json::to_vec(&on_disk).unwrap()).unwrap(); + + let loaded = load_policy(&path); + assert_eq!( + loaded.spent_today_wei, + U256::ZERO, + "spend is in-memory; never trusted" + ); + assert!(!loaded.revoked, "daemon boots armed"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn malformed_file_yields_default() { + let dir = std::env::temp_dir().join(format!("deckard-policy-bad-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("policy.json"); + std::fs::write(&path, b"{ not valid json").unwrap(); + assert_eq!( + load_policy(&path).per_tx_cap_wei, + U256::from(DEFAULT_PER_TX_CAP_WEI) + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/deckard-signerd/src/request_id.rs b/crates/deckard-signerd/src/request_id.rs new file mode 100644 index 0000000..ed65c94 --- /dev/null +++ b/crates/deckard-signerd/src/request_id.rs @@ -0,0 +1,85 @@ +//! How `propose` assigns a request id. +//! +//! The frozen `Decision::Allow` carries **no** id, yet `execute` is keyed by one. We close +//! that gap deterministically: the request id is `keccak256` of a stable, unambiguous +//! encoding of the intent. So a client that received `Allow` can derive the very same id +//! locally to `execute` it, while a `NeedsApproval` id (returned on the wire) is identical. +//! Both the daemon (server) and [`SignerClient`](crate::SignerClient) call this, so they +//! never disagree. +//! +//! v1 caveats (documented, fast-follow): the id is deterministic, hence *guessable* from the +//! intent — fine for a same-uid socket, but production should salt it (which needs the id to +//! ride the `Allow` on the wire — a contract change). Two identical intents map to one id; +//! the daemon coalesces them (it preserves an already-broadcast record, so this can never +//! double-spend). + +use alloy_primitives::keccak256; + +use deckard_contract::{Intent, IntentKind, RequestId}; + +/// Deterministic request id for an intent. Fixed-width fields first, variable `calldata` +/// last, so no field boundary is ambiguous. +pub fn request_id_for(intent: &Intent) -> RequestId { + let mut buf = Vec::with_capacity(8 + 21 + 32 + 1 + intent.calldata.len()); + buf.extend_from_slice(&intent.chain_id.to_be_bytes()); // 8 + buf.extend_from_slice(intent.to.as_slice()); // 20 + match intent.token { + Some(token) => { + buf.push(1); + buf.extend_from_slice(token.as_slice()); // 20 + } + None => buf.push(0), + } + buf.extend_from_slice(&intent.value.to_be_bytes::<32>()); // 32 + buf.push(match intent.kind { + IntentKind::Send => 0, + IntentKind::Shield => 1, + IntentKind::Unshield => 2, + IntentKind::ContractCall => 3, + }); + buf.extend_from_slice(&intent.calldata); // variable, last + keccak256(&buf) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Address, Bytes, U256}; + + fn send(value: u64) -> Intent { + Intent { + chain_id: 31337, + to: Address::repeat_byte(0x22), + token: None, + value: U256::from(value), + calldata: Bytes::new(), + kind: IntentKind::Send, + } + } + + #[test] + fn deterministic_and_nonzero() { + let id = request_id_for(&send(100)); + assert_eq!(id, request_id_for(&send(100)), "same intent → same id"); + assert_ne!(id, RequestId::ZERO); + } + + #[test] + fn distinguishes_fields() { + assert_ne!( + request_id_for(&send(100)), + request_id_for(&send(101)), + "value" + ); + let mut other_to = send(100); + other_to.to = Address::repeat_byte(0x33); + assert_ne!(request_id_for(&send(100)), request_id_for(&other_to), "to"); + let mut tokened = send(100); + tokened.token = Some(Address::repeat_byte(0x22)); + assert_ne!( + request_id_for(&send(100)), + request_id_for(&tokened), + "token presence" + ); + } +} diff --git a/crates/deckard-signerd/src/server.rs b/crates/deckard-signerd/src/server.rs new file mode 100644 index 0000000..da1f020 --- /dev/null +++ b/crates/deckard-signerd/src/server.rs @@ -0,0 +1,95 @@ +//! The UDS server: accept connections, gate each on same-uid peer-cred, then serve a stream +//! of length-delimited CBOR request/response frames. All requests funnel through the single +//! shared [`Daemon`] behind a `tokio::sync::Mutex`, so they are serialized and can't race. + +use std::sync::Arc; + +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::Mutex; +use zeroize::Zeroize; + +use deckard_contract::{Decision, SignerRequest, SignerResponse}; + +use crate::auth; +use crate::daemon::Daemon; +use crate::frame; + +/// Accept loop. Rejects (and drops) any connection whose peer uid differs from ours, then +/// spawns a per-connection task. Runs until the listener errors fatally. +pub async fn serve(listener: UnixListener, daemon: Arc>) -> anyhow::Result<()> { + let our = auth::our_uid(); + loop { + let stream = match listener.accept().await { + Ok((stream, _addr)) => stream, + Err(e) => { + eprintln!("signerd: accept error: {e}"); + continue; + } + }; + + match auth::peer_uid(&stream) { + Ok(uid) if auth::same_uid(uid, our) => {} + Ok(uid) => { + eprintln!("signerd: rejecting connection from uid {uid} (daemon uid {our})"); + continue; // drop the stream → connection refused + } + Err(e) => { + eprintln!("signerd: peer-cred check failed, dropping connection: {e}"); + continue; + } + } + + let daemon = Arc::clone(&daemon); + tokio::spawn(async move { + if let Err(e) = handle_conn(stream, daemon).await { + eprintln!("signerd: connection closed: {e}"); + } + }); + } +} + +/// Serve one connection: read a frame, decode it, zeroize the raw bytes (they may hold an +/// `Unlock` passphrase), dispatch, write the response. A malformed/oversize frame gets one +/// error response and the connection is closed. +async fn handle_conn(mut stream: UnixStream, daemon: Arc>) -> anyhow::Result<()> { + loop { + let mut buf = match frame::read_frame(&mut stream).await { + Ok(Some(buf)) => buf, + Ok(None) => return Ok(()), // peer closed cleanly between frames + Err(e) => { + // Oversize/short read: best-effort error, then close. + let _ = reply_error(&mut stream, "malformed_request").await; + return Err(e); + } + }; + + let decoded: Result = frame::decode(&buf); + // Always scrub the raw frame: an Unlock frame carries the passphrase bytes. + buf.zeroize(); + + let req = match decoded { + Ok(req) => req, + Err(e) => { + let _ = reply_error(&mut stream, "malformed_request").await; + return Err(e); + } + }; + + // Dispatch behind the shared lock (serializes all requests). Note: we deliberately + // never log the request contents — an Unlock passphrase must never reach a log line. + let resp = daemon.lock().await.handle(req).await; + + let body = frame::encode(&resp)?; + frame::write_frame(&mut stream, &body).await?; + } +} + +/// Send a generic Deny-style error response (used when we can't even decode the request, so +/// the precise response variant is unknown). +async fn reply_error(stream: &mut UnixStream, reason: &str) -> anyhow::Result<()> { + let resp = SignerResponse::Decision(Decision::Deny { + reason: reason.to_string(), + }); + let body = frame::encode(&resp)?; + frame::write_frame(stream, &body).await +} diff --git a/crates/deckard-signerd/src/signing.rs b/crates/deckard-signerd/src/signing.rs new file mode 100644 index 0000000..a41b28c --- /dev/null +++ b/crates/deckard-signerd/src/signing.rs @@ -0,0 +1,64 @@ +//! The broadcast path: build an **EIP-1559** transaction with alloy's recommended fillers +//! (nonce from the pending count, gas from fee/gas estimation, chain id), sign it, and +//! broadcast it through the configured RPC. +//! +//! ## The signer version bridge +//! `deckard-core`'s keystore yields a signer from `alloy-signer-local` **2.0.5**, but the +//! provider here is built from the `alloy` meta-crate (bundled signer-local **1.8.3**). The +//! two `PrivateKeySigner` types are incompatible — so we never hand one to the other. The +//! caller extracts the raw 32-byte secp256k1 scalar (the version-stable `B256` is the only +//! thing that crosses), and we reconstruct the signer in *this* alloy stack from those +//! bytes. The scalar is held in a `Zeroizing` buffer by the caller. + +use alloy::network::{EthereumWallet, TransactionBuilder}; +use alloy::providers::{Provider, ProviderBuilder}; +use alloy::rpc::types::TransactionRequest; +use alloy::signers::local::PrivateKeySigner; +use alloy_primitives::{Address, B256, U256}; + +/// Sign + broadcast a native-ETH send and return the broadcast tx hash. +/// +/// `scalar` is the raw 32-byte private key (the caller keeps it in `Zeroizing`). v1 supports +/// native sends only; ERC-20/contract sends are rejected upstream in `propose`. +pub async fn broadcast_native_send( + scalar: &[u8], + rpc_url: &str, + chain_id: u64, + to: Address, + value_wei: U256, +) -> anyhow::Result { + let signer = PrivateKeySigner::from_slice(scalar) + .map_err(|e| anyhow::anyhow!("reconstruct signer: {e}"))?; + let wallet = EthereumWallet::from(signer); + + let url = rpc_url + .parse() + .map_err(|e| anyhow::anyhow!("bad RPC URL {rpc_url:?}: {e}"))?; + // `new()` installs the recommended fillers (nonce/gas/chain-id); `.wallet()` adds signing. + let provider = ProviderBuilder::new().wallet(wallet).connect_http(url); + + // Only `to`/`value` set ⇒ the gas filler produces an EIP-1559 (type-2) tx and fills the + // fee fields; the nonce filler uses the pending count; chain id is pinned explicitly. + let tx = TransactionRequest::default() + .with_to(to) + .with_value(value_wei) + .with_chain_id(chain_id); + + let pending = provider + .send_transaction(tx) + .await + .map_err(|e| anyhow::anyhow!("broadcast: {e}"))?; + Ok(*pending.tx_hash()) +} + +/// Read an address's public (native) balance through the RPC — key-less, read-only. +pub async fn read_balance(rpc_url: &str, addr: Address) -> anyhow::Result { + let url = rpc_url + .parse() + .map_err(|e| anyhow::anyhow!("bad RPC URL {rpc_url:?}: {e}"))?; + let provider = ProviderBuilder::new().connect_http(url); + provider + .get_balance(addr) + .await + .map_err(|e| anyhow::anyhow!("get_balance: {e}")) +} diff --git a/crates/deckard-signerd/src/socket.rs b/crates/deckard-signerd/src/socket.rs new file mode 100644 index 0000000..9c81126 --- /dev/null +++ b/crates/deckard-signerd/src/socket.rs @@ -0,0 +1,122 @@ +//! Socket lifecycle: where the UDS lives, its permissions, stale-socket cleanup, and the +//! single-instance lock. +//! +//! Path: `$XDG_RUNTIME_DIR/deckard/signerd.sock` (Linux); when `$XDG_RUNTIME_DIR` is unset +//! (the usual macOS case) it falls back to `$TMPDIR/deckard-$UID/signerd.sock`. The parent +//! dir is forced to `0700` and the socket to `0600` — `bind`/`mkdir` honor the umask, so we +//! chmod explicitly rather than trusting it. + +use std::fs; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; + +use nix::fcntl::{Flock, FlockArg}; +use tokio::net::UnixListener; + +/// The default socket path (pure — does not touch the filesystem). Call [`prepare_parent`] +/// before binding. +pub fn default_socket_path() -> PathBuf { + runtime_dir().join("signerd.sock") +} + +/// The runtime directory that holds the socket + lockfile. +fn runtime_dir() -> PathBuf { + if let Some(xdg) = std::env::var_os("XDG_RUNTIME_DIR") { + if !xdg.is_empty() { + return PathBuf::from(xdg).join("deckard"); + } + } + // macOS (XDG_RUNTIME_DIR usually unset): per-uid dir under TMPDIR. + let tmp = std::env::var_os("TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + let uid = nix::unistd::geteuid().as_raw(); + tmp.join(format!("deckard-{uid}")) +} + +/// Create the socket's parent dir (if needed) and force it to `0700`. +pub fn prepare_parent(socket_path: &Path) -> std::io::Result<()> { + if let Some(dir) = socket_path.parent() { + fs::create_dir_all(dir)?; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +/// Acquire the single-instance lock — an exclusive, non-blocking `flock` on a sibling +/// `signerd.lock`. The returned guard must be held for the daemon's whole lifetime; the OS +/// releases the lock automatically on process exit (even on SIGKILL), so no stale-lock +/// cleanup is needed. A second daemon fails fast instead of racing on the socket. +pub fn single_instance(socket_path: &Path) -> anyhow::Result> { + let lock_path = socket_path.with_extension("lock"); + let file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) // it's only a lock handle; its contents are irrelevant + .mode(0o600) + .open(&lock_path)?; + Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_f, errno)| { + anyhow::anyhow!("another deckard-signerd is already running ({errno})") + }) +} + +/// Remove any stale socket node, then bind and chmod to `0600`. +pub fn bind(socket_path: &Path) -> std::io::Result { + match fs::remove_file(socket_path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + let listener = UnixListener::bind(socket_path)?; + // bind honored the umask; force owner-only. + fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600))?; + Ok(listener) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_path_ends_in_signerd_sock() { + assert!(default_socket_path().ends_with("signerd.sock")); + } + + #[tokio::test] + async fn bind_yields_a_0600_socket_in_a_0700_dir() { + // Use an isolated temp dir as the "runtime dir". + let base = std::env::temp_dir().join(format!("deckard-sock-test-{}", std::process::id())); + let sock = base.join("signerd.sock"); + let _ = fs::remove_dir_all(&base); + + prepare_parent(&sock).unwrap(); + let dir_mode = fs::metadata(&base).unwrap().permissions().mode() & 0o777; + assert_eq!(dir_mode, 0o700, "parent dir must be 0700"); + + let _listener = bind(&sock).unwrap(); + let sock_mode = fs::metadata(&sock).unwrap().permissions().mode() & 0o777; + assert_eq!(sock_mode, 0o600, "socket must be 0600"); + + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn single_instance_is_exclusive() { + let base = std::env::temp_dir().join(format!("deckard-lock-test-{}", std::process::id())); + let sock = base.join("signerd.sock"); + let _ = fs::remove_dir_all(&base); + prepare_parent(&sock).unwrap(); + + let first = single_instance(&sock).expect("first lock"); + // A second attempt on the same lockfile must fail while the first is held. + assert!( + single_instance(&sock).is_err(), + "second instance must be refused" + ); + drop(first); + // Once released, a fresh lock succeeds. + assert!(single_instance(&sock).is_ok()); + + let _ = fs::remove_dir_all(&base); + } +} diff --git a/crates/deckard-signerd/src/supervise.rs b/crates/deckard-signerd/src/supervise.rs new file mode 100644 index 0000000..3b8c472 --- /dev/null +++ b/crates/deckard-signerd/src/supervise.rs @@ -0,0 +1,159 @@ +//! Spawn + supervise the `deckard-signerd` child process from the GUI app. +//! +//! The app owns the daemon's lifecycle: it spawns the child, restarts it (capped backoff) if +//! it crashes, and kills it on app exit (via `Drop`). The child binary is resolved from +//! `DECKARD_SIGNERD_BIN`, else next to the app binary, else `deckard-signerd` on `PATH`. The +//! socket path is passed explicitly so the app's [`SignerClient`](crate::SignerClient) and +//! the daemon agree. The child inherits stdout/stderr → the app's log. + +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// Resolve the `deckard-signerd` binary: explicit override, then a sibling of the running +/// app binary, then the bare name (PATH lookup). +pub fn resolve_binary() -> PathBuf { + if let Some(p) = std::env::var_os("DECKARD_SIGNERD_BIN") { + return PathBuf::from(p); + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let sibling = dir.join("deckard-signerd"); + if sibling.exists() { + return sibling; + } + } + } + PathBuf::from("deckard-signerd") +} + +/// A running, self-restarting daemon child. Dropping it stops the supervisor and kills the +/// child. +pub struct DaemonSupervisor { + shutdown: Arc, + child: Arc>>, + socket_path: PathBuf, +} + +impl DaemonSupervisor { + /// Spawn the daemon bound to `socket_path` (broadcasting via `rpc_url` on `chain_id`) and a + /// monitor thread that respawns it on crash. The child inherits the current environment + /// plus `DECKARD_SOCKET_PATH`/`DECKARD_RPC_URL`/`DECKARD_CHAIN_ID`. + pub fn spawn(socket_path: PathBuf, rpc_url: String, chain_id: u64) -> Self { + let shutdown = Arc::new(AtomicBool::new(false)); + let child: Arc>> = Arc::new(Mutex::new(None)); + let bin = resolve_binary(); + let env = ChildEnv { + socket_path: socket_path.clone(), + rpc_url, + chain_id, + }; + + let sup = Self { + shutdown: Arc::clone(&shutdown), + child: Arc::clone(&child), + socket_path, + }; + + std::thread::Builder::new() + .name("deckard-signerd-sup".into()) + .spawn(move || monitor_loop(bin, env, shutdown, child)) + .ok(); + + sup + } + + /// The socket path the supervised daemon binds. + pub fn socket_path(&self) -> &std::path::Path { + &self.socket_path + } + + /// A client wired to this daemon's socket. + pub fn client(&self) -> crate::SignerClient { + crate::SignerClient::new(self.socket_path.clone()) + } +} + +impl Drop for DaemonSupervisor { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + if let Ok(mut guard) = self.child.lock() { + if let Some(mut child) = guard.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } +} + +/// The environment the daemon child needs to agree with the app on socket + chain. +struct ChildEnv { + socket_path: PathBuf, + rpc_url: String, + chain_id: u64, +} + +/// Spawn → poll-until-exit → backoff → respawn, until shutdown is signalled. +fn monitor_loop( + bin: PathBuf, + env: ChildEnv, + shutdown: Arc, + child_slot: Arc>>, +) { + let mut backoff = Duration::from_millis(200); + while !shutdown.load(Ordering::SeqCst) { + match Command::new(&bin) + .env("DECKARD_SOCKET_PATH", &env.socket_path) + .env("DECKARD_RPC_URL", &env.rpc_url) + .env("DECKARD_CHAIN_ID", env.chain_id.to_string()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + { + Ok(child) => { + if let Ok(mut slot) = child_slot.lock() { + *slot = Some(child); + } + // Poll for exit, releasing the lock between polls so Drop can kill the child. + loop { + if shutdown.load(Ordering::SeqCst) { + return; + } + let exited = match child_slot.lock() { + Ok(mut slot) => match slot.as_mut() { + Some(c) => match c.try_wait() { + Ok(Some(_status)) => { + *slot = None; + true + } + Ok(None) => false, + Err(_) => { + *slot = None; + true + } + }, + None => true, // Drop took it + }, + Err(_) => true, + }; + if exited { + break; + } + std::thread::sleep(Duration::from_millis(200)); + backoff = Duration::from_millis(200); // healthy run resets the backoff + } + } + Err(e) => { + eprintln!("deckard: failed to spawn signerd ({}): {e}", bin.display()); + } + } + + if shutdown.load(Ordering::SeqCst) { + return; + } + std::thread::sleep(backoff); + backoff = (backoff * 2).min(Duration::from_secs(5)); + } +} diff --git a/crates/deckard-signerd/tests/anvil_e2e.rs b/crates/deckard-signerd/tests/anvil_e2e.rs new file mode 100644 index 0000000..3416425 --- /dev/null +++ b/crates/deckard-signerd/tests/anvil_e2e.rs @@ -0,0 +1,188 @@ +//! Broadcast tests on a local anvil node (the only assertions that need a chain): a within-cap +//! send signs + broadcasts with a real receipt (#4), and an over-cap send broadcasts after +//! approval (#5). Native ETH sends don't need a mainnet fork, so a plain local anvil suffices +//! and CI needs no RPC secret. Skips gracefully when `anvil` isn't installed. + +mod common; + +use alloy_primitives::{Address, Bytes, U256}; +use deckard_contract::{ + ApprovalMode, ApprovalStatus, Decision, ExecuteResult, Intent, IntentKind, Policy, + SignerRequest, SignerResponse, +}; +use deckard_signerd::SignerClient; + +use common::*; + +const CHAIN: u64 = 31337; +const PER_TX_CAP: u128 = 50_000_000_000_000_000; // 0.05 ETH + +fn send(to: Address, value: u128) -> Intent { + Intent { + chain_id: CHAIN, + to, + token: None, + value: U256::from(value), + calldata: Bytes::new(), + kind: IntentKind::Send, + } +} + +#[tokio::test] +async fn within_cap_send_broadcasts_with_receipt() { + if !anvil_available() { + eprintln!("SKIP within_cap_send_broadcasts_with_receipt: anvil not on PATH"); + return; + } + let anvil = start_anvil(); + wait_anvil_ready(&anvil.url()).await; + + let dir = TempDir::new("anvil-send"); + let (_wallet, recipient) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), &anvil.url(), CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + let value: u128 = 10_000_000_000_000_000; // 0.01 ETH, within cap + let intent = send(recipient, value); + assert_eq!(client.propose(&intent).await.unwrap(), Decision::Allow); + let id = SignerClient::request_id_for_intent(&intent); + + let before = balance(&anvil.url(), recipient).await; + let tx_hash = match client.execute(id).await.unwrap() { + ExecuteResult::Broadcast { tx_hash } => tx_hash, + other => panic!("expected Broadcast, got {other:?}"), + }; + + let receipt = wait_receipt(&anvil.url(), tx_hash) + .await + .expect("a real receipt"); + assert!(receipt.status(), "tx should have succeeded"); + let after = balance(&anvil.url(), recipient).await; + assert_eq!( + after - before, + U256::from(value), + "recipient credited exactly the sent value" + ); + + // Idempotency: a second execute of the same id is refused. + assert_eq!( + client.execute(id).await.unwrap(), + ExecuteResult::Denied { + reason: "already_executed".into() + } + ); +} + +#[tokio::test] +async fn over_cap_approve_then_execute_broadcasts() { + if !anvil_available() { + eprintln!("SKIP over_cap_approve_then_execute_broadcasts: anvil not on PATH"); + return; + } + let anvil = start_anvil(); + wait_anvil_ready(&anvil.url()).await; + + let dir = TempDir::new("anvil-approve"); + let (_wallet, recipient) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), &anvil.url(), CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + let value: u128 = PER_TX_CAP + 10_000_000_000_000_000; // 0.06 ETH > cap + let intent = send(recipient, value); + let id = match client.propose(&intent).await.unwrap() { + Decision::NeedsApproval { request_id } => request_id, + other => panic!("expected NeedsApproval, got {other:?}"), + }; + + // Approve (the native card / a test), then execute → broadcast. + assert_eq!( + client + .request(&SignerRequest::Resolve { + request_id: id, + approved: true + }) + .await + .unwrap(), + SignerResponse::Ack + ); + assert_eq!( + client + .request(&SignerRequest::Status { request_id: id }) + .await + .unwrap(), + SignerResponse::Status(ApprovalStatus::Allowed) + ); + + let before = balance(&anvil.url(), recipient).await; + let tx_hash = match client.execute(id).await.unwrap() { + ExecuteResult::Broadcast { tx_hash } => tx_hash, + other => panic!("expected Broadcast, got {other:?}"), + }; + let receipt = wait_receipt(&anvil.url(), tx_hash) + .await + .expect("a real receipt"); + assert!(receipt.status()); + let after = balance(&anvil.url(), recipient).await; + assert_eq!(after - before, U256::from(value)); +} + +#[tokio::test] +async fn daily_cap_enforced_at_execute() { + // #1 regression: two within-cap proposals both Allow (spent=0 at propose), but once the + // first broadcasts, the second can't execute past the daily cap — the auto-allow is + // re-checked against the caps at sign time. + if !anvil_available() { + eprintln!("SKIP daily_cap_enforced_at_execute: anvil not on PATH"); + return; + } + let anvil = start_anvil(); + wait_anvil_ready(&anvil.url()).await; + + let dir = TempDir::new("anvil-dailycap"); + let (_wallet, recipient) = seal_account0(dir.path()); + // Tight policy: per-tx 0.05, daily 0.05. 0.04 + 0.039 each pass at propose, but together + // exceed the 0.05 daily cap. + let policy = Policy { + per_tx_cap_wei: U256::from(50_000_000_000_000_000u128), + daily_cap_wei: U256::from(50_000_000_000_000_000u128), + spent_today_wei: U256::ZERO, + allow_to: vec![], + auto_shield_min_wei: U256::from(10_000_000_000_000_000u128), + require_approval: ApprovalMode::OverCap, + revoked: false, + }; + std::fs::write( + dir.path().join("policy.json"), + serde_json::to_vec(&policy).unwrap(), + ) + .unwrap(); + + let d = spawn_daemon(dir.path(), &anvil.url(), CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + let first = send(recipient, 40_000_000_000_000_000); // 0.04 ETH + let second = send(recipient, 39_000_000_000_000_000); // 0.039 ETH (distinct id) + assert_eq!(client.propose(&first).await.unwrap(), Decision::Allow); + assert_eq!(client.propose(&second).await.unwrap(), Decision::Allow); + + // First executes (spends 0.04); the second now exceeds the 0.05 daily cap at sign time. + assert!(matches!( + client + .execute(SignerClient::request_id_for_intent(&first)) + .await + .unwrap(), + ExecuteResult::Broadcast { .. } + )); + assert_eq!( + client + .execute(SignerClient::request_id_for_intent(&second)) + .await + .unwrap(), + ExecuteResult::Denied { + reason: "cap_exceeded".into() + } + ); +} diff --git a/crates/deckard-signerd/tests/common/mod.rs b/crates/deckard-signerd/tests/common/mod.rs new file mode 100644 index 0000000..f20efe0 --- /dev/null +++ b/crates/deckard-signerd/tests/common/mod.rs @@ -0,0 +1,219 @@ +//! Shared helpers for the deckard-signerd integration tests: unique temp dirs, vault +//! sealing, spawning the daemon binary, and (optionally) a local anvil node + chain reads. + +#![allow(dead_code)] // each test binary uses a different subset of these helpers + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use alloy::providers::{Provider, ProviderBuilder}; +use alloy::rpc::types::TransactionReceipt; +use alloy_primitives::{Address, B256, U256}; +use deckard_core::{KdfParams, Vault}; + +/// Anvil's default dev mnemonic — account 0 is prefunded with 10000 ETH at the same BIP-44 +/// path the keystore derives, so a vault sealed from this phrase controls a funded account. +pub const MNEMONIC: &str = "test test test test test test test test test test test junk"; +/// The keystore passphrase the tests seal with and unlock over the socket. +pub const PASS: &str = "integration-test-pass"; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Fast Argon2 params for tests (8 MiB / t=1) — the keystore's production params are too slow +/// to run on every unlock here. (`KdfParams` fields are public, so we construct directly; this +/// is the in-bounds minimum `validate()` accepts.) +pub fn fast_kdf() -> KdfParams { + KdfParams { + m_kib: 8 * 1024, + t: 1, + p: 1, + } +} + +/// A uniquely-named temp dir, removed on drop. +pub struct TempDir { + dir: PathBuf, +} + +impl TempDir { + pub fn new(tag: &str) -> Self { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("deckard-it-{tag}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp dir"); + Self { dir } + } + pub fn path(&self) -> &Path { + &self.dir + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// Seal a vault for anvil's account 0 into `/vault.bin` and return +/// `(account0_address, account1_address)` (the wallet + a recipient), derived locally without +/// any key leaving this process. +pub fn seal_account0(dir: &Path) -> (Address, Address) { + let vault = Vault::import_mnemonic(MNEMONIC, PASS, fast_kdf()).expect("import mnemonic"); + let unlocked = vault.unlock(PASS).expect("unlock"); + let wallet = unlocked.primary_address().expect("account 0 address"); + let recipient = unlocked.account_address(1).expect("account 1 address"); + vault + .write_atomic(&dir.join("vault.bin")) + .expect("write vault"); + (wallet, recipient) +} + +/// A spawned daemon process; killed on drop. +pub struct DaemonProc { + child: Child, + pub socket_path: PathBuf, +} + +impl Drop for DaemonProc { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Spawn the real `deckard-signerd` binary against `dir` (config + socket) and `rpc_url`, +/// waiting until it binds the socket. +pub fn spawn_daemon( + dir: &Path, + rpc_url: &str, + chain_id: u64, + extra_env: &[(&str, &str)], +) -> DaemonProc { + let socket = dir.join("signerd.sock"); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_deckard-signerd")); + cmd.env("DECKARD_CONFIG_DIR", dir) + .env("DECKARD_SOCKET_PATH", &socket) + .env("DECKARD_RPC_URL", rpc_url) + .env("DECKARD_CHAIN_ID", chain_id.to_string()); + for (k, v) in extra_env { + cmd.env(k, v); + } + let child = cmd.spawn().expect("spawn deckard-signerd binary"); + assert!( + wait_for(|| socket.exists(), Duration::from_secs(5)), + "daemon never bound its socket" + ); + DaemonProc { + child, + socket_path: socket, + } +} + +/// Poll `cond` until true or `timeout` elapses. +pub fn wait_for(mut cond: impl FnMut() -> bool, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + loop { + if cond() { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +// --- anvil lane ---------------------------------------------------------------------------- + +/// Whether `anvil` is on PATH (tests that broadcast skip gracefully when it isn't). +pub fn anvil_available() -> bool { + Command::new("anvil") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +/// A spawned anvil node; killed on drop. +pub struct Anvil { + child: Child, + port: u16, +} + +impl Anvil { + pub fn url(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } +} + +impl Drop for Anvil { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Start a local anvil (chain 31337, prefunded dev accounts, automine). +pub fn start_anvil() -> Anvil { + let port = free_port(); + let child = Command::new("anvil") + .args([ + "--mnemonic", + MNEMONIC, + "--chain-id", + "31337", + "--accounts", + "10", + "--balance", + "10000", + "--port", + &port.to_string(), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn anvil"); + Anvil { child, port } +} + +/// Wait until anvil answers JSON-RPC. +pub async fn wait_anvil_ready(url: &str) { + let provider = ProviderBuilder::new().connect_http(url.parse().unwrap()); + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + if provider.get_block_number().await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("anvil never became ready at {url}"); +} + +/// Public balance of `addr` via `url`. +pub async fn balance(url: &str, addr: Address) -> U256 { + let provider = ProviderBuilder::new().connect_http(url.parse().unwrap()); + provider.get_balance(addr).await.expect("get_balance") +} + +/// Poll for a mined receipt of `hash` via `url`. +pub async fn wait_receipt(url: &str, hash: B256) -> Option { + let provider = ProviderBuilder::new().connect_http(url.parse().unwrap()); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(Some(receipt)) = provider.get_transaction_receipt(hash).await { + return Some(receipt); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + None +} diff --git a/crates/deckard-signerd/tests/daemon_e2e.rs b/crates/deckard-signerd/tests/daemon_e2e.rs new file mode 100644 index 0000000..740c8e4 --- /dev/null +++ b/crates/deckard-signerd/tests/daemon_e2e.rs @@ -0,0 +1,386 @@ +//! End-to-end daemon tests that drive the real `deckard-signerd` binary over the socket, but +//! do NOT need a chain (no transaction is broadcast). Covers acceptance #2 (socket perms), +//! #3 (unlock outcomes), the propose-decision half of #4 (chain mismatch / unsupported kind / +//! allowlist / cap classification), #5 (resolve-false + TTL deny execute), #6 (STOP zeroize + +//! re-arm), and #7 (TOCTOU). The successful broadcasts live in `anvil_e2e.rs`. + +mod common; + +use std::os::unix::fs::PermissionsExt; + +use alloy_primitives::{Address, Bytes, U256}; +use deckard_contract::{ + ApprovalMode, ApprovalStatus, Decision, ExecuteResult, Intent, IntentKind, Policy, + SignerRequest, SignerResponse, UnlockOutcome, +}; +use deckard_signerd::SignerClient; + +use common::*; + +const CHAIN: u64 = 31337; +/// We never broadcast in this file, so the RPC is never contacted — a dead address is fine. +const DUMMY_RPC: &str = "http://127.0.0.1:1"; +const PER_TX_CAP: u64 = 50_000_000_000_000_000; // 0.05 ETH (the default policy cap) + +fn send(to: Address, value: u64) -> Intent { + Intent { + chain_id: CHAIN, + to, + token: None, + value: U256::from(value), + calldata: Bytes::new(), + kind: IntentKind::Send, + } +} + +#[tokio::test] +async fn unlock_outcomes() { + // NoVault: empty config dir. + let dir = TempDir::new("novault"); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + assert_eq!( + client.unlock("anything").await.unwrap(), + UnlockOutcome::NoVault + ); + drop(d); + + // Sealed vault: wrong → BadPassphrase; correct → Unlocked{account0}. + let dir = TempDir::new("unlock"); + let (wallet, _recipient) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + assert_eq!( + client.unlock("wrong-pass").await.unwrap(), + UnlockOutcome::BadPassphrase + ); + assert_eq!( + client.unlock(PASS).await.unwrap(), + UnlockOutcome::Unlocked { address: wallet } + ); +} + +#[tokio::test] +async fn propose_decision_matrix() { + let dir = TempDir::new("propose"); + let (_wallet, to) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + + // Locked → Deny{locked}. + assert_eq!( + client.propose(&send(to, 1_000)).await.unwrap(), + Decision::Deny { + reason: "locked".into() + } + ); + client.unlock(PASS).await.unwrap(); + + // Within cap → Allow. + assert_eq!( + client.propose(&send(to, 1_000)).await.unwrap(), + Decision::Allow + ); + + // Over per-tx cap → NeedsApproval. + assert!(matches!( + client.propose(&send(to, PER_TX_CAP + 1)).await.unwrap(), + Decision::NeedsApproval { .. } + )); + + // chain mismatch. + let mut wrong_chain = send(to, 1_000); + wrong_chain.chain_id = 1; + assert_eq!( + client.propose(&wrong_chain).await.unwrap(), + Decision::Deny { + reason: "chain_mismatch".into() + } + ); + + // unsupported kind (Shield is T-Privacy). + let mut shield = send(to, 1_000); + shield.kind = IntentKind::Shield; + assert_eq!( + client.propose(&shield).await.unwrap(), + Decision::Deny { + reason: "unsupported_v1".into() + } + ); + + // ERC-20 send (token = Some) is a fast-follow. + let mut erc20 = send(to, 1_000); + erc20.token = Some(Address::repeat_byte(0xEE)); + assert_eq!( + client.propose(&erc20).await.unwrap(), + Decision::Deny { + reason: "erc20_unsupported_v1".into() + } + ); +} + +#[tokio::test] +async fn off_allowlist_denies() { + let dir = TempDir::new("allowlist"); + let (_wallet, to) = seal_account0(dir.path()); + // A policy whose allowlist excludes `to`. + let policy = Policy { + per_tx_cap_wei: U256::from(PER_TX_CAP), + daily_cap_wei: U256::from(200_000_000_000_000_000u64), + spent_today_wei: U256::ZERO, + allow_to: vec![Address::repeat_byte(0x99)], + auto_shield_min_wei: U256::from(10_000_000_000_000_000u64), + require_approval: ApprovalMode::OverCap, + revoked: false, + }; + std::fs::write( + dir.path().join("policy.json"), + serde_json::to_vec(&policy).unwrap(), + ) + .unwrap(); + + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + assert_eq!( + client.propose(&send(to, 1_000)).await.unwrap(), + Decision::Deny { + reason: "off_allowlist".into() + } + ); +} + +#[tokio::test] +async fn resolve_false_and_ttl_deny_execute() { + let dir = TempDir::new("ttl"); + let (_wallet, to) = seal_account0(dir.path()); + let d = spawn_daemon( + dir.path(), + DUMMY_RPC, + CHAIN, + &[("DECKARD_APPROVAL_TTL_SECS", "1")], + ); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + // resolve(false) → execute Denied{user_denied}. + let over = send(to, PER_TX_CAP + 1); + let id = needs_approval_id(&client, &over).await; + ack( + &client, + SignerRequest::Resolve { + request_id: id, + approved: false, + }, + ) + .await; + assert_eq!( + client.execute(id).await.unwrap(), + ExecuteResult::Denied { + reason: "user_denied".into() + } + ); + + // TTL expiry → status Expired, execute Denied{expired}. (Different value → different id.) + let over2 = send(to, PER_TX_CAP + 2); + let id2 = needs_approval_id(&client, &over2).await; + tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; + assert_eq!(status(&client, id2).await, ApprovalStatus::Expired); + assert_eq!( + client.execute(id2).await.unwrap(), + ExecuteResult::Denied { + reason: "expired".into() + } + ); +} + +#[tokio::test] +async fn stop_zeroizes_and_re_arms() { + let dir = TempDir::new("stop"); + let (wallet, to) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + // An Allowed within-cap request, then STOP. + let intent = send(to, 1_000); + assert_eq!(client.propose(&intent).await.unwrap(), Decision::Allow); + let id = SignerClient::request_id_for_intent(&intent); + ack(&client, SignerRequest::RevokeAll).await; + + // Address now reports locked (Deny-style, per the schema). + match client.request(&SignerRequest::Address).await.unwrap() { + SignerResponse::Decision(Decision::Deny { reason }) => assert_eq!(reason, "locked"), + other => panic!("expected locked Deny for Address, got {other:?}"), + } + // execute on the pre-STOP Allow → Denied{revoked}. + assert_eq!( + client.execute(id).await.unwrap(), + ExecuteResult::Denied { + reason: "revoked".into() + } + ); + + // A fresh unlock re-arms (and starts a clean session). + assert_eq!( + client.unlock(PASS).await.unwrap(), + UnlockOutcome::Unlocked { address: wallet } + ); + assert_eq!( + client.propose(&send(to, 1_000)).await.unwrap(), + Decision::Allow + ); +} + +#[tokio::test] +async fn toctou_resolve_then_revoke_then_execute_denied() { + let dir = TempDir::new("toctou"); + let (_wallet, to) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + let over = send(to, PER_TX_CAP + 1); + let id = needs_approval_id(&client, &over).await; + ack( + &client, + SignerRequest::Resolve { + request_id: id, + approved: true, + }, + ) + .await; + assert_eq!(status(&client, id).await, ApprovalStatus::Allowed); + // STOP after approval but before execute. + ack(&client, SignerRequest::RevokeAll).await; + assert_eq!( + client.execute(id).await.unwrap(), + ExecuteResult::Denied { + reason: "revoked".into() + } + ); +} + +#[tokio::test] +async fn socket_is_0600_in_0700_dir() { + let dir = TempDir::new("perms"); + let _ = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + + let sock_mode = std::fs::metadata(&d.socket_path) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(sock_mode, 0o600, "socket must be 0600"); + let parent = d.socket_path.parent().unwrap(); + let dir_mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777; + assert_eq!(dir_mode, 0o700, "socket dir must be 0700"); +} + +#[tokio::test] +async fn allowed_request_expires() { + // #2 regression: an APPROVED (Allowed) request goes stale after the TTL and can't execute. + let dir = TempDir::new("allowed-ttl"); + let (_wallet, to) = seal_account0(dir.path()); + let d = spawn_daemon( + dir.path(), + DUMMY_RPC, + CHAIN, + &[("DECKARD_APPROVAL_TTL_SECS", "1")], + ); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + let over = send(to, PER_TX_CAP + 1); + let id = needs_approval_id(&client, &over).await; + ack( + &client, + SignerRequest::Resolve { + request_id: id, + approved: true, + }, + ) + .await; + assert_eq!(status(&client, id).await, ApprovalStatus::Allowed); + + tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; + assert_eq!(status(&client, id).await, ApprovalStatus::Expired); + assert_eq!( + client.execute(id).await.unwrap(), + ExecuteResult::Denied { + reason: "expired".into() + } + ); +} + +#[tokio::test] +async fn re_propose_is_idempotent() { + // #3 regression: a re-propose never resets a live card, re-raises a Deny, or downgrades + // an approval. + let dir = TempDir::new("idempotent"); + let (_wallet, to) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + // (a) within-cap: re-propose returns the same Allow. + let within = send(to, 1_000); + assert_eq!(client.propose(&within).await.unwrap(), Decision::Allow); + assert_eq!(client.propose(&within).await.unwrap(), Decision::Allow); + + // (b) a user Deny is sticky — re-propose does NOT re-raise the card. + let denied = send(to, PER_TX_CAP + 1); + let id = needs_approval_id(&client, &denied).await; + ack( + &client, + SignerRequest::Resolve { + request_id: id, + approved: false, + }, + ) + .await; + assert_eq!( + client.propose(&denied).await.unwrap(), + Decision::Deny { + reason: "user_denied".into() + } + ); + + // (c) an approval is not downgraded back to Pending by a re-propose. + let approved = send(to, PER_TX_CAP + 2); + let id2 = needs_approval_id(&client, &approved).await; + ack( + &client, + SignerRequest::Resolve { + request_id: id2, + approved: true, + }, + ) + .await; + assert_eq!(client.propose(&approved).await.unwrap(), Decision::Allow); +} + +// --- small request helpers ----------------------------------------------------------------- + +async fn needs_approval_id(client: &SignerClient, intent: &Intent) -> deckard_contract::RequestId { + match client.propose(intent).await.unwrap() { + Decision::NeedsApproval { request_id } => request_id, + other => panic!("expected NeedsApproval, got {other:?}"), + } +} + +async fn ack(client: &SignerClient, req: SignerRequest) { + assert_eq!(client.request(&req).await.unwrap(), SignerResponse::Ack); +} + +async fn status(client: &SignerClient, id: deckard_contract::RequestId) -> ApprovalStatus { + match client + .request(&SignerRequest::Status { request_id: id }) + .await + .unwrap() + { + SignerResponse::Status(s) => s, + other => panic!("expected Status, got {other:?}"), + } +} diff --git a/crates/deckard-signerd/tests/parity.rs b/crates/deckard-signerd/tests/parity.rs new file mode 100644 index 0000000..f6bc875 --- /dev/null +++ b/crates/deckard-signerd/tests/parity.rs @@ -0,0 +1,132 @@ +//! #8 — the ONE decision function. Identical `(Intent, Policy)` vectors fed to `MockSigner` +//! and to the daemon's decision path must yield identical `Decision`s. Both route through +//! `deckard_contract::evaluate` (the daemon calls it directly after its process-level +//! pre-checks; the mock calls it in `propose`), so this pins that they never drift. +//! +//! The vectors all use an unlocked daemon, a matching chain id, and `kind = Send`, so the +//! daemon's pre-checks (`locked`/`chain_mismatch`/`unsupported_v1`) don't fire and both sides +//! reduce to `evaluate` — exactly the apples-to-apples parity contract. + +use alloy_primitives::{Address, Bytes, B256, U256}; +use deckard_contract::{ + evaluate, ApprovalMode, Decision, Intent, IntentKind, MockSigner, Policy, Signer, +}; + +/// Normalize away the (stateful, impl-specific) `NeedsApproval` request id so the comparison +/// is on the CLASSIFICATION, which is the parity contract. +fn norm(d: Decision) -> Decision { + match d { + Decision::NeedsApproval { .. } => Decision::NeedsApproval { + request_id: B256::ZERO, + }, + other => other, + } +} + +fn intent(kind: IntentKind, to: Address, value: u64, calldata: Bytes) -> Intent { + Intent { + chain_id: 31337, + to, + token: None, + value: U256::from(value), + calldata, + kind, + } +} + +#[allow(clippy::too_many_arguments)] +fn policy( + per_tx: u64, + daily: u64, + spent: u64, + mode: ApprovalMode, + allow: Vec
, + revoked: bool, +) -> Policy { + Policy { + per_tx_cap_wei: U256::from(per_tx), + daily_cap_wei: U256::from(daily), + spent_today_wei: U256::from(spent), + allow_to: allow, + auto_shield_min_wei: U256::from(10u64), + require_approval: mode, + revoked, + } +} + +#[test] +fn mock_and_daemon_decision_logic_agree() { + let a = Address::repeat_byte(0x22); + let b = Address::repeat_byte(0x33); + let send = |v| intent(IntentKind::Send, a, v, Bytes::new()); + + let vectors: Vec<(&str, Intent, Policy)> = vec![ + ( + "within per-tx cap → Allow", + send(20), + policy(50, 1000, 0, ApprovalMode::OverCap, vec![], false), + ), + ( + "over per-tx cap → NeedsApproval", + send(60), + policy(50, 1000, 0, ApprovalMode::OverCap, vec![], false), + ), + ( + "over daily cap → NeedsApproval", + send(20), + policy(u64::MAX, 100, 90, ApprovalMode::OverCap, vec![], false), + ), + ( + "exact per-tx cap boundary → Allow", + send(50), + policy(50, 1000, 0, ApprovalMode::OverCap, vec![], false), + ), + ( + "Never + over cap → Deny over_cap", + send(60), + policy(50, 1000, 0, ApprovalMode::Never, vec![], false), + ), + ( + "Never + within cap → Allow", + send(20), + policy(50, 1000, 0, ApprovalMode::Never, vec![], false), + ), + ( + "Always + within cap → NeedsApproval", + send(20), + policy(50, 1000, 0, ApprovalMode::Always, vec![], false), + ), + ( + "off allowlist → Deny", + send(20), + policy(50, 1000, 0, ApprovalMode::OverCap, vec![b], false), + ), + ( + "on allowlist → Allow", + send(20), + policy(50, 1000, 0, ApprovalMode::OverCap, vec![a], false), + ), + ( + "revoked → Deny revoked", + send(20), + policy(50, 1000, 0, ApprovalMode::OverCap, vec![], true), + ), + ( + "undecodable (Send w/ calldata) → Deny", + intent(IntentKind::Send, a, 20, Bytes::from_static(&[1, 2, 3])), + policy(50, 1000, 0, ApprovalMode::OverCap, vec![], false), + ), + ]; + + for (label, it, pol) in vectors { + // The daemon's decision path IS `evaluate` (after pre-checks that don't apply here). + let daemon_decision = norm(evaluate(&it, &pol)); + // MockSigner.propose routes through the same `evaluate` and mints a real id. + let mock = MockSigner::new(pol.clone()); + let mock_decision = norm(mock.propose(&it)); + assert_eq!( + daemon_decision, mock_decision, + "decision diverged for: {label}" + ); + } +} diff --git a/docs/build/30-mcp-shape.md b/docs/build/30-mcp-shape.md index ff46351..31ed19c 100644 --- a/docs/build/30-mcp-shape.md +++ b/docs/build/30-mcp-shape.md @@ -100,30 +100,43 @@ pub enum ApprovalMode { Never, OverCap, Always } ### Daemon socket API (the wire the harness implements) -UDS at `$XDG_RUNTIME_DIR/deckard/signerd.sock` (mode `0600`, owner-only), CBOR request/response, one request per frame: +> **Status: implemented in `crates/deckard-signerd` (issue #4).** This doc owns the wire; the daemon implements it. v1 scope = `Send` only (Shield → T-Privacy, Helios reads → `20`, `deckard-mcp` → `30`). + +UDS at `$XDG_RUNTIME_DIR/deckard/signerd.sock` (Linux) — macOS fallback `$TMPDIR/deckard-$UID/signerd.sock` — socket mode `0600` inside a `0700` dir, **length-delimited CBOR** (4-byte big-endian length prefix + body, max 1 MiB), one request per frame. Caller auth is `SO_PEERCRED`/`LOCAL_PEERCRED` **same-uid only**; single-instance via `flock` on a sibling `signerd.lock`. ```rust -// deckard-mcp (key-less) → deckard-signerd +// deckard-mcp / deckard-app (key-less) → deckard-signerd enum SignerRequest { - Propose { intent: Intent }, // -> Decision (policy check, NO signing yet) - Execute { request_id: RequestId }, // -> ExecuteResult (sign + broadcast; only if Allow/approved) - Status { request_id: RequestId }, // -> ApprovalStatus (poll for native-card result) - RevokeAll, // -> Ack (STOP: sets policy.revoked, drops in-flight approvals) - PolicyGet, // -> Policy (read-only snapshot for the agent) - // read-only, key-less helpers the daemon answers from Helios state: - Address, // -> Address - Balance { shielded: bool }, // -> BalanceReport + Unlock { passphrase: String }, // -> Unlock(UnlockOutcome) (decrypt + hold the key) + Lock, // -> Ack (zeroize the key → Locked; deny in-flight) + Resolve { request_id: RequestId, approved: bool }, // -> Ack (close an approval loop) + Propose { intent: Intent }, // -> Decision (policy check, NO signing yet) + Execute { request_id: RequestId }, // -> ExecuteResult (sign + broadcast; only if Allow/approved) + Status { request_id: RequestId }, // -> ApprovalStatus (poll for native-card result) + RevokeAll, // -> Ack (STOP: zeroize the key → Locked, deny in-flight) + PolicyGet, // -> Policy (read-only snapshot for the agent) + // read-only, key-less helpers: + Address, // -> Address (or Deny{"locked"} when Locked) + Balance { shielded: bool }, // -> BalanceReport (public only in v1; shielded_wei = 0) } enum ExecuteResult { Broadcast { tx_hash: B256 }, Denied { reason: String } } enum ApprovalStatus { Pending, Allowed, Denied { reason: String }, Expired } +enum UnlockOutcome { Unlocked { address: Address }, BadPassphrase, NoVault } ``` -Invariants frozen here, asserted by `00-test-harness.md`: -- `Propose` **never signs** and never broadcasts. It returns a `Decision`. A `Decision::Allow`/approved `RequestId` is the *only* token that lets `Execute` sign. -- `Execute` re-checks policy and `revoked` at sign time (TOCTOU guard): an approval granted before `RevokeAll` must still be denied at `Execute` if `revoked == true`. -- `RevokeAll` is idempotent and irreversible for the session (unlocks again only via the keystore unlock flow, `08-security-keystores.md`). -- The MCP process holds **no key, no decrypted seed, no signing capability** — verified by the red-team script in `00-test-harness.md` (`deckard-mcp` memory + fd scan finds no key; it has no UDS method that returns raw key bytes). +**Unlock / Lock / Resolve (the operator state machine).** The daemon is `Locked` (no key) ⇄ `Unlocked { vault }`: +- `Unlock{passphrase}` reads the keystore (`deckard-core`'s `vault.bin` in the config dir), decrypts, and holds the key → `Unlocked{address}`. The wire passphrase is a plain `String` (`Zeroizing` isn't `Serialize`); the daemon moves it into `Zeroizing` on receipt, scrubs the raw frame, and never echoes or logs it. Wrong passphrase / tampered vault → `BadPassphrase`; missing file → `NoVault`. +- `Lock` and `RevokeAll` both zeroize + drop the key → `Locked` and deny every in-flight approval (`Pending` **and** `Allowed`). Re-arm only via a fresh `Unlock` (which also starts a clean request session). +- `Resolve{request_id, approved}` closes the loop a `NeedsApproval` opened: it flips that `Pending` record to `Allowed`/`Denied`. Without it nothing turns `Pending` into executable; the native GPUI card (T-UX) is the human-facing caller. + +Invariants frozen here, asserted by `crates/deckard-signerd/tests/*` (cross-process red-team → `00-test-harness.md`): +- **One decision function.** `policy::evaluate(&Intent, &Policy) -> Decision` is the single source of the verdict; both `MockSigner` and the daemon call it (parity is unit-asserted), so the mock and the real daemon can never drift. The daemon adds only process-level pre-checks `evaluate` can't express (`Locked` → `Deny{"locked"}`, `chain_id` mismatch → `Deny{"chain_mismatch"}`, `kind != Send` → `Deny{"unsupported_v1"}`). +- `Propose` **never signs** and never broadcasts. It returns a `Decision`. A `Decision::Allow`/approved `RequestId` is the *only* token that lets `Execute` sign. (v1 `RequestId` = `keccak256` of a stable encoding of the intent, so a client that got `Allow` derives the id locally to execute it; a `NeedsApproval` id rides the wire. Production should switch to a salted, returned id.) +- `Execute` re-checks **policy** at sign time (TOCTOU guard): an approval granted before `Lock`/`RevokeAll` is still denied (`Denied{"revoked"}`); an *auto*-allow is re-run against the spend caps against the **current** `spent_today` (`Denied{"cap_exceeded"}`) so two within-cap proposals can't both broadcast past the daily cap — a human-approved overage carries its own consent and isn't re-capped. A broadcast id never signs twice (`Denied{"already_executed"}`); a stale (TTL-expired) request — `Pending` **or** `Allowed` — is `Denied{"expired"}`. A re-`Propose` of an identical intent is idempotent (it never resets a live card's TTL, downgrades an approval, or re-raises a `Denied`). It builds an **EIP-1559** tx via alloy fillers (pending nonce, fee/gas estimation, `chain_id` from the intent) and broadcasts via the config RPC (`DECKARD_RPC_URL`/`DECKARD_CHAIN_ID`) under a bounded timeout. +- **Policy (v1):** loaded from `policy.json` in the config dir, with a safe default if absent (per-tx 0.05 ETH, daily 0.2 ETH, empty allowlist = any, auto-shield-min 0.01 ETH, approval-over-cap). `spent_today_wei` is in-memory, UTC-midnight rollover, resets on restart (cross-restart persistence is a fast-follow). No `SetPolicy` yet. +- `RevokeAll` is idempotent and irreversible for the session (re-arm only via `Unlock`, `08-security-keystores.md`). +- The MCP/app process holds **no key, no decrypted seed, no signing capability** — verified by the red-team script in `00-test-harness.md` (memory + fd scan finds no key; there is no UDS method that returns raw key bytes). ### MCP tool surface (concrete list) diff --git a/justfile b/justfile index 91fc139..3db2a14 100644 --- a/justfile +++ b/justfile @@ -8,15 +8,20 @@ default: @just --list # Run the app (debug). This is the one you'll use 99% of the time. +# Build the signer daemon first so the app can spawn it as a sibling binary (the app resolves +# `deckard-signerd` next to its own binary, or via DECKARD_SIGNERD_BIN). run: + cargo build -p deckard-signerd cargo run # Run optimized. run-release: + cargo build -p deckard-signerd --release cargo run --release # Run as a menu-bar / tray app (no dock icon). run-tray: + cargo build -p deckard-signerd cargo run -p deckard-app --features tray # Format + lint the whole workspace (both feature configurations of the app). From a64d4c5d5123dfcc1ba5532b5aada7a02346458b Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 6 Jun 2026 14:16:29 +0200 Subject: [PATCH 04/10] =?UTF-8?q?docs(helios):=20eip1193-railgun=20spike?= =?UTF-8?q?=20=E2=80=94=20Helios=20localhost=20server=20as=20Railgun's=20E?= =?UTF-8?q?IP-1193=20provider=20(T-Trustless=20#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves the v1 seam end to end (against kohaku@618c53f): Helios's localhost JSON-RPC server (.rpc_address) is the EIP-1193 provider Railgun reads through. - IntoEip1193Provider is Kohaku's OWN 7-method trait (eip-1193-provider crate); an alloy DynProvider satisfies it via Kohaku's shipped Alloy adapter — no custom adapter for v1. Read/sync path = exactly eth_blockNumber + eth_getLogs + eth_call, all in Helios's served set. - One required fix: alloy's Provider::call defaults to the `pending` tag, which Helios (light client) can't serve; build the provider with ProviderBuilder::with_default_block(BlockId::latest()). One line, Deckard-side. - Tier-2 (default) drives the adapter + logs methods via a pass-through proxy; Tier-1 (--features railgun) links the full railgun ZK crate and drives the real RpcSyncer/RailgunBuilder through Helios (414 SyncEvents). railgun compiles standalone (retires 10's R1c). Loopback hop ~0.3 ms/call (release). Findings written into 20-helios-sidecar.md (Integration + Measured + open-questions). --- docs/build/20-helios-sidecar.md | 12 +- spikes/eip1193-railgun/.gitignore | 2 + spikes/eip1193-railgun/Cargo.toml | 64 +++++ spikes/eip1193-railgun/README.md | 97 +++++++ spikes/eip1193-railgun/src/helios.rs | 114 ++++++++ spikes/eip1193-railgun/src/main.rs | 275 ++++++++++++++++++++ spikes/eip1193-railgun/src/proxy.rs | 197 ++++++++++++++ spikes/eip1193-railgun/src/railgun_tier1.rs | 79 ++++++ 8 files changed, 836 insertions(+), 4 deletions(-) create mode 100644 spikes/eip1193-railgun/.gitignore create mode 100644 spikes/eip1193-railgun/Cargo.toml create mode 100644 spikes/eip1193-railgun/README.md create mode 100644 spikes/eip1193-railgun/src/helios.rs create mode 100644 spikes/eip1193-railgun/src/main.rs create mode 100644 spikes/eip1193-railgun/src/proxy.rs create mode 100644 spikes/eip1193-railgun/src/railgun_tier1.rs diff --git a/docs/build/20-helios-sidecar.md b/docs/build/20-helios-sidecar.md index e3165ed..9965e70 100644 --- a/docs/build/20-helios-sidecar.md +++ b/docs/build/20-helios-sidecar.md @@ -146,8 +146,8 @@ Hard rule (unchanged): **never silently fall back to a raw untrusted RPC.** Veri **Three consumers, two read paths:** 1. **Daemon socket reads** (`wallet_balance`, `simulate`) — typed `HeliosApi` calls through the supervisor, so they get EL-cut failover **and** a `ReadStatus`. The GPUI UI badge and the MCP agent both consume these → one source of truth, identical numbers. 2. **Railgun's chain reads** (UTXO/TXID sync, balance, state) — Railgun wants `RailgunBuilder::new(chain, impl IntoEip1193Provider)`, but `EthereumClient` exposes the typed `HeliosApi`, **not** an EIP-1193 `request(method, params)` JSON interface. Decision: - - **v1 (demo) — Helios's built-in localhost JSON-RPC server.** Build the primary client with `.rpc_address(127.0.0.1:)` (verified: `EthereumClientBuilder::rpc_address(SocketAddr)`; `HeliosClient::new` then spawns `jsonrpc::start`, which serves the `eth_*` subset Helios implements — the methods Railgun needs for live/state reads, all proof-checked; it is **not** a full JSON-RPC surface, so ⚠ confirm Railgun only calls served methods) and hand Railgun an **alloy HTTP provider** pointed at it. Least code, reuses Helios's own correct mapping. Accepted tradeoffs: (i) a loopback hop + a port (bind `127.0.0.1`, same-uid only); (ii) the server is per-`EthereumClient`, so Railgun's reads hit the primary only and do **not** get the supervisor's EL-cut failover — fine, because the shield completes *before* the on-camera cut and Railgun's reads are never the thing being cut. ⚠ verify Kohaku's `IntoEip1193Provider` accepts an alloy HTTP provider (10's open seam). Historical UTXO ranges go to Subsquid, not Helios (10). - - **production — a thin Rust adapter.** `struct HeliosEip1193(Arc)` implementing the provider trait by mapping `eth_*` → `HeliosApi` calls. Removes the loopback hop and puts Railgun's reads behind the same failover + `ReadStatus`. Build post-demo; keep it the single place a Helios↔Railgun API change touches. + - **v1 (demo) — Helios's built-in localhost JSON-RPC server.** Build the primary client with `.rpc_address(127.0.0.1:)` (verified: `EthereumClientBuilder::rpc_address(SocketAddr)`; `HeliosClient::new` then spawns `jsonrpc::start`, which serves the `eth_*` subset Helios implements — the methods Railgun needs for live/state reads, all proof-checked; it is **not** a full JSON-RPC surface, so ⚠ confirm Railgun only calls served methods) and hand Railgun an **alloy HTTP provider** pointed at it. Least code, reuses Helios's own correct mapping. Accepted tradeoffs: (i) a loopback hop + a port (bind `127.0.0.1`, same-uid only); (ii) the server is per-`EthereumClient`, so Railgun's reads hit the primary only and do **not** get the supervisor's EL-cut failover — fine, because the shield completes *before* the on-camera cut and Railgun's reads are never the thing being cut. ✅ **PROVEN end-to-end** (`spikes/eip1193-railgun/`, 2026-06-06, against `kohaku@618c53f`): `IntoEip1193Provider` is Kohaku's *own* narrow 7-method trait (its `eip-1193-provider` crate) — **not** alloy's and **not** a generic `request(method,params)`; an alloy `DynProvider` (`ProviderBuilder::new().connect(url).erased()`) satisfies it via Kohaku's **shipped** `Alloy` adapter (`impl IntoEip1193Provider for DynProvider`) — **no custom adapter for v1** (confirmed by upstream's own `sync_utxo.rs`). The read/sync/balance path calls exactly **3** of those methods, all in Helios's served set: `eth_blockNumber` (`RpcSyncer.latest_block`) + `eth_getLogs` (`RpcSyncer.events`, tail range only) + `eth_call` (`SmartWalletUtxoVerifier.verify_root`); `balance()`/`register()` are local. **One required fix:** alloy's `Provider::call` defaults to the `pending` block tag (`alloy-provider 1.8.3` `trait.rs:198`), which Helios (a light client) can't serve (`block not found: pending`) — build the provider with `ProviderBuilder::new().with_default_block(BlockId::latest())` (installs alloy's `BlockIdLayer`) so the **unmodified** adapter's `eth_call` targets `latest`. One line, Deckard-side, no Kohaku/Helios patch. Historical UTXO ranges go to Subsquid, not Helios (10). + - **production — a thin Rust adapter.** `struct HeliosEip1193(Arc)` implementing Kohaku's `Eip1193Provider` trait (7 methods) by mapping each → a typed `HeliosApi` call (`get_block_number`, `get_logs`, **`call` at `Latest`** — same pin-to-latest discipline, since it bypasses the alloy `pending` default entirely). Removes the loopback hop and puts Railgun's reads behind the same failover + `ReadStatus`. Build post-demo; keep it the single place a Helios↔Railgun API change touches. **`ReadStatus` on the wire — cross-doc proposal to `30` (it owns the contract).** For "every read carries Verified|Degraded|Unsynced" to be enforceable, `ReadStatus` must live in `deckard-contract` (the shared type home `30` owns) and ride on the read responses. Proposed delta: - define `enum ReadStatus { Verified, Degraded{reason}, Unsynced{reason} }` in `deckard-contract` — **20 owns the semantics/transitions** (table above); the **type lives with the contract** so it can serialize on the wire. @@ -240,6 +240,8 @@ So spend the privacy budget on the **EL**; the CL needs IP hygiene only, not add Implication for the demo: the beat is **"warm-start instant"** (pre-sync to ~2 s) and the cut keeps the balance verified through one block. Cold start (~11 s) is a "syncing…" state if ever shown un-pre-synced. +**Measured — `eip1193-railgun` spike (M-series, mainnet, `--release`, 2026-06-06).** Helios warm sync ≈ 2.1 s / cold ≈ 10.5 s (consistent with above). All read-path methods (`eth_chainId`/`eth_blockNumber`/`eth_getLogs`/`eth_call`) resolve through Helios's localhost server; a 2000-block `eth_getLogs` window on the live RAILGUN wallet returned ~100–326 verified events, and Kohaku's real `RpcSyncer` parsed 414 `SyncEvents` through it. **Loopback-hop overhead: direct typed `HeliosApi` head read ≈ 0.75 ms/call vs alloy→Helios-localhost ≈ 1.0 ms/call → Δ ≈ 0.27–0.31 ms/call** (HTTP-serialize + two loopback syscalls + jsonrpsee dispatch). That hop is **cheap enough to ship the v1 localhost path for the demo and defer the production `HeliosEip1193` adapter** (which removes this hop and adds failover + `ReadStatus`). + ## Local end-to-end testing (Kurtosis) — DEFERRED (not v1-critical) > **Decision (CEO review):** Kurtosis is **deferred off the v1 critical path.** The mainnet spike already proves the whole R2 beat (sync, verified balance, cut-the-EL failover, refuse-a-lie) with **zero** Kurtosis, so a local devnet is not required to ship the demo. Its only added value is a *fully offline, deterministic CI lane where you own the CL* (no public-beacon flakiness in tests) — a post-demo hardening nice-to-have, not a gate. v1 testing runs on mainnet + Sepolia public endpoints. **TODO (post-demo): build the hermetic Kurtosis CI lane** (needs the hand-written Helios devnet `Config` below). The findings below are kept so that build is cheap when we pick it up. Note: Kurtosis is *not* a wallet feature and is *not* mainnet — it's a private throwaway devnet (a few GB, laptop-fine) used only for testing; it can't be shipped to users and can't replace Helios (it's the thing Helios verifies *against* in a test). @@ -273,6 +275,8 @@ Run: See the README for the CL-choice and key-restricted-EL caveats. +**Sibling spike — `spikes/eip1193-railgun/` (the Railgun EIP-1193 seam, T-Trustless #3).** Boots the *same* Helios localhost server (`.rpc_address`) and settles the "v1 localhost vs forced-adapter" question in "Integration" above. **Tier-2** (default, light): an alloy `DynProvider` through Kohaku's *own* `IntoEip1193Provider` adapter drives `eth_chainId`/`eth_blockNumber`/`eth_getLogs`/`eth_call` through Helios, logged by a method-recording pass-through proxy. **Tier-1** (`--features railgun`, heavy): links the *full* `railgun` ZK crate (ark-circom/wasmer/groth16) — `RailgunBuilder::new(ChainConfig::mainnet(), ).build()` OK, then Kohaku's real `RpcSyncer` drives `eth_getLogs` through Helios → 414 parsed `SyncEvents` from the live mainnet RAILGUN wallet. Verdict: **v1 WORKS** with the one-line `with_default_block(latest)` fix; the `railgun` crate compiles standalone from the spike's dep edge (mirrors 3 `[patch]`es). Numbers in "Measured" below. + **Acceptance test (the R2 slice; the spike implements steps 1–3):** ``` Scenario "Helios verified reads" (mainnet hero): @@ -304,11 +308,11 @@ Steps 2–4 are the on-camera beats (verified read, refuse-a-lie, cut-the-RPC); - ~~Cold vs warm sync time; real failover latency~~ → **measured** (≈11 s / ≈2 s; failover ≤1 block). Re-measure on the actual demo machine. - ~~Published crates.io release?~~ → **no**; `helios-ethereum` git-only at `0.11.1` (crates.io stale at 0.1.0). -- ~~alloy alignment~~ → **resolved**; unifies to one `alloy-primitives 1.6.0`. +- ~~alloy alignment~~ → **resolved**; unifies to one `alloy-primitives 1.6.0` (umbrella `alloy 1.8.3`). The `eip1193-railgun` spike confirms **Kohaku's `railgun` independently resolves to the identical `alloy 1.8.3`/`alloy-primitives 1.6.0`**, so Helios + Railgun link in one process with no version conflict (mirror 3 `[patch]`es: `ethereum_hashing` + the `ruint` & `ark-circom` forks). - ~~CL approach + redundant second~~ → **decided + proven:** public CLs, **Nimbus primary + dRPC second** (both verified to drive a Helios sync, ~11 s / ~10.4 s). Lodestar + PublicNode beacon fail (200 but no sync). Self-host = flaky-rehearsal fallback only. Remaining minor: resolve the **Teku** default-flag contradiction or just avoid Teku. - ~~Does Deckard auto-rebuild on a dead CL?~~ → **specced** as a supervisor build task (frozen-head detector → rebuild against CL #2, ~2 s warm) in "Integration into the app." Not yet built. - ~~Failover (Shape A) in the daemon read path vs MCP `Decision` resolver?~~ → **decided:** one key-less `Upstreams` in `deckard-signerd` (see "Integration into the app"). Matches `30`'s "daemon so the numbers match" lean. -- **EIP-1193 adapter for Railgun:** v1 = Helios localhost JSON-RPC server + alloy HTTP provider; production = `HeliosEip1193` Rust adapter over the supervisor. ⚠ verify Kohaku's `IntoEip1193Provider` accepts the alloy HTTP provider (10's seam). +- ~~EIP-1193 adapter for Railgun~~ → **RESOLVED + PROVEN** (`spikes/eip1193-railgun/`): v1 = Helios localhost server + alloy `DynProvider` through Kohaku's *own* `IntoEip1193Provider` (no custom adapter), with the one-line `with_default_block(latest)` fix (alloy's `call` defaults to `pending`; Helios has none). Both the adapter-only path and the **full `railgun` crate** (linked under `--features railgun`; `RailgunBuilder::new(ChainConfig::mainnet(), ).build()` + Kohaku's real `RpcSyncer` drove `eth_getLogs` through Helios → 414 SyncEvents) verified on mainnet. The `railgun` crate **compiles standalone** from our dep edge (retires 10's R1c). Production `HeliosEip1193` adapter still wanted (drops the loopback hop + adds failover/ReadStatus) and must likewise pin `eth_call`→`latest`. Loopback-hop overhead: see "Measured." - **`read_status` on `30`'s read responses:** proposed (define `ReadStatus` in `deckard-contract`, add the field to `wallet_balance`/`simulate`). Needs `30`'s sign-off — it owns the contract. ## Sources (repos + docs) diff --git a/spikes/eip1193-railgun/.gitignore b/spikes/eip1193-railgun/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/spikes/eip1193-railgun/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/spikes/eip1193-railgun/Cargo.toml b/spikes/eip1193-railgun/Cargo.toml new file mode 100644 index 0000000..f012e9d --- /dev/null +++ b/spikes/eip1193-railgun/Cargo.toml @@ -0,0 +1,64 @@ +# Deckard T-Trustless #3 spike — prove Helios's localhost JSON-RPC server can be +# the EIP-1193 provider Kohaku's `railgun` crate reads through, so the shield path +# gets *verified* chain reads instead of trusting a raw vendor RPC. +# +# Standalone crate (empty [workspace] table) so it can pull BOTH the heavy Helios +# tree (revm, bls12_381) AND — under the `railgun` feature — Kohaku's ZK railgun +# tree (ark-circom, wasmer) without bloating the app workspace at the repo root. +[package] +name = "eip1193-railgun" +version = "0.1.0" +edition = "2021" +publish = false + +# Standalone — keep this spike's deps out of the repo-root workspace. +[workspace] + +[[bin]] +name = "eip1193-railgun" +path = "src/main.rs" + +[features] +default = [] +# Tier-1: ALSO link Kohaku's full `railgun` crate and drive the real +# RailgunBuilder + RpcSyncer + SmartWalletUtxoVerifier through Helios. Off by +# default so the crate always builds in the light Tier-2 mode (Kohaku's real +# eip-1193-provider trait + alloy adapter, no ark-circom/wasmer compile). +railgun = ["dep:railgun"] + +[dependencies] +# Helios — depend on `helios-ethereum` directly (NOT the umbrella `helios` crate, +# which pulls helios-opstack → libp2p → a yanked core2). Git-only, tag "0.11.1" +# (no `v`; crates.io is stale at 0.1.0). This is the crate that ships the +# localhost JSON-RPC server (`EthereumClientBuilder::rpc_address`). +helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1" } + +# Kohaku's REAL EIP-1193 provider crate — the `Eip1193Provider`/`IntoEip1193Provider` +# trait + the `Alloy`/`DynProvider` adapter Railgun reads through. Pinned to the +# exact commit `railgun` is pinned to. default-features=false drops the `js` +# (wasm-bindgen) feature; keep only `alloy` (the native adapter). +eip-1193-provider = { git = "https://github.com/ethereum/kohaku", package = "eip-1193-provider", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7", default-features = false, features = ["alloy"] } + +# Tier-1 (optional): the full Railgun client. Same git rev so the [patch]es resolve. +railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7", optional = true } + +# Match Kohaku + Helios's alloy: both already resolve to alloy 1.8.3 / +# alloy-primitives 1.6.0 independently, so the whole tree unifies to ONE alloy. +alloy = { version = "1.8", features = ["eips", "rpc-types", "network", "providers", "provider-http", "sol-types"] } + +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync"] } +reqwest = { version = "0.12", features = ["json"] } +eyre = "0.6" +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# [patch] does NOT inherit through a git dependency, so we mirror every patch the +# upstream workspaces apply, or their consensus/ZK crates fail to build: +# * ethereum_hashing → Helios's workspace patch (consensus crates). +# * ruint, ark-circom → Kohaku's workspace patches (only used under `railgun`; +# harmless "unused patch" warning in the default Tier-2 build). +[patch.crates-io] +ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" } +ruint = { git = "https://github.com/Robert-MacWha/ruint" } +ark-circom = { git = "https://github.com/Robert-MacWha/circom-compat", branch = "release/0.6.0" } diff --git a/spikes/eip1193-railgun/README.md b/spikes/eip1193-railgun/README.md new file mode 100644 index 0000000..6d7e0f2 --- /dev/null +++ b/spikes/eip1193-railgun/README.md @@ -0,0 +1,97 @@ +# eip1193-railgun — Deckard T-Trustless #3 spike + +**Question:** can an embedded **Helios** light client be the EIP-1193 provider +Kohaku's `railgun` crate reads through — so Deckard's shield path gets **verified** +chain reads instead of trusting a raw vendor RPC? + +**Answer: YES — the v1 localhost path works, with one one-line provider fix.** + +This spike boots a verified Helios mainnet light client with its **localhost +JSON-RPC server**, points an alloy provider at it, wraps that provider through +**Kohaku's real `IntoEip1193Provider` adapter**, and drives every method Railgun's +read/sync path calls THROUGH Helios — then (Tier-1) links the full `railgun` crate +and drives its real `RpcSyncer`/`RailgunBuilder` against the live mainnet RAILGUN +smart wallet through Helios. + +## The crux, settled against source (`ethereum/kohaku @ 618c53f`) + +- `RailgunBuilder::new(chain: ChainConfig, provider: impl IntoEip1193Provider)`. + **`IntoEip1193Provider` is Kohaku's OWN trait** (in its `eip-1193-provider` + crate), **not** alloy's. `Eip1193Provider` is a narrow **7-method typed trait** + (`get_chain_id`, `get_block_number`, `logs`, `eth_call`, `estimate_gas`, + `gas_price`, `transaction_count`) — **not** a generic `request(method, params)`. +- Kohaku **ships an alloy adapter**: `impl IntoEip1193Provider for DynProvider` + wraps any alloy provider as `Arc`. So + `ProviderBuilder::new().connect(url).erased()` (a `DynProvider`) satisfies the + trait **with no custom adapter** — confirmed by upstream's own `sync_utxo.rs` test. +- The **read/sync/balance** path touches the provider in exactly 3 places: + `RpcSyncer.latest_block` → **eth_blockNumber**, `RpcSyncer.events` → + **eth_getLogs** (tail range only; Subsquid carries history), and + `SmartWalletUtxoVerifier.verify_root` → **eth_call**. `balance()`/`register()` + are local (no RPC). All 7 trait methods — and all 3 read-path methods — are in + Helios 0.11.1's served set (`core/src/jsonrpc/mod.rs`). + +## The one required fix (the spike's real finding) + +alloy's `Provider::call` **defaults to the `pending` block tag** +(`alloy-provider 1.8.3` `trait.rs:198`). Kohaku's `Alloy::eth_call` adapter calls +`inner.call(req)` with no block override, so it sends `eth_call(…, "pending")`. +Helios is a light client with **no pending block** → `"block not found: pending"`. +This rides the read/sync path (`verify_root` → eth_call), so v1 must pin eth_call +to `latest`: + +```rust +// Deckard-side, one line — makes Kohaku's UNMODIFIED adapter work against Helios: +ProviderBuilder::new() + .with_default_block(BlockId::latest()) // installs alloy's BlockIdLayer + .connect(helios_localhost_url).await? + .erased() // → DynProvider : IntoEip1193Provider +``` + +No Kohaku patch, no Helios patch. (The production `HeliosEip1193` adapter the +sidecar doc plans would set `latest` itself; this is the v1 shortcut.) + +## Run + +```bash +# Tier-2 (default, light): Helios localhost server + Kohaku's real eip-1193-provider +# adapter; drives eth_chainId/blockNumber/getLogs/eth_call through Helios + logs +# every JSON-RPC method via a pass-through proxy + measures the loopback hop. +cargo run # warm if a cached checkpoint exists, else cold +WIPE=1 cargo run # force a COLD start + +# Tier-1 (heavy): ALSO link Kohaku's full `railgun` ZK crate and drive the real +# RailgunBuilder::build() + RpcSyncer (eth_blockNumber + eth_getLogs) through Helios. +cargo run --features railgun +``` + +Exit 0 = PASS (every method the read path called is in Helios's served set). + +| env | meaning | default | +|---|---|---| +| `EL` | untrusted execution RPC (must serve `eth_getProof`) | `https://ethereum-rpc.publicnode.com` | +| `CL` | beacon **light-client** API (200 ≠ syncs — see `20-helios-sidecar.md`) | `http://testing.mainnet.beacon-api.nimbus.team` | +| `CHECKPOINT` | pinned weak-subjectivity root (`0x..` B256) | community fallback if unset | +| `DATA_DIR` | FileDB dir (warm-start checkpoint cache) | `$TMPDIR/deckard-eip1193-railgun-spike` | +| `WIPE` | force a COLD start | unset | +| `WINDOW` | `eth_getLogs` window (#blocks back from head) | `2000` | + +Measured (M-series, mainnet, dev build): cold sync ≈12s, warm ≈3.5s; 323 RAILGUN +events in a 2000-block window verified through Helios; loopback hop adds ≈1ms/call +over the in-process typed `HeliosApi` call. + +## Why mainnet (not Sepolia) + +Helios is proven to sync only on **mainnet** public CLs (Nimbus/dRPC); `Network::Sepolia` +has `consensus_rpc = None` (you must supply a Sepolia beacon LC endpoint). The RAILGUN +smart wallet is live on mainnet (`ChainConfig::mainnet()` → `0xFA7093…`), so the seam +is proven there. Upstream's shield **integration** tests fork **Sepolia** — that R1 +shield→unshield test (the `10-kohaku-shield.md` job) is separate and needs a Sepolia LC +endpoint; this spike de-risks only the read/provider seam. + +## Files + +- `src/helios.rs` — build Helios with `.rpc_address()` localhost server; sync→servable. +- `src/proxy.rs` — method-logging HTTP pass-through (the Task-4 enumeration tap). +- `src/main.rs` — Tier-2: wire the adapter, drive the 3 reads, measure the hop. +- `src/railgun_tier1.rs` — Tier-1 (`--features railgun`): real `RailgunBuilder` + `RpcSyncer`. diff --git a/spikes/eip1193-railgun/src/helios.rs b/spikes/eip1193-railgun/src/helios.rs new file mode 100644 index 0000000..b19384b --- /dev/null +++ b/spikes/eip1193-railgun/src/helios.rs @@ -0,0 +1,114 @@ +//! Stand up a **verified** Helios light client whose localhost JSON-RPC server +//! is the endpoint Railgun (or a generic alloy provider) reads through. +//! +//! Verified against `a16z/helios @ 0.11.1` source: +//! * `EthereumClientBuilder::rpc_address(SocketAddr)` records a bind addr; +//! * on `.build()`, `HeliosClient::new` does `tokio::spawn(jsonrpc::start(inner, +//! addr))` (held alive by a `pending()`), serving the `eth_*` subset in +//! `core/src/jsonrpc/mod.rs` at `http://` — every read proof-checked. +//! * `.build()` is sync but MUST run inside a tokio runtime (it spawns the +//! server task). We are, via `#[tokio::main]`. +//! +//! `wait_synced()` ≠ ready: after it returns, the first execution head lands +//! ~1 slot later (≤12s); until then every `Latest` read fails the 60s +//! `check_head_age` gate. So we poll `get_block_number()` until `Ok`. + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use alloy::primitives::{B256, U256}; +use eyre::{eyre, Result}; +use helios_ethereum::config::networks::Network; +use helios_ethereum::database::FileDB; +use helios_ethereum::{EthereumClient, EthereumClientBuilder}; +use tracing::info; + +/// Build a verified Helios **mainnet** client whose localhost JSON-RPC server is +/// bound at `rpc_addr`. FileDB → warm starts from a cached checkpoint. +pub fn build_with_server( + cl: &str, + el: &str, + checkpoint: Option, + data_dir: PathBuf, + rpc_addr: SocketAddr, +) -> Result { + let b = EthereumClientBuilder::::new() + .network(Network::Mainnet) + .consensus_rpc(cl)? + .execution_rpc(el)? + .data_dir(data_dir) + // strict: refuse a too-old checkpoint (hard failure, never a silent stale read). + .strict_checkpoint_age() + // THE mechanism the whole v1 path rests on: spawn the localhost JSON-RPC + // server on build() so an alloy HTTP provider can read through it. + .rpc_address(rpc_addr); + let b = match checkpoint { + Some(cp) => b.checkpoint(cp), + // No user-pinned checkpoint → community fallback (ethPandaOps). Honest + // spike default; in Deckard this read path is labeled Degraded. + None => b.load_external_fallback(), + }; + b.with_file_db().build() +} + +/// Block until the client serves a fresh verified head (the honest "ready to +/// serve verified reads" moment — see module docs on why `wait_synced` isn't it). +pub async fn wait_until_serving( + client: &EthereumClient, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + match client.get_block_number().await { + Ok(h) => return Ok(h), + Err(e) => { + if Instant::now() > deadline { + return Err(eyre!("no fresh head within {timeout:?}: {e}")); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + } +} + +/// Poll the localhost JSON-RPC **server** (not the typed client) over HTTP until +/// it answers `eth_chainId` — proving the spawned `jsonrpc::start` task has bound +/// and is serving verified reads at `url`. +pub async fn wait_server_live(url: &str, timeout: Duration) -> Result { + let http = reqwest::Client::new(); + let deadline = Instant::now() + timeout; + let req = serde_json::json!({"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}); + loop { + let got = async { + let resp = http.post(url).json(&req).send().await.ok()?; + let v: serde_json::Value = resp.json().await.ok()?; + let hex = v.get("result")?.as_str()?; + u64::from_str_radix(hex.trim_start_matches("0x"), 16).ok() + } + .await; + if let Some(chain_id) = got { + return Ok(chain_id); + } + if Instant::now() > deadline { + return Err(eyre!("Helios localhost server at {url} never answered eth_chainId")); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +/// Grab a free loopback port by binding an ephemeral socket and dropping it. +/// (Helios discards the jsonrpsee `ServerHandle`, so we can't recover a `:0` +/// port after the fact — pick a concrete one up front. Small TOCTOU window, +/// fine for a spike.) +pub fn free_loopback_port() -> Result { + let l = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = l.local_addr()?.port(); + drop(l); + Ok(port) +} + +#[allow(dead_code)] +pub fn log_ready(head: U256, url: &str) { + info!(head = %head, server = %url, "Helios verified + localhost server live"); +} diff --git a/spikes/eip1193-railgun/src/main.rs b/spikes/eip1193-railgun/src/main.rs new file mode 100644 index 0000000..9623aff --- /dev/null +++ b/spikes/eip1193-railgun/src/main.rs @@ -0,0 +1,275 @@ +//! Deckard T-Trustless #3 spike — **Helios as Railgun's EIP-1193 provider.** +//! +//! Proves the v1 seam end to end, headless: +//! 1. Boot a verified Helios mainnet light client whose **localhost JSON-RPC +//! server** (`EthereumClientBuilder::rpc_address`) serves proof-checked reads. +//! 2. Point an alloy HTTP provider at it, `.erased()` → `DynProvider`, and wrap +//! it through Kohaku's **real** `IntoEip1193Provider` adapter +//! (`eip-1193-provider` crate) — the exact trait `RailgunBuilder::new` takes. +//! 3. Drive every method Railgun's read/sync path calls THROUGH Helios: +//! get_block_number → eth_blockNumber (RpcSyncer.latest_block) +//! logs → eth_getLogs (RpcSyncer.events, tail range) +//! eth_call → eth_call (SmartWalletUtxoVerifier.verify_root) +//! + get_chain_id (eth_chainId) for completeness. +//! 4. A logging proxy in front of Helios records the actual JSON-RPC `method`s, +//! cross-checked against Helios's served set. +//! 5. Measure the loopback-hop overhead vs a direct typed `HeliosApi` call. +//! +//! With `--features railgun` it ALSO links Kohaku's full `railgun` crate and +//! drives the real `RailgunBuilder` + `RpcSyncer` + `SmartWalletUtxoVerifier` +//! against the live mainnet RAILGUN smart wallet through Helios (Tier-1). +//! +//! Read-only: no signing, no broadcasting, no funds. + +mod helios; +mod proxy; +#[cfg(feature = "railgun")] +mod railgun_tier1; + +use std::path::PathBuf; +use std::str::FromStr; +use std::time::{Duration, Instant}; + +use alloy::eips::BlockId; +use alloy::primitives::{Address, B256, U256}; +use alloy::providers::{DynProvider, Provider, ProviderBuilder}; +use eip_1193_provider::provider::{Eip1193Caller, Eip1193Provider, IntoEip1193Provider}; +use eyre::{eyre, Result}; +use std::sync::Arc; +use tracing::info; +use tracing_subscriber::filter::{EnvFilter, LevelFilter}; + +/// RAILGUN smart wallet on Ethereum mainnet (from Kohaku `ChainConfig::mainnet()`). +const RAILGUN_SMART_WALLET_MAINNET: &str = "0xFA7093CDD9EE6932B4eb2c9e1cde7CE00B1FA4b9"; + +// The one read Railgun's verifier makes through the provider: the `rootHistory` +// getter on the smart wallet (`SmartWalletUtxoVerifier::verify_root` → sol_call → +// eth_call). Same signature as Kohaku's ABI. +alloy::sol! { + function rootHistory(uint256 treeNumber, bytes32 root) external view returns (bool); +} + +struct Cfg { + el: String, + cl: String, + checkpoint: Option, + data_dir: PathBuf, + wipe: bool, + /// eth_getLogs window (#blocks back from head) for the demo read. + window: u64, + railgun_wallet: Address, +} + +fn cfg() -> Result { + let env = |k: &str| std::env::var(k).ok().filter(|s| !s.is_empty()); + Ok(Cfg { + el: env("EL").unwrap_or_else(|| "https://ethereum-rpc.publicnode.com".into()), + cl: env("CL").unwrap_or_else(|| "http://testing.mainnet.beacon-api.nimbus.team".into()), + checkpoint: match env("CHECKPOINT") { + Some(s) => Some( + B256::from_str(s.trim_start_matches("0x")) + .or_else(|_| B256::from_str(&s)) + .map_err(|e| eyre!("bad CHECKPOINT: {e}"))?, + ), + None => None, + }, + data_dir: env("DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("deckard-eip1193-railgun-spike")), + wipe: env("WIPE").is_some(), + window: env("WINDOW").and_then(|s| s.parse().ok()).unwrap_or(2000), + railgun_wallet: Address::from_str(RAILGUN_SMART_WALLET_MAINNET)?, + }) +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(), + ) + .init(); + + let cfg = cfg()?; + if cfg.wipe { + let _ = std::fs::remove_dir_all(&cfg.data_dir); + info!("WIPE=1 → cleared {}", cfg.data_dir.display()); + } + + // ── Step 1: boot a verified Helios mainnet client + its localhost server ── + let helios_port = helios::free_loopback_port()?; + let helios_addr = format!("127.0.0.1:{helios_port}"); + let helios_url = format!("http://{helios_addr}"); + info!(server = %helios_url, el = %redact(&cfg.el), cl = %cfg.cl, "building Helios with localhost JSON-RPC server"); + + let t = Instant::now(); + let client = helios::build_with_server( + &cfg.cl, + &cfg.el, + cfg.checkpoint, + cfg.data_dir.clone(), + helios_addr.parse()?, + )?; + client.wait_synced().await?; + let head = helios::wait_until_serving(&client, Duration::from_secs(60)).await?; + let sync_secs = t.elapsed().as_secs_f64(); + let chain_id = helios::wait_server_live(&helios_url, Duration::from_secs(30)).await?; + info!(sync_secs = format!("{sync_secs:.1}"), head = %head, chain_id, "Helios verified + localhost server live"); + + // ── Step 2: logging proxy in front of Helios's server (Task 4 tap) ─────── + let proxy_port = helios::free_loopback_port()?; + let proxy_addr = format!("127.0.0.1:{proxy_port}"); + let proxy_url = format!("http://{proxy_addr}"); + let method_log = proxy::spawn(&proxy_addr, helios_url.clone()).await?; + info!(proxy = %proxy_url, "→ Helios; logging every JSON-RPC method"); + + // ── Step 3: alloy provider → Kohaku's REAL IntoEip1193Provider adapter ─── + // This is the exact v1 wiring `RailgunBuilder::new(chain, provider)` accepts: + // `ProviderBuilder::new().connect(url).erased()` is a `DynProvider`, and + // `impl IntoEip1193Provider for DynProvider` ships in the eip-1193-provider crate. + // + // ⚠ THE ONE REQUIRED v1 FIX (spike finding): alloy's `Provider::call` defaults + // to the `pending` block tag (alloy-provider 1.8.3 trait.rs:198), and Kohaku's + // `Alloy::eth_call` adapter calls `inner.call(req)` with no block override. Helios + // is a light client with NO pending block → `eth_call(pending)` fails with + // "block not found: pending". Since the read/sync path's `verify_root` → eth_call + // rides this, v1 needs eth_call pinned to `latest`. `.with_default_block(latest)` + // installs alloy's `BlockIdLayer`, which rewrites the default block for + // eth_call/estimateGas/etc to `latest` — making Kohaku's UNMODIFIED adapter work + // against Helios. One line, Deckard-side, no Kohaku/Helios patch. + let provider_proxied: DynProvider = ProviderBuilder::new() + .with_default_block(BlockId::latest()) + .connect(&proxy_url) + .await? + .erased(); + let eip: Arc = provider_proxied.into_eip1193(); + info!("wrapped alloy DynProvider via Kohaku IntoEip1193Provider::into_eip1193()"); + + // ── Step 4: drive the 3 read-path methods THROUGH Helios via the adapter ─ + // (a) eth_chainId + let got_chain = eip.get_chain_id().await.map_err(|e| eyre!("get_chain_id: {e}"))?; + // (b) eth_blockNumber — Railgun's RpcSyncer.latest_block + let got_head = eip.get_block_number().await.map_err(|e| eyre!("get_block_number: {e}"))?; + // (c) eth_getLogs — Railgun's RpcSyncer.events (tail range; Subsquid carries history) + let from = got_head.saturating_sub(cfg.window); + let logs = eip + .logs(cfg.railgun_wallet, None, Some(from), Some(got_head)) + .await + .map_err(|e| eyre!("logs: {e}"))?; + // (d) eth_call — Railgun's SmartWalletUtxoVerifier.verify_root (rootHistory getter). + // ZERO root has never been "seen" → returns false. The point is the eth_call + // resolves THROUGH Helios and decodes to sane data. + let seen: bool = eip + .sol_call(cfg.railgun_wallet, rootHistoryCall { treeNumber: U256::ZERO, root: B256::ZERO }) + .await + .map_err(|e| eyre!("eth_call rootHistory: {e}"))?; + + info!( + chain_id = got_chain, + head = %got_head, + logs_in_window = logs.len(), + window = cfg.window, + root_history_zero = seen, + "STEP 4 — all read-path methods resolved through Helios" + ); + + // ── Step 5: loopback-hop overhead (Task 5) ─────────────────────────────── + // direct = typed in-process HeliosApi call (0 hops, served from CL cache) + // loopback= alloy provider → Helios localhost server (1 hop), no proxy in path + let provider_direct: DynProvider = ProviderBuilder::new() + .with_default_block(BlockId::latest()) + .connect(&helios_url) + .await? + .erased(); + let eip_direct: Arc = provider_direct.into_eip1193(); + let (direct_us, loop_us) = measure_overhead(&client, &eip_direct, 25).await?; + + // ── Step 6 (Tier-1, --features railgun): real RailgunBuilder path ──────── + #[cfg(feature = "railgun")] + let tier1 = railgun_tier1::run(&proxy_url, got_head, cfg.window, &method_log).await?; + + // ── Report ──────────────────────────────────────────────────────────────── + let served = method_log.snapshot(); + println!("\n──────────── Deckard T-Trustless #3 — Helios⇄Railgun EIP-1193 seam ────────────"); + println!(" Helios sync (build→servable) : {sync_secs:.1}s chain_id={chain_id} head={head}"); + println!(" v1 wiring : alloy DynProvider → Kohaku IntoEip1193Provider::into_eip1193() ✅ (no custom adapter)"); + println!(" required v1 fix : ProviderBuilder::with_default_block(latest) — alloy's call() defaults to `pending`,"); + println!(" which Helios (light client) can't serve; the BlockIdLayer pins eth_call→latest. 1 line, Deckard-side."); + println!(" reads through Helios : eth_chainId={got_chain} eth_blockNumber={got_head}"); + println!(" eth_getLogs([head-{}, head] on RAILGUN wallet) → {} logs", cfg.window, logs.len()); + println!(" eth_call rootHistory(0,0x00..) → {seen}"); + println!(" loopback-hop overhead : direct(typed)≈{direct_us}µs/call loopback(alloy→Helios)≈{loop_us}µs/call Δ≈{}µs", loop_us.saturating_sub(direct_us)); + println!(" JSON-RPC methods seen (proxy):"); + for (m, c) in &served { + let served_by_helios = HELIOS_SERVED.contains(&m.as_str()); + println!(" {:<26} ×{:<4} {}", m, c, if served_by_helios { "served-by-Helios ✅" } else { "⚠ NOT in Helios served set" }); + } + #[cfg(feature = "railgun")] + { + println!(" Tier-1 (real railgun crate) : {}", tier1.summary); + } + #[cfg(not(feature = "railgun"))] + { + println!(" Tier-1 (real railgun crate) : not linked (run with --features railgun)"); + } + let all_served = served.iter().all(|(m, _)| HELIOS_SERVED.contains(&m.as_str())); + println!(" VERDICT : {}", if all_served { + "PASS ✅ — v1 localhost path WORKS: Helios serves every method the read path called" + } else { + "⚠ a called method is NOT in Helios's served set — see flags above" + }); + println!("────────────────────────────────────────────────────────────────────────────────\n"); + + if all_served { Ok(()) } else { Err(eyre!("a called method is not served by Helios")) } +} + +/// Methods Helios 0.11.1's localhost server serves (eth namespace, from +/// `core/src/jsonrpc/mod.rs`). Used to flag any method the read path calls that +/// Helios does NOT serve. +const HELIOS_SERVED: &[&str] = &[ + "eth_chainId", "eth_blockNumber", "eth_getLogs", "eth_call", "eth_estimateGas", + "eth_gasPrice", "eth_getTransactionCount", "eth_getBalance", "eth_getCode", + "eth_getProof", "eth_getStorageAt", "eth_getBlockByNumber", "eth_getBlockByHash", + "eth_getTransactionReceipt", "eth_getBlockReceipts", "eth_getTransactionByHash", + "eth_sendRawTransaction", "eth_maxPriorityFeePerGas", "eth_syncing", + "eth_createAccessList", "eth_newFilter", "eth_getFilterChanges", "eth_getFilterLogs", + "net_version", "web3_clientVersion", +]; + +/// Median per-call latency (µs) of a direct typed `HeliosApi` head read vs the +/// same read over the alloy provider → Helios localhost loopback. Rough is fine. +async fn measure_overhead( + client: &helios_ethereum::EthereumClient, + eip_direct: &Arc, + n: usize, +) -> Result<(u128, u128)> { + let median = |mut v: Vec| { + v.sort_unstable(); + v.get(v.len() / 2).copied().unwrap_or(0) + }; + let mut direct = Vec::with_capacity(n); + let mut loopback = Vec::with_capacity(n); + for _ in 0..n { + let t = Instant::now(); + let _ = client.get_block_number().await?; + direct.push(t.elapsed().as_micros()); + + let t = Instant::now(); + let _ = eip_direct.get_block_number().await.map_err(|e| eyre!("loopback head: {e}"))?; + loopback.push(t.elapsed().as_micros()); + } + Ok((median(direct), median(loopback))) +} + +/// Hide API keys in logs (path segments after the host). +fn redact(url: &str) -> String { + match url.split_once("://") { + Some((scheme, rest)) => { + let host = rest.split('/').next().unwrap_or(rest); + format!("{scheme}://{host}/…") + } + None => url.to_string(), + } +} diff --git a/spikes/eip1193-railgun/src/proxy.rs b/spikes/eip1193-railgun/src/proxy.rs new file mode 100644 index 0000000..cef0999 --- /dev/null +++ b/spikes/eip1193-railgun/src/proxy.rs @@ -0,0 +1,197 @@ +//! A tiny HTTP/1.1 reverse proxy that **records every JSON-RPC `method`** it +//! forwards, then passes the request through unchanged to Helios's localhost +//! JSON-RPC server. +//! +//! This is Task 4 of the spike: instrument the seam so we can *empirically* +//! enumerate exactly which methods the alloy provider (and, under `--features +//! railgun`, the real Railgun read/sync path) invokes — and cross-check each +//! against the set Helios's localhost server actually serves. +//! +//! Shape (same loopback hop v1 uses, plus a logging tap): +//! alloy provider ──HTTP──▶ this proxy (logs `method`) ──HTTP──▶ Helios :H +//! +//! Adapted from `spikes/helios-walkaway/src/proxy.rs` (the killable/lying proxy), +//! with the cut/lie machinery removed and method-logging added. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use eyre::{eyre, Result}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +/// Shared record of the JSON-RPC methods seen, in first-seen order, with counts. +#[derive(Clone, Default)] +pub struct MethodLog { + inner: Arc>, +} + +#[derive(Default)] +struct Inner { + /// method -> call count + counts: BTreeMap, + /// methods in first-seen order + order: Vec, +} + +impl MethodLog { + pub fn record(&self, method: &str) { + let mut g = self.inner.lock().unwrap(); + if !g.counts.contains_key(method) { + g.order.push(method.to_string()); + } + *g.counts.entry(method.to_string()).or_insert(0) += 1; + } + + /// (method, count) pairs in first-seen order. + pub fn snapshot(&self) -> Vec<(String, u64)> { + let g = self.inner.lock().unwrap(); + g.order + .iter() + .map(|m| (m.clone(), *g.counts.get(m).unwrap_or(&0))) + .collect() + } +} + +/// Bind a logging proxy on `bind_addr`, forwarding JSON-RPC POSTs to `upstream` +/// (Helios's localhost server). Returns the shared [`MethodLog`]. Returns once +/// the listener is bound so the caller can immediately point a provider at it. +pub async fn spawn(bind_addr: &str, upstream: String) -> Result { + let listener = TcpListener::bind(bind_addr) + .await + .map_err(|e| eyre!("proxy bind {bind_addr} failed: {e}"))?; + let log = MethodLog::default(); + + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build()?; + + let log_for_task = log.clone(); + tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((sock, _peer)) => { + let http = http.clone(); + let upstream = upstream.clone(); + let log = log_for_task.clone(); + tokio::spawn(async move { + let _ = handle_conn(sock, http, upstream, log).await; + }); + } + Err(_) => break, + } + } + }); + + Ok(log) +} + +async fn handle_conn( + mut sock: TcpStream, + http: reqwest::Client, + upstream: String, + log: MethodLog, +) -> Result<()> { + loop { + let body = match read_request_body(&mut sock).await? { + Some(b) => b, + None => return Ok(()), // peer closed cleanly + }; + + // Record the method(s) — JSON-RPC requests are a single object or a batch array. + record_methods(&body, &log); + + let resp = http + .post(&upstream) + .header("content-type", "application/json") + .body(body) + .send() + .await; + + let out = match resp { + Ok(r) => r.bytes().await.unwrap_or_default().to_vec(), + Err(_) => { + let _ = sock.shutdown().await; + return Ok(()); + } + }; + + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n", + out.len() + ); + sock.write_all(head.as_bytes()).await?; + sock.write_all(&out).await?; + sock.flush().await?; + } +} + +/// Parse a JSON-RPC request body and record each `method` into the log. +fn record_methods(body: &[u8], log: &MethodLog) { + let Ok(v) = serde_json::from_slice::(body) else { + return; + }; + match v { + serde_json::Value::Array(batch) => { + for item in batch { + if let Some(m) = item.get("method").and_then(|m| m.as_str()) { + log.record(m); + } + } + } + obj => { + if let Some(m) = obj.get("method").and_then(|m| m.as_str()) { + log.record(m); + } + } + } +} + +/// Minimal HTTP/1.1 request reader: read headers, parse `Content-Length`, read +/// exactly that many body bytes. alloy's reqwest client always sends +/// Content-Length JSON POSTs (never chunked), so this is sufficient. Returns +/// `None` if the peer closed before sending anything. +async fn read_request_body(sock: &mut TcpStream) -> Result>> { + let mut buf = Vec::with_capacity(2048); + let mut tmp = [0u8; 2048]; + + let header_end = loop { + if let Some(pos) = find_subsequence(&buf, b"\r\n\r\n") { + break pos + 4; + } + let n = sock.read(&mut tmp).await?; + if n == 0 { + return Ok(if buf.is_empty() { None } else { Some(Vec::new()) }); + } + buf.extend_from_slice(&tmp[..n]); + }; + + let content_length = parse_content_length(&buf[..header_end]).unwrap_or(0); + while buf.len() < header_end + content_length { + let n = sock.read(&mut tmp).await?; + if n == 0 { + break; + } + buf.extend_from_slice(&tmp[..n]); + } + + Ok(Some( + buf[header_end..(header_end + content_length).min(buf.len())].to_vec(), + )) +} + +fn parse_content_length(header_bytes: &[u8]) -> Option { + let text = String::from_utf8_lossy(header_bytes); + for line in text.split("\r\n") { + if let Some((k, v)) = line.split_once(':') { + if k.trim().eq_ignore_ascii_case("content-length") { + return v.trim().parse::().ok(); + } + } + } + None +} + +fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} diff --git a/spikes/eip1193-railgun/src/railgun_tier1.rs b/spikes/eip1193-railgun/src/railgun_tier1.rs new file mode 100644 index 0000000..658b88b --- /dev/null +++ b/spikes/eip1193-railgun/src/railgun_tier1.rs @@ -0,0 +1,79 @@ +//! Tier-1 (`--features railgun`): link Kohaku's **full `railgun` crate** and drive +//! its real read path against the live mainnet RAILGUN smart wallet THROUGH Helios. +//! +//! Proves, beyond the Tier-2 adapter check: +//! * `RailgunBuilder::new(ChainConfig::mainnet(), provider)` accepts the +//! Helios-backed alloy `DynProvider` and `.build()` links + constructs a real +//! `RailgunProvider` (the whole ZK crate compiles into our dependency edge). +//! * Railgun's real `RpcSyncer` (`UtxoSyncer::sync`) drives `eth_blockNumber` + +//! `eth_getLogs` through Helios over a bounded tail window — the exact code the +//! production sync path runs (Subsquid carries history; only the tail hits Helios). +//! +//! Read-only: no register/full-sync (that would scan from the 2022 deployment +//! block), no proving, no broadcast. + +use std::sync::Arc; +use std::time::Duration; + +use alloy::eips::BlockId; +use alloy::providers::{DynProvider, Provider, ProviderBuilder}; +use eyre::{eyre, Result}; +use railgun::builder::RailgunBuilder; +use railgun::chain_config::ChainConfig; +use railgun::indexer::syncer::{RpcSyncer, UtxoSyncer}; +use tracing::info; + +use crate::proxy::MethodLog; + +pub struct Tier1Out { + pub summary: String, +} + +pub async fn run(proxy_url: &str, head: u64, window: u64, _log: &MethodLog) -> Result { + let chain = ChainConfig::mainnet(); + let wallet = chain.railgun_smart_wallet; + + // Fresh Helios-backed providers (DynProvider is Clone, but build fresh to keep + // each consumer independent). `.with_default_block(latest)` is the same v1 fix as + // Tier-2: it pins the adapter's eth_call (verify_root) to `latest` so it never + // hits Helios's absent `pending` block. + let p_build: DynProvider = ProviderBuilder::new() + .with_default_block(BlockId::latest()) + .connect(proxy_url) + .await? + .erased(); + let p_sync: DynProvider = ProviderBuilder::new() + .with_default_block(BlockId::latest()) + .connect(proxy_url) + .await? + .erased(); + + // (1) Builder accepts the Helios-backed provider + the crate links. RPC-only + // syncer so build()/usage never depends on Subsquid. build() is network-free + // (MemoryDatabase), so this just proves construction + linkage. + let rpc_syncer_for_builder = Arc::new(RpcSyncer::new(chain.clone(), p_build.clone())); + let _railgun = RailgunBuilder::new(chain.clone(), p_build) + .with_utxo_syncer(rpc_syncer_for_builder) + .build() + .await + .map_err(|e| eyre!("RailgunBuilder::build: {e}"))?; + info!("Tier-1: RailgunBuilder::new(ChainConfig::mainnet(), ).build() OK"); + + // (2) Drive the REAL RpcSyncer over a bounded tail window → eth_blockNumber + // (latest_block) + eth_getLogs (events) through Helios. One getLogs call. + let syncer = RpcSyncer::new(chain.clone(), p_sync) + .with_batch_size(window.max(1)) + .with_batch_delay(Duration::ZERO); + let from = head.saturating_sub(window); + let events = UtxoSyncer::sync(&syncer, from, head) + .await + .map_err(|e| eyre!("RpcSyncer::sync: {e}"))?; + info!(from, to = head, events = events.len(), "Tier-1: real RpcSyncer drove eth_getLogs through Helios"); + + Ok(Tier1Out { + summary: format!( + "RailgunBuilder::build() OK; real RpcSyncer synced [{from},{head}] on {wallet} → {} SyncEvents via Helios", + events.len() + ), + }) +} From 9e19e9a36ff59dbfc14946f7ab8d000dd1926ef1 Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 6 Jun 2026 15:06:18 +0200 Subject: [PATCH 05/10] feat(helios): verified reads + ReadStatus across EthProvider and signerd (#1+#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route both raw-RPC read paths through an embedded Helios light client and tag every read with a trust label. #1 deckard-contract: new ReadStatus { Verified | Degraded{reason} | Unsynced{reason} } + a read_status field on BalanceReport (round-trips JSON + CBOR). #2 deckard-core: new helios.rs launcher (Helios localhost JSON-RPC server + the required with_default_block(latest) consumer-provider fix); EthProvider reads now go through it and return Read{ value, status }. deckard-signerd: read_balance + the daemon Balance handler route through Helios the same way. App status line surfaces the tag (verified / NOT VERIFIED). The heavy helios-ethereum dep is gated behind a default-on `verified-reads` feature with an honest raw-RPC fallback: never claims Verified without a fresh Helios-backed read (locked / Helios-down / feature-off all map to Unsynced; removed the old unwrap_or(ZERO)-as-truth). v1 runs two independent Helios instances (app + daemon); the failover supervisor and reads-consolidation are deferred (// TODO post-v1). Adversarial review (Codex invoked + manual pass) fixed one P1: the daemon held its global mutex across the multi-second Helios bootstrap, which could block the STOP/Lock brake — Helios moved into an off-lock HeliosCell. Plus 3 P2 honesty fixes (timestamp-based head freshness vs 60s, value-then-status ordering, no_std doc). Verified green with the real cargo (rtk cache bypassed): build verified-reads ON+OFF, deckard-app compiles; tests — signerd 15 lib + 9 daemon_e2e + 1 parity + 3 anvil_e2e (STOP/zeroize + TOCTOU intact), contract 32, core 13. --- Cargo.lock | 2311 ++++++++++++++++++-- Cargo.toml | 8 + crates/deckard-app/src/shell.rs | 19 +- crates/deckard-app/src/welcome.rs | 24 +- crates/deckard-contract/src/lib.rs | 27 + crates/deckard-contract/src/mock.rs | 5 + crates/deckard-contract/src/read_status.rs | 81 + crates/deckard-contract/src/rpc.rs | 5 + crates/deckard-core/Cargo.toml | 26 +- crates/deckard-core/examples/smoke.rs | 5 +- crates/deckard-core/src/eth.rs | 309 ++- crates/deckard-core/src/helios.rs | 279 +++ crates/deckard-core/src/lib.rs | 12 +- crates/deckard-signerd/Cargo.toml | 13 +- crates/deckard-signerd/src/daemon.rs | 166 +- crates/deckard-signerd/src/server.rs | 14 + crates/deckard-signerd/src/signing.rs | 36 +- 17 files changed, 3085 insertions(+), 255 deletions(-) create mode 100644 crates/deckard-contract/src/read_status.rs create mode 100644 crates/deckard-core/src/helios.rs diff --git a/Cargo.lock b/Cargo.lock index 83e2ac1..bbebb23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -155,6 +168,21 @@ dependencies = [ "equator", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -173,6 +201,7 @@ dependencies = [ "alloy-eips 1.8.3", "alloy-ens", "alloy-genesis", + "alloy-json-rpc 1.8.3", "alloy-network 1.8.3", "alloy-provider", "alloy-rpc-client", @@ -193,7 +222,7 @@ checksum = "84e0378e959aa6a885897522080a990e80eb317f1e9a222a604492ea50e13096" dependencies = [ "alloy-primitives", "num_enum", - "strum", + "strum 0.27.2", ] [[package]] @@ -364,6 +393,7 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "borsh", + "k256", "serde", "thiserror 2.0.18", ] @@ -400,9 +430,11 @@ dependencies = [ "c-kzg", "derive_more", "either", + "ethereum_ssz", + "ethereum_ssz_derive", "serde", "serde_with", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -425,7 +457,7 @@ dependencies = [ "either", "serde", "serde_with", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -477,7 +509,7 @@ checksum = "422d110f1c40f1f8d0e5562b0b649c35f345fccb7093d9f02729943dcd1eef71" dependencies = [ "alloy-primitives", "alloy-sol-types", - "http", + "http 1.4.1", "serde", "serde_json", "thiserror 2.0.18", @@ -492,7 +524,7 @@ checksum = "ec0a82e56b1843bce483942d54fcadea92e676f1bde162e93c7d3b621fabc4e1" dependencies = [ "alloy-primitives", "alloy-sol-types", - "http", + "http 1.4.1", "serde", "serde_json", "thiserror 2.0.18", @@ -589,6 +621,7 @@ dependencies = [ "const-hex", "derive_more", "foldhash 0.2.0", + "getrandom 0.4.2", "hashbrown 0.17.1", "indexmap 2.14.0", "itoa", @@ -632,9 +665,9 @@ dependencies = [ "futures", "futures-utils-wasm", "lru", - "parking_lot", + "parking_lot 0.12.5", "pin-project", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "thiserror 2.0.18", @@ -678,12 +711,12 @@ dependencies = [ "alloy-transport-http", "futures", "pin-project", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "tokio", "tokio-stream", - "tower", + "tower 0.5.3", "tracing", "url", "wasmtimer", @@ -696,6 +729,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4faad925d3a669ffc15f43b3deec7fbdf2adeb28a4d6f9cf4bc661698c0f8f4b" dependencies = [ "alloy-primitives", + "alloy-rpc-types-beacon", + "alloy-rpc-types-engine", "alloy-rpc-types-eth 1.8.3", "alloy-serde 1.8.3", "serde", @@ -727,6 +762,45 @@ dependencies = [ "serde_json", ] +[[package]] +name = "alloy-rpc-types-beacon" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f526dbd7bb039327cfd0ccf18c8a29ffd7402616b0c7a0239512bf8417d544c7" +dependencies = [ + "alloy-eips 1.8.3", + "alloy-primitives", + "alloy-rpc-types-engine", + "derive_more", + "ethereum_ssz", + "ethereum_ssz_derive", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tree_hash", + "tree_hash_derive", +] + +[[package]] +name = "alloy-rpc-types-engine" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb9b97b6e7965679ad22df297dda809b11cebc13405c1b537e5cffecc95834fa" +dependencies = [ + "alloy-consensus 1.8.3", + "alloy-eips 1.8.3", + "alloy-primitives", + "alloy-rlp", + "alloy-serde 1.8.3", + "derive_more", + "ethereum_ssz", + "ethereum_ssz_derive", + "rand 0.8.6", + "serde", + "strum 0.27.2", +] + [[package]] name = "alloy-rpc-types-eth" version = "1.8.3" @@ -941,12 +1015,12 @@ dependencies = [ "derive_more", "futures", "futures-utils-wasm", - "parking_lot", + "parking_lot 0.12.5", "serde", "serde_json", "thiserror 2.0.18", "tokio", - "tower", + "tower 0.5.3", "tracing", "url", "wasmtimer", @@ -961,9 +1035,9 @@ dependencies = [ "alloy-json-rpc 1.8.3", "alloy-transport", "itertools 0.14.0", - "reqwest", + "reqwest 0.13.4", "serde_json", - "tower", + "tower 0.5.3", "tracing", "url", ] @@ -990,7 +1064,7 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d69722eddcdf1ce096c3ab66cf8116999363f734eb36fe94a148f4f71c85da84" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -1002,7 +1076,7 @@ version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01a0035943b75fe1e249f52e688492d7a1b1826bc2d19b8e1d5d3c24a2ad8f50" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -1070,6 +1144,51 @@ dependencies = [ "password-hash", ] +[[package]] +name = "ark-bls12-381" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +dependencies = [ + "ark-ec", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", +] + +[[package]] +name = "ark-bn254" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +dependencies = [ + "ark-ec", + "ark-ff 0.5.0", + "ark-r1cs-std", + "ark-std 0.5.0", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff 0.5.0", + "ark-poly", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools 0.13.0", + "num-bigint", + "num-integer", + "num-traits", + "zeroize", +] + [[package]] name = "ark-ff" version = "0.3.0" @@ -1196,6 +1315,50 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "educe", + "fnv", + "hashbrown 0.15.5", +] + +[[package]] +name = "ark-r1cs-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941551ef1df4c7a401de7068758db6503598e6f01850bdb2cfdb614a1f9dbea1" +dependencies = [ + "ark-ec", + "ark-ff 0.5.0", + "ark-relations", + "ark-std 0.5.0", + "educe", + "num-bigint", + "num-integer", + "num-traits", + "tracing", +] + +[[package]] +name = "ark-relations" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec46ddc93e7af44bcab5230937635b06fb5744464dd6a7e7b083e80ebd274384" +dependencies = [ + "ark-ff 0.5.0", + "ark-std 0.5.0", + "tracing", + "tracing-subscriber 0.2.25", +] + [[package]] name = "ark-serialize" version = "0.3.0" @@ -1223,12 +1386,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ + "ark-serialize-derive", "ark-std 0.5.0", "arrayvec", "digest 0.10.7", "num-bigint", ] +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-std" version = "0.3.0" @@ -1347,6 +1522,7 @@ dependencies = [ "compression-core", "futures-io", "pin-project-lite", + "tokio", ] [[package]] @@ -1369,7 +1545,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ - "async-lock", + "async-lock 3.4.2", "blocking", "futures-lite 2.6.1", ] @@ -1383,7 +1559,7 @@ dependencies = [ "async-channel 2.5.0", "async-executor", "async-io", - "async-lock", + "async-lock 3.4.2", "blocking", "futures-lite 2.6.1", "once_cell", @@ -1407,6 +1583,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -1437,7 +1622,7 @@ checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ "async-channel 2.5.0", "async-io", - "async-lock", + "async-lock 3.4.2", "async-signal", "async-task", "blocking", @@ -1465,7 +1650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", - "async-lock", + "async-lock 3.4.2", "atomic-waker", "cfg-if", "futures-core", @@ -1485,14 +1670,14 @@ dependencies = [ "async-channel 1.9.0", "async-global-executor", "async-io", - "async-lock", + "async-lock 3.4.2", "async-process", "crossbeam-utils", "futures-channel", "futures-core", "futures-io", "futures-lite 2.6.1", - "gloo-timers", + "gloo-timers 0.3.0", "kv-log-macro", "log", "memchr", @@ -1598,6 +1783,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1641,6 +1835,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "aurora-engine-modexp" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518bc5745a6264b5fd7b09dffb9667e400ee9e2bbe18555fac75e1fe9afa0df9" +dependencies = [ + "hex", + "num", +] + [[package]] name = "auto_impl" version = "1.3.0" @@ -1723,6 +1927,12 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + [[package]] name = "backtrace" version = "0.3.76" @@ -1750,6 +1960,12 @@ version = "2.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -1774,6 +1990,15 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -1890,6 +2115,7 @@ checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" dependencies = [ "funty", "radium", + "serde", "tap", "wyz", ] @@ -1909,6 +2135,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1967,6 +2202,20 @@ dependencies = [ "piper", ] +[[package]] +name = "bls12_381" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bc6d6292be3a19e6379786dac800f551e5865a5bb51ebbe3064ab80433f403" +dependencies = [ + "digest 0.9.0", + "ff", + "group", + "pairing", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "blst" version = "0.3.16" @@ -2003,13 +2252,34 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "brotli" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bs58" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ - "sha2", + "sha2 0.10.9", "tinyvec", ] @@ -2330,7 +2600,7 @@ dependencies = [ "cocoa-foundation 0.1.2", "core-foundation 0.9.4", "core-graphics 0.23.2", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -2346,7 +2616,7 @@ dependencies = [ "cocoa-foundation 0.2.0", "core-foundation 0.10.0", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -2402,7 +2672,7 @@ dependencies = [ "hmac", "k256", "serde", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", ] @@ -2418,7 +2688,7 @@ dependencies = [ "once_cell", "pbkdf2", "rand 0.8.6", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", ] @@ -2436,7 +2706,7 @@ dependencies = [ "generic-array", "ripemd", "serde", - "sha2", + "sha2 0.10.9", "sha3 0.10.9", "thiserror 1.0.69", ] @@ -2483,11 +2753,14 @@ version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ + "brotli", "bzip2", "compression-core", "deflate64", "flate2", "memchr", + "zstd", + "zstd-safe", ] [[package]] @@ -2598,7 +2871,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -2611,7 +2884,7 @@ dependencies = [ "bitflags 2.12.1", "core-foundation 0.10.0", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -2624,7 +2897,7 @@ dependencies = [ "bitflags 2.12.1", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -2671,7 +2944,7 @@ checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ "core-foundation 0.10.0", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -2764,6 +3037,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -2855,14 +3134,62 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", ] [[package]] @@ -2875,7 +3202,29 @@ dependencies = [ "proc-macro2", "quote", "serde", - "strsim", + "strsim 0.11.1", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core 0.13.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", "syn 2.0.117", ] @@ -2885,7 +3234,7 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -2901,12 +3250,18 @@ dependencies = [ "hashbrown 0.14.5", "lock_api", "once_cell", - "parking_lot_core", + "parking_lot_core 0.9.12", ] [[package]] -name = "data-url" -version = "0.3.2" +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-url" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" @@ -2957,8 +3312,10 @@ dependencies = [ "argon2", "bip39", "chacha20poly1305", + "deckard-contract", "directories", "flume", + "helios-ethereum", "rand 0.8.6", "tokio", "zeroize", @@ -3018,6 +3375,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -3091,6 +3459,15 @@ dependencies = [ "dirs-sys 0.4.1", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" @@ -3286,6 +3663,18 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enum-iterator" version = "2.3.0" @@ -3404,6 +3793,55 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "ethereum_hashing" +version = "0.7.0" +source = "git+https://github.com/ncitron/ethereum_hashing?rev=7ee70944ed4fabe301551da8c447e4f4ae5e6c35#7ee70944ed4fabe301551da8c447e4f4ae5e6c35" +dependencies = [ + "cpufeatures 0.2.17", + "sha2 0.10.9", +] + +[[package]] +name = "ethereum_serde_utils" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dc1355dbb41fbbd34ec28d4fb2a57d9a70c67ac3c19f6a5ca4d4a176b9e997a" +dependencies = [ + "alloy-primitives", + "hex", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "ethereum_ssz" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dcddb2554d19cde19b099fadddde576929d7a4d0c1cd3512d1fd95cf174375c" +dependencies = [ + "alloy-primitives", + "ethereum_serde_utils", + "itertools 0.13.0", + "serde", + "serde_derive", + "smallvec", + "typenum", +] + +[[package]] +name = "ethereum_ssz_derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a657b6b3b7e153637dc6bdc6566ad9279d9ee11a15b12cfb24a2e04360637e9f" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "euclid" version = "0.22.14" @@ -3455,6 +3893,16 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -3513,6 +3961,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ + "bitvec", "rand_core 0.6.4", "subtle", ] @@ -3527,6 +3976,20 @@ dependencies = [ "rustc_version 0.4.1", ] +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.1", + "pear", + "serde", + "toml 0.8.23", + "uncased", + "version_check", +] + [[package]] name = "filetime" version = "0.2.29" @@ -3660,6 +4123,15 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -3667,7 +4139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -3681,6 +4153,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -3850,6 +4328,16 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" +dependencies = [ + "gloo-timers 0.4.0", + "send_wrapper", +] + [[package]] name = "futures-util" version = "0.3.32" @@ -4128,6 +4616,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "gloo-net" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -4140,6 +4649,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "glow" version = "0.17.0" @@ -4161,6 +4695,16 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "gmp-mpfr-sys" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db155b537cb791b133341f99f68371d86ee7fa4c79aacfbc376d72d23c70531" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -4232,7 +4776,7 @@ dependencies = [ "derive_more", "embed-resource", "etagere", - "foreign-types", + "foreign-types 0.5.0", "futures", "futures-concurrency", "getrandom 0.3.4", @@ -4252,7 +4796,7 @@ dependencies = [ "num_cpus", "objc", "parking", - "parking_lot", + "parking_lot 0.12.5", "pathfinder_geometry", "pin-project", "pollster 0.4.0", @@ -4272,7 +4816,7 @@ dependencies = [ "smallvec", "spin 0.10.0", "stacksafe", - "strum", + "strum 0.27.2", "sum_tree", "taffy", "thiserror 2.0.18", @@ -4375,14 +4919,14 @@ dependencies = [ "libc", "log", "oo7", - "parking_lot", + "parking_lot 0.12.5", "pathfinder_geometry", "pollster 0.4.0", "profiling", "raw-window-handle", "smallvec", "smol", - "strum", + "strum 0.27.2", "swash", "url", "util", @@ -4411,7 +4955,7 @@ dependencies = [ "derive_more", "dispatch2", "etagere", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui", "image", @@ -4423,12 +4967,12 @@ dependencies = [ "metal", "objc", "objc2-app-kit 0.3.2", - "parking_lot", + "parking_lot 0.12.5", "pathfinder_geometry", "raw-window-handle", "semver 1.0.28", "smallvec", - "strum", + "strum 0.27.2", "util", "uuid", "zed-font-kit", @@ -4490,7 +5034,7 @@ dependencies = [ "http_client", "js-sys", "log", - "parking_lot", + "parking_lot 0.12.5", "raw-window-handle", "smallvec", "uuid", @@ -4516,7 +5060,7 @@ dependencies = [ "itertools 0.14.0", "js-sys", "log", - "parking_lot", + "parking_lot 0.12.5", "pollster 0.4.0", "profiling", "raw-window-handle", @@ -4544,7 +5088,7 @@ dependencies = [ "image", "itertools 0.14.0", "log", - "parking_lot", + "parking_lot 0.12.5", "rand 0.9.4", "raw-window-handle", "smallvec", @@ -4625,6 +5169,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.14" @@ -4636,7 +5199,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.1", "indexmap 2.14.0", "slab", "tokio", @@ -4684,6 +5247,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + [[package]] name = "hashbrown" version = "0.14.5" @@ -4696,6 +5265,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", "foldhash 0.1.5", ] @@ -4744,53 +5314,252 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hex-conservative" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +name = "helios-common" +version = "0.11.1" +source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924" dependencies = [ - "arrayvec", + "alloy", + "async-trait", + "eyre", + "hex", + "revm", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", ] [[package]] -name = "hexf-parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +name = "helios-consensus-core" +version = "0.11.1" +source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924" +dependencies = [ + "alloy", + "alloy-rlp", + "bls12_381", + "ethereum_ssz", + "ethereum_ssz_derive", + "eyre", + "getrandom 0.2.17", + "serde", + "sha2 0.9.9", + "ssz_types", + "superstruct", + "thiserror 1.0.69", + "tracing", + "tree_hash", + "tree_hash_derive", + "typenum", + "wasmtimer", +] [[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +name = "helios-core" +version = "0.11.1" +source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924" dependencies = [ - "hmac", + "alloy", + "alloy-trie", + "async-trait", + "eyre", + "futures", + "getrandom 0.3.4", + "helios-common", + "helios-verifiable-api-client", + "hex", + "jsonrpsee", + "openssl", + "parking_lot 0.12.5", + "reqwest 0.12.28", + "revm", + "schnellru", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "wasm-bindgen-futures", + "wasmtimer", ] [[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +name = "helios-ethereum" +version = "0.11.1" +source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924" dependencies = [ - "digest 0.10.7", + "alloy", + "alloy-trie", + "async-trait", + "chrono", + "dirs 5.0.1", + "eyre", + "figment", + "futures", + "getrandom 0.3.4", + "helios-common", + "helios-consensus-core", + "helios-core", + "helios-revm-utils", + "hex", + "openssl", + "parking_lot 0.12.5", + "reqwest 0.12.28", + "retri", + "revm", + "serde", + "serde_json", + "serde_yaml", + "strum 0.26.3", + "superstruct", + "thiserror 1.0.69", + "tokio", + "tracing", + "tree_hash", + "typenum", + "url", + "wasm-bindgen-futures", ] [[package]] -name = "home" -version = "0.5.12" +name = "helios-revm-utils" +version = "0.11.1" +source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924" +dependencies = [ + "alloy", + "eyre", + "helios-common", + "helios-core", + "hex", + "revm", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "helios-verifiable-api-client" +version = "0.11.1" +source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924" +dependencies = [ + "alloy", + "async-trait", + "eyre", + "helios-common", + "helios-verifiable-api-types", + "reqwest 0.12.28", + "reqwest-middleware", + "reqwest-retry", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "helios-verifiable-api-types" +version = "0.11.1" +source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924" +dependencies = [ + "alloy", + "helios-common", + "serde", + "serde_json", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.4", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot 0.12.5", + "rand 0.9.4", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ @@ -4811,6 +5580,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.1" @@ -4821,6 +5601,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -4828,7 +5619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.1", ] [[package]] @@ -4839,8 +5630,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.1", + "http-body 1.0.1", "pin-project-lite", ] @@ -4856,14 +5647,14 @@ dependencies = [ "bytes", "derive_more", "futures", - "http", - "http-body", + "http 1.4.1", + "http-body 1.0.1", "log", - "parking_lot", + "parking_lot 0.12.5", "serde", "serde_json", "serde_urlencoded", - "sha2", + "sha2 0.10.9", "tempfile", "url", "util", @@ -4875,6 +5666,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.12" @@ -4884,6 +5681,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.10.1" @@ -4894,9 +5715,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.14", + "http 1.4.1", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -4905,19 +5726,51 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "rustls-native-certs 0.6.3", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", - "hyper", + "http 1.4.1", + "hyper 1.10.1", + "hyper-util", + "rustls 0.23.40", + "rustls-native-certs 0.8.4", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.10.1", "hyper-util", - "rustls", - "rustls-native-certs", + "native-tls", "tokio", - "tokio-rustls", + "tokio-native-tls", "tower-service", ] @@ -4931,17 +5784,19 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.1", + "http-body 1.0.1", + "hyper 1.10.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.4", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", + "windows-registry 0.4.0", ] [[package]] @@ -5165,6 +6020,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "1.9.3" @@ -5188,6 +6049,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "inotify" version = "0.10.2" @@ -5262,6 +6129,19 @@ dependencies = [ "leaky-cow", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.4", + "widestring", + "windows-registry 0.6.1", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -5390,6 +6270,162 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonrpsee" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f3783308bddc49d0218307f66a09330c106fbd792c58bac5c8dc294fdd0f98" +dependencies = [ + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-http-client", + "jsonrpsee-proc-macros", + "jsonrpsee-server", + "jsonrpsee-types", + "jsonrpsee-wasm-client", + "jsonrpsee-ws-client", + "tracing", +] + +[[package]] +name = "jsonrpsee-client-transport" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc5630e4fa0096f00ec7b44d520701fda4504170cb85e22dca603ae5d7ad0d7" +dependencies = [ + "futures-channel", + "futures-util", + "gloo-net", + "http 0.2.12", + "jsonrpsee-core", + "pin-project", + "rustls-native-certs 0.6.3", + "soketto", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.24.1", + "tokio-util", + "tracing", + "webpki-roots", +] + +[[package]] +name = "jsonrpsee-core" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aaa4c4d5fb801dcc316d81f76422db259809037a86b3194ae538dd026b05ed7" +dependencies = [ + "anyhow", + "async-lock 2.8.0", + "async-trait", + "beef", + "futures-timer", + "futures-util", + "globset", + "hyper 0.14.32", + "jsonrpsee-types", + "parking_lot 0.12.5", + "rand 0.8.6", + "rustc-hash 1.1.0", + "serde", + "serde_json", + "soketto", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "jsonrpsee-http-client" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa7165efcbfbc951d180162ff28fe91b657ed81925e37a35e4a396ce12109f96" +dependencies = [ + "async-trait", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "jsonrpsee-core", + "jsonrpsee-types", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tower 0.4.13", + "tracing", +] + +[[package]] +name = "jsonrpsee-proc-macros" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dc12b1d4f16a86e8c522823c4fab219c88c03eb7c924ec0501a64bf12e058b" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "jsonrpsee-server" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e79d78cfd5abd8394da10753723093c3ff64391602941c9c4b1d80a3414fd53" +dependencies = [ + "futures-util", + "hyper 0.14.32", + "jsonrpsee-core", + "jsonrpsee-types", + "serde", + "serde_json", + "soketto", + "tokio", + "tokio-stream", + "tokio-util", + "tower 0.4.13", + "tracing", +] + +[[package]] +name = "jsonrpsee-types" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00aa7cc87bc42e04e26c8ac3e7186142f7fd2949c763d9b6a7e64a69672d8fb2" +dependencies = [ + "anyhow", + "beef", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "jsonrpsee-wasm-client" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe953c2801356f214d3f4051f786b3d11134512a46763ee8c39a9e3fa2cc1c0" +dependencies = [ + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", +] + +[[package]] +name = "jsonrpsee-ws-client" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c71b2597ec1c958c6d5bc94bb61b44d74eb28e69dc421731ab0035706f13882" +dependencies = [ + "http 0.2.12", + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", +] + [[package]] name = "k256" version = "0.13.4" @@ -5401,7 +6437,7 @@ dependencies = [ "elliptic-curve", "once_cell", "serdect", - "sha2", + "sha2 0.10.9", "signature", ] @@ -5631,6 +6667,52 @@ dependencies = [ "libc", ] +[[package]] +name = "libsecp256k1" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +dependencies = [ + "arrayref", + "base64 0.22.1", + "digest 0.9.0", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand 0.8.6", + "serde", + "sha2 0.9.9", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c" +dependencies = [ + "libsecp256k1-core", +] + [[package]] name = "libxdo" version = "0.6.0" @@ -5901,7 +6983,7 @@ dependencies = [ "core-foundation 0.10.0", "core-video", "ctor", - "foreign-types", + "foreign-types 0.5.0", "metal", "objc", ] @@ -5939,7 +7021,7 @@ dependencies = [ "bitflags 2.12.1", "block", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", @@ -5989,6 +7071,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot 0.12.5", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -6054,6 +7153,23 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -6316,6 +7432,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.117", @@ -6647,6 +7764,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "oo7" @@ -6658,7 +7779,7 @@ dependencies = [ "ashpd", "async-fs", "async-io", - "async-lock", + "async-lock 3.4.2", "blocking", "cbc", "cipher", @@ -6675,7 +7796,7 @@ dependencies = [ "pbkdf2", "serde", "serde_bytes", - "sha2", + "sha2 0.10.9", "subtle", "zbus", "zbus_macros", @@ -6689,12 +7810,65 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -6720,6 +7894,27 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "pairing" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" +dependencies = [ + "group", +] + [[package]] name = "pango" version = "0.18.3" @@ -6779,6 +7974,17 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -6786,7 +7992,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", - "parking_lot_core", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", ] [[package]] @@ -6848,10 +8068,33 @@ dependencies = [ name = "pbkdf2" version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" dependencies = [ - "digest 0.10.7", - "hmac", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", ] [[package]] @@ -6886,7 +8129,9 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ + "phf_macros 0.11.3", "phf_shared 0.11.3", + "serde", ] [[package]] @@ -6895,7 +8140,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", + "phf_macros 0.13.1", "phf_shared 0.13.1", "serde", ] @@ -6930,6 +8175,19 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "phf_macros" version = "0.13.1" @@ -7110,11 +8368,11 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" dependencies = [ - "atomic", + "atomic 0.5.3", "crossbeam-queue", "futures", "log", - "parking_lot", + "parking_lot 0.12.5", "pin-project", "pollster 0.2.5", "static_assertions", @@ -7167,6 +8425,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "primitive-types" version = "0.12.2" @@ -7261,6 +8528,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + [[package]] name = "profiling" version = "1.0.18" @@ -7376,8 +8656,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.2", - "rustls", - "socket2", + "rustls 0.23.40", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -7397,7 +8677,7 @@ dependencies = [ "rand 0.9.4", "ring", "rustc-hash 2.1.2", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -7415,7 +8695,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -7536,6 +8816,7 @@ version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" dependencies = [ + "rand 0.9.4", "rustversion", ] @@ -7741,6 +9022,48 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.14", + "hickory-resolver", + "http 1.4.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls 0.27.9", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -7750,26 +9073,26 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", - "http", - "http-body", + "http 1.4.1", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.10.1", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-rustls", - "tower", + "tokio-rustls 0.26.4", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -7778,6 +9101,49 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest-middleware" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +dependencies = [ + "anyhow", + "async-trait", + "http 1.4.1", + "reqwest 0.12.28", + "serde", + "thiserror 1.0.69", + "tower-service", +] + +[[package]] +name = "reqwest-retry" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c73e4195a6bfbcb174b790d9b3407ab90646976c55de58a6515da25d851178" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "getrandom 0.2.17", + "http 1.4.1", + "hyper 1.10.1", + "parking_lot 0.11.2", + "reqwest 0.12.28", + "reqwest-middleware", + "retry-policies", + "thiserror 1.0.69", + "tokio", + "tracing", + "wasm-timer", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "resvg" version = "0.45.1" @@ -7795,6 +9161,214 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "retri" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38316070fbd6504dcbb6042225f2608f2323c71155836c9b48f192113f68782" +dependencies = [ + "tokio", + "zduny-wasm-timer", +] + +[[package]] +name = "retry-policies" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5875471e6cab2871bc150ecb8c727db5113c9338cc3354dc5ee3425b6aa40a1c" +dependencies = [ + "rand 0.8.6", +] + +[[package]] +name = "revm" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "718d90dce5f07e115d0e66450b1b8aa29694c1cf3f89ebddaddccc2ccbd2f13e" +dependencies = [ + "revm-bytecode", + "revm-context", + "revm-context-interface", + "revm-database", + "revm-database-interface", + "revm-handler", + "revm-inspector", + "revm-interpreter", + "revm-precompile", + "revm-primitives", + "revm-state", +] + +[[package]] +name = "revm-bytecode" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c52031b73cae95d84cd1b07725808b5fd1500da3e5e24574a3b2dc13d9f16d" +dependencies = [ + "bitvec", + "phf 0.11.3", + "revm-primitives", + "serde", +] + +[[package]] +name = "revm-context" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a20c98e7008591a6f012550c2a00aa36cba8c14cc88eb88dec32eb9102554b4" +dependencies = [ + "bitvec", + "cfg-if", + "derive-where", + "revm-bytecode", + "revm-context-interface", + "revm-database-interface", + "revm-primitives", + "revm-state", + "serde", +] + +[[package]] +name = "revm-context-interface" +version = "10.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50d241ed1ce647b94caf174fcd0239b7651318b2c4c06b825b59b973dfb8495" +dependencies = [ + "alloy-eip2930", + "alloy-eip7702", + "auto_impl", + "either", + "revm-database-interface", + "revm-primitives", + "revm-state", + "serde", +] + +[[package]] +name = "revm-database" +version = "7.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a276ed142b4718dcf64bc9624f474373ed82ef20611025045c3fb23edbef9c" +dependencies = [ + "alloy-eips 1.8.3", + "revm-bytecode", + "revm-database-interface", + "revm-primitives", + "revm-state", + "serde", +] + +[[package]] +name = "revm-database-interface" +version = "7.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c523c77e74eeedbac5d6f7c092e3851dbe9c7fec6f418b85992bd79229db361" +dependencies = [ + "auto_impl", + "either", + "revm-primitives", + "revm-state", + "serde", +] + +[[package]] +name = "revm-handler" +version = "10.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550331ea85c1d257686e672081576172fe3d5a10526248b663bbf54f1bef226a" +dependencies = [ + "auto_impl", + "derive-where", + "revm-bytecode", + "revm-context", + "revm-context-interface", + "revm-database-interface", + "revm-interpreter", + "revm-precompile", + "revm-primitives", + "revm-state", + "serde", +] + +[[package]] +name = "revm-inspector" +version = "10.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0a6e9ccc2ae006f5bed8bd80cd6f8d3832cd55c5e861b9402fdd556098512f" +dependencies = [ + "auto_impl", + "either", + "revm-context", + "revm-database-interface", + "revm-handler", + "revm-interpreter", + "revm-primitives", + "revm-state", + "serde", + "serde_json", +] + +[[package]] +name = "revm-interpreter" +version = "25.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06575dc51b1d8f5091daa12a435733a90b4a132dca7ccee0666c7db3851bc30c" +dependencies = [ + "revm-bytecode", + "revm-context-interface", + "revm-primitives", + "serde", +] + +[[package]] +name = "revm-precompile" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b57d4bd9e6b5fe469da5452a8a137bc2d030a3cd47c46908efc615bbc699da" +dependencies = [ + "ark-bls12-381", + "ark-bn254", + "ark-ec", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "arrayref", + "aurora-engine-modexp", + "c-kzg", + "cfg-if", + "k256", + "libsecp256k1", + "p256", + "revm-primitives", + "ripemd", + "rug", + "secp256k1 0.31.1", + "sha2 0.10.9", +] + +[[package]] +name = "revm-primitives" +version = "20.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa29d9da06fe03b249b6419b33968ecdf92ad6428e2f012dc57bcd619b5d94e" +dependencies = [ + "alloy-primitives", + "num_enum", + "once_cell", + "serde", +] + +[[package]] +name = "revm-state" +version = "7.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f64fbacb86008394aaebd3454f9643b7d5a782bd251135e17c5b33da592d84d" +dependencies = [ + "bitflags 2.12.1", + "revm-bytecode", + "revm-primitives", + "serde", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -7862,6 +9436,18 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rug" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07a8857882aec59d27254b02481c709327c13de6fad1da60bfc4f9783eaaa61e" +dependencies = [ + "az", + "gmp-mpfr-sys", + "libc", + "libm", +] + [[package]] name = "ruint" version = "1.18.0" @@ -7928,7 +9514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "globset", - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -7997,6 +9583,9 @@ name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +dependencies = [ + "rand 0.8.6", +] [[package]] name = "rustc-hex" @@ -8048,6 +9637,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.40" @@ -8058,21 +9659,42 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile 1.0.4", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", ] [[package]] @@ -8105,11 +9727,11 @@ dependencies = [ "jni", "log", "once_cell", - "rustls", - "rustls-native-certs", + "rustls 0.23.40", + "rustls-native-certs 0.8.4", "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", + "rustls-webpki 0.103.13", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -8121,6 +9743,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -8203,7 +9835,7 @@ dependencies = [ "chrono", "flume", "futures", - "parking_lot", + "parking_lot 0.12.5", "rand 0.9.4", "web-time", ] @@ -8246,6 +9878,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "schnellru" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "356285bbf17bea63d9e52e96bd18f039672ac92b55b8cb997d6162a2a37d1649" +dependencies = [ + "ahash", + "cfg-if", + "hashbrown 0.13.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -8275,6 +9918,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "seahash" version = "4.1.0" @@ -8337,6 +9990,19 @@ dependencies = [ "cc", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.12.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -8394,6 +10060,12 @@ dependencies = [ "pest", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -8548,7 +10220,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -8577,12 +10249,38 @@ dependencies = [ "serde", ] +[[package]] +name = "sha-1" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha1_smol" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" @@ -8639,7 +10337,7 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ - "dirs", + "dirs 6.0.0", ] [[package]] @@ -8764,7 +10462,7 @@ dependencies = [ "async-executor", "async-fs", "async-io", - "async-lock", + "async-lock 3.4.2", "async-net", "async-process", "blocking", @@ -8781,6 +10479,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.4" @@ -8791,6 +10499,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "soketto" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d1c5305e39e09653383c2c7244f2f78b3bcae37cf50c64cb4789c9f5096ec2" +dependencies = [ + "base64 0.13.1", + "bytes", + "futures", + "http 0.2.12", + "httparse", + "log", + "rand 0.8.6", + "sha-1", +] + [[package]] name = "spin" version = "0.9.8" @@ -8828,6 +10552,22 @@ dependencies = [ "der", ] +[[package]] +name = "ssz_types" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b55bedc9a18ed2860a46d6beb4f4082416ee1d60be0cc364cebdcdddc7afd4" +dependencies = [ + "ethereum_serde_utils", + "ethereum_ssz", + "itertools 0.13.0", + "serde", + "serde_derive", + "smallvec", + "tree_hash", + "typenum", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -8902,7 +10642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", - "parking_lot", + "parking_lot 0.12.5", "phf_shared 0.11.3", "precomputed-hash", "serde", @@ -8920,19 +10660,47 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", ] [[package]] @@ -8965,6 +10733,20 @@ dependencies = [ "ztracing", ] +[[package]] +name = "superstruct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f4e1f478a7728f8855d7e620e9a152cf8932c6614f86564c886f9b8141f3201" +dependencies = [ + "darling 0.13.4", + "itertools 0.10.5", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + [[package]] name = "sval" version = "2.20.0" @@ -9158,6 +10940,17 @@ dependencies = [ "system-configuration-sys", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.12.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -9193,6 +10986,12 @@ dependencies = [ "slotmap", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "take-until" version = "0.2.0" @@ -9420,7 +11219,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -9436,13 +11235,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.40", "tokio", ] @@ -9478,6 +11297,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -9597,6 +11417,21 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -9618,13 +11453,18 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", "bitflags 2.12.1", "bytes", + "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.1", + "http-body 1.0.1", + "http-body-util", "pin-project-lite", - "tower", + "tokio", + "tokio-util", + "tower 0.5.3", "tower-layer", "tower-service", "url", @@ -9686,6 +11526,15 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-subscriber" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0d2eaa99c3c2e41547cfa109e910a68ea03823cccad4a0525dcbc9b01e8c71" +dependencies = [ + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -9707,7 +11556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e47e6d063cfe4ad2e416fcbb310be3a37c5fd85c745b62cb562bfa4a003df674" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.4", @@ -9751,6 +11600,31 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "tree_hash" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee44f4cef85f88b4dea21c0b1f58320bdf35715cf56d840969487cff00613321" +dependencies = [ + "alloy-primitives", + "ethereum_hashing", + "ethereum_ssz", + "smallvec", + "typenum", +] + +[[package]] +name = "tree_hash_derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bee2ea1551f90040ab0e34b6fb7f2fa3bad8acc925837ac654f2c78a13e3089" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "triomphe" version = "0.1.15" @@ -9824,6 +11698,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicase" version = "2.9.0" @@ -9995,7 +11878,7 @@ dependencies = [ "async_zip", "collections", "command-fds", - "dirs", + "dirs 6.0.0", "dunce", "futures", "futures-lite 1.13.0", @@ -10100,6 +11983,12 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -10280,6 +12169,21 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm_thread" version = "0.3.3" @@ -10312,7 +12216,7 @@ checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" dependencies = [ "futures", "js-sys", - "parking_lot", + "parking_lot 0.12.5", "pin-utils", "slab", "wasm-bindgen", @@ -10359,6 +12263,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b291546d5d9d1eab74f069c77749f2cb8504a12caa20f0f2de93ddbf6f411888" +dependencies = [ + "rustls-webpki 0.101.7", +] + [[package]] name = "weezl" version = "0.1.12" @@ -10380,7 +12293,7 @@ dependencies = [ "js-sys", "log", "naga", - "parking_lot", + "parking_lot 0.12.5", "portable-atomic", "profiling", "raw-window-handle", @@ -10411,7 +12324,7 @@ dependencies = [ "log", "naga", "once_cell", - "parking_lot", + "parking_lot 0.12.5", "portable-atomic", "profiling", "raw-window-handle", @@ -10483,7 +12396,7 @@ dependencies = [ "objc2-quartz-core 0.3.2", "once_cell", "ordered-float", - "parking_lot", + "parking_lot 0.12.5", "portable-atomic", "portable-atomic-util", "profiling", @@ -10537,6 +12450,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -10609,7 +12528,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" dependencies = [ - "parking_lot", + "parking_lot 0.12.5", "rayon", "thiserror 2.0.18", "windows 0.61.3", @@ -10792,6 +12711,17 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -11322,6 +13252,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yazi" version = "0.2.1" @@ -11371,7 +13307,7 @@ dependencies = [ "async-broadcast", "async-executor", "async-io", - "async-lock", + "async-lock 3.4.2", "async-process", "async-recursion", "async-task", @@ -11459,6 +13395,21 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zduny-wasm-timer" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52bd30296679f51dce4a4da2a5050d9401d09866d89b89d860da37bc3ec08df" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.12.5", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "zed-font-kit" version = "0.14.1-zed" @@ -11469,7 +13420,7 @@ dependencies = [ "core-foundation 0.10.0", "core-graphics 0.24.0", "core-text", - "dirs", + "dirs 6.0.0", "dwrote", "float-ord", "freetype-sys", @@ -11493,12 +13444,12 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.14", + "http 1.4.1", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.10.1", + "hyper-rustls 0.27.9", "hyper-util", "ipnet", "js-sys", @@ -11509,20 +13460,20 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", - "rustls-native-certs", - "rustls-pemfile", + "rustls 0.23.40", + "rustls-native-certs 0.8.4", + "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", + "system-configuration 0.6.1", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-socks", "tokio-util", - "tower", + "tower 0.5.3", "tower-service", "url", "wasm-bindgen", @@ -11682,13 +13633,41 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "ztracing" version = "0.1.0" source = "git+https://github.com/zed-industries/zed#86effffd34634945a4971e1c6c65cd45b21ce6a9" dependencies = [ "tracing", - "tracing-subscriber", + "tracing-subscriber 0.3.23", "zlog", "ztracing_macro", ] diff --git a/Cargo.toml b/Cargo.toml index f817cd3..bd61e97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,11 @@ serde = { version = "1", features = ["derive"] } strip = true lto = "thin" codegen-units = 1 + +# Mirror Helios's own workspace patch. `[patch]` only resolves at the workspace ROOT +# and does NOT inherit through a git dependency, so the embedded helios-ethereum +# (behind deckard-core's `verified-reads` feature) needs this here or its consensus +# crates fail to build. The `ruint`/`ark-circom` patches from the eip1193-railgun +# spike are Kohaku/Railgun-ZK-only — NOT needed for a helios-only consumer, omitted. +[patch.crates-io] +ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" } diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index 47566df..2c10bf3 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -15,7 +15,7 @@ use gpui_component::{ v_flex, ActiveTheme, IconName, TitleBar, }; -use deckard_core::{Address, EthProvider, KdfParams, Portfolio, Vault, WordCount}; +use deckard_core::{Address, EthProvider, KdfParams, Portfolio, ReadStatus, Vault, WordCount}; use zeroize::Zeroizing; use deckard_signerd::SignerClient; @@ -131,6 +131,9 @@ pub struct Shell { /// True only during the first sync (the one allowed loading state). pub portfolio_loading: bool, pub portfolio_error: Option, + /// Trust label for the last portfolio/block read: Helios-`Verified` vs visibly + /// `Unsynced`/`Degraded`. Never silently "trusted" — surfaced in the status line. + pub read_status: Option, /// Latest block height — a liveness/sync indicator for the status line. pub synced_block: Option, /// Bumped on every `retarget`; a slow ENS resolution checks it before applying so a @@ -301,6 +304,7 @@ impl Shell { portfolio: None, portfolio_loading: false, portfolio_error: None, + read_status: None, synced_block: None, view_epoch: 0, current_rpc, @@ -680,11 +684,13 @@ impl Shell { this.update(cx, |this, cx| { this.portfolio_loading = false; match res { - Ok(Ok(p)) => { + Ok(Ok(read)) => { // Ignore a stale reply for an address we're no longer viewing. - if p.address == this.display_address { - this.portfolio = Some(p); + if read.value.address == this.display_address { + this.portfolio = Some(read.value); this.portfolio_error = None; + // Surface the trust label (Helios-verified vs unsynced). + this.read_status = Some(read.status); } } Ok(Err(e)) => this.portfolio_error = Some(short_err(e)), @@ -701,9 +707,10 @@ impl Shell { fn kick_block_number(eth: &EthProvider, cx: &mut Context) { let rx = eth.block_number(); cx.spawn(async move |this, cx| { - if let Ok(Ok(n)) = rx.recv_async().await { + if let Ok(Ok(read)) = rx.recv_async().await { this.update(cx, |this, cx| { - this.synced_block = Some(n); + this.synced_block = Some(read.value); + this.read_status = Some(read.status); cx.notify(); }) .ok(); diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index c5f20b6..a3d54a7 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -90,18 +90,36 @@ impl Shell { } let has_tokens = self.portfolio.as_ref().map(|p| !p.tokens.is_empty()).unwrap_or(false); - // Status sub-line: synced block, watching tag, or an error. + // Status sub-line: synced block, watching tag, or an error. When a read carries a + // non-Verified trust label, surface it: a balance is never shown as quietly trusted. + let trust_tag = match &self.read_status { + Some(deckard_core::ReadStatus::Verified) => " · verified", + Some(deckard_core::ReadStatus::Degraded { .. }) => " · degraded", + Some(deckard_core::ReadStatus::Unsynced { .. }) => " · NOT VERIFIED", + None => "", + }; let status_line = if let Some(err) = &self.portfolio_error { format!("⚠ {err}") } else if first_sync { "Syncing over Ethereum…".to_string() } else if let Some(block) = self.synced_block { let net = if self.viewing_watch { "watching · " } else { "" }; - format!("{net}synced · block {block}") + format!("{net}synced · block {block}{trust_tag}") } else { "Ethereum mainnet".to_string() }; - let status_color = if self.portfolio_error.is_some() { theme.danger } else { muted }; + // An unverified read is a soft warning (the value may not be trustless), not a hard error. + let unverified = matches!( + self.read_status, + Some(deckard_core::ReadStatus::Unsynced { .. }) + ); + let status_color = if self.portfolio_error.is_some() { + theme.danger + } else if unverified { + theme.warning + } else { + muted + }; div() .flex_1() diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index 3cf7df1..98ef372 100644 --- a/crates/deckard-contract/src/lib.rs +++ b/crates/deckard-contract/src/lib.rs @@ -26,6 +26,7 @@ pub mod decision; pub mod intent; pub mod mock; pub mod policy; +pub mod read_status; pub mod rpc; pub mod signer; @@ -33,6 +34,7 @@ pub use decision::{Decision, RequestId}; pub use intent::{Intent, IntentKind}; pub use mock::MockSigner; pub use policy::{evaluate, ApprovalMode, Policy}; +pub use read_status::ReadStatus; pub use rpc::{ ApprovalStatus, BalanceReport, ExecuteResult, SignerRequest, SignerResponse, UnlockOutcome, }; @@ -191,6 +193,7 @@ mod roundtrip_tests { roundtrip(&SignerResponse::Balance(BalanceReport { public_wei: U256::from(1_u64), shielded_wei: U256::from(2_u64), + read_status: ReadStatus::Verified, })); } @@ -212,13 +215,37 @@ mod roundtrip_tests { #[test] fn balance_report_roundtrip() { + // Exercise every ReadStatus variant (incl. the owned-String reasons) so both + // CBOR and JSON coverage of the new field stays complete + byte-stable. roundtrip(&BalanceReport { public_wei: U256::from(0_u64), shielded_wei: U256::from(0_u64), + read_status: ReadStatus::Verified, }); roundtrip(&BalanceReport { public_wei: U256::MAX, shielded_wei: U256::from(42_u64), + read_status: ReadStatus::Unsynced { + reason: "head stale".into(), + }, + }); + roundtrip(&BalanceReport { + public_wei: U256::from(7_u64), + shielded_wei: U256::from(0_u64), + read_status: ReadStatus::Degraded { + reason: "failover→nimbus".into(), + }, + }); + } + + #[test] + fn read_status_roundtrip() { + roundtrip(&ReadStatus::Verified); + roundtrip(&ReadStatus::Degraded { + reason: "failover→drpc".into(), + }); + roundtrip(&ReadStatus::Unsynced { + reason: "verification disabled".into(), }); } } diff --git a/crates/deckard-contract/src/mock.rs b/crates/deckard-contract/src/mock.rs index 6078ea6..03987ba 100644 --- a/crates/deckard-contract/src/mock.rs +++ b/crates/deckard-contract/src/mock.rs @@ -14,6 +14,7 @@ use alloy_primitives::{Address, B256, U256}; use crate::decision::{Decision, RequestId}; use crate::intent::Intent; use crate::policy::{self, Policy}; +use crate::read_status::ReadStatus; use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult, UnlockOutcome}; use crate::signer::Signer; @@ -63,6 +64,9 @@ impl MockSigner { balance: Mutex::new(BalanceReport { public_wei: U256::ZERO, shielded_wei: U256::ZERO, + // The mock is deterministic + offline; it never touches a chain, so + // it reports its canned balances as Verified (no untrusted RPC behind it). + read_status: ReadStatus::Verified, }), } } @@ -563,6 +567,7 @@ mod tests { s.set_balance(BalanceReport { public_wei: U256::from(7u64), shielded_wei: U256::from(3u64), + read_status: ReadStatus::Verified, }); let b = s.balance(false); assert_eq!(b.public_wei, U256::from(7u64)); diff --git a/crates/deckard-contract/src/read_status.rs b/crates/deckard-contract/src/read_status.rs new file mode 100644 index 0000000..cb3fe10 --- /dev/null +++ b/crates/deckard-contract/src/read_status.rs @@ -0,0 +1,81 @@ +//! `ReadStatus` — Deckard-owned trust label attached to every chain read. +//! +//! This is the contract the UI and the MCP agent surface see. The hard rule: +//! **never silently serve an untrusted read.** A read is either verified, or +//! visibly degraded/unsynced — never quietly trusted. +//! +//! The three states map onto *observable* Helios behavior (verified against +//! a16z/helios @ 0.11.1, `core/src/client/node.rs`): +//! +//! - `Verified` — Helios head is fresh (age ≤ 60s, the hard `check_head_age` gate) +//! and the read came back from the verified light client. +//! - `Degraded` — still cryptographically verified, but off the happy path: we +//! failed over to a secondary EL, or we're on a community fallback checkpoint. +//! Trust note shown. Rarely emitted in v1. +//! - `Unsynced` — cannot produce a verified read right now: sync not finished, head +//! stale past the 60s gate, the read failed, or verification is disabled. The UI +//! shows a hard "NOT VERIFIED" state. Deckard MUST NOT fall back to a raw +//! untrusted RPC and still claim it is verified. +//! +//! ## Portability +//! +//! `deckard-contract` is a **std** crate today (no `#![no_std]`), so `String` +//! here resolves to `std::string::String`. The type is written to be no_std- +//! *ready* — it leans only on `alloc`-available types (`String`) and `core::fmt` +//! for `Display` — so a future `#![no_std]` + `extern crate alloc` flip would be +//! mechanical, not a rewrite. Like every other wire type it carries the same +//! `serde` derives, so it round-trips byte-stably across JSON (the MCP surface) +//! and CBOR (the daemon UDS). + +use core::fmt; + +use serde::{Deserialize, Serialize}; + +/// Trust label attached to every chain read. Maps onto observable Helios state +/// (see deckard-core's verified read path). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReadStatus { + /// Helios head fresh and the read succeeded against the verified light client. + /// Fully trustless. + Verified, + /// Still cryptographically verified, but off the happy path (EL failover or + /// community-fallback checkpoint). `reason` is shown to the user. Rarely + /// emitted in v1. + Degraded { reason: String }, + /// No verified read is possible right now (Helios unsynced, head stale past + /// the 60s gate, the read failed, or verification is disabled). `reason` is + /// shown to the user. Deckard MUST NOT fall back to a raw untrusted RPC and + /// still claim Verified. + Unsynced { reason: String }, +} + +impl ReadStatus { + /// Off-the-happy-path-but-still-verified label. + pub fn degraded(reason: impl Into) -> Self { + ReadStatus::Degraded { + reason: reason.into(), + } + } + + /// No-verified-read-possible label. + pub fn unsynced(reason: impl Into) -> Self { + ReadStatus::Unsynced { + reason: reason.into(), + } + } + + /// True only when a real, verified value backs the read. + pub fn is_trustworthy(&self) -> bool { + matches!(self, ReadStatus::Verified | ReadStatus::Degraded { .. }) + } +} + +impl fmt::Display for ReadStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReadStatus::Verified => write!(f, "VERIFIED"), + ReadStatus::Degraded { reason } => write!(f, "DEGRADED ({reason})"), + ReadStatus::Unsynced { reason } => write!(f, "NOT VERIFIED ({reason})"), + } + } +} diff --git a/crates/deckard-contract/src/rpc.rs b/crates/deckard-contract/src/rpc.rs index b2f8669..110de44 100644 --- a/crates/deckard-contract/src/rpc.rs +++ b/crates/deckard-contract/src/rpc.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::decision::{Decision, RequestId}; use crate::intent::Intent; use crate::policy::Policy; +use crate::read_status::ReadStatus; /// `deckard-mcp` → `deckard-signerd`. The key-less client only proposes; it never signs. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -99,4 +100,8 @@ pub enum ApprovalStatus { pub struct BalanceReport { pub public_wei: U256, pub shielded_wei: U256, + /// Trust label for this read (Helios-verified vs unsynced/degraded). The hard + /// rule: a balance is `Verified` only when a fresh Helios-verified read backs + /// it; otherwise it is visibly `Unsynced`/`Degraded`, never quietly trusted. + pub read_status: ReadStatus, } diff --git a/crates/deckard-core/Cargo.toml b/crates/deckard-core/Cargo.toml index 8b467f4..0d5b8ea 100644 --- a/crates/deckard-core/Cargo.toml +++ b/crates/deckard-core/Cargo.toml @@ -5,15 +5,35 @@ edition = "2021" license = "AGPL-3.0-or-later" description = "Deckard's headless engine: Ethereum provider, balances, HD keys, and the encrypted keystore — no GPUI dependency, fully unit-testable." +[features] +# Embedded Helios light client → verified localhost reads. Heavy (revm/bls); ON by +# default so the app + daemon get verified reads, but toggleable so the heavy build +# can be skipped. When OFF, reads fall back to the raw RPC and are tagged +# ReadStatus::Unsynced("verification disabled") — never silently Verified. +default = ["verified-reads"] +verified-reads = ["dep:helios-ethereum"] + [dependencies] +# The frozen wire contract — only for the shared `ReadStatus` trust label attached +# to every read. (Types-only; no key material, no logic.) +deckard-contract = { path = "../deckard-contract" } + # The full alloy surface Deckard needs across chunks. Front-loaded so the heavy # alloy/reqwest compile happens once: provider+http (reads), contract+sol-types -# (Multicall3 / ERC-20), ens (name resolution). signer-local stays standalone -# below to keep the `mnemonic` feature explicit. -alloy = { version = "1", features = ["provider-http", "contract", "sol-types", "ens"] } +# (Multicall3 / ERC-20), ens (name resolution), eips (BlockId for the Helios +# `with_default_block(latest)` fix). signer-local stays standalone below to keep +# the `mnemonic` feature explicit. +alloy = { version = "1", features = ["provider-http", "contract", "sol-types", "ens", "eips"] } alloy-signer-local = { version = "2.0.5", features = ["mnemonic"] } alloy-primitives = "1.6.0" +# Embedded Helios light client (verified localhost JSON-RPC server). Git-only, tag +# "0.11.1" (crates.io is stale at 0.1.0); the umbrella `helios` crate pulls a yanked +# core2, so depend on `helios-ethereum` directly. Heavy (revm/bls12_381) — gated +# behind `verified-reads`. The `ethereum_hashing` patch it needs is at the workspace +# root (a git-dep can't carry its own `[patch]`). +helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1", optional = true } + # A single background tokio runtime owns all network; the GUI thread never makes # a network call. `rt` (current-thread) only — no multi-thread worker pool needed. tokio = { version = "1", features = ["rt", "macros", "sync"] } diff --git a/crates/deckard-core/examples/smoke.rs b/crates/deckard-core/examples/smoke.rs index b817eb5..2d3677a 100644 --- a/crates/deckard-core/examples/smoke.rs +++ b/crates/deckard-core/examples/smoke.rs @@ -19,7 +19,10 @@ fn main() { println!("{name} -> {addr}"); match eth.portfolio(addr).recv() { - Ok(Ok(p)) => { + Ok(Ok(read)) => { + // The trust label the read carries (Helios-Verified vs Unsynced). + println!("read status: {}", read.status); + let p = read.value; println!("ETH: {}", format_amount(p.native_wei, 18, 6)); for t in &p.tokens { println!("{:>5}: {}", t.symbol, format_amount(t.raw, t.decimals, 4)); diff --git a/crates/deckard-core/src/eth.rs b/crates/deckard-core/src/eth.rs index 4f1f7d7..002e9d2 100644 --- a/crates/deckard-core/src/eth.rs +++ b/crates/deckard-core/src/eth.rs @@ -3,30 +3,78 @@ //! requests in and gets a `flume::Receiver` back; it awaits that receiver on its own //! executor (`cx.spawn`), so a slow RPC never stalls a frame. //! -//! v0 points at a public mainnet RPC by default (overridable in settings). The -//! trustless default — a bundled Helios light client serving localhost — is the next -//! increment per the spec; swapping it in is just a different URL passed to `spawn`. +//! ## Verified reads (the `verified-reads` feature, ON by default) +//! +//! When `verified-reads` is on, the worker stands up an **embedded Helios light +//! client** (see [`crate::helios`]) whose localhost JSON-RPC server is what the alloy +//! provider reads through — every read is proof-checked. The `rpc_url` passed to +//! [`EthProvider::spawn`] becomes the *execution-layer* endpoint Helios proves against +//! (it must serve `eth_getProof`); it is no longer read directly. Each read is tagged +//! with a [`ReadStatus`]: `Verified` when a fresh Helios head backs it, `Unsynced` +//! otherwise. +//! +//! When `verified-reads` is OFF, the worker keeps the original raw-RPC path but tags +//! every read `ReadStatus::Unsynced("verification disabled")` — it never claims a raw +//! read is Verified. +//! +//! Threading model (eng-review decision, preserved): a *single* background tokio +//! current-thread runtime owns every network call — including Helios's spawned +//! localhost server task, which runs cooperatively on the same runtime. The GUI never +//! blocks and never touches tokio. +//! +//! TODO(post-v1): v1 runs an INDEPENDENT Helios instance per reader (this one + the +//! daemon's). The "consolidate all reads into the daemon" refactor is deferred. +//! TODO(post-v1): if Helios's server task starves under load on the shared +//! current-thread runtime, consider `new_multi_thread`. Do NOT switch preemptively — +//! it would break the "single current-thread runtime" decision without proven need. use alloy::ens::ProviderEnsExt; use alloy::primitives::{Address, U256}; use alloy::providers::{DynProvider, Provider, ProviderBuilder}; +use deckard_contract::ReadStatus; + use crate::balances::{fetch_portfolio, Portfolio}; -/// A reliable public mainnet RPC, used until the bundled Helios light client lands. -/// Overridable via settings (bring-your-own-RPC). +/// A reliable public mainnet RPC, used as the execution-layer endpoint Helios proves +/// against (or, with `verified-reads` off, read directly). Overridable via settings. pub const DEFAULT_RPC: &str = "https://ethereum-rpc.publicnode.com"; /// The reply half of a request: the worker sends the result here; the caller awaits it. type Reply = flume::Sender>; +/// A value read off-chain, with the trust label that read carries. Returned to the UI +/// so it can render the verified/unsynced state alongside the value. +#[derive(Clone, Debug)] +pub struct Read { + pub value: T, + pub status: ReadStatus, +} + +impl Read { + fn new(value: T, status: ReadStatus) -> Self { + Self { value, status } + } +} + /// Typed requests the GUI sends to the network worker. Each carries its own reply /// channel so call sites stay ergonomic and unrelated requests never head-of-line block. enum EthReq { - Balance { addr: Address, reply: Reply }, - BlockNumber { reply: Reply }, - Portfolio { addr: Address, reply: Reply }, - ResolveName { name: String, reply: Reply
}, + Balance { + addr: Address, + reply: Reply>, + }, + BlockNumber { + reply: Reply>, + }, + Portfolio { + addr: Address, + reply: Reply>, + }, + ResolveName { + name: String, + reply: Reply
, + }, } /// A cloneable handle to the network worker thread. Clone it freely into UI views; @@ -37,8 +85,9 @@ pub struct EthProvider { } impl EthProvider { - /// Spawn the network worker pointed at `rpc_url`. Never blocks; the runtime and - /// the alloy provider are built on the worker thread. + /// Spawn the network worker pointed at `rpc_url`. Never blocks; the runtime, the + /// embedded Helios client (when `verified-reads` is on), and the alloy provider are + /// all built on the worker thread. pub fn spawn(rpc_url: impl Into) -> Self { let rpc_url = rpc_url.into(); let (tx, rx) = flume::unbounded::(); @@ -49,25 +98,26 @@ impl EthProvider { Self { tx } } - /// Fetch the native ETH balance (wei) of `addr`. Returns immediately; the caller - /// awaits the receiver on its own executor. A dead worker resolves to an error - /// rather than hanging. - pub fn balance(&self, addr: Address) -> flume::Receiver> { + /// Fetch the native ETH balance (wei) of `addr`, with its trust label. Returns + /// immediately; the caller awaits the receiver on its own executor. A dead worker + /// resolves to an error rather than hanging. + pub fn balance(&self, addr: Address) -> flume::Receiver>> { self.request(|reply| EthReq::Balance { addr, reply }) } - /// Fetch the latest block number — a cheap liveness/sync probe for the status line. - pub fn block_number(&self) -> flume::Receiver> { + /// Fetch the latest block number (a cheap liveness/sync probe) with its trust label. + pub fn block_number(&self) -> flume::Receiver>> { self.request(|reply| EthReq::BlockNumber { reply }) } /// Fetch the full portfolio (native + listed ERC-20 balances) in one Multicall3 - /// round-trip. Non-blocking; await the receiver on the UI executor. - pub fn portfolio(&self, addr: Address) -> flume::Receiver> { + /// round-trip, with its trust label. Non-blocking; await on the UI executor. + pub fn portfolio(&self, addr: Address) -> flume::Receiver>> { self.request(|reply| EthReq::Portfolio { addr, reply }) } - /// Forward-resolve an ENS name (e.g. `vitalik.eth`) to an address. + /// Forward-resolve an ENS name (e.g. `vitalik.eth`) to an address. Not value-bearing, + /// so no trust label — the resulting address is then read with one. pub fn resolve_name(&self, name: impl Into) -> flume::Receiver> { let name = name.into(); self.request(|reply| EthReq::ResolveName { name, reply }) @@ -88,8 +138,8 @@ impl EthProvider { } } -/// The worker entry point: build the runtime + provider, then service requests until -/// every `EthProvider` handle has dropped (which closes `rx`). +/// The worker entry point: build the runtime + the read provider (verified or raw), +/// then service requests until every `EthProvider` handle has dropped (closing `rx`). fn run_worker(rpc_url: String, rx: flume::Receiver) { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -97,48 +147,174 @@ fn run_worker(rpc_url: String, rx: flume::Receiver) { .expect("build tokio current-thread runtime"); rt.block_on(async move { - // A bad URL yields `None`; we still drain the queue and answer every request - // with an error so the UI never hangs waiting on a reply that never comes. - let provider: Option = rpc_url - .parse() - .ok() - .map(|url| ProviderBuilder::new().connect_http(url).erased()); + let read_path = ReadPath::build(&rpc_url).await; while let Ok(req) = rx.recv_async().await { match req { EthReq::Balance { addr, reply } => { - let _ = reply.send(fetch_balance(provider.as_ref(), addr).await); + let _ = reply.send(read_path.balance(addr).await); } EthReq::BlockNumber { reply } => { - let _ = reply.send(fetch_block_number(provider.as_ref()).await); + let _ = reply.send(read_path.block_number().await); } EthReq::Portfolio { addr, reply } => { - let res = match provider.as_ref() { - Some(p) => fetch_portfolio(p, addr).await, - None => Err(anyhow::anyhow!("invalid RPC URL")), - }; - let _ = reply.send(res); + let _ = reply.send(read_path.portfolio(addr).await); } EthReq::ResolveName { name, reply } => { - let res = match provider.as_ref() { - Some(p) => p.resolve_name(&name).await.map_err(anyhow::Error::from), - None => Err(anyhow::anyhow!("invalid RPC URL")), - }; - let _ = reply.send(res); + let _ = reply.send(read_path.resolve_name(&name).await); } } } }); } -async fn fetch_balance(provider: Option<&DynProvider>, addr: Address) -> anyhow::Result { - let provider = provider.ok_or_else(|| anyhow::anyhow!("invalid RPC URL"))?; - Ok(provider.get_balance(addr).await?) +/// The worker's resolved read path. Holds the alloy provider it reads through and, when +/// `verified-reads` is on, the embedded Helios client that owns the localhost server +/// (kept alive for the worker's lifetime — its Drop tears the server down). +struct ReadPath { + /// `None` when the URL was unparseable / Helios failed to come up. Every read then + /// answers with an error or an `Unsynced` status (fail-closed; the UI never hangs). + provider: Option, + /// `None` → this is the verified Helios path: the trust label is re-derived per read + /// from Helios head freshness. `Some(reason)` → a non-verified path (Helios down or + /// the feature disabled): every read is tagged `Unsynced(reason)`, NEVER `Verified`. + unverified_reason: Option, + /// Keeps the embedded Helios localhost server alive. `None` for the raw path. + #[cfg(feature = "verified-reads")] + _helios: Option, +} + +impl ReadPath { + /// Build the read path on the worker thread, inside the worker's tokio runtime. + #[cfg(feature = "verified-reads")] + async fn build(rpc_url: &str) -> Self { + // The configured RPC is now the EXECUTION-layer endpoint Helios proves against + // (it must serve eth_getProof) — never read directly. CL drives the sync. + let data_dir = crate::config::config_dir() + .map(|d| d.join("helios")) + .unwrap_or_else(|| std::path::PathBuf::from(".deckard-helios")); + + match crate::helios::launch_verified( + crate::helios::DEFAULT_CONSENSUS_RPC, + rpc_url, + data_dir, + ) + .await + { + Ok(reader) => { + // Clone the verified localhost provider out for the read handlers; the + // VerifiedReader is retained so the server task stays alive. + let provider = reader.provider().clone(); + Self { + provider: Some(provider), + unverified_reason: None, // verified path: label by head freshness + _helios: Some(reader), + } + } + Err(e) => { + // Helios never came up: serve reads as Unsynced. We do NOT fall back to a + // raw read of the (untrusted) RPC and call it Verified. We still build a + // raw provider so values can be shown, but always tagged Unsynced with an + // honest reason. + let reason = format!("helios unavailable: {}", one_line(&e)); + let provider = rpc_url + .parse() + .ok() + .map(|url| ProviderBuilder::new().connect_http(url).erased()); + Self { + provider, + unverified_reason: Some(reason), + _helios: None, + } + } + } + } + + /// Feature-off build: the original raw-RPC path, always tagged Unsynced. + #[cfg(not(feature = "verified-reads"))] + async fn build(rpc_url: &str) -> Self { + let provider = rpc_url + .parse() + .ok() + .map(|url| ProviderBuilder::new().connect_http(url).erased()); + Self { + provider, + unverified_reason: Some("verification disabled".to_string()), + } + } + + /// The trust label for a read taken now. On the verified path (`unverified_reason == + /// None`), re-derive from the Helios head (a head gone stale mid-session downgrades to + /// Unsynced). Otherwise the fixed honest reason. NEVER returns Verified without a + /// fresh Helios head behind it. + /// + /// Each value-bearing read (`balance`/`block_number`/`portfolio`) reads the value FIRST + /// and then calls `status()` — so a `Verified` tag is bound to a head observed *after* + /// the value came back (the daemon's read path uses the same ordering). A small + /// time-of-check/time-of-use window remains between the two round-trips: a head could go + /// stale in the gap. This is an accepted v1 limitation; it always fails toward "a fresh + /// verified head backed the value", never toward a false `Verified`. + async fn status(&self) -> ReadStatus { + match &self.unverified_reason { + None => { + #[cfg(feature = "verified-reads")] + if let Some(reader) = &self._helios { + return reader.head_status().await; + } + // Defensive: a verified path with no client shouldn't happen. + ReadStatus::unsynced("verification unavailable") + } + Some(reason) => ReadStatus::unsynced(reason.clone()), + } + } + + async fn balance(&self, addr: Address) -> anyhow::Result> { + let provider = self + .provider + .as_ref() + .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?; + let value = provider.get_balance(addr).await?; + Ok(Read::new(value, self.status().await)) + } + + async fn block_number(&self) -> anyhow::Result> { + let provider = self + .provider + .as_ref() + .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?; + let value = provider.get_block_number().await?; + Ok(Read::new(value, self.status().await)) + } + + async fn portfolio(&self, addr: Address) -> anyhow::Result> { + let provider = self + .provider + .as_ref() + .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?; + let value = fetch_portfolio(provider, addr).await?; + Ok(Read::new(value, self.status().await)) + } + + async fn resolve_name(&self, name: &str) -> anyhow::Result
{ + let provider = self + .provider + .as_ref() + .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?; + provider.resolve_name(name).await.map_err(anyhow::Error::from) + } } -async fn fetch_block_number(provider: Option<&DynProvider>) -> anyhow::Result { - let provider = provider.ok_or_else(|| anyhow::anyhow!("invalid RPC URL"))?; - Ok(provider.get_block_number().await?) +/// Collapse a multi-line error into one short line for a `reason` string. Only the +/// verified-reads build constructs a reason from an error. +#[cfg(feature = "verified-reads")] +fn one_line(e: &impl std::fmt::Display) -> String { + e.to_string() + .lines() + .next() + .unwrap_or("") + .chars() + .take(160) + .collect() } #[cfg(test)] @@ -146,23 +322,42 @@ mod tests { use super::*; use alloy::providers::mock::Asserter; - /// The provider abstraction reads a balance off a mocked transport — no network, - /// deterministic, fast. Proves the decode path without hitting a real RPC. - #[tokio::test] - async fn balance_reads_from_mocked_transport() { - let asserter = Asserter::new(); - asserter.push_success(&U256::from(31_415u64)); + /// Build a ReadPath over a mocked transport (no network, deterministic). Reads are + /// tagged Unsynced because there is no Helios behind a mock — the hard rule holds. + fn mocked_path(asserter: Asserter) -> ReadPath { let provider = ProviderBuilder::new() .connect_mocked_client(asserter) .erased(); + ReadPath { + provider: Some(provider), + unverified_reason: Some("test (no helios)".to_string()), + #[cfg(feature = "verified-reads")] + _helios: None, + } + } + + /// The read path decodes a balance off a mocked transport and attaches a status. + #[tokio::test] + async fn balance_reads_from_mocked_transport_with_status() { + let asserter = Asserter::new(); + asserter.push_success(&U256::from(31_415u64)); + let path = mocked_path(asserter); - let bal = fetch_balance(Some(&provider), Address::ZERO).await.unwrap(); - assert_eq!(bal, U256::from(31_415u64)); + let read = path.balance(Address::ZERO).await.unwrap(); + assert_eq!(read.value, U256::from(31_415u64)); + // No Helios behind a mock → never Verified. + assert!(!read.status.is_trustworthy()); } - /// A bad RPC URL fails closed with an error rather than panicking or hanging. + /// A missing provider fails closed with an error rather than panicking or hanging. #[tokio::test] - async fn invalid_url_errors_cleanly() { - assert!(fetch_balance(None, Address::ZERO).await.is_err()); + async fn no_provider_errors_cleanly() { + let path = ReadPath { + provider: None, + unverified_reason: Some("test (no provider)".to_string()), + #[cfg(feature = "verified-reads")] + _helios: None, + }; + assert!(path.balance(Address::ZERO).await.is_err()); } } diff --git a/crates/deckard-core/src/helios.rs b/crates/deckard-core/src/helios.rs new file mode 100644 index 0000000..016f530 --- /dev/null +++ b/crates/deckard-core/src/helios.rs @@ -0,0 +1,279 @@ +//! Stand up an embedded **verified** Helios light client whose localhost JSON-RPC +//! server is the endpoint an alloy provider reads through — so every chain read is +//! proof-checked instead of trusting a raw vendor RPC. +//! +//! Lifted from the verified `eip1193-railgun` spike (`spikes/eip1193-railgun/src/helios.rs`), +//! ported from `eyre` → `anyhow` and trimmed to the helios-only consumer Deckard needs. +//! +//! Verified against `a16z/helios @ 0.11.1`: +//! * `EthereumClientBuilder::rpc_address(SocketAddr)` records a bind addr; +//! * on `.build()`, the client `tokio::spawn`s the localhost JSON-RPC server, +//! serving the `eth_*` subset — every read proof-checked. +//! * `.build()` is sync but MUST run inside a tokio runtime (it spawns the server +//! task). Both callers (deckard-core's EthProvider worker and deckard-signerd's +//! daemon) own a tokio runtime, so this holds. +//! +//! `wait_synced()` ≠ ready: after it returns, the first execution head lands ~1 slot +//! later (≤12s); until then every `Latest` read fails the 60s `check_head_age` gate. +//! So we poll `get_block_number()` until `Ok` before declaring the read path live. +//! +//! THE one-line consumer fix (see [`connect_verified_provider`]): build the alloy +//! provider with `.with_default_block(BlockId::latest())`. alloy's `Provider::call` +//! defaults the block tag to `pending`, which a light client cannot serve +//! ("block not found: pending") — this rewrites the default to `latest` so the +//! Multicall3 / ENS `eth_call` reads work. +//! +//! TODO(post-v1): v1 runs an INDEPENDENT Helios instance per reader (one behind +//! deckard-core::EthProvider, one in the daemon). The "consolidate all reads into +//! the daemon" refactor is deferred. The failover/community-checkpoint supervisor +//! (spikes/helios-walkaway/src/upstreams.rs) that would emit `ReadStatus::Degraded` +//! is also deferred — v1 runs a single client per reader. + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use alloy::eips::BlockId; +use alloy::primitives::U256; +use alloy::providers::{DynProvider, Provider, ProviderBuilder}; +use anyhow::{anyhow, Result}; +use deckard_contract::ReadStatus; +use helios_ethereum::config::networks::Network; +use helios_ethereum::database::FileDB; +use helios_ethereum::{EthereumClient, EthereumClientBuilder}; + +/// A consensus-layer (beacon) endpoint that actually drives a Helios sync. Nimbus's +/// public testing beacon API is the spike's proven default; dRPC +/// (`https://eth-beacon-chain.drpc.org`) is the documented alternate. +pub const DEFAULT_CONSENSUS_RPC: &str = "http://testing.mainnet.beacon-api.nimbus.team"; + +/// A live, verified Helios read path: the localhost provider an alloy consumer reads +/// through, plus the owning `EthereumClient` whose Drop tears down the spawned server. +/// +/// **The `_client` field is load-bearing**: dropping it kills the spawned localhost +/// JSON-RPC server task. Keep this struct alive for as long as reads are served. +pub struct VerifiedReader { + /// The localhost provider, already built with the `with_default_block(latest)` fix. + provider: DynProvider, + /// The `http://127.0.0.1:` URL the localhost JSON-RPC server is bound at — so a + /// caller (e.g. the daemon's `signing::read_balance`) can build its OWN consumer + /// provider against the same verified server. + localhost_url: String, + /// Owns the spawned localhost JSON-RPC server task; must outlive `provider`. + _client: EthereumClient, +} + +impl VerifiedReader { + /// Borrow the verified localhost provider (alloy, `with_default_block(latest)`). + pub fn provider(&self) -> &DynProvider { + &self.provider + } + + /// The verified localhost JSON-RPC URL (`http://127.0.0.1:`). Reads through + /// this are proof-checked by Helios. + pub fn localhost_url(&self) -> &str { + &self.localhost_url + } + + /// Compute the trust label for a read taken *now*: `Verified` only when the Helios + /// head is fresh (age ≤ 60s), else `Unsynced`. v1 never emits `Degraded` here — that + /// is the deferred failover/community-checkpoint path (see the module TODO). + /// + /// Called once per read so a head that goes stale mid-session is caught: a value is + /// only ever labelled `Verified` when a fresh verified head actually backs it. + /// + /// We fetch the *latest block by tag* (not a bare `eth_blockNumber`): fetching the + /// `Latest` block exercises Helios's own `check_head_age` (60s) gate AND lets us derive + /// freshness from the block's timestamp directly, rather than trusting that a stored + /// height implies a fresh head. A bare height call can be answered by a stalled-but- + /// not-yet-expired client and would over-report `Verified`; the timestamp check closes + /// that gap. + pub async fn head_status(&self) -> ReadStatus { + let block = match self.provider.get_block(BlockId::latest()).await { + Ok(Some(b)) => b, + // No latest block: either still syncing or the head aged out of the gate. + Ok(None) => return ReadStatus::unsynced("helios head unavailable: no latest block"), + Err(e) => { + return ReadStatus::unsynced(format!("helios head unavailable: {}", one_line(&e))) + } + }; + + let head_ts = block.header.timestamp; + let now = now_unix(); + // `now` can legitimately be < head_ts by a few seconds (clock skew / a head minted + // slightly ahead); saturating_sub treats that as age 0, never as a stale read. + let age = now.saturating_sub(head_ts); + if age <= MAX_HEAD_AGE_SECS { + ReadStatus::Verified + } else { + ReadStatus::unsynced(format!("helios head stale ({age}s > {MAX_HEAD_AGE_SECS}s)")) + } + } +} + +/// Helios's own hard freshness gate for `Latest` reads (`check_head_age`, 60s). We mirror +/// it here so a value is labelled `Verified` only when its backing head is within the gate. +const MAX_HEAD_AGE_SECS: u64 = 60; + +/// Current wall-clock UNIX time in seconds. Used only to compare against the verified +/// head's block timestamp for the freshness label. +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Build a verified Helios **mainnet** client, launch its localhost JSON-RPC server, +/// and return a [`VerifiedReader`] only once the server is actually serving a fresh +/// verified head. +/// +/// * `consensus_rpc` — the beacon (CL) endpoint that drives the sync (e.g. Nimbus). +/// * `execution_rpc` — the EL endpoint Helios proves against (must serve `eth_getProof`). +/// This is the *untrusted* RPC the app was previously reading directly — now it only +/// feeds proofs that Helios verifies. +/// * `data_dir` — FileDB cache dir → warm starts from a cached checkpoint. +/// +/// On any failure (sync timeout, server never binds, head never fresh) returns an +/// `Err` — the caller MUST then serve reads tagged `Unsynced`, NEVER fall back to a +/// raw RPC and call it `Verified`. +pub async fn launch_verified( + consensus_rpc: &str, + execution_rpc: &str, + data_dir: PathBuf, +) -> Result { + let port = free_loopback_port()?; + let rpc_addr: SocketAddr = ([127, 0, 0, 1], port).into(); + let helios_url = format!("http://127.0.0.1:{port}"); + + let client = build_with_server(consensus_rpc, execution_rpc, data_dir, rpc_addr)?; + + // CL checkpoint bootstrapped... + client + .wait_synced() + .await + .map_err(|e| anyhow!("helios wait_synced: {e}"))?; + // ...then the typed client serves a fresh execution head (the honest "ready" moment; + // wait_synced alone isn't it — the first head lands ~1 slot later). + wait_until_serving(&client, Duration::from_secs(60)).await?; + + // Build the CONSUMER provider against the localhost server, with THE fix. + let provider = connect_verified_provider(&helios_url).await?; + + // Prove the spawned localhost server is actually answering (and the consumer + // provider's default-block layer works) before we declare the path live. + wait_provider_live(&provider, Duration::from_secs(30)).await?; + + Ok(VerifiedReader { + provider, + localhost_url: helios_url, + _client: client, + }) +} + +/// Build a verified Helios mainnet client whose localhost JSON-RPC server is bound at +/// `rpc_addr`. FileDB → warm starts from a cached checkpoint. Sync, but must run inside +/// a tokio runtime (it spawns the server task on `.build()`). +fn build_with_server( + cl: &str, + el: &str, + data_dir: PathBuf, + rpc_addr: SocketAddr, +) -> Result { + EthereumClientBuilder::::new() + .network(Network::Mainnet) + .consensus_rpc(cl) + .map_err(|e| anyhow!("helios consensus_rpc {cl:?}: {e}"))? + .execution_rpc(el) + .map_err(|e| anyhow!("helios execution_rpc: {e}"))? + .data_dir(data_dir) + // strict: refuse a too-old checkpoint (hard failure, never a silent stale read). + .strict_checkpoint_age() + // No user-pinned checkpoint → community fallback (ethPandaOps). v1 labels reads + // off this path Verified-by-freshness; the Degraded community-checkpoint + // distinction is a deferred supervisor concern (see module TODO). + .load_external_fallback() + // THE mechanism the whole verified path rests on: spawn the localhost JSON-RPC + // server on build() so an alloy HTTP provider can read through it. + .rpc_address(rpc_addr) + .with_file_db() + .build() + .map_err(|e| anyhow!("helios build: {e}")) +} + +/// Build the **consumer** alloy provider that reads through Helios's localhost server. +/// +/// THE one-line fix: `.with_default_block(BlockId::latest())`. alloy's `Provider::call` +/// defaults the block tag to `pending`; Helios (a light client) has no pending block and +/// 404s on it ("block not found: pending"). This rewrites the default to `latest` so the +/// Multicall3 `aggregate3` (portfolio) and ENS `eth_call` reads succeed. Applied +/// uniformly so every read path is identical; plain `get_balance`/`get_block_number` +/// reads are unaffected but harmless to layer. +async fn connect_verified_provider(helios_url: &str) -> Result { + let url = helios_url + .parse() + .map_err(|e| anyhow!("bad helios url {helios_url:?}: {e}"))?; + Ok(ProviderBuilder::new() + .with_default_block(BlockId::latest()) + .connect_http(url) + .erased()) +} + +/// Block until the typed client serves a fresh verified head (the honest "ready to serve +/// verified reads" moment — `wait_synced` returning is NOT it). +async fn wait_until_serving(client: &EthereumClient, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + match client.get_block_number().await { + Ok(h) => return Ok(h), + Err(e) => { + if Instant::now() > deadline { + return Err(anyhow!("helios: no fresh head within {timeout:?}: {e}")); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + } +} + +/// Poll the CONSUMER alloy provider (built against the localhost server) until it answers +/// `eth_blockNumber` — proving the spawned `jsonrpc::start` task has bound AND that the +/// consumer provider talks to it. Uses the alloy provider directly so we don't pull in +/// `reqwest`/`serde_json` just for a liveness probe (the spike used reqwest). +async fn wait_provider_live(provider: &DynProvider, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + match provider.get_block_number().await { + Ok(n) => return Ok(n), + Err(e) => { + if Instant::now() > deadline { + return Err(anyhow!( + "helios localhost server never answered via the consumer provider: {e}" + )); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + } + } +} + +/// Grab a free loopback port by binding an ephemeral socket and dropping it. Helios +/// discards the jsonrpsee `ServerHandle`, so a `:0` port can't be recovered after the +/// fact — pick a concrete one up front. (Small TOCTOU window; acceptable.) +fn free_loopback_port() -> Result { + let l = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = l.local_addr()?.port(); + drop(l); + Ok(port) +} + +/// Collapse a multi-line error into one short line for a `reason` string. +fn one_line(e: &impl std::fmt::Display) -> String { + e.to_string() + .lines() + .next() + .unwrap_or("") + .chars() + .take(160) + .collect() +} diff --git a/crates/deckard-core/src/lib.rs b/crates/deckard-core/src/lib.rs index 0d4fd81..7bcbab4 100644 --- a/crates/deckard-core/src/lib.rs +++ b/crates/deckard-core/src/lib.rs @@ -12,15 +12,25 @@ pub mod balances; pub mod config; pub mod eth; +/// Embedded Helios light client → verified localhost reads. Gated behind the +/// default-on `verified-reads` feature so the heavy revm/bls build is toggleable. +#[cfg(feature = "verified-reads")] +pub mod helios; pub mod keystore; pub mod tokens; pub use balances::{fetch_portfolio, format_amount, Portfolio, TokenBalance}; pub use config::{config_dir, policy_path, vault_path}; -pub use eth::{EthProvider, DEFAULT_RPC}; +pub use eth::{EthProvider, Read, DEFAULT_RPC}; +#[cfg(feature = "verified-reads")] +pub use helios::{launch_verified, VerifiedReader, DEFAULT_CONSENSUS_RPC}; pub use keystore::{random_word_positions, KdfParams, SecretKind, UnlockedVault, Vault, WordCount}; pub use tokens::{TokenInfo, DEFAULT_TOKENS}; +// The shared trust label, re-exported so the app + daemon can name it through core +// without a direct deckard-contract dependency just to render a read status. +pub use deckard_contract::ReadStatus; + // Re-export the alloy primitive types the UI renders, so the app layer doesn't // need a direct alloy dependency just to name an `Address` or a `U256`. pub use alloy_primitives::{Address, U256}; diff --git a/crates/deckard-signerd/Cargo.toml b/crates/deckard-signerd/Cargo.toml index a19c378..d37f491 100644 --- a/crates/deckard-signerd/Cargo.toml +++ b/crates/deckard-signerd/Cargo.toml @@ -15,10 +15,19 @@ path = "src/lib.rs" name = "deckard-signerd" path = "src/main.rs" +[features] +# Verified reads via the embedded Helios light client (shared launcher in deckard-core). +# ON by default; threads through to deckard-core's `verified-reads`. When OFF, the +# daemon's balance read falls back to the raw RPC, tagged Unsynced("verification disabled"). +default = ["verified-reads"] +verified-reads = ["deckard-core/verified-reads"] + [dependencies] # The frozen wire contract (Intent / Decision / Policy / RPC + the shared `evaluate`). deckard-contract = { path = "../deckard-contract" } -# The headless engine: the keystore (`Vault`/`UnlockedVault`) we reuse — never rebuilt here. +# The headless engine: the keystore (`Vault`/`UnlockedVault`) we reuse — never rebuilt +# here — plus the shared Helios launcher (`launch_verified`) behind `verified-reads`. +# `default-features = false` is NOT set, so deckard-core's default `verified-reads` is on. deckard-core = { path = "../deckard-core" } # Async UDS server + framing. multi-thread rt so the Argon2 unlock can run on the blocking @@ -35,7 +44,7 @@ serde_json = "1" # B256 scalar crosses the boundary). Features mirror deckard-core (default features on, which # already bring the rustls TLS backend) so the workspace shares ONE alloy build + TLS stack — # no second TLS backend, no feature drift. -alloy = { version = "1", features = ["provider-http", "network", "rpc-types", "signer-local"] } +alloy = { version = "1", features = ["provider-http", "network", "rpc-types", "signer-local", "eips"] } alloy-primitives = { workspace = true } # Peer-cred uid (geteuid) + the single-instance flock. diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index 3e2ffb9..d12f862 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -10,14 +10,18 @@ //! any execute whose STOP landed first. use std::collections::HashMap; +#[cfg(feature = "verified-reads")] +use std::sync::Arc; use std::time::{Duration, Instant}; use alloy_primitives::{Address, B256, U256}; +#[cfg(feature = "verified-reads")] +use tokio::sync::Mutex as AsyncMutex; use zeroize::Zeroizing; use deckard_contract::{ evaluate, ApprovalStatus, BalanceReport, Decision, ExecuteResult, Intent, IntentKind, Policy, - RequestId, SignerRequest, SignerResponse, UnlockOutcome, + ReadStatus, RequestId, SignerRequest, SignerResponse, UnlockOutcome, }; use deckard_core::{UnlockedVault, Vault}; @@ -65,6 +69,57 @@ struct PendingReq { /// rather than wedging the daemon (and STOP) forever behind the held state lock. const BROADCAST_TIMEOUT: Duration = Duration::from_secs(30); +/// The daemon's embedded Helios verified-read path, kept in a **separately-locked** cell so +/// the multi-second-to-90s `launch_verified` bootstrap can run WITHOUT holding the daemon's +/// own `Mutex`. The server clones this `Arc` and primes it (see [`HeliosCell::ensure`]) off +/// the daemon lock before dispatching a `Balance`, so a slow first read can never serialize +/// behind it — the STOP/Lock brake stays responsive. +/// +/// Its own `Drop` (via the inner `VerifiedReader`) tears the spawned localhost server down. +/// `None` inside the option means "not built / failed to come up" — reads then fall back, +/// tagged Unsynced, never silently Verified. +/// +/// TODO(post-v1): v1 runs an INDEPENDENT Helios instance here (separate from the app's +/// deckard-core::EthProvider one). The "consolidate all reads into the daemon" refactor is +/// deferred. +#[cfg(feature = "verified-reads")] +#[derive(Clone, Default)] +pub struct HeliosCell { + inner: Arc>>, +} + +#[cfg(feature = "verified-reads")] +impl HeliosCell { + fn new() -> Self { + Self::default() + } + + /// Bootstrap the embedded Helios client if it isn't up yet. Runs the long + /// `launch_verified` while holding ONLY this cell's lock — never the daemon's — so the + /// security brake (STOP/Lock) and every other request stay live during the bootstrap. + /// Idempotent: a second caller that finds the client already built returns immediately. + /// On failure leaves the cell empty and logs (so the read falls back to a raw, + /// Unsynced-tagged read — never a silent Verified). + pub async fn ensure( + &self, + consensus_rpc: &str, + execution_rpc: &str, + data_dir: std::path::PathBuf, + ) { + let mut guard = self.inner.lock().await; + if guard.is_some() { + return; + } + match deckard_core::launch_verified(consensus_rpc, execution_rpc, data_dir).await { + Ok(reader) => *guard = Some(reader), + Err(e) => { + // Stays None → the read path falls back to a raw, Unsynced read. + eprintln!("signerd: helios bootstrap failed (reads tagged unsynced): {}", one_line(&e)); + } + } + } +} + /// The whole daemon: config, the lock state, the live policy (with in-memory daily spend), /// and the request table. pub struct Daemon { @@ -76,6 +131,13 @@ pub struct Daemon { /// Lifetime of a `NeedsApproval` record. approval_ttl: Duration, requests: HashMap, + /// The daemon's embedded Helios verified-read path, held in a SEPARATELY-locked + /// [`HeliosCell`] so its slow bootstrap never blocks the daemon mutex (see the cell's + /// docs). The server primes it off the daemon lock before a `Balance` dispatch; the + /// `balance` handler then borrows the already-built reader for the quick read. Cloning + /// the `Arc` is cheap and lets the server hold a handle without the daemon lock. + #[cfg(feature = "verified-reads")] + helios: HeliosCell, } impl Daemon { @@ -89,9 +151,30 @@ impl Daemon { spent_day: current_utc_day(), approval_ttl: approval_ttl(), requests: HashMap::new(), + #[cfg(feature = "verified-reads")] + helios: HeliosCell::new(), } } + /// A clone of the daemon's [`HeliosCell`] handle, so the server can prime the Helios + /// bootstrap OFF the daemon lock before dispatching a `Balance` (keeping the STOP/Lock + /// brake responsive — the long bootstrap never holds the daemon mutex). + #[cfg(feature = "verified-reads")] + pub fn helios_cell(&self) -> HeliosCell { + self.helios.clone() + } + + /// The (consensus_rpc, execution_rpc, data_dir) the embedded Helios client bootstraps + /// with. Exposed so the server can prime the cell off the daemon lock. + #[cfg(feature = "verified-reads")] + pub fn helios_bootstrap_args(&self) -> (&'static str, String, std::path::PathBuf) { + ( + deckard_core::DEFAULT_CONSENSUS_RPC, + self.cfg.rpc_url.clone(), + self.cfg.config_dir.join("helios-signerd"), + ) + } + /// Dispatch one request to one response. `async` because `execute`/`balance` do network /// I/O and `unlock` runs Argon2 on the blocking pool. pub async fn handle(&mut self, req: SignerRequest) -> SignerResponse { @@ -401,8 +484,18 @@ impl Daemon { } } - /// Public balance via the RPC (key-less). `shielded_wei` is 0 until T-Privacy. A locked - /// daemon reports zeros (it doesn't know which address to read). + /// Public balance, key-less. `shielded_wei` is 0 until T-Privacy. + /// + /// With `verified-reads` on (the default), the read goes through the daemon's own + /// embedded Helios light client (built lazily here) and is tagged + /// [`ReadStatus::Verified`] only when a fresh Helios head backs it. If Helios isn't + /// up / the head is stale / the read fails, the value is tagged `Unsynced` — we + /// **stop the old silent `.unwrap_or(ZERO)`-as-truth**: a 0 is no longer reported as + /// a trusted balance. With the feature off, the read goes through the raw RPC and is + /// always tagged `Unsynced("verification disabled")` — never `Verified`. + /// + /// A locked daemon doesn't know which address to read, so it reports zeros tagged + /// `Unsynced("locked")` — honest non-verification, not a trusted zero. async fn balance(&mut self, _shielded: bool) -> BalanceReport { self.rollover(); let addr = match &self.state { @@ -411,15 +504,76 @@ impl Daemon { return BalanceReport { public_wei: U256::ZERO, shielded_wei: U256::ZERO, + read_status: ReadStatus::unsynced("locked"), } } }; - let public_wei = signing::read_balance(&self.cfg.rpc_url, addr) - .await - .unwrap_or(U256::ZERO); + + let (public_wei, read_status) = self.read_public_balance(addr).await; BalanceReport { public_wei, shielded_wei: U256::ZERO, + read_status, + } + } + + /// Resolve the read endpoint + trust label, then read the native balance. Verified + /// path: reuse the embedded Helios client (already primed off the daemon lock by the + /// server — see [`HeliosCell`]), read through its localhost server, and label by head + /// freshness. Feature-off / Helios-down: read the raw RPC, label `Unsynced`. NEVER + /// returns `Verified` without a fresh Helios-verified read. + /// + /// The configured rpc_url is the EXECUTION-layer endpoint Helios proves against (must + /// serve eth_getProof); Nimbus drives the CL sync (deckard-core's default). + #[cfg(feature = "verified-reads")] + async fn read_public_balance(&mut self, addr: Address) -> (U256, ReadStatus) { + // The server primes the cell off-lock before dispatch, so this is normally a fast + // already-built borrow. `ensure` here is a defensive no-op fallback for callers + // (e.g. unit tests) that drive `handle` directly without the server priming first. + let (cl, el, data_dir) = self.helios_bootstrap_args(); + self.helios.ensure(cl, &el, data_dir).await; + + let guard = self.helios.inner.lock().await; + let reader = match guard.as_ref() { + Some(reader) => reader, + None => { + // Helios isn't up. Read the raw RPC so a value can be shown, but tag it + // Unsynced — we do NOT claim a raw read is Verified. + drop(guard); + let wei = signing::read_balance(&self.cfg.rpc_url, addr) + .await + .unwrap_or(U256::ZERO); + return (wei, ReadStatus::unsynced("helios unavailable")); + } + }; + // Read the value FIRST, then derive its freshness label, so a `Verified` tag is + // bound to a head observed *after* the value came back (consistent with the + // app-side path in deckard-core::eth). A small TOCTOU window remains between the + // two round-trips, but it always fails toward "fresh head backed the value". + let read_url = reader.localhost_url().to_string(); + match signing::read_balance(&read_url, addr).await { + Ok(wei) => { + // head_status() re-probes Helios freshness; a head gone stale → Unsynced. + let status = reader.head_status().await; + (wei, status) + } + Err(e) => ( + U256::ZERO, + ReadStatus::unsynced(format!("verified read failed: {}", one_line(&e))), + ), + } + } + + /// Feature-off path: read the raw RPC directly, always tagged Unsynced — never claim + /// a raw read is Verified. + #[cfg(not(feature = "verified-reads"))] + async fn read_public_balance(&mut self, addr: Address) -> (U256, ReadStatus) { + match signing::read_balance(&self.cfg.rpc_url, addr).await { + Ok(wei) => (wei, ReadStatus::unsynced("verification disabled")), + Err(e) => ( + U256::ZERO, + ReadStatus::unsynced(format!("read failed: {}", one_line(&e))), + ), } } diff --git a/crates/deckard-signerd/src/server.rs b/crates/deckard-signerd/src/server.rs index da1f020..dac41a4 100644 --- a/crates/deckard-signerd/src/server.rs +++ b/crates/deckard-signerd/src/server.rs @@ -75,6 +75,20 @@ async fn handle_conn(mut stream: UnixStream, daemon: Arc>) -> anyh } }; + // A `Balance` read needs the embedded Helios light client. Its first-time bootstrap + // (`launch_verified`) can take seconds-to-90s; prime it HERE, OFF the daemon lock, so + // the long bootstrap never serializes ahead of the security brake (STOP/Lock) or any + // other request. The cell is idempotent and separately locked — after this returns, + // the daemon's `balance` handler does only the quick verified read under its mutex. + #[cfg(feature = "verified-reads")] + if matches!(req, SignerRequest::Balance { .. }) { + let (cell, (cl, el, data_dir)) = { + let d = daemon.lock().await; + (d.helios_cell(), d.helios_bootstrap_args()) + }; + cell.ensure(cl, &el, data_dir).await; + } + // Dispatch behind the shared lock (serializes all requests). Note: we deliberately // never log the request contents — an Unlock passphrase must never reach a log line. let resp = daemon.lock().await.handle(req).await; diff --git a/crates/deckard-signerd/src/signing.rs b/crates/deckard-signerd/src/signing.rs index a41b28c..e17f030 100644 --- a/crates/deckard-signerd/src/signing.rs +++ b/crates/deckard-signerd/src/signing.rs @@ -10,7 +10,7 @@ //! thing that crosses), and we reconstruct the signer in *this* alloy stack from those //! bytes. The scalar is held in a `Zeroizing` buffer by the caller. -use alloy::network::{EthereumWallet, TransactionBuilder}; +use alloy::network::{Ethereum, EthereumWallet, TransactionBuilder}; use alloy::providers::{Provider, ProviderBuilder}; use alloy::rpc::types::TransactionRequest; use alloy::signers::local::PrivateKeySigner; @@ -39,10 +39,16 @@ pub async fn broadcast_native_send( // Only `to`/`value` set ⇒ the gas filler produces an EIP-1559 (type-2) tx and fills the // fee fields; the nonce filler uses the pending count; chain id is pinned explicitly. - let tx = TransactionRequest::default() - .with_to(to) - .with_value(value_wei) - .with_chain_id(chain_id); + // + // The `TransactionBuilder` methods are disambiguated to alloy's `Ethereum` network: + // pulling helios-ethereum into the tree (via deckard-core's `verified-reads`) adds a + // second `TransactionBuilder` impl for + // `TransactionRequest`, so the chained builder calls would otherwise be ambiguous. + // Setting via `&mut` with an `Ethereum`-typed binding anchors every call to alloy's impl. + let mut tx = TransactionRequest::default(); + >::set_to(&mut tx, to); + >::set_value(&mut tx, value_wei); + >::set_chain_id(&mut tx, chain_id); let pending = provider .send_transaction(tx) @@ -51,12 +57,22 @@ pub async fn broadcast_native_send( Ok(*pending.tx_hash()) } -/// Read an address's public (native) balance through the RPC — key-less, read-only. -pub async fn read_balance(rpc_url: &str, addr: Address) -> anyhow::Result { - let url = rpc_url +/// Read an address's public (native) balance through `read_url` — key-less, read-only. +/// +/// `read_url` is the endpoint the consumer provider reads through. With verified reads +/// on (the default) the daemon passes Helios's **localhost** URL here, so this read is +/// proof-checked; with the feature off it is the raw RPC (and the caller tags the result +/// `Unsynced`). The `with_default_block(latest)` fix is applied uniformly: alloy defaults +/// `eth_call`/`estimateGas` to the `pending` tag, which a Helios light client cannot +/// serve — `get_balance` itself targets `latest`, but layering the default keeps every +/// read path uniform with the eth_call-backed reads. +pub async fn read_balance(read_url: &str, addr: Address) -> anyhow::Result { + let url = read_url .parse() - .map_err(|e| anyhow::anyhow!("bad RPC URL {rpc_url:?}: {e}"))?; - let provider = ProviderBuilder::new().connect_http(url); + .map_err(|e| anyhow::anyhow!("bad read URL {read_url:?}: {e}"))?; + let provider = ProviderBuilder::new() + .with_default_block(alloy::eips::BlockId::latest()) + .connect_http(url); provider .get_balance(addr) .await From c30cdd40dc21dfce37f3d7ab76d52c3a707e9b4c Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 6 Jun 2026 16:27:12 +0200 Subject: [PATCH 06/10] =?UTF-8?q?feat(railgun):=20shield=20spike=20?= =?UTF-8?q?=E2=80=94=20R1=20retired,=20proving=20is=20on=20spend=20not=20s?= =?UTF-8?q?hield?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone spike (spikes/shield-railgun/) ports kohaku's transact_utxo.rs (rev 618c53f) against an anvil Sepolia fork @ 10822990 through a plain alloy provider, and runs the full shield→sync→balance→transfer→unshield with the EXACT upstream numeric asserts — all GREEN from our own dep edge: shield 997_500 · shield_native 1_097_250 · transfer 1_092_250/5_000 · unshield 1_091_250/5_000 + EOA WETH 998. (independently re-run, 1 passed, 32.8s) R1d answered (the "is instant auto-shield honest?" question): YES, and for the right reason. Railgun SHIELD does NO client ZK proof — ~2–13 ms (note-encrypt + ABI-encode); the contract verifies the commitment on-chain. Groth16 proving lives entirely on the SPEND: transfer ~11.7s / unshield ~9.6s cold-debug, ~halved by the `parallel` feature (~5.4 / ~4.3s). So "instant shield, prove-on-spend" is a faithful UX; the slow path (unshield) is off-camera fast-follow. Retires R1c (railgun standalone-consumable — full crate links + runs from our edge). Gotchas captured: the `railgun/testing` feature is required for external consumers (gates SubsquidSyncer::with_latest_block); `.with_poi()` constructs but credits None on a local fork (defaults to no-POI, WITH_POI=1 opt-in). Adversarial review (Codex + manual) caught + fixed a P1: two SHIELD timers were mislabeled "proof build" when shields do no proving — relabeled, R1d framing corrected. Asserts verified genuine (hard assert_eq! vs upstream constants, not weakened). Doc 10-kohaku-shield.md updated (status + R1d/proving resolved). --- docs/build/10-kohaku-shield.md | 7 +- spikes/shield-railgun/.gitignore | 2 + spikes/shield-railgun/Cargo.toml | 63 +++++ spikes/shield-railgun/src/lib.rs | 320 ++++++++++++++++++++++++++ spikes/shield-railgun/tests/shield.rs | 24 ++ 5 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 spikes/shield-railgun/.gitignore create mode 100644 spikes/shield-railgun/Cargo.toml create mode 100644 spikes/shield-railgun/src/lib.rs create mode 100644 spikes/shield-railgun/tests/shield.rs diff --git a/docs/build/10-kohaku-shield.md b/docs/build/10-kohaku-shield.md index 6ed92c4..3e48bc9 100644 --- a/docs/build/10-kohaku-shield.md +++ b/docs/build/10-kohaku-shield.md @@ -1,6 +1,6 @@ # Kohaku / Railgun Shield Integration -> Auto-shield received funds into an owner-only private balance using Kohaku's pure-Rust `railgun` crate · serves demo beat 2 (HERO "receive → instantly private") and acceptance step 2 (`shield(amount)` → private ↑, public ↓, link broken) · status: spec. Part of the Deckard build docs. +> Auto-shield received funds into an owner-only private balance using Kohaku's pure-Rust `railgun` crate · serves demo beat 2 (HERO "receive → instantly private") and acceptance step 2 (`shield(amount)` → private ↑, public ↓, link broken) · status: **R1 spike GREEN** — full shield→sync→balance→transfer→unshield runs from our own dep edge with the exact upstream asserts (`spikes/shield-railgun/`); **shield is instant (no client proof); ZK proving is on the *spend*, not the shield**. Part of the Deckard build docs. ## Why this exists (2-4 sentences, concrete) @@ -161,14 +161,15 @@ The exact numeric asserts (`997_500`, `5_000`, `998`) are copied from the verifi - **R1a — alpha API churn.** `0.0.1-alpha.x` (latest `alpha.22`, 2026-05-26); the Rust crate is `0.1.0` and unpublished. *Mitigation:* git-pin a specific commit `rev`; vendor the crate if needed. Do not track `master`. - **R1b — mainnet reliability of the alpha crate.** *Fallback (a):* shield on **Sepolia** for the video (`ChainConfig::sepolia()`), keep the Helios walkaway beat on mainnet — explicitly sanctioned by `v1-demo-plan.md`. The upstream test is itself Sepolia, so Sepolia is the better-trodden path. - **R1c — crate not standalone-consumable / build breaks.** Largely *retired* by the verified `rlib` + alloy + integration tests, but if the workspace `[patch]` deps or edition-2024 toolchain fight Deckard's build: *Fallback (b):* a thin Node bridge to `@kohaku-eth/railgun@0.0.1-alpha.22` (MIT, published, WASM) spoken to over the daemon socket — slower and adds a JS runtime, last resort. -- **R1d — proving cost makes "instant" a lie.** *Mitigation:* `parallel` feature + pre-warm; UI shows a "shielding…" state. *Fallback:* shrink the demo amount / pre-shield a warm pool note so the on-camera proof is a 1-out path. +- ~~**R1d — proving cost makes "instant" a lie.**~~ → **RESOLVED (`spikes/shield-railgun/`, 2026-06-06):** the shield is **instant by design** — `ShieldBuilder::build` does **no** client ZK proof, only note-encrypt + ABI-encode (~2–13 ms); the contract verifies the commitment on-chain. The Groth16 proving cost lives entirely on the **spend** (transfer/unshield): ~9.6–11.7 s cold (debug), roughly **halved** by the `parallel` feature (~4.3–5.4 s). So "instant auto-shield, prove-on-spend" is honest; spend (unshield) is off-camera fast-follow. (Cold numbers include first-use artifact download; warm/release will be faster.) Still apply: `parallel` + pre-warm + a "spending…" state for unshield. - **R1e — licensing.** Crate `Cargo.toml` has **no `license` field**; root `package.json` and npm say **MIT**. Deckard is 0BSD. MIT is compatible to vendor/depend on, but **confirm the Rust crate inherits MIT** (open a clarifying issue / check the eventual crate publish) before shipping. ⚠ partial: per-crate license not explicitly declared in-tree. - **R1f — Subsquid/broadcaster centralization.** UTXO sync leans on a Subsquid endpoint and broadcast leans on a 4337 bundler — both are network deps that aren't Helios. For the demo, sync is a read of public events (acceptable); shield (the hero) needs no broadcaster. Flag for the "walkaway" narrative: shield-on-receive itself only needs the EOA + the pool contract. - **Alternate shielded path (c):** **Privacy Pools** (`@kohaku-eth/privacy-pools`, live on mainnet since Mar 2025) if Railgun is unworkable — but it's marked WIP in the SDK and uses the opposite (allowlist-inclusion) compliance model, so treat as a true last resort, not a drop-in. ## Open questions -- **Proving wall-clock on a desktop:** how long does `ShieldBuilder::build()` / `RailgunProvider::build(tx)` take for a 1-in/2-out shield on an M-series Mac, with and without `parallel`? This sets the "instant" UX claim. (Bench in the R1 spike with `criterion` — the crate already ships `benches/`.) ⚠ unmeasured. +- ~~**Proving wall-clock on a desktop:**~~ → **measured (`spikes/shield-railgun/`):** shield `ShieldBuilder::build` does no proof (~2–13 ms, encrypt+ABI only); spend `RailgunProvider::build` proving = transfer ~11.7 s / unshield ~9.6 s cold-debug, ~halved with `parallel` (~5.4 / ~4.3 s). Re-measure on the demo machine in `--release` (warm artifacts) for the final UX number. +- **Spike gotchas (for the integration):** the `railgun` crate's `testing` feature is **required** by external consumers to reach `SubsquidSyncer::with_latest_block` (capping Subsquid at the fork block) — it's `#[cfg(any(test, feature="testing"))]` at rev 618c53f. And `.with_poi()` *constructs* fine but credits `None` balance on a local fork (the live POI service never sees fork commitments), so the spike defaults to no-POI for honest asserts with `WITH_POI=1` to exercise the path. - **Does the crate's EIP-1193 provider accept Helios cleanly,** or does it need methods Helios doesn't serve (e.g. heavy log ranges for UTXO sync that Helios proxies but Subsquid actually answers)? Verify in the `20-helios-sidecar.md` integration. ⚠ unverified. - **Per-crate license:** does `railgun` (no `license` field) inherit the monorepo MIT for a downstream Rust dependency? ⚠ partial. - **Mainnet broadcaster availability:** is there a public Railgun 4337 bundler/broadcaster Deckard can use for unshield, or must we run one? (Not needed for v1 shield-on-receive; needed for the unshield fast-follow.) ⚠ unverified. diff --git a/spikes/shield-railgun/.gitignore b/spikes/shield-railgun/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/spikes/shield-railgun/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/spikes/shield-railgun/Cargo.toml b/spikes/shield-railgun/Cargo.toml new file mode 100644 index 0000000..bec8997 --- /dev/null +++ b/spikes/shield-railgun/Cargo.toml @@ -0,0 +1,63 @@ +# Deckard SHIELD spike (beat 2) — de-risk the hero auto-shield action. +# +# Proves Kohaku's pure-Rust `railgun` crate does a full +# shield -> sync -> balance -> transfer -> unshield +# from OUR OWN dependency edge, against a LOCAL anvil fork of Sepolia +# (block 10822990), with the EXACT upstream numeric asserts so any +# regression in our edge is loud. +# +# KEY DELIVERABLE (R1d): measure the proof-generation wall-clock for the +# 1-in/2-out shield, default features vs the railgun `parallel` feature. +# That number decides whether "instant auto-shield" is honest. +# +# Standalone crate (empty [workspace] table) so the heavy ZK tree +# (ark-circom / wasmer / groth16) stays out of the app workspace at the +# repo root. Dep edge copied verbatim from spikes/eip1193-railgun. +[package] +name = "shield-railgun" +version = "0.1.0" +edition = "2021" +publish = false + +# Standalone — keep this spike's heavy deps out of the repo-root workspace. +[workspace] + +[features] +default = [] +# Forward railgun's `parallel` feature (ark-* rayon parallelism) so we can +# measure proving wall-clock BOTH ways for R1d. +parallel = ["railgun/parallel"] + +[dependencies] +# Kohaku's full Railgun client — the ZK crate. Plain (non-optional) dep: +# the shield spike always needs it. Same git rev as the proven eip1193 edge +# so the [patch.crates-io] set resolves identically. +# +# `testing` feature is REQUIRED here (the dissection said otherwise, but the +# checked-out source at 618c53f gates `SubsquidSyncer::with_latest_block` +# — the call the upstream test uses to cap Subsquid at the fork block — +# behind `#[cfg(any(test, feature = "testing"))]`. Upstream gets it via the +# crate's own `cfg(test)`; as an EXTERNAL consumer we must opt into `testing`). +railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7", features = ["testing"] } + +# Match Kohaku's alloy (resolves to 1.8.3 / alloy-primitives 1.6.0 so the +# whole tree unifies to ONE alloy). Add signer-local + contract + sol-types +# for the upstream test's EOA signer and the WETH sol! interface. +alloy = { version = "1.8", features = ["eips", "rpc-types", "network", "providers", "provider-http", "sol-types", "signer-local", "contract"] } + +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync"] } +rand = "0.9" # upstream uses rand::random() and rand::rng() +eyre = "0.6" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# [patch] does NOT inherit through a git dependency, so mirror Kohaku's +# workspace patches or the ZK tree fails to build. ruint + ark-circom are +# MANDATORY (the merkle tree / circom-compat won't build without them). +# ethereum_hashing was only needed by Helios in eip1193-railgun; kept here +# harmlessly (an unused patch is just a warning) for parity with the +# proven edge. +[patch.crates-io] +ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" } +ruint = { git = "https://github.com/Robert-MacWha/ruint" } +ark-circom = { git = "https://github.com/Robert-MacWha/circom-compat", branch = "release/0.6.0" } diff --git a/spikes/shield-railgun/src/lib.rs b/spikes/shield-railgun/src/lib.rs new file mode 100644 index 0000000..55ac2e7 --- /dev/null +++ b/spikes/shield-railgun/src/lib.rs @@ -0,0 +1,320 @@ +//! Deckard SHIELD spike (beat 2) — de-risk the hero auto-shield action. +//! +//! Ported VERBATIM (numeric asserts) from kohaku +//! `crates/railgun/tests/integration/transact_utxo.rs` @ rev 618c53f, but run +//! against a LOCAL anvil fork of Sepolia (block 10822990) through a PLAIN alloy +//! provider (NOT Helios — that seam is proven separately in eip1193-railgun). +//! +//! Run it (anvil must already be forking Sepolia @ 10822990 on 127.0.0.1:8545): +//! cargo test -- --ignored --nocapture (default features) +//! cargo test --features parallel -- --ignored --nocapture (parallel) +//! +//! Env: +//! RPC_URL_SEPOLIA — Sepolia archive RPC anvil forks from (only needed if you +//! let anvil read more history; the fork is already pinned). +//! WITH_POI=1 — graft RailgunBuilder::with_poi() onto the construction to +//! exercise the PPOI path. NOTE: POI gates spends to notes the +//! POI provider marked `spendable`; on a local fork that +//! provider can't mark our fresh notes, so the transfer / +//! unshield numeric asserts only hold WITHOUT poi. Default +//! (no WITH_POI) keeps the 4 asserts honest; WITH_POI=1 proves +//! the .with_poi() construction edge compiles + builds. +//! +//! R1d (the key deliverable) — "is instant auto-shield honest?": +//! +//! YES, but for a specific reason that the timers below make explicit. +//! A Railgun SHIELD requires NO client-side ZK proof. `ShieldBuilder::build` +//! (kohaku shield_builder.rs:54) only does symmetric note encryption +//! (`encrypt_shield`) + `abi_encode` of the `shield`/`multicall` calldata — +//! there is no `Groth16Prover`, no witness calc, no `prove_transact` on the +//! shield path. The contract verifies the commitments on-chain. So shield IS +//! genuinely instant (single-digit-ms build in this debug spike) because it +//! skips proving, NOT because proving is fast. +//! +//! The proving cost the user actually pays is on the SPEND (transfer / +//! unshield), which routes `railgun.build(tx, rng)` through +//! `RailgunProvider::build` -> `build_operation` -> `prove_transact` +//! (groth16 witness + create + verify, plus a cold artifact download the +//! first time a given circuit size is used). THOSE are the numbers that bound +//! user-perceived proving latency. +//! +//! Therefore: +//! - The two SHIELD timers below are labelled "calldata/encrypt build +//! (NO zk proof)" — they are NOT proving time, by design. +//! - The TRANSFER and UNSHIELD timers are the REAL R1d proving wall-clock. +//! The first measured spend is COLD (it includes the proving-key/matrices +//! download + brotli decompress for that circuit size); subsequent spends +//! of the SAME circuit size are warm (artifacts are LRU-cached by URL). +//! transfer and unshield use different circuit sizes, so each pays its own +//! cold download. Compare these numbers default-vs-parallel. + +use std::{str::FromStr, sync::Arc}; + +use alloy::{ + network::Ethereum, + primitives::{address, U256}, + providers::{Provider, ProviderBuilder}, + signers::local::PrivateKeySigner, + sol, +}; +use railgun::{ + account::signer::RailgunSigner, + builder::RailgunBuilder, + caip::AssetId, + chain_config::ChainConfig, + indexer::syncer::{ChainedSyncer, RpcSyncer, SubsquidSyncer}, + transact::TransactionBuilder, +}; +use rand::random; +use tracing::info; + +sol! { + #[sol(rpc)] + // WETH interface + contract WETH { + function approve(address guy, uint256 wad) external returns (bool); + function balanceOf(address input) external view returns (uint256); + function deposit() external payable; + } +} + +const ANVIL_RPC: &str = "http://127.0.0.1:8545"; +// anvil dev key #0 — already funded with ETH on the fork. +const EOA_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const FORK_BLOCK: u64 = 10_822_990; + +/// Full shield -> shield_native -> transfer -> unshield scenario with the EXACT +/// upstream numeric asserts. Panics (test fails / process exits non-zero) on any +/// failed assert. Returns nothing — the asserts ARE the proof. +pub async fn run_shield_scenario() -> eyre::Result<()> { + let with_poi = std::env::var("WITH_POI").map(|v| v == "1").unwrap_or(false); + let parallel = cfg!(feature = "parallel"); + info!(with_poi, parallel, "shield-railgun spike start"); + println!( + "=== shield-railgun spike (parallel_feature={parallel}, with_poi={with_poi}) ===" + ); + + let chain = ChainConfig::sepolia(); + let weth = AssetId::Erc20(chain.wrapped_base_token); + + // --- plain alloy provider over the local anvil fork --- + let signer = PrivateKeySigner::from_str(EOA_KEY).unwrap(); + let provider = ProviderBuilder::new() + .network::() + .wallet(signer) + .connect(ANVIL_RPC) + .await? + .erased(); + + let weth_contract = WETH::new(chain.wrapped_base_token, provider.clone()); + + // --- Railgun construction: chained Subsquid(capped at fork) + RPC syncer --- + let syncer = Arc::new( + ChainedSyncer::new() + .then(SubsquidSyncer::new(&chain.subsquid_endpoint).with_latest_block(FORK_BLOCK)) + .then(RpcSyncer::new(chain.clone(), provider.clone()).with_batch_size(1000)), + ); + + let mut builder = RailgunBuilder::new(chain.clone(), provider.clone()).with_utxo_syncer(syncer); + if with_poi { + // Design ask: exercise the PPOI construction path. with_poi() takes no args. + builder = builder.with_poi(); + } + let mut railgun = builder.build().await.map_err(|e| eyre::eyre!("build: {e}"))?; + + info!("railgun constructed (with_poi={with_poi})"); + + // --- 2 railgun (0zk) accounts: random spending/viewing keys --- + let account_1 = + railgun::account::signer::PrivateKeySigner::new_evm(random(), random(), chain.id); + let account_2 = + railgun::account::signer::PrivateKeySigner::new_evm(random(), random(), chain.id); + railgun.register(account_1.clone()).await.unwrap(); + railgun.register(account_2.clone()).await.unwrap(); + + // --- fund + approve WETH (raw wei units, not 1e18-scaled) --- + weth_contract + .deposit() + .value(U256::from(2_000_000)) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + weth_contract + .approve(chain.railgun_smart_wallet, U256::from(2_000_000)) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // ===================== 1. SHIELD 1_000_000 ===================== + info!("step 1: shield 1_000_000"); + // NOTE: ShieldBuilder::build does NO zk proof — only encrypt_shield + abi_encode. + // Shields are unproven on the client (contract-verified). This timer is + // note-encryption + ABI-encode wall-clock, NOT proving time. See R1d header. + let t0 = std::time::Instant::now(); + let shield_tx = railgun + .shield() + .shield(account_1.address(), weth, 1_000_000) + .build(&mut rand::rng()) + .unwrap(); + let shield_build_ms = t0.elapsed().as_millis(); + println!( + "R1d SHIELD calldata/encrypt build (NO zk proof — shields are unproven): {shield_build_ms} ms (parallel={parallel})" + ); + + for tx in shield_tx { + provider + .send_transaction(tx.into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + } + railgun.sync().await.unwrap(); + + let balance_1 = railgun.balance(account_1.address()).await; + let balance_2 = railgun.balance(account_2.address()).await; + println!( + " after shield: acct1[weth]={:?} acct2[weth]={:?} (expect Some(997_500), None)", + balance_1.get(&weth), + balance_2.get(&weth) + ); + assert_eq!(balance_1.get(&weth), Some(&997_500), "shield acct1 balance"); + assert_eq!(balance_2.get(&weth), None, "shield acct2 balance"); + + // ===================== 2. SHIELD NATIVE 100_000 ===================== + info!("step 2: shield_native 100_000"); + // NOTE: shield_native also does NO zk proof (same ShieldBuilder::build path, + // via RelayAdapt wrapBase + shield multicall). This is encrypt + abi_encode + // wall-clock, NOT proving time. See R1d header. + let t0 = std::time::Instant::now(); + let shield_tx = railgun + .shield() + .shield_native(account_1.address(), 100_000) + .build(&mut rand::rng()) + .unwrap(); + println!( + "R1d SHIELD_NATIVE calldata/encrypt build (NO zk proof): {} ms (parallel={parallel})", + t0.elapsed().as_millis() + ); + + for tx in shield_tx { + provider + .send_transaction(tx.into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + } + railgun.sync().await.unwrap(); + + let balance_1 = railgun.balance(account_1.address()).await; + let balance_2 = railgun.balance(account_2.address()).await; + println!( + " after shield_native: acct1[weth]={:?} acct2[weth]={:?} (expect Some(1_097_250), None)", + balance_1.get(&weth), + balance_2.get(&weth) + ); + assert_eq!( + balance_1.get(&weth), + Some(&1_097_250), + "shield_native acct1 balance" + ); + assert_eq!(balance_2.get(&weth), None, "shield_native acct2 balance"); + + // ===================== 3. TRANSFER 5_000 (acct1 -> acct2) ===================== + info!("step 3: transfer 5_000"); + let tx = TransactionBuilder::new().transfer( + account_1.clone(), + account_2.address(), + weth, + 5_000, + "test transfer", + ); + // This IS the real R1d proving wall-clock: railgun.build -> build_operation + // -> prove_transact -> groth16 (witness + create + verify). COLD: includes + // the first download+brotli-decompress of this circuit size's proving key + + // matrices (LRU-cached by URL thereafter). + let t0 = std::time::Instant::now(); + let transfer_tx = railgun.build(tx, &mut rand::rng()).await.unwrap(); + println!( + "R1d TRANSFER zk proof build (COLD: download+witness+prove+verify): {} ms (parallel={parallel})", + t0.elapsed().as_millis() + ); + + provider + .send_transaction(transfer_tx.tx_data.into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + railgun.sync().await.unwrap(); + + let balance_1 = railgun.balance(account_1.address()).await; + let balance_2 = railgun.balance(account_2.address()).await; + println!( + " after transfer: acct1[weth]={:?} acct2[weth]={:?} (expect Some(1_092_250), Some(5_000))", + balance_1.get(&weth), + balance_2.get(&weth) + ); + assert_eq!( + balance_1.get(&weth), + Some(&1_092_250), + "transfer acct1 balance" + ); + assert_eq!(balance_2.get(&weth), Some(&5_000), "transfer acct2 balance"); + + // ===================== 4. UNSHIELD 1_000 (acct1 -> EOA) ===================== + info!("step 4: unshield 1_000"); + let eoa = address!("0xe03747a83E600c3ab6C2e16dd1989C9b419D3a86"); + let tx = TransactionBuilder::new() + .unshield(account_1.clone(), eoa, weth, 1_000) + .unwrap(); + // Real R1d proving wall-clock (same prove_transact path as transfer). Uses a + // DIFFERENT circuit size than transfer (different nullifier/commitment counts + // -> different railgun/NNxMM artifact URL), so this is its OWN cold download; + // the transfer run did not warm it. + let t0 = std::time::Instant::now(); + let unshield_tx = railgun.build(tx, &mut rand::rng()).await.unwrap(); + println!( + "R1d UNSHIELD zk proof build (COLD: download+witness+prove+verify): {} ms (parallel={parallel})", + t0.elapsed().as_millis() + ); + + provider + .send_transaction(unshield_tx.tx_data.into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + railgun.sync().await.unwrap(); + + let balance_1 = railgun.balance(account_1.address()).await; + let balance_2 = railgun.balance(account_2.address()).await; + let balance_eoa = weth_contract.balanceOf(eoa).call().await.unwrap(); + println!( + " after unshield: acct1[weth]={:?} acct2[weth]={:?} EOA.WETH={} (expect Some(1_091_250), Some(5_000), 998)", + balance_1.get(&weth), + balance_2.get(&weth), + balance_eoa + ); + assert_eq!( + balance_1.get(&weth), + Some(&1_091_250), + "unshield acct1 balance" + ); + assert_eq!(balance_2.get(&weth), Some(&5_000), "unshield acct2 balance"); + assert_eq!(balance_eoa, U256::from(998), "unshield EOA WETH balance"); + + println!("=== ALL 4 STEPS PASSED (shield/shield_native/transfer/unshield) ==="); + Ok(()) +} diff --git a/spikes/shield-railgun/tests/shield.rs b/spikes/shield-railgun/tests/shield.rs new file mode 100644 index 0000000..0470673 --- /dev/null +++ b/spikes/shield-railgun/tests/shield.rs @@ -0,0 +1,24 @@ +//! Runnable, self-asserting integration test for the SHIELD spike. +//! +//! Requires a local anvil forking Sepolia @ 10822990 on 127.0.0.1:8545: +//! anvil --fork-url $RPC_URL_SEPOLIA --fork-block-number 10822990 +//! +//! Run: +//! cargo test -- --ignored --nocapture (default) +//! cargo test --features parallel -- --ignored --nocapture (parallel, R1d) + +use tracing_subscriber::EnvFilter; + +#[tokio::test] +#[ignore = "network: needs anvil forking Sepolia @ 10822990 on 127.0.0.1:8545"] +async fn test_shield_transact_utxo() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_test_writer() + .try_init() + .ok(); + + shield_railgun::run_shield_scenario() + .await + .expect("shield scenario failed"); +} From a0a37fd234059e903b99b794c6bc52fcea4d07be Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 6 Jun 2026 17:32:03 +0200 Subject: [PATCH 07/10] feat(shield): key-less shield path wired into core + signerd + black-box anvil test (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beat-2 shield is now a first-class write in Deckard's own path. #1a deckard-core: `shield::build_shield_native_intent(chain_id, recipient, value) -> Intent{kind:Shield, to, value, calldata}` — KEY-LESS (a deposit needs only the recipient 0zk address, no spending key), behind a default-on `shield` Cargo feature. Feature-off compiles without railgun and returns an honest "shield unavailable" error. #1b deckard-signerd: `broadcast_intent` carries calldata (Shield/ContractCall); the execute path broadcasts Intent calldata+value+to. Native sends stay byte-identical (broadcast_native_send is now a thin &[] wrapper). The daemon stays ZK-free — it only signs+broadcasts the bytes it's handed. #1c crates/deckard-signerd/tests/shield_e2e.rs: a repeatable BLACK-BOX integration test (#[ignore], fresh anvil Sepolia fork @ 10822990) that drives Deckard's path end to end (core builder -> daemon propose/execute -> on-chain -> railgun sync) and hard-asserts the privacy property: private +997_500 (exact 25bps on 1_000_000), public down by value+gas. shield-only (fast, no proving/artifacts). Adversarial review (Codex + manual) fixed two P1s: - signerd `shield` feature was leaky (deckard-core dep lacked default-features=false, so the off-switch didn't drop railgun) -> fixed; railgun now absent from signerd's normal tree when off. - SECURITY: `Intent{kind:Shield, calldata: empty}` would have broadcast as a plain native send to an arbitrary `to` under the "Shield" label -> `calldata_ok` now requires non-empty calldata for Shield/Unshield/ContractCall (Decision::Deny{undecodable}); daemon + contract tests cover it. vendor/eip-1193-provider: a native-only fork of kohaku's eip-1193-provider (rev 618c53f, verbatim .rs, `js` dropped) + a workspace [patch]. Required because upstream's `js` feature pins wasm-bindgen=0.2.108 exact while the GPUI app's web-sys pins 0.2.122 exact — irreconcilable in one workspace; `js` is wasm32-only and unused. Maintenance + license (no upstream license field, like railgun) to revisit before ship. Verified GREEN with real cargo (pinned 1.95.0), rtk cache bypassed: build default + feature-off (railgun absent) + whole workspace incl. GPUI app; daemon_e2e 9/9 + parity 1/1 (STOP/zeroize + TOCTOU + Shield-Allow/empty-Shield-Deny intact); deckard-contract 32+1; shield_e2e re-run by me on a fresh fork (private +997500). Deferred (// TODO post-v1): receive-watcher, MCP, railgun key-derivation for balance-display, production HeliosEip1193, Unshield/ContractCall (Deny v1). --- Cargo.lock | 1701 ++++++++++++++++- Cargo.toml | 25 +- crates/deckard-contract/src/mock.rs | 16 +- crates/deckard-contract/src/policy.rs | 17 +- .../deckard-contract/tests/harness_slice.rs | 9 +- crates/deckard-core/Cargo.toml | 20 +- crates/deckard-core/src/lib.rs | 22 + crates/deckard-core/src/shield.rs | 125 ++ crates/deckard-signerd/Cargo.toml | 26 +- crates/deckard-signerd/src/daemon.rs | 23 +- crates/deckard-signerd/src/signing.rs | 36 +- crates/deckard-signerd/tests/common/mod.rs | 41 +- crates/deckard-signerd/tests/daemon_e2e.rs | 23 +- crates/deckard-signerd/tests/shield_e2e.rs | 234 +++ vendor/eip-1193-provider/Cargo.toml | 39 + vendor/eip-1193-provider/build.rs | 8 + vendor/eip-1193-provider/src/alloy.rs | 133 ++ vendor/eip-1193-provider/src/js.rs | 188 ++ vendor/eip-1193-provider/src/lib.rs | 6 + vendor/eip-1193-provider/src/provider.rs | 118 ++ vendor/eip-1193-provider/src/tx_data.rs | 20 + 21 files changed, 2735 insertions(+), 95 deletions(-) create mode 100644 crates/deckard-core/src/shield.rs create mode 100644 crates/deckard-signerd/tests/shield_e2e.rs create mode 100644 vendor/eip-1193-provider/Cargo.toml create mode 100644 vendor/eip-1193-provider/build.rs create mode 100644 vendor/eip-1193-provider/src/alloy.rs create mode 100644 vendor/eip-1193-provider/src/js.rs create mode 100644 vendor/eip-1193-provider/src/lib.rs create mode 100644 vendor/eip-1193-provider/src/provider.rs create mode 100644 vendor/eip-1193-provider/src/tx_data.rs diff --git a/Cargo.lock b/Cargo.lock index bbebb23..8811d46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ - "gimli", + "gimli 0.32.3", ] [[package]] @@ -113,7 +113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common 0.1.7", - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -128,6 +128,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -240,7 +254,7 @@ dependencies = [ "auto_impl", "borsh", "c-kzg", - "derive_more", + "derive_more 2.1.1", "either", "k256", "once_cell", @@ -267,7 +281,7 @@ dependencies = [ "auto_impl", "borsh", "c-kzg", - "derive_more", + "derive_more 2.1.1", "either", "k256", "once_cell", @@ -428,7 +442,7 @@ dependencies = [ "auto_impl", "borsh", "c-kzg", - "derive_more", + "derive_more 2.1.1", "either", "ethereum_ssz", "ethereum_ssz_derive", @@ -453,7 +467,7 @@ dependencies = [ "auto_impl", "borsh", "c-kzg", - "derive_more", + "derive_more 2.1.1", "either", "serde", "serde_with", @@ -550,7 +564,7 @@ dependencies = [ "alloy-sol-types", "async-trait", "auto_impl", - "derive_more", + "derive_more 2.1.1", "futures-utils-wasm", "serde", "serde_json", @@ -576,7 +590,7 @@ dependencies = [ "alloy-sol-types", "async-trait", "auto_impl", - "derive_more", + "derive_more 2.1.1", "futures-utils-wasm", "serde", "serde_json", @@ -619,7 +633,7 @@ dependencies = [ "bytes", "cfg-if", "const-hex", - "derive_more", + "derive_more 2.1.1", "foldhash 0.2.0", "getrandom 0.4.2", "hashbrown 0.17.1", @@ -771,7 +785,7 @@ dependencies = [ "alloy-eips 1.8.3", "alloy-primitives", "alloy-rpc-types-engine", - "derive_more", + "derive_more 2.1.1", "ethereum_ssz", "ethereum_ssz_derive", "serde", @@ -793,7 +807,7 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "alloy-serde 1.8.3", - "derive_more", + "derive_more 2.1.1", "ethereum_ssz", "ethereum_ssz_derive", "rand 0.8.6", @@ -1012,7 +1026,7 @@ dependencies = [ "alloy-json-rpc 1.8.3", "auto_impl", "base64 0.22.1", - "derive_more", + "derive_more 2.1.1", "futures", "futures-utils-wasm", "parking_lot 0.12.5", @@ -1050,7 +1064,7 @@ checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" dependencies = [ "alloy-primitives", "alloy-rlp", - "derive_more", + "derive_more 2.1.1", "nybbles", "serde", "smallvec", @@ -1103,7 +1117,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "object", + "object 0.37.3", ] [[package]] @@ -1150,7 +1164,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" dependencies = [ - "ark-ec", + "ark-ec 0.5.0", "ark-ff 0.5.0", "ark-serialize 0.5.0", "ark-std 0.5.0", @@ -1162,12 +1176,89 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" dependencies = [ - "ark-ec", + "ark-ec 0.5.0", "ark-ff 0.5.0", - "ark-r1cs-std", + "ark-r1cs-std 0.5.0", "ark-std 0.5.0", ] +[[package]] +name = "ark-bn254" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bc66f96ebe2a17a499475b4f94791d379817592ef494171586967ffdc6f95db" +dependencies = [ + "ark-ec 0.6.0", + "ark-ff 0.6.0", + "ark-std 0.6.0", +] + +[[package]] +name = "ark-circom" +version = "0.6.0" +source = "git+https://github.com/Robert-MacWha/circom-compat?branch=release%2F0.6.0#5c6abcdb0e788364e885e4206904288222e8f447" +dependencies = [ + "ark-bn254 0.6.0", + "ark-crypto-primitives", + "ark-ec 0.6.0", + "ark-ff 0.6.0", + "ark-groth16", + "ark-poly 0.6.0", + "ark-relations 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "byteorder", + "cfg-if", + "color-eyre", + "ethers-core", + "fnv", + "hex", + "num", + "num-bigint", + "num-traits", + "rayon", + "serde", + "serde_json", + "thiserror 2.0.18", + "wasmer", +] + +[[package]] +name = "ark-crypto-primitives" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b3409b1846fe459d19c95df039481575ac6d5842ae63858ad75cc31219bfc1" +dependencies = [ + "ahash", + "ark-crypto-primitives-macros", + "ark-ec 0.6.0", + "ark-ff 0.6.0", + "ark-r1cs-std 0.6.0", + "ark-relations 0.6.0", + "ark-serialize 0.6.0", + "ark-snark", + "ark-std 0.6.0", + "blake2", + "blake3", + "derivative", + "digest 0.10.7", + "fnv", + "merlin", + "num-bigint", + "sha2 0.10.9", +] + +[[package]] +name = "ark-crypto-primitives-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e89fe77d1f0f4fe5b96dfc940923d88d17b6a773808124f21e764dfb063c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-ec" version = "0.5.0" @@ -1176,7 +1267,7 @@ checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" dependencies = [ "ahash", "ark-ff 0.5.0", - "ark-poly", + "ark-poly 0.5.0", "ark-serialize 0.5.0", "ark-std 0.5.0", "educe", @@ -1189,6 +1280,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ark-ec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8352a2b2aedf6ba2cc38f7520fc51191d518dde96175c729af19f2d059f191c4" +dependencies = [ + "ahash", + "ark-ff 0.6.0", + "ark-poly 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "educe", + "fnv", + "hashbrown 0.17.1", + "itertools 0.14.0", + "num-bigint", + "num-integer", + "num-traits", + "zeroize", +] + [[package]] name = "ark-ff" version = "0.3.0" @@ -1247,6 +1359,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + [[package]] name = "ark-ff-asm" version = "0.3.0" @@ -1277,6 +1406,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-ff-macros" version = "0.3.0" @@ -1315,6 +1454,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-groth16" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a293328aa422e65527e285614ce5d1dceb0bd7b8b18d18b1b63191ee1f74cb41" +dependencies = [ + "ark-crypto-primitives", + "ark-ec 0.6.0", + "ark-ff 0.6.0", + "ark-poly 0.6.0", + "ark-relations 0.6.0", + "ark-serialize 0.6.0", + "ark-snark", + "ark-std 0.6.0", +] + [[package]] name = "ark-poly" version = "0.5.0" @@ -1330,15 +1498,30 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "ark-poly" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75f55af10b672002b8d953e230282c51206842e20e5791a94432219b4201de5c" +dependencies = [ + "ahash", + "ark-ff 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "educe", + "fnv", + "hashbrown 0.17.1", +] + [[package]] name = "ark-r1cs-std" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "941551ef1df4c7a401de7068758db6503598e6f01850bdb2cfdb614a1f9dbea1" dependencies = [ - "ark-ec", + "ark-ec 0.5.0", "ark-ff 0.5.0", - "ark-relations", + "ark-relations 0.5.1", "ark-std 0.5.0", "educe", "num-bigint", @@ -1347,6 +1530,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "ark-r1cs-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291f1c6628bfcac79b0dc2adbe401aa9100e2e96daa971645e0b18fc94de9a98" +dependencies = [ + "ark-ec 0.6.0", + "ark-ff 0.6.0", + "ark-relations 0.6.0", + "ark-std 0.6.0", + "educe", + "itertools 0.14.0", + "num-bigint", + "num-integer", + "num-traits", + "tracing", +] + [[package]] name = "ark-relations" version = "0.5.1" @@ -1359,6 +1560,22 @@ dependencies = [ "tracing-subscriber 0.2.25", ] +[[package]] +name = "ark-relations" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4c11c797a64b8a23e22bf4e77bf582ac27bb21395e3183a9a506ba2561e9f9" +dependencies = [ + "ark-ff 0.6.0", + "ark-poly 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "foldhash 0.1.5", + "indexmap 2.14.0", + "tracing", + "tracing-subscriber 0.3.23", +] + [[package]] name = "ark-serialize" version = "0.3.0" @@ -1386,13 +1603,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ - "ark-serialize-derive", + "ark-serialize-derive 0.5.0", "ark-std 0.5.0", "arrayvec", "digest 0.10.7", "num-bigint", ] +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + [[package]] name = "ark-serialize-derive" version = "0.5.0" @@ -1404,6 +1634,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-snark" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bdb461d2be9b2bd6f303c79fffc89f5858790a7b4d33257bca3178e2c071fb9" +dependencies = [ + "ark-ff 0.6.0", + "ark-relations 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", +] + [[package]] name = "ark-std" version = "0.3.0" @@ -1434,6 +1687,16 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.6", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -1721,7 +1984,7 @@ dependencies = [ "libc", "pin-project", "redox_syscall 0.2.16", - "xattr", + "xattr 0.2.3", ] [[package]] @@ -1943,7 +2206,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link 0.2.1", ] @@ -1990,6 +2253,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "beef" version = "0.5.2" @@ -1999,6 +2268,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.12.1", + "cexpr", + "clang-sys", + "itertools 0.10.5", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.117", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -2120,6 +2409,16 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake-hash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d218558dbce3a929ff02b6c12a22f4866afb46484d57a2fe2ad3ed358b90b6a" +dependencies = [ + "block-buffer 0.7.3", + "digest 0.8.1", +] + [[package]] name = "blake2" version = "0.10.6" @@ -2129,19 +2428,45 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" +dependencies = [ + "block-padding 0.1.5", + "byte-tools", + "byteorder", + "generic-array 0.12.4", +] + [[package]] name = "block-buffer" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -2150,7 +2475,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -2162,13 +2487,22 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" +dependencies = [ + "byte-tools", +] + [[package]] name = "block-padding" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -2312,23 +2646,74 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" [[package]] -name = "bytemuck" -version = "1.25.0" +name = "byte-tools" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" dependencies = [ - "bytemuck_derive", + "bytecheck_derive 0.6.12", + "ptr_meta 0.1.4", + "simdutf8", ] [[package]] -name = "bytemuck_derive" -version = "1.10.2" +name = "bytecheck" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive 0.8.2", + "ptr_meta 0.3.1", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 1.0.109", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -2699,11 +3084,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62b962ad8545e43a28e14e87377812ba9ae748dd4fd963f4c10e9fcc6d13475b" dependencies = [ "base64 0.21.7", - "bech32", + "bech32 0.9.1", "bs58", "const-hex", "digest 0.10.7", - "generic-array", + "generic-array 0.14.7", "ripemd", "serde", "sha2 0.10.9", @@ -2721,6 +3106,33 @@ dependencies = [ "rustc-hash 2.1.2", ] +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -2747,6 +3159,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "common" +version = "0.1.0" +source = "git+https://github.com/ethereum/kohaku?rev=618c53facd0d44cf0f01d74e0dcc18d2242351c7#618c53facd0d44cf0f01d74e0dcc18d2242351c7" +dependencies = [ + "gloo-timers 0.3.0", + "tokio", + "web-time", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -2827,6 +3249,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.10.0" @@ -2971,6 +3399,19 @@ dependencies = [ "libm", ] +[[package]] +name = "corosensei" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6886a0c0f263965933c438626e7179139a62b978a33aa18281cbf0cd5a975f34" +dependencies = [ + "autocfg", + "cfg-if", + "libc", + "scopeguard", + "windows-sys 0.59.0", +] + [[package]] name = "cosmic-text" version = "0.19.0" @@ -3013,6 +3454,95 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-bforest" +version = "0.110.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "305d51c180ebdc46ef61bc60c54ae6512db3bc9a05842a1f1e762e45977019ab" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.110.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "690d8ae6c73748e5ce3d8fe59034dceadb8823e6c8994ba324141c5eae909b0e" + +[[package]] +name = "cranelift-codegen" +version = "0.110.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd7ca95e831c18d1356da783765c344207cbdffea91e13e47fa9327dbb2e0719" +dependencies = [ + "bumpalo", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli 0.28.1", + "hashbrown 0.14.5", + "log", + "regalloc2", + "rustc-hash 1.1.0", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.110.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a2d2ab65e6cbf91f81781d8da65ec2005510f18300eff21a99526ed6785863" +dependencies = [ + "cranelift-codegen-shared", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.110.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efcff860573cf3db9ae98fbd949240d78b319df686cc306872e7fab60e9c84d7" + +[[package]] +name = "cranelift-control" +version = "0.110.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d70e5b75c2d5541ef80a99966ccd97aaa54d2a6af19ea31759a28538e1685a" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.110.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a48cb0a194c9ba82fec35a1e492055388d89b2e3c03dee9dcf2488892be8004d" +dependencies = [ + "cranelift-bitset", +] + +[[package]] +name = "cranelift-frontend" +version = "0.110.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8327afc6c1c05f4be62fefce5b439fa83521c65363a322e86ea32c85e7ceaf64" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.110.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56b08621c00321efcfa3eee6a3179adc009e21ea8d24ca7adc3c326184bc3f48" + [[package]] name = "crc" version = "3.4.0" @@ -3092,13 +3622,30 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto" +version = "0.1.0" +source = "git+https://github.com/ethereum/kohaku?rev=618c53facd0d44cf0f01d74e0dcc18d2242351c7#618c53facd0d44cf0f01d74e0dcc18d2242351c7" +dependencies = [ + "ark-bn254 0.6.0", + "ark-ff 0.6.0", + "blake-hash", + "num-bigint", + "num-traits", + "poseidon-rust", + "ruint", + "serde", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "crypto-bigint" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "subtle", "zeroize", @@ -3110,7 +3657,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] @@ -3134,6 +3681,42 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version 0.4.1", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.13.4" @@ -3154,6 +3737,16 @@ dependencies = [ "darling_macro 0.20.11", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + [[package]] name = "darling" version = "0.23.0" @@ -3192,6 +3785,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_core" version = "0.23.0" @@ -3228,6 +3834,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" @@ -3316,7 +3933,9 @@ dependencies = [ "directories", "flume", "helios-ethereum", + "railgun", "rand 0.8.6", + "rand 0.9.4", "tokio", "zeroize", ] @@ -3331,7 +3950,10 @@ dependencies = [ "ciborium", "deckard-contract", "deckard-core", + "eyre", "nix 0.29.0", + "railgun", + "rand 0.9.4", "serde", "serde_json", "tokio", @@ -3386,13 +4008,44 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl", + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3419,13 +4072,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "digest" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +dependencies = [ + "generic-array 0.12.4", +] + [[package]] name = "digest" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -3593,6 +4255,30 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "educe" version = "0.6.0" @@ -3605,6 +4291,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "eip-1193-provider" +version = "0.1.0" +dependencies = [ + "alloy", + "async-trait", + "cfg_aliases", + "common", + "hex", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "either" version = "1.16.0" @@ -3624,7 +4323,7 @@ dependencies = [ "crypto-bigint", "digest 0.10.7", "ff", - "generic-array", + "generic-array 0.14.7", "group", "pkcs8", "rand_core 0.6.4", @@ -3675,13 +4374,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enum-iterator" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eeac5c5edb79e4e39fe8439ef35207780a11f69c52cbe424ce3dfad4cb78de6" +dependencies = [ + "enum-iterator-derive 0.7.0", +] + [[package]] name = "enum-iterator" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" dependencies = [ - "enum-iterator-derive", + "enum-iterator-derive 1.5.0", +] + +[[package]] +name = "enum-iterator-derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c134c37760b27a871ba422106eedbb8247da973a09e82558bf26d619c882b159" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -3736,6 +4455,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equator" version = "0.4.2" @@ -3793,6 +4533,54 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "ethabi" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7413c5f74cc903ea37386a8965a936cbeb334bd270862fdece542c1b2dcbc898" +dependencies = [ + "ethereum-types", + "hex", + "once_cell", + "regex", + "serde", + "serde_json", + "sha3 0.10.9", + "thiserror 1.0.69", + "uint", +] + +[[package]] +name = "ethbloom" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c22d4b5885b6aa2fe5e8b9329fb8d232bf739e434e6b87347c63bdd00c120f60" +dependencies = [ + "crunchy", + "fixed-hash", + "impl-codec", + "impl-rlp", + "impl-serde", + "scale-info", + "tiny-keccak", +] + +[[package]] +name = "ethereum-types" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d215cbf040552efcbe99a38372fe80ab9d00268e20012b79fcd0f073edd8ee" +dependencies = [ + "ethbloom", + "fixed-hash", + "impl-codec", + "impl-rlp", + "impl-serde", + "primitive-types", + "scale-info", + "uint", +] + [[package]] name = "ethereum_hashing" version = "0.7.0" @@ -3842,6 +4630,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ethers-core" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d80cc6ad30b14a48ab786523af33b37f28a8623fc06afd55324816ef18fb1f" +dependencies = [ + "arrayvec", + "bytes", + "chrono", + "const-hex", + "elliptic-curve", + "ethabi", + "generic-array 0.14.7", + "k256", + "num_enum", + "open-fastrlp", + "rand 0.8.6", + "rlp", + "serde", + "serde_json", + "strum 0.26.3", + "tempfile", + "thiserror 1.0.69", + "tiny-keccak", + "unicode-xid", +] + [[package]] name = "euclid" version = "0.22.14" @@ -3903,6 +4718,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + [[package]] name = "fastrand" version = "1.9.0" @@ -3966,6 +4787,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -4117,7 +4944,7 @@ checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ "fontconfig-parser", "log", - "memmap2", + "memmap2 0.9.10", "slotmap", "tinyvec", "ttf-parser", @@ -4419,6 +5246,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -4470,6 +5306,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.13.3" @@ -4490,6 +5336,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +dependencies = [ + "fallible-iterator", + "indexmap 2.14.0", + "stable_deref_trait", +] + [[package]] name = "gimli" version = "0.32.3" @@ -4759,7 +5616,7 @@ dependencies = [ "anyhow", "async-channel 2.5.0", "async-task", - "bindgen", + "bindgen 0.71.1", "bitflags 2.12.1", "block", "cbindgen", @@ -4773,7 +5630,7 @@ dependencies = [ "core-text", "core-video", "ctor", - "derive_more", + "derive_more 2.1.1", "embed-resource", "etagere", "foreign-types 0.5.0", @@ -4790,7 +5647,7 @@ dependencies = [ "itertools 0.14.0", "log", "lyon", - "mach2", + "mach2 0.5.0", "media", "metal", "num_cpus", @@ -4842,7 +5699,7 @@ dependencies = [ "async-channel 2.5.0", "chrono", "core-text", - "enum-iterator", + "enum-iterator 2.3.0", "futures", "gpui", "gpui-component-assets", @@ -4952,7 +5809,7 @@ dependencies = [ "core-text", "core-video", "ctor", - "derive_more", + "derive_more 2.1.1", "dispatch2", "etagere", "foreign-types 0.5.0", @@ -4962,7 +5819,7 @@ dependencies = [ "itertools 0.14.0", "libc", "log", - "mach2", + "mach2 0.5.0", "media", "metal", "objc", @@ -5252,12 +6109,18 @@ name = "hashbrown" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -5286,6 +6149,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", "foldhash 0.2.0", "serde", "serde_core", @@ -5645,7 +6509,7 @@ dependencies = [ "async-fs", "async-tar", "bytes", - "derive_more", + "derive_more 2.1.1", "futures", "http 1.4.1", "http-body 1.0.1", @@ -6009,6 +6873,24 @@ dependencies = [ "parity-scale-codec", ] +[[package]] +name = "impl-rlp" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28220f89297a075ddc7245cd538076ee98b01f2a9c23a53a4f1105d5a322808" +dependencies = [ + "rlp", +] + +[[package]] +name = "impl-serde" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc88fc67028ae3db0c853baa36269d398d5f45b6982f95549ff5def78c935cd" +dependencies = [ + "serde", +] + [[package]] name = "impl-trait-for-tuples" version = "0.2.3" @@ -6081,8 +6963,8 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", - "generic-array", + "block-padding 0.3.3", + "generic-array 0.14.7", ] [[package]] @@ -6166,6 +7048,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -6574,6 +7465,12 @@ dependencies = [ "leak", ] +[[package]] +name = "leb128" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -6713,6 +7610,12 @@ dependencies = [ "libsecp256k1-core", ] +[[package]] +name = "libunwind" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6639b70a7ce854b79c70d7e83f16b5dc0137cc914f3d7d03803b513ecc67ac" + [[package]] name = "libxdo" version = "0.6.0" @@ -6888,6 +7791,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "mach2" version = "0.5.0" @@ -6897,6 +7809,17 @@ dependencies = [ "libc", ] +[[package]] +name = "macho-unwind-info" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4bdc8b0ce69932332cf76d24af69c3a155242af95c226b2ab6c2e371ed1149" +dependencies = [ + "thiserror 2.0.18", + "zerocopy", + "zerocopy-derive", +] + [[package]] name = "macro-string" version = "0.2.0" @@ -6979,7 +7902,7 @@ version = "0.1.0" source = "git+https://github.com/zed-industries/zed#86effffd34634945a4971e1c6c65cd45b21ce6a9" dependencies = [ "anyhow", - "bindgen", + "bindgen 0.71.1", "core-foundation 0.10.0", "core-video", "ctor", @@ -6994,6 +7917,15 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "memmap2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d28bba84adfe6646737845bc5ebbfa2c08424eb1c37e94a1fd2a82adb56a872" +dependencies = [ + "libc", +] + [[package]] name = "memmap2" version = "0.9.10" @@ -7012,6 +7944,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak 0.1.6", + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "metal" version = "0.33.0" @@ -7088,6 +8032,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "more-asserts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7843ec2de400bcbc6a6328c958dc38e5359da6e93e72e37bc5246bf1ae776389" + [[package]] name = "moxcms" version = "0.8.1" @@ -7119,6 +8069,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "naga" version = "29.0.3" @@ -7320,6 +8290,8 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "rand 0.8.6", + "serde", ] [[package]] @@ -7750,6 +8722,20 @@ dependencies = [ "objc", ] +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "crc32fast", + "flate2", + "hashbrown 0.14.5", + "indexmap 2.14.0", + "memchr", + "ruzstd", +] + [[package]] name = "object" version = "0.37.3" @@ -7810,6 +8796,31 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open-fastrlp" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786393f80485445794f6043fd3138854dd109cc6c4bd1a6383db304c9ce9b9ce" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", + "ethereum-types", + "open-fastrlp-derive", +] + +[[package]] +name = "open-fastrlp-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003b2be5c6c53c1cfeb0a238b8a1c3915cd410feb684457a36c10038f764bb1c" +dependencies = [ + "bytes", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "openssl" version = "0.10.80" @@ -7894,6 +8905,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "p256" version = "0.13.2" @@ -8347,6 +9364,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -8362,6 +9391,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "poseidon-rust" +version = "0.1.0" +source = "git+https://github.com/ethereum/kohaku?rev=618c53facd0d44cf0f01d74e0dcc18d2242351c7#618c53facd0d44cf0f01d74e0dcc18d2242351c7" +dependencies = [ + "ark-bn254 0.6.0", + "ark-ff 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "itertools 0.14.0", + "num-bigint", + "num-traits", + "thiserror 2.0.18", +] + [[package]] name = "postage" version = "0.5.0" @@ -8442,6 +9486,9 @@ checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" dependencies = [ "fixed-hash", "impl-codec", + "impl-rlp", + "impl-serde", + "scale-info", "uint", ] @@ -8589,6 +9636,46 @@ dependencies = [ "cc", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive 0.1.4", +] + +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive 0.3.1", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -8727,6 +9814,64 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "railgun" +version = "0.1.0" +source = "git+https://github.com/ethereum/kohaku?rev=618c53facd0d44cf0f01d74e0dcc18d2242351c7#618c53facd0d44cf0f01d74e0dcc18d2242351c7" +dependencies = [ + "aes", + "aes-gcm", + "alloy", + "ark-bn254 0.6.0", + "ark-circom", + "ark-ff 0.6.0", + "ark-groth16", + "ark-relations 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "async-trait", + "bech32 0.11.1", + "brotli", + "cfg_aliases", + "common", + "crypto", + "ctr", + "curve25519-dalek", + "ed25519-dalek", + "eip-1193-provider", + "futures", + "getrandom 0.2.17", + "getrandom 0.3.4", + "gloo-timers 0.3.0", + "hex", + "js-sys", + "num-bigint", + "rand 0.9.4", + "rand_chacha 0.9.0", + "reqwest 0.13.4", + "ruint", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber 0.3.23", + "tracing-wasm", + "userop-kit", + "wasmer", + "web-time", +] + +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta 0.3.1", +] + [[package]] name = "rand" version = "0.8.6" @@ -8987,6 +10132,19 @@ dependencies = [ "derive_refineable", ] +[[package]] +name = "regalloc2" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad156d539c879b7a24a363a2016d77961786e71f48f2e2fc8302a92abd2429a6" +dependencies = [ + "hashbrown 0.13.2", + "log", + "rustc-hash 1.1.0", + "slice-group-by", + "smallvec", +] + [[package]] name = "regex" version = "1.12.3" @@ -9014,7 +10172,28 @@ dependencies = [ name = "regex-syntax" version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "region" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" +dependencies = [ + "bitflags 1.3.2", + "libc", + "mach2 0.4.3", + "windows-sys 0.52.0", +] + +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck 0.8.2", +] [[package]] name = "renderdoc-sys" @@ -9327,8 +10506,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25b57d4bd9e6b5fe469da5452a8a137bc2d030a3cd47c46908efc615bbc699da" dependencies = [ "ark-bls12-381", - "ark-bn254", - "ark-ec", + "ark-bn254 0.5.0", + "ark-ec 0.5.0", "ark-ff 0.5.0", "ark-serialize 0.5.0", "arrayref", @@ -9411,6 +10590,36 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "rkyv" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" +dependencies = [ + "bytecheck 0.8.2", + "bytes", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "munge", + "ptr_meta 0.3.1", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "rlp" version = "0.5.2" @@ -9418,9 +10627,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" dependencies = [ "bytes", + "rlp-derive", "rustc-hex", ] +[[package]] +name = "rlp-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e33d7b2abe0c340d8797fe2907d3f20d3b5ea5908683618bfe80df7f621f672a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "ropey" version = "2.0.0-beta.1" @@ -9451,13 +10672,13 @@ dependencies = [ [[package]] name = "ruint" version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0298da754d1395046b0afdc2f20ee76d29a8ae310cd30ffa84ed42acba9cb12a" +source = "git+https://github.com/Robert-MacWha/ruint#3dc44f0610de0770514ce92427436d21484c112c" dependencies = [ "alloy-rlp", "ark-ff 0.3.0", "ark-ff 0.4.2", "ark-ff 0.5.0", + "ark-ff 0.6.0", "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", @@ -9479,8 +10700,7 @@ dependencies = [ [[package]] name = "ruint-macro" version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" +source = "git+https://github.com/Robert-MacWha/ruint#3dc44f0610de0770514ce92427436d21484c112c" [[package]] name = "rust-embed" @@ -9801,6 +11021,17 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "ruzstd" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c4eb8a81997cf040a091d1f7e1938aeab6749d3a0dfa73af43cdc32393483d" +dependencies = [ + "byteorder", + "derive_more 0.99.20", + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.23" @@ -9816,6 +11047,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scale-info" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" +dependencies = [ + "cfg-if", + "derive_more 1.0.0", + "parity-scale-codec", + "scale-info-derive", +] + +[[package]] +name = "scale-info-derive" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "schannel" version = "0.1.29" @@ -9942,7 +11197,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der", - "generic-array", + "generic-array 0.14.7", "pkcs8", "serdect", "subtle", @@ -10076,6 +11331,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -10331,6 +11597,16 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared-buffer" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c99835bad52957e7aa241d3975ed17c1e5f8c92026377d117a606f36b84b16" +dependencies = [ + "bytes", + "memmap2 0.6.2", +] + [[package]] name = "shellexpand" version = "3.1.2" @@ -10434,6 +11710,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slice-group-by" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826167069c09b99d56f31e9ae5c99049e932a98c9dc2dac47645b08dbbf76ba7" + [[package]] name = "slotmap" version = "1.1.1" @@ -11016,6 +12298,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr 1.6.1", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -11158,6 +12451,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -11515,6 +12817,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber 0.3.23", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -11549,6 +12861,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber 0.3.23", + "wasm-bindgen", +] + [[package]] name = "tray-icon" version = "0.24.0" @@ -11651,6 +12974,16 @@ dependencies = [ "core_maths", ] +[[package]] +name = "twox-hash" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if", + "static_assertions", +] + [[package]] name = "typeid" version = "1.0.3" @@ -11829,6 +13162,25 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "userop-kit" +version = "0.1.0" +source = "git+https://github.com/ethereum/kohaku?rev=618c53facd0d44cf0f01d74e0dcc18d2242351c7#618c53facd0d44cf0f01d74e0dcc18d2242351c7" +dependencies = [ + "alloy", + "alloy-sol-types", + "async-trait", + "cfg_aliases", + "common", + "eip-1193-provider", + "futures", + "reqwest 0.13.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "usvg" version = "0.45.1" @@ -11887,7 +13239,7 @@ dependencies = [ "itertools 0.14.0", "libc", "log", - "mach2", + "mach2 0.5.0", "nix 0.29.0", "percent-encoding", "regex", @@ -12141,7 +13493,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" +dependencies = [ + "leb128fmt", + "wasmparser 0.251.0", ] [[package]] @@ -12152,8 +13514,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -12196,6 +13558,160 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmer" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d85671948f8886a1cc946141c0b688a5617603c103699a5fceeebeb4e75b0b6" +dependencies = [ + "bindgen 0.70.1", + "bytes", + "cfg-if", + "cmake", + "derive_more 2.1.1", + "indexmap 2.14.0", + "js-sys", + "more-asserts", + "paste", + "rustc-demangle", + "serde", + "serde-wasm-bindgen", + "shared-buffer", + "tar", + "target-lexicon", + "thiserror 1.0.69", + "tracing", + "wasm-bindgen", + "wasmer-compiler", + "wasmer-compiler-cranelift", + "wasmer-derive", + "wasmer-types", + "wasmer-vm", + "wasmparser 0.224.1", + "wat", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmer-compiler" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4946475adc0af265af8f10aadf4d4a3c64845bcd3801c655bdd81ce5e3ee869b" +dependencies = [ + "backtrace", + "bytes", + "cfg-if", + "enum-iterator 0.7.0", + "enumset", + "leb128", + "libc", + "macho-unwind-info", + "memmap2 0.6.2", + "more-asserts", + "object 0.32.2", + "region", + "rkyv", + "self_cell", + "shared-buffer", + "smallvec", + "target-lexicon", + "thiserror 1.0.69", + "wasmer-types", + "wasmer-vm", + "wasmparser 0.224.1", + "windows-sys 0.59.0", + "xxhash-rust", +] + +[[package]] +name = "wasmer-compiler-cranelift" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780f9c2050941b4e3f6bb82d32bd796a6c1750d2f97cbd892f07b384bb6af6f8" +dependencies = [ + "cranelift-codegen", + "cranelift-entity", + "cranelift-frontend", + "gimli 0.28.1", + "itertools 0.12.1", + "more-asserts", + "rayon", + "smallvec", + "target-lexicon", + "tracing", + "wasmer-compiler", + "wasmer-types", +] + +[[package]] +name = "wasmer-derive" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c546f3380840cd63fdcc390f04cd19002f2dfa19b4691b77ecbd27642bd93452" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wasmer-types" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a4027ce165e8dc776dc5e2a3231a96983e6dc7330efd97b793cfc4e973ad0c" +dependencies = [ + "bytecheck 0.6.12", + "enum-iterator 0.7.0", + "enumset", + "getrandom 0.2.17", + "hex", + "indexmap 2.14.0", + "more-asserts", + "rkyv", + "sha2 0.10.9", + "target-lexicon", + "thiserror 1.0.69", + "xxhash-rust", +] + +[[package]] +name = "wasmer-vm" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c37d5be291eea00a00d077ce3a427bb3074709ee386ec358f18f0b7da33be01" +dependencies = [ + "backtrace", + "cc", + "cfg-if", + "corosensei", + "crossbeam-queue", + "dashmap", + "enum-iterator 0.7.0", + "fnv", + "indexmap 2.14.0", + "libc", + "libunwind", + "mach2 0.4.3", + "memoffset", + "more-asserts", + "region", + "rustversion", + "scopeguard", + "thiserror 1.0.69", + "wasmer-types", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmparser" +version = "0.224.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f17a5917c2ddd3819e84c661fae0d6ba29d7b9c1f0e96c708c65a9c4188e11" +dependencies = [ + "bitflags 2.12.1", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -12208,6 +13724,17 @@ dependencies = [ "semver 1.0.28", ] +[[package]] +name = "wasmparser" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" +dependencies = [ + "bitflags 2.12.1", + "indexmap 2.14.0", + "semver 1.0.28", +] + [[package]] name = "wasmtimer" version = "0.4.3" @@ -12222,6 +13749,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wast" +version = "251.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc7467dda0a96142eb2c980329dfb62480b1e1d3622fdeb1a44e2bca6ceed74" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.251.0", +] + +[[package]] +name = "wat" +version = "1.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b1086c9e85b95bd6a229a928bc6c6d0662e42af0250c88d067b418831ea4d4" +dependencies = [ + "wast", +] + [[package]] name = "wayland-sys" version = "0.31.11" @@ -13147,9 +14696,9 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", - "wasmparser", + "wasmparser 0.244.0", "wit-parser", ] @@ -13168,7 +14717,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", ] [[package]] @@ -13211,6 +14760,16 @@ dependencies = [ "libc", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xcb" version = "1.7.0" @@ -13246,6 +14805,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "y4m" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index bd61e97..301b844 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,10 +30,25 @@ strip = true lto = "thin" codegen-units = 1 -# Mirror Helios's own workspace patch. `[patch]` only resolves at the workspace ROOT -# and does NOT inherit through a git dependency, so the embedded helios-ethereum -# (behind deckard-core's `verified-reads` feature) needs this here or its consensus -# crates fail to build. The `ruint`/`ark-circom` patches from the eip1193-railgun -# spike are Kohaku/Railgun-ZK-only — NOT needed for a helios-only consumer, omitted. +# Mirror Helios's + Kohaku/Railgun's workspace patches. `[patch]` only resolves at the +# workspace ROOT and does NOT inherit through a git dependency, so every patched git-dep +# (helios-ethereum behind `verified-reads`; the ZK `railgun` tree behind deckard-core's +# `shield` feature) needs its patches here or those crates fail to build. +# - ethereum_hashing: Helios consensus crates (verified-reads). +# - ruint + ark-circom: MANDATORY for the Railgun ZK tree (merkle tree / circom-compat) — +# copied from the proven shield spike. With `shield` off these resolve but pull nothing +# extra into the build. [patch.crates-io] ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" } +ruint = { git = "https://github.com/Robert-MacWha/ruint" } +ark-circom = { git = "https://github.com/Robert-MacWha/circom-compat", branch = "release/0.6.0" } + +# Redirect Kohaku's own `eip-1193-provider` to a native-only fork (vendor/) that DROPS the +# wasm32-only `js` feature. Upstream's `js` default pins `wasm-bindgen = "=0.2.108"`, which +# collides head-on with the GPUI app's `web-sys`→`wasm-bindgen = "=0.2.122"` exact pin — two +# exact pins can't coexist in one workspace. Deckard never builds wasm32, so the fork removes +# `js` (and only `js`); the native API railgun uses is byte-identical. See +# vendor/eip-1193-provider/Cargo.toml for the full rationale. This is what lets the heavy +# `railgun` shield tree live in the app workspace behind the default-on `shield` feature. +[patch."https://github.com/ethereum/kohaku"] +eip-1193-provider = { path = "vendor/eip-1193-provider" } diff --git a/crates/deckard-contract/src/mock.rs b/crates/deckard-contract/src/mock.rs index 03987ba..c99b862 100644 --- a/crates/deckard-contract/src/mock.rs +++ b/crates/deckard-contract/src/mock.rs @@ -329,7 +329,9 @@ mod tests { to: Address::repeat_byte(0x44), token: None, value: U256::from(value), - calldata: Bytes::new(), + // A real Shield always carries the RelayAdapt call; the policy gate now requires + // it (an empty payload would degrade into a bare native send). Stand-in bytes here. + calldata: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]), kind: IntentKind::Shield, } } @@ -426,6 +428,18 @@ mod tests { reason: "undecodable".into() } ); + // A Shield with EMPTY calldata is rejected: without the RelayAdapt call it would + // degrade into a bare native send to `to` (no private note) while labelled "Shield". + let empty_shield = Intent { + calldata: Bytes::new(), + ..shield(20) + }; + assert_eq!( + s.propose(&empty_shield), + Decision::Deny { + reason: "undecodable".into() + } + ); } #[test] diff --git a/crates/deckard-contract/src/policy.rs b/crates/deckard-contract/src/policy.rs index 6e76eb7..b735280 100644 --- a/crates/deckard-contract/src/policy.rs +++ b/crates/deckard-contract/src/policy.rs @@ -107,13 +107,22 @@ pub fn evaluate(intent: &Intent, policy: &Policy) -> Decision { /// Shape check: does the calldata match the kind? The real Railgun adapter calldata is /// validated downstream (`10-kohaku-shield.md`); this only enforces the coarse invariant /// the policy gate relies on. +/// +/// The Shield invariant matters now that Shield routes to the signing path: a +/// `Shield`/`Unshield` MUST carry non-empty calldata. Without it, an `Intent{kind:Shield, +/// calldata: empty}` would fall through the daemon's broadcast as a **plain native ETH send** +/// to `intent.to` (no private note ever created) while wire-labelled "Shield" — a key-less +/// client could thereby move ETH to an arbitrary address under the Shield label. Requiring +/// calldata closes that. (The deeper `to == RelayAdapt(chain)` check lives downstream — the +/// contract crate is pure policy with zero chain knowledge and no railgun dep, by charter.) fn calldata_ok(intent: &Intent) -> bool { match intent.kind { // A plain send carries no calldata (the daemon builds the tx from to/value/token). IntentKind::Send => intent.calldata.is_empty(), - // A generic contract write needs calldata to call. - IntentKind::ContractCall => !intent.calldata.is_empty(), - // Railgun deposit/withdraw: accept whatever calldata is handed over. - IntentKind::Shield | IntentKind::Unshield => true, + // A contract write / Railgun deposit / withdraw all carry an encoded call. An empty + // payload for any of these would degrade into a bare native send — reject it. + IntentKind::ContractCall | IntentKind::Shield | IntentKind::Unshield => { + !intent.calldata.is_empty() + } } } diff --git a/crates/deckard-contract/tests/harness_slice.rs b/crates/deckard-contract/tests/harness_slice.rs index 0d70231..b648a05 100644 --- a/crates/deckard-contract/tests/harness_slice.rs +++ b/crates/deckard-contract/tests/harness_slice.rs @@ -34,12 +34,19 @@ fn demo_signer() -> MockSigner { } fn intent(kind: IntentKind, value: u64) -> Intent { + // Send carries no calldata; every other kind (Shield/Unshield/ContractCall) must carry + // its encoded call — the policy gate now rejects an empty payload for those (an empty + // "Shield" would otherwise degrade into a bare native send). Stand-in bytes for non-Send. + let calldata = match kind { + IntentKind::Send => Bytes::new(), + _ => Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]), + }; Intent { chain_id: 1, to: Address::repeat_byte(0x22), token: None, value: U256::from(value), - calldata: Bytes::new(), + calldata, kind, } } diff --git a/crates/deckard-core/Cargo.toml b/crates/deckard-core/Cargo.toml index 0d5b8ea..56b66be 100644 --- a/crates/deckard-core/Cargo.toml +++ b/crates/deckard-core/Cargo.toml @@ -10,8 +10,14 @@ description = "Deckard's headless engine: Ethereum provider, balances, HD keys, # default so the app + daemon get verified reads, but toggleable so the heavy build # can be skipped. When OFF, reads fall back to the raw RPC and are tagged # ReadStatus::Unsynced("verification disabled") — never silently Verified. -default = ["verified-reads"] +default = ["verified-reads", "shield"] verified-reads = ["dep:helios-ethereum"] +# Key-less Railgun shield-calldata builder. Heavy (the full ZK `railgun` tree); ON by +# default so the app + daemon can build shield intents, but toggleable so the heavy build +# can be skipped. When OFF, `build_shield_native_intent` returns "shield unavailable +# (feature off)" — never a fake success. Pulls `rand_09` because railgun pins rand 0.9 +# while core's own `rand` stays 0.8. +shield = ["dep:railgun", "dep:rand_09"] [dependencies] # The frozen wire contract — only for the shared `ReadStatus` trust label attached @@ -34,6 +40,18 @@ alloy-primitives = "1.6.0" # root (a git-dep can't carry its own `[patch]`). helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1", optional = true } +# Kohaku's pure-Rust Railgun client — the heavy ZK crate. Gated behind `shield` (DEFAULT +# ON). We use ONLY the key-less `ShieldBuilder` (no sync, no proving, no key), but the crate +# pulls the full ZK tree, so it is `optional`. `testing` matches the proven spike edge (rev +# 618c53f) so the workspace-root `[patch.crates-io]` set resolves identically. The two +# mandatory ZK patches (ruint, ark-circom) live at the workspace root — a git-dep can't +# carry its own `[patch]`. +railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7", features = ["testing"], optional = true } +# railgun's `ShieldBuilder::build` requires a rand **0.9** Rng; core's own `rand` +# (below) is 0.8 (RustCrypto-aligned), so the 0.9 crate is aliased `rand_09` and pulled only +# with the `shield` feature. +rand_09 = { package = "rand", version = "0.9", optional = true } + # A single background tokio runtime owns all network; the GUI thread never makes # a network call. `rt` (current-thread) only — no multi-thread worker pool needed. tokio = { version = "1", features = ["rt", "macros", "sync"] } diff --git a/crates/deckard-core/src/lib.rs b/crates/deckard-core/src/lib.rs index 7bcbab4..289d1b5 100644 --- a/crates/deckard-core/src/lib.rs +++ b/crates/deckard-core/src/lib.rs @@ -17,6 +17,11 @@ pub mod eth; #[cfg(feature = "verified-reads")] pub mod helios; pub mod keystore; +/// Key-less Railgun shield-calldata builder. Gated behind the default-on `shield` feature +/// so the heavy ZK `railgun` crate is toggleable. When the feature is off, the +/// `build_shield_native_intent` stub below returns a clear error (never a fake success). +#[cfg(feature = "shield")] +pub mod shield; pub mod tokens; pub use balances::{fetch_portfolio, format_amount, Portfolio, TokenBalance}; @@ -25,8 +30,25 @@ pub use eth::{EthProvider, Read, DEFAULT_RPC}; #[cfg(feature = "verified-reads")] pub use helios::{launch_verified, VerifiedReader, DEFAULT_CONSENSUS_RPC}; pub use keystore::{random_word_positions, KdfParams, SecretKind, UnlockedVault, Vault, WordCount}; +// The key-less shield-calldata builder + the 0zk recipient type, re-exported so the daemon +// and its tests can name them through core without a direct `railgun` dependency. +#[cfg(feature = "shield")] +pub use shield::{build_shield_native_intent, RailgunAddress}; pub use tokens::{TokenInfo, DEFAULT_TOKENS}; +/// Feature-off stub: when `shield` is compiled out, the symbol still exists so the daemon +/// and tests build, but it returns a clear error — NEVER a fake success. Mirrors the +/// honest-failure pattern the `verified-reads`-off read path uses (a Deny/Unsynced label +/// rather than a silent fabricated value). +#[cfg(not(feature = "shield"))] +pub fn build_shield_native_intent( + _chain_id: u64, + _recipient: (), + _value: alloy_primitives::U256, +) -> anyhow::Result { + anyhow::bail!("shield unavailable (feature off)") +} + // The shared trust label, re-exported so the app + daemon can name it through core // without a direct deckard-contract dependency just to render a read status. pub use deckard_contract::ReadStatus; diff --git a/crates/deckard-core/src/shield.rs b/crates/deckard-core/src/shield.rs new file mode 100644 index 0000000..fe26a02 --- /dev/null +++ b/crates/deckard-core/src/shield.rs @@ -0,0 +1,125 @@ +//! Key-less Railgun **shield**-calldata builder. +//! +//! A SHIELD (depositing public ETH into a Railgun `0zk` private balance) is **key-less**: +//! it needs only the recipient [`RailgunAddress`], the chain, and the value — never the +//! spending key. And — the de-risked hero finding — a shield does **NO client-side ZK +//! proof**: [`ShieldBuilder::build`] only encrypts the note (`encrypt_shield`) and +//! `abi_encode`s the calldata; the on-chain contract verifies the commitment and deducts +//! the 25-bps fee. So this builder is pure, synchronous, and instant. +//! +//! It builds the calldata and wraps it as an [`Intent`] with [`IntentKind::Shield`]; the +//! daemon (which never sees this heavy ZK crate) just signs + broadcasts the handed +//! `{to, value, calldata}`. That split is deliberate: the heavy `railgun` dep + any sync +//! stays OUT of the key-holding daemon. +//! +//! Gated behind the default-on `shield` Cargo feature so the heavy ZK `railgun` tree is +//! toggleable. When the feature is off, [`build_shield_native_intent`] is replaced by a +//! stub (declared in `lib.rs`) that returns a clear "shield unavailable (feature off)" +//! error — never a fake success. + +use alloy_primitives::U256; +use anyhow::{anyhow, ensure}; + +use deckard_contract::{Intent, IntentKind}; + +// Re-exported from `lib.rs` (gated) so the daemon's test can name the recipient type +// without taking a direct `railgun` dependency. +pub use railgun::account::address::RailgunAddress; + +/// Build the key-less Railgun native-shield calldata and wrap it as an +/// `Intent { kind: Shield, .. }`. +/// +/// Key-less: shielding native ETH to `recipient` (a `0zk…` [`RailgunAddress`]) needs only +/// the recipient, the chain config, and the value — never the spending key. The on-chain +/// 25-bps (0.25%) shield fee is deducted by the contract; the calldata carries the full +/// pre-fee `value`, so the synced private balance reads `value - value*25/10000`. +/// +/// `value` is wei. For a *native* shield the builder always produces **exactly one** +/// `TxData` (a single RelayAdapt `wrapBase + shield` multicall), so the 1-intent:1-tx model +/// holds; we assert that invariant rather than silently dropping a tx. +pub fn build_shield_native_intent( + chain_id: u64, + recipient: RailgunAddress, + value: U256, +) -> anyhow::Result { + let chain = railgun::chain_config::ChainConfig::from_chain_id(chain_id) + .ok_or_else(|| anyhow!("shield: unsupported chain_id {chain_id}"))?; + + // The note preimage carries a u128 value; reject anything that can't fit (a shield of + // > ~3.4e20 ETH is not a real case, but never silently truncate). + let value_u128: u128 = value + .try_into() + .map_err(|_| anyhow!("shield: value exceeds u128"))?; + + // Pure + synchronous: no provider, no sync, no key. `build` only does symmetric note + // encryption + ABI-encode (no ZK proof). NOTE: railgun pins `rand` 0.9; deckard-core's + // own `rand` is 0.8, so this rng comes from the 0.9 crate aliased as `rand_09` in + // Cargo.toml to satisfy the `R: rand::Rng` (0.9) bound on `build`. + let mut txs = railgun::transact::ShieldBuilder::new(chain) + .shield_native(recipient, value_u128) + .build(&mut rand_09::rng()) + .map_err(|e| anyhow!("shield build: {e}"))?; + + ensure!( + txs.len() == 1, + "shield_native produced {} txs, expected exactly 1", + txs.len() + ); + // Safe: just asserted len == 1. + let tx = txs.pop().expect("len checked == 1"); + + Ok(Intent { + chain_id, + to: tx.to, // RelayAdapt contract + token: None, // native shield; the value rides as msg.value + value: tx.value, // == the gross native total (wei); contract deducts the fee + calldata: tx.data, // RelayAdapt.multicall(wrapBase + shield) + kind: IntentKind::Shield, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh ephemeral 0zk recipient → a well-formed Shield intent (key-less, no network): + /// exactly one tx, native (token None), non-empty calldata, value preserved (gross). + #[test] + fn builds_a_single_native_shield_intent() { + let chain = railgun::chain_config::ChainConfig::sepolia(); + let acct = railgun::account::signer::PrivateKeySigner::new_evm( + rand_09::random(), + rand_09::random(), + chain.id, + ); + use railgun::account::signer::RailgunSigner; + let recipient = acct.address(); + + let value = U256::from(1_000_000u64); + let intent = build_shield_native_intent(chain.id, recipient, value).expect("build"); + + assert_eq!(intent.kind, IntentKind::Shield); + assert_eq!(intent.token, None, "native shield carries no token"); + assert_eq!(intent.value, value, "calldata carries the GROSS (pre-fee) value"); + assert!(!intent.calldata.is_empty(), "shield calldata must be present"); + assert_eq!( + intent.to, chain.relay_adapt_contract, + "native shield targets the RelayAdapt contract" + ); + } + + /// An unsupported chain id is a clear error, not a panic. + #[test] + fn unsupported_chain_errors() { + let chain = railgun::chain_config::ChainConfig::sepolia(); + let acct = railgun::account::signer::PrivateKeySigner::new_evm( + rand_09::random(), + rand_09::random(), + chain.id, + ); + use railgun::account::signer::RailgunSigner; + let err = build_shield_native_intent(424242, acct.address(), U256::from(1u64)) + .expect_err("unsupported chain must error"); + assert!(err.to_string().contains("unsupported chain_id")); + } +} diff --git a/crates/deckard-signerd/Cargo.toml b/crates/deckard-signerd/Cargo.toml index d37f491..5d66d4b 100644 --- a/crates/deckard-signerd/Cargo.toml +++ b/crates/deckard-signerd/Cargo.toml @@ -19,16 +19,22 @@ path = "src/main.rs" # Verified reads via the embedded Helios light client (shared launcher in deckard-core). # ON by default; threads through to deckard-core's `verified-reads`. When OFF, the # daemon's balance read falls back to the raw RPC, tagged Unsynced("verification disabled"). -default = ["verified-reads"] +default = ["verified-reads", "shield"] verified-reads = ["deckard-core/verified-reads"] +# Threads deckard-core's key-less shield-calldata builder through (DEFAULT ON). The daemon +# itself gains NO railgun dep — it only broadcasts handed calldata; this feature just lets the +# black-box `shield_e2e` integration test drive deckard-core's builder + assert via railgun. +shield = ["deckard-core/shield"] [dependencies] # The frozen wire contract (Intent / Decision / Policy / RPC + the shared `evaluate`). deckard-contract = { path = "../deckard-contract" } # The headless engine: the keystore (`Vault`/`UnlockedVault`) we reuse — never rebuilt # here — plus the shared Helios launcher (`launch_verified`) behind `verified-reads`. -# `default-features = false` is NOT set, so deckard-core's default `verified-reads` is on. -deckard-core = { path = "../deckard-core" } +# `default-features = false` so this crate's own feature flags (below) are the SINGLE source +# of truth for what core builds: toggling signerd's `shield` / `verified-reads` off actually +# drops the heavy `railgun` / `helios` tree from core (no leaky default pulling them back in). +deckard-core = { path = "../deckard-core", default-features = false } # Async UDS server + framing. multi-thread rt so the Argon2 unlock can run on the blocking # pool without starving the reactor. @@ -51,3 +57,17 @@ alloy-primitives = { workspace = true } nix = { version = "0.29", features = ["fs", "user"] } zeroize = "1" anyhow = "1" + +[dev-dependencies] +# The black-box `shield_e2e` test drives deckard-core's key-less builder to get the Intent, +# but to ASSERT the privacy property it talks to railgun directly (register an ephemeral 0zk +# recipient, sync against the anvil fork, read the private balance). These mirror the proven +# shield spike's edge (rev 618c53f, `testing` feature) so the [patch] set resolves identically. +# Only the TEST gets railgun — never the daemon binary/lib. +railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7", features = ["testing"] } +# Match Kohaku's alloy (1.8.3 / alloy-primitives 1.6.0) + the erased provider the spike uses +# to fund the EOA and to drive railgun's RPC syncer. +alloy = { version = "1.8", features = ["eips", "rpc-types", "network", "providers", "provider-http", "sol-types", "signer-local", "contract"] } +rand_09 = { package = "rand", version = "0.9" } +eyre = "0.6" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync"] } diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index d12f862..75d44bc 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -313,12 +313,18 @@ impl Daemon { reason: "chain_mismatch".into(), }; } - if intent.kind != IntentKind::Send { + // v1 admits a native Send and a Shield (the privacy hero). The Shield's RelayAdapt + // calldata is built key-less in deckard-core and rides in `intent.calldata`; the + // daemon never sees the ZK crate, it only signs+broadcasts the handed bytes. Unshield + // / ContractCall stay a fast-follow. + if !matches!(intent.kind, IntentKind::Send | IntentKind::Shield) { return Decision::Deny { reason: "unsupported_v1".into(), }; } // v1 spine is native ETH only; an ERC-20 (`token = Some`) Send is a fast-follow. + // A native shield is `token: None` (the value rides as msg.value via RelayAdapt + // wrapBase), so it passes this guard. if intent.token.is_some() { return Decision::Deny { reason: "erc20_unsupported_v1".into(), @@ -377,7 +383,7 @@ impl Daemon { // Phase 1 (lock held): TOCTOU re-check + eligibility, then extract tx params and the // raw scalar (transiently, into `Zeroizing`). Borrows end before the await. - let (to, value, scalar) = { + let (to, value, calldata, scalar) = { let vault = match &self.state { // STOP landed first — refuse even a previously-approved request. VaultState::Locked => { @@ -438,17 +444,26 @@ impl Daemon { }; // Only the version-stable raw scalar crosses into our alloy stack; zeroized on drop. let scalar = Zeroizing::new(signer.to_bytes().0); - (req.intent.to, req.intent.value, scalar) + // Calldata is empty for a native Send (→ broadcast is byte-identical to before) and + // carries the RelayAdapt call for a Shield. The empty-vs-non-empty input IS the + // native/contract-call discriminator, so no IntentKind branch is needed here. + ( + req.intent.to, + req.intent.value, + req.intent.calldata.clone(), + scalar, + ) }; // Phase 2: sign + broadcast (lock held — serialized; acceptable for v1). A bounded // timeout keeps a hung RPC from wedging the daemon (and STOP) behind the held lock. - let broadcast = signing::broadcast_native_send( + let broadcast = signing::broadcast_intent( scalar.as_slice(), &self.cfg.rpc_url, self.cfg.chain_id, to, value, + &calldata, ); let tx_hash = match tokio::time::timeout(BROADCAST_TIMEOUT, broadcast).await { Ok(Ok(hash)) => hash, diff --git a/crates/deckard-signerd/src/signing.rs b/crates/deckard-signerd/src/signing.rs index e17f030..0aede4d 100644 --- a/crates/deckard-signerd/src/signing.rs +++ b/crates/deckard-signerd/src/signing.rs @@ -14,18 +14,40 @@ use alloy::network::{Ethereum, EthereumWallet, TransactionBuilder}; use alloy::providers::{Provider, ProviderBuilder}; use alloy::rpc::types::TransactionRequest; use alloy::signers::local::PrivateKeySigner; -use alloy_primitives::{Address, B256, U256}; +use alloy_primitives::{Address, Bytes, B256, U256}; /// Sign + broadcast a native-ETH send and return the broadcast tx hash. /// -/// `scalar` is the raw 32-byte private key (the caller keeps it in `Zeroizing`). v1 supports -/// native sends only; ERC-20/contract sends are rejected upstream in `propose`. +/// Thin wrapper over [`broadcast_intent`] with empty calldata, so the native-send call +/// sites (and their tests) keep byte-identical behaviour: no `input` field is set, the gas +/// filler produces the same type-2 tx as before. +/// +/// `scalar` is the raw 32-byte private key (the caller keeps it in `Zeroizing`). pub async fn broadcast_native_send( scalar: &[u8], rpc_url: &str, chain_id: u64, to: Address, value_wei: U256, +) -> anyhow::Result { + broadcast_intent(scalar, rpc_url, chain_id, to, value_wei, &Bytes::new()).await +} + +/// Sign + broadcast an intent's `(to, value, calldata)` and return the broadcast tx hash. +/// +/// Generalizes the native send to carry **calldata**, so a Shield / ContractCall intent +/// broadcasts the RelayAdapt (or other) call the key-less builder handed over. The selection +/// is implicit: empty `input` ⇒ a plain native send (identical to the old path); a non-empty +/// `input` ⇒ a contract call. The daemon stays ZK-free — it only signs+broadcasts the bytes. +/// +/// `scalar` is the raw 32-byte private key (the caller keeps it in `Zeroizing`). +pub async fn broadcast_intent( + scalar: &[u8], + rpc_url: &str, + chain_id: u64, + to: Address, + value_wei: U256, + input: &Bytes, ) -> anyhow::Result { let signer = PrivateKeySigner::from_slice(scalar) .map_err(|e| anyhow::anyhow!("reconstruct signer: {e}"))?; @@ -37,8 +59,9 @@ pub async fn broadcast_native_send( // `new()` installs the recommended fillers (nonce/gas/chain-id); `.wallet()` adds signing. let provider = ProviderBuilder::new().wallet(wallet).connect_http(url); - // Only `to`/`value` set ⇒ the gas filler produces an EIP-1559 (type-2) tx and fills the - // fee fields; the nonce filler uses the pending count; chain id is pinned explicitly. + // `to`/`value` (and, for a shield/contract call, `input`) set ⇒ the gas filler produces an + // EIP-1559 (type-2) tx and fills the fee fields — now estimating gas against the calldata + // too; the nonce filler uses the pending count; chain id is pinned explicitly. // // The `TransactionBuilder` methods are disambiguated to alloy's `Ethereum` network: // pulling helios-ethereum into the tree (via deckard-core's `verified-reads`) adds a @@ -49,6 +72,9 @@ pub async fn broadcast_native_send( >::set_to(&mut tx, to); >::set_value(&mut tx, value_wei); >::set_chain_id(&mut tx, chain_id); + if !input.is_empty() { + >::set_input(&mut tx, input.clone()); + } let pending = provider .send_transaction(tx) diff --git a/crates/deckard-signerd/tests/common/mod.rs b/crates/deckard-signerd/tests/common/mod.rs index f20efe0..5c6eb3e 100644 --- a/crates/deckard-signerd/tests/common/mod.rs +++ b/crates/deckard-signerd/tests/common/mod.rs @@ -148,6 +148,7 @@ fn free_port() -> u16 { pub struct Anvil { child: Child, port: u16, + chain_id: u64, } impl Anvil { @@ -183,7 +184,45 @@ pub fn start_anvil() -> Anvil { .stderr(Stdio::null()) .spawn() .expect("spawn anvil"); - Anvil { child, port } + Anvil { + child, + port, + chain_id: 31337, + } +} + +impl Anvil { + pub fn chain_id(&self) -> u64 { + self.chain_id + } +} + +/// Start a FRESH anvil forking a chain at a pinned block, prefunding account-0 of [`MNEMONIC`] +/// (so a vault sealed from that phrase controls a funded EOA). A fresh fork each run is +/// deterministic — re-using a non-reset fork would accumulate the EOA's balance and drift the +/// asserts. Killed on drop. The fork preserves the upstream chain id (e.g. Sepolia 11155111). +pub fn start_anvil_fork(fork_url: &str, fork_block: u64, chain_id: u64) -> Anvil { + let port = free_port(); + let child = Command::new("anvil") + .args([ + "--fork-url", + fork_url, + "--fork-block-number", + &fork_block.to_string(), + "--mnemonic", + MNEMONIC, + "--port", + &port.to_string(), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn anvil fork"); + Anvil { + child, + port, + chain_id, + } } /// Wait until anvil answers JSON-RPC. diff --git a/crates/deckard-signerd/tests/daemon_e2e.rs b/crates/deckard-signerd/tests/daemon_e2e.rs index 740c8e4..a1dfcb4 100644 --- a/crates/deckard-signerd/tests/daemon_e2e.rs +++ b/crates/deckard-signerd/tests/daemon_e2e.rs @@ -98,11 +98,30 @@ async fn propose_decision_matrix() { } ); - // unsupported kind (Shield is T-Privacy). + // Shield (the privacy hero) is now admitted: a within-cap, on-allowlist native shield + // classifies like a send (its RelayAdapt calldata rides in `intent.calldata`). It is + // `token: None`, so it passes the ERC-20 guard; within cap → Allow. let mut shield = send(to, 1_000); shield.kind = IntentKind::Shield; + shield.calldata = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]); // stand-in RelayAdapt call + assert_eq!(client.propose(&shield).await.unwrap(), Decision::Allow); + + // A Shield with EMPTY calldata is rejected (would otherwise broadcast as a bare native + // send to `to` — moving ETH to an arbitrary address under the "Shield" label, no note). + let mut empty_shield = send(to, 1_000); + empty_shield.kind = IntentKind::Shield; // calldata stays empty (from send()) + assert_eq!( + client.propose(&empty_shield).await.unwrap(), + Decision::Deny { + reason: "undecodable".into() + } + ); + + // Unshield stays a fast-follow → Deny{unsupported_v1}. + let mut unshield = send(to, 1_000); + unshield.kind = IntentKind::Unshield; assert_eq!( - client.propose(&shield).await.unwrap(), + client.propose(&unshield).await.unwrap(), Decision::Deny { reason: "unsupported_v1".into() } diff --git a/crates/deckard-signerd/tests/shield_e2e.rs b/crates/deckard-signerd/tests/shield_e2e.rs new file mode 100644 index 0000000..98917ba --- /dev/null +++ b/crates/deckard-signerd/tests/shield_e2e.rs @@ -0,0 +1,234 @@ +//! Repeatable, black-box SHIELD integration test — the privacy hero, driven end-to-end +//! through DECKARD'S OWN path against a fresh anvil fork of Sepolia. +//! +//! What it proves (the privacy property, shield-only — fast, NO ZK proving / artifacts): +//! 1. deckard-core's KEY-LESS builder turns `(chain, 0zk recipient, value)` into an +//! `Intent{kind:Shield, to=RelayAdapt, value, calldata}` — no spending key, no sync. +//! 2. The daemon admits + signs + broadcasts that Intent (generalized broadcast carries the +//! calldata; the daemon never touches the ZK crate — it just signs the handed bytes). +//! 3. After `railgun.sync()`, the recipient's PRIVATE 0zk balance is up by exactly +//! `value - value*25/10000` (the on-chain 25-bps shield fee; the calldata carried the +//! gross value), and the EOA's PUBLIC balance is down by ~value + gas. +//! +//! Shield does NO client ZK proof (the de-risked finding): `ShieldBuilder::build` only +//! encrypts the note + ABI-encodes — the contract verifies the commitment. So this is fast. +//! Transfer/unshield (slow; download ZK artifacts) stay in `spikes/shield-railgun`, NOT here. +//! +//! `#[ignore]` (needs network + a fresh anvil). Run: +//! RUSTUP_TOOLCHAIN=1.95.0-aarch64-apple-darwin \ +//! RPC_URL_SEPOLIA= \ +//! cargo test -p deckard-signerd --test shield_e2e -- --ignored --nocapture +//! +//! It spawns its OWN fresh anvil fork each run (deterministic — a re-used non-reset fork +//! accumulates the EOA balance and drifts the asserts) and kills it on drop. + +#![cfg(feature = "shield")] + +mod common; + +use std::sync::Arc; + +use alloy::{ + network::Ethereum, + providers::{Provider, ProviderBuilder}, + signers::local::PrivateKeySigner, +}; +use alloy_primitives::U256; + +use deckard_contract::{Decision, ExecuteResult}; +use deckard_core::build_shield_native_intent; +use deckard_signerd::SignerClient; + +use railgun::{ + account::signer::{PrivateKeySigner as RailgunKeySigner, RailgunSigner}, + builder::RailgunBuilder, + caip::AssetId, + chain_config::ChainConfig, + indexer::syncer::{ChainedSyncer, RpcSyncer, SubsquidSyncer}, +}; +use rand_09::random; + +use common::*; + +/// Sepolia archive RPC the spec pre-verified to serve the pinned fork block. Overridable via +/// `RPC_URL_SEPOLIA` (CI secret) so the literal isn't the only path. +const DEFAULT_SEPOLIA_RPC: &str = + "https://eth-sepolia.g.alchemy.com/v2/xqR9JXkWao0ETLYaaZt9fye8yeE4Cxyd"; +/// Pinned fork block (pre-verified). A fixed block keeps Subsquid + the asserts deterministic. +const FORK_BLOCK: u64 = 10_822_990; +/// Sepolia chain id — the fork preserves it; the daemon's chain_id + the Intent must match it. +const SEPOLIA_CHAIN_ID: u64 = 11_155_111; +/// anvil dev key #0 — the EOA the daemon's account-0 maps to (sealed from [`MNEMONIC`]); the +/// fork prefunds it with ETH. Used here only to read its public balance for the down-assert. +const EOA_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + +/// On-chain shield fee in basis points (== `ChainConfig::sepolia().unshield_fee_bps`); the +/// contract deducts it, so the synced private note reads `value - value*25/10000`. +const SHIELD_FEE_BPS: u128 = 25; + +fn sepolia_rpc() -> String { + std::env::var("RPC_URL_SEPOLIA").unwrap_or_else(|_| DEFAULT_SEPOLIA_RPC.to_string()) +} + +#[tokio::test] +#[ignore = "network: spawns a fresh anvil Sepolia fork + drives the full shield path"] +async fn shield_e2e_privacy_property() { + if !anvil_available() { + eprintln!("SKIP shield_e2e_privacy_property: anvil not on PATH"); + return; + } + + // --- fresh anvil fork of Sepolia @ the pinned block (chain id preserved) --- + let anvil = start_anvil_fork(&sepolia_rpc(), FORK_BLOCK, SEPOLIA_CHAIN_ID); + wait_anvil_ready(&anvil.url()).await; + assert_eq!(anvil.chain_id(), SEPOLIA_CHAIN_ID); + + // --- daemon, sealed for anvil account-0 (the funded EOA), pointed at the fork --- + let dir = TempDir::new("shield-e2e"); + let (wallet, _recipient_eoa) = seal_account0(dir.path()); + // verified-reads is irrelevant here (we never call Balance on the daemon), but the daemon + // would try to bootstrap Helios against the fork URL on a Balance request; we avoid that + // path entirely by reading public balance directly from the fork below. + let d = spawn_daemon(dir.path(), &anvil.url(), SEPOLIA_CHAIN_ID, &[]); + let client = SignerClient::new(d.socket_path.clone()); + let unlocked = client.unlock(PASS).await.unwrap(); + assert_eq!( + unlocked, + deckard_contract::UnlockOutcome::Unlocked { address: wallet }, + "daemon's account-0 must be the funded EOA" + ); + + // --- test-side railgun provider: register an EPHEMERAL 0zk recipient, then sync+assert --- + let chain = ChainConfig::sepolia(); + let weth = AssetId::Erc20(chain.wrapped_base_token); + + // A plain alloy erased provider over the fork (for railgun's RPC syncer). The EOA wallet + // here is only used by the syncer's read path; the daemon owns the BROADCAST wallet. + let read_signer = PrivateKeySigner::from_str_eoa(EOA_KEY); + let provider = ProviderBuilder::new() + .network::() + .wallet(read_signer) + .connect(&anvil.url()) + .await + .expect("connect provider") + .erased(); + + let syncer = Arc::new( + ChainedSyncer::new() + .then(SubsquidSyncer::new(&chain.subsquid_endpoint).with_latest_block(FORK_BLOCK)) + .then(RpcSyncer::new(chain.clone(), provider.clone()).with_batch_size(1000)), + ); + let mut railgun = RailgunBuilder::new(chain.clone(), provider.clone()) + .with_utxo_syncer(syncer) + .build() + .await + .expect("build railgun"); + + // Ephemeral 0zk recipient (random spending/viewing keys). KEY-LESS shield: the builder + // takes only this account's RailgunAddress — never its keys. + let recipient_acct = RailgunKeySigner::new_evm(random(), random(), chain.id); + railgun + .register(recipient_acct.clone()) + .await + .expect("register recipient"); + let recipient_0zk = recipient_acct.address(); + + // Sanity: recipient has no private balance before the shield. + railgun.sync().await.expect("pre-sync"); + let before_private = railgun.balance(recipient_0zk).await; + assert_eq!( + before_private.get(&weth), + None, + "recipient must have NO 0zk note before the shield" + ); + + // ============================ DECKARD'S OWN PATH ============================ + // 1. deckard-core KEY-LESS builder → Intent{kind:Shield, ...}. The shield value (raw wei) + // is well within the default 0.05 ETH per-tx cap, so propose → Allow directly. + let shield_value: u128 = 1_000_000; + let intent = build_shield_native_intent( + SEPOLIA_CHAIN_ID, + recipient_0zk, + U256::from(shield_value), + ) + .expect("build shield intent"); + assert_eq!( + intent.kind, + deckard_contract::IntentKind::Shield, + "builder must produce a Shield intent" + ); + assert_eq!(intent.to, chain.relay_adapt_contract); + assert!(!intent.calldata.is_empty()); + + // 2. propose → the daemon admits the Shield (within cap → Allow). + let decision = client.propose(&intent).await.expect("propose"); + assert_eq!( + decision, + Decision::Allow, + "within-cap native shield must be admitted by the daemon" + ); + let id = SignerClient::request_id_for_intent(&intent); + + // Public balance of the EOA before broadcast (for the down-assert). + let eoa = wallet; + let public_before = balance(&anvil.url(), eoa).await; + + // 3. execute → the daemon signs + broadcasts the Intent's calldata + value. + let tx_hash = match client.execute(id).await.expect("execute") { + ExecuteResult::Broadcast { tx_hash } => tx_hash, + other => panic!("expected Broadcast, got {other:?}"), + }; + let receipt = wait_receipt(&anvil.url(), tx_hash) + .await + .expect("a mined shield receipt"); + assert!(receipt.status(), "the shield tx must succeed on-chain"); + // ============================================================================ + + // --- ASSERT THE PRIVACY PROPERTY --- + railgun.sync().await.expect("post-shield sync"); + + // Private balance UP by exactly value - 25bps fee (the recipient 0zk note now exists). + let expected_net = shield_value - shield_value * SHIELD_FEE_BPS / 10_000; + let after_private = railgun.balance(recipient_0zk).await; + println!( + "shield_e2e: recipient 0zk[weth] = {:?} (expect Some({expected_net}))", + after_private.get(&weth) + ); + assert_eq!( + after_private.get(&weth), + Some(&expected_net), + "recipient private balance must be value minus the 25-bps on-chain shield fee \ + ({shield_value} -> {expected_net})" + ); + + // Public balance DOWN by ~value + gas (strictly more than the gross value). + let public_after = balance(&anvil.url(), eoa).await; + let public_spent = public_before - public_after; + println!( + "shield_e2e: EOA public spent = {public_spent} wei (>= gross value {shield_value} + gas)" + ); + assert!( + public_spent >= U256::from(shield_value), + "EOA public balance must drop by at least the gross shield value (+ gas)" + ); + + // Sanity that the drop is value + gas (not wildly more): bound it loosely (< value + 0.01 ETH gas). + assert!( + public_spent < U256::from(shield_value) + U256::from(10_000_000_000_000_000u128), + "EOA spend should be value + reasonable gas, got {public_spent}" + ); + + println!("=== shield_e2e PASSED: private +{expected_net}, public -{public_spent} ==="); +} + +/// Small helper so the alloy `PrivateKeySigner::from_str` import doesn't clash names with +/// railgun's signer in scope. +trait FromStrEoa { + fn from_str_eoa(s: &str) -> Self; +} +impl FromStrEoa for PrivateKeySigner { + fn from_str_eoa(s: &str) -> Self { + use std::str::FromStr; + PrivateKeySigner::from_str(s).expect("parse EOA key") + } +} diff --git a/vendor/eip-1193-provider/Cargo.toml b/vendor/eip-1193-provider/Cargo.toml new file mode 100644 index 0000000..8a59c91 --- /dev/null +++ b/vendor/eip-1193-provider/Cargo.toml @@ -0,0 +1,39 @@ +# Vendored, native-only fork of Kohaku's `eip-1193-provider` (git rev 618c53f), used ONLY +# via a `[patch]` at the workspace root so the heavy `railgun` (shield) tree can coexist with +# the GPUI app in ONE workspace. +# +# WHY THIS EXISTS: upstream `eip-1193-provider` has `default = ["alloy", "js"]`, and its `js` +# feature pulls `wasm-bindgen = "=0.2.108"` (an EXACT pin from kohaku's workspace). The app's +# GPUI stack pulls `web-sys` which pins `wasm-bindgen = "=0.2.122"` (also exact). Two exact +# pins are irreconcilable in one workspace. `js` is wasm32-only and Deckard never builds +# wasm32, so this fork simply DROPS the `js` feature + its wasm-bindgen deps. The native +# surface railgun actually uses (`provider`, `tx_data`, `alloy`) is byte-identical to upstream +# (the .rs files are copied verbatim from rev 618c53f; `js.rs` stays `#[cfg(js)]`-gated and is +# never compiled without the feature). +# +# `common` is pulled from the SAME kohaku git rev so the type identities match railgun's. +[package] +name = "eip-1193-provider" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["rlib"] + +[features] +# Drop `js` from defaults (and remove the feature entirely): wasm32-only, never built here. +default = ["alloy"] +alloy = ["alloy/network", "alloy/providers"] + +[dependencies] +alloy = { version = "1.8", default-features = false, features = ["rpc", "rpc-types", "sol-types"] } +async-trait = "0.1" +# The kohaku `common` crate (MaybeSend, etc.) at the proven rev — same identity railgun uses. +common = { git = "https://github.com/ethereum/kohaku", package = "common", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7" } +hex = "0.4" +serde = { version = "1", features = ["derive"] } +thiserror = "2" + +[build-dependencies] +cfg_aliases = "0.2.0" diff --git a/vendor/eip-1193-provider/build.rs b/vendor/eip-1193-provider/build.rs new file mode 100644 index 0000000..301d867 --- /dev/null +++ b/vendor/eip-1193-provider/build.rs @@ -0,0 +1,8 @@ +fn main() { + cfg_aliases::cfg_aliases! { + native: { not(target_arch = "wasm32") }, + wasm: { all(target_arch = "wasm32") }, + js: { all(target_arch = "wasm32", feature = "js") }, + alloy: { all(feature = "alloy") }, + } +} diff --git a/vendor/eip-1193-provider/src/alloy.rs b/vendor/eip-1193-provider/src/alloy.rs new file mode 100644 index 0000000..afa2a03 --- /dev/null +++ b/vendor/eip-1193-provider/src/alloy.rs @@ -0,0 +1,133 @@ +use std::sync::Arc; + +use alloy::{ + eips::BlockId, + network::TransactionBuilder, + primitives::{Address, Bytes, FixedBytes}, + providers::{DynProvider, Provider}, + rpc::types::{Filter, TransactionRequest}, + transports::{RpcError, TransportErrorKind}, +}; + +use crate::{ + provider::{Eip1193Error, Eip1193Provider, IntoEip1193Provider, RawLog}, + tx_data::TxData, +}; + +pub struct Alloy { + inner: Arc, +} + +impl Alloy { + pub fn new(inner: P) -> Self { + Self { + inner: Arc::new(inner), + } + } +} + +#[cfg_attr(native, async_trait::async_trait)] +#[cfg_attr(wasm, async_trait::async_trait(?Send))] +impl Eip1193Provider for Alloy { + async fn get_chain_id(&self) -> Result { + Ok(self.inner.get_chain_id().await?) + } + + async fn get_block_number(&self) -> Result { + Ok(self.inner.get_block_number().await?) + } + + async fn logs( + &self, + address: Address, + event_signature: Option>, + from_block: Option, + to_block: Option, + ) -> Result, Eip1193Error> { + let mut filter = Filter::new().address(address); + if let Some(event_signature) = event_signature { + filter = filter.event_signature(event_signature); + } + if let Some(from_block) = from_block { + filter = filter.from_block(from_block); + } + if let Some(to_block) = to_block { + filter = filter.to_block(to_block); + } + + let logs = self.inner.get_logs(&filter).await?; + let logs = logs + .into_iter() + .map(|log| RawLog { + topics: log.topics().to_vec(), + block_number: log.block_number, + block_timestamp: log.block_timestamp, + transaction_hash: log.transaction_hash, + address: log.address(), + data: log.data().data.clone(), + }) + .collect(); + + Ok(logs) + } + + async fn eth_call(&self, to: Address, data: Bytes) -> Result { + let request = TransactionRequest::default().to(to).with_input(data); + Ok(self.inner.call(request).await?) + } + + async fn estimate_gas( + &self, + to: Address, + data: Bytes, + from: Option
, + ) -> Result { + let mut request = TransactionRequest::default().to(to).with_input(data); + if let Some(f) = from { + request = request.from(f); + } + Ok(self.inner.estimate_gas(request).await?) + } + + async fn gas_price(&self) -> Result { + Ok(self.inner.get_gas_price().await?) + } + + async fn transaction_count( + &self, + address: Address, + block: Option, + ) -> Result { + let block_id = match block { + Some(b) => BlockId::number(b), + None => BlockId::latest(), + }; + + Ok(self + .inner + .get_transaction_count(address) + .block_id(block_id) + .await?) + } +} + +impl IntoEip1193Provider for DynProvider { + fn into_eip1193(self) -> Arc { + Arc::new(Alloy::new(self)) + } +} + +impl From> for Eip1193Error { + fn from(e: RpcError) -> Self { + Eip1193Error::Rpc(e.to_string()) + } +} + +impl From for TransactionRequest { + fn from(tx_data: TxData) -> Self { + TransactionRequest::default() + .to(tx_data.to) + .input(tx_data.data.into()) + .value(tx_data.value) + } +} diff --git a/vendor/eip-1193-provider/src/js.rs b/vendor/eip-1193-provider/src/js.rs new file mode 100644 index 0000000..990206e --- /dev/null +++ b/vendor/eip-1193-provider/src/js.rs @@ -0,0 +1,188 @@ +use alloy::primitives::{Address, Bytes, FixedBytes}; +use js_sys::BigInt; +use wasm_bindgen::prelude::*; + +use crate::provider::{Eip1193Error, Eip1193Provider, RawLog}; + +#[wasm_bindgen(typescript_custom_section)] +const TS_INTERFACE: &str = r#" +export interface Eip1193Provider { + getChainId(): Promise; + getBlockNumber(): Promise; + getLogs( + address: `0x${string}`, + eventSignature: `0x${string}` | undefined, + fromBlock: number | undefined, + toBlock: number | undefined, + ): Promise; + ethCall(to: `0x${string}`, data: `0x${string}`): Promise<`0x${string}`>; + estimateGas(to: `0x${string}`, from: `0x${string}` | undefined, data: `0x${string}`): Promise; + getGasPrice(): Promise; + getTransactionCount(address: `0x${string}`, block: number | undefined): Promise; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Eip1193Provider")] + pub type JsEip1193Provider; + + #[wasm_bindgen(method, catch, js_name = "getChainId")] + pub async fn get_chain_id(this: &JsEip1193Provider) -> Result; + + #[wasm_bindgen(method, catch, js_name = "getBlockNumber")] + pub async fn get_block_number(this: &JsEip1193Provider) -> Result; + + #[wasm_bindgen(method, catch, js_name = "getLogs")] + pub async fn get_logs( + this: &JsEip1193Provider, + address: &str, + event_signature: Option, + from_block: Option, + to_block: Option, + ) -> Result; + + #[wasm_bindgen(method, catch, js_name = "ethCall")] + pub async fn eth_call( + this: &JsEip1193Provider, + to: &str, + data: &str, + ) -> Result; + + #[wasm_bindgen(method, catch, js_name = "estimateGas")] + pub async fn estimate_gas( + this: &JsEip1193Provider, + to: &str, + from: Option, + data: &str, + ) -> Result; + + #[wasm_bindgen(method, catch, js_name = "getGasPrice")] + pub async fn get_gas_price(this: &JsEip1193Provider) -> Result; + + #[wasm_bindgen(method, catch, js_name = "getTransactionCount")] + pub async fn get_transaction_count( + this: &JsEip1193Provider, + address: &str, + block: Option, + ) -> Result; +} + +#[async_trait::async_trait(?Send)] +impl Eip1193Provider for JsEip1193Provider { + async fn get_chain_id(&self) -> Result { + let result = self + .get_chain_id() + .await + .map_err(|e| Eip1193Error::Rpc(format!("{:?}", e)))?; + js_bigint_to_u64(result) + } + + async fn get_block_number(&self) -> Result { + let result = self + .get_block_number() + .await + .map_err(|e| Eip1193Error::Rpc(format!("{:?}", e)))?; + js_bigint_to_u64(result) + } + + async fn logs( + &self, + address: Address, + event_signature: Option>, + from_block: Option, + to_block: Option, + ) -> Result, Eip1193Error> { + let addr_str = format!("{:#x}", address); + let sig_str = event_signature.map(|s| format!("{:#x}", s)); + + let result = self + .get_logs(&addr_str, sig_str, from_block, to_block) + .await + .map_err(|e| Eip1193Error::Rpc(format!("{:?}", e)))?; + + let logs: Vec = serde_wasm_bindgen::from_value(result) + .map_err(|e| Eip1193Error::Decode(e.to_string()))?; + Ok(logs) + } + + async fn eth_call(&self, to: Address, data: Bytes) -> Result { + let to_str = format!("{:#x}", to); + let data_str = format!("0x{}", hex::encode(data)); + + let result = self + .eth_call(&to_str, &data_str) + .await + .map_err(|e| Eip1193Error::Rpc(format!("{:?}", e)))?; + + let hex_str: String = serde_wasm_bindgen::from_value(result) + .map_err(|e| Eip1193Error::Decode(e.to_string()))?; + let bytes = parse_hex_bytes(&hex_str)?; + Ok(bytes.into()) + } + + async fn estimate_gas( + &self, + to: Address, + data: Bytes, + from: Option
, + ) -> Result { + let to_str = format!("{:#x}", to); + let from_str = from.map(|f| format!("{:#x}", f)); + let data_str = format!("0x{}", hex::encode(data)); + let result = self + .estimate_gas(&to_str, from_str, &data_str) + .await + .map_err(|e| Eip1193Error::Rpc(format!("{:?}", e)))?; + js_bigint_to_u64(result) + } + + async fn gas_price(&self) -> Result { + let result = self + .get_gas_price() + .await + .map_err(|e| Eip1193Error::Rpc(format!("{:?}", e)))?; + js_bigint_to_u128(result) + } + + async fn transaction_count( + &self, + address: Address, + block: Option, + ) -> Result { + let address_str = format!("{:#x}", address); + let block_num = block; + let result = self + .get_transaction_count(&address_str, block_num) + .await + .map_err(|e| Eip1193Error::Rpc(format!("{:?}", e)))?; + js_bigint_to_u64(result) + } +} + +fn js_bigint_to_u64(val: JsValue) -> Result { + let bigint = BigInt::from(val); + let s = bigint + .to_string(10) + .map_err(|e| Eip1193Error::Decode(format!("{:?}", e)))? + .as_string() + .ok_or_else(|| Eip1193Error::Decode("BigInt.toString returned non-string".into()))?; + s.parse::() + .map_err(|e| Eip1193Error::Decode(e.to_string())) +} + +fn js_bigint_to_u128(val: JsValue) -> Result { + let bigint = BigInt::from(val); + let s = bigint + .to_string(10) + .map_err(|e| Eip1193Error::Decode(format!("{:?}", e)))? + .as_string() + .ok_or_else(|| Eip1193Error::Decode("BigInt.toString returned non-string".into()))?; + s.parse::() + .map_err(|e| Eip1193Error::Decode(e.to_string())) +} + +fn parse_hex_bytes(s: &str) -> Result, Eip1193Error> { + let s = s.strip_prefix("0x").unwrap_or(s); + hex::decode(s).map_err(|e| Eip1193Error::Decode(e.to_string())) +} diff --git a/vendor/eip-1193-provider/src/lib.rs b/vendor/eip-1193-provider/src/lib.rs new file mode 100644 index 0000000..6c2f92a --- /dev/null +++ b/vendor/eip-1193-provider/src/lib.rs @@ -0,0 +1,6 @@ +#[cfg(alloy)] +pub mod alloy; +#[cfg(js)] +pub mod js; +pub mod provider; +pub mod tx_data; diff --git a/vendor/eip-1193-provider/src/provider.rs b/vendor/eip-1193-provider/src/provider.rs new file mode 100644 index 0000000..ef20eef --- /dev/null +++ b/vendor/eip-1193-provider/src/provider.rs @@ -0,0 +1,118 @@ +use std::sync::Arc; + +use alloy::{ + primitives::{Address, Bytes, FixedBytes, Log}, + sol_types::SolCall, +}; +use common::MaybeSend; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +// TODO: Split me up into multiple per-domain traits (logs, receipts, gas, +// transactions, caller, etc) +// +// TODO: Ensure I'm a fully compliant EIP-1193 provider +#[cfg_attr(native, async_trait::async_trait)] +#[cfg_attr(wasm, async_trait::async_trait(?Send))] +pub trait Eip1193Provider: MaybeSend { + async fn get_chain_id(&self) -> Result; + + async fn get_block_number(&self) -> Result; + + async fn logs( + &self, + address: Address, + event_signature: Option>, + from_block: Option, + to_block: Option, + ) -> Result, Eip1193Error>; + + async fn eth_call(&self, to: Address, data: Bytes) -> Result; + + async fn estimate_gas( + &self, + to: Address, + data: Bytes, + from: Option
, + ) -> Result; + + async fn gas_price(&self) -> Result; + + async fn transaction_count( + &self, + address: Address, + block: Option, + ) -> Result; +} + +#[cfg_attr(native, async_trait::async_trait)] +#[cfg_attr(wasm, async_trait::async_trait(?Send))] +pub trait Eip1193Caller: Eip1193Provider { + async fn sol_call( + &self, + to: Address, + call: C, + ) -> Result { + let data = call.abi_encode().into(); + let ret = self.eth_call(to, data).await?; + C::abi_decode_returns(&ret).map_err(|e| Eip1193Error::Decode(e.to_string())) + } +} + +pub trait IntoEip1193Provider { + fn into_eip1193(self) -> Arc; +} + +impl Eip1193Caller for T where T: Eip1193Provider + ?Sized {} + +impl IntoEip1193Provider for Arc +where + T: Eip1193Provider + 'static, +{ + fn into_eip1193(self) -> Arc { + self + } +} + +impl IntoEip1193Provider for Arc { + fn into_eip1193(self) -> Arc { + self + } +} + +#[derive(Debug, Error)] +pub enum Eip1193Error { + #[error("RPC error: {0}")] + Rpc(String), + #[error("Decode error: {0}")] + Decode(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] +pub struct RawLog { + #[cfg_attr(target_arch = "wasm32", tsify(type = "number | null"))] + pub block_number: Option, + + #[cfg_attr(target_arch = "wasm32", tsify(type = "number | null"))] + pub block_timestamp: Option, + + #[cfg_attr(target_arch = "wasm32", tsify(type = "`0x${string}` | null"))] + pub transaction_hash: Option>, + + #[cfg_attr(target_arch = "wasm32", tsify(type = "`0x${string}`"))] + pub address: Address, + + #[cfg_attr(target_arch = "wasm32", tsify(type = "`0x${string}`[]"))] + pub topics: Vec>, + + #[cfg_attr(target_arch = "wasm32", tsify(type = "`0x${string}`"))] + pub data: Bytes, +} + +impl RawLog { + pub fn inner(&self) -> Log { + Log::new_unchecked(self.address, self.topics.clone(), self.data.clone()) + } +} diff --git a/vendor/eip-1193-provider/src/tx_data.rs b/vendor/eip-1193-provider/src/tx_data.rs new file mode 100644 index 0000000..e647214 --- /dev/null +++ b/vendor/eip-1193-provider/src/tx_data.rs @@ -0,0 +1,20 @@ +use alloy::primitives::{Address, Bytes, U256}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(js, derive(tsify::Tsify))] +#[cfg_attr(js, tsify(into_wasm_abi, from_wasm_abi))] +pub struct TxData { + #[cfg_attr(js, tsify(type = "`0x${string}`"))] + pub to: Address, + #[cfg_attr(js, tsify(type = "`0x${string}`"))] + pub data: Bytes, + #[cfg_attr(js, tsify(type = "`0x${string}`"))] + pub value: U256, +} + +impl TxData { + pub fn new(to: Address, data: Bytes, value: U256) -> Self { + TxData { to, data, value } + } +} From 4e7c50948d5325a1676b7afddbb1afc51823d10d Mon Sep 17 00:00:00 2001 From: hellno Date: Sun, 7 Jun 2026 21:47:34 +0200 Subject: [PATCH 08/10] docs: single STATUS.md tracker + de-drift the README/specs status surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracking was scattered across 5 mostly-stale surfaces (root README "Status — v0", docs/build/README "spec ✓" table, specs/SPEC-v0-epic "pre-implementation", specs/HANDOFF "Built so far: src/wallet.rs…", roadmap). Consolidate: - NEW STATUS.md (repo root) = single source of truth: v1 demo beats, crates/tracks, spikes, v0 base, open risks — each with status + commit refs. - root README + docs/build/README: status sections now point to STATUS.md and are corrected to current reality (keystore/daemon/verified-reads/shield built, not "plaintext EOA / spec"). - docs/build/README: dropped the drifting per-doc "spec ✓" column (now a spec index), fixed the stale hero-spikes section (R1 proving measured = instant; R2 reads integrated). - specs/SPEC-v0-epic + HANDOFF: one-line "live status → /STATUS.md, fields below are stale" banners so they stop competing as trackers. No code change. --- README.md | 16 ++++++------ STATUS.md | 50 +++++++++++++++++++++++++++++++++++++ docs/build/README.md | 37 +++++++++++++++------------ specs/HANDOFF-next-phase.md | 4 +++ specs/SPEC-v0-epic.md | 6 ++++- 5 files changed, 87 insertions(+), 26 deletions(-) create mode 100644 STATUS.md diff --git a/README.md b/README.md index dfdfc23..a42940b 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,15 @@ Native (macOS + Linux), trustless by construction, open source. > Forked from the [`deck`](https://github.com/hellno/deck) GPUI starter (0BSD, which permits > relicensing). Now its own project: Rust + [GPUI](https://www.gpui.rs/), licensed AGPL-3.0-or-later. -## Status — v0 (working today) +## Status -- **Real self-custodial wallet** via [alloy](https://github.com/alloy-rs/alloy) - (`alloy-signer-local`): a secp256k1 keypair, EIP-55 address, key persisted to the OS config - dir. No hand-rolled crypto. -- **Portfolio** screen: address, balance, Send / Receive / Swap, holdings. -- **Receive**: a real scannable QR plus copy-to-clipboard. -- **Command palette** (`cmd-K`) and the amber-on-near-black design system (see `DESIGN.md`). -- Light / dark. +**Live build status: [`STATUS.md`](STATUS.md) — the single source of truth** (demo beats, crates, risks). -Representative / not yet wired (next): live balances, Send/Swap execution, BIP-39 seed backup. +Working today: encrypted BIP-39 keystore + onboarding, live on-chain balances (Multicall3), receive (QR), +command palette, and the amber-on-near-black design system (`DESIGN.md`). Reads are **Helios-verified** (no +third-party RPC trusted by default). A process-isolated signer daemon (`deckard-signerd`) holds the key and +gates every write. The **shield** hero (auto-private via Railgun) is wired + black-box tested on an anvil fork. +Next: receive-watcher, the agent (MCP) surface, Send/Swap UI. See `STATUS.md` for the beat-by-beat picture. ## Roadmap diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..555c915 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,50 @@ +# Deckard — STATUS + +> **Single source of truth for where the build stands.** Specs/design live in `specs/` + `docs/build/`; +> the git log is the audit trail. The READMEs and `specs/*` are reference, not status — update *this* file +> when a track changes state. +> +> Last updated: **2026-06-06** · at commit `a0a37fd`. + +Legend: ✅ done + tested · 🟡 partial / integrated-not-finished · 🧪 spiked (proven, not wired in) · ⬜ todo · ⏸ deferred + +## The v1 demo — *receive → instantly private → can't switch it off* (agent-driven) + +| Beat | What | Status | Where | Next | +|---|---|---|---|---| +| **1 · receive** | detect inbound + surface it | 🟡 receive (addr + QR) built; **auto-detect watcher ⬜** | `deckard-app/receive.rs`; `policy.auto_shield_min_wei` exists | a Helios `get_logs` watcher that emits the shield intent | +| **2 · shield (HERO)** | auto-shield received funds via Railgun | ✅ spiked + integrated + **black-box tested** | spike `c30cdd4`; `deckard-core/shield.rs` + signerd broadcast + `shield_e2e` `a0a37fd` | wire the receive-watcher trigger + the UI | +| **3 · walkaway** | cut the RPC on camera, stay verified | ✅ R2 spiked + verified-reads integrated; 🟡 in-app live cut | spike `5e3a16d`; reads `9e19e9a`; `ReadStatus` badge in app | failover supervisor (`Degraded`) + the in-app cut | +| **agent surface (MCP)** | Claude Desktop drives `shield` via MCP | ⬜ `deckard-mcp` not built; contract + daemon socket ✅ | `deckard-contract`, `deckard-signerd` | build `deckard-mcp` (key-less) over the daemon socket + `simulate` | + +## Crates / tracks + +| Crate | State | Tests (passing) | Key commits | +|---|---|---|---| +| `deckard-contract` | ✅ `Intent`/`Decision`/`Policy` + `ReadStatus` + `calldata_ok` non-empty invariant | 32 | `da29a37` `9e19e9a` `a0a37fd` | +| `deckard-core` | ✅ `EthProvider` (C1) + balances/Multicall3 (C2) + encrypted keystore (C3) + Helios verified reads + key-less shield builder | 13 | `e1aa079` `42e04ad` `57f21bc` `9e19e9a` `a0a37fd` | +| `deckard-signerd` | ✅ process-isolated signer daemon + policy gate + STOP/zeroize + Helios read + calldata broadcast | `daemon_e2e` 9 · `parity` 1 · `anvil_e2e` 3 · `shield_e2e` 1 | `a24f62c` `9e19e9a` `a0a37fd` | +| `deckard-app` (GPUI) | ✅ onboarding / portfolio / receive / palette / settings + `ReadStatus` badge + socket signer client; 🟡 Send UI gated ("next release"), Swap ⬜ | `send_path` | C1–C3 + this branch | + +## Spikes (de-risk, proven, standalone under `spikes/`) + +| Spike | Proves | Commit | +|---|---|---| +| `helios-walkaway` | R2 walkaway on mainnet (cut EL → keep verified; refuse a lying RPC) | `5e3a16d` | +| `eip1193-railgun` | Helios's localhost server **is** Railgun's EIP-1193 provider (one-line `with_default_block(latest)` fix) | `a64d4c5` | +| `shield-railgun` | full shield→sync→balance→transfer→unshield from our edge; shield is instant, proving is on the spend | `c30cdd4` | + +## v0 wallet base (`specs/SPEC-v0.md`) + +balances ✅ · receive ✅ · keystore + onboarding ✅ (BIP-39 vault) · **send** (daemon + app-socket path ✅/tested, UI gated 🟡) · **swap** ⬜ (CoW, deferred; button disabled) + +## Open risks / track-before-ship + +- **CL is the fragile, no-SLA dependency** (walkaway): Nimbus + dRPC are the two proven CLs; **cut the EL on camera, never the CL.** [`20`] +- **`vendor/eip-1193-provider`** native-only fork (dodges a `wasm-bindgen` exact-pin conflict) + **railgun license** (no upstream license field, same R1e) — resolve both before ship. [`10`] +- Shield is instant (no client proof); spend proving ~10s cold / ~halved with `parallel` → a "spending…" UX for unshield. [`10`] +- Daemon holds its mutex across a broadcast (documented v1 tradeoff). Receive-watcher, MCP, and railgun key-derivation for balance-display are deferred. + +## Deferred → `docs/research/roadmap.md` + +STOP-on-camera beat · allocate/donate · EIP-7702 session keys · x402/MPP plugins · stealth addresses · hardware-wallet signing · audit · Kurtosis hermetic-CI lane · production `HeliosEip1193` adapter · on-camera unshield/transfer. diff --git a/docs/build/README.md b/docs/build/README.md index 0bba887..8364b17 100644 --- a/docs/build/README.md +++ b/docs/build/README.md @@ -10,12 +10,15 @@ live on mainnet, agent-driven (Claude Desktop via MCP), shielded via Railgun, ve ## The docs -| Doc | Owns | Status | -|---|---|---| -| [`00-test-harness.md`](00-test-harness.md) | The **v0 baseline**: 3 local lanes + a headless agentic runner that drives the shot-list and self-asserts; mainnet fixtures; CI. | spec ✓ | -| [`10-kohaku-shield.md`](10-kohaku-shield.md) | The **hero action**: auto-shield via Kohaku's pure-Rust `railgun` crate. **R1 resolved** (crate is standalone-consumable). | spec ✓ | -| [`20-helios-sidecar.md`](20-helios-sidecar.md) | **Trustless reads + walkaway** via embedded Helios (`helios-ethereum` 0.11.1 as a Rust lib, git-only). **R2 proven** — runnable mainnet spike in `spikes/helios-walkaway/` (cold ≈11s, warm ≈2s, cut→failover ≤1 block). | spec ✓ + spike ✓ | -| [`30-mcp-shape.md`](30-mcp-shape.md) | The **agent surface** (one binary = CLI + MCP server, key-less) **and the freeze-first contract**. | spec ✓ | +> **Live build status for every track lives in [`STATUS.md`](../../STATUS.md) — the single source of truth.** +> This page is the spec *index* (what each doc owns), not a status surface; the deep docs are the spec. + +| Doc | Owns | +|---|---| +| [`00-test-harness.md`](00-test-harness.md) | The **test substrate**: local lanes (anvil fork / Sepolia) + a headless agentic runner that drives the shot-list and self-asserts; mainnet fixtures; CI. | +| [`10-kohaku-shield.md`](10-kohaku-shield.md) | The **hero action**: auto-shield via Kohaku's pure-Rust `railgun` crate. **R1 retired** — full shield→unshield runs from our edge, and shield is *instant* (no client proof; ZK proving is on the spend). | +| [`20-helios-sidecar.md`](20-helios-sidecar.md) | **Trustless reads + walkaway** via embedded `helios-ethereum` 0.11.1 (git-only). **R2 proven** + verified reads **integrated** into `EthProvider`/signerd behind `verified-reads`. | +| [`30-mcp-shape.md`](30-mcp-shape.md) | The **agent surface** (one binary = CLI + MCP server, key-less) **and the freeze-first contract**. Contract + daemon socket built; the `deckard-mcp` binary is not yet. | ## Build order (what gates what) @@ -62,16 +65,18 @@ against this; the harness's `FakeModel` exercises it before any LLM is in the lo ## The two hero-beat spikes -- **R1 — shield from Rust (10):** ✅ largely retired. Kohaku's `railgun` crate (v0.1.0, `rlib`) is proven - standalone-consumable by the repo's own `transact_utxo.rs` integration test (full shield→transfer→unshield - on an anvil Sepolia fork). Remaining: measure desktop proving time (is "instant" honest?) and confirm the - per-crate license vs the monorepo MIT. -- **R2 — walkaway (20):** ✅ proven on mainnet. Helios has **no native multi-EL/CL failover** (one client = one - EL + one CL); the head is **consensus-driven and EL-independent** (served from cache), so cutting the EL keeps - the head live while a second synced client recovers state reads via Deckard's own supervisor (Shape A). The - runnable spike (`spikes/helios-walkaway/`) does this headless. **Key finding: cut the *EL* on camera, never the - *CL*** — a dead CL freezes the head and Helios won't self-heal (needs a rebuild against CL #2). The CL is the - fragile, no-SLA, least-redundant dependency; self-host or pre-stage a second. See 20 for the measured numbers. +- **R1 — shield from Rust (10):** ✅ **retired + integrated.** Kohaku's `railgun` crate runs full + shield→transfer→unshield from our own dep edge (`spikes/shield-railgun/`), and the shield is now wired into + Deckard (key-less builder in `deckard-core` + signerd calldata-broadcast + a black-box `shield_e2e`). Proving + time *measured*: **shield is instant** (no client ZK proof, ~ms); ZK proving is on the *spend* (~10s cold, + ~halved with `parallel`) — so "instant auto-shield, prove-on-spend" is honest. Still open: railgun's per-crate + license (no upstream `license` field) + the `vendor/eip-1193-provider` fork — resolve before ship. +- **R2 — walkaway (20):** ✅ proven on mainnet **+ verified reads integrated.** Helios has **no native multi-EL/CL + failover** (one client = one EL + one CL); the head is **consensus-driven and EL-independent** (served from + cache), so cutting the EL keeps the head live while a second synced client recovers state reads via Deckard's + own supervisor (Shape A). Both read paths (`EthProvider` + signerd) now route through Helios behind a + `verified-reads` feature. **Key finding: cut the *EL* on camera, never the *CL*** — a dead CL freezes the head + and Helios won't self-heal. The CL is the fragile, no-SLA dependency; Nimbus + dRPC are the two proven CLs. ## Acceptance = the shot list (lives in `00-test-harness.md`) diff --git a/specs/HANDOFF-next-phase.md b/specs/HANDOFF-next-phase.md index 72c27a6..7488947 100644 --- a/specs/HANDOFF-next-phase.md +++ b/specs/HANDOFF-next-phase.md @@ -1,5 +1,9 @@ # Deckard — next-phase build handoff +> ⚠ **Stale snapshot.** Live build status is in [`/STATUS.md`](../STATUS.md) (single source of truth). The +> "Built so far" / paths below predate the `crates/` workspace restructure, the encrypted keystore, the signer +> daemon, and the Helios/shield integration. Kept for the GPUI gotchas + ground rules; use `/STATUS.md` for state. + For a fresh session to pick up implementation cold. Read this + `SPEC-v0-epic.md` (backlog C1–C9) + the repo's `DESIGN.md`. Project memory also carries the state. diff --git a/specs/SPEC-v0-epic.md b/specs/SPEC-v0-epic.md index 5b0a4cd..e5b8db1 100644 --- a/specs/SPEC-v0-epic.md +++ b/specs/SPEC-v0-epic.md @@ -1,6 +1,10 @@ # Epic: Deckard v0 — native self-custodial operator wallet -Status: spec (pre-implementation). Source requirements: `SPEC-v0.md`. Strategy: `strategy.md`. +> ⚠ **Live build status is in [`/STATUS.md`](../STATUS.md)** (single source of truth). The status fields in +> this epic are the *original plan* and are now stale (C1–C3 and much of v1 are built). Use this doc for the +> backlog/requirements, `/STATUS.md` for where things actually stand. + +Status: spec (pre-implementation) — **see `/STATUS.md` for actual progress.** Source requirements: `SPEC-v0.md`. Strategy: `strategy.md`. Target repo: fork of `hellno/deck`. License: AGPL-3.0. Chain: Ethereum mainnet only. ## Context From e5f887919867215b0705f3aded0603f92ae987f9 Mon Sep 17 00:00:00 2001 From: hellno Date: Sun, 7 Jun 2026 21:53:45 +0200 Subject: [PATCH 09/10] =?UTF-8?q?docs(status):=20reconcile=20STATUS.md=20t?= =?UTF-8?q?o=20the=20Codex=20audit=20=E2=80=94=20de-oversell=20the=20hero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent Codex audit (read-only, high reasoning) over all 4 crates + tests found no lies/stubs/spike-confusion, but that STATUS oversold 'done + tested' for the hero: - shield mechanism is built (core builder + daemon broadcast, confirmed) but shield_e2e is #[ignore] (not default cargo test) and shield is NOT reachable from the app or an agent (no trigger/watcher/MCP) -> downgraded to 🟡. - added a top 'reality check': mechanisms built + security state machine real, but the recordable demo FLOW (receive->shield->walkaway, agent-driven) is unwired end-to-end. - added per-table test caveats (shield_e2e ignored; anvil_e2e skips w/o anvil; core reads mocked; app send uses a fake daemon; STOP/gate tests are real). --- STATUS.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/STATUS.md b/STATUS.md index 555c915..3826a80 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,12 +8,18 @@ Legend: ✅ done + tested · 🟡 partial / integrated-not-finished · 🧪 spiked (proven, not wired in) · ⬜ todo · ⏸ deferred +> **Reality check (independent Codex audit, 2026-06-06):** the *mechanisms* are built + de-risked and the security +> state machine is real — but the **recordable demo FLOW does not exist end-to-end yet**. No receive-watcher, no +> agent/MCP trigger, no in-app live cut, and **shield is reachable only from the test/manual client, not the app +> or an agent.** "Tested" below includes `#[ignore]` network tests (need anvil + an archive RPC, not run by default +> `cargo test`) and some mocked/fake-daemon unit tests — noted per row. So: strong foundation, demo flow unwired. + ## The v1 demo — *receive → instantly private → can't switch it off* (agent-driven) | Beat | What | Status | Where | Next | |---|---|---|---|---| | **1 · receive** | detect inbound + surface it | 🟡 receive (addr + QR) built; **auto-detect watcher ⬜** | `deckard-app/receive.rs`; `policy.auto_shield_min_wei` exists | a Helios `get_logs` watcher that emits the shield intent | -| **2 · shield (HERO)** | auto-shield received funds via Railgun | ✅ spiked + integrated + **black-box tested** | spike `c30cdd4`; `deckard-core/shield.rs` + signerd broadcast + `shield_e2e` `a0a37fd` | wire the receive-watcher trigger + the UI | +| **2 · shield (HERO)** | auto-shield received funds via Railgun | 🟡 mechanism built (core builder + daemon broadcast) + black-box test — **but test is `#[ignore]` and shield is NOT app/agent-reachable** | spike `c30cdd4`; `deckard-core/shield.rs` + signerd broadcast + `shield_e2e` (#[ignore]) `a0a37fd` | a **trigger** (button/watcher/agent) + a **shielded-balance view** | | **3 · walkaway** | cut the RPC on camera, stay verified | ✅ R2 spiked + verified-reads integrated; 🟡 in-app live cut | spike `5e3a16d`; reads `9e19e9a`; `ReadStatus` badge in app | failover supervisor (`Degraded`) + the in-app cut | | **agent surface (MCP)** | Claude Desktop drives `shield` via MCP | ⬜ `deckard-mcp` not built; contract + daemon socket ✅ | `deckard-contract`, `deckard-signerd` | build `deckard-mcp` (key-less) over the daemon socket + `simulate` | @@ -26,6 +32,8 @@ Legend: ✅ done + tested · 🟡 partial / integrated-not-finished · 🧪 spik | `deckard-signerd` | ✅ process-isolated signer daemon + policy gate + STOP/zeroize + Helios read + calldata broadcast | `daemon_e2e` 9 · `parity` 1 · `anvil_e2e` 3 · `shield_e2e` 1 | `a24f62c` `9e19e9a` `a0a37fd` | | `deckard-app` (GPUI) | ✅ onboarding / portfolio / receive / palette / settings + `ReadStatus` badge + socket signer client; 🟡 Send UI gated ("next release"), Swap ⬜ | `send_path` | C1–C3 + this branch | +> Test caveats (per the audit): `signerd/shield_e2e` is `#[ignore]` (network + anvil + archive RPC); `anvil_e2e` runs by default but **silently skips if `anvil` is missing**; `deckard-core`'s reads are tested against a *mocked* transport (live-Helios path is untested by default); `deckard-app`'s `send_path` test uses a *fake recording daemon*, not real signerd/chain. The daemon STOP/zeroize + propose→Decision→execute tests are real (run by default). + ## Spikes (de-risk, proven, standalone under `spikes/`) | Spike | Proves | Commit | From 5c681ca9ea25f5c3b75f0b3bd2f3861d61e30170 Mon Sep 17 00:00:00 2001 From: hellno Date: Sun, 7 Jun 2026 22:10:55 +0200 Subject: [PATCH 10/10] docs(status): add the 'remaining for a recordable demo' punch-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanisms are de-risked + built; the gap is reachability + visible state. A) shield on screen (trigger + shielded-balance view — the one new build) B) in-app walkaway (cut control + ReadStatus badge flip) C) receive landing (balance refresh) D) agent spine (manual stand-in or deckard-mcp) E) one continuous take + polish. Critical path: A+B+C, D as narration. --- STATUS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/STATUS.md b/STATUS.md index 3826a80..5cd15b0 100644 --- a/STATUS.md +++ b/STATUS.md @@ -46,6 +46,18 @@ Legend: ✅ done + tested · 🟡 partial / integrated-not-finished · 🧪 spik balances ✅ · receive ✅ · keystore + onboarding ✅ (BIP-39 vault) · **send** (daemon + app-socket path ✅/tested, UI gated 🟡) · **swap** ⬜ (CoW, deferred; button disabled) +## Remaining for a minimum recordable demo + +The mechanisms are de-risked + built; the gap is **reachability + visible state** (turning terminal-proven paths into on-screen beats). In record order: + +- **A · Beat 2 (hero) — shield on screen** *(the one genuinely new build)*: a **trigger** (a "Shield" button → `build_shield_native_intent` → propose → execute, the path `shield_e2e` already drives) + a **shielded-balance view** (derive the railgun viewing key from the seed, in-app railgun `sync`, show the `0zk` balance rise while public drops + "link broken"). Without the shielded-balance view, "instantly private" has no visual. +- **B · Beat 3 — in-app walkaway**: a demo control to cut the RPC + the `ReadStatus` badge flipping `Verified → failover → Verified` (or `NOT VERIFIED`). The badge already renders in the GUI; minimum viable is the single-client "cut → NOT VERIFIED → reconnect → Verified" framing (the Shape-A failover supervisor is the nicer version, deferred). +- **C · Beat 1 — receive landing**: balance refreshes visibly when funds arrive (poll/refresh; QR exists). Auto-watcher → auto-shield is the fuller version; manual refresh suffices to record. +- **D · the "agent-driven" spine**: minimum = a scripted/manual stand-in (the app triggers the shield, narrated as the agent); fuller = build `deckard-mcp` + drive from Claude Desktop. +- **E · one continuous take + polish** to `DESIGN.md` (onboarding → funded → 3 beats). + +**Critical path for the smallest take:** A + B + C, with D as narration. **Biggest single build: the shielded-balance view (A).** + ## Open risks / track-before-ship - **CL is the fragile, no-SLA dependency** (walkaway): Nimbus + dRPC are the two proven CLs; **cut the EL on camera, never the CL.** [`20`]