From da29a373f0b8cf457024235d547c739dfdd10525 Mon Sep 17 00:00:00 2001 From: hellno Date: Fri, 5 Jun 2026 20:13:28 +0200 Subject: [PATCH 01/12] feat(contract): deckard-contract crate + virtual Cargo workspace Freeze the shared wire so the agent surface, custody/daemon, and test harness can build in parallel before the real signer daemon exists. New crate `crates/deckard-contract` (std + serde, zero key material): - Frozen types: Intent (carries chain_id; daemon owns the nonce), IntentKind, Decision, Policy, ApprovalMode, and the SignerRequest/ SignerResponse/ExecuteResult/ApprovalStatus/BalanceReport RPC enums. - A sync `Signer` trait + an in-memory `MockSigner`: deterministic and pinned (address 0x11.., tx_hash 0xAB.., request_ids 0x01,0x02,..), implementing the full policy decision matrix incl. the TOCTOU revoke guard at execute time. `Box` works. - Tests: serde_json + ciborium (CBOR) byte-stable round-trip per type; the decision matrix; and the daemon-free slice of 30-mcp-shape T1-T8. - Normal deps are only alloy-primitives + serde; ciborium/serde_json are dev-deps. id minting panics loudly on >255 proposals instead of wrapping. Workspace: root becomes a virtual manifest (members deckard-app/-core/ -contract, default-members deckard-app so `cargo run` still launches the GUI; [profile.release] + [workspace.dependencies] at the root). The app moves to `crates/deckard-app` via `git mv` (binary stays `deckard`). justfile + CI build/test `--workspace`; `just bundle` bundles the prebuilt icon.icns from crates/deckard-app/assets (cargo-bundle 0.11 can't convert the 1024px png, and resolves icon paths against the CWD). --- .github/workflows/ci.yml | 18 +- Cargo.lock | 74 ++- Cargo.toml | 89 +-- crates/deckard-app/Cargo.toml | 80 +++ .../deckard-app/assets}/icon.icns | Bin .../deckard-app/assets}/icon.png | Bin .../deckard-app/assets}/icon.svg | 0 {src => crates/deckard-app/src}/main.rs | 0 {src => crates/deckard-app/src}/onboarding.rs | 0 {src => crates/deckard-app/src}/palette.rs | 0 {src => crates/deckard-app/src}/receive.rs | 0 {src => crates/deckard-app/src}/settings.rs | 0 .../deckard-app/src}/settings_view.rs | 0 {src => crates/deckard-app/src}/shell.rs | 0 {src => crates/deckard-app/src}/theme.rs | 0 {src => crates/deckard-app/src}/tray.rs | 0 {src => crates/deckard-app/src}/wallet.rs | 0 {src => crates/deckard-app/src}/welcome.rs | 0 crates/deckard-contract/Cargo.toml | 19 + crates/deckard-contract/README.md | 24 + crates/deckard-contract/src/decision.rs | 21 + crates/deckard-contract/src/intent.rs | 39 ++ crates/deckard-contract/src/lib.rs | 205 ++++++ crates/deckard-contract/src/mock.rs | 626 ++++++++++++++++++ crates/deckard-contract/src/policy.rs | 36 + crates/deckard-contract/src/rpc.rs | 71 ++ crates/deckard-contract/src/signer.rs | 32 + .../deckard-contract/tests/harness_slice.rs | 108 +++ justfile | 21 +- 29 files changed, 1357 insertions(+), 106 deletions(-) create mode 100644 crates/deckard-app/Cargo.toml rename {assets => crates/deckard-app/assets}/icon.icns (100%) rename {assets => crates/deckard-app/assets}/icon.png (100%) rename {assets => crates/deckard-app/assets}/icon.svg (100%) rename {src => crates/deckard-app/src}/main.rs (100%) rename {src => crates/deckard-app/src}/onboarding.rs (100%) rename {src => crates/deckard-app/src}/palette.rs (100%) rename {src => crates/deckard-app/src}/receive.rs (100%) rename {src => crates/deckard-app/src}/settings.rs (100%) rename {src => crates/deckard-app/src}/settings_view.rs (100%) rename {src => crates/deckard-app/src}/shell.rs (100%) rename {src => crates/deckard-app/src}/theme.rs (100%) rename {src => crates/deckard-app/src}/tray.rs (100%) rename {src => crates/deckard-app/src}/wallet.rs (100%) rename {src => crates/deckard-app/src}/welcome.rs (100%) create mode 100644 crates/deckard-contract/Cargo.toml create mode 100644 crates/deckard-contract/README.md create mode 100644 crates/deckard-contract/src/decision.rs create mode 100644 crates/deckard-contract/src/intent.rs create mode 100644 crates/deckard-contract/src/lib.rs create mode 100644 crates/deckard-contract/src/mock.rs create mode 100644 crates/deckard-contract/src/policy.rs create mode 100644 crates/deckard-contract/src/rpc.rs create mode 100644 crates/deckard-contract/src/signer.rs create mode 100644 crates/deckard-contract/tests/harness_slice.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6259b5f..7b327f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: CI -# Builds the Deck on macOS *and* Linux so cross-platform stays honest. +# Builds + tests the Deckard workspace on macOS *and* Linux so cross-platform stays honest. # (GPUI renders with Metal on macOS and Vulkan via wgpu on Linux.) # # COST: free. Standard GitHub-hosted runners (macos-latest, ubuntu-latest) are @@ -30,9 +30,11 @@ 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 - - run: cargo build - - run: cargo build --features tray - - run: cargo clippy --all-targets --features tray -- -D warnings + - run: cargo build --workspace + - run: cargo build -p deckard-app --features tray + - run: cargo test --workspace + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo clippy -p deckard-app --all-targets --features tray -- -D warnings linux: runs-on: ubuntu-latest @@ -56,6 +58,8 @@ jobs: libfontconfig1-dev libfreetype6-dev \ libssl-dev \ libgtk-3-dev libayatana-appindicator3-dev libxdo-dev - - run: cargo build - - run: cargo build --features tray - - run: cargo clippy --all-targets --features tray -- -D warnings + - run: cargo build --workspace + - run: cargo build -p deckard-app --features tray + - run: cargo test --workspace + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo clippy -p deckard-app --all-targets --features tray -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index 0179916..ebe8538 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -741,7 +741,7 @@ dependencies = [ "alloy-rlp", "alloy-serde 1.8.3", "alloy-sol-types", - "itertools 0.13.0", + "itertools 0.14.0", "serde", "serde_json", "serde_with", @@ -762,7 +762,7 @@ dependencies = [ "alloy-rlp", "alloy-serde 2.0.5", "alloy-sol-types", - "itertools 0.13.0", + "itertools 0.14.0", "serde", "serde_json", "serde_with", @@ -960,7 +960,7 @@ checksum = "e8597d36d546e1dab822345ad563243ec3920e199322cb554ce56c8ef1a1e2e7" dependencies = [ "alloy-json-rpc 1.8.3", "alloy-transport", - "itertools 0.13.0", + "itertools 0.14.0", "reqwest", "serde_json", "tower", @@ -1783,7 +1783,7 @@ dependencies = [ "bitflags 2.12.1", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -2261,6 +2261,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -2884,11 +2911,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] -name = "deckard" +name = "deckard-app" version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-signer-local 2.0.5", + "deckard-contract", "deckard-core", "directories", "gpui", @@ -2905,6 +2933,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deckard-contract" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "ciborium", + "serde", + "serde_json", +] + [[package]] name = "deckard-core" version = "0.1.0" @@ -3062,7 +3100,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3332,7 +3370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5958,7 +5996,7 @@ dependencies = [ "once_cell", "png 0.18.1", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6120,7 +6158,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7973,7 +8011,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7986,7 +8024,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8053,7 +8091,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8729,7 +8767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8785,7 +8823,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9174,7 +9212,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9659,7 +9697,7 @@ dependencies = [ "once_cell", "png 0.18.1", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9744,7 +9782,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10500,7 +10538,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3456779..c28441d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,80 +1,25 @@ -[package] -name = "deckard" -version = "0.1.0" -edition = "2021" -description = "Deckard — a native, self-custodial Ethereum wallet for onchain operators (GPUI + Rust). Forked from the deck starter; now its own project." -license = "AGPL-3.0-or-later" -default-run = "deckard" - -# The GUI app is the workspace root; `deckard-core` is the headless engine (no GPUI). +# Deckard — virtual Cargo workspace. +# +# The root carries NO `[package]`; every crate lives under `crates/`: +# - 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) +# +# `cargo run` from the repo root still launches the app via `default-members`. [workspace] -members = ["crates/deckard-core"] - -[[bin]] -name = "deckard" -path = "src/main.rs" - -[dependencies] -# The headless engine: Ethereum provider, balances, HD keys, keystore. No GPUI. -deckard-core = { path = "crates/deckard-core" } - -# 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 -# fresh gpui with the component kit is this matched git pair. Reproducibility comes from -# the committed Cargo.lock (it pins exact commits); bump on a cadence with `just bump-gpui`. -# Prefer the simpler-but-stale pure-crates.io pair instead? See docs/UPGRADING.md. -gpui = { git = "https://github.com/zed-industries/zed" } -# After Zed split gpui into multiple crates, the windowing + renderer backend (and the -# `Application` bootstrap) live in `gpui_platform`. -gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit"] } -gpui-component = { git = "https://github.com/longbridge/gpui-component" } -# Prebuilt asset source: all gpui-component icon SVGs + bundled fonts. -# Register it with `.with_assets(...)` so `IconName::*` renders. -gpui-component-assets = { git = "https://github.com/longbridge/gpui-component" } - -# Preferences: serialize a Settings struct to the platform config dir. -serde = { version = "1", features = ["derive"] } -serde_json = "1" -directories = "5" -qrcode = "0.14" -# Scrub the pending recovery phrase from memory after onboarding. -zeroize = "1" +resolver = "2" +members = ["crates/deckard-app", "crates/deckard-core", "crates/deckard-contract"] +default-members = ["crates/deckard-app"] -# --- Optional: menu-bar / tray apps (`--features tray`) — cross-platform --- -# tray-icon draws a native status item on every OS: NSStatusItem (macOS), -# libappindicator / StatusNotifierItem (Linux), Shell_NotifyIcon (Windows). -# There is no second renderer — your windows stay GPUI everywhere. -tray-icon = { version = "0.24", optional = true } -alloy-signer-local = { version = "2.0.5", features = ["mnemonic"] } +[workspace.dependencies] +# Single-sourced versions shared across crates. deckard-contract pins to these; +# the app and core may layer extra features on top (e.g. alloy-primitives `serde`). alloy-primitives = "1.6.0" +serde = { version = "1", features = ["derive"] } -[features] -default = [] -tray = ["dep:tray-icon", "dep:objc2", "dep:objc2-app-kit", "dep:objc2-foundation"] - -# objc2 is only used to hide the macOS dock icon — pull it on macOS only, so a -# Linux/Windows `--features tray` build never tries to compile Apple crates. -[target.'cfg(target_os = "macos")'.dependencies] -objc2 = { version = "0.6", optional = true } -objc2-app-kit = { version = "0.3", optional = true } -objc2-foundation = { version = "0.3", optional = true } - +# Profiles are only honoured at the workspace root, so they live here (not in the +# app crate). These shrink the release binary: strip symbols, thin-LTO, one codegen unit. [profile.release] strip = true lto = "thin" codegen-units = 1 - -# --------------------------------------------------------------------------- -# `cargo bundle` config (batteries included). On macOS → Deck.app; cargo -# bundle can also emit `deb` on Linux. Run `just bundle`. -# cargo install cargo-bundle -# Drop your own 1024x1024 PNG at assets/icon.png and re-bundle to rebrand. -# --------------------------------------------------------------------------- -[package.metadata.bundle] -name = "Deckard" -identifier = "com.deckard.app" -icon = ["assets/icon.png"] -category = "public.app-category.finance" -short_description = "A native, self-custodial Ethereum wallet for onchain operators." -long_description = "Deckard is a fast, keyboard-first, self-custodial Ethereum wallet for people who live onchain. Native (macOS + Linux), trustless by construction, open source (AGPL-3.0)." -osx_minimum_system_version = "11.0" diff --git a/crates/deckard-app/Cargo.toml b/crates/deckard-app/Cargo.toml new file mode 100644 index 0000000..f9edaca --- /dev/null +++ b/crates/deckard-app/Cargo.toml @@ -0,0 +1,80 @@ +[package] +name = "deckard-app" +version = "0.1.0" +edition = "2021" +description = "Deckard — a native, self-custodial Ethereum wallet for onchain operators (GPUI + Rust). The GUI app crate; the binary is `deckard`." +license = "AGPL-3.0-or-later" +default-run = "deckard" + +# The binary name stays `deckard` even though the package is `deckard-app`, so +# `cargo run` / the bundle / `claude mcp` registrations are unchanged. +[[bin]] +name = "deckard" +path = "src/main.rs" + +[dependencies] +# The headless engine: Ethereum provider, balances, HD keys, keystore. No GPUI. +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" } + +# 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 +# fresh gpui with the component kit is this matched git pair. Reproducibility comes from +# the committed Cargo.lock (it pins exact commits); bump on a cadence with `just bump-gpui`. +# Prefer the simpler-but-stale pure-crates.io pair instead? See docs/UPGRADING.md. +gpui = { git = "https://github.com/zed-industries/zed" } +# After Zed split gpui into multiple crates, the windowing + renderer backend (and the +# `Application` bootstrap) live in `gpui_platform`. +gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit"] } +gpui-component = { git = "https://github.com/longbridge/gpui-component" } +# Prebuilt asset source: all gpui-component icon SVGs + bundled fonts. +# Register it with `.with_assets(...)` so `IconName::*` renders. +gpui-component-assets = { git = "https://github.com/longbridge/gpui-component" } + +# Preferences: serialize a Settings struct to the platform config dir. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +directories = "5" +qrcode = "0.14" +# Scrub the pending recovery phrase from memory after onboarding. +zeroize = "1" + +# --- Optional: menu-bar / tray apps (`--features tray`) — cross-platform --- +# tray-icon draws a native status item on every OS: NSStatusItem (macOS), +# libappindicator / StatusNotifierItem (Linux), Shell_NotifyIcon (Windows). +# There is no second renderer — your windows stay GPUI everywhere. +tray-icon = { version = "0.24", optional = true } +alloy-signer-local = { version = "2.0.5", features = ["mnemonic"] } +alloy-primitives = "1.6.0" + +[features] +default = [] +tray = ["dep:tray-icon", "dep:objc2", "dep:objc2-app-kit", "dep:objc2-foundation"] + +# objc2 is only used to hide the macOS dock icon — pull it on macOS only, so a +# Linux/Windows `--features tray` build never tries to compile Apple crates. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { version = "0.6", optional = true } +objc2-app-kit = { version = "0.3", optional = true } +objc2-foundation = { version = "0.3", optional = true } + +# --------------------------------------------------------------------------- +# `cargo bundle` config. On macOS → Deckard.app. Run `just bundle`, which cd's +# into this crate first so cargo-bundle resolves the relative icon path against +# crates/deckard-app/ — it resolves icons against the CWD, not the manifest dir. +# cargo install cargo-bundle +# We point at the prebuilt assets/icon.icns directly: cargo-bundle 0.11's PNG→icns +# converter rejects the 1024px icon.png ("No matching IconType"), but copies a +# ready .icns straight through. To rebrand: edit assets/icon.svg, run `just icon` +# (regenerates icon.png + icon.icns), then `just bundle`. +# --------------------------------------------------------------------------- +[package.metadata.bundle] +name = "Deckard" +identifier = "com.deckard.app" +icon = ["assets/icon.icns"] +category = "public.app-category.finance" +short_description = "A native, self-custodial Ethereum wallet for onchain operators." +long_description = "Deckard is a fast, keyboard-first, self-custodial Ethereum wallet for people who live onchain. Native (macOS + Linux), trustless by construction, open source (AGPL-3.0)." +osx_minimum_system_version = "11.0" diff --git a/assets/icon.icns b/crates/deckard-app/assets/icon.icns similarity index 100% rename from assets/icon.icns rename to crates/deckard-app/assets/icon.icns diff --git a/assets/icon.png b/crates/deckard-app/assets/icon.png similarity index 100% rename from assets/icon.png rename to crates/deckard-app/assets/icon.png diff --git a/assets/icon.svg b/crates/deckard-app/assets/icon.svg similarity index 100% rename from assets/icon.svg rename to crates/deckard-app/assets/icon.svg diff --git a/src/main.rs b/crates/deckard-app/src/main.rs similarity index 100% rename from src/main.rs rename to crates/deckard-app/src/main.rs diff --git a/src/onboarding.rs b/crates/deckard-app/src/onboarding.rs similarity index 100% rename from src/onboarding.rs rename to crates/deckard-app/src/onboarding.rs diff --git a/src/palette.rs b/crates/deckard-app/src/palette.rs similarity index 100% rename from src/palette.rs rename to crates/deckard-app/src/palette.rs diff --git a/src/receive.rs b/crates/deckard-app/src/receive.rs similarity index 100% rename from src/receive.rs rename to crates/deckard-app/src/receive.rs diff --git a/src/settings.rs b/crates/deckard-app/src/settings.rs similarity index 100% rename from src/settings.rs rename to crates/deckard-app/src/settings.rs diff --git a/src/settings_view.rs b/crates/deckard-app/src/settings_view.rs similarity index 100% rename from src/settings_view.rs rename to crates/deckard-app/src/settings_view.rs diff --git a/src/shell.rs b/crates/deckard-app/src/shell.rs similarity index 100% rename from src/shell.rs rename to crates/deckard-app/src/shell.rs diff --git a/src/theme.rs b/crates/deckard-app/src/theme.rs similarity index 100% rename from src/theme.rs rename to crates/deckard-app/src/theme.rs diff --git a/src/tray.rs b/crates/deckard-app/src/tray.rs similarity index 100% rename from src/tray.rs rename to crates/deckard-app/src/tray.rs diff --git a/src/wallet.rs b/crates/deckard-app/src/wallet.rs similarity index 100% rename from src/wallet.rs rename to crates/deckard-app/src/wallet.rs diff --git a/src/welcome.rs b/crates/deckard-app/src/welcome.rs similarity index 100% rename from src/welcome.rs rename to crates/deckard-app/src/welcome.rs diff --git a/crates/deckard-contract/Cargo.toml b/crates/deckard-contract/Cargo.toml new file mode 100644 index 0000000..ae7d145 --- /dev/null +++ b/crates/deckard-contract/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "deckard-contract" +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-or-later" +description = "Deckard's frozen wire contract: Intent / Decision / Policy, the signer-daemon RPC enums, a sync Signer trait, and an in-memory MockSigner. Zero key material. Owned by docs/build/30-mcp-shape.md." + +[dependencies] +# EVM value types (Address / U256 / Bytes / B256). The `serde` feature is what lets +# these cross the wire as JSON (MCP) and CBOR (the daemon UDS). +alloy-primitives = { workspace = true, features = ["serde"] } +serde = { workspace = true } + +[dev-dependencies] +# Wire-format round-trip tests only — NOT a runtime dependency of the crate, so they +# never appear in `cargo tree -p deckard-contract -e normal`. JSON is the MCP encoding, +# CBOR (ciborium) is the daemon-socket encoding. +serde_json = "1" +ciborium = "0.2" diff --git a/crates/deckard-contract/README.md b/crates/deckard-contract/README.md new file mode 100644 index 0000000..c193929 --- /dev/null +++ b/crates/deckard-contract/README.md @@ -0,0 +1,24 @@ +# deckard-contract + +Frozen contract owned by `docs/build/30-mcp-shape.md` — do not redefine these types elsewhere. + +This crate is the single source of truth for the wire every Deckard process speaks: + +- **`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. + +## 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. + +## Deterministic mock + +`MockSigner` is pinned for byte-stable tests: `address = 0x1111…11`, broadcast `tx_hash = 0xABAB…AB`, and `request_id`s assigned `0x0101…01`, `0x0202…02`, … in order. See `mock.rs` for the policy decision matrix (caps, allowlist, approval, and the TOCTOU revoke guard). + +## Encodings + +Every type round-trips through both `serde_json` (the MCP encoding) and `ciborium` / CBOR (the daemon-socket encoding); see the tests. Normal dependencies are exactly `alloy-primitives` + `serde`; `serde_json` and `ciborium` are dev-dependencies only. diff --git a/crates/deckard-contract/src/decision.rs b/crates/deckard-contract/src/decision.rs new file mode 100644 index 0000000..887a05f --- /dev/null +++ b/crates/deckard-contract/src/decision.rs @@ -0,0 +1,21 @@ +//! The daemon's verdict, returned by `propose`. The agent cannot forge `Allow`. + +use alloy_primitives::B256; +use serde::{Deserialize, Serialize}; + +/// What `propose` decided about an [`crate::Intent`]. A `Decision::Allow` or an approved +/// `RequestId` is the *only* token that lets `execute` sign. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Decision { + /// Within policy → safe to `execute`. + Allow, + /// Policy violation; terminal. `reason` is a short machine-readable tag + /// (e.g. `revoked`, `off_allowlist`, `undecodable`, `over_cap`). + Deny { reason: String }, + /// A human must approve via the native card before `execute` will sign. + NeedsApproval { request_id: RequestId }, +} + +/// Opaque approval handle; the agent polls `status` on it. (A 32-byte hash so the daemon +/// can make it unguessable in production.) +pub type RequestId = B256; diff --git a/crates/deckard-contract/src/intent.rs b/crates/deckard-contract/src/intent.rs new file mode 100644 index 0000000..b49bf97 --- /dev/null +++ b/crates/deckard-contract/src/intent.rs @@ -0,0 +1,39 @@ +//! What the agent wants to do — the ONLY thing that crosses `mcp → daemon` for a write. +//! The agent never sends raw signed bytes, only intent; the daemon decides and signs. + +use alloy_primitives::{Address, Bytes, U256}; +use serde::{Deserialize, Serialize}; + +/// A proposed write. Carries `chain_id` (multi-chain ready); the daemon owns the nonce +/// and assigns it at sign time — there is deliberately no nonce field here. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Intent { + /// EVM chain the daemon must sign for. The agent picks the chain; the daemon picks + /// the nonce. + pub chain_id: u64, + /// Target: recipient, token contract, or Railgun adapter, depending on `kind`. + pub to: Address, + /// `None` = native ETH; `Some` = an ERC-20 contract. + pub token: Option
, + /// Wei (native) or token base units. + pub value: U256, + /// Empty for a plain send; the encoded call otherwise. + pub calldata: Bytes, + /// The discriminator the policy gate switches on. + pub kind: IntentKind, +} + +/// The class of write. The Railgun deposit/withdraw calldata for `Shield`/`Unshield` +/// rides in [`Intent::calldata`] (owned by `docs/build/10-kohaku-shield.md`); this enum +/// is purely the discriminator. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum IntentKind { + /// Plain transfer (native or ERC-20). Calldata is empty for native sends. + Send, + /// Railgun deposit — the demo hero. Calldata carries the adapter call. + Shield, + /// Railgun withdraw back to a public balance. + Unshield, + /// Generic contract write (forward-compat for plugins). Calldata is the call. + ContractCall, +} diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs new file mode 100644 index 0000000..932227e --- /dev/null +++ b/crates/deckard-contract/src/lib.rs @@ -0,0 +1,205 @@ +//! # deckard-contract +//! +//! The **freeze-first wire** every Deckard process speaks: the `Intent` / `Decision` / +//! `Policy` types, the signer-daemon RPC enums, a sync [`Signer`] trait, and an in-memory +//! [`MockSigner`]. Published as a standalone crate so the agent surface (`deckard-mcp`), +//! the desktop app, and the test harness can build and run the acceptance scenario +//! **before** the real signer daemon (`deckard-signerd`) exists. +//! +//! Frozen contract owned by `docs/build/30-mcp-shape.md` — do not redefine these types +//! elsewhere. This crate carries **zero key material**: types + a trait + a mock. It never +//! signs and never holds a key; the key boundary is the daemon's process. +//! +//! ## Encodings +//! +//! The types are `serde`-derived so the same definitions serialize to **JSON** (the MCP +//! surface) and **CBOR** (the daemon's Unix-domain-socket framing, via `ciborium`). Both +//! encodings round-trip byte-stably; see the crate tests. +//! +//! **Wei on the JSON wire are 0x-hex strings, not bare numbers.** `alloy-primitives` +//! encodes every `U256` (e.g. [`Intent::value`], the [`Policy`] caps, [`BalanceReport`]) +//! as a `"0x…"` string in JSON. A JSON producer (a JS/Python MCP client) MUST emit wei that +//! way: a bare number literal above `u64::MAX` — routine for wei (> ~18.4 ETH) — is parsed +//! as a float and rejected on decode. CBOR has no such limit. + +pub mod decision; +pub mod intent; +pub mod mock; +pub mod policy; +pub mod rpc; +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 signer::Signer; + +#[cfg(test)] +mod roundtrip_tests { + //! Every wire type must survive both encodings unchanged: JSON (the MCP surface) and + //! CBOR (the daemon UDS, via ciborium). Both are also asserted byte-stable (re-encoding + //! the same value yields identical bytes) — the wire types contain no maps/sets, so + //! encoding is deterministic. + + use super::*; + use alloy_primitives::{Address, Bytes, B256, U256}; + use core::fmt::Debug; + use serde::de::DeserializeOwned; + use serde::Serialize; + + fn roundtrip(value: &T) { + // JSON (human-readable): encode → decode → assert_eq, and assert byte-stability. + let json = serde_json::to_vec(value).expect("json encode"); + let from_json: T = serde_json::from_slice(&json).expect("json decode"); + assert_eq!(&from_json, value, "json round-trip changed the value"); + assert_eq!( + json, + serde_json::to_vec(value).unwrap(), + "json not byte-stable" + ); + + // CBOR (binary): encode → decode → assert_eq, and assert byte-stability. + let mut cbor = Vec::new(); + ciborium::into_writer(value, &mut cbor).expect("cbor encode"); + let from_cbor: T = ciborium::from_reader(&cbor[..]).expect("cbor decode"); + assert_eq!(&from_cbor, value, "cbor round-trip changed the value"); + let mut cbor2 = Vec::new(); + ciborium::into_writer(value, &mut cbor2).unwrap(); + assert_eq!(cbor, cbor2, "cbor not byte-stable"); + } + + fn sample_intent(kind: IntentKind) -> Intent { + Intent { + chain_id: 8453, + to: Address::repeat_byte(0x22), + token: Some(Address::repeat_byte(0x33)), + value: U256::from(123_456_789_u64), + calldata: Bytes::from_static(&[0x01, 0x02, 0x03, 0x04]), + kind, + } + } + + fn sample_policy() -> Policy { + Policy { + per_tx_cap_wei: U256::from(50_000_000_000_000_000_u64), + daily_cap_wei: U256::from(1_000_000_000_000_000_000_u64), + spent_today_wei: U256::from(7_u64), + allow_to: vec![Address::repeat_byte(0xAA), Address::repeat_byte(0xBB)], + auto_shield_min_wei: U256::from(10_000_000_000_000_000_u64), + require_approval: ApprovalMode::OverCap, + revoked: false, + } + } + + #[test] + fn intent_and_kind_roundtrip() { + for kind in [ + IntentKind::Send, + IntentKind::Shield, + IntentKind::Unshield, + IntentKind::ContractCall, + ] { + roundtrip(&kind); + roundtrip(&sample_intent(kind)); + } + // native ETH (token = None) and empty calldata + roundtrip(&Intent { + token: None, + calldata: Bytes::new(), + ..sample_intent(IntentKind::Send) + }); + } + + #[test] + fn decision_roundtrip() { + roundtrip(&Decision::Allow); + roundtrip(&Decision::Deny { + reason: "off_allowlist".into(), + }); + roundtrip(&Decision::NeedsApproval { + request_id: B256::repeat_byte(0x01), + }); + } + + #[test] + fn policy_and_mode_roundtrip() { + for mode in [ + ApprovalMode::Never, + ApprovalMode::OverCap, + ApprovalMode::Always, + ] { + roundtrip(&mode); + } + roundtrip(&sample_policy()); + // empty allowlist + revoked variant + roundtrip(&Policy { + allow_to: vec![], + revoked: true, + ..sample_policy() + }); + } + + #[test] + fn signer_request_roundtrip() { + roundtrip(&SignerRequest::Propose { + intent: sample_intent(IntentKind::Shield), + }); + roundtrip(&SignerRequest::Execute { + request_id: B256::repeat_byte(0x02), + }); + roundtrip(&SignerRequest::Status { + request_id: B256::repeat_byte(0x03), + }); + roundtrip(&SignerRequest::RevokeAll); + roundtrip(&SignerRequest::PolicyGet); + roundtrip(&SignerRequest::Address); + roundtrip(&SignerRequest::Balance { shielded: true }); + roundtrip(&SignerRequest::Balance { shielded: false }); + } + + #[test] + fn signer_response_roundtrip() { + roundtrip(&SignerResponse::Decision(Decision::Allow)); + roundtrip(&SignerResponse::Execute(ExecuteResult::Broadcast { + tx_hash: B256::repeat_byte(0xAB), + })); + roundtrip(&SignerResponse::Status(ApprovalStatus::Pending)); + roundtrip(&SignerResponse::Ack); + roundtrip(&SignerResponse::Policy(sample_policy())); + roundtrip(&SignerResponse::Address(Address::repeat_byte(0x11))); + roundtrip(&SignerResponse::Balance(BalanceReport { + public_wei: U256::from(1_u64), + shielded_wei: U256::from(2_u64), + })); + } + + #[test] + fn execute_result_and_status_roundtrip() { + roundtrip(&ExecuteResult::Broadcast { + tx_hash: B256::repeat_byte(0xAB), + }); + roundtrip(&ExecuteResult::Denied { + reason: "already_executed".into(), + }); + roundtrip(&ApprovalStatus::Pending); + roundtrip(&ApprovalStatus::Allowed); + roundtrip(&ApprovalStatus::Denied { + reason: "revoked".into(), + }); + roundtrip(&ApprovalStatus::Expired); + } + + #[test] + fn balance_report_roundtrip() { + roundtrip(&BalanceReport { + public_wei: U256::from(0_u64), + shielded_wei: U256::from(0_u64), + }); + roundtrip(&BalanceReport { + public_wei: U256::MAX, + shielded_wei: U256::from(42_u64), + }); + } +} diff --git a/crates/deckard-contract/src/mock.rs b/crates/deckard-contract/src/mock.rs new file mode 100644 index 0000000..0479304 --- /dev/null +++ b/crates/deckard-contract/src/mock.rs @@ -0,0 +1,626 @@ +//! An in-memory, deterministic [`Signer`] so the agent surface, the desktop app, and the +//! test harness can run the acceptance scenario before the real `deckard-signerd` exists. +//! +//! Pinned for byte-stable tests: address `0x1111…11`, broadcast tx hash `0xABAB…AB`, and +//! `request_id`s assigned `0x0101…01`, `0x0202…02`, … in propose order. Holds a `Mutex` +//! and a `Mutex` of in-flight requests; **carries no key material** and never signs anything — +//! `execute` just returns the pinned hash. + +use std::collections::HashMap; +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::signer::Signer; + +/// 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). +#[derive(Clone, Debug)] +struct Request { + intent: Intent, + status: ApprovalStatus, + broadcast: Option, +} + +/// The request table, the deterministic id counter (`1, 2, …`), and the most recently +/// minted id. The pinned single-byte `repeat_byte(n)` scheme tops out at 255 ids. +#[derive(Debug)] +struct Requests { + by_id: HashMap, + next_id: u8, + last_id: Option, +} + +/// 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. +#[derive(Debug)] +pub struct MockSigner { + policy: Mutex, + requests: Mutex, + balance: Mutex, +} + +impl MockSigner { + /// Build a mock from a starting policy. Balances default to zero; set them with + /// [`MockSigner::set_balance`]. + pub fn new(policy: Policy) -> Self { + Self { + policy: Mutex::new(policy), + requests: Mutex::new(Requests { + by_id: HashMap::new(), + next_id: 1, + last_id: None, + }), + balance: Mutex::new(BalanceReport { + public_wei: U256::ZERO, + shielded_wei: U256::ZERO, + }), + } + } + + /// The pinned deterministic address (`0x1111…11`). + pub fn mock_address() -> Address { + Address::repeat_byte(0x11) + } + + /// The pinned broadcast tx hash every successful `execute` returns (`0xABAB…AB`). + pub fn broadcast_tx_hash() -> B256 { + B256::repeat_byte(0xAB) + } + + /// Overwrite the reported balances (setup helper). + pub fn set_balance(&self, report: BalanceReport) { + *self.balance.lock().expect("mock balance mutex poisoned") = report; + } + + /// Test helper: flip a `Pending` request to `Allowed`, simulating the human tapping + /// Approve on the native card. No-op for any other state. + 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; + } + } + } + + /// Test helper: the id of the most recently minted request, or `None` if none yet. + /// Useful for executing an `Allow` decision, which does not carry the id on the wire. + pub fn last_request_id(&self) -> Option { + self.requests + .lock() + .expect("mock requests mutex poisoned") + .last_id + } + + /// Mint the next deterministic id (`repeat_byte(1)`, `repeat_byte(2)`, …). Caller holds + /// the requests lock. The pinned single-byte scheme yields at most 255 distinct ids; + /// minting a 256th would collide with a live entry, so we panic loudly rather than + /// silently wrap (which would clobber an in-flight request and defeat idempotency). + fn mint_id(reqs: &mut Requests) -> RequestId { + assert!( + reqs.next_id != 0, + "MockSigner request-id space (u8) exhausted: this mock supports at most 255 proposals" + ); + let id = B256::repeat_byte(reqs.next_id); + reqs.next_id = reqs.next_id.wrapping_add(1); + reqs.last_id = Some(id); + id + } +} + +/// 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 address(&self) -> Address { + Self::mock_address() + } + + fn balance(&self, _shielded: bool) -> BalanceReport { + self.balance + .lock() + .expect("mock balance mutex poisoned") + .clone() + } + + fn policy(&self) -> Policy { + self.policy + .lock() + .expect("mock policy mutex poisoned") + .clone() + } + + fn propose(&self, intent: &Intent) -> Decision { + 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(), + }; + } + // 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) + + let mut reqs = self.requests.lock().expect("mock requests mutex poisoned"); + let id = Self::mint_id(&mut reqs); + let status = if needs_card { + ApprovalStatus::Pending + } else { + ApprovalStatus::Allowed + }; + reqs.by_id.insert( + id, + Request { + intent: intent.clone(), + status, + broadcast: None, + }, + ); + + if needs_card { + Decision::NeedsApproval { request_id: id } + } else { + Decision::Allow + } + } + + fn execute(&self, request_id: RequestId) -> ExecuteResult { + // Always policy-before-requests so execute and revoke_all can't deadlock. + let mut policy = self.policy.lock().expect("mock policy mutex poisoned"); + let mut reqs = self.requests.lock().expect("mock requests mutex poisoned"); + + let req = match reqs.by_id.get_mut(&request_id) { + None => { + return ExecuteResult::Denied { + reason: "unknown_request".into(), + } + } + Some(req) => req, + }; + + // Idempotency: a broadcast id never signs twice. + if req.broadcast.is_some() { + return ExecuteResult::Denied { + reason: "already_executed".into(), + }; + } + + // TOCTOU guard: re-check `revoked` at sign time. An approval granted before + // revoke_all must still be denied here. + if policy.revoked { + return ExecuteResult::Denied { + reason: "revoked".into(), + }; + } + + match req.status.clone() { + // The only state that signs (covers allow-equivalent and human-approved). + ApprovalStatus::Allowed => { + let tx = Self::broadcast_tx_hash(); + let value = req.intent.value; + req.broadcast = Some(tx); + policy.spent_today_wei = policy.spent_today_wei.saturating_add(value); + ExecuteResult::Broadcast { tx_hash: tx } + } + ApprovalStatus::Pending => ExecuteResult::Denied { + reason: "not_approved".into(), + }, + ApprovalStatus::Denied { reason } => ExecuteResult::Denied { reason }, + ApprovalStatus::Expired => ExecuteResult::Denied { + reason: "expired".into(), + }, + } + } + + fn status(&self, request_id: RequestId) -> ApprovalStatus { + let reqs = self.requests.lock().expect("mock requests mutex poisoned"); + match reqs.by_id.get(&request_id) { + Some(req) => req.status.clone(), + None => ApprovalStatus::Denied { + reason: "unknown_request".into(), + }, + } + } + + fn revoke_all(&self) { + // 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(), + }; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Bytes; + + // --- builders ------------------------------------------------------------------- + + /// A policy with an empty allowlist and `auto_shield_min_wei = 10`. + fn policy(per_tx: u64, daily: u64, spent: u64, mode: ApprovalMode) -> Policy { + Policy { + per_tx_cap_wei: U256::from(per_tx), + daily_cap_wei: U256::from(daily), + spent_today_wei: U256::from(spent), + allow_to: vec![], + auto_shield_min_wei: U256::from(10u64), + require_approval: mode, + revoked: false, + } + } + + fn send(value: u64) -> Intent { + Intent { + chain_id: 1, + to: Address::repeat_byte(0x22), + token: None, + value: U256::from(value), + calldata: Bytes::new(), + kind: IntentKind::Send, + } + } + + fn shield(value: u64) -> Intent { + Intent { + chain_id: 1, + to: Address::repeat_byte(0x44), + token: None, + value: U256::from(value), + calldata: Bytes::new(), + kind: IntentKind::Shield, + } + } + + fn unwrap_needs(d: Decision) -> RequestId { + match d { + Decision::NeedsApproval { request_id } => request_id, + other => panic!("expected NeedsApproval, got {other:?}"), + } + } + + // --- decision matrix ------------------------------------------------------------ + + #[test] + fn within_cap_allows() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + assert_eq!(s.propose(&send(20)), Decision::Allow); + } + + #[test] + fn over_per_tx_cap_needs_approval() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + assert!(matches!( + s.propose(&send(60)), + Decision::NeedsApproval { .. } + )); + } + + #[test] + fn over_daily_cap_needs_approval() { + // per-tx effectively unbounded so only the daily cap can bind. + let s = MockSigner::new(policy(u64::MAX, 100, 90, ApprovalMode::OverCap)); + assert!(matches!( + s.propose(&send(20)), + Decision::NeedsApproval { .. } + )); + } + + #[test] + fn off_allowlist_denies() { + let mut p = policy(50, 1000, 0, ApprovalMode::OverCap); + p.allow_to = vec![Address::repeat_byte(0x33)]; // send() targets 0x22 + let s = MockSigner::new(p); + assert_eq!( + s.propose(&send(20)), + Decision::Deny { + reason: "off_allowlist".into() + } + ); + } + + #[test] + fn on_allowlist_allows() { + let mut p = policy(50, 1000, 0, ApprovalMode::OverCap); + p.allow_to = vec![Address::repeat_byte(0x22)]; // matches send()'s target + let s = MockSigner::new(p); + assert_eq!(s.propose(&send(20)), Decision::Allow); + } + + #[test] + fn revoked_policy_denies_propose() { + let mut p = policy(50, 1000, 0, ApprovalMode::OverCap); + p.revoked = true; + let s = MockSigner::new(p); + assert_eq!( + s.propose(&send(20)), + Decision::Deny { + reason: "revoked".into() + } + ); + } + + #[test] + fn undecodable_calldata_denies() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + // A Send must have empty calldata. + let mut bad_send = send(20); + bad_send.calldata = Bytes::from_static(&[0x01, 0x02]); + assert_eq!( + s.propose(&bad_send), + Decision::Deny { + reason: "undecodable".into() + } + ); + // A ContractCall must have non-empty calldata. + let empty_call = Intent { + kind: IntentKind::ContractCall, + calldata: Bytes::new(), + ..send(20) + }; + assert_eq!( + s.propose(&empty_call), + Decision::Deny { + reason: "undecodable".into() + } + ); + } + + #[test] + fn never_over_cap_denies() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::Never)); + assert_eq!( + s.propose(&send(60)), + Decision::Deny { + reason: "over_cap".into() + } + ); + } + + #[test] + fn always_within_cap_needs_approval() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::Always)); + assert!(matches!( + s.propose(&send(20)), + Decision::NeedsApproval { .. } + )); + } + + #[test] + fn execute_on_pending_denied() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + let id = unwrap_needs(s.propose(&send(60))); + assert_eq!( + s.execute(id), + ExecuteResult::Denied { + reason: "not_approved".into() + } + ); + } + + #[test] + fn approve_then_execute_broadcasts_and_increments_spent() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + let id = unwrap_needs(s.propose(&send(60))); + s.approve(id); + assert_eq!( + s.execute(id), + ExecuteResult::Broadcast { + tx_hash: MockSigner::broadcast_tx_hash() + } + ); + assert_eq!(s.policy().spent_today_wei, U256::from(60u64)); + } + + #[test] + fn toctou_revoke_then_execute_denied() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + let id = unwrap_needs(s.propose(&send(60))); + s.approve(id); // human approved BEFORE the STOP + s.revoke_all(); + assert_eq!( + s.execute(id), + ExecuteResult::Denied { + reason: "revoked".into() + } + ); + // and nothing was spent + assert_eq!(s.policy().spent_today_wei, U256::ZERO); + } + + #[test] + fn unknown_id_denied() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + assert_eq!( + s.execute(B256::repeat_byte(0xFF)), + ExecuteResult::Denied { + reason: "unknown_request".into() + } + ); + } + + #[test] + fn double_execute_denied() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + // within-cap → Allow, stored as allow-equivalent and executable by its minted id. + assert_eq!(s.propose(&send(20)), Decision::Allow); + let id = s.last_request_id().expect("an id was minted"); + assert!(matches!(s.execute(id), ExecuteResult::Broadcast { .. })); + assert_eq!( + s.execute(id), + ExecuteResult::Denied { + reason: "already_executed".into() + } + ); + // spent incremented exactly once + assert_eq!(s.policy().spent_today_wei, U256::from(20u64)); + } + + #[test] + fn auto_shield_within_cap_never_allows() { + // The demo beat: an inbound shield within cap, hands-free (Never). + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::Never)); + assert_eq!(s.propose(&shield(20)), Decision::Allow); + } + + #[test] + fn revoke_all_flips_pending_to_denied() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + let id = unwrap_needs(s.propose(&send(60))); // Pending + s.revoke_all(); + assert_eq!( + s.status(id), + ApprovalStatus::Denied { + reason: "revoked".into() + } + ); + assert!(s.policy().revoked); + } + + #[test] + fn pinned_constants_and_first_id() { + assert_eq!(MockSigner::mock_address(), Address::repeat_byte(0x11)); + assert_eq!(MockSigner::broadcast_tx_hash(), B256::repeat_byte(0xAB)); + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + assert_eq!(s.address(), Address::repeat_byte(0x11)); + // The first minted request_id is 0x0101…01. + let id = unwrap_needs(s.propose(&send(60))); + assert_eq!(id, B256::repeat_byte(0x01)); + } + + #[test] + fn box_dyn_signer_is_usable() { + let s: Box = + Box::new(MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap))); + assert_eq!(s.address(), MockSigner::mock_address()); + assert_eq!(s.propose(&send(20)), Decision::Allow); + assert!(!s.policy().revoked); + s.revoke_all(); + assert!(s.policy().revoked); + } + + #[test] + fn balance_reads_what_was_set() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + s.set_balance(BalanceReport { + public_wei: U256::from(7u64), + shielded_wei: U256::from(3u64), + }); + let b = s.balance(false); + assert_eq!(b.public_wei, U256::from(7u64)); + assert_eq!(b.shielded_wei, U256::from(3u64)); + } + + // --- boundary + guard coverage (added after review) ----------------------------- + + #[test] + fn exact_per_tx_cap_is_within() { + // projected == per_tx_cap is "within" (strict `>`): pins against a `>=` regression. + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + assert_eq!(s.propose(&send(50)), Decision::Allow); + } + + #[test] + fn exact_daily_cap_is_within_one_over_needs_approval() { + // per-tx unbounded so only the daily cap binds. projected == daily → Allow; + // projected == daily + 1 → NeedsApproval. + let s = MockSigner::new(policy(u64::MAX, 100, 90, ApprovalMode::OverCap)); + assert_eq!(s.propose(&send(10)), Decision::Allow); // 90 + 10 == 100 + let s2 = MockSigner::new(policy(u64::MAX, 100, 90, ApprovalMode::OverCap)); + assert!(matches!( + s2.propose(&send(11)), // 90 + 11 == 101 > 100 + Decision::NeedsApproval { .. } + )); + } + + #[test] + fn toctou_revoke_then_execute_within_cap_allow_denied() { + // STOP must also block an unexecuted within-cap Allow (status=Allowed), not just the + // human-approved over-cap path: the execute-time revoked guard is the only thing + // standing between an Allow and a broadcast after revoke_all. + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + assert_eq!(s.propose(&send(20)), Decision::Allow); + let id = s.last_request_id().expect("Allow minted an id"); + s.revoke_all(); + assert_eq!( + s.execute(id), + ExecuteResult::Denied { + reason: "revoked".into() + } + ); + assert_eq!(s.policy().spent_today_wei, U256::ZERO); + } + + #[test] + fn last_request_id_tracks_latest_mint() { + let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + assert_eq!(s.last_request_id(), None); + s.propose(&send(20)); // mints 0x01 + assert_eq!(s.last_request_id(), Some(B256::repeat_byte(0x01))); + s.propose(&send(20)); // mints 0x02 + assert_eq!(s.last_request_id(), Some(B256::repeat_byte(0x02))); + } + + #[test] + #[should_panic(expected = "exhausted")] + fn request_id_space_exhaustion_panics_instead_of_wrapping() { + // The pinned single-byte id scheme supports 255 ids; the 256th proposal must panic + // loudly rather than silently wrap and clobber a live request. + let s = MockSigner::new(policy(u64::MAX, u64::MAX, 0, ApprovalMode::OverCap)); + for _ in 0..256 { + let _ = s.propose(&send(1)); // within cap → Allow → mints an id each time + } + } +} diff --git a/crates/deckard-contract/src/policy.rs b/crates/deckard-contract/src/policy.rs new file mode 100644 index 0000000..1c3a2d7 --- /dev/null +++ b/crates/deckard-contract/src/policy.rs @@ -0,0 +1,36 @@ +//! 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. + +use alloy_primitives::{Address, U256}; +use serde::{Deserialize, Serialize}; + +/// The agent-readable policy. All caps are in wei. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Policy { + /// Per-transaction ceiling. + pub per_tx_cap_wei: U256, + /// Rolling daily ceiling. + pub daily_cap_wei: U256, + /// Spent so far today; the cap check compares `spent_today_wei + value`. + pub spent_today_wei: U256, + /// Allowed recipients. **EMPTY = any address allowed.** + pub allow_to: Vec
, + /// Demo rule: auto-shield inbound ETH ≥ this. Read by the agent to decide *whether to + /// propose a shield*; the policy gate itself does not switch on it. + pub auto_shield_min_wei: U256, + /// When a write needs a human approval card. + pub require_approval: ApprovalMode, + /// Set true by `revoke_all` / STOP. Re-checked at execute time (TOCTOU guard). + pub revoked: bool, +} + +/// When the policy gate raises a native approval card. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum ApprovalMode { + /// Never raise a card. Within cap → allow; over cap → deny (no card to override it). + Never, + /// Raise a card only when over a cap; within cap → allow. + OverCap, + /// Always raise a card, even within cap. + Always, +} diff --git a/crates/deckard-contract/src/rpc.rs b/crates/deckard-contract/src/rpc.rs new file mode 100644 index 0000000..ed67b12 --- /dev/null +++ b/crates/deckard-contract/src/rpc.rs @@ -0,0 +1,71 @@ +//! The daemon socket API — the wire `deckard-mcp` (key-less) speaks to `deckard-signerd`. +//! serde-derived so it frames as CBOR (ciborium) on the UDS and JSON for MCP. One request +//! per frame, one response per frame. + +use alloy_primitives::{Address, B256, U256}; +use serde::{Deserialize, Serialize}; + +use crate::decision::{Decision, RequestId}; +use crate::intent::Intent; +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 { + /// 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`. + RevokeAll, + /// Read-only snapshot for the agent → [`Policy`]. + PolicyGet, + /// → [`Address`](alloy_primitives::Address). + Address, + /// → [`BalanceReport`]. + Balance { shielded: bool }, +} + +/// `deckard-signerd` → `deckard-mcp`. One variant per request shape. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum SignerResponse { + Decision(Decision), + Execute(ExecuteResult), + Status(ApprovalStatus), + /// Reply to `RevokeAll`. + Ack, + Policy(Policy), + Address(Address), + Balance(BalanceReport), +} + +/// Outcome of `execute`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum ExecuteResult { + /// Signed + broadcast. + Broadcast { tx_hash: B256 }, + /// Refused at sign time (e.g. `revoked`, `already_executed`, `unknown_request`). + Denied { reason: String }, +} + +/// Result of polling a `RequestId`. Approvals expire so a stale id can't be executed later. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum ApprovalStatus { + /// Awaiting the human on the native card. + Pending, + /// The human approved; `execute` will sign (subject to a fresh `revoked` re-check). + Allowed, + /// Terminal denial. + Denied { reason: String }, + /// The approval window elapsed. + Expired, +} + +/// Public + shielded balances, both in wei. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct BalanceReport { + pub public_wei: U256, + pub shielded_wei: U256, +} diff --git a/crates/deckard-contract/src/signer.rs b/crates/deckard-contract/src/signer.rs new file mode 100644 index 0000000..07c18e2 --- /dev/null +++ b/crates/deckard-contract/src/signer.rs @@ -0,0 +1,32 @@ +//! The signer abstraction. **Sync on purpose**: it keeps this crate runtime-free. The real +//! UDS client does a fast blocking round-trip off the UI thread; wrapping it in async is the +//! daemon ticket's call, not this contract's. `MockSigner` is the in-memory implementation. + +use alloy_primitives::Address; + +use crate::decision::{Decision, RequestId}; +use crate::intent::Intent; +use crate::policy::Policy; +use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult}; + +/// 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 { + /// The wallet's public address (key-less to read). + fn address(&self) -> Address; + /// Public + shielded balances. `shielded` mirrors the wire request; the report carries + /// both fields regardless. + fn balance(&self, shielded: bool) -> BalanceReport; + /// A read-only snapshot of the spending fence. + fn policy(&self) -> Policy; + /// Policy check only — NEVER signs, NEVER broadcasts. Returns a [`Decision`]. + fn propose(&self, intent: &Intent) -> Decision; + /// Sign + broadcast, only for an allow-equivalent or approved request. Re-checks + /// `revoked` at sign time (TOCTOU guard). + fn execute(&self, request_id: RequestId) -> ExecuteResult; + /// Poll an approval handle. + fn status(&self, request_id: RequestId) -> ApprovalStatus; + /// STOP: revoke all agent authority for the session and drop in-flight approvals. + fn revoke_all(&self); +} diff --git a/crates/deckard-contract/tests/harness_slice.rs b/crates/deckard-contract/tests/harness_slice.rs new file mode 100644 index 0000000..0d70231 --- /dev/null +++ b/crates/deckard-contract/tests/harness_slice.rs @@ -0,0 +1,108 @@ +//! The daemon-free slice of the `docs/build/30-mcp-shape.md` acceptance scenario +//! ("MCP surface: read-free, write-gated, secret-tight"), run against `MockSigner`. +//! +//! This exercises the **signer half** of T1–T8 — every step that is a daemon RPC. The +//! steps that live in the (out-of-scope) MCP server are called out inline: +//! * T1 `list_tools` — MCP tool registry, not a signer op. +//! * T5 `simulate` — Helios eth_call preview, not a signer op. +//! * T7 secret-refusal of `--passphrase`/`--key` flags — MCP-mode CLI parsing. +//! * T9 transcript key-leak scan — asserted over the MCP JSON-RPC transcript, where a +//! 64-hex private key would leak; note a `tx_hash`/`request_id` is legitimately 64-hex, +//! so that gate belongs to the MCP server ticket, not this contract. + +use alloy_primitives::{Address, Bytes, U256}; +use deckard_contract::{ + ApprovalStatus, Decision, ExecuteResult, Intent, IntentKind, MockSigner, Policy, Signer, +}; + +const PER_TX_CAP: u64 = 50_000_000_000_000_000; // 0.05 ETH +const DAILY_CAP: u64 = 1_000_000_000_000_000_000; // 1 ETH +const AUTO_SHIELD_MIN: u64 = 10_000_000_000_000_000; // 0.01 ETH +const OVER_CAP_VALUE: u64 = 200_000_000_000_000_000; // 0.2 ETH (> per-tx cap) +const WITHIN_CAP_VALUE: u64 = 20_000_000_000_000_000; // 0.02 ETH (< per-tx cap) + +fn demo_signer() -> MockSigner { + MockSigner::new(Policy { + per_tx_cap_wei: U256::from(PER_TX_CAP), + daily_cap_wei: U256::from(DAILY_CAP), + spent_today_wei: U256::ZERO, + allow_to: vec![], // empty = any address + auto_shield_min_wei: U256::from(AUTO_SHIELD_MIN), + require_approval: deckard_contract::ApprovalMode::OverCap, + revoked: false, + }) +} + +fn intent(kind: IntentKind, value: u64) -> Intent { + Intent { + chain_id: 1, + to: Address::repeat_byte(0x22), + token: None, + value: U256::from(value), + calldata: Bytes::new(), + kind, + } +} + +#[test] +fn mcp_surface_daemon_free_slice() { + let s = demo_signer(); + + // ---- T2: read tools succeed, deterministic, carry no secret ----------------------- + assert_eq!(s.address(), MockSigner::mock_address()); + let pol = s.policy(); + assert_eq!(pol.per_tx_cap_wei, U256::from(PER_TX_CAP)); + assert!(!pol.revoked); + let bal = s.balance(false); + assert_eq!(bal.public_wei, U256::ZERO); // unset → zero, never a key + + // The read responses serialize without any "passphrase"/secret field. + let json = serde_json::to_string(&pol).unwrap(); + assert!(!json.contains("passphrase")); + + // ---- T3: propose an over-cap Send → NeedsApproval (NOT Allow) ---------------------- + let over = s.propose(&intent(IntentKind::Send, OVER_CAP_VALUE)); + let req_id = match over { + Decision::NeedsApproval { request_id } => request_id, + other => panic!("T3 expected NeedsApproval, got {other:?}"), + }; + + // ---- T4: execute before approval → Denied (never signs on Pending) ----------------- + assert!(matches!(s.execute(req_id), ExecuteResult::Denied { .. })); + assert_eq!(s.status(req_id), ApprovalStatus::Pending); + + // ---- T6: shield within cap with OverCap → Allow; execute → broadcast --------------- + let shield = s.propose(&intent(IntentKind::Shield, WITHIN_CAP_VALUE)); + assert_eq!(shield, Decision::Allow); + let shield_id = s.last_request_id().expect("Allow minted a request id"); + assert_eq!( + s.execute(shield_id), + ExecuteResult::Broadcast { + tx_hash: MockSigner::broadcast_tx_hash() + } + ); + // the shield spend is now reflected in policy + assert_eq!(s.policy().spent_today_wei, U256::from(WITHIN_CAP_VALUE)); + + // ---- T8: approve an over-cap write, STOP, then execute → Denied{revoked} (TOCTOU) -- + let pending = match s.propose(&intent(IntentKind::Send, OVER_CAP_VALUE)) { + Decision::NeedsApproval { request_id } => request_id, + other => panic!("T8 setup expected NeedsApproval, got {other:?}"), + }; + s.approve(pending); // human approved BEFORE the STOP + assert_eq!(s.status(pending), ApprovalStatus::Allowed); + s.revoke_all(); + assert_eq!( + s.execute(pending), + ExecuteResult::Denied { + reason: "revoked".into() + } + ); + // STOP is sticky: further proposes are denied too. + assert_eq!( + s.propose(&intent(IntentKind::Send, WITHIN_CAP_VALUE)), + Decision::Deny { + reason: "revoked".into() + } + ); +} diff --git a/justfile b/justfile index 197fd14..91fc139 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,7 @@ -# Deck — task runner. Install `just`: brew install just +# Deckard — task runner. Install `just`: brew install just # (Everything here is plain cargo + macOS built-ins; you can run the commands by hand too.) +# This is a virtual Cargo workspace: `cargo run` launches the app via default-members +# (crates/deckard-app, binary `deckard`); `--workspace` reaches deckard-core + deckard-contract. # List available recipes. default: @@ -15,14 +17,14 @@ run-release: # Run as a menu-bar / tray app (no dock icon). run-tray: - cargo run --features tray + cargo run -p deckard-app --features tray -# Format + lint (both feature configurations). +# Format + lint the whole workspace (both feature configurations of the app). fmt: cargo fmt check: - cargo clippy --all-targets -- -D warnings - cargo clippy --all-targets --features tray -- -D warnings + cargo clippy --workspace --all-targets -- -D warnings + cargo clippy -p deckard-app --all-targets --features tray -- -D warnings # Bump the git GPUI stack to the latest upstream commits, then rebuild. # Reproducibility lives in Cargo.lock — commit it (and rust-toolchain.toml if you @@ -35,22 +37,23 @@ bump-gpui: @echo "→ Bumped. Run the app to smoke-test, then commit Cargo.lock (+ rust-toolchain.toml if changed)." # Build a distributable Deckard.app (needs: cargo install cargo-bundle). -# Output: target/release/bundle/osx/Deckard.app +# Runs from crates/deckard-app so cargo-bundle resolves the relative icon path +# (it uses the CWD, not the manifest). Output → workspace target/release/bundle/osx/Deckard.app bundle: - cargo bundle --release + cd crates/deckard-app && cargo bundle --release @echo "→ target/release/bundle/osx/Deckard.app" # Open the bundled app. open: bundle open "target/release/bundle/osx/Deckard.app" -# Regenerate assets/icon.png + assets/icon.icns from assets/icon.svg. +# Regenerate the app icon (crates/deckard-app/assets/icon.png + .icns) from icon.svg. # Needs cairosvg (pip install cairosvg); falls back to qlmanage if missing. # Uses only macOS built-ins (sips, iconutil) for the .icns step. icon: #!/usr/bin/env bash set -euo pipefail - cd assets + cd crates/deckard-app/assets if command -v cairosvg >/dev/null; then cairosvg icon.svg -o icon.png -W 1024 -H 1024 else From 3f56cbfef9dfd9ccec714d68fd5a783b84f2a40c Mon Sep 17 00:00:00 2001 From: hellno Date: Fri, 5 Jun 2026 20:13:49 +0200 Subject: [PATCH 02/12] docs: land the build + research specs that own the frozen contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These were authored via the /spec process but were only ever untracked working-tree files. deckard-contract's README and crate docs point at docs/build/30-mcp-shape.md as the contract's owner, so land the set the links resolve to: - docs/build/ — the parallelizable v1 build specs (30-mcp-shape owns the Intent/Decision/daemon-socket contract; 00/10/20 reference it). - docs/research/ — the research KB + v1-demo-plan the build specs cite. Additive markdown only. If another PR already owns these, drop this commit. --- docs/build/00-test-harness.md | 253 ++++++++++++++++++++++++ docs/build/10-kohaku-shield.md | 188 ++++++++++++++++++ docs/build/20-helios-sidecar.md | 248 +++++++++++++++++++++++ docs/build/30-mcp-shape.md | 247 +++++++++++++++++++++++ docs/build/README.md | 88 +++++++++ docs/research/01-landscape-2026.md | 146 ++++++++++++++ docs/research/02-account-abstraction.md | 91 +++++++++ docs/research/03-kohaku.md | 97 +++++++++ docs/research/04-splits.md | 119 +++++++++++ docs/research/05-agentic-wallets.md | 120 +++++++++++ docs/research/06-privacy.md | 131 ++++++++++++ docs/research/07-wallet-rankings.md | 125 ++++++++++++ docs/research/08-security-keystores.md | 160 +++++++++++++++ docs/research/09-deckard-relevance.md | 200 +++++++++++++++++++ docs/research/README.md | 77 ++++++++ docs/research/roadmap.md | 160 +++++++++++++++ docs/research/v1-demo-plan.md | 88 +++++++++ 17 files changed, 2538 insertions(+) create mode 100644 docs/build/00-test-harness.md create mode 100644 docs/build/10-kohaku-shield.md create mode 100644 docs/build/20-helios-sidecar.md create mode 100644 docs/build/30-mcp-shape.md create mode 100644 docs/build/README.md create mode 100644 docs/research/01-landscape-2026.md create mode 100644 docs/research/02-account-abstraction.md create mode 100644 docs/research/03-kohaku.md create mode 100644 docs/research/04-splits.md create mode 100644 docs/research/05-agentic-wallets.md create mode 100644 docs/research/06-privacy.md create mode 100644 docs/research/07-wallet-rankings.md create mode 100644 docs/research/08-security-keystores.md create mode 100644 docs/research/09-deckard-relevance.md create mode 100644 docs/research/README.md create mode 100644 docs/research/roadmap.md create mode 100644 docs/research/v1-demo-plan.md diff --git a/docs/build/00-test-harness.md b/docs/build/00-test-harness.md new file mode 100644 index 0000000..7de104a --- /dev/null +++ b/docs/build/00-test-harness.md @@ -0,0 +1,253 @@ +# 00 · v0 Test Harness — local devnet + agentic self-test + +> Purpose: a fully-controlled local chain + a headless agentic runner so the agent, CI, and an AI coding agent can self-test every later feature. · Serves: the whole demo acceptance test (shot-list steps 1–5 of [v1-demo-plan.md](../research/v1-demo-plan.md)) — this doc is the substrate the other three build docs test against. · Status: spec. Part of the Deckard build docs. + +## Why this exists + +The v1 demo is one continuous mainnet recording of `receive → instant auto-shield → walkaway` (v1-demo-plan §"The video"). You cannot rehearse that on mainnet — it costs gas, you can't *trigger* an inbound payment on cue, and you can't safely cut RPCs. So before any feature lands we build a local environment we fully control plus a headless runner that drives the exact shot-list and asserts pass/fail. The shot-list **is** both the CI gate and the storyboard (v1-demo-plan §"Acceptance test = the shot list"), so the harness is the single thing that proves "shootable, today." + +## Where it sits — Depends on / Unblocks + +- **Depends on:** Foundry (anvil/cast/forge), Docker + Kurtosis (for the Helios lane), Sepolia RPC keys. The Intent/Decision/daemon-socket contract is **owned by [30-mcp-shape.md](30-mcp-shape.md)** — this harness drives it but does not define it. +- **Unblocks:** [10-kohaku-shield.md](10-kohaku-shield.md) (shield/unshield spiked on the anvil-fork lane), [20-helios-sidecar.md](20-helios-sidecar.md) (verified reads + walkaway on the Kurtosis/Sepolia lanes), [30-mcp-shape.md](30-mcp-shape.md) (the runner is a deterministic, LLM-free client of the daemon socket — proves the contract before Claude Desktop is in the loop). +- **Demo:** every beat. Step 1 receive-watcher, step 2 shield, step 3 walkaway, fast-follow steps 4 STOP / 5 allocate. + +## Architecture / approach + +Three lanes, because **one chain can't do everything** and the real constraint is Helios. + +> **The Helios constraint, stated honestly.** Helios is a light client: its consensus layer verifies the execution layer against the beacon chain's **sync committee**, rooted at a trusted weak-subjectivity **checkpoint**; the execution layer then uses an *untrusted* EL RPC for verified data ([a16z/helios README](https://github.com/a16z/helios/blob/master/README.md), [config.md](https://github.com/a16z/helios/blob/master/config.md)). Concretely Helios needs **two** upstreams: a `--consensus-rpc` that "must be a consensus node that supports the light client beaconchain api," and an `--execution-rpc` that "must be an execution node that supports the light client execution api" (README/config.md). **A bare `anvil --fork-url` has no beacon chain and no CL at all**, so Helios cannot point at plain anvil. This is the single fact that shapes the lane split — do not paper over it. + +| Lane | Chain | Helios? | What it's for | Cost/speed | +|---|---|---|---|---| +| **A · anvil-fork** | `anvil --fork-url ` | **No** (no CL) | Fast EL iteration: shield/unshield spikes, receive-watcher, send tx, MCP/daemon contract, agent loop. The default dev + CI lane. | seconds, free | +| **B · Kurtosis devnet** | `ethpandaops/ethereum-package` (EL + CL over Docker) | **Yes**, end-to-end local | Full Helios integration + walkaway with zero external dependencies; CL light-client API local. | minutes to spin up | +| **C · Sepolia** | public Sepolia | **Yes** (public beacon + EL light-client RPC) | Helios/walkaway integration against a real network; Kohaku/Kohaku-extension is Sepolia-only ([03-kohaku.md](../research/03-kohaku.md)); shield fallback target if the alpha Railgun crate misbehaves on mainnet. | live testnet | + +**Recommended default split:** Lane A for everyday dev + the per-push CI gate (fast, deterministic, can trigger receives on demand via cheatcodes). Lane B/C for the Helios+walkaway integration, run nightly/gated. The mainnet hero is shot only after A is green and B **or** C proves Helios continuation (per v1-demo-plan §"Reliability plan"). + +The **agentic runner** is a headless Rust driver (a `#[tokio::test]` integration test plus a `deckard-harness` bin) that: brings up a lane, runs the scenario DSL, and asserts. It speaks to the signer daemon over the socket defined in 30-mcp-shape.md. It has a **deterministic mode** (a `FakeModel` adapter that replays scripted intents) so the gate never depends on a live LLM, and a **live mode** that drives Claude Desktop via the MCP sidecar for the real take. + +## Concrete interface + +### File layout + +``` +crates/harness/ # the runner (new crate) + src/lib.rs # Lane, Scenario, Runner, asserts + src/lanes/anvil.rs # spawn anvil --fork-url, cast helpers + src/lanes/kurtosis.rs # kurtosis run + endpoint discovery + src/lanes/sepolia.rs # env-driven endpoints + src/model.rs # ModelAdapter trait: FakeModel | ClaudeMcp + tests/shot_list.rs # #[tokio::test] the acceptance scenario +fixtures/ + addresses.mainnet.json # USDC, Railgun contracts (see below) + accounts.json # HD mnemonic + derived payer/wallet/extra + scenarios/shield_on_receive.json +scripts/ + anvil-fork.sh kurtosis-up.sh fund.sh trigger-receive.sh +.github/workflows/harness.yml +``` + +### Lane A — anvil fork (the controllable chain) + +```bash +# Fork mainnet at a pinned block so contracts (USDC, Railgun) exist and fixtures are deterministic. +anvil --fork-url "$MAINNET_RPC_URL" --fork-block-number 22000000 \ + --mnemonic "test test test test test test test test test test test junk" \ + --accounts 10 --balance 10000 --chain-id 31337 --port 8545 +``` + +Default mnemonic gives 10 accounts × 10000 ETH; it is public — dev only ([Foundry: Anvil overview](https://getfoundry.sh/anvil/overview/)). + +**Cheatcodes that make the chain fully controllable** (exact names verified against [Foundry · Anvil custom methods](https://getfoundry.sh/anvil/custom-methods)). These drive the demo's "live receive" beat in tests — we *trigger* inbound payments on command: + +| Need | Method | Use in the harness | +|---|---|---| +| Fund any address | `anvil_setBalance` | top up payer / wallet | +| Send *as* a whale (e.g. a USDC holder) | `anvil_impersonateAccount` / `anvil_stopImpersonatingAccount` | move real USDC into the wallet to fire the receive watcher | +| Force-mine | `anvil_mine` / `evm_mine` | confirm the inbound tx, advance state | +| Advance time | `evm_increaseTime` / `evm_setNextBlockTimestamp` | age checkpoints, test timeouts | +| Poke storage directly | `anvil_setStorageAt` | set an ERC-20 balance slot without a transfer (fastest "receive") | +| Inject code / nonce | `anvil_setCode` / `anvil_setNonce` | mock a contract if needed | +| Mining policy | `evm_setAutomine` / `evm_setIntervalMining` | step-mode vs interval for deterministic tests | +| Save/restore | `evm_snapshot` / `evm_revert` | reset between scenario steps cheaply | + +Two ways to "trigger a live receive," fastest first: +1. **Storage poke** — compute the ERC-20 balance slot and `anvil_setStorageAt` (no real holder needed). Best for ETH/native and for an instant deterministic bump. +2. **Impersonate a real holder** — `anvil_impersonateAccount()` then `cast send "transfer(address,uint256)" --from --unlocked`, then `anvil_mine`. Best for an end-to-end `Transfer` log the receive-watcher consumes (drives step 1 from real logs). + +cast/forge scripting examples: +```bash +cast rpc anvil_setBalance "$PAYER" 0xDE0B6B3A7640000 # 1 ETH +cast rpc anvil_impersonateAccount "$USDC_WHALE" +cast send "$USDC" "transfer(address,uint256)" "$WALLET" 1000000 \ + --from "$USDC_WHALE" --unlocked --rpc-url http://127.0.0.1:8545 # 1 USDC (6 dp) +cast rpc anvil_mine 1 +``` + +### Lane B — Kurtosis local EL+CL devnet (Helios can point at it) + +```bash +# Spins up EL (geth/reth) + CL (lighthouse/teku/…) over Docker, exposes beacon + EL RPC. +kurtosis run --enclave deckard-devnet github.com/ethpandaops/ethereum-package +kurtosis enclave inspect deckard-devnet # discover EL RPC + beacon (CL) ports +``` + +`ethpandaops/ethereum-package` deploys both layers and exposes a Beacon API (CL) and JSON-RPC (EL); it supports a fresh `kurtosis` genesis or a public-network shadowfork, and light clients like Helios can point at the local endpoints ([ethpandaops/ethereum-package](https://github.com/ethpandaops/ethereum-package)). Then point Helios at the local endpoints: +```bash +helios ethereum --network kurtosis \ + --consensus-rpc http://127.0.0.1: \ + --execution-rpc http://127.0.0.1: \ + --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. + +### Lane C — Sepolia + +`MAINNET_RPC_URL` unused; set `SEPOLIA_EXECUTION_RPC` + `SEPOLIA_CONSENSUS_RPC` (a Nimbus/Lodestar beacon supporting the light-client API) and run `helios ethereum --network sepolia --checkpoint `. This is the Kohaku-compatible lane (Sepolia-only) and the shield fallback. + +### Helios as a library (Rust, in-process) + +Latest Helios is **0.11.1** (Feb 2026); the crate was restructured from the umbrella `helios` into `helios-ethereum` exposing `EthereumClientBuilder` ([docs](https://docs.rs/zemse-helios-ethereum/latest/zemse_helios_ethereum/) shows the `EthereumClientBuilder` re-export). The README's `ClientBuilder::new().network(...).consensus_rpc(...).execution_rpc(...).build()` + `client.start()` + `client.get_balance(addr, BlockTag::Latest)` pattern is the shape; the harness depends on it as a lib so reads are verified in-process. ⚠ **unverified:** exact 0.11.x builder type path and method signatures — pin the version and confirm against `helios-ethereum` docs when wiring 20-helios-sidecar.md. Useful flag: `--strict-checkpoint-age` (`-s`) errors on >2-week-old checkpoints (README). + +### Fixtures (mainnet, available via fork) + +`fixtures/addresses.mainnet.json` — verified mainnet addresses: +```json +{ + "USDC": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "RailgunSmartWallet": "0xc0BEF2D373A1EfaDE8B952f33c1370E486f209Cc", + "RailgunRelayProxy": "0xfa7093cdd9ee6932b4eb2c9e1cde7ce00b1fa4b9", + "USDC_WHALE": "" +} +``` +Railgun addresses verified via Etherscan: SmartWallet `0xc0BEF2D373A1EfaDE8B952f33c1370E486f209Cc`, Relay proxy `0xfa7093cd…` ([Etherscan: Railgun Relay](https://etherscan.io/address/0xfa7093cdd9ee6932b4eb2c9e1cde7ce00b1fa4b9)). USDC is the canonical mainnet token. The exact Railgun contracts 10-kohaku-shield.md targets are **owned by that doc** — keep this fixture file the single source and let 10-kohaku-shield.md add what it needs. ⚠ **unverified:** pick + verify a `USDC_WHALE` holding ≥ demo amount at the pinned fork block before relying on the impersonate path. + +`fixtures/accounts.json` — deterministic roles off the anvil mnemonic: +```json +{ "mnemonic": "test test test test test test test test test test test junk", + "wallet": "m/44'/60'/0'/0/0", "payer": "m/44'/60'/0'/0/1", "extra": ["…/0/2","…/0/3"] } +``` +`wallet` is the address under test (the one Deckard's keystore holds); `payer` sends the inbound tx. + +### Scenario DSL (`fixtures/scenarios/*.json`) + +A flat list of steps the runner executes; each step has a `lane` op and an `assert`. Mirrors v1-demo-plan's shot-list verbatim: +```json +{ + "name": "Shield-on-Receive, Trustless", + "lane": "anvil-fork", + "setup": { "unlock_keystore": true, "helios": "kurtosis|sepolia|none", + "agent_policy": "auto-shield inbound ETH above 0.01" }, + "steps": [ + { "op": "receive", "from": "payer", "asset": "ETH", "amount": "0.05", + "assert": "watcher_fires_within_seconds <= 5 && source == verified_logs" }, + { "op": "agent_intent", "intent": "shield", "amount": "0.05", + "assert": "private_balance_up && public_balance_down && link_broken && tx_confirmed" }, + { "op": "cut_rpc", "target": "primary", + "assert": "balances_still_verified_via_helios && no_crash" }, + { "op": "agent_intent", "intent": "execute", "after": "stop", + "assert": "denied" }, + { "op": "allocate", "fraction": 0.1, "assert": "rule_honored" } + ] +} +``` +`op: "agent_intent"` is dispatched through the daemon socket **as defined in 30-mcp-shape.md** — this harness does not define the Intent/Decision shape, it constructs and submits whatever that doc specifies. In deterministic mode `FakeModel` emits the intent directly; in live mode the same intent originates from Claude Desktop via the MCP sidecar. + +### Model adapter (LLM-free determinism) + +```rust +pub trait ModelAdapter { + /// Given the observed receive event, produce the next intent to submit to the daemon. + async fn next_intent(&mut self, ctx: &ScenarioCtx) -> Intent; // Intent type owned by 30-mcp-shape.md +} +pub struct FakeModel { script: Vec } // replays fixtures, no network, CI default +pub struct ClaudeMcp { /* drives Claude Desktop over the MCP sidecar */ } // live take only +``` + +## v0 baseline / spike plan + acceptance test + +**Build order (this is the v0 baseline — build it before features):** +1. `scripts/anvil-fork.sh` + `fixtures/` + cast helpers → can fork, fund, and *trigger a receive* on demand. +2. `crates/harness` Lane A + `FakeModel` + the daemon-socket client → run the scenario headless, deterministic. +3. `tests/shot_list.rs` asserting steps 1–2 against the anvil-fork lane (no Helios). +4. Lane B (Kurtosis) + Helios-as-lib → add step 3 (walkaway) end-to-end local; Lane C as fallback. +5. CI wiring. + +**Agent-runnable acceptance test (run this to self-verify the harness itself):** +```bash +# A0 · tools present +anvil --version && cast --version && forge --version # assert: exit 0 +# A1 · fork comes up with real contracts +bash scripts/anvil-fork.sh & sleep 3 +cast code 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --rpc-url http://127.0.0.1:8545 \ + | grep -q '0x60' # assert: USDC bytecode present (non-empty) +# A2 · cheatcode-driven receive works +WALLET=$(cast wallet address --mnemonic "test test test test test test test test test test test junk" --mnemonic-index 0) +cast rpc anvil_setBalance "$WALLET" 0x16345785D8A0000 --rpc-url http://127.0.0.1:8545 # 0.1 ETH +cast balance "$WALLET" --rpc-url http://127.0.0.1:8545 | grep -q 100000000000000000 # assert: balance set +# A3 · the deterministic scenario passes with no LLM and no Helios +cargo test -p harness --test shot_list -- --nocapture # assert: steps 1–2 PASS (FakeModel, anvil-fork) +# A4 · the Helios lane reaches synced and survives an RPC cut (Kurtosis or Sepolia) +HARNESS_LANE=sepolia cargo test -p harness --test shot_list helios_walkaway -- --ignored --nocapture +# assert: helios.status == synced; after cut_rpc, get_balance still returns a verified value; no panic +``` +A0–A3 are the per-push gate. A4 is the nightly/gated Helios lane. + +### CI wiring (`.github/workflows/harness.yml`) + +```yaml +on: [push, pull_request] +jobs: + anvil-fork-gate: # every push — fast, deterministic, the real gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: foundry-rs/foundry-toolchain@v1 + - run: bash scripts/anvil-fork.sh & sleep 3 + - run: cargo test -p harness --test shot_list # A1–A3, FakeModel, no Helios + env: { MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} } + helios-walkaway-nightly: # nightly + manual — the integration lane + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: foundry-rs/foundry-toolchain@v1 + - run: cargo test -p harness --test shot_list helios_walkaway -- --ignored # A4 + env: + SEPOLIA_EXECUTION_RPC: ${{ secrets.SEPOLIA_EXECUTION_RPC }} + SEPOLIA_CONSENSUS_RPC: ${{ secrets.SEPOLIA_CONSENSUS_RPC }} +# add `on: schedule: - cron: '0 6 * * *'` at top level for nightly. +``` +Pin `--fork-block-number` so the fork lane is reproducible and doesn't hammer the upstream RPC. + +## Risks & fallbacks + +- **Helios won't run on plain anvil** (no CL). *Fallback:* that's why Lanes B/C exist; the anvil-fork gate runs `helios: "none"` and step 3 is exercised on Kurtosis/Sepolia only. +- **Kurtosis CL doesn't expose the light-client beacon API by default** (⚠ unverified). *Fallback:* enable the flags (`--light-client-server` on Lighthouse + EL light-client API), else use Lane C (Sepolia) for walkaway; the mainnet hero only needs *one* of B/C green. +- **Helios crate API moved** (umbrella → `helios-ethereum`/`EthereumClientBuilder`, latest 0.11.1). *Fallback:* pin the version; 20-helios-sidecar.md owns the exact builder wiring and confirms signatures. +- **Live LLM flakiness** in the take. *Fallback:* `FakeModel` is the CI default; an in-app agent loop can drive the same MCP tools if Claude Desktop flakes on stage (v1-demo-plan §"Reliability plan"). +- **Fork RPC rate limits / drift.** *Fallback:* pinned block + a cached fork; run a local reth archive if needed. +- **USDC_WHALE balance changes by block.** *Fallback:* prefer the `anvil_setStorageAt` balance-slot poke for determinism; reserve impersonation for the real-`Transfer`-log path. + +## Open questions + +- Which Kurtosis CL client (Lighthouse/Teku/Nimbus) reliably serves the **light-client beaconchain API** for Helios with minimal flags, and how long is its spin-up vs Sepolia? (⚠ unverified — spike both.) +- Is fork-lane block-pinning compatible with 10-kohaku-shield.md's Railgun proof generation (do circuits/POI need live state newer than the pin)? +- Does the daemon socket (30-mcp-shape.md) expose a test/inject hook the runner can use to fast-path `agent_intent` without a full MCP round-trip in deterministic mode? +- For the walkaway beat, what's the cleanest "cut the RPC" primitive in tests — drop the upstream via a local proxy (toxiproxy) we can kill, or swap Helios's `execution_rpc` to a dead URL and assert it continues from cache/secondary? +- Do we need a second EL upstream for Helios to *continue* after a cut, or does Helios serve cached/verified reads from its last finalized state (determines whether step 3 is "continues live" vs "shows last-verified")? + +## Sources + +- v1 demo plan (shot-list, lanes, reliability) — [docs/research/v1-demo-plan.md](../research/v1-demo-plan.md) +- Kohaku research (Sepolia-only extension; Railgun alpha crate) — [docs/research/03-kohaku.md](../research/03-kohaku.md) +- Foundry · Anvil overview (default mnemonic, 10×10000 ETH, fork) — https://getfoundry.sh/anvil/overview/ +- Foundry · Anvil custom methods (exact cheatcode names) — https://getfoundry.sh/anvil/custom-methods +- a16z/helios README (consensus+execution light-client API requirement, CLI flags, custom networks) — https://github.com/a16z/helios/blob/master/README.md +- a16z/helios config.md (`consensus_rpc`/`execution_rpc`/`checkpoint`, `max_checkpoint_age`, fallbacks) — https://github.com/a16z/helios/blob/master/config.md +- helios-ethereum `EthereumClientBuilder` (restructured crate; latest 0.11.1) — https://docs.rs/zemse-helios-ethereum/latest/zemse_helios_ethereum/ +- ethpandaops/ethereum-package (Kurtosis EL+CL devnet, beacon + EL RPC, fresh genesis or shadowfork) — https://github.com/ethpandaops/ethereum-package +- Etherscan · Railgun Relay/SmartWallet mainnet contracts — https://etherscan.io/address/0xfa7093cdd9ee6932b4eb2c9e1cde7ce00b1fa4b9 diff --git a/docs/build/10-kohaku-shield.md b/docs/build/10-kohaku-shield.md new file mode 100644 index 0000000..6ed92c4 --- /dev/null +++ b/docs/build/10-kohaku-shield.md @@ -0,0 +1,188 @@ +# 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. + +## Why this exists (2-4 sentences, concrete) + +Beat 2 of the video is the hero action: a payment lands, the agent calls `shield(amount)`, and the funds move into a Railgun shielded pool where the balance is visible only to the owner's `0zk` viewing key. We consume the pure-Rust `railgun` crate inside `ethereum/kohaku` (an alloy-based `rlib`), **not** the `@kohaku-eth/railgun` WASM/TS wrapper, so Deckard's native GPUI/Rust process links it directly with no JS bridge. This is risk **R1** (alpha tooling, `@kohaku-eth/railgun@0.0.1-alpha.22`): the open question was whether the crate is standalone-consumable from Rust — the repo's integration tests answer **yes** (verified below), so the spec is "wire it up + spike on a fork," not "reverse-engineer a TS lib." + +## Where it sits — Depends on / Unblocks (cross-doc + demo) + +- **Depends on `20-helios-sidecar.md`** — all chain reads (UTXO/TXID sync, balance, on-chain state) go through an EIP-1193 provider; in the demo that provider is Helios over a private RPC. `RailgunBuilder::new(chain, provider)` takes `impl IntoEip1193Provider`, which is the seam (verified, see Interface). +- **Depends on `30-mcp-shape.md`** — owns the `Intent` / `Decision` / daemon-socket CONTRACT. The `shield(amount)` intent shape and the rule "auto-shield inbound ETH above X" are defined there; this doc only describes what the daemon does when it receives a `Shield` decision. Do **not** redefine the Intent enum here. +- **Depends on `00-test-harness.md`** — the anvil-mainnet-fork / Sepolia-fork harness, env vars (`RPC_URL_SEPOLIA`, `RPC_URL_MAINNET`), and the assertion runner used by the R1 spike below. +- **Unblocks** beat 2 / acceptance step 2 entirely. Without a working shield, the HERO action does not exist and the video has no payload. +- **Sibling, not dependency:** the **receive watcher** (deliverable #3) detects the inbound tx and emits the intent; it lives in T-Core, not here. + +## Architecture / approach + +The signer daemon (the only process holding key material — see `30-mcp-shape.md`) owns a long-lived `RailgunProvider`. The flow per the verified integration test `crates/railgun/tests/integration/transact_utxo.rs`: + +``` +inbound ETH lands at the EOA + → receive watcher fires (Helios-verified logs) [T-Core, 20-helios] + → MCP: agent issues shield intent [30-mcp-shape] + → daemon: railgun.shield().shield_native(zk_addr, value) + .build(rng) → Vec [this doc] + → daemon signs + submits the shield deposit txs (EOA pays) [alloy / 08] + → railgun.sync() [reads via Helios] + → railgun.balance(zk_addr) → private balance ↑, public ↓ + → UI renders "before/after, trail broken" [T-UX, #9] +``` + +Two distinct key materials, do not conflate: +1. the **EOA secp256k1 key** (`src/wallet.rs`, alloy `PrivateKeySigner`) — pays gas and signs the public shield-deposit tx; +2. the **Railgun spending+viewing keypair** (`railgun::account::signer::PrivateKeySigner`, a `RailgunSigner` over Poseidon/babyjubjub) — owns the `0zk` address and the private balance. Both live only in the signer daemon. The Railgun keys can be derived deterministically from the EOA seed (BIP-39 next increment) via `spending_key_path(index)` / `viewing_key_path(index)`, so backup is one seed. + +**Shield is the only on-camera operation.** Shield is a *public* deposit tx from the EOA into the `railgun_smart_wallet` contract — the EOA paying it does not leak the private balance (that's the point of the pool). Private **transfer** and **unshield** must go through a **broadcaster** (4337 bundler) to avoid linking the EOA at withdrawal; those are fast-follow (see Shield lifecycle). + +### Shield lifecycle — v1 vs fast-follow + +| Step | What it does | EOA exposure | v1 demo? | +|---|---|---|---| +| **shield** | deposit ERC-20/ETH into the pool, credit a `0zk` note owned by the viewing key | EOA visibly deposits (expected) | **YES — the HERO** | +| **balance/sync** | sync UTXO/TXID state, decrypt owner notes, report private balance | read-only | **YES** (proves "trail broken") | +| private **transfer** | move value `0zk → 0zk`, encrypted | must use broadcaster or EOA leaks | fast-follow | +| **unshield** | withdraw `0zk → EOA/any address` | must use broadcaster or EOA leaks at exit | fast-follow (needed for R1 acceptance assert + fund recovery) | + +The v1 demo needs **shield-on-receive + private-balance proof** only. Unshield is in the spike (to assert the link can be broken *and* funds recovered) but is not on camera. + +### Proving + +Proof generation (Groth16/BN254, ark-* + the patched ZK deps in the workspace `Cargo.toml`) happens **locally, in-process** inside `.build()` / `railgun.build(tx)`. There is no remote prover, so no metadata leak from proving — consistent with Deckard's local-first posture. **Cost is the open question** (see Open questions): the circuit + witness generation for a 1-in/2-out shield is the latency budget for "instant" auto-shield. Mitigation if proving is slow: enable the crate's `parallel` feature (the workspace patches `ark-*` for parallel proving), pre-warm the prover, and have the UI show "shielding…" between the deposit-tx confirmation and the proof landing rather than promising sub-second. + +### Broadcasters / relayers (fast-follow, but spec'd now) + +Transfer/unshield must not originate from the owner EOA or the privacy is lost at the edges. The crate submits these via a **4337-style broadcast**: `railgun.prepare_userop(tx, bundler, delegator_address, signer, fee_token, tail_calls, rng)` → a signable UserOperation; a *separate* `delegator` signer (the broadcaster's relay account, not the owner EOA) signs and the **bundler** submits it. A `TailCall` can atomically unwrap WETH→ETH on unshield. This is exactly what an unattended agent needs: it can move private funds without ever exposing the owner address (verified in `broadcast_utxo.rs`). + +Compliance model: Railgun uses **Private Proof of Innocence (PPOI)** — a non-membership proof against blocklists, submitted to a POI node via JSON-RPC `ppoi_submit_transact_proof` (verified in `poi/client.rs`). Opt in with `RailgunBuilder::with_poi()`. v1 shield-on-receive does not strictly require POI submission to credit the private balance, but transfers/unshields out of the pool are gated on POI in production; build the daemon with `.with_poi()` so the path is exercised in the spike. + +### RPC + +Reads (UTXO sync, TXID sync, balance, `eth_call`) go through the `Eip1193Provider` Deckard hands `RailgunBuilder` — in the demo that is **Helios over a private RPC** (see `20-helios-sidecar.md`). UTXO syncing additionally uses a **Subsquid endpoint** (`ChainConfig.subsquid_endpoint`, with an `RpcSyncer` fallback chained after it) for fast historical scan — that path is a read of public pool events, not address-bearing, but note it as a second network dependency. Shield deposit tx submission is a normal alloy `send_transaction`. Transfer/unshield submission goes through the broadcaster/bundler. + +## Concrete interface (commands, types, crate names, RPC methods, file layout) + +**Crate:** `railgun`, version `0.1.0`, edition `2024`, `[lib] crate-type = ["rlib"]`, `default = []`, feature `js = ["dep:tsify","dep:wasm-bindgen"]` (we do **not** enable `js`), plus `parallel`, `testing`, `bench`. Depends on `alloy = "1.8"`, `ark-bn254 = "0.6"`, `tokio = "1.49"`. There is **no `license` field in the crate's own `Cargo.toml`**; the monorepo root `package.json` declares `"license": "MIT"` and the npm artifact is MIT — confirm the Rust crate inherits MIT before depending (see Risks). [crate Cargo.toml, root package.json] + +**Cargo dependency** (no published crate; vendor or git-pin a commit): +```toml +[dependencies] +railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev = "" } +# transitive: the workspace [patch] block patches ark-* for parallel/ZK — pin the rev so patches resolve +``` + +**Public API actually verified in `crates/railgun/src/`:** + +```rust +// builder.rs +RailgunBuilder::new(chain: ChainConfig, provider: impl IntoEip1193Provider) -> RailgunBuilder + .with_utxo_syncer(syncer: Arc) // ChainedSyncer of Subsquid + RpcSyncer + .with_database(db: Arc) // persists synced UTXOs / POI proofs + .with_poi() // enable PPOI submission + .build().await -> Result + +// provider.rs — the handle the daemon holds +impl RailgunProvider { + async fn register(&mut self, signer) -> ... // register a 0zk account to track + async fn sync(&mut self) -> Result<(), _> // sync UTXO/TXID state via the provider + async fn balance(&mut self, addr: RailgunAddress) -> HashMap + fn shield(&self) -> ShieldBuilder + fn transact(&self) -> TransactionBuilder + async fn build(tx, rng) -> ProvedTransaction { tx_data, .. } // direct (EOA-submitted) + async fn prepare_userop(tx, bundler, delegator, signer, fee_token, tail_calls: Vec, rng) + -> SignableUserOp // broadcaster path (4337) +} + +// transact/shield_builder.rs +ShieldBuilder::new(chain) + .shield(recipient: RailgunAddress, asset: AssetId, value: u128) -> Self + .shield_native(recipient: RailgunAddress, value: u128) -> Self + .build(rng) -> Result, ShieldError> // submit each TxData via alloy + +// transact/transaction_builder.rs +TransactionBuilder::new() + .transfer(from_signer, to: RailgunAddress, asset, value, memo) -> Self + .unshield(from_signer, to: Address, asset, value) -> Result + +// account/signer.rs +trait RailgunSigner { fn sign(&self, inputs: U256) -> Result; fn address(&self) -> RailgunAddress; } +PrivateKeySigner::new_evm(spending_key, viewing_key, chain_id: u64) -> Arc // in-memory railgun keypair +spending_key_path(index: u32) -> String; viewing_key_path(index: u32) -> String // derivation paths + +// chain_config.rs +ChainConfig::mainnet() -> Self // railgun_smart_wallet, wrapped_base_token, subsquid_endpoint +ChainConfig::sepolia() -> Self +ChainConfig::from_chain_id(id) -> Option +``` + +**RPC / methods touched:** standard `eth_*` reads through the EIP-1193 provider (→ Helios); Subsquid GraphQL for historical UTXO scan; `eth_sendTransaction`/`eth_sendRawTransaction` for the shield deposit; 4337 bundler `eth_sendUserOperation` for broadcast transfer/unshield; POI JSON-RPC `ppoi_submit_transact_proof`. + +**Deckard file layout (proposed):** +``` +src/shield/ + mod.rs // pub fn handle_shield_decision(value, asset) -> ShieldResult (called by daemon) + client.rs // owns RailgunProvider lifecycle: build once, sync, expose shield/balance + keys.rs // derive railgun spending+viewing keys from the EOA seed +spikes/r1_shield/ // standalone bin for the acceptance test below (or a #[test] in tests/) +``` + +## v0 baseline / spike plan + acceptance test (agent-runnable asserts) + +**v0 baseline:** none. `src/wallet.rs` is a bare alloy EOA persisting plaintext hex; there is no Railgun code yet. + +**R1 spike** — port `crates/railgun/tests/integration/transact_utxo.rs` into Deckard against `00-test-harness.md`'s anvil fork. That test already does the full shield→transfer→unshield with concrete balance asserts; reproducing it from *our* dependency edge proves standalone-consumability. Spike on **Sepolia fork first** (the upstream test forks Sepolia at block `10822990` and uses `RPC_URL_SEPOLIA`), then repeat on an **anvil mainnet fork** with `ChainConfig::mainnet()` before the mainnet hero. + +``` +Scenario R1 "shield + unshield, standalone Rust" (anvil fork; Sepolia first, then mainnet fork): + setup: anvil --fork-url $RPC; RailgunProvider built with our Eip1193 provider (Helios in demo, + plain alloy provider in spike); two railgun accounts registered; WETH deposited+approved + to chain.railgun_smart_wallet. + + 1. railgun.shield().shield(acct1, weth, 1_000_000).build(rng); submit each TxData; railgun.sync() + assert: railgun.balance(acct1)[weth] == 997_500 # pool fee taken, private balance up + assert: railgun.balance(acct2)[weth] == None + 2. shield_native(acct1, 100_000) → submit → sync + assert: railgun.balance(acct1)[weth] increases # native wrapped + shielded + 3. TransactionBuilder::transfer(acct1, acct2, weth, 5_000, "..") → railgun.build → submit → sync + assert: balance(acct1) down 5_000; balance(acct2)[weth] == 5_000 # private transfer, no public trace + 4. TransactionBuilder::unshield(acct1, EOA, weth, 1_000) → railgun.build → submit → sync + assert: WETH.balanceOf(EOA) increased (~998 after fee); balance(acct1) down # link broken, funds recovered + + GREEN = R1 passes → attempt mainnet hero. RED = take a Fallback (below). +``` + +The exact numeric asserts (`997_500`, `5_000`, `998`) are copied from the verified upstream test, so a regression in our integration edge is immediately visible. Mark the spike `#[ignore]` (network) and run it explicitly in CI like upstream does. + +**Demo acceptance (mirrors `v1-demo-plan.md` step 2):** after R1 is green, the on-camera assert is `private balance ↑, public ↓, link broken; tx confirms`, driven by the receive watcher → MCP `shield` decision → `handle_shield_decision`. + +## Risks & fallbacks + +- **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. +- **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. +- **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. +- **POI standby:** Railgun's ~1-hour unshield-only standby period (per `06-privacy.md`) — does it affect the on-camera unshield in the spike? (Shield + private-balance proof are unaffected.) ⚠ unverified against the crate. + +## Sources (repos + docs, linked) + +- `ethereum/kohaku` — privacy SDK monorepo, workspace `Cargo.toml` (8 crates, `alloy 1.8`, `[profile.release-wasm]`) — https://github.com/ethereum/kohaku/blob/master/Cargo.toml — (source, verified) +- `crates/railgun/Cargo.toml` — `name = "railgun"`, `0.1.0`, edition 2024, `crate-type = ["rlib"]`, `js`/`parallel`/`testing` features, `[[bin]] main` — https://github.com/ethereum/kohaku/blob/master/crates/railgun/Cargo.toml — (source, verified) +- `crates/railgun/src/{builder,provider}.rs` — `RailgunBuilder::new(chain, impl IntoEip1193Provider)`, `RailgunProvider::{register,sync,balance,shield,transact,build,prepare_userop}` — https://github.com/ethereum/kohaku/tree/master/crates/railgun/src — (source, verified) +- `crates/railgun/src/transact/{shield_builder,transaction_builder}.rs` — `ShieldBuilder::{shield,shield_native,build}`, `TransactionBuilder::{transfer,unshield}` — https://github.com/ethereum/kohaku/tree/master/crates/railgun/src/transact — (source, verified) +- `crates/railgun/tests/integration/transact_utxo.rs` — full shield→transfer→unshield with balance asserts on a Sepolia anvil fork (the R1 reference) — https://github.com/ethereum/kohaku/blob/master/crates/railgun/tests/integration/transact_utxo.rs — (source, verified) +- `crates/railgun/tests/integration/broadcast_utxo.rs` — 4337 broadcaster transfer/unshield via `prepare_userop` + bundler + `delegator` (EOA-unlinking path) — https://github.com/ethereum/kohaku/blob/master/crates/railgun/tests/integration/broadcast_utxo.rs — (source, verified) +- `crates/railgun/src/poi/client.rs` — PPOI submission via JSON-RPC `ppoi_submit_transact_proof` — https://github.com/ethereum/kohaku/blob/master/crates/railgun/src/poi/client.rs — (source, verified) +- `@kohaku-eth/railgun` npm — latest `0.0.1-alpha.22` (2026-05-26), 20 versions, license MIT (maturity signal) — https://www.npmjs.com/package/@kohaku-eth/railgun — (registry, verified via registry.npmjs.org) +- Railgun PPOI (non-membership compliance model, broadcasters, 1h standby) — https://docs.railgun.org/wiki/assurance/private-proofs-of-innocence — (docs, high) +- Internal: `docs/research/03-kohaku.md`, `docs/research/06-privacy.md`, `docs/research/v1-demo-plan.md` diff --git a/docs/build/20-helios-sidecar.md b/docs/build/20-helios-sidecar.md new file mode 100644 index 0000000..d9dd8e4 --- /dev/null +++ b/docs/build/20-helios-sidecar.md @@ -0,0 +1,248 @@ +# 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. +> +> **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. + +**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. + +## Where it sits — Depends on / Unblocks (cross-doc + demo) + +**Depends on:** +- A private/proxied upstream EL RPC URL and a CL light-client RPC URL — see "Privacy interplay" below and the network plumbing in `00-test-harness.md`. +- Nothing in the signer/keystore path: Helios is read-only. It never touches the key. + +**Unblocks:** +- **Beat 2 / receive watcher** (`10-kohaku-shield.md`, deliverable #3): the watcher polls `eth_getLogs` / `eth_getBlockByNumber` through the Helios client so the "payment landed" event is itself verified. (Useful nuance, verified below: `get_logs` does **not** go through Helios's 60s head-age gate, so the watcher keeps working a bit differently from `Latest`-tag state reads.) +- **Beat 3 / walkaway** (deliverable #2): this doc *is* beat 3. +- **MCP `balance` / `simulate` reads** (`30-mcp-shape.md`): the daemon answers read intents from the Helios client. The `Intent`/`Decision`/daemon-socket contract is **owned by `30-mcp-shape.md`** — this doc only specifies that read intents resolve against the local Helios endpoint and that read status carries a `Verified|Degraded|Unsynced` flag. +- **`00-test-harness.md`**: owns spinning up the CL for a local devnet so this client has a consensus source to verify against (the Kurtosis section below now has the exact answer). + +## Crate + API — verified against source at tag `0.11.1` + +This section supersedes the earlier (memory-written) spec. The earlier version had three concrete errors, now fixed: the git tag (`0.11.1`, **no** `v`), `.checkpoint()` did not take a `?`, and the mainnet CL default is not `lightclientdata.org`. + +**Crate — depend on `helios-ethereum`, NOT the umbrella `helios`.** +```toml +# Cargo.toml +helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1" } + +# Helios's workspace patches ethereum_hashing; [patch] does NOT inherit through a +# git dependency, so mirror it or the consensus crates fail to build: +[patch.crates-io] +ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" } +``` +- The umbrella `helios` crate re-exports everything (`helios::ethereum::*`) **but also pulls `helios-opstack` → libp2p → a yanked `core2 0.4.0`, which fails to resolve today.** Depending on `helios-ethereum` directly avoids opstack/linea/libp2p entirely (smaller tree, no p2p stack) and builds clean. Verified: the spike builds against `helios-ethereum` in ~2.5 min release. +- **Not on crates.io.** `helios-ethereum` on crates.io is stale at `0.1.0` (published 2024-10-27); `0.11.1` is **git-only**. Pin the tag (`0.11.1`, not `v0.11.1` — the `v`-prefixed tag is a 404). Re-verify the builder API at any bump (pre-1.0). +- **alloy alignment is a non-issue (resolved).** Helios pins the `alloy` meta-crate `1.0.37` (caret), which resolves `alloy-primitives` up to Deckard's pinned `1.6.0` — they **unify to a single `alloy-primitives 1.6.0`** in the lock. `Address`/`B256`/`U256`/`BlockId` are the same type at the Helios↔Deckard boundary; no duplicate-types conflict. (Verified in the spike's `Cargo.lock`: one `alloy-primitives`, version `1.6.0`.) revm pins `29.0.1`. + +**Library construction — verbatim shape, corrected (`ethereum/src/builder.rs`):** +```rust +use helios_ethereum::config::networks::Network; +use helios_ethereum::database::FileDB; +use helios_ethereum::{EthereumClient, EthereumClientBuilder}; +use alloy::primitives::B256; + +// Turbofish pins the builder's DB type param up front (the builder is +// generic `EthereumClientBuilder`; `.with_file_db()` exists ONLY on +// ``, `.with_config_db()` only on ``). +let client: EthereumClient = EthereumClientBuilder::::new() + .network(Network::Mainnet) // Mainnet | Sepolia | Holesky | Hoodi + .consensus_rpc(consensus_rpc)? // -> Result (needs ?) our private CL LC-API + .execution_rpc(untrusted_el_rpc)? // -> Result (needs ?) our private/proxied EL + .checkpoint(trusted_checkpoint_b256) // -> Self (NO ?) takes a B256 + .strict_checkpoint_age() // -> Self refuse a >14d checkpoint, don't warn + // .load_external_fallback() // -> Self community checkpoints — gate behind a flag + .data_dir(deckard_data_dir().join("helios")) + .with_file_db() // pins DB=FileDB; persists last finalized checkpoint + .build()?; // -> Result + +client.wait_synced().await?; // returns once CONSENSUS bootstrapped (see caveat ↓) +``` +Fallible setters returning `Result` (need `?`): `consensus_rpc`, `execution_rpc`, `fallback`, `verifiable_api` (all generic over `T: IntoUrl`, so `&str`/`String`/`Url` all work). Infallible setters returning `Self`: `network`, `checkpoint(B256)`, `data_dir(PathBuf)`, `rpc_address(SocketAddr)`, `config(Config)`, `load_external_fallback()`, `strict_checkpoint_age()`, `with_file_db()`, `with_config_db()`. + +**⚠ `wait_synced()` is NOT "ready to serve reads."** It returns once the *consensus* checkpoint is bootstrapped; the latest *execution* head isn't pushed into cache until the next optimistic update (≤1 slot, ~12 s). Until then `get_block_number()` / any `Latest`-tag read fails the 60 s head-age gate with `OutOfSync`. **Poll `get_block_number()` until `Ok` after `wait_synced()`** (the basic.rs example sleeps 15 s for exactly this; the spike polls). This caught us — measure "time to first servable head," not "time to `wait_synced`." + +**The read surface lives on the `HeliosApi` trait** (`EthereumClient = HeliosClient` derefs to `Arc>`). The methods Deckard uses, with signatures: +- `get_balance(Address, BlockId) -> Result` · `get_nonce(..) -> Result` · `get_code(..) -> Result` · `get_storage_at(..) -> Result` · `get_proof(..) -> Result` +- `get_block_number() -> Result` · `get_block(BlockId, full) -> Result>` · `call(&TxReq, BlockId, Option) -> Result` · `get_logs(&Filter) -> Result>` +- **Status observables (these power `ReadStatus`):** `syncing() -> Result` (`None`=synced, `Info`=catching up), `current_checkpoint() -> Result>`, `new_checkpoints_recv() -> watch::Receiver>` (fires on each sync-committee update — a liveness signal), `wait_synced()`, `shutdown()`. +- Pass `Latest` as `alloy::eips::BlockNumberOrTag::Latest.into()` (a `BlockId`). `U256` head does **not** cleanly `.into()` a `BlockId` — use the tag. + +## Architecture — and the one fact the whole walkaway rests on + +Helios is an EL light client: it takes beacon block headers verified by the **CL sync committee** and combines them with an *untrusted* EL RPC to return verified EL data. The untrusted EL must serve correct Merkle proofs (`eth_getProof`); it cannot lie about state without detection. Source: a16z, ["Building Helios"](https://a16zcrypto.com/posts/article/building-helios-ethereum-light-client/). + +**The load-bearing detail (verified in `core/src/client/node.rs` + `execution/providers/`):** when the consensus client verifies a new header, a background task **pushes the verified execution block into the execution provider's in-memory cache** (`execution.push_block(block, Latest)`). So: + +- **`get_block_number()` / head reads from that cache — it does NOT call the EL RPC.** The head is *consensus-driven* and EL-independent. +- **Only proof-bearing state reads hit the EL.** `get_balance` → `get_account` → `eth_getProof` against the untrusted EL, then verifies the returned account against the cached header's state root. +- Each `Latest`-tag read first runs `check_head_age()`, which **hard-fails with `OutOfSync` once the cached head is >60 s old.** (`get_logs`, `get_transaction`, receipts, `send_raw_transaction` skip this gate — relevant for the receive-watcher.) +- Helios's `CachingProvider` **caches the account proof per block**, so repeated `get_balance` of the same address at the same head is served from cache with no EL call until the head advances. + +These four facts dictate the entire failover design and the demo's behavior. They are why the walkaway is honest and why it's demoable. + +## The walkaway beat (R2) — the chosen design, proven + +**Verified constraint:** one `EthereumClient` has exactly one EL and one CL (`execution_rpc`/`consensus_rpc` are single URLs). **Helios has no native multi-EL/CL failover.** Continuation after a cut is *Deckard's* logic. + +**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 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:** + +| 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 | + +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`. + +## Inputs, trust, and the checkpoint + +**Three inputs (verified):** +1. **Untrusted EL RPC** (`execution_rpc`) — must support `eth_getProof`. (Not all public RPCs do — it's the gating filter; see providers.) +2. **CL light-client RPC** (`consensus_rpc`) — must speak the beacon light-client REST API. The mainnet default in source is `https://ethereum.operationsolarstorm.org` (a CNAME to the Nimbus-team `testing.mainnet.beacon-api.nimbus.team` box) — **not** `lightclientdata.org` (that's a16z's old default, currently 503). Sepolia/Holesky/Hoodi have **no** default CL (`consensus_rpc=None`) — you must supply one. +3. **Weak-subjectivity TRUSTED CHECKPOINT** (`checkpoint`, a `B256` beacon block root) — the one thing trusted on cold start. `max_checkpoint_age = 1_209_600` s = **exactly 14 days** for every network. `strict_checkpoint_age()` refuses an older one instead of warning — run strict in the demo build. + +**Checkpoint sources, in descending trust** (unchanged, all verified to exist): +- **User-pinned** (best): a recent finalized root from a source you trust; Deckard ships a recent default + lets the user override. +- **Cached** (good): `FileDB` persists the last finalized root to `data_dir/checkpoint` (32 raw bytes); next start re-uses it if fresh. This is what makes warm start ~2 s vs ~11 s cold. +- **Community fallback** (weakest): `load_external_fallback()` / `CheckpointFallback` queries ethPandaOps's list, which the example code itself calls *"NOT guaranteed to be secure."* Treat as last resort, surface as `Degraded` when used. + +## Beacon light-client providers — and a caveat the route-200 check misses + +A provider qualifies only if it serves the `/eth/v1/beacon/light_client/*` REST namespace **and** full `/eth/v2/beacon/blocks/{slot}` blocks whose `tree_hash_root` matches the verified header. **Serving the LC routes with HTTP 200 is necessary but NOT sufficient** — Helios fetches the full block to extract the execution payload header, and rejects it on a hash mismatch. We learned this the hard way: + +| Endpoint | LC routes 200? | Helios actually syncs? | Notes | +|---|---|---|---| +| `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://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). + +**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-host fallback (smallest path).** The `light_client/*` namespace is standard ([beacon-APIs spec](https://github.com/ethereum/beacon-APIs)). Which CLs serve it: + +| CL | LC server default | Flag | +|---|---|---| +| **Lighthouse** | **ON by default** | disable-only: `--disable-light-client-server`. Just run `--http`. **Easiest self-host.** | +| **Nimbus** | **ON by default** | `--light-client-data-serve=true` (default). | +| **Lodestar** | **ON by default** | `lightclient` is in the default REST namespaces; disable-only `--disableLightClientServer`. | +| **Teku** | ⚠ **conflicting reports** | one source: `--light-client-support-enabled` default `true`; another: `--Xrest-api-light-client-enabled` default `false`. **Resolve or avoid Teku.** | +| **Grandine** | ⚠ unverified | no documented LC flag; couldn't confirm the routes. **Avoid for now.** | + +## Privacy interplay + +Helios closes the **integrity** gap (no server can lie about state) but **not** the **metadata** gap — and the gap is asymmetric: + +| Upstream | Sees IP? | Sees user address? | How | +|---|---|---|---| +| **EL (execution RPC)** | yes | **YES** | `eth_getProof` (address is a param, backs balance/nonce/code/storage), `eth_call` (`to`/`from`/`data`), `eth_getLogs` (address/topics). **The real leak surface.** | +| **CL (beacon LC RPC)** | yes | **no** | every LC endpoint carries only slots / sync-committee periods / block roots — **no user address ever crosses the CL.** | + +So spend the privacy budget on the **EL**; the CL needs IP hygiene only, not address hygiene. Mitigations, ranked: (1) **self-host the EL** (closes it fully; heavy); (2) **self-hosted Helios `verifiable-api` server** in front of your EL — note that a *third-party-hosted* verifiable-api leaks the same IP+address (the address is in the URL path), so it's only a privacy win if **you** run it; (3) **a proxy Deckard controls**; (4) **a no-log keyless public EL that supports `eth_getProof`** — PublicNode (documented no-log + "IP not correlated to wallet addresses"), dRPC, BlastAPI public. **Tor is out of scope for v1.** + +**Both EL upstreams (primary + failover) must be no-log/keyless — not Infura/Alchemy** (the IP↔address leak Deckard's pitch rejects). ⚠ "No-log" is a provider *policy*, not a cryptographic guarantee; attribute it to the provider, never assert it. The honest claim: *"verified locally, and no default IP↔address-correlating vendor in the read path"* — not *"private reads."* (The spike's defaults — publicnode + dRPC + Nimbus — are exactly this posture.) + +## Measured (M-series desktop, mainnet, 2026-06-05, from the spike) + +| 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 | +| **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 | +| **Head liveness after EL cut** | advanced 25252833 → 25252835 ✓ | served from CL cache with EL1 dead — EL-independent, as designed | +| **Verified balance correctness** | 86,313,877.35 ETH (deposit contract) | identical pre- and post-cut (0 wei drift) | +| Release build (helios-ethereum tree) | ~2.5 min | revm + alloy + bls; binary 19 MB | + +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 + +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: + +```yaml +# lc-devnet.yaml — CL answers the light_client/* routes out of the box +participants: + - el_type: geth + cl_type: lighthouse # serves LC by default; pass extra flags via cl_extra_params if ever needed + count: 1 +``` +`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 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`. + +## The spike (`spikes/helios-walkaway/`) + +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. + +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. + +**Acceptance test (the R2 slice; the spike implements steps 1–3):** +``` +Scenario "Helios verified reads + walkaway" (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) + 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 + 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. + +## 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. +- **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. +- **Devnet has no CL.** anvil can't be Helios-verified — use Kurtosis (Option A) or Sepolia. Owned by `00-test-harness.md`. + +## Open questions — status after this deep-dive + +- ~~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.) + +## Sources (repos + docs) + +- [a16z/helios](https://github.com/a16z/helios) @ tag `0.11.1` (ref `204c998a`) — `Cargo.toml` (workspace members, `alloy 1.0.37`/`revm 29.0.1`, ethereum_hashing patch); `ethereum/src/builder.rs` (builder signatures); `ethereum/src/lib.rs` (`EthereumClient` alias); `ethereum/src/config/networks.rs` (mainnet CL default, 14-day age, Hoodi); `ethereum/src/database.rs` (FileDB warm-start); `ethereum/src/consensus.rs` + `ethereum/src/rpc/http_rpc.rs` (LC endpoints, CL-death behavior); `core/src/client/{mod,api,node}.rs` (HeliosApi trait, CL-driven head, 60 s gate); `examples/{basic,client,checkpoints,call}.rs`. +- [Beacon LC API spec](https://github.com/ethereum/beacon-APIs) · [Lighthouse Book](https://lighthouse-book.sigmaprime.io/help_bn.html) · [Nimbus light-client-data](https://nimbus.guide/light-client-data.html) · [Lodestar beacon-cli](https://chainsafe.github.io/lodestar/run/beacon-management/beacon-cli/) — CL light-client-server defaults/flags. +- [ethpandaops/ethereum-package](https://github.com/ethpandaops/ethereum-package) — Kurtosis CL config (`cl_extra_params`), all-forks-from-genesis. +- [a16z: Building Helios](https://a16zcrypto.com/posts/article/building-helios-ethereum-light-client/) — design. +- `docs/research/06-privacy.md` — Infura IP+address leak; Helios as the embeddable Rust light client. +- `docs/research/v1-demo-plan.md` — beat 3, R2 spike, mainnet-regardless walkaway framing. +- **Live spike: `spikes/helios-walkaway/`** — the runnable proof + measurements. diff --git a/docs/build/30-mcp-shape.md b/docs/build/30-mcp-shape.md new file mode 100644 index 0000000..cebe8c1 --- /dev/null +++ b/docs/build/30-mcp-shape.md @@ -0,0 +1,247 @@ +# MCP Tool Surface (CLI + MCP sidecar) + +> The agent-facing surface for Deckard, plus the freeze-first `Intent`/`Decision`/daemon-socket contract every other build doc codes against · serves demo beat 2 (agent shields on receive via MCP) + acceptance step "MCP sidecar registered in Claude Desktop, secrets never in transcript" (deliverable #6) · status (spec). Part of the Deckard build docs. + +## Why this exists (2-4 sentences, concrete) + +The hero beat is "the agent (Claude Desktop) auto-shields an inbound payment." Claude reaches Deckard through one Rust binary — `deckard-mcp` — that is **both a CLI and an MCP server** (the `@splits/splits-cli` pattern: one binary, `--mcp` auto-exposes every command as a tool). That binary is a **key-less client**: it never holds the secp256k1 key, never signs; it only proposes intents to the process-isolated signer daemon (`deckard-signerd`, owned by `00-test-harness.md`) and renders native approval cards. This doc **owns the freeze-first contract** (`Intent`, `Decision`, the daemon socket API) so T-Privacy, T-Custody, and T-Agent can build in parallel against frozen types. + +## Where it sits — Depends on / Unblocks (cross-doc + demo) + +**Depends on** +- `deckard-signerd` — the process-isolated signer daemon. The daemon's *implementation* and the STOP/revoke red-team test live in `00-test-harness.md`; this doc defines the **socket API it must expose** so both sides freeze the same wire contract. +- The shield path — `shield(amount)` ultimately lands in Kohaku's Railgun integration (`10-kohaku-shield.md`). The MCP `shield` tool produces a `kind: Shield` `Intent`; this doc owns the `Intent` shape, `10-kohaku-shield.md` owns what the daemon does with it. +- Helios over private RPC (`20-helios-sidecar.md`) — the read tools (`wallet_balance`, `simulate`) source verified state from Helios, not a raw vendor RPC. + +**Unblocks / what this doc freezes for others** +- `00-test-harness.md` implements `deckard-signerd` against the socket API frozen here, and its STOP test asserts `revoke_all()` then `propose`/`execute` deny. +- `10-kohaku-shield.md` references the `Intent{kind:Shield}` shape and the `shield` tool. +- T-UX (deliverable #9) renders the native approval card this doc specifies (`needs_approval` → card → poll). + +**Demo beat:** beat 2 (live receive → agent calls `shield` via MCP → approval → tx). Reliability backup (v1-demo-plan §Reliability): the same tool surface is callable by an in-app agent loop if Claude Desktop flakes on stage — so the MCP layer must be a thin shell over the daemon socket, with **no logic that only Claude can trigger**. + +## Architecture / approach + +``` +┌─────────────────┐ MCP stdio (JSON-RPC 2.0) ┌──────────────────┐ UDS (CBOR) ┌──────────────────┐ +│ Claude Desktop │ ─────────────────────────► │ deckard-mcp │ ──────────► │ deckard-signerd │ +│ / Cursor / Codex│ list_tools / call_tool │ (CLI + MCP, KEY- │ propose/ │ (holds key, │ +└─────────────────┘ │ LESS) │ execute/ │ process-isolated│ + ▲ └──────────────────┘ revoke_all │ policy gate) │ + │ approval card (native, GPUI) │ └──────────────────┘ + │ │ raise card / read status │ + └──────────────── Deckard GPUI app ◄─────────────────────────────────────────────┘ + (renders the card, owns the lock screen / STOP button) +``` + +Three processes, three trust levels: + +1. **`deckard-mcp`** — the agent surface. Key-less. Translates tool calls → `Intent` → daemon `propose`/`execute`. Speaks MCP over stdio to the LLM host and a Unix-domain-socket (UDS) RPC to the daemon. This is the **anti-pattern inversion** of `mcpdotdirect/evm-mcp-server` / `dcSpark/mcp-cryptowallet-evm`, which load the raw key (`EVM_PRIVATE_KEY` / `EVM_MNEMONIC`) into the MCP process where the tool layer can reach it (05-agentic-wallets.md [10]). Deckard's MCP process has **no key material at all**. +2. **`deckard-signerd`** — holds the decrypted key in its own address space, runs the policy gate (`Decision`), signs. Defined here as a socket; implemented in `00-test-harness.md`. +3. **Deckard GPUI app** — owns the user. It renders the **native approval card** (not a browser `approvalUrl` like Base MCP) and the STOP button, and it is where the keystore is unlocked. + +The CLI and the MCP server are the **same binary, same command tree** — `cli.serve()` for the CLI, `--mcp` for the server, exactly the Splits/`incur` shape: "no manual config, no copy-pasting tool definitions" (04-splits.md [1]; verified: `splits-cli` v0.2.9 depends on `incur ^0.3.13` + `viem ^2.48.2`, bin `splits` → `dist/cli.js`). Every CLI subcommand auto-registers as an MCP tool with the snake_case name `namespace_command` (Splits: `transactions list` → `transactions_list`, `accounts get` → `accounts_get` — verified against the published source). + +## Concrete interface (commands, types, crate names, RPC methods, file layout) + +### Crates + +- MCP server: **`rmcp`** (the official Rust MCP SDK, `modelcontextprotocol/rust-sdk`) — provides `#[tool]` macros, stdio + streamable-HTTP transports, JSON-RPC 2.0 framing. ⚠ confirm the current `rmcp` version supports the stdio + streamable-HTTP transports we need at build time. +- CLI parsing: **`clap`** v4 (derive). The same command structs feed both `clap` and the `rmcp` tool registry via a thin macro/codegen layer (our equivalent of `incur`). +- Daemon RPC: UDS via **`tokio`** `UnixListener`/`UnixStream`; framing with **`serde`** + **`ciborium`** (CBOR — compact, no string-quoting of binary calldata). +- Types crate: **`deckard-contract`** — a `no_std`-friendly crate holding `Intent`, `Decision`, `Policy`, and the RPC enums, depended on by `deckard-mcp`, `deckard-signerd`, and the GPUI app so the contract is one source of truth. +- EVM types: `alloy-primitives` (`Address`, `U256`, `Bytes`) — already in `Cargo.toml`. + +### THE FREEZE-FIRST CONTRACT (owned here) + +```rust +// crate: deckard-contract — frozen 2026-06-05, reference, do not redefine elsewhere. +use alloy_primitives::{Address, U256, Bytes, B256}; + +/// What the agent wants to do. The ONLY thing that crosses mcp → daemon for a write. +/// The agent never sends raw signed bytes — only intent; the daemon decides + signs. +pub struct Intent { + pub to: Address, // target (token contract, Railgun adapter, recipient) + pub token: Option
,// None = native ETH; Some = ERC-20 contract + pub value: U256, // wei (native) or token base units + pub calldata: Bytes, // empty for a plain send; encoded call otherwise + pub kind: IntentKind, // discriminator the policy gate switches on +} + +pub enum IntentKind { + Send, // plain transfer + Shield { /* Railgun deposit; see 10-kohaku-shield.md for adapter/calldata */ }, + Unshield, + ContractCall, // generic write (forward-compat for plugins) +} + +/// The daemon's verdict. Returned by `propose`. The agent cannot forge `Allow`. +pub enum Decision { + Allow, // within policy → safe to `execute` + Deny { reason: String }, // policy violation; terminal + NeedsApproval { request_id: RequestId },// human must approve via native card +} + +pub type RequestId = B256; // opaque; the agent polls status on it + +/// Policy the agent is allowed to READ (so it can stay inside its fence) but never write. +pub struct Policy { + pub per_tx_cap_wei: U256, + pub daily_cap_wei: U256, + pub spent_today_wei: U256, + pub allow_to: Vec
, // empty = any + pub auto_shield_min_wei: U256, // the demo rule: auto-shield inbound ETH ≥ X + pub require_approval: ApprovalMode, // Never | OverCap | Always + pub revoked: bool, // set true by revoke_all / STOP +} + +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: + +```rust +// deckard-mcp (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 +} + +enum ExecuteResult { Broadcast { tx_hash: B256 }, Denied { reason: String } } +enum ApprovalStatus { Pending, Allowed, Denied { reason: String }, Expired } +``` + +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). + +### MCP tool surface (concrete list) + +Read tools (no approval, key-less, safe to call freely — the "observe" half, 05-agentic-wallets.md [21]): + +| Tool | Maps to | Returns | Approval | +|---|---|---|---| +| `wallet_address` | `SignerRequest::Address` | `{ address }` | none | +| `wallet_balance` | `SignerRequest::Balance{shielded}` | `{ public_wei, shielded_wei, token_balances[] }` (Helios-verified, 20-helios-sidecar.md) | none | +| `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 | + +Write tools (route through `propose` → `Decision`; "execute validated intents, not raw LLM suggestions", 05 [10]): + +| Tool | Builds | Approval | +|---|---|---| +| `propose` | `Intent` → `Decision` | returns `needs_approval` when over cap / `ApprovalMode::Always` | +| `execute` | `Execute{request_id}` | only succeeds on `Allow` or an `Allowed` approval | +| `shield` | `Intent{kind:Shield}` (the demo HERO; calldata from `10-kohaku-shield.md`) | per `Policy.require_approval`; demo runs `auto_shield_min_wei` with `Never`/`OverCap` so the beat is hands-free | +| `revoke_all` | `RevokeAll` | none to call; it *is* the brake (STOP). Always available. | + +The agent's demo loop: receive watcher fires → `wallet_balance` → `simulate` the shield → `shield(amount)`. If `Decision::Allow`, `execute`; the auto-shield rule keeps beat 2 free of a human prompt. + +### Approval flow for writes (native card, not a browser URL) + +``` +agent: propose(Intent) ──► daemon: Decision::NeedsApproval{ request_id } + │ +Deckard GPUI raises a NATIVE card ◄───────┘ (shows simulate() asset-changes + to/value) + user taps Approve / Deny on the desktop, in-process + │ +agent: poll status(request_id) every ~750ms ──► Pending → Allowed | Denied{reason} | Expired + on Allowed: agent calls execute(request_id) ──► tx_hash +``` + +Contrast with **Base MCP** (verified against `docs.base.org/ai-agents`): a write returns `{ approvalUrl, requestId }`, the user opens a **browser/Base Account** link, and the assistant polls `get_request_status(requestId)` until `confirmed` (05 [4]). Deckard keeps the identical poll *shape* (`status(request_id)`) but the review surface is a **native GPUI card** — local-first, no browser round-trip, no hosted account, and the card reuses Deckard's own `simulate` output. Approvals expire (`ApprovalStatus::Expired`) so a stale `request_id` can't be executed later. + +### Security discipline (from Splits) + +- **MCP mode refuses flag-based secrets.** Verified Splits behavior: with `SPLITS_MCP_MODE=1` the CLI "refuses flag-based secrets (`--api-key`, `--private-key`) so secrets don't appear in tool-call transcripts" and "the private key never appears in any command's response — only the derived address." Deckard mirrors this: when launched with `--mcp` (or `DECKARD_MCP_MODE=1`), `deckard-mcp` **hard-rejects** any flag that could carry a secret (`--passphrase`, `--rpc-token`, `--key`); secrets live only in a `0600` config or the OS keychain, and stdin is the only accepted entry path. This is largely moot because `deckard-mcp` is key-less, but the rule still guards RPC tokens and the keystore passphrase from entering tool-call transcripts. +- **No secret in any response.** Read tools return addresses/balances/policy only — never key bytes, never the passphrase, never an RPC bearer token. +- **The sidecar is key-less.** The key boundary is the daemon's process, not a flag. + +### Transport + +- **stdio (primary)** — JSON-RPC 2.0 over stdin/stdout, the Claude Desktop / Cursor / Codex registration path. Registration mirrors Splits' `claude mcp add splits -e SPLITS_API_KEY=... -- npx @splits/splits-cli --mcp`; Deckard: + `claude mcp add deckard -- /usr/local/bin/deckard-mcp --mcp` (no secret env var needed — it is key-less; it auto-discovers `signerd.sock`). +- **Optional authenticated localhost HTTP** — `--mcp-http --bind 127.0.0.1:7423 --auth-token-file <0600>`, for the in-app backup driver and for clients that don't speak stdio. Bound to loopback only; bearer token from a `0600` file, never a flag. +- **Caller auth + single-instance:** UDS to the daemon uses **peer-cred** (`SO_PEERCRED` / `LOCAL_PEERCRED`) so only the same-uid Deckard/MCP process connects; the daemon is single-instance (flock on the socket dir). HTTP mode adds the bearer token on top. `revoke_all`/STOP is reachable on every transport. + +## v0 baseline / spike plan + acceptance test (agent-runnable asserts) + +**v0 baseline (today):** none of this exists. `src/wallet.rs` is a plaintext-hex EOA with the signer **in-process** (the anti-pattern). The freeze-first job is: publish `deckard-contract` (the types above) and a **mock `deckard-signerd`** that answers the socket API from an in-memory policy, so T-Agent/T-UX build before the real daemon (`00-test-harness.md`) lands. + +**Spike order (½ day, the "freeze first" of v1-demo-plan §Parallel tracks):** +1. Publish `deckard-contract` with the types above; `cargo build`. +2. Stand up `deckard-mcp` over `rmcp` stdio exposing the 8 tools, talking to a **mock daemon** (in-memory `Policy`, deterministic `tx_hash`). +3. Write an MCP test client (Rust, `rmcp` client, or `@modelcontextprotocol/inspector`) that lists + calls each tool. +4. Claude Desktop dry-run: register, confirm tools appear, run the demo loop against the mock. + +**Acceptance test (agent-runnable; the shot-list style of v1-demo-plan):** + +``` +Scenario "MCP surface: read-free, write-gated, secret-tight" (mock daemon, then real): + setup: deckard-contract built; deckard-mcp --mcp talking to a mock signerd + with Policy{ per_tx_cap_wei: 0.05e18, auto_shield_min_wei: 0.01e18, + require_approval: OverCap, revoked: false }. + + T1 list_tools assert: exactly {wallet_address, wallet_balance, simulate, + policy_get, propose, execute, shield, revoke_all} + T2 call wallet_address / wallet_balance / policy_get + assert: succeed with NO approval; response JSON contains + no 64-hex-char key, no "passphrase", no bearer token + T3 propose(Intent{kind:Send, value: 0.2e18}) // over per_tx_cap + assert: Decision == NeedsApproval{request_id} (NOT Allow) + T4 execute(request_id) before approval assert: ExecuteResult::Denied (no tx, never signs on Pending) + T5 simulate the over-cap shield assert: returns asset_changes + warnings, signs nothing + T6 shield(0.02e18) with require_approval=OverCap and 0.02 ≤ per_tx_cap + assert: Decision == Allow; execute → tx_hash present + T7 secret-refusal: invoke any tool with --passphrase=x / --key=x in MCP mode + assert: hard error "secrets not accepted in MCP mode"; + the rejected value never echoed in the response + T8 revoke_all(), then execute(prior Allowed request_id) + assert: Denied{reason:"revoked"} (TOCTOU guard holds) + T9 transcript scan: grep the ENTIRE tool-call transcript (T1..T8) + assert: zero 64-hex-char strings, zero "passphrase", + zero RPC bearer tokens ← the key-leak gate (deliverable #6) + --- Claude Desktop dry-run (manual, recorded) --- + D1 `claude mcp add deckard -- deckard-mcp --mcp`; tools list in the UI + D2 ask Claude to "shield 0.02 ETH"; assert it calls simulate → shield → execute, + an over-cap amount raises a NATIVE card (no browser opens), and STOP denies the next execute. +``` + +T9 is the demo's load-bearing assertion (v1-demo-plan deliverable #6: "secrets never in transcript"). It runs headless in CI; D1–D2 are the on-camera rehearsal. + +## Risks & fallbacks + +- **`rmcp` maturity / churn.** The official Rust MCP SDK is young. *Fallback:* hand-roll the JSON-RPC 2.0 stdio framing (it's small) behind the same tool registry; the contract crate is transport-agnostic so the swap is local. ⚠ unverified: exact `rmcp` version + transport set at build time. +- **`incur`-style auto-exposure has no Rust equivalent.** We replicate it with a `clap`-derive → `rmcp`-tool macro. *Fallback:* register the ~8 tools by hand — the surface is small enough that hand-registration is cheap and the "auto" property matters more for a 40-command CLI than for ours. +- **Approval-poll latency vs. agent speed** (the 05-agentic-wallets §safe-signing tension): per-write human prompts collapse agent speed. *Resolution (already in the design):* the auto-shield rule runs `ApprovalMode::Never`/`OverCap` inside the policy fence so the HERO beat needs no prompt; cards fire only over cap. +- **Claude Desktop flakes on stage** (v1-demo-plan §Reliability backup). *Fallback:* the in-app agent loop calls the same `deckard-mcp` over localhost HTTP — guaranteed because the MCP layer is a thin shell over the daemon socket with no Claude-only logic. +- **UDS peer-cred portability.** `SO_PEERCRED` (Linux) vs `LOCAL_PEERCRED` (macOS) differ. *Fallback:* on macOS gate on socket file mode `0600` + `$XDG_RUNTIME_DIR` owner-only dir; add a per-launch nonce in the socket dir. + +## Open questions + +- Does `Intent` need a `chain_id`/nonce field at freeze time, or does the daemon own nonce/chain entirely? (Leaning: daemon owns it — the agent should not pick nonces. Confirm with `00-test-harness.md`.) +- Should `simulate` live in `deckard-mcp` (key-less, calls Helios directly) or in the daemon? Putting it in the daemon keeps one Helios client; putting it in MCP keeps the daemon minimal. (Leaning: daemon, so the approval card and the agent see identical numbers.) +- Approval-card timeout default (30s? 60s?) and whether `Expired` auto-denies or requires re-propose. +- For the localhost-HTTP backup driver, is a static `0600` bearer token enough, or do we want per-launch token rotation? + +## Sources (repos + docs, linked) + +- splits-cli — one binary CLI+MCP, `--mcp` auto-exposure, `SPLITS_MCP_MODE=1` secret refusal, key never in responses — https://github.com/0xSplits/splits-cli (verified: v0.2.9, deps `incur ^0.3.13` + `viem ^2.48.2`, bin `splits`→`dist/cli.js`, tool naming `namespace_command`) +- incur (the CLI→MCP framework Splits builds on) — https://github.com/wevm/incur +- Base MCP approval flow — `send()`/`swap()` return `{approvalUrl, requestId}`, assistant polls `get_request_status(requestId)` until confirmed, smart wallet signs server-side, keys never exposed to AI — https://docs.base.org/ai-agents (verified) +- Coinbase Payments MCP — local desktop, no API key, x402 pay + spend limits + approval thresholds — https://www.coinbase.com/developer-platform/discover/launches/payments-mcp (05-agentic-wallets.md [3]) +- Anti-pattern: raw-key EVM MCP servers — `EVM_PRIVATE_KEY`/`EVM_MNEMONIC` in the MCP process — https://github.com/mcpdotdirect/evm-mcp-server · https://github.com/dcSpark/mcp-cryptowallet-evm +- rmcp — official Rust MCP SDK — https://github.com/modelcontextprotocol/rust-sdk +- MCP spec (JSON-RPC 2.0, tools, stdio) — https://modelcontextprotocol.io/specification +- Kohaku (Railgun shield path; `ethereum/kohaku`, TypeScript-primary with ~476KB Rust — standalone-Rust-crate consumability is R1, owned by `10-kohaku-shield.md`) — https://github.com/ethereum/kohaku · https://ethereum.github.io/kohaku/railgun/intro/ (⚠ Rust-crate-stability unverified here by design) +- Safe-signing canon (simulate-before-sign, scoped policy, human-in-loop, key isolation) — 05-agentic-wallets.md [1][2][13] diff --git a/docs/build/README.md b/docs/build/README.md new file mode 100644 index 0000000..1de1cd2 --- /dev/null +++ b/docs/build/README.md @@ -0,0 +1,88 @@ +# Deckard Build Specs + +> Concrete, parallelizable implementation specs for the v1 demo. These implement +> [`../research/v1-demo-plan.md`](../research/v1-demo-plan.md) (the locked demo) and draw on the research +> KB in [`../research/`](../research/). Written + repo-verified 2026-06-05. + +**The demo they build toward:** *receive money → it's instantly private → and you can't switch it off* — +live on mainnet, agent-driven (Claude Desktop via MCP), shielded via Railgun, verified by Helios, with the +**walkaway** beat (cut the RPC on camera, Deckard keeps working). CROPS-aligned, open-source, self-custodial. + +## 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 ✓ | + +## Build order (what gates what) + +``` + ┌─ freeze the contract (deckard-contract crate: Intent / Decision / Policy / daemon UDS API) ─┐ [owned by 30] + │ │ + 00 harness Lane A (anvil fork) + fixtures/addresses.mainnet.json + FakeModel runner ◄─────────────┘ + │ (the substrate everything tests against — build FIRST) + ▼ + ┌────────────┬─────────────────┬──────────────────┬───────────────────┐ + │ T-Custody │ T-Privacy (10) │ T-Trustless (20) │ T-Agent (30) │ ← run in parallel + │ keystore→ │ railgun shield │ Helios lib + │ MCP binary + │ + │ signer │ spike (R1) on │ walkaway │ contract impl; │ + │ daemon │ fork/Sepolia │ supervisor (R2) │ FakeModel first, │ + │ │ │ │ Claude Desktop │ + └────────────┴─────────────────┴──────────────────┴───────────────────┘ + ▼ + integrate on Lane B (Kurtosis EL+CL) / Lane C (Sepolia) → mainnet hero when green +``` + +**Start immediately, in parallel:** the **contract crate** (tiny, unblocks all), **harness Lane A**, and the +two risky hero spikes (**10** shield, **20** walkaway). T-Custody and T-Agent build against the frozen +contract + FakeModel before the daemon/Claude are wired. + +## The freeze-first contract (owned by `30-mcp-shape.md`) + +A shared `deckard-contract` crate: `Intent{to,token,value,calldata,kind}` · `Decision{Allow | Deny{reason} +| NeedsApproval{request_id}}` · `Policy` (agent-readable) · the daemon UDS socket API +(`propose / execute / status / revoke_all / policy_get / address / balance`). Every other track codes +against this; the harness's `FakeModel` exercises it before any LLM is in the loop. + +## Shared seams (single sources of truth — don't fork them) + +- **`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.) +- **`ReadStatus { Verified | Degraded | Unsynced }`** — attached to every read; the UI/agent must see it; + **never silently fall back to untrusted RPC.** (20 owns.) + +## 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. + +## Acceptance = the shot list (lives in `00-test-harness.md`) + +One headless scenario (`receive < N s → shield asserts balance↑/public↓/link-broken → cut RPC asserts +still-verified`) that an AI coding agent runs to self-verify. Green on Lane A/C ⇒ the mainnet video is shootable. + +## 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 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` +- `rmcp` (official Rust MCP SDK) version/transports vs hand-rolled JSON-RPC stdio. — `30` + +## Not here (fast-follow — see `../research/roadmap.md`) + +STOP-on-camera beat · allocate/donate slice · **EIP-7702 session keys** · **x402 / MPP as wallet plugins** +(the pluggable MCP tool registry is designed for this) · stealth addresses · hardware-wallet signing · audit. diff --git a/docs/research/01-landscape-2026.md b/docs/research/01-landscape-2026.md new file mode 100644 index 0000000..b33dabc --- /dev/null +++ b/docs/research/01-landscape-2026.md @@ -0,0 +1,146 @@ +# The 2026 Wallet Landscape — State of the Art + +> A language-agnostic survey of Ethereum wallet architecture, standards, security, and product direction circa mid-2026. Part of the Deckard wallet research KB. Researched 2026-06-05. + +## TL;DR + +- Account abstraction (AA) split into two tracks that now **compose rather than compete**: ERC-4337 supplies off-protocol infrastructure (EntryPoint, bundlers, paymasters), and EIP-7702 lets an existing EOA delegate to that infrastructure without changing address. The shipped production pattern is "7702 + 4337 together." [1][2][9][16] +- EIP-7702 went live on Ethereum mainnet on **May 7, 2025** in the Pectra hard fork (epoch 364032). It adds transaction type `0x04`, writing a persistent `0xef0100 || address` delegation pointer while the EOA keeps its address and key. [1][2][3] +- EIP-7702 has **no native gas sponsorship**; paymasters come from layering ERC-4337 on top. [2][16] +- ERC-4337 EntryPoint **v0.8.0** (March 26, 2025, `0x4337084d9e255ff0702461cf8895ce9e3b5ff108`) added native EIP-7702 handling and shipped the audited minimal **Simple7702Account** any EOA can delegate to. [4][10] +- The frontier of differentiation is the surrounding **standards mesh**: ERC-5792 (batched/atomic calls + capabilities), ERC-7715/7710 (scoped, time-boxed delegated permissions), ERC-7811 (unified balance), ERC-7683 (cross-chain intents), ERC-7730 (clear signing), and RIP-7212 (cheap on-chain passkeys). [5][6][7][8][11][13] +- MetaMask shipped ERC-7715/7710 **"Advanced Permissions"** to production on **April 6, 2026**, explicitly naming AI agents, subscriptions, DCA, vesting, and auto-compounding as use cases. Granting is an EIP-712 signature, not an on-chain tx. [6][17] +- Most wallets still lack the full stack in practice: EOAs remain dominant, ERC-5792/7715 are a sliver of real traffic, clear-signing coverage is partial, and cross-chain module portability is unsolved. [12][18][22] +- The agentic-payments layer (ERC-8004 trustless agent identity + x402 micropayments + session-key delegation) is the literal blueprint for an LLM operator wallet — but **none of it works on a bare EOA**; it requires keystore + smart-account/7702 + session-key layers. [19][20] +- Native protocol-level AA (EIP-8141) is proposed and "Considered for Inclusion" for the late-2026 Hegota fork, but is **not shipped and not a confirmed headliner**. 4337+7702 is the only shipped path through 2026. [21] + +## Account abstraction in practice: ERC-4337 + EIP-7702 post-Pectra + +Two composing tracks define what users actually get in 2026. + +**ERC-4337** (Finalized; live on mainnet since ~March 1, 2023) is the off-protocol model: an `EntryPoint` singleton validates "UserOperations" submitted by bundlers, with optional paymasters for gas sponsorship. ethereum.org cites over 26M smart wallets and over 170M UserOperations as a point-in-time, lower-bound snapshot. [9] + +**EIP-7702** (Standards-Track Core, created May 7 2024, activated in Pectra on May 7, 2025) introduces transaction type 4: an EOA signs an authorization tuple `(chain_id, address, nonce)` that writes a persistent `0xef0100 || address` delegation pointer into the account, so the EOA executes a chosen contract's code while keeping its address and private key. The original key retains full control; delegation resets by pointing at the null address. [1][2] + +What a 7702-upgraded EOA gains: atomic transaction batching, gas sponsorship and pay-gas-in-token (via 4337 paymasters), session keys, and recovery logic — without migrating address. Crucially, EIP-7702 includes no gas-sponsorship mechanism of its own; it only enables sponsorship architecturally, and production wallets borrow 4337 paymasters. The prevailing architecture is therefore "7702 + 4337 together": keep EntryPoint/bundlers/paymasters, drop per-user contract deployment. [2][16] + +⚠ unverified: a widely repeated secondary figure of "~14M EOAs signed at least one 7702 authorization" appears in aggregations but was not confirmed against a primary on-chain census; treat the magnitude as indicative, not exact. The Block independently reported 11,000+ authorizations in Pectra's first week. [22] + +### EntryPoint versioning and the 7702 bridge + +The canonical `eth-infinitism/account-abstraction` repo is the reference. EntryPoint v0.7 (`0x0000000071727De22E5E9d8BAf0edAc6f37da032`) moved simulation off-chain and was in production through 2024. **v0.8.0** (March 26, 2025; `0x4337084d9e255ff0702461cf8895ce9e3b5ff108`) is the pivotal release for operator-wallet relevance: it added native EIP-7702 authorization handling (the UserOp hash incorporates the 7702 delegation address) and introduced **Simple7702Account**, a fully audited minimal contract (ERC-165/721/1155/1271/4337) that any EOA can safely delegate to. A later v0.9.0 added parallelizable paymaster signing and a `paymasterSignature` field. Infra providers (Pimlico, Alchemy, ZeroDev, Biconomy, Gelato) serve both 4337 and 7702 from one stack. OtterSec (Dec 2025) documents "hidden risks" in paymasters (griefing/accounting bugs) — relevant security reading. [4][10][16][27] + +## Native protocol AA on the roadmap (EIP-8141) + +The frontier item is native, protocol-level AA. **EIP-8141** ("omnibus" AA) was created in the canonical `ethereum/EIPs` repo on **Jan 29, 2026** (Draft) with authors including Vitalik Buterin; Vitalik's public Ethereum Magicians unveiling followed on **Feb 28, 2026**. It introduces type-`0x06` "frame transactions" that separate a transaction into a verification phase and an execution phase (the spec defines frame MODE values DEFAULT/VERIFY/SENDER; "verification/execution" here is a paraphrase, not literal mode names), enabling native sponsored fees, multisig, alternative/quantum-resistant signatures, and gas in non-ETH tokens **without a separate bundler layer**. As of a late-March 2026 All Core Devs call, EIP-8141 holds **"Considered for Inclusion" (CFI)** status for the late-2026 Hegota fork — explicitly NOT a confirmed headliner (FOCIL is). The prior Glamsterdam fork headlines ePBS and Block-Level Access Lists, not AA. Bottom line: native AA is experimental, not shipped; 4337+7702 is the only shipped path through 2026. [21] + +## Passkey / WebAuthn signers (RIP-7212 / P256) + +Passkeys are now a mainstream signer option, enabled by **RIP-7212** — a precompile for secp256r1 (P256) verification taking `(hash, r, s, x, y)` at exactly **3,450 gas**. The "~100x cheaper" framing is specifically versus pure-Solidity P256 verification; the spec actually benchmarks the precompile as ~15% *slower* than `ecrecover`. RIP-7212 live status is firmly verified for **Arbitrum** (ArbOS 31 "Bianca," Finalized AIP); Optimism, Polygon, zkSync, and Kakarot are reported as committed/implemented via secondary aggregation. [7][13] + +Because P256 is what Apple Secure Enclave, Android Keystore, and browser WebAuthn use, a smart account can use a hardware-backed biometric passkey as a signer with no seed phrase. Coinbase Smart Wallet / Base Account is the flagship (passkey primary signer, iCloud/Google sync, multi-owner). For a native desktop app, the analogous primitive is OS secure storage + Touch ID — but **on-chain passkey signers require a smart account**, which a v0 EOA cannot do without a 7702 delegation. RIP-7212 only matters once smart-account support exists. [13][15] + +## Recovery, multisig, gas, and batching + +| Capability | Standard / product | State in 2026 | +|---|---|---| +| Multisig | Safe (M-of-N, Modules, Guards) | Dominant; signers can be EOAs, passkeys, hardware [23] | +| Social/guardian recovery | Argent (2018-origin pattern); 7702 delegation | Production; Starknet on-chain recovery reported partly offchain-only [25] | +| Sponsored gas | ERC-4337 verifying paymaster; ERC-7677 API | Table-stakes; used as acquisition lever [14][28] | +| Pay gas in token | ERC-20 paymaster (Circle, Pimlico, ZeroDev) | USDC ≈62% of ERC-20 paymaster volume Q1 2026 (vendor-reported) [14] | +| Batched / atomic calls | ERC-5792 (`wallet_sendCalls`) | Spec non-Final; real usage a tiny fraction of traffic [5][18] | + +**Recovery** is the headline reason smart accounts beat raw EOAs. A raw EOA (Deckard v0) has none of it: losing the key loses funds. **ERC-20 paymasters** are strategically important for an operator wallet — an autonomous agent can transact purely in stablecoins it holds, never needing the user to top up ETH. **ERC-5792** (`wallet_sendCalls` with an `atomicRequired` flag, plus `wallet_getCapabilities` for fingerprint-free feature discovery) is the surface enabling one-click approve+swap, but per WalletConnect/Reown tracking (WalletConnect-routed traffic only, not a neutral census), it remains a sliver versus legacy `eth_sendTransaction`/`personal_sign`. An EOA typically needs 7702 before it can batch atomically. [5][18][14] + +## Session keys & granular permissions (ERC-7715 / ERC-7710) + +This is the single most important standard cluster for an operator wallet. **ERC-7715** defines `wallet_grantPermissions`: an agent requests scoped authority and the wallet returns a permission, scoped by asset, amount, time window, and pattern, shown in plain language, granted via an **EIP-712 signature (not an on-chain tx)**. **ERC-7710** provides the underlying delegation framework (delegation chains, sub-delegation). MetaMask shipped this as **Advanced Permissions** on April 6, 2026 (requires a MetaMask Smart Account, not a bare EOA), with three types — **Periodic** (resets each period: subscriptions/DCA), **Streaming** (linear allowance: vesting), and **Revocation** — explicitly listing **AI agents** as a use case. ZeroDev (Kernel) and Biconomy (Nexus) also offer session keys. The pattern for an LLM agent: hold a session key bound by a 7715/7710 permission (e.g., "spend up to X USDC/day on DEX Y for 30 days") so the autonomous layer never touches the root key. [6][17][26] + +## Intents, chain abstraction, unified balance (ERC-7683 / ERC-7811) + +Chain abstraction — hide chains, show one balance, execute cross-chain from one signed intent — is described as 2026's dominant UX paradigm across three layers: Account, Execution (intents + solvers), and Liquidity. **ERC-7683** (co-authored by Uniswap Labs and Across, created April 11, 2024) lets solvers serve many protocols without bespoke integrations; it is **in DRAFT status, not Final**, and its current spec has materially evolved to a Steps/variables/payments model with an `IResolver` interface — the original "CrossChainOrder struct + ISettlementContract" framing is out of date. Production endpoints include Across, UniswapX, CoW, and Eco. [8] + +"~88% of Across volume via ERC-7683" and the "Q3 2025 solver migration" are now confirmed against Across's own first-party docs — still a vendor self-report, not a neutral third-party dashboard. [35] + +**ERC-7811** (`wallet_getAssets`, authored Nov 2024) is the primitive behind a single unified-balance number. Remaining gaps: thin-liquidity chains lack solvers, and smart-account modules/standards do not port cleanly across chains. [36] + +## Embedded / MPC wallets vs. local self-custody + +Wallet-as-a-service (Privy, Dynamic, Turnkey, Coinbase WaaS, Magic) optimizes onboarding: email/social sign-up, no seed phrase. The dominant model is **TEE + key-sharding**: Privy (acquired by Stripe, June 2025) generates the key inside a Trusted Execution Environment and splits it via Shamir's Secret Sharing into a 2-of-2 (enclave share + auth share); the key is reconstructed only briefly inside the enclave at signing and immediately wiped, so no single party — including Privy — ever holds the whole key. Turnkey markets an explicit "AI Agents" product with policy-gated signing. The architecturally interesting borrow for an operator wallet is the **policy engine**: a programmable allow/deny ruleset gating what the agent's signer can do — a layer a fully local single-keypair EOA lacks until it adds keystore + session-key tiers. [13] + +## Security: simulation, clear-signing (ERC-7730), revoke tooling + +The modern baseline is simulate-before-sign + clear-signing + risk scanning + approval management. Rabby is the reference UX (simulates every tx via the Tenderly Simulation API, scores approvals, surfaces revoke tooling); Blockaid is the dominant risk engine (integrated server-side into MetaMask and into WalletConnect). **ERC-7730 "clear signing"** had its governance transferred from Ledger to the Ethereum Foundation; the registry (`ethereum/clear-signing-erc7730-registry`) is live (~102 stars, ~357 commits, dozens of open PRs/issues) but coverage is **partial**, so most contracts still produce blind-signing. [11][24][29] + +Threat backdrop: per Scam Sniffer, phishing/drainer losses fell ~83% YoY in 2025 to ~$83.85M (~106k victims), while signature-phishing spiked ~207% MoM in January 2026 (~$6.27M, ~4,741 victims). Note the figures trace to a single vendor (medium confidence on exact dollar amounts). A CMU CyLab study **published** Jan 2026 reported 270M+ address-poisoning attempts against 17M+ wallets — but that figure covers the **July 2022–June 2024 dataset period**, not January 2026 activity. [12] + +## Agentic / AI-agent wallet primitives + +A distinct 2025–2026 frontier targets autonomous on-chain agents. **ERC-8004 "Trustless Agents"** (canonical EIP, Draft, created Aug 13, 2025; mainnet ~Jan 29, 2026) defines on-chain Identity (ERC-721-based), Reputation, and Validation registries so agents are discoverable and trust-scored without a central intermediary. **x402** revives HTTP 402 for HTTP-native USDC micropayments. Together with 4337/7702 session-key delegation and ERC-7715/7710 scoped permissions they form an end-to-end loop: discover a service (8004), receive a 402 with terms, pay in USDC via a bounded session key, reputation-log the interaction. Each component is independently real; the unified "payment loop" is an architectural narrative from secondary sources, not one normative spec. None of it is possible on a bare v0 EOA. [19][20] + +## Privacy: EF Kohaku SDK + +The Ethereum Foundation's **Kohaku** initiative (unveiled ~Oct 8, 2025; part of a 47-member EF Privacy Cluster) is an open-source, modular privacy SDK that integrates shielded-pool protocols (Railgun, Privacy Pools) and per-dapp addresses directly into the wallet layer, with ERC-4337 relaying operational. It dovetails with an operator-wallet model: an agent transacting across many dapps benefits from per-dapp address isolation. (Reported via reputable crypto press; medium confidence pending a single canonical EF page per sub-claim.) [30] + +## Modular smart accounts: ERC-7579 & ERC-6900 + +Beneath the user-facing features sits an account-modularity layer. **ERC-7579** (ratified 2024) is the de-facto modular standard, defining a shared ABI for validator/executor/hook/fallback modules; it underpins ZeroDev Kernel and Biconomy Nexus (both 7579 + EIP-7702 compatible). **ERC-6900** (Alchemy-led) is a competing standard. The practical 2026 problem: modular standards have **not** unified cross-chain, and EIP-1271 (smart-account signature validation) is still not universally honored by older dapps, creating a fragmented smart/legacy experience. [22] + +## What this means for Deckard + +- Deckard's v0 (a single alloy-generated secp256k1 EOA in the OS config dir) sits on the legacy side of the smart/legacy split: it has no batching, no recovery, no session keys, and no policy engine — the same gaps that smart accounts exist to close. [22] +- Every operator-wallet primitive surveyed (scoped session keys, ERC-20 gas payment, on-chain agent identity, simulate-before-sign co-signing) presupposes either a smart account or a 7702-delegated EOA; on a bare EOA none of them are reachable. [6][19] +- EIP-7702 is the lowest-friction bridge from an EOA to the smart-account feature set because it preserves the existing address and key — relevant given Deckard's locked-in keystore plans. [1][2] +- The desktop/native posture maps cleanly to OS-level secure storage + Touch ID for unlock, but on-chain passkey signing (RIP-7212) is a smart-account-only capability, so biometric unlock and on-chain passkey signers are distinct concerns. [7][13] +- ERC-7715/7710 in production (MetaMask, April 2026) demonstrates the exact pattern an LLM operator layer needs — a tightly scoped, time-boxed, revocable permission granted by signature — and is the closest shipped analog to Deckard's vision. [6][17] +- Clear-signing (ERC-7730) and transaction simulation are language-agnostic, EOA-compatible security features whose value increases when a non-human (LLM) is in the signing loop; the EF registry and `erc7730` validator are direct integration targets. [11][24] +- The white space: essentially no shipping consumer wallet offers safe, scoped, revocable LLM-operator control end-to-end as a product — it exists today only as infra-provider plumbing (Turnkey, Cobo) plus MetaMask's just-launched permissions feature. [22] +- Stablecoin-native onramps plus ERC-20 paymasters mean a wallet could in principle be funded and operated entirely in USDC without the user ever holding ETH — observationally aligned with an agent that transacts in stablecoins it already holds. [14] + +## Open questions + +- What is the actual, primary-sourced count of 7702-delegated EOAs and the real adoption curve of ERC-5792/7715 in on-chain traffic (vs. WalletConnect-routed samples)? +- Does EIP-8141 (native AA) advance from CFI to scheduled inclusion in Hegota, and if so, how does it change the 4337+7702 architecture a wallet should bet on? +- For a native (non-browser) desktop wallet, what is the cleanest path to a hardware-backed signer — OS keystore + Touch ID for local unlock vs. an on-chain P256/passkey signer requiring a smart account? +- Which modular-account substrate (Safe vs. Kernel vs. Nexus, ERC-7579 vs. ERC-6900) best supports session-key validators, recovery modules, and spend-limit hooks without forking the core account — given cross-chain module portability is unsolved? +- How mature and audited is the agentic stack (ERC-8004 + x402 + session keys) for real funds, and what is its incident/exploit history? +- What does a defensible policy engine for an LLM signer look like (allow/deny rules, rate/spend limits, simulation gating) and how much can be enforced on-chain via permissions vs. locally in the wallet? + +## Sources + +1. Pectra 7702 guidelines — https://ethereum.org/roadmap/pectra/7702/ — (docs, high) +2. EIP-7702: Set Code for EOAs — https://eips.ethereum.org/EIPS/eip-7702 — (spec, high) +3. Pectra Mainnet Announcement — https://blog.ethereum.org/2025/04/23/pectra-mainnet — (docs/primary, high) +4. Releases — eth-infinitism/account-abstraction — https://github.com/eth-infinitism/account-abstraction/releases — (github, high) +5. EIP-5792: Wallet Call API — https://eips.ethereum.org/EIPS/eip-5792 — (spec, high) +6. ERC-7715: Request Permissions from Wallets — https://eips.ethereum.org/EIPS/eip-7715 — (spec, high) +7. RIP-7212: Precompile for secp256r1 — https://github.com/ethereum/RIPs/blob/master/RIPS/rip-7212.md — (spec, high) +8. ERC-7683: Cross Chain Intents (canonical, Draft) — https://eips.ethereum.org/EIPS/eip-7683 — (spec, high) +9. Account abstraction — https://ethereum.org/en/roadmap/account-abstraction/ — (docs, high) +10. eth-infinitism/account-abstraction (repo) — https://github.com/eth-infinitism/account-abstraction — (github, high) +11. ethereum/clear-signing-erc7730-registry — https://github.com/ethereum/clear-signing-erc7730-registry — (github, high) +12. Scam Sniffer 2025 phishing-losses report — https://drops.scamsniffer.io/scam-sniffer-2025-crypto-phishing-losses-fall-83-to-84-million/ — (vendor report, medium) +13. How Privy embedded wallets work — https://privy.io/blog/how-privy-embedded-wallets-work — (blog, high) +14. Circle Paymaster — Pay Gas in USDC — https://www.circle.com/paymaster — (docs, high) +15. coinbase/smart-wallet — https://github.com/coinbase/smart-wallet — (github, high) +16. ERC-4337 vs EIP-7702 — https://docs.pimlico.io/guides/eip7702/erc4337-vs-eip7702 — (docs, high) +17. Introducing MetaMask Advanced Permissions — https://metamask.io/news/introducing-advanced-permissions — (blog/primary, high) +18. EIP-5792: The UX Breakthrough Everyone's Ignoring — https://walletconnect.com/blog/eip-5792-the-ux-breakthrough-everyone-s-ignoring — (blog, medium) +19. ERC-8004: Trustless Agents (canonical EIP) — https://eips.ethereum.org/EIPS/eip-8004 — (spec, high) +20. What ERC-8004 unlocks for agent infrastructure — https://www.allium.so/blog/onchain-ai-identity-what-erc-8004-unlocks-for-agent-infrastructure/ — (blog, medium) +21. EIP-8141: Native Account Abstraction (Frame Transactions) — https://github.com/ethereum/EIPs/blob/master/EIPS/eip-8141.md — (spec, high) +22. EOA vs Smart Wallets in 2026 — https://www.openfort.io/blog/eoa-vs-smart-wallet — (blog, medium) +23. Safe Modules — https://docs.safe.global/advanced/smart-account-modules — (docs, high) +24. Tenderly x Rabby transaction preview — https://github.com/Tenderly/tenderly-rabby-transaction-preview — (github, high) +25. About wallet recovery — Argent — https://support.argent.xyz/hc/en-us/articles/360022631412-About-wallet-recovery — (docs, high) +26. Paying Gas with ERC20s / 7702 quickstart — ZeroDev — https://docs.zerodev.app/sdk/core-api/pay-gas-with-erc20s — (docs, high) +27. ERC-4337 Paymasters: Better UX, Hidden Risks — OtterSec — https://osec.io/blog/2025-12-02-paymasters-evm/ — (blog, high) +28. ERC-7677: Paymaster Web Service Capability — https://github.com/ethereum/ERCs/blob/master/ERCS/erc-7677.md — (spec, high) +29. The Evolution of Clear Signing — Ledger — https://www.ledger.com/blog-the-evolution-of-clear-signing — (blog, high) +30. EF Kohaku SDK for wallet-level privacy — The Defiant — https://thedefiant.io/news/blockchains/ethereum-foundation-kohaku-sdk-privacy-wallet-integration-bb4t52 — (news, medium) +31. ethereum/RIPs — https://github.com/ethereum/RIPs — (github, high) +32. fireblocks-labs/awesome-eip-7702 — https://github.com/fireblocks-labs/awesome-eip-7702 — (github, medium) +33. ethereum/kohaku — https://github.com/ethereum/kohaku — (github, medium) +34. Smart wallet adoption surges after Pectra — The Block — https://www.theblock.co/post/354414/smart-wallet-adoption-surges-after-pectra-upgrade — (news, medium) +35. ERC-7683 in Production — Across docs — https://docs.across.to/developer-quickstart/erc-7683-in-production — (docs/vendor self-report, medium) +36. ERC-7811: Wallet Asset Discovery (wallet_getAssets) — https://eips.ethereum.org/EIPS/eip-7811 — (spec, high) diff --git a/docs/research/02-account-abstraction.md b/docs/research/02-account-abstraction.md new file mode 100644 index 0000000..cadd8af --- /dev/null +++ b/docs/research/02-account-abstraction.md @@ -0,0 +1,91 @@ +# Account Abstraction & Smart Accounts + +> Where ERC-4337 and EIP-7702 stand post-Pectra, the canonical contract addresses, the Rust tooling, and the early threat data. Part of the Deckard wallet research KB. Researched 2026-06-05. + +## TL;DR + +- EIP-7702 has status **Final** (Standards Track, Core) and shipped on Ethereum mainnet in the **Pectra** hard fork, which activated **May 7, 2025**. [1][2][3] +- The 7702 spec document itself does not name Pectra; the hard-fork attribution comes from the Pectra meta-EIP (EIP-7600), not from the eip-7702.md text. [1][2][3] +- ERC-4337 EntryPoint **v0.8.0** added **native EIP-7702** authorization handling in the EntryPoint contract and introduced **Simple7702Account**, "a fully audited minimalist smart contract wallet." [4][5] +- EntryPoint addresses: **v0.7** `0x0000000071727De22E5E9d8BAf0edAc6f37da032`, **v0.8** `0x4337084d9e255ff0702461cf8895ce9e3b5ff108`, **v0.9** `0x433709009B8330FDa32311DF1C2AFA402eD8D009` (v0.9.0 release dated Nov 16, 2025, ABI-compatible with v0.8/v0.7). [5][6][7] +- Native 7702 support was added in **v0.8**, not v0.9. [4][5] +- The Rust **alloy** stack supports both 7702 and 4337: `TransactionBuilder7702`, the `alloy-eip7702` crate, and ERC-4337 types under `alloy_rpc_types_eth::erc4337`. [8][9] +- Two production-grade **ERC-4337 bundlers are written in Rust**: **Rundler** (Alchemy) and **Silius** (modular). [10][11] +- Early threat signal: a market-maker's research (Wintermute) found **>97% of 7702 delegations in the first month post-Pectra pointed to contracts running identical "sweeper" bytecode** — but these sweepers reportedly made essentially no money. Treat as point-in-time, not a standing fact. [12][13] + +## EIP-7702: status and what it is + +EIP-7702 is a Standards Track / Core EIP whose on-chain frontmatter shows status **Final**. [1][2] It shipped as part of Ethereum's **Pectra** upgrade, which the Ethereum Foundation confirms activated on mainnet on **May 7, 2025** (epoch 364032). [3] One nuance worth carrying forward: the EIP-7702 document does not itself reference Pectra — the inclusion in that fork is established by the Pectra meta-EIP (EIP-7600), not by the 7702 spec. [1][2][3] + +7702 lets an existing externally-owned account (EOA) delegate its code to a smart-contract implementation without migrating to a new address, bridging plain keypairs into the smart-account world. The reference infrastructure for that smart-account behavior is ERC-4337. + +## ERC-4337 EntryPoint: versions and addresses + +The canonical implementation lives in `eth-infinitism/account-abstraction`. [5] The pivotal release for 7702 interop is **v0.8.0**: its official GitHub release states it adds "native support for EIP-7702 authorizations in the EntryPoint contract" and introduces **Simple7702Account**, described as "a fully audited minimalist smart contract wallet" at `contracts/accounts/Simple7702Account.sol`. [4][5] A later v0.9.0 (released Nov 16, 2025) is the latest tagged release; it is ABI-compatible with v0.8 and v0.7. Its release notes enumerate the delta over v0.8: parallelizable paymaster signing via a new `paymasterSignature` field, block-number-based validity ranges (`validAfter`/`validUntil`), silent `initCode` handling for already-deployed accounts, a `getCurrentUserOpHash` helper, an `EIP7702AccountInitialized` event, and a `BasePaymaster` constructor change. Native 7702 handling is a v0.8 feature, not a v0.9 one. [4][5][6] + +| EntryPoint | Address | Notes | +| --- | --- | --- | +| v0.7 | `0x0000000071727De22E5E9d8BAf0edAc6f37da032` | Confirmed via Etherscan + v0.7.0 release [5][7] | +| v0.8 | `0x4337084d9e255ff0702461cf8895ce9e3b5ff108` | Added native EIP-7702 + Simple7702Account [4][5] | +| v0.9 | `0x433709009B8330FDa32311DF1C2AFA402eD8D009` | v0.9.0 release dated Nov 16, 2025, ABI-compatible w/ v0.8 & v0.7 [6] | + +## Rust tooling for AA + +For a Rust codebase, the AA ecosystem is more reachable than the language gap suggests: + +- **alloy** provides `TransactionBuilder7702` for constructing 7702 transactions, plus the dedicated **`alloy-eip7702`** crate exposing EIP-7702 constants, helpers, and types — together these are the basis for general 7702 authorization signing. [8][14] alloy PR #2499 ("Adding support for signing 7702 authorizations") is specifically the Ledger hardware-signer 7702 path, not general signing. [9] +- alloy also ships ERC-4337 request/response types under `alloy_rpc_types_eth::erc4337`. [8] +- **Rundler** ([alchemyplatform/rundler](https://github.com/alchemyplatform/rundler)) is Alchemy's ERC-4337 bundler, written in Rust. [10] +- **Silius** ([silius-rs/silius](https://github.com/silius-rs/silius)) is a modular ERC-4337 (account abstraction) bundler, written in Rust. [11] + +This means a Rust wallet can sign 7702 authorizations and assemble UserOperations against the standard ERC-4337 stack without leaving the alloy ecosystem. + +## Early 7702 threat data: the "CrimeEnjoyor" sweepers + +The most-cited early statistic about 7702 adoption is a security one, and it deserves careful framing. The figure originates with **Wintermute** — a market maker's research/Dune dashboard, **not a neutral protocol-level source** — measured over the **first month** after Pectra (mid-2025). [12][13] + +Wintermute's actual finding: **>97% of all EIP-7702 delegations were authorized to multiple contracts using the same exact (sweeper) bytecode.** The **>97% figure itself is independently reported by CoinDesk**, not only by Wintermute. [12][13] "CrimeEnjoyor" is the name Wintermute gave to the single most-reused decompiled variant; the 97% reportedly spans a small family of identical-bytecode sweepers (CrimeEnjoyor, CrimeEnjoyor2, AdvancedCrimeEnjoyor, HardcodedCrimeEnjoyor). The specific variant-name list is **single-sourced to Wintermute's X post, which is now login-walled (HTTP 402) and could not be re-fetched for this verification**. So "are CrimeEnjoyor sweepers" is a slight simplification of "are copies of the same sweeper bytecode Wintermute named CrimeEnjoyor." [13] + +Two corrections matter for anyone reusing this stat: + +- **It measures delegation *count* dominance, not value stolen.** Per CoinDesk/Wintermute, the sweeper operators spent ~2.88 ETH to authorize ~79,000 addresses but made **essentially no money** — no observed inbound ETH to the destination wallets. Most of those delegations are automated/spam-like, not successful drains. [12][13] +- **It is point-in-time.** Tied to the first month post-Pectra, it should not be presented as a standing characterization of the 7702 ecosystem in 2026. [12][13] + +The underlying mechanism is the real lesson: a sweeper preys on a 7702 authorization signed (often blindly, or for a compromised key) that delegates an EOA's execution to attacker-controlled code, which then drains incoming funds. The signing UX — what exactly a user authorizes when they sign a 7702 tuple — is the security surface. + +## What this means for Deckard + +Observations and opportunities only — not a roadmap. + +- Deckard today is a bare EOA (single secp256k1 keypair via alloy). EIP-7702 is the standardized path for a *bare EOA to gain smart-account behavior without changing address* — directly relevant to a wallet that already has accounts in the field. [1][2] +- The needed Rust primitives already exist in the stack Deckard uses: alloy's `TransactionBuilder7702`, the `alloy-eip7702` crate, and `alloy_rpc_types_eth::erc4337` types — so AA exploration would not require leaving alloy or adding a non-Rust dependency. [8][9] +- Running a bundler in-process or alongside the desktop app is feasible in Rust today (Rundler, Silius are both Rust), which is notable for a local-first app that may prefer not to depend solely on hosted bundler services. [10][11] +- If Deckard ever adopts 7702, **Simple7702Account** is a pre-audited, minimal delegation target maintained in the canonical eth-infinitism repo — an off-the-shelf implementation rather than a bespoke contract. [4][5] +- The CrimeEnjoyor data is a concrete argument that **7702 authorization signing is a high-stakes UX surface**: the dominant real-world 7702 activity in its first month was malicious delegation. A wallet that makes the delegation target legible to the user (and to an operator LLM) is mitigating the exact attack class observed on-chain. [12][13] +- EntryPoint addresses are version-pinned and ABI-compatible across v0.7–v0.9; any integration must target a specific deployed singleton, and v0.8+ is the line where native 7702 handling exists. [4][5][6] +- For the operator-wallet vision, AA (4337 + 7702) is the substrate that makes session keys, batching, and sponsored/delegated execution possible — but none of that is reachable from a plain EOA without first adopting the smart-account or 7702 layer. (Observation; the operator-specific standards are out of scope for this file.) + +## Open questions + +- What is the 7702 delegation/sweeper picture in mid-2026? The 97% figure is first-month-post-Pectra (mid-2025) and from a single market-maker source; a current, neutral on-chain census was not verified here. [12][13] +- ~~What does v0.9.0 add beyond v0.8?~~ Resolved: the v0.9.0 release notes enumerate the delta — parallelizable paymaster signing (new `paymasterSignature` field), block-number-based validity ranges (`validAfter`/`validUntil`), silent `initCode` handling for existing accounts, `getCurrentUserOpHash`, an `EIP7702AccountInitialized` event, and a `BasePaymaster` constructor change. [6] +- How mature/audited are the Rust bundlers (Rundler, Silius) for production self-custody use, and what is their EntryPoint-version coverage? [10][11] +- What is the security review status of `alloy-eip7702` and alloy's 7702 signing path for a wallet that signs authorizations on behalf of a user? [8][9] +- Does running a bundler locally inside a desktop app change the trust/mempool assumptions versus using a hosted bundler? (Not addressed by the verified sources.) + +## Sources + +1. EIP-7702 (eips.ethereum.org) — https://eips.ethereum.org/EIPS/eip-7702 — (spec, high) +2. EIP-7702 markdown source (ethereum/EIPs, raw) — https://raw.githubusercontent.com/ethereum/EIPs/master/EIPS/eip-7702.md — (spec/source, high) +3. Ethereum Foundation: Pectra Mainnet Announcement — https://blog.ethereum.org/2025/04/23/pectra-mainnet — (primary blog, high) +4. EntryPoint v0.8.0 release (eth-infinitism/account-abstraction) — https://github.com/eth-infinitism/account-abstraction/releases/tag/v0.8.0 — (release notes, high) +5. eth-infinitism/account-abstraction (canonical ERC-4337 repo) — https://github.com/eth-infinitism/account-abstraction — (GitHub repo, high) +6. EntryPoint v0.9.0 release — https://github.com/eth-infinitism/account-abstraction/releases/tag/v0.9.0 — (release notes, high) +7. EntryPoint v0.7.0 release — https://github.com/eth-infinitism/account-abstraction/releases/tag/v0.7.0 — (release notes, high) +8. alloy-rs/alloy — https://github.com/alloy-rs/alloy — (GitHub repo, high) +9. alloy-rs/alloy PR #2499 "Adding support for signing 7702 authorizations" — https://github.com/alloy-rs/alloy/pull/2499 — (GitHub PR, high) +10. Rundler — Alchemy's ERC-4337 bundler in Rust — https://github.com/alchemyplatform/rundler — (GitHub repo, high) +11. Silius — modular ERC-4337 bundler in Rust — https://github.com/silius-rs/silius — (GitHub repo, high) +12. CoinDesk: Post-Pectra, malicious Ethereum contracts try to drain wallets but to no avail (Wintermute) — https://www.coindesk.com/tech/2025/06/02/post-pectra-upgrade-malicious-ethereum-contracts-are-trying-to-drain-wallets-but-to-no-avail-wintermute — (news, medium) +13. Wintermute research (X / Dune dashboard) — https://x.com/wintermute_t/status/1932101433916305743 — (market-maker research, medium; login-walled / HTTP 402) +14. alloy-eip7702 crate — https://crates.io/crates/alloy-eip7702 — (crate registry, high) diff --git a/docs/research/03-kohaku.md b/docs/research/03-kohaku.md new file mode 100644 index 0000000..e4fbab5 --- /dev/null +++ b/docs/research/03-kohaku.md @@ -0,0 +1,97 @@ +# Kohaku — the EF-maintained wallet + +> The Ethereum Foundation's open-source privacy SDK and reference wallet: a Rust-to-WASM crypto core, an Ambire-forked browser extension, and a roadmap that explicitly names local-AI transaction scoring and post-quantum accounts. Part of the Deckard wallet research KB. Researched 2026-06-05. + +## TL;DR + +- **Kohaku is two repos, not one.** `github.com/ethereum/kohaku` is the **SDK** (a Rust + TypeScript monorepo of privacy packages). The **reference wallet** is a separate repo, `github.com/ethereum/kohaku-extension`, whose README states explicitly that it is "a fork of Ambire Wallet" and is currently Sepolia-testnet-only and "under active development" [1][2][3]. +- Framing: "Privacy-first tooling for the Ethereum ecosystem." The SDK ships packages including `@kohaku-eth/railgun`, `@kohaku-eth/privacy-pools`, `@kohaku-eth/provider`, and `@kohaku-eth/pq-account` [1]. +- **The crypto core is Rust compiled to WebAssembly.** The root `Cargo.toml` defines a workspace of 8 crates, declares `wasm-bindgen` 0.2.108 plus `wasm-bindgen-futures` and `gloo-net`, and carries a dedicated `[profile.release-wasm]` (opt-level `'z'`, `lto=true`) tuned for WASM [4]. +- The Rust→WASM→TypeScript binding is canonical: `crates/railgun-ts/Cargo.toml` sets `crate-type = ['cdylib','rlib']` and depends on the Rust `railgun` crate (with the `js` feature), `wasm-bindgen`, `wasm-bindgen-futures`, and `tsify` [5]. +- **Railgun is the most mature integration, shipped as an alpha.** The published npm package `@kohaku-eth/railgun` reached `0.0.1-alpha.22` (latest May 26, 2026), following a continuous alpha.13→alpha.22 series [6]. +- The `railgun` crate supports UTXO/TXID syncing, state querying, transaction construction, proof generation, POI (proof-of-innocence) generation/submission, and submission via broadcasters [7]. +- The naming is a tell: **"Kohaku" is Japanese for amber**, chosen because the wallet forked from **Ambire** (amber) [2][10]. +- **`@kohaku-eth/pq-account` is a post-quantum ERC-4337 account** — account abstraction via the *current* ERC-4337 path, distinct from the future L1 "native AA" roadmap item [1]. +- **Roadmap items are planned/exploratory, not shipped.** The EF-hosted roadmap lists local-AI transaction scoring under "future directions we are exploring," and treats native account abstraction as an L1-advocacy goal for 2026 — not something Kohaku builds itself [8]. +- **EF stewardship is formally announced.** The Ethereum Foundation named Kohaku on its official blog on 2025-10-08, as part of its Privacy Cluster / "Commitment to Privacy," describing "a new reference implementation of a privacy-preserving wallet and an open-source wallet SDK" and linking both repos [11]. This is corroborated by org ownership (both repos under `github.com/ethereum`) and the EF-hosted roadmap on `notes.ethereum.org` [1][8][11]. + +## What Kohaku actually is + +Kohaku is the Ethereum Foundation's open-source privacy wallet stack, best understood as a **layered split**: a reusable SDK and a reference application that consumes it. + +The **SDK** (`ethereum/kohaku`) bundles privacy primitives behind a package interface. Confirmed npm packages include `@kohaku-eth/railgun` (Railgun shielding), `@kohaku-eth/privacy-pools`, `@kohaku-eth/provider` (an RPC/provider abstraction), and `@kohaku-eth/pq-account` (a post-quantum ERC-4337 account) [1]. The README frames it as "Privacy-first tooling for the Ethereum ecosystem," with a blanket caveat that "some parts of this project are work in progress and not ready for production use" [1]. + +The **reference wallet** (`ethereum/kohaku-extension`) is a *separate* repository and is **a fork of Ambire Wallet** — stated verbatim in its README, and corroborated by the official docs noting it was "Forked from `@ambiretech/extension` & `@ambiretech/ambire-common`" [2][3][9]. It is currently a work-in-progress browser extension supporting only **Sepolia testnet** [2][9]. The split matters: the privacy logic is meant to be embeddable, while the Ambire-derived extension is just one consumer of it. + +EF stewardship is anchored in a first-party announcement: the Ethereum Foundation's official blog post "The Ethereum Foundation's Commitment to Privacy" (2025-10-08) names Kohaku as part of its Privacy Cluster, describing "a new reference implementation of a privacy-preserving wallet and an open-source wallet SDK" and linking both repos [11]. This is reinforced by repository ownership under the official `github.com/ethereum` org, the EF-hosted roadmap on `notes.ethereum.org`, and the PSE (Privacy Stewardship of Ethereum) lineage. As corroboration, a QuickNode deep-dive states "the Ethereum Foundation leads the project with collaboration from teams like Ambire, Railgun, Helios, and PSE" [10]. + +## Architecture: a Rust core, compiled to WASM, bound to TypeScript + +For a Rust shop, the key fact is that **Kohaku's cryptographic core is Rust**, compiled to WebAssembly and exposed to a TypeScript application layer. + +- The root `Cargo.toml` defines a Rust workspace of **8 crates**: `common`, `crypto`, `eip-1193-provider`, `poseidon-rust`, `railgun`, `railgun-ts`, `userop-kit`, and `userop-kit-ts` [4]. +- It declares `wasm-bindgen` 0.2.108, plus `wasm-bindgen-futures` and `gloo-net`, and adds a dedicated `[profile.release-wasm]` (`opt-level = 'z'`, `lto = true`) for size-optimized WASM builds [4]. +- `crates/railgun-ts/Cargo.toml` is the canonical binding crate: `[lib] crate-type = ['cdylib','rlib']`, depending on the Rust `railgun` crate (with the `js` feature) plus `wasm-bindgen`, `wasm-bindgen-futures`, and `tsify` for TypeScript-type generation [5]. + +The `-ts` crate-naming convention (`railgun-ts`, `userop-kit-ts`) signals the pattern: a pure-Rust crate implements the protocol; a sibling `-ts` crate wraps it for WASM/TypeScript consumption — a clean reference for making a Rust crypto core portable without rewriting the cryptography. + +| Layer | What it is | Evidence | +|---|---|---| +| Rust core crates | `crypto`, `poseidon-rust`, `railgun`, `userop-kit`, `common` | workspace `Cargo.toml` [4] | +| WASM bindings | `railgun-ts`, `userop-kit-ts` (`cdylib`, `wasm-bindgen`, `tsify`) | `railgun-ts/Cargo.toml` [5] | +| TS SDK packages | `@kohaku-eth/railgun`, `…/privacy-pools`, `…/provider`, `…/pq-account` | SDK README [1] | +| Reference wallet | `kohaku-extension` (Ambire fork, Sepolia-only) | extension README / docs [2][9] | + +The SDK also exposes a documented **plugin interface**, and `@kohaku-eth/pq-account` is described as a "post-quantum 4337 account implementation" — PQ account abstraction over the **existing ERC-4337** path [1]. + +## Railgun: shipped as alpha, the most mature piece + +Railgun is Kohaku's flagship integration and the clearest evidence of "shipped." The substantiation is **release versioning**, not a prose label: the published `@kohaku-eth/railgun` package reached `0.0.1-alpha.22` (latest May 26, 2026), with a continuous alpha.13→alpha.22 series of releases [6]. + +The Rust `railgun` crate's README enumerates supported capabilities: UTXO and TXID syncing, on-chain state querying, transaction construction, proof generation, POI (proof-of-innocence) proof generation and submission, and transaction submission via **broadcasters** [7]. That covers the full shield/transact/unshield lifecycle of a Railgun-style shielded pool. + +⚠ Precision note: the SDK README does **not** literally label Railgun "alpha." It shows Railgun with a checkmark under the blanket "not ready for production" caveat, while the published docs (`llms-full.txt`) mark Privacy Pools and Tornado as "WIP" and leave Railgun unmarked [1][9]. The word "alpha" is justified purely by the npm semver (`0.0.1-alpha.x`), which is unambiguous. "Shipped" here means *published and usable in alpha*, not a stable release [6]. + +## Roadmap: AI scoring and account abstraction are aspirational + +The EF-hosted roadmap (`notes.ethereum.org/@niard/KohakuRoadmap`) is explicit that several headline-grabbing items are **not yet built** [8]: + +- **Local-AI transaction scoring** is listed under "future directions we are exploring": "develop transaction security scoring through local AI to help identify low-risk vs high-risk transactions without leaking private information." Exploratory, not shipped [8]. +- **Native account abstraction** is an **L1-dependency advocacy item**, not a Kohaku feature: "we need the ethereum network to implement native account abstraction. We will be working in that direction over 2026." This is distinct from the *current* ERC-4337 path that `pq-account` already uses [8][1]. +- The **plugin system** and a **post-quantum killswitch** (optimized Falcon/Dilithium Solidity verifiers, opt-in PQ accounts) are confirmed roadmap items [8]. + +Kohaku was showcased by Vitalik Buterin at **Devcon 2025** (Buenos Aires, Nov 16, 2025), which is part of the EF-stewardship evidence base [10]. + +## What this means for Deckard + +Observations and opportunities only — no sequencing or priorities implied. + +- **Kohaku is a same-language reference for a portable Rust crypto core.** Its `railgun`/`railgun-ts` split (pure-Rust protocol crate + `wasm-bindgen`/`tsify` binding crate) is a concrete pattern for keeping cryptography in Rust while exposing it elsewhere — directly relevant if Deckard ever needs a non-native surface, though Deckard's native GPUI app can consume the pure-Rust crates without the WASM layer at all [4][5][7]. +- **The EF's reference wallet is browser-extension-shaped (Ambire fork, Sepolia-only).** Deckard occupies a different niche — a native desktop app — so the SDK packages are reusable, but the reference UX is not a template for Deckard's form factor [2][9]. +- **Local-AI transaction scoring is on the EF's own exploratory roadmap**, framed as classifying low- vs high-risk transactions "without leaking private information." That is conceptually adjacent to Deckard's operator-wallet vision, and notably the EF frames it as *local* AI for privacy reasons [8]. +- **Post-quantum account abstraction is treated as ERC-4337-based today** (`pq-account`), with L1 "native AA" positioned as a multi-year advocacy goal. For an EOA-today wallet, this signals that account abstraction remains an opt-in account-layer choice, not a settled L1 primitive [1][8]. +- **The Railgun crate is an off-the-shelf Rust implementation of a shielded-pool lifecycle** (syncing, proof generation, POI, broadcaster submission) — a reference point if Deckard ever evaluates privacy features, with the caveat that it is alpha-versioned [6][7]. +- **"EF-maintained" is backed by a first-party EF announcement** (the 2025-10-08 "Commitment to Privacy" blog post naming Kohaku), plus org ownership and the EF-hosted roadmap — useful context when weighing Kohaku's maturity and longevity as a dependency or design reference [8][10][11]. + +## Open questions + +- Are any of the SDK's Rust crates (e.g. `crypto`, `poseidon-rust`, `railgun`) consumable as standalone Rust dependencies without the WASM/TS wrapper, with a stable enough API to depend on? +- What is the licensing of the SDK crates and of the Ambire-forked extension, and how does Ambire's upstream license flow through? +- How concrete is the "local AI transaction scoring" exploration — is there any prototype, threat model, or spec beyond the one-line roadmap entry? [8] +- Does Kohaku's "without leaking private information" local-AI framing imply on-device inference, and if so what model class/size is contemplated? +- What is the relationship and dependency direction between `userop-kit` (ERC-4337 tooling) and `pq-account`, and is the PQ account validated on any live network beyond Sepolia? [1][2] +- Beyond the EF's first-party announcement (the 2025-10-08 "Commitment to Privacy" blog post), what is the ongoing governance model — who formally owns Kohaku's roadmap and release decisions across the EF, PSE, and the named collaborating teams? [10][11] + +## Sources + +1. ethereum/kohaku — privacy SDK monorepo (README; packages `@kohaku-eth/railgun`, `privacy-pools`, `provider`, `pq-account`) — https://github.com/ethereum/kohaku — (GitHub repo, high) +2. ethereum/kohaku-extension — reference wallet, README states "a fork of Ambire Wallet," Sepolia-only, WIP — https://github.com/ethereum/kohaku-extension — (GitHub repo, high) +3. Kohaku official docs (full text) — confirms wallet "Forked from `@ambiretech/extension` & `@ambiretech/ambire-common`" — https://ethereum.github.io/kohaku/llms-full.txt — (project docs, high) +4. ethereum/kohaku root `Cargo.toml` — 8-crate Rust workspace, `wasm-bindgen` 0.2.108, `[profile.release-wasm]` — https://github.com/ethereum/kohaku/blob/master/Cargo.toml — (source file, high) +5. ethereum/kohaku `crates/railgun-ts/Cargo.toml` — `crate-type=['cdylib','rlib']`, depends on `railgun` + `wasm-bindgen` + `tsify` — https://github.com/ethereum/kohaku/blob/master/crates/railgun-ts/Cargo.toml — (source file, high) +6. Kohaku GitHub Releases — `@kohaku-eth/railgun@0.0.1-alpha.22` (latest May 26, 2026); alpha.13–alpha.22 series — https://github.com/ethereum/kohaku/releases — (release feed, high) +7. ethereum/kohaku `crates/railgun` — README enumerates UTXO/TXID sync, proof + POI generation, broadcaster submission — https://github.com/ethereum/kohaku/tree/master/crates/railgun — (source/README, high) +8. Kohaku Roadmap (EF-hosted) — plugin system, PQ killswitch (Falcon/Dilithium), local-AI tx scoring (exploratory), native AA (L1 advocacy over 2026) — https://notes.ethereum.org/@niard/KohakuRoadmap — (spec/roadmap, high) +9. Kohaku official docs — Introduction to Railgun and protocol status markers (Privacy Pools / Tornado "WIP") — https://ethereum.github.io/kohaku/railgun/intro/ — (project docs, high) +10. QuickNode — "Ethereum Foundation leads the project"; reference wallet "a browser extension forked from Ambire"; Devcon 2025 showcase — https://blog.quicknode.com/ethereum-kohaku-wallet-privacy-roadmap/ — (secondary deep-dive, medium) +11. Ethereum Foundation blog — "The Ethereum Foundation's Commitment to Privacy" (2025-10-08); names Kohaku as "a new reference implementation of a privacy-preserving wallet and an open-source wallet SDK," links both repos — https://blog.ethereum.org/2025/10/08/privacy-commitment — (first-party EF blog, high) diff --git a/docs/research/04-splits.md b/docs/research/04-splits.md new file mode 100644 index 0000000..54154ab --- /dev/null +++ b/docs/research/04-splits.md @@ -0,0 +1,119 @@ +# Splits — agentic, smart-account-native + +> How Splits (formerly 0xSplits) turned onchain payment-splitting into a self-custodial, ERC-4337 smart-account "onchain bank" with agents as first-class signers. Part of the Deckard wallet research KB. Researched 2026-06-05. + +## TL;DR + +- Splits evolved from a non-upgradable onchain payment-splitting protocol into "Splits Teams" — self-custodial onchain banking built on a **custom ERC-4337 smart account** they call **Smart Vaults** (EntryPoint v0.7), a tiered system of multi-chain m-of-n multisigs supporting both passkeys (WebAuthn/secp256r1) and EOAs as signers, with ERC-1271 verification [2][9]. +- The agentic surface is **shipped, not aspirational**: a single binary, `@splits/splits-cli`, is *both* a CLI and an MCP server — running it with `--mcp` auto-exposes every command as an MCP tool with no separate registration step [1]. +- On **2026-05-28** Splits shipped "agents as signers on accounts, enabling server keys and agents to transact on behalf of teams," plus custom JSON transaction metadata. The CLI/MCP + scoped-keys path shipped **2026-04-14**; subaccount management + signing shipped **2026-04-28** [4]. +- An agent becomes a signer by **registering its EOA** (`splits auth register-signer`) and **attaching it to a specific subaccount** (`splits accounts update-signers`), then signing pending multisig UserOps locally (`splits transactions sign`). No seed phrase is ever handed over [3]. +- The security model is **three layered controls**: scoped API keys (read-only vs owner-scoped), per-account signer membership, and the multisig threshold. In MCP mode the CLI **refuses flag-based secrets** so keys never appear in tool-call transcripts [5]. +- There are **no client-exposed spend limits, allowlists, or session keys** in the CLI/MCP today. The only allowlist-like surfaces (`tokens whitelist`, `tokens blocklist`) and `automations list` are **read-only** [6]. +- Smart Vaults support **Merkelized UserOps** (sign one Merkle root to authorize many UserOps across networks/accounts) and **Light UserOps** (early signers sign a reduced op so the final signer prices gas at execution) [7]. +- The protocol layer — Split/SplitV2, Warehouse, Waterfall, Swapper, Vesting, plus templates (Liquid Split, Recoup, Diversifier) — is **non-upgradable, fee-free** (gas-cost-only) and deployed across Ethereum, Optimism, Base, Zora, Polygon, Arbitrum and more [5][8]. +- Forward signals: Splits maintains a **fork of Ithaca's Porto** (EIP-7702 + passkeys account stack, last pushed 2026-04-09) [10], and runs forks of two agent-infra projects — `centaur` (credential-bounded team agents) and `iron-proxy` (egress firewall with boundary secret injection) [9]. +- For Deckard: an existing alloy EOA can become a Splits signer with almost no new crypto, and the v2 distribution contracts are directly callable — but *natively owning* a smart account means adopting a 4337 stack and a bundler/paymaster pipeline. + +## From splitting protocol to onchain bank + +The original **0xSplits** is a set of non-upgradable, fee-free "hyperstructure" contracts (it "runs exactly at gas cost"; "non-upgradable contracts run as long as the underlying network exists") with full/direct support on ~13 chains (Ethereum, Base, Optimism, Arbitrum, Celo, World Chain, and more), plus partial support on 70+ EVM networks via bridging (relay.link) [8]. The core primitives (per docs.splits.org) are **Split/SplitV2** (distribute incoming tokens by fixed percentage shares), **Warehouse** (a central balance/distribution hub with ERC-6909-style internal accounting that batches and reduces gas), **Waterfall** (tranched/priority payouts), **Swapper** (accept any input token, pay out a chosen output token), **Vesting** (time-locked release), and an **Oracle** primitive; **Liquid Split**, **Recoup**, and **Diversifier** are classified as *templates* built on those primitives rather than standalone primitives [8]. Splits v2 shipped 2024-05-09; the latest contracts release is **Splits v2.2 (2025-07-14)** [9]. + +In 2026 Splits repositioned as **"Modern banking for onchain startups"** (verbatim on splits.org/treasury/) with the pitch "the speed and workflows of Mercury, the security and peace of mind of multisigs" (verbatim on splits.org/treasury/); the agent-positioning quote "Agents can do everything people can, without the insecurity of handing over a seed phrase" is verbatim on the splits.org homepage; by end-2025, 100+ teams had processed over $50M through the product [12]. ⚠ unverified: only the exact string "onchain banking for startups and solo builders" remains unconfirmed verbatim (primary instead says "Modern banking for onchain startups"); the load-bearing technical claims below are confirmed from CLI source and the changelog. + +## Smart-account architecture: custom ERC-4337 "Smart Vaults" + +Splits does **not** use Safe. The account is a bespoke contract suite, **Smart Vaults**, in `packages/smart-vaults` of `splits-contracts-monorepo` (Solidity, GPL-3.0, Foundry/turborepo/pnpm), released as `smart-vaults-v1.0` on **2025-04-08** [9]. The README states they are "Splits 4337 smart accounts ... a tiered system of multi-chain multi-sigs" and "currently ... compatible with entry point v0.7" [2]. + +Source layout and features [2]: + +| Component | Role | +|---|---| +| `src/vault/SmartVault.sol`, `SmartVaultFactory.sol` | the account + its factory | +| `src/signers/MultiSigner.sol` | m-of-n signer set | +| `src/signers/PasskeySigner.sol` | WebAuthn / secp256r1 (P-256) signer | +| `src/signers/AccountSigner.sol` | "a signer backed by an EOA or ERC-1271 smart account" | +| `src/utils/ModuleManager.sol` | add trusted modules that act on behalf of the account | +| `src/utils/FallbackManager.sol` | extensible callbacks; ERC-721/1155 receiving | + +The **Module Manager** is the extensibility hook where policy/automation modules could live — conceptually analogous to ERC-7579 modular accounts, but it is **their own design, not an off-the-shelf ERC-7579 account**. The account also supports contract deployment via `CREATE` inside a UserOp. + +## Cross-chain signing: Merkelized and Light UserOps + +Two mechanisms reduce friction for multi-chain multisigs [7]: + +- **Merkelized User Operations** — the signer builds a Merkle tree of all intended UserOps (across any number of networks and accounts), signs the single Merkle root **once**, and each submitted UserOp carries a Merkle proof for verification. There is "no strict limit on the number of operations." This is how a human or agent authorizes a batch of cross-chain actions with one signature. +- **Light User Operations** — when threshold > 1, the first *threshold − 1* signers sign over a reduced UserOp (only `sender`, `nonce`, `calldata`; excluding `initCode`, gas limits, `preVerificationGas`, `gasFees`, `paymasterAndData`, `signature`), so the **final signer prices gas at current market conditions**. + +Together these directly serve an operator-wallet pattern: a human pre-authorizes intent and a later signer (or agent) finalizes execution and gas. + +## The agentic surface: one binary, CLI + MCP + +`@splits/splits-cli` (v0.2.9, last published 2026-05-22) is a single-file (`src/cli.ts`) Node 22+/TypeScript ESM tool built on the **`incur`** framework (wevm/incur). `cli.serve()` runs the CLI, and invoking it with `--mcp` exposes **every command as an MCP tool automatically** — incur documents "no manual config, no copy-pasting tool definitions" [1]. + +Command namespaces map 1:1 to backend resources: **`accounts`, `transactions`, `contacts`, `tokens`, `chains`, `members`, `settings`, `automations`, `auth`, and `org`** (the `org create` flow is an unauthenticated email-link org setup) [1]. Key commands include `auth login/whoami/create-key/register-signer/signers`, `accounts list/get/signers/create/rename/archive/update-signers`, `transactions list/get/sign/properties`, and `mcp add` (auto-detects Claude Code / Cursor). MCP tools mirror these with underscore names (`transactions_sign`, `accounts_create`, `auth_register_signer`). Registration: `claude mcp add splits -e SPLITS_API_KEY=sk_... -- npx @splits/splits-cli --mcp` [1]. + +The public API is reached at `SPLITS_API_URL + /public/v1` with a Bearer token (default base `https://server.production.splits.org`); API keys (`sk_...`) are issued from Teams Settings. Transaction rows expose `direction`, `transactionHash`, and `userOpHash` (both nullable) so callers can correlate Splits records with explorers and bundler webhooks — a clean REST surface an external wallet could call directly without the CLI [14]. + +> Note: the *published README* documents only the `auth`/`accounts`/`transactions`/`members` namespaces. Evidence for `tokens`/`chains`/`contacts`/`settings`/`automations`/`org` and the production hostname lives only in `src/` (`cli.ts`, `config.ts`, `http.ts`). + +## How signing authority is delegated safely + +Delegation is **layered, and notably does not yet use onchain session keys or client-exposed spend-limit modules** [3][5]: + +1. **Scoped API keys** — issued per-team; some read-only, some owner-scoped (subaccount create/archive/rename require an owner-scoped key) [5]. +2. **Signer membership** — the agent's EOA must be registered (`auth register-signer`, idempotent, returns an id) *and* attached to a specific subaccount (`accounts update-signers --add-eoa-signer-ids`). An unattached key can sign nothing [3]. +3. **Multisig threshold** — an account can require m-of-n, so an agent can be configured to merely *propose* / partially-sign while a human provides the final signature (`--no-submit` records a signature without submitting) [3]. + +Hygiene controls: the private key lives only in `~/.splits/config.json` (mode 0600, auto-gitignored) and "never appears in any command's response — only the derived address." Under `SPLITS_MCP_MODE=1` (or `--mcp`) the CLI **refuses `--api-key`/`--private-key` flags** so secrets never leak into MCP tool-call transcripts; stdin is preferred for key entry [3][5]. + +## What's not there yet (spend limits, session keys, allowlists) + +Despite a smart-account foundation that could support it, the agent-facing surface has **no spend-limit, allowlist-enforcement, or session-key commands** — a source review of `src/cli.ts` (v0.2.9) finds none [6]. The only allowlist-like surfaces are read-only `tokens whitelist` (GET `/tokens/whitelist`; described as "allowlisted tokens") and `tokens blocklist`, plus a read-only `automations list` (GET `/automations`) [6]. Today the granularity comes from **API-key scope + signer attachment + multisig threshold**, not from per-action onchain policy. Treat per-transaction spend policy as server/account-side and roadmap-level. + +## Forward signals: 7702 and agent infrastructure + +- **Porto (EIP-7702):** Splits maintains a **fork of Ithaca's Porto** ("Porto — Next-gen Account for Ethereum"), last pushed 2026-04-09. Porto is the EIP-7702 + WebAuthn/passkeys account stack (RIP-7212 P256 precompile, app sessions / permissions) [10]. Maintaining a 7702 fork alongside their bespoke 4337 Smart Vaults suggests they are *evaluating* a 7702-based path (upgrade an EOA in place) — this is a **fork, not a shipped product line** (experimental). (Attribution note: Porto is Ithaca's; "Reth" is a separate Paradigm execution client.) +- **Agent infrastructure (forks, not homegrown):** `centaur` — "Shared AI agents for teams" with **credential boundaries** ("agents can use approved services without receiving raw API keys"), isolated Kubernetes sandboxes, bring-your-own-harness (Claude Code/Codex/Amp), durable sleep/resume/spawn workflows. `iron-proxy` — a default-deny MITM egress firewall that injects **real secrets at the network boundary** ("workloads use proxy tokens ... a compromised workload can exfiltrate a token that's worthless outside the proxy"), blocking SSRF/DNS-rebinding to `169.254.169.254`/loopback, with per-request JSON audit [9]. ⚠ correction: both repos are **forks** (`centaur` from `paradigmxyz/centaur`, `iron-proxy` from `ironsh/iron-proxy`), not Splits-built; Splits adopting them signals its agent-security worldview, but does not imply authorship. (`centaur` last pushed 2026-06-04; `iron-proxy` last pushed 2026-05-29.) + +## Adjacent surfaces + +The **TypeScript SDK** (`@0xsplits/splits-sdk` core + `splits-sdk-react` + `splits-kit` components) is at **v6.4.1 (2026-01-22)** as the latest GitHub release tag (npm latest is **6.5.0**, published 2026-03-19) across 65 releases, but targets the original 0xSplits **protocol contracts + subgraph**, *not* the new Teams smart-account/agent API (which lives behind `/public/v1` and the CLI) — for a Rust consumer like Deckard it is reference material, and the REST API is the integration point [11]. **Splits Connect** (`splits-connect`, shipped per the 2026-04-28 changelog) is a browser extension that lets a self-custodied Teams smart account act as a wallet in external dapps via WalletConnect + injected provider, with batch transactions [13]. Recovery is framed as re-establishing a sufficient signer set (passkey + EOA), with email-based recovery for team accounts shipped 2026-04-14 — a multisig-smart-account custody model rather than single-seed restore [13]. + +## What this means for Deckard + +- **Deckard's existing alloy secp256k1 EOA can become a Splits signer with almost no new crypto** — register it via the public API/CLI flow and co-sign multisig UserOps; Deckard only needs an API token plus the ability to produce ERC-1271/EOA signatures over a UserOp or Merkle root it can already sign [14][3]. +- **The v2 distribution contracts (Split, Warehouse, Waterfall, Swapper, Diversifier, Vesting) are open-source, non-upgradable, with full/direct support on ~13 chains (Ethereum, Base, Optimism, Arbitrum, Celo, World Chain, and more) plus partial support on 70+ EVM networks via bridging** — Deckard could *call* them directly to split revenue without any account migration [8][3]. +- **The one-binary CLI-and-MCP pattern is a directly copyable design** for Deckard's own LLM-operator surface: scoped keys, MCP-mode secret refusal, keys never in transcripts, secrets only in a 0600 config file [1][5]. +- **Natively owning a smart account is the heavy path:** it requires adopting a 4337 account stack (Smart-Vaults-like contracts, or a 7702 path à la their Porto fork), running or renting a bundler + paymaster, and implementing UserOp construction, Merkelized/Light-UserOp signing, and EntryPoint v0.7 packing [2][7][10]. +- **Merkelized + Light UserOps map cleanly onto the operator-wallet thesis** — a human can pre-authorize a batch of cross-chain intent with one signature and let a later signer (or agent) finalize gas/execution [7]. +- **Splits does not give you operator-LLM spend-policy off the shelf** — there are no client-exposed spend limits, allowlists, or session keys today; a policy layer would be Deckard's to build [6]. +- **The `iron-proxy` / `centaur` "revocable, low-blast-radius credential" pattern is the security model to study** for an autonomous local operator — applied to signing authority, the agent holds a scoped, attachable, revocable signer key rather than a master seed [9]. +- **Off-chain treasury/fiat services Splits operates** — splits.org/treasury/ confirms invoicing plus generic bank transfers / fiat ramps / yield on idle cash (specific product names like ACH/SEPA rails, 1099 tax forms, or a "USDC Earn" product are not stated verbatim) — they are not reusable as contracts; consuming them would mean integrating Splits' API or comparable ramps [12][13]. + +## Open questions + +- Will Splits expose onchain spend-limit / session-key / allowlist primitives to clients (closing the gap between the "granular cryptographic approvals" framing and today's API-key-scope reality), and via the Module Manager or a 7702/Porto path? +- Is the Porto fork headed for production as a 7702 in-place EOA-to-smart-account upgrade, or is it pure evaluation? +- Does the public `/public/v1` API expose enough (UserOp construction, signature submission, Merkle-root retrieval) for a non-TS client to act as a signer without the CLI, or is the CLI the de facto SDK? +- How are recurring/scheduled transactions and Automations actually executed (relayer/bundler cadence, who pays gas), and can an external signer participate? +- What are the licensing implications for Deckard of the GPL-3.0 Smart Vaults contracts versus calling them as deployed bytecode? + +## Sources + +[1] splits-cli README + `src/cli.ts` (CLI + MCP server) — https://github.com/0xSplits/splits-cli — (github, high). Framework: https://github.com/wevm/incur +[2] Smart Vaults README (ERC-4337 v0.7 account architecture) — https://github.com/0xSplits/splits-contracts-monorepo/blob/main/packages/smart-vaults/README.md — (github, high) +[3] Splits Teams — onchain banking (signer-delegation flow) — https://splits.org/teams/ — (docs, high) +[4] Splits Changelog (2023-01 → 2026-05) — https://splits.org/changelog/ — (docs, high) +[5] splits-cli `src/http.ts` / `src/config.ts` (scoped keys, MCP secret refusal) — https://github.com/0xSplits/splits-cli — (github, high) +[6] splits-cli `src/cli.ts` command inventory (no spend-limit/session-key commands) — https://github.com/0xSplits/splits-cli — (github, high) +[7] Smart Vaults README — Merkelized + Light UserOps — https://github.com/0xSplits/splits-contracts-monorepo/blob/main/packages/smart-vaults/README.md — (github, high) +[8] Splits protocol documentation (primitives, Warehouse, fee-free/non-upgradable) — https://docs.splits.org/ — (docs, high) +[9] splits-contracts-monorepo (Splits v2 + Smart Vaults) — https://github.com/0xSplits/splits-contracts-monorepo — (github, high). Agent-infra forks: centaur — https://github.com/0xSplits/centaur (upstream https://github.com/paradigmxyz/centaur); iron-proxy — https://github.com/0xSplits/iron-proxy (upstream https://github.com/ironsh/iron-proxy) +[10] 0xSplits fork of Ithaca Porto (EIP-7702 account stack) — https://github.com/0xSplits/porto — (github, high). Upstream context: https://ithaca.xyz/updates/porto +[11] splits-sdk (TypeScript protocol SDK; v6.4.1 is the latest GitHub release tag, npm latest is 6.5.0 published 2026-03-19) — https://github.com/0xSplits/splits-sdk — (github, high) +[12] Splits Treasury — "Modern banking for onchain startups" + 2025 in review — https://splits.org/treasury/ — (docs, high). Traction: https://splits.org/blog/2025-in-review/ +[13] splits-connect (browser extension for Teams accounts) — https://github.com/0xSplits/splits-connect — (github, high) +[14] splits-cli `src/config.ts` + `src/http.ts` (public API shape: `/public/v1`, Bearer, production host) — https://github.com/0xSplits/splits-cli — (github, high) +[15] ERC-4337 account-abstraction EntryPoint v0.7 — https://github.com/eth-infinitism/account-abstraction/tree/releases/v0.7 — (github, high) +[16] ERC-1271: Standard Signature Validation for Contracts — https://eips.ethereum.org/EIPS/eip-1271 — (spec, high) diff --git a/docs/research/05-agentic-wallets.md b/docs/research/05-agentic-wallets.md new file mode 100644 index 0000000..d09877f --- /dev/null +++ b/docs/research/05-agentic-wallets.md @@ -0,0 +1,120 @@ +# Agentic & LLM-driven Wallets + +> Landscape of LLM-driven crypto wallets — agent-wallet frameworks, MCP integration surfaces, payment/identity standards, and safe-signing architecture. Part of the Deckard wallet research KB. Researched 2026-06-05. + +## TL;DR + +- The agentic-crypto stack has consolidated around one safety axiom: **the agent never sees the seed**. The LLM is a scoped signer; the key stays isolated (TEE or a custody/signing service) behind a policy gate it cannot bypass [1][2]. +- **MCP (Model Context Protocol) is the dominant integration surface.** A local stdio/HTTP daemon that exposes wallet ops (`simulate`, `sign`, `transfer`, `set spend limit`) as LLM tools is now the standard pattern — Coinbase Payments MCP, the relaunched Base MCP, GOAT MCP, standalone EVM MCP servers, and Alchemy MCP all follow it [3][4][9][10]. +- **Coinbase shipped this as product.** Agentic Wallets (Feb 11, 2026) put agent keys in Trusted Execution Environments with three named guardrails: session caps, transaction limits, and enclave isolation [1]. +- **The canonical safe-signing toolkit:** simulate-before-sign, scoped + expiring session permissions (ERC-7715/7710), onchain-enforced policy (limits/allowlists), human-in-the-loop approval for write actions, and key isolation [1][2][12][13]. +- **Dual-key architecture** is the recurring blueprint: an operational *agent key* (scoped, often TEE-sealed) plus a non-custodial *owner key* that retains override (halt, withdraw, modify permissions), both gated by a smart-contract wallet [2]. +- **Payment standards shipped fast.** x402 (HTTP-402 stablecoin payments) was donated to a Linux Foundation **x402 Foundation launched April 2, 2026** with Google/Microsoft/AWS/Visa/Mastercard/Amex/Stripe/Cloudflare/Circle/Shopify backing [5][6]. +- **Google AP2** (Sept 16, 2025) adds cryptographically signed "Mandates"; its A2A-x402 extension is the production crypto path. AP2 itself is payment-agnostic — it does **not** mandate crypto [7][8]. +- **ERC-8004 "Trustless Agents"** (Draft, Aug 13, 2025) defines onchain Identity/Reputation/Validation registries; reference contracts (CC0) are deployed on 30+ chains, though the Validation Registry is still in flux [11][12]. +- Bounded session-signing (ERC-7715/7710) is shipping in MetaMask today — but the **granting** wallet must be a smart account (the session/agent account can be an EOA or smart account). Deckard is a plain EOA, so it can't grant 7715 permissions the way a MetaMask Smart Account can; adopting the semantics means either a smart-account layer or replicating scope/expiry/limit checks locally [14]. + +## Agent-wallet frameworks: convergence on the scoped-signer model + +Across vendors, the architecture is strikingly uniform. The agent is given a *scoped signer*, not the master key, and a policy layer (ideally onchain) caps what that signer can do. + +**Coinbase Agentic Wallets** (shipped Feb 11, 2026) is the canonical product expression of the "agent never sees the seed" model. Agents operate non-custodial wallets whose keys live inside Trusted Execution Environments, with three named security pillars — *session caps* (max spend per session), *transaction limits* (per-tx size), and *enclave isolation* (private keys in secure Coinbase infrastructure, never exposed to the prompt or LLM). It includes gasless settlement on Base and native x402 support [1]. Architecturally these are CDP Server Wallet v2: the key is split via Coinbase's `cb-mpc` library (threshold keyshares held between Coinbase and the operator) and the MPC operation runs inside an AWS Nitro Enclave — so it is MPC + TEE. "Enclave isolation" is the branded third pillar, not a denial that MPC is used [29]. + +**Crossmint** articulates the safety blueprint most explicitly as a **dual-key, two-layer** model [2]: + +| Layer | Key | Role | Property | +|---|---|---|---| +| Operational | Agent Key | Signs only criteria-meeting txs, deployed in its own TEE | "Encrypted in memory, inaccessible to host"; can't be leaked | +| Override | Owner Key | Master "emergency brake" — halt agent, withdraw, modify perms | Non-custodial with user (passkey/MetaMask/embedded) | + +Both interact with a smart-contract wallet (ERC-4337 on EVM, Squads on Solana) whose modules enforce guardrails. The stated rationale: the agent never holds full custody, eliminating the "honeypot" risk where one compromised agent key drains everything [2]. + +**Coinbase AgentKit** (`coinbase/agentkit`, tagline "Every AI Agent deserves a wallet") is the most mature open-source toolkit: framework-agnostic (LangChain, Vercel AI SDK, MCP, OpenAI Agents SDK, Pydantic AI, Strands, AutoGen, Eliza) and wallet-agnostic (CDP, Privy, and Viem — the Viem path being plain self-custodial EOA signing). It ships 50+ TS / 30+ Python action providers across Base/Ethereum/Solana, and "spend permissions" that limit token, amount, and time period [15][16]. Architecturally it separates the *agent skill modules* (authenticate/fund/send/trade) from the *wallet layer* (who can sign what) — a clean seam Deckard can mirror. Its public `WISHLIST.md` signals roadmap: Claude MCP support, more frameworks (CrewAI, Mastra, AutoGPT), Turnkey/Lit wallet providers, XMTP agent comms, and smart-wallet spend-permission actions [17]. Current versions: TS `@coinbase/agentkit` 0.10.x, Python `coinbase-agentkit` 0.7.x [27][28]. + +**GOAT SDK** (`goat-sdk/goat`, by Crossmint, MIT) is the broadest open-source onchain-actions library — its current README claims 200+ tools across 30+ chains and 10 framework adapters (including MCP) [18]. It is wallet-architecture-agnostic (self-custodial via Viem/Web3, smart-wallet via Safe/Lit, custodial via Crossmint) and deliberately minimal-core: install only the tools you need — a good model for a Rust port. Note: GOAT does **not** manage keys; it plugs into whatever wallet you give it, which is precisely Deckard's seam [18]. ⚠ unverified: the "200+ integrations / 10 framework adapters" figures come from the evolved current README, not the originally cited launch blog (which states only 30+ chains / 5 frameworks); the community `goat-mcp` wrapper that demonstrates exposing GOAT to Claude Desktop is a **0-star, single-commit demo**, not a mature project (the 993-star count belongs to the main `goat-sdk/goat` repo) [19]. + +**Thirdweb AI / Nebula** is a competing service-backed approach: a proprietary blockchain model ("t1") that reads/writes/reasons onchain across 2500+ EVM chains, exposed via an MCP server [20]. Relevant as an "AI that transacts onchain" reference point, though it is service-backed rather than local-first. + +## MCP as the integration surface + +Every major player now exposes wallet operations to an LLM over MCP — a local or hosted daemon presenting wallet ops as callable tools. Two distinct security postures emerge: + +**Key-isolated (the safe pattern).** *Coinbase Payments MCP* runs locally on desktop, needs no API key, and lets Claude/Gemini/Codex create a wallet by email, onramp, pay via x402 (with a "Bazaar Explorer" to discover payable APIs), and set user-approved spend limits and approval thresholds [3]. *Base MCP* is the strongest human-in-the-loop example: the original `coinbase/base-mcp` repo is **archived/deprecated** (the org moved from "coinbase" to "base"; it now lives at `base/base-mcp-legacy`), and Base **relaunched** "Base MCP" (~May 26, 2026) connecting any AI to a Base Account where **every write action requires explicit user approval** — the MCP returns an `approvalUrl` + `requestId`, the user reviews a simulation of asset changes, and the assistant polls `get_request_status()` until confirmed. The smart wallet signs server-side; private keys are never exposed to the AI layer [4]. ⚠ unverified: the literal "OAuth 2.1" wording and "private keys never exposed / smart wallet signs server-side" phrasing come from the Base blog and Fortune coverage, not the `docs.base.org/ai-agents` page itself, which confirms the approval flow but not those exact terms. + +**Direct-key (the anti-pattern to improve on).** Standalone local EVM MCP servers handle keys directly via env vars and run over stdio — the closest open analog to a raw self-custodial signing sidecar. `mcpdotdirect/evm-mcp-server` exposes 22 tools + 10 prompts across 60+ chains (`transfer_native`, `transfer_erc20`, `approve_token_spending`, `write_contract`, `sign_message`, `sign_typed_data`), keyed by `EVM_PRIVATE_KEY` / `EVM_MNEMONIC`, over stdio (default) or HTTP/SSE on port 3001 [10]. `dcSpark/mcp-cryptowallet-evm` (ethers v5) supports wallet create/import (private-key/mnemonic/encrypted-JSON), send/sign, EIP-712, and ENS [10]. Both warn never to commit keys and say keys are used only for signing, never stored — but the raw key still sits in env/process memory accessible to the LLM tool layer, the opposite of the TEE models [10]. + +**Read/observe half.** *Alchemy MCP* (`alchemyplatform/alchemy-mcp-server`, released May 10, 2025) is the data side of an operator loop: ~159 tools across 100+ networks (prices, NFT metadata, tx history, holdings, contract simulation, tracing, account-abstraction), hosted via OAuth or local via API key, and can drive webhooks so agents react to onchain events without polling [21]. Pairing read-heavy data tools with a tightly-scoped signing tool is the natural decomposition. + +## Payment & identity standards + +**x402** embeds stablecoin payments into HTTP. Flow: client requests a resource → server returns `402` with payment details → client builds a `PaymentPayload`, re-sends with a signature → a *Facilitator* verifies and settles → server returns `200` + resource. It supports EVM, Solana, and Stellar (USDC on Base most common), with SDKs in TS, Python, and Go. Open-sourced by Coinbase May 2025, it was donated to the Linux Foundation **x402 Foundation, launched April 2, 2026**, with members spanning Google, Microsoft, AWS, Visa, Mastercard, Amex, Stripe, Cloudflare, Circle, Shopify, and others; the canonical repo moved to `github.com/x402-foundation/x402` (`coinbase/x402` is now a development fork) [5][6][22]. ⚠ unverified: vendor/aggregator figures of ~69k active agents / 165M transactions / ~$50M cumulative volume (late Apr 2026) are **not traceable to an official x402 dashboard** and the originally cited source reports different numbers. On trajectory, the Chainalysis report says growth "moderated" and reports 100M+ cumulative transactions [23]. The sharper claim of a **~92% drop in *daily* x402 transactions** from Dec 2025 (~731k/day) to Feb 2026 (~57k/day) comes only from the **low-reliability** blockeden.xyz blog, not Chainalysis — treat those daily figures with caution even as cumulative totals grew [23]. + +**Google AP2 (Agent Payments Protocol)** launched Sept 16, 2025 with 60+ partners (Mastercard, Amex, PayPal, Coinbase, Mysten Labs, et al.). It is payment-agnostic and addresses three trust gaps — Authorization, Authenticity, Accountability — via **Mandates**, tamper-proof cryptographically-signed contracts signed by verifiable credentials: an *Intent Mandate* (captures user intent and delegation rules: price limits, timing, conditions) and a *Cart Mandate* (signed after exact items + price, creating an unchangeable record). The crypto path is the A2A-x402 extension (`google-agentic-commerce/a2a-x402`, built with Coinbase/EF/MetaMask), described as production-ready. AP2 extends A2A and MCP and does **not** mandate crypto — x402 is an optional extension [7][8]. The Mandate concept maps directly onto Deckard: a signed, scoped pre-authorization the LLM operates under. + +**ERC-8004 "Trustless Agents"** (Draft ERC, created Aug 13, 2025; authors from MetaMask, EF, Google, Coinbase) defines a minimal onchain trust layer via three registries: **Identity** (ERC-721, portable agent ID; registration file lists A2A cards, MCP endpoints, ENS, DIDs, wallet addresses — so MCP/A2A endpoints are first-class), **Reputation** (signed feedback), and **Validation** (0–100 scores via stake-secured re-execution, ZK proofs, or TEE oracles) [11]. Reference contracts (CC0) are deployed across 30+ EVM networks with `0x8004…` vanity addresses, but the Validation Registry is "still under active update and discussion with the TEE community" and there are **no formal releases** [12]. + +## Safe-signing architecture + +The consensus stack, synthesized from primary vendor guidance: + +1. **Simulate everything.** Dry-run each action in a forked/simulated environment to compute asset/balance changes (with USD values), gas, decoded traces, and human-readable warnings; block execution if slippage, approvals, or calldata deviate from intent. Tenderly's Simulation API provides this and is already wired into MetaMask Snaps and Rabby's sign modal [13]. +2. **Execute validated intents, not raw LLM suggestions.** +3. **Scoped, expiring permissions (ERC-7715/7710).** ERC-7715 (Draft, May 2024) adds a wallet-side JSON-RPC method (`wallet_requestExecutionPermissions`, earlier `wallet_grantPermissions`) to grant a session account scoped permissions — `native-token-allowance`, `erc20-token-allowance` (spend limits), `ExpiryRule` (unix timestamp). It pairs with ERC-7710 delegation: the grant returns a `delegationManager` + context blob, and the agent redeems via `redeemDelegation` to execute within bounds, offline, without exposing the main wallet. Canonical example: authorize an agent to spend up to 10 USDC/day to DCA into ETH for 30 days with one signed permission. MetaMask ships this as "Advanced Permissions." The **granting** wallet must be a smart account (per MetaMask docs the session/agent account can be an EOA or smart account) [14]. +4. **Human-in-the-loop for writes** — but with a known tension: agents make hundreds of decisions/minute, so per-signature prompts collapse agent speed to human speed. The resolution is a "fenced area" where the agent acts freely inside scoped bounds while the human retains override/revocation outside [13]. +5. **Key isolation** — the agent never sees the seed; signing happens behind a TEE or a policy-checking signing API. + +An emerging local pattern packages exactly this: `1lystore/dcp` describes itself as a "permission layer for AI agents — wallet signing, vault access, budgets, human approvals" [13]. It is now at v2.0.4 (May 2026) with a desktop app + CLI and active maintenance, though still small/niche (single-digit stars). + +**Identity & metering frontier.** Skyfire offers a "Know Your Agent" (KYA) framework plus KYAPay USDC settlement, and Nevermined adds metering/business-logic layers atop x402/A2A/MCP/AP2 [24][25]. ⚠ unverified: the claim that Skyfire records KYA IDs as "ERC-8004-compliant onchain attributes" rests on **secondary** sources, not a Skyfire primary source — the cited Skyfire page describes JWT/OAuth2 identity, not blockchain attributes. KYAPay/USDC settlement is confirmed by Skyfire's own June 2025 release [26]. + +## What this means for Deckard + +- The **local-MCP-sidecar + simulate + scoped-policy + key-isolation** pattern is proven and shipping today (Coinbase Payments MCP, Base MCP v2). Deckard's "local CLI/sidecar driving the wallet" idea is the same shape multiple vendors converged on independently [1][3][4]. +- The industry's load-bearing safety axiom — **the LLM never touches the seed** — is directly at odds with the simplest open MCP servers, which place the raw private key in env/process memory reachable by the tool layer [2][10]. Deckard's planned encrypted keystore (Argon2id + XChaCha20-Poly1305) gives it a key-isolation boundary those servers lack. +- Deckard is an **EOA**, so the onchain-enforcement primitives (ERC-4337 spend caps, ERC-7715/7710 session keys) are unavailable without a smart-account layer. The same scope/expiry/limit *semantics* can be replicated locally in a policy engine between the LLM tools and the secp256k1 key — at the cost of being software-enforced rather than chain-enforced [2][14]. +- The **dual-key split** (operational signer vs. master override) is a language-agnostic blueprint: it maps onto a Rust design where a bounded, policy-gated signing path is separate from the master seed, and a human-held override can halt or revoke [2]. +- A native operator wallet could **pay x402 endpoints directly** for data/compute, and consume Mandate-style signed pre-authorizations (AP2) as the local policy object the LLM operates under [5][7]. +- An **observe/act decomposition** fits the operator loop: read-only state/portfolio/simulation tools (an Alchemy-MCP-style data layer) paired with a separate, tightly-scoped local signing tool for writes [21][13]. +- **Simulate-before-sign** is a self-contained safety primitive Deckard can adopt regardless of account type — compute expected asset changes, then block on deviation [13]. +- The identity/reputation layer (ERC-8004, KYA) and fully-autonomous unattended signing remain **frontier**, not settled — production products still fence autonomy with human-in-the-loop and override keys [11][12][13]. + +## Open questions + +- For an EOA with a software policy gate (no onchain enforcement), what threat model is acceptable — i.e., what can a compromised LLM tool layer still do if the seed is encrypted at rest but must be decrypted to sign? +- Is a smart-account layer (ERC-4337) worth adopting purely to gain chain-enforced spend caps and ERC-7715 session keys, given Deckard's EOA-today stance? +- How should the "fenced area" autonomy boundary be configured — per-transaction approval, daily budgets, allowlists, or a hybrid — without collapsing agent speed to human speed [13]? +- Does Deckard's operator vision need a verifiable onchain identity (ERC-8004) at all, or only when transacting with *other* agents/services? +- Which MCP transport (stdio vs. local HTTP) best fits a native GPUI desktop app, and how should the policy gate be process-isolated from the model context? +- What is x402's real adoption trajectory, given the reported daily-transaction decline in early 2026 despite cumulative growth [23]? + +## Sources + +1. Introducing Agentic Wallets — https://www.coinbase.com/developer-platform/discover/launches/agentic-wallets — (docs, high) +2. The AI Agent Wallet Problem: Why Your Architecture Needs Dual Keys — https://www.crossmint.com/learn/ai-agent-wallet-architecture — (blog, medium) +3. Payments MCP: Bringing Wallets, Onramps, and Payments to Every Agent — https://www.coinbase.com/developer-platform/discover/launches/payments-mcp — (docs, high) +4. Base AI Agents / Base MCP — https://docs.base.org/ai-agents — (docs, high); relaunch detail: https://blog.base.org/base-mcp and https://fortune.com/2026/05/26/coinbase-pushes-further-into-ai-payments-with-new-mcp-for-base-network/ — (blog/news, high/medium) +5. Linux Foundation launching the x402 Foundation — https://www.linuxfoundation.org/press/linux-foundation-is-launching-the-x402-foundation-and-welcoming-the-contribution-of-the-x402-protocol — (news, high) +6. coinbase/x402 (now a dev fork of x402-foundation/x402) — https://github.com/coinbase/x402 — (github, high) +7. Announcing Agent Payments Protocol (AP2) — https://cloud.google.com/blog/products/ai-machine-learning/announcing-agents-to-payments-ap2-protocol — (blog, high) +8. google-agentic-commerce/a2a-x402 (A2A x402 extension) — https://github.com/google-agentic-commerce/a2a-x402 — (github, high) +9. cryptoleek-team/goat-mcp (GOAT as a Claude-Desktop MCP server; 0-star demo) — https://github.com/cryptoleek-team/goat-mcp — (github, medium) +10. mcpdotdirect/evm-mcp-server — https://github.com/mcpdotdirect/evm-mcp-server — (github, high); dcSpark/mcp-cryptowallet-evm — https://github.com/dcSpark/mcp-cryptowallet-evm — (github, high) +11. ERC-8004: Trustless Agents (spec) — https://eips.ethereum.org/EIPS/eip-8004 — (spec, high) +12. erc-8004/erc-8004-contracts (reference registries, CC0) — https://github.com/erc-8004/erc-8004-contracts — (github, high) +13. Transaction Preview — Tenderly Documentation — https://docs.tenderly.co/simulations/transaction-preview — (docs, high); How to Build Onchain Agents — https://www.alchemy.com/blog/how-to-build-onchain-agents — (blog, high); 1lystore/dcp — https://github.com/1lystore/dcp — (github, medium) +14. ERC-7715: Request/Grant Permissions from Wallets (spec) — https://eips.ethereum.org/EIPS/eip-7715 — (spec, high); Advanced Permissions (ERC-7715) — https://docs.metamask.io/smart-accounts-kit/concepts/advanced-permissions/ — (docs, high) +15. coinbase/agentkit — https://github.com/coinbase/agentkit — (github, high) +16. AgentKit Overview — Coinbase Developer Documentation — https://docs.cdp.coinbase.com/agent-kit/welcome — (docs, high) +17. AgentKit WISHLIST.md (roadmap signals) — https://github.com/coinbase/agentkit/blob/master/WISHLIST.md — (github, high) +18. goat-sdk/goat — Great Onchain Agent Toolkit — https://github.com/goat-sdk/goat — (github, high) +19. Introducing GOAT — Crossmint blog — https://blog.crossmint.com/introducing-goat-great-onchain-agent-toolkit/ — (blog, high) +20. thirdweb-dev/ai (Nebula model "t1") — https://github.com/thirdweb-dev/ai — (github, high); thirdweb MCP Server docs — https://portal.thirdweb.com/ai/mcp — (docs, high) +21. alchemyplatform/alchemy-mcp-server — https://github.com/alchemyplatform/alchemy-mcp-server — (github, high); Alchemy MCP Server docs — https://www.alchemy.com/docs/alchemy-mcp-server — (docs, high) +22. Launching the x402 Foundation with Coinbase — Cloudflare blog — https://blog.cloudflare.com/x402/ — (blog, high) +23. Inside x402 — Agentic Payments on Base — https://www.chainalysis.com/blog/x402-agentic-payments-adoption/ — (analysis, high); x402 Foundation: payment layer for the AI internet — https://blockeden.xyz/blog/2026/03/05/x402-foundation-ai-payment-internet/ — (blog, low) +24. Skyfire KYA Protocol as identity layer for Experian's KYA framework — https://skyfire.xyz/skyfires-kya-protocol-is-now-the-identity-layer-for-experians-know-your-agent-framework/ — (blog, medium) +25. AI Agent Payment Systems — Nevermined — https://nevermined.ai/blog/ai-agent-payment-systems — (blog, low) +26. Skyfire Launches Open KYAPay Protocol With Agent Checkout — BusinessWire — https://www.businesswire.com/news/home/20250626772489/en/Skyfire-Launches-Open-KYAPay-Protocol-With-Agent-Checkout — (news, medium) +27. @coinbase/agentkit (npm latest) — https://registry.npmjs.org/@coinbase/agentkit/latest — (registry, high) +28. coinbase-agentkit (PyPI) — https://pypi.org/pypi/coinbase-agentkit/json — (registry, high) +29. coinbase/cb-mpc (MPC library for CDP wallets) — https://github.com/coinbase/cb-mpc — (github, high) diff --git a/docs/research/06-privacy.md b/docs/research/06-privacy.md new file mode 100644 index 0000000..2fc7be4 --- /dev/null +++ b/docs/research/06-privacy.md @@ -0,0 +1,131 @@ +# Privacy in Wallets, 2026 + +> How Ethereum privacy went from fringe "mixer" tooling to an Ethereum-Foundation-led roadmap, the shipping primitives (stealth addresses, shielded pools, FHE tokens), the near-horizon protocol-native bet (EIP-8182), and the operational/metadata layer a native wallet can own. Part of the Deckard wallet research KB. Researched 2026-06-05. + +## TL;DR + +- The canonical framing is Vitalik Buterin's **"maximally simple L1 privacy roadmap"** (ethereum-magicians, April 2025): four pillars — on-chain payment privacy (shielded balances, ideally on by default), in-app activity anonymization via **one address per application**, **private reads** (RPC/metadata), and **network-level obfuscation** — all "very light on Ethereum consensus changes" [1]. +- The Ethereum Foundation reorganized: "Privacy & Scaling Explorations" rebranded to **Privacy Stewards of Ethereum (PSE)** (roadmap Sept 2025) and stood up a **~47-person Privacy Cluster** (Oct 2025) whose reference deliverable is **Kohaku**, an open-source privacy wallet SDK [2][3][4]. +- **Kohaku** (`github.com/ethereum/kohaku`) is a TypeScript+Rust monorepo (≈TS 44% / Rust 38% / Solidity 7%) bundling Railgun, Privacy Pools (WIP), Tornado (WIP), a provider abstraction (ethers/viem/Helios/Colibri), and a post-quantum ERC-4337 account; the repo itself carries a "not ready for production" disclaimer [5][6]. +- Shipping primitives: **stealth addresses** (ERC-5564 / ERC-6538, canonical contracts deployed at deterministic vanity addresses across ~16 networks); **shielded pools** (Railgun with Private Proof of Innocence; 0xbow's compliant Privacy Pools live on mainnet since March 2025) [7][8][9][10]. +- The two leading shielded-pool **compliance models are opposites**: Privacy Pools proves *inclusion* in an allowlist association set; Railgun PPOI proves *non-membership* in blocklists [10][11]. +- **FHE confidential tokens** reached mainnet: Zama's ERC-7984 (encrypted balances/amounts via fhEVM) went live Dec 30 2025 — complementary to shielded pools (hides amounts, not the address graph) [12][13]. +- Big near-horizon bet: **EIP-8182** (Draft, March 2026) proposes a *protocol-native* shielded pool as a no-admin system contract for the H2-2026 **Hegota** upgrade — one shared chain-wide anonymity set, any wallet, no special address format [14][15]. +- The **operational/metadata layer** (private RPC, light clients, address-per-dapp, broadcast privacy) is now an explicit EF workstream ("Private Reads") but remains largely unshipped in mainstream wallets, which still default to IP-leaking RPC like Infura [16][17]. +- Regulatory backdrop is more favorable: Tornado Cash sanctions were vacated and OFAC delisted the contracts (March 2025); but developer Roman Storm was convicted (Aug 2025) on one money-transmission count — operators face exposure, self-custodial integrators much less so [18][19]. + +## Vitalik's "maximally simple L1 privacy roadmap" — the strategic spine + +On **April 2025** Vitalik Buterin posted a four-pillar roadmap to "practically improve the state of privacy experienced by Ethereum's users in a way that is very light on Ethereum consensus changes" [1]. The pillars: (1) **privacy of on-chain payments** — wallets "should have a notion of a shielded balance, and when you send to someone else, there should be a 'send from shielded balance' option, ideally turned on by default," integrating Privacy Pools and Railgun; (2) **anonymizing in-app activity** via a "one address per application" default (he conceded "significant convenience sacrifices" but called it the most practical way to break public cross-app links); (3) **private reads** — protecting RPC calls so reading the chain doesn't leak which addresses you care about (the post emphasizes a near-term TEE-based RPC mitigation); (4) **network-level obfuscation** — hiding IP/metadata at the transport layer [1]. This document is the spine PSE, the Privacy Cluster, and Kohaku all execute against, and it maps closely onto the operational-privacy concerns of a desktop wallet. + +By **May 26, 2026** Vitalik reframed the goal as shipping over rhetoric — "We've accelerated narratives enough. Let's accelerate the cypherpunk privacy reality" — and described planned Kohaku support for browser extensions, **CLI wallets**, post-quantum accounts, multisigs, and hardware wallets, i.e. the stack is explicitly meant to reach native/CLI wallets, not only browser extensions [6]. + +## The EF reorganization: PSE rebrand + Privacy Cluster + Kohaku + +PSE published its roadmap in **Sept 2025**, shifting from cryptography exploration to "problem-first" work and warning that without privacy Ethereum "risks becoming the backbone of global surveillance rather than global freedom" [2]. On **Oct 8, 2025** the EF published a formal privacy commitment and unveiled a **~47-member Privacy Cluster** organized into five initiatives: Private Reads & Writes, Private Proving, Private Identities, Privacy Experience, and an Institutional Privacy Task Force [3][4]. Kohaku is named as the cluster's reference privacy wallet + SDK. + +**Kohaku** (`github.com/ethereum/kohaku`) is the most important artifact here. Confirmed packages: `@kohaku-eth/railgun` (Railgun shielding lib), `@kohaku-eth/privacy-pools` (WIP), `@kohaku-eth/tornado` (WIP), `@kohaku-eth/provider` (abstraction over ethers/viem/Helios/Colibri), and `@kohaku-eth/pq-account` (post-quantum ERC-4337 account) [5]. The SDK leaves railgun **unmarked** (only privacy-pools and tornado carry "WIP" labels), and railgun is published as an alpha (npm `0.0.1-alpha.x`), not a stable/production release — it is the most mature integration, but not "production-ready." Architecturally it pushes "privacy by default" through per-dapp account creation, user-defined/private RPC, light-client verification via Helios, and Tor routing for extreme cases [5][20]. + +⚠ unverified: the precise claim that "ERC-4337 mempool relaying shipped at `@kohaku-eth/railgun@0.0.1-alpha.21`" rests on secondary press, not the primary changelog — the alpha.21 release (published May 23, 2026) note reads only "fix: account for railgun fee" [6][21]. The version exists and railgun is the most mature integration (though still alpha, not production-ready; the latest is `0.0.1-alpha.22`, published May 26, 2026, with alpha.21 the relaying release); the 4337-relaying feature attribution is secondary-sourced. Open issues signal the roadmap: Tornado/Railgun v0.1.0 + Snap Sync, direct devp2p sync, an ERC-7579 PQValidator module, Tx-Shield modules, and explicit discussion of whether libraries in "languages like Rust, Swift" are in scope — directly relevant to a Rust wallet [5]. + +## Shipping primitive 1: stealth addresses (ERC-5564 / ERC-6538) + +ERC-5564 standardizes a non-interactive stealth-address scheme on secp256k1: a sender generates an ephemeral keypair, derives a shared secret with the recipient's published viewing key, and computes a fresh stealth address only the recipient can spend; a one-byte **view tag** lets recipients filter announcements ~6x faster. ERC-6538 is the Stealth Meta-Address Registry [7]. **Canonical contracts** (ScopeLift) are deployed via CREATE2 at the same deterministic vanity addresses on every chain — Announcer `0x55649E01B5Df198D18D95b5cc5051630cfD45564`, Registry `0x6538E6bf4B0eBd30A8Ea093027Ac2422ce5d6538` — live on Ethereum, Arbitrum, Base, Optimism, Polygon, Gnosis (and Scroll) mainnets plus testnets [8]. + +⚠ unverified: the exact "16 networks" count is close but not cleanly reproducible from the ScopeLift README table (renders as ~15–16 depending on counting, and includes Scroll mainnet); the addresses, CREATE2 sameness, and the named mainnets are confirmed [8]. Audit/security docs were listed "coming soon." + +Production wallets: **Fluidkey** is a live, non-custodial ERC-5564 wallet that derives viewing/spending keys from a signed message (path `m/5564'/0'/8'/0'/0'/p'/n'`), uses 1-of-1 Safe smart accounts as counterfactually-deployed stealth accounts, and — notably — does *not* rely on scanning announcements: its `.fkey.id`/`.fkey.eth` ENS offchain resolver returns a fresh stealth address per query, so senders just send to a normal-looking address. Live on ~6 EVM chains (Base, Optimism, Arbitrum, Polygon, Gnosis, Ethereum); multisigs not yet supported; audited by Dedaub (May 2024) [22][23]. **Umbra** (ScopeLift) is the original ERC-5564-aligned protocol (live since 2021, ~$500M volume); v2 is ~90% complete, targeting a summer-2026 stablecoin-focused launch, and is now a self-funded public good (no token/VC) — a useful signal that even successful privacy infra struggles to fund itself post-Tornado [24]. + +**Inherent limitations a wallet must handle:** the funding wallet can de-anonymize the recipient if linkable; announcement spam is an un-compensated DoS on scanners; the view tag trades ~4 bits of margin (128→124-bit) for faster scanning; and stealth addresses alone do **not** break on-chain transaction-graph traceability — coin-selection at withdrawal still matters [7][23]. + +## Shipping primitive 2: shielded pools and their compliance models + +**Privacy Pools** (0xbow) is the production implementation of the 2023 Buterin/Soleimani/Illum/Nadler/Schär paper "Blockchain Privacy and Regulatory Compliance" [25][26]. Users deposit then withdraw with no on-chain link, proving via ZK that they belong to a chosen **association set** maintained by an Association Set Provider (ASP); a **ragequit** lets an un-approved depositor publicly exit. Live on Ethereum mainnet since **March 2025**, multi-asset since July 2025; by late 2025 it had processed **~$6M from 1,500+ users**, raised a **$3.5M seed (Nov 2025)**, and was integrated into Kohaku [10][9]. ⚠ unverified: the finer "1,186 withdrawals / 16,000+ flagged addresses" figures come from 0xbow's own materials and weren't independently re-confirmed; the headline stats are confirmed. + +**Railgun** gives users encrypted `0zk` addresses where balances/history are visible only to them, via zk-SNARKs, with private DeFi swaps. Transactions are submitted by **Broadcasters** (relayers, ~10% gas premium) so activity appears to originate from the Broadcaster, not the shielding address. Its compliance answer is **Private Proof of Innocence (PPOI)**: a recursive-SNARK proof of *non-membership* in blocklists from five list providers (Elliptic, ScamSniffer, PureFi, SlowMist, Chainalysis Sanctions Oracle), plus a **1-hour unshield-only standby period** so bad actors can't hop addresses faster than lists update [11][27]. Railgun is the privacy tool Vitalik explicitly cited and the most-shipped Kohaku integration. + +| Model | Proof | Default posture | Tradeoff | +|---|---|---|---| +| **Privacy Pools** (0xbow) | *Inclusion* in a curated allowlist (association set) | Opt-in; an excluded user is de-anonymized via ragequit | Regulator-friendly but exclusionary | +| **Railgun PPOI** | *Non-membership* in known-bad blocklists | Private-by-default for anyone not on a list | Depends on list quality; harder to make airtight | +| **Labyrinth** (testnet→mainnet) | Threshold/selective reveal ("Decom") | Hidden by default, selective de-anon via threshold decryption | Third model; user-downloadable data for selective disclosure | + +Both ZK approaches reveal nothing beyond the single membership/non-membership bit [10][11]. ⚠ unverified (medium-reliability sources): Labyrinth's gas figures and Optimism/testnet status [28]. + +## Shipping primitive 3: FHE confidential tokens (Zama, ERC-7984) + +Zama's FHE confidentiality layer reached Ethereum mainnet on **Dec 30 2025** with the **ERC-7984** confidential token standard — encrypted balances and transfer amounts via fhEVM, with OpenZeppelin confidential-contract libraries and a Confidential Token Wrappers Registry to shield/unshield any ERC-20 at **~$0.13 per transfer**; the launch operator set includes Ledger and Fireblocks [12][13]. A **Jan 2026** sealed-bid token auction drew **~$118–121M** committed (value shielded in bidding, not strictly net proceeds) [29]. For a wallet, FHE tokens hide balances/amounts but **not** the sender/recipient address graph the way Railgun/Privacy Pools do — complementary, not a replacement. ⚠ note: an earlier-cited Zama URL was a deprecated 2023 post; the ERC-7984/registry/operators claims are nonetheless confirmed via Zama's 2025–2026 materials. + +## The near-horizon bet: EIP-8182 (protocol-native shielded pool) + +**EIP-8182 "Private ETH and ERC-20 Transfers"** (Draft, Standards Track/Core, created **March 2026**, author **Tom Lehman** of Facet) would embed a shielded pool as a **system contract at `0x...081820`** — no proxy, no admin, no pause, upgradeable only via hard fork — to solve the pool-bootstrapping problem ("a small pool offers weak privacy even for a superior product") [14]. Design: a UTXO/note model with a depth-32 commitment tree, and a **split-proof architecture** — a fork-managed Groth16/BN254 "pool proof" (value conservation, nullifiers, Merkle membership) plus a permissionless, user-selected **"auth proof"** enabling ECDSA, passkeys, hardware wallets, and delegated proving. Three functions: `deposit()`, `transact()`, `setAuthPolicy()`. It deliberately ships **no** in-protocol compliance. Lehman pitched it (late May 2026) for Ethereum's H2-2026 **Hegota** upgrade [15]. If it lands, every wallet — including a native EOA-style one — could offer "send private ETH/ERC-20 to any address or ENS" from existing accounts, with no special address format, sharing one chain-wide anonymity set. This is the single most strategically important near-horizon item. ⚠ unverified: the exact pitch date ("May 25, 2026" vs "pitched Friday" in coverage clustered May 22–25); the H2-2026 targeting and technical specifics are confirmed. + +## Operational / metadata privacy: RPC, IP, light clients + +The privacy mainstream wallets routinely ignore is operational/metadata privacy. The default leak: MetaMask's default RPC, **Infura** (ConsenSys), collects users' IP + Ethereum addresses on transactions (ConsenSys' policy update, Nov 2022, made this explicit); any third-party RPC sees your IP+address, and dApp connections add network-level data that combines with on-chain history into a behavioral fingerprint [16]. EF-recommended mitigations: (1) **private/self-hosted RPC**; (2) **light clients** — a16z's **Helios**, a Rust Ethereum + OP-stack light client that "converts an untrusted centralized RPC endpoint into a safe unmanipulable local RPC" and compiles to WebAssembly to embed inside wallets (Kohaku plans a WASM build); the Colibri-Stateless provider is an EIP-1193-compatible alternative; (3) **network-layer obfuscation** via Tor/mixnets [16][17]. + +PSE's **"Private Reads"** workstream codifies the metadata roadmap: launching a **Private RPC working group**; integrating **ORAM** into Kohaku for privacy-preserving state reads from remote RPC; implementing a **Sphinx-protocol mixnet** for transaction-broadcast privacy; and TLSNotary/zkTLS for production [2][30]. Community critique on the Magicians thread: the roadmap is heavy on research and light on a clear line to concrete user-visible improvements, and overlaps with account-abstraction concerns [30]. + +## Regulatory context + +The post-Tornado-Cash picture clarified and de-risked self-custodial privacy tooling. **Van Loon v. Treasury** (5th Circuit, Nov 2024) held that Tornado Cash's immutable smart contracts aren't "property" under IEEPA (no ownership/control/exclusivity); **OFAC formally delisted** the contracts on **March 21, 2025**; a W.D. Texas court (April 2025) permanently enjoined re-sanctioning [18][31]. Separately, developer **Roman Storm was convicted (Aug 6, 2025)** on one count — conspiracy to operate an unlicensed money-transmitting business (18 U.S.C. §1960) — while the jury deadlocked on the heavier money-laundering and sanctions counts [19]. Net signal: immutable privacy smart contracts are much harder to sanction, but operators/developers running active money-transmission services still face criminal exposure — which is why production protocols lead with compliance-by-design and self-custodial, non-operator architectures. A self-custodial wallet that merely *integrates* these protocols sits on the favorable side of that line. + +## Adjacent: Aztec (privacy at the execution layer) + +Aztec launched its **"Ignition" chain** on Ethereum mainnet (Nov 2025), billed as the first fully decentralized privacy L2 — producing consensus blocks but **without the smart-contract execution layer** initially; private contract execution and live transactions targeted for early 2026, with earliest TGE Feb 11 2026 [32][33]. Relevant as a destination chain where privacy is native at the execution layer (vs. bolt-on L1 shielded pools), but it requires a chain-specific account/wallet model and is not a drop-in for an EOA-style wallet. + +## What this means for Deckard + +- The four-pillar roadmap maps closely onto a desktop wallet's surfaces, and the **operational/metadata pillars** (private reads, network-level obfuscation) are largely unshipped in mainstream wallets — a gap a native client controls directly rather than depending on protocol upgrades [1][16]. +- **Helios is a Rust light client built to embed in wallets**, and Deckard's runtime is already Rust — so the light-client / "untrusted-RPC-into-verifiable-local-RPC" path involves no language bridge, unlike the TS-first Kohaku SDK [17][5]. +- Kohaku contributors are **explicitly discussing whether Rust/Swift libraries are in scope**, and the EF roadmap names **CLI/native wallets** as intended Kohaku targets — so a native desktop wallet is within the stated audience, not outside it [5][6]. +- The shielded-pool **compliance models are mutually exclusive design choices** (allowlist inclusion vs blocklist non-membership vs threshold reveal); an operator-wallet that lets the user/agent pick per-transaction would span all three rather than hard-coding one posture [10][11][28]. +- **EIP-8182, if it lands in Hegota (H2-2026), would give an EOA-today wallet private transfers with no new address format and a shared anonymity set** — i.e. payment privacy without first migrating to smart accounts; its split auth-proof design already contemplates ECDSA and hardware-wallet signing [14][15]. +- **FHE confidential tokens and shielded pools are complementary, not substitutes** — FHE hides amounts/balances, shielded pools break the address graph — so "privacy" is not one toggle but a stack of independent properties a wallet exposes separately [12][11]. +- The regulatory line currently favors **self-custodial integrators over service operators**, which matches Deckard's self-custodial, non-custodial-relayer posture; integrating compliance-by-design protocols (PPOI, association sets) keeps a wallet on that side [18][19]. +- **Address-per-dapp and coin-selection-at-withdrawal are wallet-side responsibilities**, not protocol features — stealth addresses and shielded pools don't deliver unlinkability on their own, so account-management UX inside the wallet is load-bearing for the privacy actually achieved [1][23]. + +## Open questions + +- Is the Kohaku SDK consumable from Rust, or does its TS-first design force a native wallet to reimplement primitives (Railgun proving, provider abstraction) rather than bind to it? +- Will EIP-8182 actually make the Hegota (H2-2026) cut, and does its permissionless "auth proof" verifier admit a plain-EOA ECDSA path with acceptable proving cost on a desktop machine? +- For an LLM-driven operator wallet, what is the right default privacy posture (which compliance model, shielded-by-default vs opt-in), and how is that decision surfaced to or delegated by the user? +- What is the desktop UX/perf cost of running Helios as an embedded light client (sync time, resource use) versus a privacy-respecting hosted RPC? +- How mature is the broadcast-privacy layer (Sphinx mixnet, Broadcasters) for a wallet that wants to avoid linking IP↔address at transaction submission, and what latency does it add? +- Does delegated/remote proving (for shielded transfers or EIP-8182 auth proofs) reintroduce a metadata leak or trust dependency that undercuts the local-first model? + +## Sources + +1. A maximally simple L1 privacy roadmap (Vitalik Buterin, Apr 2025) — https://ethereum-magicians.org/t/a-maximally-simple-l1-privacy-roadmap/23459 — (forum, high) +2. PSE Roadmap: 2025 and Beyond — https://pse.dev/blog/pse-roadmap-2025 — (blog, high) +3. The Ethereum Foundation's Commitment to Privacy — https://blog.ethereum.org/2025/10/08/privacy-commitment — (blog, high) +4. EF Expands Privacy Push With Dedicated Research Cluster — https://www.coindesk.com/tech/2025/10/09/ethereum-foundation-expands-privacy-push-with-dedicated-research-cluster — (news, medium) +5. ethereum/kohaku — Privacy-first tooling for Ethereum (SDK monorepo) — https://github.com/ethereum/kohaku — (github, high) +6. Vitalik: Ethereum Has Enough Privacy Narratives as Kohaku SDK Advances — https://www.cryptotimes.io/2026/05/26/vitalik-ethereum-has-enough-privacy-narratives-as-kohaku-sdk-advances/ — (news, medium) +7. ERC-5564: Stealth Addresses (with ERC-6538 Registry) — https://eips.ethereum.org/EIPS/eip-5564 — (spec, high) +8. ScopeLift/stealth-address-erc-contracts (canonical 5564/6538 deployments) — https://github.com/ScopeLift/stealth-address-erc-contracts — (github, high) +9. 0xbow Closes $3.5M Round Following Ethereum Foundation Integration — https://www.globenewswire.com/news-release/2025/11/18/3190435/0/en/0xbow-Closes-3-5M-Round-for-Compliant-Crypto-Privacy-Technology-Following-Ethereum-Foundation-Integration.html — (news, medium) +10. Privacy Pools documentation — https://docs.privacypools.com/ — (docs, high) +11. RAILGUN Private Proofs of Innocence — https://docs.railgun.org/wiki/assurance/private-proofs-of-innocence — (docs, high) +12. ERC-7984 Standard (Zama/OpenZeppelin) — https://docs.zama.org/protocol/examples/openzeppelin-confidential-contracts/erc7984 — (docs, high) +13. Confidentiality Layer: Zama Wraps Blockchains in Privacy — https://www.bankless.com/read/confidentiality-layer-zama-wraps-blockchains-in-privacy — (news, medium) +14. EIP-8182: Private ETH and ERC-20 Transfers — https://eips.ethereum.org/EIPS/eip-8182 — (spec, high) +15. Facet's Tom Lehman Pitches EIP-8182 for Hegota — https://unchainedcrypto.com/facets-tom-lehman-pitches-eip-8182-to-bring-native-private-transfers-to-ethereums-hegota-upgrade/ — (news, medium) +16. Infura to Collect MetaMask Users' IP + Ethereum Addresses (policy update) — https://decrypt.co/115486/infura-collect-metamask-users-ip-ethereum-addresses-after-privacy-policy-update — (news, medium) +17. a16z/helios — Rust Ethereum + OP-stack light client — https://github.com/a16z/helios — (github, high) +18. Why OFAC Delisted Tornado Cash — https://www.coindesk.com/policy/2025/04/05/why-ofac-delisted-tornado-cash — (news, medium) +19. US v. Storm: Background & Timeline — https://www.defieducationfund.org/us-v-storm-background-timeline/ — (other, high) +20. Kohaku documentation (llms-full) — https://ethereum.github.io/kohaku/llms-full.txt — (docs, high) +21. Kohaku GitHub releases page — https://github.com/ethereum/kohaku/releases — (github, high) +22. Fluidkey Technical Walkthrough — https://docs.fluidkey.com/technical-documentation/technical-walkthrough/ — (docs, high) +23. Fluidkey FAQ — https://docs.fluidkey.com/readme/frequently-asked-questions/ — (docs, high) +24. ScopeLift/umbra-protocol (Umbra stealth-payment protocol) — https://github.com/ScopeLift/umbra-protocol — (github, high) +25. Blockchain Privacy and Regulatory Compliance: Towards a Practical Equilibrium (Buterin et al., 2023) — https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4563364 — (spec, high) +26. 0xbow: Unlocking Privacy-Preserving Compliance with Association Sets — https://0xbow.io/blog/unlocking-privacy-preserving-compliance-with-association-sets — (blog, high) +27. RAILGUN Privacy System (docs) — https://docs.railgun.org/wiki/learn/privacy-system — (docs, high) +28. Labyrinth's journey to private and compliant DeFi — https://labyrinthprotocol.tech/blog/labyrinths-journey-to-private-and-compliant-defi-milestones-integrations-and-the-road-to-mainnet-2/ — (blog, medium) +29. $118M Committed for the First Encrypted ICO on Ethereum (Zama) — https://www.zama.org/post/118m-committed-for-the-first-encrypted-ico-on-ethereum — (blog, high) +30. PSE Roadmap: 2025 and Beyond (Magicians discussion) — https://ethereum-magicians.org/t/pse-roadmap-2025-and-beyond/25423 — (forum, high) +31. Fifth Circuit Tosses OFAC Sanctions on Tornado Cash (Mayer Brown) — https://www.mayerbrown.com/en/insights/publications/2024/12/federal-appeals-court-tosses-ofac-sanctions-on-tornado-cash-and-limits-federal-governments-ability-to-police-crypto-transactions — (other, high) +32. Privacy-Focused Aztec Network's Ignition Chain Lights Up on Ethereum (CoinDesk) — https://www.coindesk.com/markets/2025/11/20/privacy-focused-aztec-network-s-ignition-chain-lights-up-on-ethereum — (news, medium) +33. Aztec — Roadmap for Decentralized Privacy On-Chain — https://aztec.network/roadmap — (docs, high) diff --git a/docs/research/07-wallet-rankings.md b/docs/research/07-wallet-rankings.md new file mode 100644 index 0000000..3d265c0 --- /dev/null +++ b/docs/research/07-wallet-rankings.md @@ -0,0 +1,125 @@ +# Wallet Rankings & Scorecards (the 'L2BEAT for wallets') + +> Survey of the credible, codified wallet-evaluation projects (Walletbeat, WalletScrutiny) and where a native self-custodial EOA desktop wallet lands against their rubrics. Part of the Deckard wallet research KB. Researched 2026-06-05. + +## TL;DR + +- The "L2BEAT for wallets" exists: it is **Walletbeat**, whose GitHub README literally calls itself "the L2BEAT of wallets — an open repository of EVM-compatible wallets." Live beta at `beta.walletbeat.eth.limo`, code at `github.com/walletbeat/walletbeat` (active on the `beta` branch) [1][2][5]. +- Walletbeat rates wallets on a fully codified pass/partial/fail rubric across five attribute groups — **Security, Privacy, Self-sovereignty, Transparency, Ecosystem** — plus a standalone **Maintenance** check (the latter applies to hardware/embedded wallets, not software) [6]. +- Ratings map to numbers (`FAIL=0.0`, `UNRATED=-0.5`, `EXEMPT=null`) and are weighted-averaged into a score from **-0.5 to 1.0**; an unrated component appends an asterisk. The `PASS`/`PARTIAL` numeric defaults and the verifiability weighting live in `attributes.ts`, not `score.ts` (see correction below) [7][a]. +- Walletbeat has a **Stages maturity ladder** (Stage 0 / 1 / 2 in code) borrowed from L2BEAT's rollup framework. ⚠ unverified: a "Stage 0.5" appears in EF/EthCC press coverage but **not** in the beta code file `software-wallet-stages.ts`, which defines only stages 0/1/2 [8][b]. +- Stage 0 needs only **publicly available source code** to qualify for evaluation; Stage 1 adds recent audits, multi-vendor hardware support, private-by-default transfers, account portability, own-node use, a FOSS license, and ENS; Stage 2 adds a funded bug bounty, address non-correlation, account abstraction, and atomic batching [8]. +- Created by **Moritz** (of Fluidkey, a Swiss company that also ships a wallet), revamped in 2025 by **polymutex**; funded by Ethereum Foundation grants and committed to **not rating Fluidkey's own wallet** for credible neutrality [c][d]. +- **WalletScrutiny** (`walletscrutiny.com`) is the complementary project: it verifies wallets by **reproducible builds** (does the shipped binary match public source?) and gives **categorical verdicts, no numeric score** [9][10]. +- **L2BEAT itself** (`l2beat.com`) does **not** rank wallets — it covers L2 rollups. It is relevant only as the methodological template Walletbeat copied [11][12]. +- **ethereum.org's wallet finder** is a curated, filterable **directory** (~52 wallets), explicitly "not official endorsements" — not a scorecard [13]. +- A native self-custodial EOA desktop wallet like Deckard scores well on the **self-sovereignty/ownership** and **license/source-visibility** axes, but goes FAIL/unrated on audits, bug bounty, default-private RPC, hardware support, privacy non-correlation, and most Ecosystem items (account abstraction, batching, ENS, WalletConnect) [8][14][15][16]. + +## Walletbeat — the answer to "is there an L2BEAT for wallets?" + +Yes. **Walletbeat** is an open repository of EVM-compatible wallets that rates them, and it explicitly brands itself "the L2BEAT of wallets" in its GitHub README [1]. The canonical live surface is the ENS/IPFS-hosted beta at `beta.walletbeat.eth.limo` [2]; the legacy site `walletbeat.fyi` reflects an older, simpler feature-matrix schema with the disclaimer that "a high score does not necessarily mean better performance, it just means more available features" [4]. Active development happens on the `beta` branch (TypeScript ~76%, Svelte ~16%; ~2,481 commits, 112 stars, 83 forks as observed mid-2026) [1]. The repo was historically under `github.com/fluidkey/walletbeat` and now lives at `walletbeat/walletbeat`. The project's About page frames the mission directly: "As L2Beat has done for Ethereum Layer 2s, Walletbeat aims to do the same for Ethereum wallets" [e]. Anyone can add a wallet by dropping a data file in the wallet-data folder and opening a PR [1]. The Walletbeat repo itself is **MIT-licensed** [a]. + +## The codified rubric — a ready-made "what a good wallet has" checklist + +`src/schema/attribute-groups.ts` enumerates the exact scored attributes [6]: + +| Group | Attributes | +|---|---| +| **Security** | securityAudits, scamPrevention, chainVerification, transactionLegibility, hardwareWalletSupport, securityBestPractices, bugBountyProgram, supplyChainDIY, supplyChainFactory, firmware, userSafety, accountRecovery, duressResistance | +| **Privacy** | addressCorrelation, multiAddressCorrelation, privateTransfers, hardwarePrivacy, appIsolation, privacyHygiene | +| **Self-sovereignty** | l1ProviderIndependence, accountPortability, permissionsManagement, transactionInclusion, accountUnruggability | +| **Transparency** | openSource, sourceVisibility, funding, feeTransparency, releaseProcess, reputation | +| **Ecosystem** | accountAbstraction, addressResolution, browserIntegration, chainAbstraction, transactionBatching, hardwareWalletInteroperability, interoperability, appConnectionSupport | +| **Maintenance** | standalone group; software wallets omit it (applies to hardware/embedded wallets) | + +The attribute folders mirror this layout under `src/schema/attributes/{security,privacy,self-sovereignty,transparency,ecosystem}/`, with a shared `common.ts` [6]. + +### How grading works + +Each attribute is rated by an objectively-measurable, mostly pass/partial/fail rule. `src/schema/score.ts` defines `FAIL=0.0`, `UNRATED=-0.5`, and `EXEMPT=null` (excluded entirely — e.g. hardware-only attributes are EXEMPT for software wallets), plus a `weightedScore()` that sums `score × weight` over non-null scores and divides by summed weights; the final score runs from **-0.5 (fully unrated, worst) to 1.0 (best)**, and a `hasUnratedComponent` flag appends an asterisk [7]. ⚠ correction: the `PASS=1.0` / `PARTIAL=0.5` mapping is **not** in `score.ts` — it lives in `src/schema/attributes.ts`'s `defaultRatingScore()`, and is **verifiability-weighted, not flat**: `PASS` is 1.0 when self-evident but drops to 0.7 if independently audited and 0.1 if unverifiable; `PARTIAL` is 0.5 default, 0.2 if audited, 0.05 if unverifiable [a]. For multi-version wallets the system floors each attribute at its worst rating across versions [c]. + +## Walletbeat Stages — an L2BEAT-style maturity ladder for wallets + +`src/schema/stages/software-wallet-stages.ts` defines a maturity ladder analogous to L2BEAT's rollup Stages [8]: + +- **Stage 0** — "meets the minimum criteria for evaluation": the single criterion is publicly available source code (assessed via `sourceVisibility`). +- **Stage 1** — recent audit (within 1 year), hardware-wallet support across 3+ manufacturers, L1 chain verification, private-by-default token transfers, account portability/export, ability to use your own Ethereum node, a FOSS license, ENS human-readable addresses, and browser-integration standards compliance. +- **Stage 2** — funded bug bounty, address & multi-address non-correlation, permissionless L2→L1 withdrawals, custom RPC for all chains, public funding/revenue disclosure, fee transparency, chain-specific address resolution (ERC-7828/7831), Account Abstraction support, and atomic transaction batching. + +⚠ unverified: A **Stage 0.5** is *not* present in the cited beta code file (the `stages` array is `[softwareWalletStageZero, softwareWalletStageOne, softwareWalletStageTwo]`), and the file contains no internal L2BEAT reference [8][b]. The Stage 0.5 concept and the explicit L2BEAT analogy come from EF/EthCC press coverage of the maturity model (described as unveiled by EF's Hester Bruikman at EthCC, ~April 2026), not from the code; the secondary news source for it is low-reliability [g]. + +## The attribute rules that matter most for an EOA desktop wallet + +- **Account Portability** (`self-sovereignty`): for an EOA, `PASS` requires standards-compliant **BIP-39 + BIP-32 + BIP-44** derivation with an exportable seed phrase or private key; non-standard derivation but exportable key = `PARTIAL`; no key export = `FAIL` [14]. +- **Security Best Practices** (`security`): key storage in a secure enclave / HSM = `PASS`; **standardized-KDF-encrypted or OS-sandboxed storage = `PARTIAL`**; weak/non-standard KDF, off-device key generation, MPC reconstruction that bypasses the user device, or closed source = `FAIL`. RNG: OS CSPRNG = `PASS`, unverified library RNG = `PARTIAL`. It hard-requires key material to be generated/reconstructed on the user's device [15]. +- **Source visibility vs license** (`transparency`): these are two distinct attributes. `sourceVisibility` asks only whether code is public (irrespective of license): `PASS` if all repos are viewable, `PARTIAL` if only some components, `FAIL` if private. `openSource` (license) is stricter: `PASS` for OSI-definition FOSS (MIT/Apache/BSD/GPL), `PARTIAL`/`FUTURE_FOSS` for a delayed-FOSS license like BUSL, `FAIL` for proprietary, mixed, or **unlicensed** (conservatively treated as NOT_FOSS) [16][f]. +- **L1 Provider Independence** (`self-sovereignty`): `PASS` only if a self-hosted node can be configured **before any request hits the default RPC** and all basic ops work through it; configurable-but-default-used-first = `PARTIAL`; no config / hard external dependency = `FAIL`. Motivation: don't leak address/IP to a default RPC [17]. +- **Account Unruggability** (`self-sovereignty`): `FAIL` if the provider or any single external party can unilaterally take over/reconstruct the account, if keys live on external servers, or if the developer offers unencrypted seed backup on their own platform; `PASS` requires on-device key control [18]. +- **Account Recovery** (`security`): evaluates **only guardian-based ("social") recovery — explicitly NOT seed-phrase backup**. `PASS` requires the recovery secret split across 3+ independent external services with 2+ different shares needed, no single party (including the provider) able to recover alone, and reconstitution on the user's device. It is fail/pass with no `PARTIAL` [18]. +- **Security Audits** (`security`): `PASS` = audited within the last 365 days with all medium+ findings fixed; `PARTIAL` = stale (>1yr) or recent-but-unresolved findings; `FAIL` = never audited or stale with unresolved findings; no audit data => unrated, not auto-fail [c]. + +The rule-selection philosophy (per the FAQ): attributes are chosen for Ethereum/cypherpunk alignment, shared ecosystem goals, and *not-already-market-driven* gaps (e.g. supply-chain security, data privacy). The scoring rules must be objectively measurable, technology-neutral, immediately feasible, pragmatic, and designed to **raise the bar over time** [3]. + +## WalletScrutiny — the complementary "can you trust the binary" check + +WalletScrutiny (`walletscrutiny.com`) answers a different question than Walletbeat: does the binary users run actually match the published source (a **reproducible build**)? It targets the exit-scam / bait-and-switch attack [9][10]. It assigns **categorical verdicts, no numeric score** — e.g. positive: "Source code is available", "Do-It-Yourself Project"; negative: "Custodial: The provider holds the keys", "No source for current release found", "Obfuscated", "Provided private keys", "Leaks Keys" — plus status verdicts ("Review is Work in Progress", "Discontinued") [9]. Android/desktop evaluation runs review-status → authenticity → is-it-a-wallet → custody → source availability → obfuscation → reproducibility → maintenance. **No iPhone app has been reproducible** because Apple restricts the needed access, so the burden of proof is shifted onto providers/Apple [9]. The project stresses reproducibility verifies a point-in-time match, not the absence of malware or a future bait-and-switch [9]. + +The canonical source is **GitLab** (`gitlab.com/walletscrutiny/walletScrutinyCom`); the GitHub repo (`github.com/WalletScrutiny/WalletScrutinyCom`) is a mirror (~8,989 commits, JS-heavy, actively maintained) [10][h]. Originally Bitcoin-focused, it now covers mobile/desktop/hardware across multiple asset classes, runs a community "Verifications" model with an automated build server that re-runs reproducibility scripts on new releases, and is decentralizing verdict data via **Nostr event specifications** so other apps can consume verdicts [10][i]. + +## What's *not* a ranking + +- **L2BEAT** (`l2beat.com`) tracks L2 rollups — TVS, activity, risk, and a Stages framework introduced **June 19, 2023** (Stage 0 "Full Training Wheels" → Stage 1 "Limited Training Wheels" → Stage 2 "No Training Wheels") that rates rollup decentralization/trust-minimization. It does not rank wallets; it is purely the template Walletbeat borrowed [11][12]. +- **ethereum.org wallet finder** is a curated, filterable directory (~52 wallets, "not official endorsements ... for informational purposes only") with filters for non-custody, open source, hardware, multisig, social recovery, privacy, smart accounts, account upgrades, custom RPC import, gas customization, ENS, etc. Listing requires EIP-1559 (type-2) support, an Ethereum/L2 default network, 6+ months live (or an established team), and one of an audit / internal security team / open-source review — not strictly an audit [13][j]. +- DeFiLlama and "top 10 wallets" pages are SEO listicles, not codified rubrics — treat as low-reliability. The credible, codified options are **Walletbeat** (values/feature scorecard + Stages) and **WalletScrutiny** (reproducibility verdicts). + +## What this means for Deckard + +Observations and opportunities only — not a roadmap. + +- **A codified, open checklist already exists.** Walletbeat's attribute groups and Stage criteria are a public, machine-readable spec of "what a good Ethereum wallet has," and any wallet can self-assess against it without permission [6][8]. +- **Source visibility gates everything.** Walletbeat Stage 0 requires public source code merely to *qualify for evaluation*; a closed-source wallet is effectively below Stage 0 and unrated [8]. +- **Deckard's 0BSD license clears the license bar.** 0BSD is OSI-approved/FOSS, so `transparency.openSource` would be a `PASS` — though any unlicensed component would conservatively be treated as NOT_FOSS and could drag it to FAIL [16][f]. +- **The self-custodial EOA structurally aligns with the highest-leverage attributes.** Keys generated and held on-device (no provider able to take over) is a strong `accountUnruggability` candidate, and the planned BIP-39/BIP-32/BIP-44 seed backup with exportable keys maps directly onto `accountPortability`'s `PASS` rule [14][18]. +- **The planned keystore lands at `PARTIAL`, not `PASS`.** An Argon2id + XChaCha20-Poly1305 encrypted keystore reads as "standardized-KDF-encrypted / OS-sandboxed" storage = `PARTIAL` under `securityBestPractices`; a `PASS` requires a hardware/secure-enclave path. Persisting an unencrypted key to the OS config dir (v0) sits at the `PARTIAL`/`FAIL` boundary [15]. +- **RNG is likely already a `PASS`** if key generation uses an OS CSPRNG (alloy/getrandom draws from the OS CSPRNG) [15]. +- **Several attributes are FAIL/unrated until external milestones land**, independent of code quality: `securityAudits` (no independent audit), `bugBountyProgram` (no funded program), `l1ProviderIndependence` (PASS needs user-set self-hosted RPC before first request), plus privacy non-correlation, hardware support, and Ecosystem items (account abstraction, batching, ENS, WalletConnect/EIP-6963) [8][14][15]. +- **The operator-wallet vision intersects directly with `accountUnruggability` and `securityBestPractices`.** A *local* or self-custodial LLM agent keeps keys on-device and aligns with the rubric; any cloud component that could move funds without on-device key control would jeopardize those PASS ratings — and note that `accountRecovery` credits only 3+-guardian social recovery, so seed backup alone does not score there [15][18]. + +## Open questions + +- The EF ESP grant proposal **requested** $106,100 and its front-matter is marked **"Status: Funded"**; was the full sum actually disbursed? (Primary source confirms "Amount: 106100 USD" and "Status: Funded", but disbursement of the full amount is not separately proven) [d]. +- Where does the canonical "Stage 0.5" definition live, given it is absent from the beta code file? Is it slated to land in code, or is it press-only framing? [b][g]. +- How does the verifiability-weighting in `defaultRatingScore()` change real-world rankings versus a flat pass/partial/fail — i.e. how much does "independently audited" vs "self-evident" move a score? [a]. +- Does Walletbeat currently list any native Rust / GPUI / desktop EOA wallets, and how are pure desktop (non-extension, non-mobile) wallets scored on browser-integration and app-connection attributes? +- Would a desktop wallet that defaults to a bundled RPC but exposes a pre-first-request custom-RPC setting clear `l1ProviderIndependence`'s `YES_BEFORE_ANY_REQUEST` bar? [17]. + +## Sources + +[1] walletbeat/walletbeat — "the L2BEAT of wallets" repo — https://github.com/walletbeat/walletbeat — (github, high) +[2] Walletbeat (live beta site) — https://beta.walletbeat.eth.limo/ — (docs, high) +[3] Walletbeat FAQ — rubric philosophy, scoring, governance — https://beta.walletbeat.eth.limo/faq/ — (docs, high) +[4] Walletbeat legacy site (older feature-matrix schema) — https://www.walletbeat.fyi/ — (docs, medium) +[5] Walletbeat README — https://github.com/walletbeat/walletbeat/blob/main/README.md — (github, high) +[6] attribute-groups.ts — full list of scored attributes by group — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/src/schema/attribute-groups.ts — (github, high) +[7] score.ts — FAIL=0.0/UNRATED=-0.5/EXEMPT=null & weighted average — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/src/schema/score.ts — (github, high) +[8] software-wallet-stages.ts — Stage 0/1/2 ladder — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/src/schema/stages/software-wallet-stages.ts — (github, high) +[9] WalletScrutiny methodology — reproducible builds, verdicts — https://walletscrutiny.com/methodology/ — (docs, high) +[10] WalletScrutiny GitHub mirror — https://github.com/WalletScrutiny/WalletScrutinyCom — (github, high) +[11] L2BEAT — L2 ecosystem summary (no wallet ranking) — https://l2beat.com/scaling/summary — (docs, high) +[12] L2BEAT — Introducing Stages (June 19, 2023) — https://medium.com/l2beat/introducing-stages-a-framework-to-evaluate-rollups-maturity-d290bb22befe — (blog, high) +[13] ethereum.org wallet finder (filterable directory) — https://ethereum.org/en/wallets/find-wallet/ — (docs, high) +[14] account-portability.ts — BIP-39/32/44 export rating — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/src/schema/attributes/self-sovereignty/account-portability.ts — (github, high) +[15] security-best-practices.ts — key storage, RNG, hardening — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/src/schema/attributes/security/security-best-practices.ts — (github, high) +[16] open-source.ts — license rating (FOSS/FUTURE_FOSS/NOT_FOSS) — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/src/schema/attributes/transparency/open-source.ts — (github, high) +[17] l1-provider-independence.ts — own-node/RPC rating — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/src/schema/attributes/self-sovereignty/l1-provider-independence.ts — (github, high) +[18] account-unruggability.ts & account-recovery.ts — provider-takeover and social-recovery rules — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/src/schema/attributes/self-sovereignty/account-unruggability.ts — (github, high) +[a] attributes.ts — defaultRatingScore(): PASS/PARTIAL→number with verifiability adjustments; repo is MIT-licensed — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/src/schema/attributes.ts — (github, high) +[b] stages.ts — StageCriterionRating enum & WalletStage type; confirms stages 0/1/2, no 0.5 in code — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/src/schema/stages.ts — (github, high) +[c] Walletbeat FAQ — origin (Moritz created it; 2025 revamp by polymutex), scoring philosophy, DAO goal — https://beta.walletbeat.eth.limo/faq/ — (docs, high) +[d] Walletbeat ESP grant proposal — "Amount: 106100 USD", "Status: Funded", Fluidkey-ineligibility, separate "Pectra Proactive Grant" = $577.02 — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/governance/grants/2025-07-ethereum-foundation-esp-grant-proposal/proposal.md — (github, high) +[e] Walletbeat About page — "As L2Beat has done for Ethereum Layer 2s, Walletbeat aims to do the same"; MIT-licensed; affiliation disclosure — https://beta.walletbeat.eth.limo/about/ — (docs, high) +[f] source-visibility.ts — public-code rating (irrespective of license) — https://raw.githubusercontent.com/walletbeat/walletbeat/beta/src/schema/attributes/transparency/source-visibility.ts — (github, high) +[g] EF/EthCC coverage of the wallet security maturity model (Stage 0.5 framing) — https://www.binance.com/en/square/post/308159202760305 — (news, low) +[h] WalletScrutiny canonical repo on GitLab (GitHub is the mirror) — https://gitlab.com/walletscrutiny/walletScrutinyCom — (gitlab, high) +[i] WalletScrutiny — User-Created Verifications on Nostr (decentralized verdict-sharing) — https://walletscrutiny.com/verifications/ — (docs, high) +[j] (covered under [13]) ethereum.org listing criteria — audit OR internal security team OR open-source review — https://ethereum.org/en/wallets/find-wallet/ — (docs, high) diff --git a/docs/research/08-security-keystores.md b/docs/research/08-security-keystores.md new file mode 100644 index 0000000..e47709e --- /dev/null +++ b/docs/research/08-security-keystores.md @@ -0,0 +1,160 @@ +# Key Management & Security Patterns + +> How software and hardware Ethereum wallets protect keys at rest, on-device, and at signing time in 2026 — and where Deckard's locked Argon2id + XChaCha20-Poly1305 envelope sits relative to the field. Part of the Deckard wallet research KB. Researched 2026-06-05. + +## TL;DR + +- The de-facto software-wallet key-at-rest format is the **Web3 Secret Storage Definition v3**: AES-128-CTR cipher, PBKDF2-HMAC-SHA256 (mandatory) or scrypt (optional) KDF, and a bolt-on **keccak-256 MAC** = `KECCAK(DK[16..31] ++ ciphertext)` for integrity [1]. Geth, ethers, Foundry/`cast`, and the Rust `eth-keystore` crate all implement it [1][2][3]. +- Deckard's locked envelope (**Argon2id** + **XChaCha20-Poly1305**) is cryptographically stronger on both axes but **not interoperable** with that format. Argon2id is OWASP's top-recommended KDF; XChaCha20-Poly1305 is an AEAD whose Poly1305 tag authenticates intrinsically, replacing the separate keccak MAC and removing AES-CTR malleability [4][5]. +- Deckard v0's **plaintext-hex private key on disk is below the universal field floor** — no mainstream wallet stores cleartext keys at rest [1][3][6]. Shipping the encrypted envelope is the single highest-value security change. +- alloy / `eth-keystore` give you scrypt + AES-128-CTR Web3-Secret-Storage out of the box, **not** Argon2id/XChaCha — that envelope is a custom layer built from RustCrypto's `argon2`, `chacha20poly1305`, and `zeroize` [7][8][9]. +- The Rust primitives are mature and audited: `k256` (NCC Group 2023, two high-sev issues found and fixed), `chacha20poly1305` (NCC Group, no significant findings), reference `argon2`, and `zeroize` for non-optimizable memory wiping [9][10][11]. +- Apple's **Secure Enclave only supports NIST P-256 (secp256r1)** — it cannot hold or sign with Ethereum's secp256k1 keys, so a Secure-Enclave-backed EOA is impossible without a smart account [12][13]. +- Passkeys / WebAuthn sign with secp256r1; on-chain verification is now cheap via **RIP-7212** (precompile at `0x100`, 3450 gas, live on L2s) and on **mainnet** via **EIP-7951** (`0x100`, 6900 gas), shipped in the **Fusaka fork on Dec 3 2025** — but only usable through a smart account [14][15][16]. +- **EIP-7702** (Pectra, mainnet **May 7 2025**) lets an EOA delegate its code without changing address — the lowest-friction path to account-abstraction features, but a real phishing surface: within weeks the vast majority of mainnet delegations pointed at drainer contracts [17][18]. +- Institutional infra removed single-key risk via **TEEs + MPC/sharding** (Turnkey, Privy, Web3Auth, Lit); the TEE-plus-policy-engine pattern maps closely onto Deckard's operator-wallet vision [19][20][21][22]. +- **Clear signing** (EIP-712 + **ERC-7730**, Ledger-led, Draft since Feb 2024) gives machine-readable transaction intent — directly relevant to letting an LLM (or user) understand what a signature does before approving [23][24]. + +## The field-standard keystore: Web3 Secret Storage v3 + +The canonical software-wallet key-at-rest format, documented on ethereum.org and originating from go-ethereum, is a JSON file (`.json`) with a `crypto` object holding `cipher`, `cipherparams.iv`, `ciphertext`, `kdf`, `kdfparams`, and `mac`, plus top-level `id` (UUID) and `version: 3` [1]. The specifics: + +- **Cipher**: AES-128-CTR is mandatory for minimal compliance; the encryption key is the leftmost 16 bytes of the derived key (`DK[0..15]`) [1]. +- **KDF**: PBKDF2-HMAC-SHA256 must be supported (params `c`, `salt`, `dklen ≥ 32`); scrypt (`n`, `r`, `p`, `salt`, `dklen`) is an optional alternative. The PBKDF2 test vector uses `c = 262144`, `dklen = 32` [1]. +- **Integrity**: NOT an AEAD tag but a separate keccak-256 MAC, `KECCAK(DK[16..31] ++ ciphertext)` [1]. + +AES-128-CTR is **unauthenticated** — the keccak MAC is what prevents ciphertext tampering, a bolt-on that modern AEADs make unnecessary [1]. + +Foundry's `cast wallet import` writes per-account encrypted JSON to `~/.foundry/keystores` in this exact v3 format (scrypt KDF, AES-128-CTR, keccak MAC), and its `--unsafe-password` / plaintext path is explicitly flagged "not recommended" [3][6]. This is the modern recommendation for replacing plaintext `PRIVATE_KEY` env vars in dev tooling, and confirms that even developer CLIs encrypt keys at rest with a memory-hard-ish KDF. + +## How Deckard's Argon2id + XChaCha20-Poly1305 compares + +Deckard's locked envelope is **stronger but non-standard**. + +| Axis | Web3 Secret Storage v3 | Deckard envelope | +|---|---|---| +| KDF | PBKDF2-HMAC-SHA256 (mandatory) / scrypt (optional) | **Argon2id** (memory-hard) | +| Cipher | AES-128-CTR (unauthenticated) | **XChaCha20** (256-bit, 192-bit nonce) | +| Integrity | separate keccak-256 MAC | **Poly1305 AEAD tag** (intrinsic) | +| Interop | Geth / MetaMask / Foundry | Deckard-only | + +OWASP's Password Storage Cheat Sheet lists **Argon2id first** (minimum 19 MiB memory, `t=2`, `p=1`), names scrypt as the fallback, and reserves PBKDF2 (600,000+ iterations) for FIPS-140 compliance [4]. Argon2id resists GPU/ASIC cracking far better than PBKDF2 or scrypt. XChaCha20-Poly1305 is an AEAD: the Poly1305 tag authenticates the ciphertext intrinsically (so no separate keccak MAC), and the 192-bit XChaCha nonce can be randomly generated without collision worry, unlike AES-CTR's 128-bit IV [5]. This is the same modern construction family as `age` and libsodium `secretbox`. (Note: "stronger" and "below the field floor" are well-grounded engineering judgments rather than spec-verifiable facts, but they follow directly from the primary evidence.) + +The cost is **portability**: a Deckard keystore cannot be opened by Geth, MetaMask, or Foundry. The field-standard mitigation is to (a) ship a **BIP-39 mnemonic backup** — the true cross-wallet portability layer — and optionally (b) offer a **Web3-Secret-Storage (scrypt + AES-128-CTR) export** so users can recover into any standard wallet [1][8]. + +## The v0 plaintext-hex problem + +Persisting the raw secp256k1 private key as plaintext hex in the OS config dir is below the universal field floor. MetaMask and Rabby keep the seed+keys in an encrypted "vault" blob — `browser-passworder` derives an AES key from the password via PBKDF2 and encrypts with AES-GCM — unlocked by a password and only briefly held in memory during signing [25][26]. Geth, Foundry/`cast`, and ethers all write Web3-Secret-Storage JSON [1][3]. Any local-disk read (malware, backup sync, lost laptop, shoulder-surf of the config file) is instant total compromise. + +**Corrected (verified against the primary `browser-passworder` source):** an earlier claim that "MetaMask uses PBKDF2 with only 10,000 iterations" is **outdated**. MetaMask's `browser-passworder` repo shows 10,000 as the *legacy* `OLD_DERIVATION_PARAMS`; since v4.2.0 (Nov 13, 2023) the library default jumped to 900,000 and the **extension was configured to 600,000** iterations to match OWASP's 2023 guidance [25][27]. The 10,000-iteration figure was a real *historical* weakness (and the mobile app historically used ~5,000 with AES-CBC), but the present-tense framing is wrong for current MetaMask. The broader point stands: Argon2id is a stronger KDF than PBKDF2 for the same UX [4]. + +## Seed handling: BIP-39 / BIP-32 / BIP-44 + +BIP-39 encodes entropy (128–256 bits, a multiple of 32) plus a SHA-256 checksum (ENT/32 bits) into 11-bit indices over a 2048-word list (128 bits → 12 words, 256 → 24 words) [28]. The seed is then `PBKDF2-HMAC-SHA512(password = mnemonic, salt = "mnemonic" + passphrase, 2048 iterations, 64-byte output)` [28]. The optional passphrase (the informal "25th word") yields a completely different wallet tree for each value — useful plausible-deniability UX, with the harsh property that there is **no recovery if forgotten** [28]. BIP-32 turns the seed into an HD key tree; BIP-44 defines `m/purpose'/coin_type'/account'/change/address_index`, with Ethereum at `m/44'/60'/0'/0/0`. Because all EVM chains share `coin_type 60` and identical address/signature schemes, one mnemonic reproduces the same `0x` address everywhere. For Deckard, BIP-39 backup is the portability/recovery layer (importable into MetaMask/Ledger) and the passphrase is a cheap optional defense-in-depth feature. + +## OS-level protection: Keychain, Secure Enclave, and the secp256r1 wall + +Apple's Secure Enclave (SEP) generates and holds EC keys with optional Touch ID access control, but **only on the NIST P-256 (secp256r1) curve** — `SecureEnclave.P256` is the only EC type it exposes [12][13]. Ethereum uses secp256k1, which the SEP cannot hold or sign with. So you cannot put an Ethereum EOA key in the Secure Enclave. Two realistic patterns: + +1. **Pragmatic ("Touch ID later")**: use the Keychain / Secure Enclave to protect a *wrapping key or passphrase* that decrypts the Argon2id+XChaCha keystore; the actual secp256k1 key lives in the encrypted file [12]. +2. **Smart-account route**: a P-256 SEP key becomes an on-chain signer verified via RIP-7212/EIP-7951 — requires account abstraction Deckard doesn't have [14][15]. + +On Linux the equivalent unlock-secret store is the freedesktop Secret Service API (GNOME Keyring / KWallet over D-Bus). + +For Rust, `keyring-rs` (v4.0.1, May 2026) is the cross-platform credential store (macOS Keychain, Windows Credential Manager, Linux/BSD Secret Service); its macOS backend uses the **login Keychain, not the Secure Enclave**, and is not biometric-gated by default [29][30]. The v4 README advises depending on `keyring-core` + per-platform store crates rather than the umbrella crate. For SEP / Touch-ID-guarded P-256 keys you need the **experimental** `iqlusioninc/keychain-services.rs` (a thin wrapper over Keychain Services / `SecAccessControl`), explicitly flagged as possibly having memory-safety bugs [13]. A sound design: store the keystore-unlock secret (not the raw key) in keyring/Keychain for the no-passphrase-each-time UX, and reserve `keychain-services.rs` for Phase-2 Touch ID. + +## Rust signing & keystore ecosystem + +`alloy-signer-local` (v2.x) is the canonical signer: the default `PrivateKeySigner` uses the pure-Rust `k256` crate; an optional `secp256k1` (libsecp256k1 C-bindings) backend produces identical signatures; there's also a YubiHSM2 signer [7]. Encrypted keystores sit behind the `keystore` feature, which wraps the `eth-keystore` crate (Web3 Secret Storage, scrypt for encryption, scrypt+pbkdf2 for decryption, AES via `aes`/`ctr`); the `mnemonic` feature enables BIP-39 [7][8]. The `eth-keystore-rs` crate is minimalist (latest 0.5.0, Apache-2.0, low activity) and has **no Argon2id/XChaCha support** [8][31]. + +**Key gap for Deckard**: alloy/`eth-keystore` give scrypt + AES-128-CTR out of the box, but the Argon2id + XChaCha20 envelope is a custom layer built with RustCrypto's `argon2` + `chacha20poly1305` + `zeroize`, feeding decrypted bytes into alloy's `PrivateKeySigner` — and keep `eth-keystore` for an export path [7][8][9]. + +The primitives are audited/mature: `k256` is constant-time secp256k1 (NCC Group's 2023 Entropy/Rust review found two high-severity issues, since fixed — so pin a current version); `chacha20poly1305` was NCC-audited with no significant findings and runs in constant time; RustCrypto's `argon2` is the reference Argon2id; `zeroize` performs volatile, non-optimizable wiping via `write_volatile` + atomic fences (but cannot defend against Spectre-class microarchitectural leakage) [9][10][11]. Wrap in-memory keys/seeds in `Zeroizing<...>`, and prefer `k256` (no C toolchain) unless libsecp256k1 perf is needed. + +## Hardware wallets and the stronger single-key fix + +Ledger and Trezor hold keys in a certified Secure Element: Ledger uses ST33 chips at CC EAL5+/EAL6+; Trezor Safe 3/5 use Infineon OPTIGA Trust M (V3) at EAL6+, with the Trezor Safe 7 (2025/26) adding the open/auditable TROPIC01 element [32][33]. The SE enforces a PIN without storing it. (Caveat, March 2025: Ledger researchers showed Trezor still runs crypto on the general MCU, a voltage-glitch surface [43].) Both ship EIP-712 typed-data display; Trezor's Sept 2025 firmware added EIP-712 message-hash display [34]. For a desktop EOA wallet, hardware-wallet support is the strongest available single-key-risk reduction. + +## Clear signing: EIP-712 and ERC-7730 + +EIP-712 lets dapps present typed structured data so wallets can show fields instead of an opaque hash, but type info alone isn't enough to render safe human intent. **ERC-7730** (Draft, created Feb 2024, Ledger-led, authors Castillo/Aoun et al.) standardizes a JSON "clear-signing" descriptor for both EVM calldata and EIP-712 messages, with `context`, `metadata`, `display`, and `includes` sections [23][24]. A public registry (`ethereum/clear-signing-erc7730-registry`) holds descriptors and is deliberately treated as untrusted, recommending cryptographic provenance + multi-party governance [35]. For an operator-wallet, ERC-7730 descriptors are the mechanism to show the user *and the LLM* what a transaction means before an autonomous signature. + +## Higher up the stack: MPC/TSS, TEEs, passkeys, smart-EOAs + +The institutional/embedded-wallet field removed single-key risk two ways [19][20][21][22]: + +- **TEE-isolated signing**: Turnkey decrypts and signs inside AWS Nitro secure enclaves with attestation; raw keys never leave, and transaction policies (limits, multisig, roles) are enforced *inside* the TEE [19]. Privy combines AWS Nitro TEEs with Shamir Secret Sharing (a 2-of-2 enclave-share / auth-share model, reconstructed only ephemerally in-enclave) — acquired by Stripe June 2025 [20]. +- **MPC/TSS where the key is never reconstructed**: Web3Auth tKey uses 2/3 SSS (device / OAuth-network / recovery shares) with TSS producing partial signatures; Lit Protocol uses DKG + threshold TSS across nodes, minting each key as a Programmable Key Pair (ERC-721) [21][22]. + +The TEE-plus-policy-engine pattern maps almost exactly onto the operator-wallet vision: enforce spending/action policy in a trusted boundary the LLM cannot bypass. + +**Passkeys as on-chain signers**: WebAuthn / Secure Enclave / Android Keystore all sign secp256r1. On-chain verification needs a P-256 verifier: **RIP-7212** (Final) — precompile at `0x100`, 3450 gas, 160-byte input `(hash, r, s, qx, qy)` — is live on Arbitrum (RIP-7212 support AIP'd in ArbOS 30, activated in ArbOS 31 "Bianca"), OP-Stack chains (Base/Optimism), Polygon zkEVM and others, making passkey verification roughly as cheap as `ECRECOVER` [14][36]. **EIP-7951** brings it to **mainnet** at `0x100`, 6900 gas, fixing two RIP-7212 edge cases (reject point-at-infinity; compare `r' ≡ r (mod n)`); it shipped in the **Fusaka hard fork, mainnet Dec 3 2025 (21:49:11 UTC, epoch 411392)** [15][16]. The flagship production user is **Coinbase Smart Wallet** on Base: owners are stored as `bytes` to allow both Ethereum-address and secp256r1 passkey owners, with signatures wrapped in a `SignatureWrapper`/`WebAuthnAuth` struct; the actual on-chain verifier (`base-org/webauthn-sol`) tries the RIP-7212 precompile and falls back to the open-source FreshCryptoLib Solidity verifier [37][38]. Passkeys can only be an *on-chain signer* through a smart account — for a plain EOA they're an excellent local-keystore unlock factor. + +**EIP-7702 smart-EOA delegation** (Pectra, mainnet **May 7 2025**): a `SetCode` (0x04) transaction points an EOA at an implementation contract, and the EVM executes that code as the EOA without changing the address — enabling batching, gas sponsorship, and alt-auth with no migration [17][39]. But it broke the "EOAs cannot execute code" assumption: per **Wintermute's** research, within ~4 weeks **97% of mainnet 7702 delegations** pointed to copy-pasted sweeper/drainer contracts (the "CrimeEnjoyor" family), with individual losses of $1.54M and ~$146K to 7702 phishing [17][18][40]. (Wintermute later framed ~48% of 7702 *uses* as crime-linked — a different measure that shouldn't be conflated with the 97%-of-delegations figure [40].) Signing a 7702 authorization is signing away your account's code, so the wallet must surface the delegate target (ideally with ERC-7730 metadata) and warn on unknown delegates. + +**Social recovery** uses guardians (a quorum of trusted addresses) to authorize a new signer. Argent pioneered it with a guardian quorum and a 36-hour delay during which the owner can `cancelRecovery` [41][42]. In 2025 it's delivered via account-abstraction modules (ERC-4337, ERC-7579). It is impossible on a plain EOA — it requires smart-account logic. + +## What this means for Deckard + +- Deckard v0's plaintext-hex key on disk is below the floor every comparable wallet meets; the locked Argon2id + XChaCha20-Poly1305 envelope closes the largest gap and uses primitives (RustCrypto `argon2` / `chacha20poly1305` / `zeroize`, alloy's `k256`) that are already audited and pure-Rust [4][5][9][10][11]. +- The envelope is cryptographically ahead of the Web3 Secret Storage field standard but **not interoperable** with it; BIP-39 mnemonic backup is the genuine cross-wallet recovery layer, and an optional `eth-keystore` (scrypt + AES-128-CTR) export exists as a portability escape hatch [1][8][28]. +- The Secure Enclave's secp256r1-only constraint means Touch ID can gate the *unlock secret* for the keystore today, but cannot hold the Ethereum key itself — full SEP/passkey signing is gated on account abstraction Deckard doesn't yet have [12][13]. +- For the operator-wallet vision, the recurring industry pattern is a **policy engine inside a trust boundary the signer cannot bypass** (Turnkey/Privy TEEs); a local equivalent — enforced spending/action limits between the LLM and the signing key — is the analogous self-custodial control [19][20]. +- **ERC-7730 clear-signing descriptors** are the natural source of machine-readable transaction intent for an LLM to reason about *before* an autonomous signature, complementing EIP-712 [23][24][35]. +- The now-mainnet P-256 precompile (EIP-7951, Fusaka Dec 3 2025) and EIP-7702 delegation are the two infrastructure pieces that would let a future Deckard add passkey signers and smart-account features to the *same* EOA address — both also introduce new signing-time risks (7702 drainer phishing) the UI must surface [15][17][18]. +- Hardware-wallet (Ledger/Trezor) support is the strongest off-the-shelf single-key-risk reduction available to a desktop EOA wallet, independent of any smart-account work [32][33]. + +## Open questions + +- Should Deckard's keystore JSON adopt a versioned, self-describing header (KDF params, AEAD, nonce) so future migrations (e.g. Argon2id parameter bumps, or to a different AEAD) are backward-readable? +- What exact Argon2id parameters should Deckard ship for a desktop CPU profile — OWASP's 19 MiB / `t=2` / `p=1` floor, or a higher memory cost given desktop hardware headroom? [4] +- For the Touch-ID path, is the experimental `keychain-services.rs` mature enough to depend on, or should Deckard wrap the platform Security framework directly / via its own FFI? [13] +- What is the right Linux story for biometric/hardware-gated unlock, given Secret Service (GNOME Keyring / KWallet) has no Secure-Enclave equivalent? +- For the operator-wallet, where does the policy boundary live in a local-first app with no TEE — a separate signing process, OS sandbox, or a future hardware/enclave dependency? [19] +- If/when Deckard adds account abstraction, is EIP-7702 delegation on the existing EOA preferable to a fresh ERC-4337/7579 account, given 7702's address-preservation benefit but added delegation-phishing surface? [17][18] + +## Sources + +1. Web3 Secret Storage Definition (v3) — https://ethereum.org/developers/docs/data-structures-and-encoding/web3-secret-storage/ — (docs, high) +2. eth-keystore crate docs — https://docs.rs/eth-keystore — (docs, high) +3. Foundry — `cast wallet` reference (incl. `cast wallet decrypt-keystore`) — https://getfoundry.sh/cast/reference/wallet/ — (docs, high) +4. OWASP Password Storage Cheat Sheet (Argon2id / scrypt / PBKDF2) — https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html — (docs, high) +5. RustCrypto AEADs — chacha20poly1305 (NCC audit, constant-time AEAD) — https://github.com/RustCrypto/AEADs/tree/master/chacha20poly1305 — (github, high) +6. Foundry — `cast wallet` reference (incl. `cast wallet import`; Web3 Secret Storage v3, `--unsafe-password` flagged) — https://getfoundry.sh/cast/reference/wallet/ — (docs, high) +7. alloy-signer-local crate docs (k256 default; `keystore`/`mnemonic` features) — https://docs.rs/alloy-signer-local — (docs, high) +8. eth-keystore-rs GitHub (scrypt+pbkdf2, AES-128-CTR; no Argon2/XChaCha) — https://github.com/roynalnaruto/eth-keystore-rs — (github, high) +9. RustCrypto elliptic-curves — k256 (NCC audit 2023; constant-time secp256k1) — https://github.com/RustCrypto/elliptic-curves/tree/master/k256 — (github, high) +10. NCC Group Entropy/Rust Cryptography Review (2023-08-25; two high-sev k256 findings) — https://www.nccgroup.com/research-blog/public-report-entropyrust-cryptography-review/ — (other, high) +11. zeroize crate docs (volatile, non-optimizable memory wiping) — https://docs.rs/zeroize/latest/zeroize/ — (docs, high) +12. Apple — Protecting keys with the Secure Enclave (CryptoKit `SecureEnclave.P256`, P-256 only) — https://developer.apple.com/documentation/cryptokit/secureenclave/p256 — (docs, high) +13. keychain-services.rs (experimental macOS Keychain/SEP Rust bindings, Touch ID) — https://github.com/iqlusioninc/keychain-services.rs — (github, high) +14. RIP-7212 secp256r1 precompile spec (Final; `0x100`, 3450 gas) — https://github.com/ethereum/RIPs/blob/master/RIPS/rip-7212.md — (spec, high) +15. EIP-7951 secp256r1 mainnet precompile (`0x100`, 6900 gas, two security fixes) — https://eips.ethereum.org/EIPS/eip-7951 — (spec, high) +16. EF Blog — Fusaka Mainnet Announcement (mainnet Dec 3 2025, includes EIP-7951) — https://blog.ethereum.org/2025/11/06/fusaka-mainnet-announcement — (blog, high) +17. Zealynx — EIP-7702 wallet security (auditor view; SetCode 0x04, delegation phishing) — https://www.zealynx.io/research/smart-contracts/eip-7702-wallet-security — (blog, medium) +18. CertiK — Pectra EIP-7702 trust assumptions — https://www.certik.com/blog/pectras-eip-7702-redefining-trust-assumptions-of-externally-owned-accounts — (blog, medium) +19. Turnkey — Non-custodial key management (AWS Nitro enclaves, in-enclave policy) — https://docs.turnkey.com/security/non-custodial-key-mgmt — (docs, high) +20. Privy — Wallet security architecture (AWS Nitro TEE + Shamir Secret Sharing) — https://docs.privy.io/security/wallet-infrastructure/architecture — (docs, high) +21. Web3Auth Full MPC / tKey architecture (2/3 SSS + TSS) — https://hackmd.io/@torus/Hyv8HjO8i — (docs, medium) +22. Lit Protocol — 60 Days of Autonomous Signing (DKG + threshold TSS, PKPs) — https://spark.litprotocol.com/60-days-of-autonomous-signing/ — (blog, medium) +23. ERC-7730 Structured Data Clear Signing Format (Draft, Feb 2024, Ledger-led) — https://eips.ethereum.org/EIPS/eip-7730 — (spec, high) +24. Ledger — ERC-7730 v2 & the evolution of clear signing — https://www.ledger.com/blog-the-evolution-of-clear-signing — (blog, medium) +25. MetaMask browser-passworder source (OLD_DERIVATION_PARAMS=10k vs default 900k; AES-GCM) — https://github.com/MetaMask/browser-passworder/blob/main/src/index.ts — (github, high) +26. Rabby Wallet README (MetaMask-derived key management) — https://github.com/RabbyHub/Rabby/blob/develop/README.md — (github, high) +27. MetaMask browser-passworder releases (v4.2.0, Nov 13 2023; configurable KDF) — https://github.com/MetaMask/browser-passworder/releases — (github, high) +28. BIP-39 specification (PBKDF2-HMAC-SHA512, 2048 iters, salt "mnemonic"+passphrase) — https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki — (spec, high) +29. keyring-rs GitHub (cross-platform credential store; macOS = login Keychain, not SEP) — https://github.com/hwchen/keyring-rs — (github, high) +30. keyring crate docs (Secret Service backend) — https://docs.rs/keyring/latest/keyring/ — (docs, high) +31. alloy-rs/alloy (alloy-signer-local source) — https://github.com/alloy-rs/alloy — (github, high) +32. Trezor — Secure Elements in Trezor Safe devices (OPTIGA Trust M, EAL6+; TROPIC01) — https://trezor.io/learn/security-privacy/how-trezor-keeps-you-safe/secure-elements-in-trezor-safe-devices — (docs, high) +33. Ledger — Why Secure Elements matter (ST33, EAL5+/EAL6+) — https://www.ledger.com/why-secure-elements-make-a-crucial-difference-to-hardware-wallet-security — (docs, medium) +34. Trezor Suite/Firmware Sept 2025 update (EIP-712 typed-data display) — https://forum.trezor.io/t/update-trezor-suite-trezor-firmware-september-2025-update-is-here/24843 — (forum, medium) +35. ethereum/clear-signing-erc7730-registry spec (untrusted-registry model) — https://github.com/ethereum/clear-signing-erc7730-registry/blob/master/specs/erc-7730.md — (github, high) +36. Arbitrum AIP — Support RIP-7212 (ArbOS 30 deployment) — https://forum.arbitrum.foundation/t/aip-support-rip-7212-for-account-abstraction-wallets-arbos-30/23298 — (forum, high) +37. Coinbase Smart Wallet README (owners as bytes; secp256r1 passkey owners; SignatureWrapper) — https://github.com/coinbase/smart-wallet/blob/main/README.md — (github, high) +38. base-org/webauthn-sol — WebAuthn.sol (tries RIP-7212 precompile, falls back to FreshCryptoLib) — https://github.com/base-org/webauthn-sol/blob/main/src/WebAuthn.sol — (github, high) +39. EF Blog — Pectra Mainnet Announcement (mainnet May 7 2025) — https://blog.ethereum.org/2025/04/23/pectra-mainnet — (blog, high) +40. Protos — coverage of Wintermute's EIP-7702 research (delegation/crime statistics) — https://protos.com/48-of-ethereum-eip-7702-uses-linked-to-crime-says-wintermute/ — (other, medium) +41. Argent — How to recover my wallet with guardians (36-hour delay, cancelRecovery) — https://support.argent.xyz/hc/en-us/articles/360007338877-How-to-recover-my-wallet-with-guardians-onchain-complete-guide — (docs, high) +42. OpenZeppelin — Argent vulnerability report (recoveryPeriod / cancelRecovery / guardian model) — https://blog.openzeppelin.com/argent-vulnerability-report — (other, high) +43. The Block — Trezor discloses vulnerability in Safe 3 (March 2025; Ledger Donjon researchers, crypto on general MCU / voltage-glitch surface) — https://www.theblock.co/post/346018/trezor-discloses-vulnerability-safe-3-crypto-wallet-rival-ledger — (news, medium) diff --git a/docs/research/09-deckard-relevance.md b/docs/research/09-deckard-relevance.md new file mode 100644 index 0000000..88cce02 --- /dev/null +++ b/docs/research/09-deckard-relevance.md @@ -0,0 +1,200 @@ +# Cross-cutting Synthesis — the Opportunity Surface for Deckard + +> Threads that recur across files 01–08, mapped to Deckard (a native Rust/GPUI, cross-platform, +> self-custodial desktop wallet — a bare EOA today, with an LLM operator-wallet vision). +> Part of the Deckard wallet research KB. Researched 2026-06-05. + +**This is a synthesis of observations and opportunities — explicitly NOT a roadmap, timeline, or +prioritization.** It exists to make the recurring structure legible before the separate product-planning +step. Bracketed references point to the source files (e.g. `[02]` = `02-account-abstraction.md`), where +the primary citations live. + +--- + +## Thread 1 — The EOA→smart-account hinge is the master variable + +This is the single most load-bearing fact across the whole KB: **almost every advanced capability a +2026 wallet differentiates on is unreachable from a bare EOA.** Session keys, gas sponsorship, +pay-gas-in-token, atomic batching, on-chain spend limits, passkey signers, social recovery, and the +entire agentic permission stack all presuppose a smart account or a 7702-delegated EOA `[01][02][05]`. + +- **EIP-7702 is the address-preserving bridge.** It's Final, live since Pectra (May 7 2025), and lets + an existing EOA delegate to contract code *without changing its address or key* — so Deckard's + already-in-the-field accounts could gain smart-account behavior in place `[02]`. The shipped + production pattern is "**7702 + 4337 together**," with `Simple7702Account` (audited, in the canonical + eth-infinitism repo) as an off-the-shelf delegation target `[01][02]`. +- **The Rust tooling already exists** (this matters despite the "ignore language" note — it lowers the + cost of every smart-account option): alloy's `TransactionBuilder7702`, the `alloy-eip7702` crate, + `alloy_rpc_types_eth::erc4337` types, and two Rust ERC-4337 bundlers (Alchemy's **Rundler**, modular + **Silius**) that could even run in-process for a local-first app `[02]`. +- **7702 is also a live attack surface.** In its first month, the dominant on-chain 7702 activity was + malicious delegation to sweeper bytecode (Wintermute's "CrimeEnjoyor" finding — count-dominance, not + value-stolen, and point-in-time) `[02][08]`. The lesson is UX: *what a user/agent authorizes when + signing a 7702 tuple* is the security-critical moment. +- **Native protocol AA (EIP-8141)** is proposed but only "Considered for Inclusion" for the late-2026 + Hegota fork — **not shipped, not a confirmed headliner.** 4337+7702 is the only shipped path through + 2026, so it's the substrate to reason about, not native AA `[01]`. + +*Opportunity surface:* a wallet that treats 7702 delegation as a first-class, legible, reversible +operation (clear "what am I delegating to" UX, easy reset-to-EOA) addresses both the capability gap and +the dominant observed attack class at once. + +## Thread 2 — The operator-wallet blueprint has already converged + +Independent vendors arrived at the same architecture for "an LLM that drives a wallet," and Deckard's +stated vision is the same shape `[05]`: + +- **The axiom: the agent never sees the seed.** The LLM is a *scoped signer*; the key stays isolated + behind a policy gate the model cannot bypass `[05]`. +- **Dual-key model:** an operational, scoped *agent key* + a non-custodial *owner key* that retains + override (halt, withdraw, revoke). Maps onto a Rust design where a bounded, policy-gated signing path + is separate from the master seed `[05]`. +- **MCP is the integration surface.** A local stdio/HTTP daemon exposing wallet ops (`simulate`, + `sign`, `transfer`, `set spend limit`) as LLM tools is now the standard "sidecar." Coinbase Payments + MCP and the relaunched Base MCP (every write needs explicit user approval) are the safe-pattern + references; raw "private key in env var" EVM MCP servers are the anti-pattern to improve on `[05]`. +- **Splits is a directly copyable design point.** `@splits/splits-cli` is *one binary that is both a + CLI and an MCP server*; in MCP mode it **refuses flag-based secrets** so keys never enter tool-call + transcripts, and the key lives only in a `0600` config file. An agent becomes a signer by registering + its EOA and attaching it to a subaccount — never receiving a seed `[04][05]`. +- **Simulate-before-sign** is a self-contained safety primitive that works regardless of account type + (compute expected asset changes, block on deviation) — EOA-compatible today `[05][01]`. +- **The EOA tension:** on-chain-enforced scoping (ERC-4337 spend caps, ERC-7715/7710 session keys — + the latter shipped in MetaMask Apr 2026) requires a smart account. On a bare EOA the *same + scope/expiry/limit semantics* can be replicated in a **local software policy gate** between the LLM + tools and the secp256k1 key — at the cost of being software-enforced rather than chain-enforced `[01][05]`. +- **The economic/identity layer is real but frontier:** x402 (HTTP-402 stablecoin payments; now a Linux + Foundation foundation), Google AP2 "Mandates" (signed, scoped pre-authorizations), and ERC-8004 + (on-chain agent identity/reputation). ERC-20 paymasters mean an agent could transact entirely in + stablecoins it holds, never needing the user to top up ETH `[01][05]`. + +*Opportunity surface:* the **local-MCP-sidecar + simulate + scoped-policy + key-isolation** stack is +proven and shipping — and the institutional pattern (TEE + policy engine, à la Turnkey/Privy) has a +self-custodial local analog: an enforced limit layer the LLM cannot bypass, with the seed encrypted at +rest. Splits' revocable, low-blast-radius credential model (`centaur`/`iron-proxy` worldview) is the +security posture to study for an autonomous local operator `[04][05][08]`. + +## Thread 3 — Privacy is a stack of independent properties, and the neglected layer is Rust-native + +Vitalik's "maximally simple L1 privacy roadmap" has four pillars: payment privacy, address-per-app, +private reads, and network-level obfuscation `[06]`. Key structural observations: + +- **The operational/metadata layer is the most wallet-controllable and the most neglected.** Mainstream + wallets still default to IP-leaking RPC (Infura sees your IP + address). This is a gap a *native + client controls directly*, with no protocol dependency `[06]`. +- **Helios is a Rust light client built to embed in wallets** — so the "untrusted-RPC → verifiable + local-RPC" path involves no language bridge for Deckard `[06]`. +- **Privacy primitives are separable, not one toggle:** stealth addresses (ERC-5564/6538) break the + address graph; FHE confidential tokens (Zama ERC-7984, mainnet Dec 2025) hide amounts; shielded pools + (Railgun, Privacy Pools) do balance privacy. They're complementary, exposed as distinct properties + `[06]`. +- **Shielded-pool compliance models are mutually exclusive design choices** — Privacy Pools proves + *inclusion* in an allowlist; Railgun PPOI proves *non-membership* in a blocklist; Labyrinth does + threshold reveal. A wallet that let the user/agent pick per-transaction would span all three rather + than hard-coding a posture `[06]`. +- **EIP-8182** (proposed for Hegota, H2-2026) would give an *EOA-today* wallet private transfers with no + new address format and a shared anonymity set — i.e. payment privacy *without* first migrating to + smart accounts; its design contemplates ECDSA/hardware-wallet signing `[06]`. +- **Kohaku's crypto core is Rust** (the `railgun`/`railgun-ts` split) and EF contributors explicitly name + CLI/native wallets as targets — so the EF reference privacy work is consumable by a native Rust app + without the WASM/TS wrapper `[03][06]`. +- **Regulatory backdrop favors self-custodial integrators** over service operators (Tornado sanctions + vacated; the conviction risk lands on operators) — matching Deckard's non-custodial posture `[06]`. + +*Opportunity surface:* operational privacy (embedded light client, address-per-dapp, private RPC) is an +under-served, wallet-controllable layer that compounds when a non-human (LLM) is transacting across many +dapps and would otherwise leave a linkable trail. + +## Thread 4 — A public, codified scorecard already defines "good wallet" + +**Walletbeat** is the "L2BEAT of wallets": a live site (`beta.walletbeat.eth.limo`) backed by an MIT +repo whose rubric is machine-readable and self-assessable without permission `[07]`. It rates five +attribute groups — Security, Privacy, Self-sovereignty, Transparency, Ecosystem — plus a Stages maturity +ladder. WalletScrutiny complements it with reproducible-build verdicts `[07]`. + +Where a native self-custodial EOA desktop wallet structurally lands (descriptive, per the rubric `[07]`): + +- **Strong by construction:** self-sovereignty/ownership (keys generated and held on-device), + `accountUnruggability` (no provider can take over), and `transparency.openSource` — Deckard's **0BSD + license clears the FOSS bar**. Planned BIP-39/32/44 exportable seed backup maps onto `accountPortability`. + OS-CSPRNG key generation is likely already a PASS on RNG. +- **`PARTIAL`, not `PASS`, on storage:** an Argon2id + XChaCha20-Poly1305 keystore reads as + "standardized-KDF-encrypted / OS-sandboxed" = `PARTIAL`; a `PASS` needs a hardware/secure-enclave path. + The v0 plaintext key sits at the `PARTIAL`/`FAIL` boundary. +- **FAIL/unrated until external milestones land** (independent of code quality): security audits, a + funded bug bounty, default-private RPC (`l1ProviderIndependence` wants user-set self-hosted RPC before + first request), privacy non-correlation, hardware support, and Ecosystem items (account abstraction, + batching, ENS, WalletConnect/EIP-6963). Note `accountRecovery` credits only **3+-guardian social + recovery** — seed backup alone does not score there. + +*Opportunity surface:* the rubric is a ready-made, permissionless checklist. The operator-wallet vision +intersects directly with `accountUnruggability`/`securityBestPractices` — a *local* LLM agent keeps keys +on-device and aligns with the rubric, whereas any cloud component able to move funds without on-device +key control would jeopardize those ratings. + +## Thread 5 — The security floor is non-negotiable, the ceiling is smart-account-gated + +- **v0's plaintext-hex key on disk is below the universal field floor** — no mainstream wallet stores + cleartext keys; even Foundry/`cast` encrypts. The locked Argon2id + XChaCha20-Poly1305 envelope is the + single highest-value security change and is *cryptographically ahead* of the Web3 Secret Storage v3 + standard (AES-128-CTR + PBKDF2/scrypt + keccak MAC) `[08]`. +- **But "ahead" means non-interoperable.** BIP-39 mnemonic backup is the genuine cross-wallet recovery + layer; an optional `eth-keystore` (scrypt + AES-128-CTR) export is the portability escape hatch `[08]`. +- **The Rust primitives are mature and audited:** `k256`, `chacha20poly1305`, `argon2`, `zeroize`, + `eth-keystore` — the envelope is buildable from audited pure-Rust crates `[08]`. +- **Secure Enclave is secp256r1-only** — it can gate the keystore *unlock secret* via Touch ID today, + but **cannot hold the Ethereum secp256k1 key itself.** Full enclave/passkey *signing* needs a smart + account (P-256 on-chain via RIP-7212 on L2s, EIP-7951 on mainnet since Fusaka, Dec 3 2025) `[08][01]`. +- **Hardware wallets (Ledger/Trezor)** are the strongest off-the-shelf single-key-risk reduction + available to a desktop EOA, independent of any smart-account work `[08]`. +- **Clear-signing (EIP-712 + ERC-7730)** gives machine-readable transaction intent — the natural source + for an LLM (or user) to understand *what a signature does* before approving. The registry is now + EF-governed but coverage is partial `[01][08]`. + +## What's reusable in Rust today (consolidated) + +A cross-cutting note because so much of the relevant stack is already Rust — it lowers the cost of +several options above. (Maturity varies; see source files.) + +| Capability | Rust artifact | File | +|---|---|---| +| EOA signing / keys | alloy (`alloy-signer-local`, `k256`) — already in Deckard | `[02][08]` | +| Encrypted keystore | `argon2`, `chacha20poly1305`, `zeroize` (custom envelope); `eth-keystore` (interop export) | `[08]` | +| EIP-7702 | alloy `TransactionBuilder7702`, `alloy-eip7702` crate, alloy 7702 signing (PR #2499) | `[02]` | +| ERC-4337 | `alloy_rpc_types_eth::erc4337` types; bundlers Rundler (Alchemy) & Silius | `[02]` | +| Light client / private reads | Helios (a16z) — embeddable Rust light client | `[06]` | +| Shielded pools | Kohaku's `railgun` crate (pure-Rust core, alpha) — consumable without the WASM/TS layer | `[03][06]` | + +## The white space (observational) + +Across every file, one gap recurs: **no shipping consumer wallet offers safe, scoped, revocable +end-to-end LLM-operator control as a product.** It exists today only as infra-provider plumbing +(Turnkey, Coinbase Agentic Wallets, Splits' CLI/MCP) plus MetaMask's just-launched Advanced Permissions +`[01][04][05]`. A **native, local-first, self-custodial desktop operator wallet in Rust** sits in an +under-occupied niche — and the adjacent neglected layer (operational/RPC privacy) is also +wallet-controllable and Rust-native. Whether and how to occupy that niche is a product-planning question, +not a research conclusion. + +## Consolidated open questions + +The sharpest unresolved items pulled across files (full lists in each file's "Open questions"): + +- **EOA vs smart account:** Is a smart-account/7702 layer worth adopting *purely* to gain chain-enforced + spend caps and ERC-7715 session keys, or do local software-enforced limits suffice for an EOA operator, + and under what threat model? `[01][02][05]` +- **Policy boundary:** What does a defensible policy engine for an LLM signer look like in a local-first + app with no TEE — separate signing process, OS sandbox, future enclave dependency — and how much can be + enforced on-chain (permissions) vs locally? `[05][08]` +- **Autonomy fencing:** How to configure the "fenced area" (per-tx approval vs daily budgets vs + allowlists) without collapsing agent speed to human speed? `[05]` +- **MCP transport:** stdio vs local HTTP for a native GPUI app, and how to process-isolate the policy gate + from the model context? `[05]` +- **Hardware-backed signer path:** OS keystore + Touch ID for *unlock* vs an on-chain P256/passkey signer + that *requires a smart account* — these are distinct concerns. `[01][08]` +- **Privacy posture:** which compliance model (allowlist/blocklist/threshold), shielded-by-default vs + opt-in, and the desktop UX/perf cost of an embedded Helios light client vs privacy-respecting hosted RPC? + Will EIP-8182 make the Hegota cut with an EOA-compatible ECDSA path? `[06]` +- **Real adoption signal:** primary-sourced 7702-delegation and ERC-5792/7715 adoption curves (vs + vendor/WalletConnect-routed samples); x402's daily-volume trajectory after its early-2026 decline. `[01][02][05]` +- **Kohaku as a dependency:** are its Rust crates consumable standalone with a stable API and clear + license, given it's an EF GitHub-org project with no formal product launch? `[03][06]` diff --git a/docs/research/README.md b/docs/research/README.md new file mode 100644 index 0000000..0396985 --- /dev/null +++ b/docs/research/README.md @@ -0,0 +1,77 @@ +# Deckard Wallet Research Knowledge Base + +> The 2026 state of the art for crypto wallets — capabilities, account abstraction, the EF's +> Kohaku, Splits' agentic/smart-account model, AI-driven "operator" wallets, privacy, the +> "L2BEAT-for-wallets" scorecard, and key-management security — assembled as a reference for +> building **Deckard** (a native Rust/GPUI, cross-platform, self-custodial desktop wallet with an +> LLM operator-wallet vision). Researched **2026-06-05**. + +This is a **research knowledge base, not a product plan.** Every file ends with a neutral +"What this means for Deckard" section of *observations and opportunities only* — no sequencing, +priorities, or roadmap. Product planning is a deliberate next step done against this material. + +## How this was built + +Each file was produced by an independent three-stage pipeline: **deep research** (many web + +GitHub-repo searches, reading primary pages) → **adversarial verification** (a skeptic re-checked +every load-bearing claim against a primary source, defaulting to "unverifiable" when it couldn't be +confirmed) → **write** (only confirmed/partial claims asserted; refuted or unconfirmable ones either +dropped or flagged inline with `⚠ unverified`). Verifier corrections are baked in throughout +(e.g. EntryPoint version attribution, MetaMask's PBKDF2 iteration count, the Wintermute 7702 stat's +framing, AgentKit's real version, x402's daily-volume decline). + +## Source-reliability legend + +Sources are tagged `(kind, reliability)`. Prefer **high** when acting on a claim. + +- **high** — official docs, GitHub repos/releases/source, EIP/ERC/RIP specs, EF blog, primary forum + threads (ethereum-magicians / ethresear.ch), Linux Foundation / standards bodies. +- **medium** — reputable secondary deep-dives, vendor blogs making first-party claims, market-maker + research, well-sourced trade press. +- **low** — single-source aggregators or promotional posts; used only where no primary source exists, + and flagged. + +Inline `[n]` citations in each file resolve to its own numbered **Sources** section. (Files 04 and 07 +use `[n]`-style source numbering; the rest use `n.` — internally consistent within each file.) + +## The files + +| # | File | What's inside | Anchor facts (verified) | +|---|------|---------------|-------------------------| +| 01 | [`01-landscape-2026.md`](01-landscape-2026.md) | SOTA capability map: AA in practice, the standards mesh, recovery/gas/batching, embedded vs local, security baseline, agentic primitives, where most wallets still fall short | 4337+7702 *compose* (the shipped pattern); MetaMask shipped ERC-7715/7710 "Advanced Permissions" Apr 6 2026; native-AA EIP-8141 only "Considered for Inclusion" for late-2026 Hegota | +| 02 | [`02-account-abstraction.md`](02-account-abstraction.md) | The technical AA substrate + **the EOA→smart-account migration path** + Rust tooling reality | EIP-7702 Final, live in Pectra May 7 2025; EntryPoint v0.8 added native 7702 + audited Simple7702Account; alloy `TransactionBuilder7702` / `alloy-eip7702` + Rust bundlers Rundler & Silius exist | +| 03 | [`03-kohaku.md`](03-kohaku.md) | The EF wallet: it's **two repos** — a Rust+TS SDK and an Ambire-fork extension; architecture, privacy stack, roadmap | Crypto core is **Rust → WASM → TS**; `@kohaku-eth/railgun` shipped at alpha; "local-AI tx scoring" is exploratory, "native AA" is L1 advocacy | +| 04 | [`04-splits.md`](04-splits.md) | Splits' agentic, smart-account-native model: how agents become signers, the CLI/MCP surface, what's missing | Custom 4337 "Smart Vaults" (not Safe); `@splits/splits-cli` is **one binary = CLI + MCP server**; "agents as signers" shipped 2026-05-28; **no** client-exposed spend limits / session keys yet | +| 05 | [`05-agentic-wallets.md`](05-agentic-wallets.md) | **The core dimension** — AgentKit/GOAT/MCP servers, x402/AP2/ERC-8004, and the safe-signing architecture for an LLM operator | Convergent axiom: **the agent never sees the seed**; MCP is the integration surface; dual-key (scoped signer + master override); simulate-before-sign; x402 Foundation launched at the Linux Foundation Apr 2 2026 | +| 06 | [`06-privacy.md`](06-privacy.md) | Privacy as a stack: stealth addresses, shielded pools, FHE, the metadata/RPC layer, regulatory backdrop | Vitalik's 4-pillar L1 privacy roadmap; PSE rebrand + ~47-person Privacy Cluster; opposite compliance models (Privacy Pools allowlist vs Railgun PPOI blocklist); EIP-8182 protocol-native shielded pool proposed for Hegota; Helios is a Rust embeddable light client | +| 07 | [`07-wallet-rankings.md`](07-wallet-rankings.md) | The "L2BEAT for wallets" and its **codified rubric** (a ready-made checklist) | **Walletbeat** (`beta.walletbeat.eth.limo`, MIT repo) rates 5 attribute groups + a Stages ladder; WalletScrutiny does reproducible-build verdicts; L2BEAT itself does **not** rank wallets | +| 08 | [`08-security-keystores.md`](08-security-keystores.md) | Key-management field standards; validates Deckard's locked keystore; flags the v0 risk | Web3 Secret Storage v3 is the floor; v0 **plaintext key on disk is below it**; Argon2id+XChaCha20 is stronger but non-interoperable; Secure Enclave is **secp256r1-only**; EIP-7951 put P-256 on mainnet (Fusaka, Dec 3 2025) | +| 09 | [`09-deckard-relevance.md`](09-deckard-relevance.md) | **Cross-cutting synthesis** — the recurring threads across all eight files and the opportunity surface (observations only) | — | + +## How to use it + +- **Orienting / sharing context?** Read this README + the TL;DR of each file. +- **Planning a feature area?** Open the matching file; the "What this means for Deckard" and + "Open questions" sections are the seams into product work. +- **Want the big picture?** Read [`09-deckard-relevance.md`](09-deckard-relevance.md) — it threads the + themes that recur across files (the EOA→smart-account hinge, the operator-wallet blueprint, the + privacy stack, the public scorecard, the security floor) and the genuine white space. +- **Acting on a claim?** Check its `[n]` source and reliability tag first; treat `⚠ unverified` + notes as open items, not facts. + +## Recurring threads (one line each — detail in file 09) + +1. **The EOA→smart-account hinge.** Almost every advanced capability needs a smart account or a + **7702-delegated EOA**; 7702 is the address-preserving bridge, and the Rust tooling already exists. +2. **The operator-wallet blueprint has converged** — agent-never-sees-the-seed, dual-key, local MCP + sidecar, simulate-before-sign — but scoped on-chain permissions need a smart account; on a bare + EOA the same limits must live in a local policy gate. +3. **Privacy is a stack of independent properties**, and the most wallet-controllable layer + (operational/RPC/metadata) is the most neglected — and is Rust-native (Helios). +4. **A public, codified scorecard exists** (Walletbeat) — a permissionless checklist of "what a good + wallet has," with Deckard's structural strengths and externally-gated gaps both legible. +5. **The security floor is non-negotiable and Rust-served** — the locked encrypted keystore clears it; + v0's plaintext key does not. Hardware/enclave and passkeys are smart-account-gated. +6. **The white space:** no shipping consumer wallet offers safe, scoped, revocable end-to-end + LLM-operator control as a product — it exists today only as infra plumbing plus MetaMask's + just-launched permissions. diff --git a/docs/research/roadmap.md b/docs/research/roadmap.md new file mode 100644 index 0000000..7f1f7ae --- /dev/null +++ b/docs/research/roadmap.md @@ -0,0 +1,160 @@ + +# Deckard Product Roadmap — Now / Later / Never + +> Operator-first prioritization derived from the research KB in this directory (`README.md` + files +> 01–09) and **pressure-tested via `/autoplan`** (CEO · Eng · DX · codex dual voices, 2026-06-05). +> Citations `[NN]` point to the KB file that grounds the item. The `/autoplan` consensus + decision +> log are at the end of this file. + +## What Deckard is (fixed constraints) + +- A **native, cross-platform (macOS + Linux) desktop** Ethereum wallet, written in **Rust on GPUI**. +- **Self-custodial**, **local-first**. Keys live on the user's device. +- Today (v0): a single alloy-generated **secp256k1 EOA**, persisted as **plaintext hex** in the OS config dir. +- North-star: an **"operator wallet"** — an LLM that manages the wallet semi-autonomously, running + locally or wired to the user's chosen AI, **under limits it cannot exceed**. + +## Decisions applied (from the `/autoplan` review) + +The review found the v1 draft **engineering-correct but strategically inverted**: it led with a +conventional security floor and enforced the operator's limits in a *same-process software gate* that +all four voices judged unsafe to call "the agent never sees the seed." The decisions below re-spine it. + +1. **Safety boundary (①B):** the signer runs in an **isolated process** (key + policy inside it; the + AI gets a key-less client), **and** a **minimal EIP-7702 session-key** path is pulled into NOW so + limits are **chain-enforced**, not just software-checked. The honest claim becomes: *"a compromised + agent cannot exfiltrate the key or exceed its on-chain limits — only request actions the policy and + the chain permit."* +2. **Operator-first spine (②A):** the security floor is **cost-of-admission, done fast**; the operator + is the headline; target a **6-week demo**: *"the agent safely pays/swaps/monitors under revocable + limits."* +3. **Embedded Helios stays in NOW (③B):** kept as a differentiator (with the trusted-checkpoint + + visible-fallback invariants below). +4. **Operator-experience pieces promoted to NOW (④A):** STOP/override, typed refusals, native approval + surface, low-gas pre-flight, autonomy modes. +5. **Dapp connectivity allowed (⑤):** native-desktop form factor, **but** WalletConnect/dapp + connections are supported — only the *browser-extension form factor* is excluded. + +## How to read this + +Each item: **capability · KB ref · why · gate · build signal (S/M/L)**. +NOW = reachable on a 7702-capable EOA in Rust today + on the critical path to the demo. +LATER = gated on a further prerequisite. NEVER = excluded by positioning. + +--- + +## NOW + +### 0 · Ship-floor (cost of admission — do fast, then stop polishing) + +| Capability | KB | Why | Build | +|---|---|---|---| +| **Encrypted keystore** (Argon2id + XChaCha20-Poly1305) replacing the v0 plaintext key | `[08]` | v0 plaintext key is **below the universal field floor** | M | +| **BIP-39 seed backup + key export** | `[07][08]` | Real cross-wallet recovery; Walletbeat `accountPortability` = PASS | S | +| **Simulate-before-sign + clear-signing (ERC-7730)** — **fail-closed**; returns machine-readable asset deltas (for the agent) *and* the human card | `[01][08]` | EOA-compatible safety baseline; the input the operator reasons over | M | + +### 1 · Operator core (the headline / the wedge) + +| Capability | KB | Why | Build | +|---|---|---|---| +| **Process-isolated signer daemon** — holds the decrypted key + runs the policy; exposes only a `sign(intent)` RPC over an authenticated local socket; **no "sign arbitrary bytes"**; single-instance lock; audit log | `[05][08]` | The real trust boundary; the AI process never holds the key (Decision ①) | L | +| **Policy gate (inside the daemon)** — caps, allowlists, expiry, sim-on-deviation; **decodes calldata** (approvals/permits/7702 SetCode); **default-deny** unrecognized; returns **typed allow/deny/needs-approval + machine-readable reason + remediation** | `[05]` | Limits the agent can't bypass in-process; typed refusals stop agent flailing | L | +| **Minimal EIP-7702 session keys** — reversible, address-preserving delegation to an audited target (`Simple7702Account`/session-key validator); **chain-enforced** caps/expiry/allowlist; legible "what am I delegating to" UX | `[01][02]` | Makes the operator's limits **unbreakable**, not just local (Decision ①B) | L | +| **Local MCP sidecar** — key-less client of the daemon; `read`/`simulate`/`draft`/`scoped-execute` tools; **refuses flag-based secrets**; stdio or authed-localhost-HTTP with documented auth + revocation | `[04][05]` | The converged integration surface (Splits' one-binary CLI+MCP) | M | +| **Native approval surface** — desktop modal/tray showing intent + asset delta + counterparty + sim source + "why the agent wants this"; **deny / approve-once / approve-rule / pause-agent** | `[05]` | The most-seen operator interaction; Deckard's native edge over browser MCP (Decision ④) | M | +| **Owner-key override / STOP / revoke-all-agent-authority** | `[05]` | The operator panic button (Decision ④) | S | +| **Autonomy modes** — observe-only / human-confirm / local-autonomous (within limits) / smart-account-autonomous (7702-enforced); the risk boundary is **visible** | `[05]` | Resolves the agent-speed vs human-approval tension; sets honest expectations (Decision ④) | S | +| **Low-gas / insufficient-funds pre-flight** — structured refusal + funding affordance | `[01]` | A bare EOA needs ETH per tx; prevents silent mid-task stalls (Decision ④) | S | +| **🎯 First-autonomous-action demo (6 weeks)** — "monitor balance + pay an allowlisted address ≤ $X/day, simulated-then-approved, under revocable limits"; an **operator quickstart golden path** (testnet default) | `[04][05]` | The product proof the whole NOW set exists to deliver (Decision ②) | — | + +### 2 · Operational privacy + signing hardening + +| Capability | KB | Why | Build | +|---|---|---|---| +| **Private/proxied RPC by default** | `[06][07]` | Cheap, immediate; Walletbeat `l1ProviderIndependence`; stops IP+address leak | S–M | +| **Embedded Helios light client** (trust-minimized reads) — *kept in NOW per Decision ③* | `[06]` | Verifiable local reads, Rust-native; **invariants:** trusted-checkpoint policy + **visible** fallback when unsynced + prototype-and-measure sync cost | M–L | +| **Hardware-wallet signing (Ledger/Trezor)** — *separate from Touch ID*; protects the **user-driven** path (mutually exclusive with unattended agent signing) | `[08]` | Strongest single-key-risk reduction for the human path | M | +| **Touch ID** gates the keystore **unlock secret** (cold state only — orthogonal to per-tx operator auth) | `[08]` | At-rest protection; not per-action agent gating | S | + +## LATER (gated) + +| Capability | KB | Gate | +|---|---|---| +| **Full smart-account substrate** (7579 Kernel/Nexus/Safe) + full ERC-7715/7710 beyond minimal 7702 | `[01][05]` | Beyond the NOW minimal-7702 path; cross-chain module portability unsolved | +| **Gas abstraction** — paymasters, sponsored gas, pay-gas-in-token | `[01][02]` | Smart-account/paymaster infra; removes the gas-babysitting trap | +| **x402 payments** — *note: EOA-reachable today, gated only on prioritization* (unbundled from AP2/8004) | `[05]` | Product priority — a cheap early way for the operator to pay for data/compute | +| **AP2 Mandates / ERC-8004 identity** | `[05]` | Frontier; identity only when transacting with other agents/services | +| **On-chain passkey signer** (RIP-7212 / EIP-7951) | `[08]` | Smart-account-only | +| **Privacy upgrades** — stealth addresses; shielded pools (Railgun via Kohaku's Rust crate); EIP-8182 if it makes Hegota (EOA-compatible) | `[06]` | Kohaku Rust-crate consumability; EIP-8182 fork inclusion | +| **Splits integration** — register as a signer; call distribution contracts | `[04]` | API token + ERC-1271/UserOp signing | +| **Social recovery / guardians** | `[07][08]` | Smart-account-only; Walletbeat `accountRecovery` | +| **Independent audit + funded bug bounty** | `[07]` | Funding (a NOW threat-model review precedes MCP signing — see invariants) | + +## NEVER (not by positioning — revisit only if positioning changes) + +| Excluded | KB | Why | +|---|---|---| +| Custodial / WaaS / **MPC-as-a-service** custody | `[05][08]` | Breaks self-custody | +| Operating a **hosted relayer/bundler/treasury/fiat** service | `[04][05]` | Breaks local-first; *calling/renting* is fine | +| **Browser-extension form factor** (the *form*, not connectivity — see below) | `[03][07]` | Deckard is native desktop | +| Any **cloud component that can move funds** without on-device key control | `[07]` | Breaks Walletbeat `accountUnruggability` | +| The agent obtaining **unbounded signing authority** (or the raw seed) | `[05]` | The corrected safety axiom — bounded, revocable authority only | + +> **Allowed (Decision ⑤):** dapp connectivity via **WalletConnect / companion surfaces**. Native-desktop +> is the form factor; web/dapp interaction is not banned (a wallet that can't touch apps loses on utility). + +## Build invariants (non-negotiable — applied from the Eng review, not optional) + +- **Atomic keystore writes** (temp + fsync + rename; never overwrite in place; never `let _ = fs::write`); **versioned self-describing header** (KDF/params/AEAD/nonce); **decrypt-after-encrypt round-trip verify** before deleting plaintext; `Zeroizing` on every decrypted buffer incl. error paths. +- **v0 migration hazard:** the v0 key is `PrivateKeySigner::random()` with **no mnemonic** — migration must encrypt-in-place, tell the user this key has no seed phrase, and offer a fresh BIP-39 wallet. Never fake a mnemonic. +- **Signer daemon:** authenticated caller (peer-cred/token), single-instance lock, replay protection, no raw-byte signing, append-only audit log. +- **Simulation = risk signal, not authorization:** fail-closed; treat the third-party simulator as untrusted + a privacy leak; re-check invariants close to broadcast; ERC-7730 descriptors from an untrusted registry → verify provenance, raw-hash fallback. +- **NOW threat-model / security-design review** before MCP signing ships (full audit reserved for funded release). +- **Tests:** key round-trip; migration crash-injection; policy-gate calldata-decode (approval/permit/7702); sidecar redaction; fail-closed simulation. + +## Sequencing (rationale, not a fixed timeline) + +`encrypted keystore → signer daemon (process boundary) → policy gate (inside daemon) + minimal 7702 +session keys → MCP sidecar (key-less client) → simulate (feeds the gate) → native approval surface + +STOP/override + autonomy modes → 🎯 demo`. Private RPC and the Helios/HW-wallet hardening run in +parallel. The floor (keystore/BIP-39/simulate) is done fast and quietly; the operator core is the loud, +demoable spine. + +--- + +## /autoplan Review Report + +**Scope reviewed:** this roadmap. **Voices:** Claude subagents (CEO/Eng/DX, independent) + codex (gpt-5.5, +cross-model). **Design phase:** skipped (no UI scope). **Date:** 2026-06-05. + +### Consensus — the unanimous finding + +All four voices independently flagged the same **critical** issue: a **same-process software policy gate +on a hot EOA key is not a security boundary**. "The agent never sees the seed" was true only literally — +the agent/tool layer could obtain *unbounded signing authority*. Codex's "the one thing this most gets +wrong": *"it treats 'agent never sees the seed' as the safety boundary, when the real boundary is whether +the agent/tool layer can obtain an unbounded signing capability over the EOA."* → **resolved by Decision ①B.** + +### Dual-voice verdicts (pre-revision) + +| Voice | Headline | Verdict | +|---|---|---| +| CEO (Claude) | Engineering-correct but strategically inverted; defers the moat | NO ×4 / PARTIAL premises | +| Eng (Claude) | "Agent never sees the seed" false as architected; isolate the signer; missing error paths + tests | 1 YES / 3 NO / 2 PARTIAL | +| DX (Claude) | Sound prioritization, incomplete operator-experience spec | NO/PARTIAL ×5 | +| Codex (gpt-5.5) | Real boundary is unbounded-signing-capability; pull 7702 forward; define first action | critical ×4 | + +### Decision log + +| # | Decision | Choice | Principle / source | +|---|----------|--------|--------------------| +| ① | Operator safety boundary | **B** — isolated signer daemon NOW + minimal EIP-7702 chain-enforced limits NOW | User Challenge (all 4 voices); user-confirmed | +| ② | Roadmap spine | **A** — operator-first; floor as cost-of-admission; 6-week demo target | CEO+DX+codex; user-confirmed | +| ③ | Embedded Helios | **B** — keep in NOW (with trusted-checkpoint + visible-fallback invariants) | user override of the demote recommendation | +| ④ | Operator-experience pieces | **A** — promote all (STOP/override, typed refusals, native approval, gas pre-flight, autonomy modes) | DX+Eng+codex; user-confirmed | +| ⑤ | Dapp connectivity | **Allow** WalletConnect/dapp connections; native-desktop form only | codex; applied by default | +| — | Eng build invariants | **Applied** as non-negotiable requirements (atomic writes, fail-closed sim, migration hazard, tests, NOW threat-model) | Eng review | +| — | x402 | Noted **EOA-reachable**, gated only on prioritization (unbundled from AP2/8004) | Eng review | + +**Status: APPROVED with revisions applied.** Next step when you're ready to build: `/spec` the first NOW +item (the **process-isolated signer daemon**, the critical-path dependency), or `/ship` once changes land. diff --git a/docs/research/v1-demo-plan.md b/docs/research/v1-demo-plan.md new file mode 100644 index 0000000..d7ca287 --- /dev/null +++ b/docs/research/v1-demo-plan.md @@ -0,0 +1,88 @@ +# Deckard v1 — Demo-Driven Build Plan + +> One goal: **ship a reliable, exciting 90-second video that pitches the EF CROPS direction + community.** +> Everything here serves it. Supersedes the Now/Later framing in `roadmap.md` for build purposes. +> Settled via 3 rounds of requirements Q&A, 2026-06-05. + +## The spirit + +A native, **open-source, self-custodial** wallet where an AI handles your money **privately** and you +**can't be switched off**. CROPS in one product: **P**rivacy (shielded), **S**elf-sovereign / **S**ecurity +(local keys, bounded agent), **C**ensorship- & capture-**R**esistance + the **walkaway test** (Helios — no +Infura), **O**pen-source (0BSD). MPP/x402 are deliberately *not* here — they come later as **plugins**. + +## The video (the only spec that matters) + +**Scene: "receive → instantly private → can't be switched off."** ~90s, one continuous mainnet recording: + +1. **Real, self-custodial wallet.** Deckard opens — native, fast, real mainnet balance; on-screen: *keys never leave this device, open-source.* *(0:10)* +2. **🎯 Live receive → instant auto-shield (HERO).** A payment lands in the wallet *live*. The agent (Claude Desktop, via Deckard's MCP sidecar) immediately shields it: public balance drops, **private balance rises, the trail is broken** — all on mainnet. *(0:30)* +3. **🎯 Walkaway test (HERO).** Cut / block the centralized RPC on camera. Deckard keeps showing **verified** balances because **Helios** checks the chain itself. "Works even if Infura — or the EF — disappears." *(0:30)* +4. **Trust close.** Quick pan: it's open-source, the key lives in an isolated signer process the AI can't reach, there's a STOP button. *(0:20)* + +Beats 2 and 3 are the must-haves (locked). STOP-on-camera and an allocate/donate slice are **fast-follow**. + +## The two risky hero beats — spike FIRST, in parallel, before committing the shot + +Both heroes rest on immature pieces. De-risk them on day one; only attempt the mainnet hero once both are green. + +- **R1 · Shield on mainnet via Kohaku's *alpha* Railgun crate.** Open question from the KB: is the crate consumable standalone from Rust with a stable API? **Spike:** shield+unshield a test amount on a fork/Sepolia from Rust. *Fallback if it's flaky:* shield on **Sepolia** for the video (keep Helios-walkaway on mainnet), or swap the shielded-pool path (Privacy Pools). +- **R2 · Helios "cut the RPC and keep working."** The walkaway beat must be *real*, not cached. **Spike:** run Helios on mainnet, verify reads, then kill the primary RPC and confirm it continues from a second source / light-client peer. *Fallback:* if continuation is hard, the beat becomes "Helios *verified locally* (no trusted server)" with a visible proof, minus the live cut. + +## Deliverables, ranked by demo impact + +| # | Deliverable | Done when (concrete, testable) | Proven by (agent/automated) | Beat | Track | Size | +|---|---|---|---|---|---|---| +| 1 | **Shield-on-receive (Railgun via Kohaku Rust crate)** | a received deposit is shielded into an owner-only private balance; public trail broken | fork/Sepolia: deposit→shield→assert private balance up, public down, link broken; mainnet rehearsal | 2 | T-Privacy | L ⚠R1 | +| 2 | **Embedded Helios + walkaway** | balances/state verified via Helios vs an untrusted RPC; cutting the primary RPC keeps verified reads working | integration: verify reads; kill RPC→assert continued verified reads (or graceful proof) | 3 | T-Trustless | L ⚠R2 | +| 3 | **Receive watcher** | wallet detects an inbound tx within seconds (via Helios-verified logs) and fires the agent | send→assert event < N s, sourced from verified logs | 2 | T-Core | S–M | +| 4 | **Process-isolated signer daemon + STOP/revoke** | key in a separate process; `propose`/`execute` only, no raw-bytes; STOP revokes agent authority | red-team script: agent process can't read key / raw-sign; STOP→next execute denied | 4 | T-Custody | M–L | +| 5 | **Encrypted keystore + unlock** (Argon2id+XChaCha20, atomic write) | no plaintext key on disk; passphrase unlock; survives crash mid-write | round-trip; kill-during-write→key intact; `grep` disk→no plaintext | 1 | T-Custody | M | +| 6 | **MCP sidecar** (key-less client of the daemon) for Claude Desktop/Cursor | external client registers + calls `balance`/`simulate`/`shield`/`execute`; secrets never in transcript | MCP test-client + Claude Desktop dry-run; assert policy enforced + no key leak | 2 | T-Agent | M | +| 7 | **Private RPC by default** | app talks to a privacy-respecting/proxied RPC (no IP+address leak to a default vendor); Helios on top | assert no address-bearing calls to a default centralized vendor | 1,3 | T-Trustless | S–M | +| 8 | **Mainnet balances + shield-deposit tx** (alloy) | shows ETH/ERC-20; constructs + sends the shield deposit on mainnet | fork/mainnet rehearsal: send, confirm receipt | 2 | T-Core | S–M | +| 9 | **Native "what just happened" surface** (GPUI) | shows live receive → shielding → private (before/after balances, trail broken) + a "verified by Helios — no Infura" indicator | UI test: states render; indicator reflects Helios status | 2,3 | T-UX | M | + +## Parallel tracks (freeze one contract, then go wide) + +**Freeze first (½ day):** the MCP tool surface + signer-daemon `Intent`/`Decision` API + the `shield(amount)` intent shape. Everyone codes against it. + +- **T-Privacy** (#1) — *start immediately, it's R1.* Independent: needs only an EOA + fork/mainnet. +- **T-Trustless** (#2, #7) — *start immediately, it's R2.* Independent. +- **T-Custody** (#5 → #4) — keystore then the daemon (the integration point). +- **T-Agent** (#6) — mocks the daemon via the frozen contract; integrates when #4 lands. +- **T-Core** (#3, #8) — receive watcher + send; starts on plain RPC, swaps to Helios-verified. +- **T-UX** (#9) — builds against mocked states; this is what the camera sees. + +T-Privacy and T-Trustless are both the **riskiest** and the **two hero beats** → they run first and in parallel; the rest can't make the video matter if those two don't land. + +## Acceptance test = the shot list (one agent-runnable scenario) + +Both the **CI gate** and the **storyboard**. If it passes on mainnet (or Sepolia per the fallback), shoot it. + +``` +Scenario "Shield-on-Receive, Trustless" (mainnet; Sepolia fallback for the shield): + setup: encrypted wallet unlocked; Helios synced over private RPC; MCP sidecar registered + in Claude Desktop; agent policy = "auto-shield inbound ETH above X". + 1. send a deposit to the wallet (live) assert: receive watcher fires < N s, from Helios-verified logs + 2. agent (Claude via MCP) calls shield(amount) assert: private balance ↑, public ↓, link broken; tx confirms + 3. cut the primary RPC assert: Deckard still shows VERIFIED balances via Helios (no crash) + --- fast-follow asserts --- + 4. STOP / revoke assert: agent's next execute is denied + 5. allocate/donate a slice assert: rule honored +``` + +Steps 1–3 are the video. A coding agent runs this headless; the same run + GUI + screen recorder = the cut. + +## Reliability plan (it cannot faceplant in front of EF) + +Spike R1+R2 on Sepolia/fork → go mainnet only when both green → pre-fund the wallet, pre-sync Helios, do +multiple takes. Shield falls back to Sepolia if the alpha crate misbehaves on mainnet; the Helios walkaway +stays on mainnet regardless. **Backup driver:** if Claude Desktop (external MCP) flakes on stage, an in-app +agent loop can drive the same MCP tools — build the sidecar so either can call it. + +## Fast-follow (right after the video — not in v1) + +STOP-on-camera beat · allocate/donate slice · **7702 session keys** (with the plugin wave) · +**x402 / MPP as wallet plugins** (+ the plugin architecture that hosts them) · stealth addresses · +hardware-wallet signing · paid audit/bug-bounty. (`roadmap.md` holds the full Later/Never frame.) From 5e3a16ddb91b2fc0da66ba5d08fcddea08cee015 Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 6 Jun 2026 00:12:53 +0200 Subject: [PATCH 03/12] 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 04/12] 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 05/12] =?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 06/12] =?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 07/12] 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 08/12] =?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 09/12] 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 10/12] 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 11/12] =?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 12/12] 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`]