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/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.) 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