diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6259b5f..26e34d9 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,15 @@ 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
+ # Foundry gives the integration tests a local `anvil` to broadcast against
+ # (deckard-signerd's anvil-fork lane). The daemon tests skip gracefully if it's absent,
+ # but CI installs it so the broadcast path is actually exercised.
+ - uses: foundry-rs/foundry-toolchain@v1
+ - run: cargo build --workspace
+ - run: cargo build -p deckard-app --features tray
+ - run: cargo test --workspace
+ - 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 +62,10 @@ 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
+ # Foundry (anvil) for the deckard-signerd broadcast integration tests.
+ - uses: foundry-rs/foundry-toolchain@v1
+ - run: cargo build --workspace
+ - run: cargo build -p deckard-app --features tray
+ - run: cargo test --workspace
+ - 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..8811d46 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -97,7 +97,7 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
- "gimli",
+ "gimli 0.32.3",
]
[[package]]
@@ -113,7 +113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
- "generic-array",
+ "generic-array 0.14.7",
]
[[package]]
@@ -128,6 +128,33 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -155,6 +182,21 @@ dependencies = [
"equator",
]
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -173,6 +215,7 @@ dependencies = [
"alloy-eips 1.8.3",
"alloy-ens",
"alloy-genesis",
+ "alloy-json-rpc 1.8.3",
"alloy-network 1.8.3",
"alloy-provider",
"alloy-rpc-client",
@@ -193,7 +236,7 @@ checksum = "84e0378e959aa6a885897522080a990e80eb317f1e9a222a604492ea50e13096"
dependencies = [
"alloy-primitives",
"num_enum",
- "strum",
+ "strum 0.27.2",
]
[[package]]
@@ -211,7 +254,7 @@ dependencies = [
"auto_impl",
"borsh",
"c-kzg",
- "derive_more",
+ "derive_more 2.1.1",
"either",
"k256",
"once_cell",
@@ -238,7 +281,7 @@ dependencies = [
"auto_impl",
"borsh",
"c-kzg",
- "derive_more",
+ "derive_more 2.1.1",
"either",
"k256",
"once_cell",
@@ -364,6 +407,7 @@ dependencies = [
"alloy-primitives",
"alloy-rlp",
"borsh",
+ "k256",
"serde",
"thiserror 2.0.18",
]
@@ -398,11 +442,13 @@ dependencies = [
"auto_impl",
"borsh",
"c-kzg",
- "derive_more",
+ "derive_more 2.1.1",
"either",
+ "ethereum_ssz",
+ "ethereum_ssz_derive",
"serde",
"serde_with",
- "sha2",
+ "sha2 0.10.9",
]
[[package]]
@@ -421,11 +467,11 @@ dependencies = [
"auto_impl",
"borsh",
"c-kzg",
- "derive_more",
+ "derive_more 2.1.1",
"either",
"serde",
"serde_with",
- "sha2",
+ "sha2 0.10.9",
]
[[package]]
@@ -477,7 +523,7 @@ checksum = "422d110f1c40f1f8d0e5562b0b649c35f345fccb7093d9f02729943dcd1eef71"
dependencies = [
"alloy-primitives",
"alloy-sol-types",
- "http",
+ "http 1.4.1",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -492,7 +538,7 @@ checksum = "ec0a82e56b1843bce483942d54fcadea92e676f1bde162e93c7d3b621fabc4e1"
dependencies = [
"alloy-primitives",
"alloy-sol-types",
- "http",
+ "http 1.4.1",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -518,7 +564,7 @@ dependencies = [
"alloy-sol-types",
"async-trait",
"auto_impl",
- "derive_more",
+ "derive_more 2.1.1",
"futures-utils-wasm",
"serde",
"serde_json",
@@ -544,7 +590,7 @@ dependencies = [
"alloy-sol-types",
"async-trait",
"auto_impl",
- "derive_more",
+ "derive_more 2.1.1",
"futures-utils-wasm",
"serde",
"serde_json",
@@ -587,8 +633,9 @@ dependencies = [
"bytes",
"cfg-if",
"const-hex",
- "derive_more",
+ "derive_more 2.1.1",
"foldhash 0.2.0",
+ "getrandom 0.4.2",
"hashbrown 0.17.1",
"indexmap 2.14.0",
"itoa",
@@ -632,9 +679,9 @@ dependencies = [
"futures",
"futures-utils-wasm",
"lru",
- "parking_lot",
+ "parking_lot 0.12.5",
"pin-project",
- "reqwest",
+ "reqwest 0.13.4",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -678,12 +725,12 @@ dependencies = [
"alloy-transport-http",
"futures",
"pin-project",
- "reqwest",
+ "reqwest 0.13.4",
"serde",
"serde_json",
"tokio",
"tokio-stream",
- "tower",
+ "tower 0.5.3",
"tracing",
"url",
"wasmtimer",
@@ -696,6 +743,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4faad925d3a669ffc15f43b3deec7fbdf2adeb28a4d6f9cf4bc661698c0f8f4b"
dependencies = [
"alloy-primitives",
+ "alloy-rpc-types-beacon",
+ "alloy-rpc-types-engine",
"alloy-rpc-types-eth 1.8.3",
"alloy-serde 1.8.3",
"serde",
@@ -727,6 +776,45 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "alloy-rpc-types-beacon"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f526dbd7bb039327cfd0ccf18c8a29ffd7402616b0c7a0239512bf8417d544c7"
+dependencies = [
+ "alloy-eips 1.8.3",
+ "alloy-primitives",
+ "alloy-rpc-types-engine",
+ "derive_more 2.1.1",
+ "ethereum_ssz",
+ "ethereum_ssz_derive",
+ "serde",
+ "serde_json",
+ "serde_with",
+ "thiserror 2.0.18",
+ "tree_hash",
+ "tree_hash_derive",
+]
+
+[[package]]
+name = "alloy-rpc-types-engine"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb9b97b6e7965679ad22df297dda809b11cebc13405c1b537e5cffecc95834fa"
+dependencies = [
+ "alloy-consensus 1.8.3",
+ "alloy-eips 1.8.3",
+ "alloy-primitives",
+ "alloy-rlp",
+ "alloy-serde 1.8.3",
+ "derive_more 2.1.1",
+ "ethereum_ssz",
+ "ethereum_ssz_derive",
+ "rand 0.8.6",
+ "serde",
+ "strum 0.27.2",
+]
+
[[package]]
name = "alloy-rpc-types-eth"
version = "1.8.3"
@@ -741,7 +829,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 +850,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",
@@ -938,15 +1026,15 @@ dependencies = [
"alloy-json-rpc 1.8.3",
"auto_impl",
"base64 0.22.1",
- "derive_more",
+ "derive_more 2.1.1",
"futures",
"futures-utils-wasm",
- "parking_lot",
+ "parking_lot 0.12.5",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
- "tower",
+ "tower 0.5.3",
"tracing",
"url",
"wasmtimer",
@@ -960,10 +1048,10 @@ checksum = "e8597d36d546e1dab822345ad563243ec3920e199322cb554ce56c8ef1a1e2e7"
dependencies = [
"alloy-json-rpc 1.8.3",
"alloy-transport",
- "itertools 0.13.0",
- "reqwest",
+ "itertools 0.14.0",
+ "reqwest 0.13.4",
"serde_json",
- "tower",
+ "tower 0.5.3",
"tracing",
"url",
]
@@ -976,7 +1064,7 @@ checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb"
dependencies = [
"alloy-primitives",
"alloy-rlp",
- "derive_more",
+ "derive_more 2.1.1",
"nybbles",
"serde",
"smallvec",
@@ -990,7 +1078,7 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d69722eddcdf1ce096c3ab66cf8116999363f734eb36fe94a148f4f71c85da84"
dependencies = [
- "darling",
+ "darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -1002,7 +1090,7 @@ version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a0035943b75fe1e249f52e688492d7a1b1826bc2d19b8e1d5d3c24a2ad8f50"
dependencies = [
- "darling",
+ "darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -1029,7 +1117,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
dependencies = [
- "object",
+ "object 0.37.3",
]
[[package]]
@@ -1070,6 +1158,149 @@ dependencies = [
"password-hash",
]
+[[package]]
+name = "ark-bls12-381"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5"
+dependencies = [
+ "ark-ec 0.5.0",
+ "ark-ff 0.5.0",
+ "ark-serialize 0.5.0",
+ "ark-std 0.5.0",
+]
+
+[[package]]
+name = "ark-bn254"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc"
+dependencies = [
+ "ark-ec 0.5.0",
+ "ark-ff 0.5.0",
+ "ark-r1cs-std 0.5.0",
+ "ark-std 0.5.0",
+]
+
+[[package]]
+name = "ark-bn254"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bc66f96ebe2a17a499475b4f94791d379817592ef494171586967ffdc6f95db"
+dependencies = [
+ "ark-ec 0.6.0",
+ "ark-ff 0.6.0",
+ "ark-std 0.6.0",
+]
+
+[[package]]
+name = "ark-circom"
+version = "0.6.0"
+source = "git+https://github.com/Robert-MacWha/circom-compat?branch=release%2F0.6.0#5c6abcdb0e788364e885e4206904288222e8f447"
+dependencies = [
+ "ark-bn254 0.6.0",
+ "ark-crypto-primitives",
+ "ark-ec 0.6.0",
+ "ark-ff 0.6.0",
+ "ark-groth16",
+ "ark-poly 0.6.0",
+ "ark-relations 0.6.0",
+ "ark-serialize 0.6.0",
+ "ark-std 0.6.0",
+ "byteorder",
+ "cfg-if",
+ "color-eyre",
+ "ethers-core",
+ "fnv",
+ "hex",
+ "num",
+ "num-bigint",
+ "num-traits",
+ "rayon",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "wasmer",
+]
+
+[[package]]
+name = "ark-crypto-primitives"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b3409b1846fe459d19c95df039481575ac6d5842ae63858ad75cc31219bfc1"
+dependencies = [
+ "ahash",
+ "ark-crypto-primitives-macros",
+ "ark-ec 0.6.0",
+ "ark-ff 0.6.0",
+ "ark-r1cs-std 0.6.0",
+ "ark-relations 0.6.0",
+ "ark-serialize 0.6.0",
+ "ark-snark",
+ "ark-std 0.6.0",
+ "blake2",
+ "blake3",
+ "derivative",
+ "digest 0.10.7",
+ "fnv",
+ "merlin",
+ "num-bigint",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "ark-crypto-primitives-macros"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e89fe77d1f0f4fe5b96dfc940923d88d17b6a773808124f21e764dfb063c6a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "ark-ec"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce"
+dependencies = [
+ "ahash",
+ "ark-ff 0.5.0",
+ "ark-poly 0.5.0",
+ "ark-serialize 0.5.0",
+ "ark-std 0.5.0",
+ "educe",
+ "fnv",
+ "hashbrown 0.15.5",
+ "itertools 0.13.0",
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+ "zeroize",
+]
+
+[[package]]
+name = "ark-ec"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8352a2b2aedf6ba2cc38f7520fc51191d518dde96175c729af19f2d059f191c4"
+dependencies = [
+ "ahash",
+ "ark-ff 0.6.0",
+ "ark-poly 0.6.0",
+ "ark-serialize 0.6.0",
+ "ark-std 0.6.0",
+ "educe",
+ "fnv",
+ "hashbrown 0.17.1",
+ "itertools 0.14.0",
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+ "zeroize",
+]
+
[[package]]
name = "ark-ff"
version = "0.3.0"
@@ -1128,6 +1359,23 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "ark-ff"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af"
+dependencies = [
+ "ark-ff-asm 0.6.0",
+ "ark-ff-macros 0.6.0",
+ "ark-serialize 0.6.0",
+ "ark-std 0.6.0",
+ "digest 0.10.7",
+ "educe",
+ "num-bigint",
+ "num-traits",
+ "zeroize",
+]
+
[[package]]
name = "ark-ff-asm"
version = "0.3.0"
@@ -1158,6 +1406,16 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "ark-ff-asm"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "ark-ff-macros"
version = "0.3.0"
@@ -1197,92 +1455,272 @@ dependencies = [
]
[[package]]
-name = "ark-serialize"
-version = "0.3.0"
+name = "ark-ff-macros"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671"
+checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8"
dependencies = [
- "ark-std 0.3.0",
- "digest 0.9.0",
+ "num-bigint",
+ "num-traits",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
-name = "ark-serialize"
-version = "0.4.2"
+name = "ark-groth16"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5"
+checksum = "a293328aa422e65527e285614ce5d1dceb0bd7b8b18d18b1b63191ee1f74cb41"
dependencies = [
- "ark-std 0.4.0",
- "digest 0.10.7",
- "num-bigint",
+ "ark-crypto-primitives",
+ "ark-ec 0.6.0",
+ "ark-ff 0.6.0",
+ "ark-poly 0.6.0",
+ "ark-relations 0.6.0",
+ "ark-serialize 0.6.0",
+ "ark-snark",
+ "ark-std 0.6.0",
]
[[package]]
-name = "ark-serialize"
+name = "ark-poly"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7"
+checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27"
dependencies = [
+ "ahash",
+ "ark-ff 0.5.0",
+ "ark-serialize 0.5.0",
"ark-std 0.5.0",
- "arrayvec",
- "digest 0.10.7",
- "num-bigint",
+ "educe",
+ "fnv",
+ "hashbrown 0.15.5",
]
[[package]]
-name = "ark-std"
-version = "0.3.0"
+name = "ark-poly"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c"
+checksum = "75f55af10b672002b8d953e230282c51206842e20e5791a94432219b4201de5c"
dependencies = [
- "num-traits",
- "rand 0.8.6",
+ "ahash",
+ "ark-ff 0.6.0",
+ "ark-serialize 0.6.0",
+ "ark-std 0.6.0",
+ "educe",
+ "fnv",
+ "hashbrown 0.17.1",
]
[[package]]
-name = "ark-std"
-version = "0.4.0"
+name = "ark-r1cs-std"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185"
+checksum = "941551ef1df4c7a401de7068758db6503598e6f01850bdb2cfdb614a1f9dbea1"
dependencies = [
+ "ark-ec 0.5.0",
+ "ark-ff 0.5.0",
+ "ark-relations 0.5.1",
+ "ark-std 0.5.0",
+ "educe",
+ "num-bigint",
+ "num-integer",
"num-traits",
- "rand 0.8.6",
+ "tracing",
]
[[package]]
-name = "ark-std"
-version = "0.5.0"
+name = "ark-r1cs-std"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a"
+checksum = "291f1c6628bfcac79b0dc2adbe401aa9100e2e96daa971645e0b18fc94de9a98"
dependencies = [
+ "ark-ec 0.6.0",
+ "ark-ff 0.6.0",
+ "ark-relations 0.6.0",
+ "ark-std 0.6.0",
+ "educe",
+ "itertools 0.14.0",
+ "num-bigint",
+ "num-integer",
"num-traits",
- "rand 0.8.6",
+ "tracing",
]
[[package]]
-name = "arrayref"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
-
-[[package]]
-name = "arrayvec"
-version = "0.7.6"
+name = "ark-relations"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+checksum = "ec46ddc93e7af44bcab5230937635b06fb5744464dd6a7e7b083e80ebd274384"
+dependencies = [
+ "ark-ff 0.5.0",
+ "ark-std 0.5.0",
+ "tracing",
+ "tracing-subscriber 0.2.25",
+]
[[package]]
-name = "as-slice"
-version = "0.2.1"
+name = "ark-relations"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
+checksum = "fe4c11c797a64b8a23e22bf4e77bf582ac27bb21395e3183a9a506ba2561e9f9"
dependencies = [
- "stable_deref_trait",
+ "ark-ff 0.6.0",
+ "ark-poly 0.6.0",
+ "ark-serialize 0.6.0",
+ "ark-std 0.6.0",
+ "foldhash 0.1.5",
+ "indexmap 2.14.0",
+ "tracing",
+ "tracing-subscriber 0.3.23",
]
[[package]]
-name = "ash"
-version = "0.38.0+1.3.281"
+name = "ark-serialize"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671"
+dependencies = [
+ "ark-std 0.3.0",
+ "digest 0.9.0",
+]
+
+[[package]]
+name = "ark-serialize"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5"
+dependencies = [
+ "ark-std 0.4.0",
+ "digest 0.10.7",
+ "num-bigint",
+]
+
+[[package]]
+name = "ark-serialize"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7"
+dependencies = [
+ "ark-serialize-derive 0.5.0",
+ "ark-std 0.5.0",
+ "arrayvec",
+ "digest 0.10.7",
+ "num-bigint",
+]
+
+[[package]]
+name = "ark-serialize"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b"
+dependencies = [
+ "ark-serialize-derive 0.6.0",
+ "ark-std 0.6.0",
+ "digest 0.10.7",
+ "num-bigint",
+ "serde_with",
+]
+
+[[package]]
+name = "ark-serialize-derive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "ark-serialize-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "ark-snark"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bdb461d2be9b2bd6f303c79fffc89f5858790a7b4d33257bca3178e2c071fb9"
+dependencies = [
+ "ark-ff 0.6.0",
+ "ark-relations 0.6.0",
+ "ark-serialize 0.6.0",
+ "ark-std 0.6.0",
+]
+
+[[package]]
+name = "ark-std"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c"
+dependencies = [
+ "num-traits",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "ark-std"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185"
+dependencies = [
+ "num-traits",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "ark-std"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a"
+dependencies = [
+ "num-traits",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "ark-std"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155"
+dependencies = [
+ "num-traits",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "as-slice"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [
@@ -1347,6 +1785,7 @@ dependencies = [
"compression-core",
"futures-io",
"pin-project-lite",
+ "tokio",
]
[[package]]
@@ -1369,7 +1808,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
- "async-lock",
+ "async-lock 3.4.2",
"blocking",
"futures-lite 2.6.1",
]
@@ -1383,7 +1822,7 @@ dependencies = [
"async-channel 2.5.0",
"async-executor",
"async-io",
- "async-lock",
+ "async-lock 3.4.2",
"blocking",
"futures-lite 2.6.1",
"once_cell",
@@ -1407,6 +1846,15 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "async-lock"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b"
+dependencies = [
+ "event-listener 2.5.3",
+]
+
[[package]]
name = "async-lock"
version = "3.4.2"
@@ -1437,7 +1885,7 @@ checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel 2.5.0",
"async-io",
- "async-lock",
+ "async-lock 3.4.2",
"async-signal",
"async-task",
"blocking",
@@ -1465,7 +1913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
- "async-lock",
+ "async-lock 3.4.2",
"atomic-waker",
"cfg-if",
"futures-core",
@@ -1485,14 +1933,14 @@ dependencies = [
"async-channel 1.9.0",
"async-global-executor",
"async-io",
- "async-lock",
+ "async-lock 3.4.2",
"async-process",
"crossbeam-utils",
"futures-channel",
"futures-core",
"futures-io",
"futures-lite 2.6.1",
- "gloo-timers",
+ "gloo-timers 0.3.0",
"kv-log-macro",
"log",
"memchr",
@@ -1536,7 +1984,7 @@ dependencies = [
"libc",
"pin-project",
"redox_syscall 0.2.16",
- "xattr",
+ "xattr 0.2.3",
]
[[package]]
@@ -1598,6 +2046,15 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
+[[package]]
+name = "atomic"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
+dependencies = [
+ "bytemuck",
+]
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -1641,6 +2098,16 @@ dependencies = [
"zbus",
]
+[[package]]
+name = "aurora-engine-modexp"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "518bc5745a6264b5fd7b09dffb9667e400ee9e2bbe18555fac75e1fe9afa0df9"
+dependencies = [
+ "hex",
+ "num",
+]
+
[[package]]
name = "auto_impl"
version = "1.3.0"
@@ -1723,6 +2190,12 @@ dependencies = [
"fs_extra",
]
+[[package]]
+name = "az"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7"
+
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -1733,7 +2206,7 @@ dependencies = [
"cfg-if",
"libc",
"miniz_oxide",
- "object",
+ "object 0.37.3",
"rustc-demangle",
"windows-link 0.2.1",
]
@@ -1750,6 +2223,12 @@ version = "2.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b"
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
[[package]]
name = "base64"
version = "0.21.7"
@@ -1774,6 +2253,41 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445"
+[[package]]
+name = "bech32"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
+
+[[package]]
+name = "beef"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bindgen"
+version = "0.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f"
+dependencies = [
+ "bitflags 2.12.1",
+ "cexpr",
+ "clang-sys",
+ "itertools 0.10.5",
+ "log",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash 1.1.0",
+ "shlex 1.3.0",
+ "syn 2.0.117",
+]
+
[[package]]
name = "bindgen"
version = "0.71.1"
@@ -1783,7 +2297,7 @@ dependencies = [
"bitflags 2.12.1",
"cexpr",
"clang-sys",
- "itertools 0.11.0",
+ "itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
@@ -1890,10 +2404,21 @@ checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
+ "serde",
"tap",
"wyz",
]
+[[package]]
+name = "blake-hash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d218558dbce3a929ff02b6c12a22f4866afb46484d57a2fe2ad3ed358b90b6a"
+dependencies = [
+ "block-buffer 0.7.3",
+ "digest 0.8.1",
+]
+
[[package]]
name = "blake2"
version = "0.10.6"
@@ -1903,19 +2428,54 @@ dependencies = [
"digest 0.10.7",
]
+[[package]]
+name = "blake3"
+version = "1.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures 0.3.0",
+]
+
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
+[[package]]
+name = "block-buffer"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b"
+dependencies = [
+ "block-padding 0.1.5",
+ "byte-tools",
+ "byteorder",
+ "generic-array 0.12.4",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
+dependencies = [
+ "generic-array 0.14.7",
+]
+
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
]
[[package]]
@@ -1927,13 +2487,22 @@ dependencies = [
"hybrid-array",
]
+[[package]]
+name = "block-padding"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5"
+dependencies = [
+ "byte-tools",
+]
+
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
]
[[package]]
@@ -1967,6 +2536,20 @@ dependencies = [
"piper",
]
+[[package]]
+name = "bls12_381"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7bc6d6292be3a19e6379786dac800f551e5865a5bb51ebbe3064ab80433f403"
+dependencies = [
+ "digest 0.9.0",
+ "ff",
+ "group",
+ "pairing",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
[[package]]
name = "blst"
version = "0.3.16"
@@ -2003,13 +2586,34 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "brotli"
+version = "8.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
- "sha2",
+ "sha2 0.10.9",
"tinyvec",
]
@@ -2042,7 +2646,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d"
[[package]]
-name = "bytemuck"
+name = "byte-tools"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7"
+
+[[package]]
+name = "bytecheck"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
+dependencies = [
+ "bytecheck_derive 0.6.12",
+ "ptr_meta 0.1.4",
+ "simdutf8",
+]
+
+[[package]]
+name = "bytecheck"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b"
+dependencies = [
+ "bytecheck_derive 0.8.2",
+ "ptr_meta 0.3.1",
+ "rancor",
+ "simdutf8",
+]
+
+[[package]]
+name = "bytecheck_derive"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "bytecheck_derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
@@ -2261,6 +2916,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"
@@ -2303,7 +2985,7 @@ dependencies = [
"cocoa-foundation 0.1.2",
"core-foundation 0.9.4",
"core-graphics 0.23.2",
- "foreign-types",
+ "foreign-types 0.5.0",
"libc",
"objc",
]
@@ -2319,7 +3001,7 @@ dependencies = [
"cocoa-foundation 0.2.0",
"core-foundation 0.10.0",
"core-graphics 0.24.0",
- "foreign-types",
+ "foreign-types 0.5.0",
"libc",
"objc",
]
@@ -2375,7 +3057,7 @@ dependencies = [
"hmac",
"k256",
"serde",
- "sha2",
+ "sha2 0.10.9",
"thiserror 1.0.69",
]
@@ -2391,7 +3073,7 @@ dependencies = [
"once_cell",
"pbkdf2",
"rand 0.8.6",
- "sha2",
+ "sha2 0.10.9",
"thiserror 1.0.69",
]
@@ -2402,14 +3084,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b962ad8545e43a28e14e87377812ba9ae748dd4fd963f4c10e9fcc6d13475b"
dependencies = [
"base64 0.21.7",
- "bech32",
+ "bech32 0.9.1",
"bs58",
"const-hex",
"digest 0.10.7",
- "generic-array",
+ "generic-array 0.14.7",
"ripemd",
"serde",
- "sha2",
+ "sha2 0.10.9",
"sha3 0.10.9",
"thiserror 1.0.69",
]
@@ -2424,6 +3106,33 @@ dependencies = [
"rustc-hash 2.1.2",
]
+[[package]]
+name = "color-eyre"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
+dependencies = [
+ "backtrace",
+ "color-spantrace",
+ "eyre",
+ "indenter",
+ "once_cell",
+ "owo-colors",
+ "tracing-error",
+]
+
+[[package]]
+name = "color-spantrace"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
+dependencies = [
+ "once_cell",
+ "owo-colors",
+ "tracing-core",
+ "tracing-error",
+]
+
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -2450,17 +3159,30 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "common"
+version = "0.1.0"
+source = "git+https://github.com/ethereum/kohaku?rev=618c53facd0d44cf0f01d74e0dcc18d2242351c7#618c53facd0d44cf0f01d74e0dcc18d2242351c7"
+dependencies = [
+ "gloo-timers 0.3.0",
+ "tokio",
+ "web-time",
+]
+
[[package]]
name = "compression-codecs"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
dependencies = [
+ "brotli",
"bzip2",
"compression-core",
"deflate64",
"flate2",
"memchr",
+ "zstd",
+ "zstd-safe",
]
[[package]]
@@ -2527,6 +3249,12 @@ dependencies = [
"unicode-xid",
]
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
[[package]]
name = "convert_case"
version = "0.10.0"
@@ -2571,7 +3299,7 @@ dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
- "foreign-types",
+ "foreign-types 0.5.0",
"libc",
]
@@ -2584,7 +3312,7 @@ dependencies = [
"bitflags 2.12.1",
"core-foundation 0.10.0",
"core-graphics-types 0.2.0",
- "foreign-types",
+ "foreign-types 0.5.0",
"libc",
]
@@ -2597,7 +3325,7 @@ dependencies = [
"bitflags 2.12.1",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
- "foreign-types",
+ "foreign-types 0.5.0",
"libc",
]
@@ -2644,7 +3372,7 @@ checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130"
dependencies = [
"core-foundation 0.10.0",
"core-graphics 0.24.0",
- "foreign-types",
+ "foreign-types 0.5.0",
"libc",
]
@@ -2671,6 +3399,19 @@ dependencies = [
"libm",
]
+[[package]]
+name = "corosensei"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6886a0c0f263965933c438626e7179139a62b978a33aa18281cbf0cd5a975f34"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "libc",
+ "scopeguard",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "cosmic-text"
version = "0.19.0"
@@ -2713,6 +3454,95 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cranelift-bforest"
+version = "0.110.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "305d51c180ebdc46ef61bc60c54ae6512db3bc9a05842a1f1e762e45977019ab"
+dependencies = [
+ "cranelift-entity",
+]
+
+[[package]]
+name = "cranelift-bitset"
+version = "0.110.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "690d8ae6c73748e5ce3d8fe59034dceadb8823e6c8994ba324141c5eae909b0e"
+
+[[package]]
+name = "cranelift-codegen"
+version = "0.110.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd7ca95e831c18d1356da783765c344207cbdffea91e13e47fa9327dbb2e0719"
+dependencies = [
+ "bumpalo",
+ "cranelift-bforest",
+ "cranelift-bitset",
+ "cranelift-codegen-meta",
+ "cranelift-codegen-shared",
+ "cranelift-control",
+ "cranelift-entity",
+ "cranelift-isle",
+ "gimli 0.28.1",
+ "hashbrown 0.14.5",
+ "log",
+ "regalloc2",
+ "rustc-hash 1.1.0",
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cranelift-codegen-meta"
+version = "0.110.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0a2d2ab65e6cbf91f81781d8da65ec2005510f18300eff21a99526ed6785863"
+dependencies = [
+ "cranelift-codegen-shared",
+]
+
+[[package]]
+name = "cranelift-codegen-shared"
+version = "0.110.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efcff860573cf3db9ae98fbd949240d78b319df686cc306872e7fab60e9c84d7"
+
+[[package]]
+name = "cranelift-control"
+version = "0.110.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69d70e5b75c2d5541ef80a99966ccd97aaa54d2a6af19ea31759a28538e1685a"
+dependencies = [
+ "arbitrary",
+]
+
+[[package]]
+name = "cranelift-entity"
+version = "0.110.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a48cb0a194c9ba82fec35a1e492055388d89b2e3c03dee9dcf2488892be8004d"
+dependencies = [
+ "cranelift-bitset",
+]
+
+[[package]]
+name = "cranelift-frontend"
+version = "0.110.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8327afc6c1c05f4be62fefce5b439fa83521c65363a322e86ea32c85e7ceaf64"
+dependencies = [
+ "cranelift-codegen",
+ "log",
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cranelift-isle"
+version = "0.110.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56b08621c00321efcfa3eee6a3179adc009e21ea8d24ca7adc3c326184bc3f48"
+
[[package]]
name = "crc"
version = "3.4.0"
@@ -2737,6 +3567,12 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@@ -2786,13 +3622,30 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "crypto"
+version = "0.1.0"
+source = "git+https://github.com/ethereum/kohaku?rev=618c53facd0d44cf0f01d74e0dcc18d2242351c7#618c53facd0d44cf0f01d74e0dcc18d2242351c7"
+dependencies = [
+ "ark-bn254 0.6.0",
+ "ark-ff 0.6.0",
+ "blake-hash",
+ "num-bigint",
+ "num-traits",
+ "poseidon-rust",
+ "ruint",
+ "serde",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
"rand_core 0.6.4",
"subtle",
"zeroize",
@@ -2804,7 +3657,7 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
"rand_core 0.6.4",
"typenum",
]
@@ -2829,143 +3682,370 @@ dependencies = [
]
[[package]]
-name = "darling"
-version = "0.23.0"
+name = "ctr"
+version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
- "darling_core",
- "darling_macro",
+ "cipher",
]
[[package]]
-name = "darling_core"
-version = "0.23.0"
+name = "curve25519-dalek"
+version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
- "ident_case",
- "proc-macro2",
- "quote",
- "serde",
- "strsim",
- "syn 2.0.117",
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest 0.10.7",
+ "fiat-crypto",
+ "rustc_version 0.4.1",
+ "subtle",
+ "zeroize",
]
[[package]]
-name = "darling_macro"
-version = "0.23.0"
+name = "curve25519-dalek-derive"
+version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
- "darling_core",
+ "proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
-name = "dashmap"
-version = "6.2.1"
+name = "darling"
+version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
+checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
dependencies = [
- "cfg-if",
- "crossbeam-utils",
- "hashbrown 0.14.5",
- "lock_api",
- "once_cell",
- "parking_lot_core",
+ "darling_core 0.13.4",
+ "darling_macro 0.13.4",
]
[[package]]
-name = "data-url"
-version = "0.3.2"
+name = "darling"
+version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
-
-[[package]]
-name = "deckard"
-version = "0.1.0"
-dependencies = [
- "alloy-primitives",
- "alloy-signer-local 2.0.5",
- "deckard-core",
- "directories",
- "gpui",
- "gpui-component",
- "gpui-component-assets",
- "gpui_platform",
- "objc2 0.6.4",
- "objc2-app-kit 0.3.2",
- "objc2-foundation 0.3.2",
- "qrcode",
- "serde",
- "serde_json",
- "tray-icon",
- "zeroize",
-]
-
-[[package]]
-name = "deckard-core"
-version = "0.1.0"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
- "alloy",
- "alloy-primitives",
- "alloy-signer-local 2.0.5",
- "anyhow",
- "argon2",
- "bip39",
- "chacha20poly1305",
- "flume",
- "rand 0.8.6",
- "tokio",
- "zeroize",
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
]
[[package]]
-name = "deflate64"
-version = "0.1.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
-
-[[package]]
-name = "der"
-version = "0.7.10"
+name = "darling"
+version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
- "const-oid",
- "zeroize",
+ "darling_core 0.21.3",
+ "darling_macro 0.21.3",
]
[[package]]
-name = "deranged"
-version = "0.5.8"
+name = "darling"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
- "powerfmt",
- "serde_core",
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
]
[[package]]
-name = "derivative"
-version = "2.2.0"
+name = "darling_core"
+version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
+checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
dependencies = [
+ "fnv",
+ "ident_case",
"proc-macro2",
"quote",
+ "strsim 0.10.0",
"syn 1.0.109",
]
[[package]]
-name = "derive_more"
-version = "2.1.1"
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim 0.11.1",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "strsim 0.11.1",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
+dependencies = [
+ "darling_core 0.13.4",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core 0.20.11",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+dependencies = [
+ "darling_core 0.21.3",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core 0.23.0",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dashmap"
+version = "6.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core 0.9.12",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
+[[package]]
+name = "data-url"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
+
+[[package]]
+name = "deckard-app"
+version = "0.1.0"
+dependencies = [
+ "alloy-primitives",
+ "alloy-signer-local 2.0.5",
+ "anyhow",
+ "deckard-contract",
+ "deckard-core",
+ "deckard-signerd",
+ "directories",
+ "gpui",
+ "gpui-component",
+ "gpui-component-assets",
+ "gpui_platform",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "qrcode",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tray-icon",
+ "zeroize",
+]
+
+[[package]]
+name = "deckard-contract"
+version = "0.1.0"
+dependencies = [
+ "alloy-primitives",
+ "ciborium",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "deckard-core"
+version = "0.1.0"
+dependencies = [
+ "alloy",
+ "alloy-primitives",
+ "alloy-signer-local 2.0.5",
+ "anyhow",
+ "argon2",
+ "bip39",
+ "chacha20poly1305",
+ "deckard-contract",
+ "directories",
+ "flume",
+ "helios-ethereum",
+ "railgun",
+ "rand 0.8.6",
+ "rand 0.9.4",
+ "tokio",
+ "zeroize",
+]
+
+[[package]]
+name = "deckard-signerd"
+version = "0.1.0"
+dependencies = [
+ "alloy",
+ "alloy-primitives",
+ "anyhow",
+ "ciborium",
+ "deckard-contract",
+ "deckard-core",
+ "eyre",
+ "nix 0.29.0",
+ "railgun",
+ "rand 0.9.4",
+ "serde",
+ "serde_json",
+ "tokio",
+ "zeroize",
+]
+
+[[package]]
+name = "deflate64"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+ "serde_core",
+]
+
+[[package]]
+name = "derivative"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "derive-where"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "derive_more"
+version = "0.99.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "derive_more"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
+dependencies = [
+ "derive_more-impl 1.0.0",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
- "derive_more-impl",
+ "derive_more-impl 2.1.1",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
@@ -2992,13 +4072,22 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "digest"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5"
+dependencies = [
+ "generic-array 0.12.4",
+]
+
[[package]]
name = "digest"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
]
[[package]]
@@ -3032,6 +4121,15 @@ dependencies = [
"dirs-sys 0.4.1",
]
+[[package]]
+name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys 0.4.1",
+]
+
[[package]]
name = "dirs"
version = "6.0.0"
@@ -3062,7 +4160,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3157,6 +4255,30 @@ dependencies = [
"spki",
]
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "serde",
+ "sha2 0.10.9",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "educe"
version = "0.6.0"
@@ -3169,6 +4291,19 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "eip-1193-provider"
+version = "0.1.0"
+dependencies = [
+ "alloy",
+ "async-trait",
+ "cfg_aliases",
+ "common",
+ "hex",
+ "serde",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "either"
version = "1.16.0"
@@ -3188,7 +4323,7 @@ dependencies = [
"crypto-bigint",
"digest 0.10.7",
"ff",
- "generic-array",
+ "generic-array 0.14.7",
"group",
"pkcs8",
"rand_core 0.6.4",
@@ -3227,13 +4362,45 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+[[package]]
+name = "enum-as-inner"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "enum-iterator"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4eeac5c5edb79e4e39fe8439ef35207780a11f69c52cbe424ce3dfad4cb78de6"
+dependencies = [
+ "enum-iterator-derive 0.7.0",
+]
+
[[package]]
name = "enum-iterator"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016"
dependencies = [
- "enum-iterator-derive",
+ "enum-iterator-derive 1.5.0",
+]
+
+[[package]]
+name = "enum-iterator-derive"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c134c37760b27a871ba422106eedbb8247da973a09e82558bf26d619c882b159"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
]
[[package]]
@@ -3288,6 +4455,27 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "enumset"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d"
+dependencies = [
+ "enumset_derive",
+]
+
+[[package]]
+name = "enumset_derive"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a"
+dependencies = [
+ "darling 0.21.3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "equator"
version = "0.4.2"
@@ -3332,7 +4520,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]]
@@ -3345,6 +4533,130 @@ dependencies = [
"svg_fmt",
]
+[[package]]
+name = "ethabi"
+version = "18.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7413c5f74cc903ea37386a8965a936cbeb334bd270862fdece542c1b2dcbc898"
+dependencies = [
+ "ethereum-types",
+ "hex",
+ "once_cell",
+ "regex",
+ "serde",
+ "serde_json",
+ "sha3 0.10.9",
+ "thiserror 1.0.69",
+ "uint",
+]
+
+[[package]]
+name = "ethbloom"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c22d4b5885b6aa2fe5e8b9329fb8d232bf739e434e6b87347c63bdd00c120f60"
+dependencies = [
+ "crunchy",
+ "fixed-hash",
+ "impl-codec",
+ "impl-rlp",
+ "impl-serde",
+ "scale-info",
+ "tiny-keccak",
+]
+
+[[package]]
+name = "ethereum-types"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02d215cbf040552efcbe99a38372fe80ab9d00268e20012b79fcd0f073edd8ee"
+dependencies = [
+ "ethbloom",
+ "fixed-hash",
+ "impl-codec",
+ "impl-rlp",
+ "impl-serde",
+ "primitive-types",
+ "scale-info",
+ "uint",
+]
+
+[[package]]
+name = "ethereum_hashing"
+version = "0.7.0"
+source = "git+https://github.com/ncitron/ethereum_hashing?rev=7ee70944ed4fabe301551da8c447e4f4ae5e6c35#7ee70944ed4fabe301551da8c447e4f4ae5e6c35"
+dependencies = [
+ "cpufeatures 0.2.17",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "ethereum_serde_utils"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3dc1355dbb41fbbd34ec28d4fb2a57d9a70c67ac3c19f6a5ca4d4a176b9e997a"
+dependencies = [
+ "alloy-primitives",
+ "hex",
+ "serde",
+ "serde_derive",
+ "serde_json",
+]
+
+[[package]]
+name = "ethereum_ssz"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dcddb2554d19cde19b099fadddde576929d7a4d0c1cd3512d1fd95cf174375c"
+dependencies = [
+ "alloy-primitives",
+ "ethereum_serde_utils",
+ "itertools 0.13.0",
+ "serde",
+ "serde_derive",
+ "smallvec",
+ "typenum",
+]
+
+[[package]]
+name = "ethereum_ssz_derive"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a657b6b3b7e153637dc6bdc6566ad9279d9ee11a15b12cfb24a2e04360637e9f"
+dependencies = [
+ "darling 0.20.11",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "ethers-core"
+version = "2.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82d80cc6ad30b14a48ab786523af33b37f28a8623fc06afd55324816ef18fb1f"
+dependencies = [
+ "arrayvec",
+ "bytes",
+ "chrono",
+ "const-hex",
+ "elliptic-curve",
+ "ethabi",
+ "generic-array 0.14.7",
+ "k256",
+ "num_enum",
+ "open-fastrlp",
+ "rand 0.8.6",
+ "rlp",
+ "serde",
+ "serde_json",
+ "strum 0.26.3",
+ "tempfile",
+ "thiserror 1.0.69",
+ "tiny-keccak",
+ "unicode-xid",
+]
+
[[package]]
name = "euclid"
version = "0.22.14"
@@ -3396,6 +4708,22 @@ dependencies = [
"zune-inflate",
]
+[[package]]
+name = "eyre"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
+dependencies = [
+ "indenter",
+ "once_cell",
+]
+
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
[[package]]
name = "fastrand"
version = "1.9.0"
@@ -3454,10 +4782,17 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
+ "bitvec",
"rand_core 0.6.4",
"subtle",
]
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -3468,6 +4803,20 @@ dependencies = [
"rustc_version 0.4.1",
]
+[[package]]
+name = "figment"
+version = "0.10.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3"
+dependencies = [
+ "atomic 0.6.1",
+ "pear",
+ "serde",
+ "toml 0.8.23",
+ "uncased",
+ "version_check",
+]
+
[[package]]
name = "filetime"
version = "0.2.29"
@@ -3595,12 +4944,21 @@ checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
dependencies = [
"fontconfig-parser",
"log",
- "memmap2",
+ "memmap2 0.9.10",
"slotmap",
"tinyvec",
"ttf-parser",
]
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared 0.1.1",
+]
+
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -3608,7 +4966,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
- "foreign-types-shared",
+ "foreign-types-shared 0.3.1",
]
[[package]]
@@ -3622,6 +4980,12 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -3791,6 +5155,16 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+[[package]]
+name = "futures-timer"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
+dependencies = [
+ "gloo-timers 0.4.0",
+ "send_wrapper",
+]
+
[[package]]
name = "futures-util"
version = "0.3.32"
@@ -3872,6 +5246,15 @@ dependencies = [
"system-deps",
]
+[[package]]
+name = "generic-array"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd"
+dependencies = [
+ "typenum",
+]
+
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -3923,6 +5306,16 @@ dependencies = [
"wasip3",
]
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
[[package]]
name = "gif"
version = "0.13.3"
@@ -3943,6 +5336,17 @@ dependencies = [
"weezl",
]
+[[package]]
+name = "gimli"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
+dependencies = [
+ "fallible-iterator",
+ "indexmap 2.14.0",
+ "stable_deref_trait",
+]
+
[[package]]
name = "gimli"
version = "0.32.3"
@@ -4069,6 +5473,27 @@ dependencies = [
"walkdir",
]
+[[package]]
+name = "gloo-net"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-sink",
+ "gloo-utils",
+ "http 0.2.12",
+ "js-sys",
+ "pin-project",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "gloo-timers"
version = "0.3.0"
@@ -4081,6 +5506,31 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "gloo-timers"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "gloo-utils"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e"
+dependencies = [
+ "js-sys",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "glow"
version = "0.17.0"
@@ -4102,6 +5552,16 @@ dependencies = [
"gl_generator",
]
+[[package]]
+name = "gmp-mpfr-sys"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7db155b537cb791b133341f99f68371d86ee7fa4c79aacfbc376d72d23c70531"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -4156,7 +5616,7 @@ dependencies = [
"anyhow",
"async-channel 2.5.0",
"async-task",
- "bindgen",
+ "bindgen 0.71.1",
"bitflags 2.12.1",
"block",
"cbindgen",
@@ -4170,10 +5630,10 @@ dependencies = [
"core-text",
"core-video",
"ctor",
- "derive_more",
+ "derive_more 2.1.1",
"embed-resource",
"etagere",
- "foreign-types",
+ "foreign-types 0.5.0",
"futures",
"futures-concurrency",
"getrandom 0.3.4",
@@ -4187,13 +5647,13 @@ dependencies = [
"itertools 0.14.0",
"log",
"lyon",
- "mach2",
+ "mach2 0.5.0",
"media",
"metal",
"num_cpus",
"objc",
"parking",
- "parking_lot",
+ "parking_lot 0.12.5",
"pathfinder_geometry",
"pin-project",
"pollster 0.4.0",
@@ -4213,7 +5673,7 @@ dependencies = [
"smallvec",
"spin 0.10.0",
"stacksafe",
- "strum",
+ "strum 0.27.2",
"sum_tree",
"taffy",
"thiserror 2.0.18",
@@ -4239,7 +5699,7 @@ dependencies = [
"async-channel 2.5.0",
"chrono",
"core-text",
- "enum-iterator",
+ "enum-iterator 2.3.0",
"futures",
"gpui",
"gpui-component-assets",
@@ -4316,14 +5776,14 @@ dependencies = [
"libc",
"log",
"oo7",
- "parking_lot",
+ "parking_lot 0.12.5",
"pathfinder_geometry",
"pollster 0.4.0",
"profiling",
"raw-window-handle",
"smallvec",
"smol",
- "strum",
+ "strum 0.27.2",
"swash",
"url",
"util",
@@ -4349,27 +5809,27 @@ dependencies = [
"core-text",
"core-video",
"ctor",
- "derive_more",
+ "derive_more 2.1.1",
"dispatch2",
"etagere",
- "foreign-types",
+ "foreign-types 0.5.0",
"futures",
"gpui",
"image",
"itertools 0.14.0",
"libc",
"log",
- "mach2",
+ "mach2 0.5.0",
"media",
"metal",
"objc",
"objc2-app-kit 0.3.2",
- "parking_lot",
+ "parking_lot 0.12.5",
"pathfinder_geometry",
"raw-window-handle",
"semver 1.0.28",
"smallvec",
- "strum",
+ "strum 0.27.2",
"util",
"uuid",
"zed-font-kit",
@@ -4431,7 +5891,7 @@ dependencies = [
"http_client",
"js-sys",
"log",
- "parking_lot",
+ "parking_lot 0.12.5",
"raw-window-handle",
"smallvec",
"uuid",
@@ -4457,7 +5917,7 @@ dependencies = [
"itertools 0.14.0",
"js-sys",
"log",
- "parking_lot",
+ "parking_lot 0.12.5",
"pollster 0.4.0",
"profiling",
"raw-window-handle",
@@ -4485,7 +5945,7 @@ dependencies = [
"image",
"itertools 0.14.0",
"log",
- "parking_lot",
+ "parking_lot 0.12.5",
"rand 0.9.4",
"raw-window-handle",
"smallvec",
@@ -4566,6 +6026,25 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "h2"
+version = "0.3.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http 0.2.12",
+ "indexmap 2.14.0",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "h2"
version = "0.4.14"
@@ -4577,7 +6056,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
- "http",
+ "http 1.4.1",
"indexmap 2.14.0",
"slab",
"tokio",
@@ -4625,11 +6104,23 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+[[package]]
+name = "hashbrown"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
+dependencies = [
+ "ahash",
+]
+
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+]
[[package]]
name = "hashbrown"
@@ -4637,6 +6128,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
+ "allocator-api2",
"foldhash 0.1.5",
]
@@ -4657,6 +6149,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
+ "allocator-api2",
"foldhash 0.2.0",
"serde",
"serde_core",
@@ -4684,6 +6177,159 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "helios-common"
+version = "0.11.1"
+source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924"
+dependencies = [
+ "alloy",
+ "async-trait",
+ "eyre",
+ "hex",
+ "revm",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "tokio",
+]
+
+[[package]]
+name = "helios-consensus-core"
+version = "0.11.1"
+source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924"
+dependencies = [
+ "alloy",
+ "alloy-rlp",
+ "bls12_381",
+ "ethereum_ssz",
+ "ethereum_ssz_derive",
+ "eyre",
+ "getrandom 0.2.17",
+ "serde",
+ "sha2 0.9.9",
+ "ssz_types",
+ "superstruct",
+ "thiserror 1.0.69",
+ "tracing",
+ "tree_hash",
+ "tree_hash_derive",
+ "typenum",
+ "wasmtimer",
+]
+
+[[package]]
+name = "helios-core"
+version = "0.11.1"
+source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924"
+dependencies = [
+ "alloy",
+ "alloy-trie",
+ "async-trait",
+ "eyre",
+ "futures",
+ "getrandom 0.3.4",
+ "helios-common",
+ "helios-verifiable-api-client",
+ "hex",
+ "jsonrpsee",
+ "openssl",
+ "parking_lot 0.12.5",
+ "reqwest 0.12.28",
+ "revm",
+ "schnellru",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "tokio",
+ "tracing",
+ "url",
+ "wasm-bindgen-futures",
+ "wasmtimer",
+]
+
+[[package]]
+name = "helios-ethereum"
+version = "0.11.1"
+source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924"
+dependencies = [
+ "alloy",
+ "alloy-trie",
+ "async-trait",
+ "chrono",
+ "dirs 5.0.1",
+ "eyre",
+ "figment",
+ "futures",
+ "getrandom 0.3.4",
+ "helios-common",
+ "helios-consensus-core",
+ "helios-core",
+ "helios-revm-utils",
+ "hex",
+ "openssl",
+ "parking_lot 0.12.5",
+ "reqwest 0.12.28",
+ "retri",
+ "revm",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "strum 0.26.3",
+ "superstruct",
+ "thiserror 1.0.69",
+ "tokio",
+ "tracing",
+ "tree_hash",
+ "typenum",
+ "url",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "helios-revm-utils"
+version = "0.11.1"
+source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924"
+dependencies = [
+ "alloy",
+ "eyre",
+ "helios-common",
+ "helios-core",
+ "hex",
+ "revm",
+ "thiserror 1.0.69",
+ "tracing",
+]
+
+[[package]]
+name = "helios-verifiable-api-client"
+version = "0.11.1"
+source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924"
+dependencies = [
+ "alloy",
+ "async-trait",
+ "eyre",
+ "helios-common",
+ "helios-verifiable-api-types",
+ "reqwest 0.12.28",
+ "reqwest-middleware",
+ "reqwest-retry",
+ "serde",
+ "serde_json",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "helios-verifiable-api-types"
+version = "0.11.1"
+source = "git+https://github.com/a16z/helios?tag=0.11.1#204c998a927348e1c000a664f08d5b37b1b0d924"
+dependencies = [
+ "alloy",
+ "helios-common",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "hermit-abi"
version = "0.5.2"
@@ -4711,6 +6357,52 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+[[package]]
+name = "hickory-proto"
+version = "0.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
+dependencies = [
+ "async-trait",
+ "cfg-if",
+ "data-encoding",
+ "enum-as-inner",
+ "futures-channel",
+ "futures-io",
+ "futures-util",
+ "idna",
+ "ipnet",
+ "once_cell",
+ "rand 0.9.4",
+ "ring",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tokio",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "hickory-resolver"
+version = "0.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "hickory-proto",
+ "ipconfig",
+ "moka",
+ "once_cell",
+ "parking_lot 0.12.5",
+ "rand 0.9.4",
+ "resolv-conf",
+ "smallvec",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+]
+
[[package]]
name = "hkdf"
version = "0.12.4"
@@ -4752,6 +6444,17 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
[[package]]
name = "http"
version = "1.4.1"
@@ -4762,6 +6465,17 @@ dependencies = [
"itoa",
]
+[[package]]
+name = "http-body"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
+dependencies = [
+ "bytes",
+ "http 0.2.12",
+ "pin-project-lite",
+]
+
[[package]]
name = "http-body"
version = "1.0.1"
@@ -4769,7 +6483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.4.1",
]
[[package]]
@@ -4780,8 +6494,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
- "http",
- "http-body",
+ "http 1.4.1",
+ "http-body 1.0.1",
"pin-project-lite",
]
@@ -4795,16 +6509,16 @@ dependencies = [
"async-fs",
"async-tar",
"bytes",
- "derive_more",
+ "derive_more 2.1.1",
"futures",
- "http",
- "http-body",
+ "http 1.4.1",
+ "http-body 1.0.1",
"log",
- "parking_lot",
+ "parking_lot 0.12.5",
"serde",
"serde_json",
"serde_urlencoded",
- "sha2",
+ "sha2 0.10.9",
"tempfile",
"url",
"util",
@@ -4816,6 +6530,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
[[package]]
name = "hybrid-array"
version = "0.4.12"
@@ -4825,6 +6545,30 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "hyper"
+version = "0.14.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2 0.3.27",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2 0.5.10",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
[[package]]
name = "hyper"
version = "1.10.1"
@@ -4835,9 +6579,9 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
- "h2",
- "http",
- "http-body",
+ "h2 0.4.14",
+ "http 1.4.1",
+ "http-body 1.0.1",
"httparse",
"itoa",
"pin-project-lite",
@@ -4846,19 +6590,51 @@ dependencies = [
"want",
]
+[[package]]
+name = "hyper-rustls"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
+dependencies = [
+ "futures-util",
+ "http 0.2.12",
+ "hyper 0.14.32",
+ "log",
+ "rustls 0.21.12",
+ "rustls-native-certs 0.6.3",
+ "tokio",
+ "tokio-rustls 0.24.1",
+]
+
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
- "http",
- "hyper",
+ "http 1.4.1",
+ "hyper 1.10.1",
+ "hyper-util",
+ "rustls 0.23.40",
+ "rustls-native-certs 0.8.4",
+ "tokio",
+ "tokio-rustls 0.26.4",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper 1.10.1",
"hyper-util",
- "rustls",
- "rustls-native-certs",
+ "native-tls",
"tokio",
- "tokio-rustls",
+ "tokio-native-tls",
"tower-service",
]
@@ -4872,17 +6648,19 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
- "http",
- "http-body",
- "hyper",
+ "http 1.4.1",
+ "http-body 1.0.1",
+ "hyper 1.10.1",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2",
+ "socket2 0.6.4",
+ "system-configuration 0.7.0",
"tokio",
"tower-service",
"tracing",
+ "windows-registry 0.4.0",
]
[[package]]
@@ -5095,6 +6873,24 @@ dependencies = [
"parity-scale-codec",
]
+[[package]]
+name = "impl-rlp"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28220f89297a075ddc7245cd538076ee98b01f2a9c23a53a4f1105d5a322808"
+dependencies = [
+ "rlp",
+]
+
+[[package]]
+name = "impl-serde"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc88fc67028ae3db0c853baa36269d398d5f45b6982f95549ff5def78c935cd"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "impl-trait-for-tuples"
version = "0.2.3"
@@ -5106,6 +6902,12 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "indenter"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
+
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -5129,6 +6931,12 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "inlinable_string"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
+
[[package]]
name = "inotify"
version = "0.10.2"
@@ -5155,8 +6963,8 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
- "block-padding",
- "generic-array",
+ "block-padding 0.3.3",
+ "generic-array 0.14.7",
]
[[package]]
@@ -5203,6 +7011,19 @@ dependencies = [
"leaky-cow",
]
+[[package]]
+name = "ipconfig"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
+dependencies = [
+ "socket2 0.6.4",
+ "widestring",
+ "windows-registry 0.6.1",
+ "windows-result 0.4.1",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -5227,6 +7048,15 @@ dependencies = [
"either",
]
+[[package]]
+name = "itertools"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itertools"
version = "0.13.0"
@@ -5331,6 +7161,162 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "jsonrpsee"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5f3783308bddc49d0218307f66a09330c106fbd792c58bac5c8dc294fdd0f98"
+dependencies = [
+ "jsonrpsee-client-transport",
+ "jsonrpsee-core",
+ "jsonrpsee-http-client",
+ "jsonrpsee-proc-macros",
+ "jsonrpsee-server",
+ "jsonrpsee-types",
+ "jsonrpsee-wasm-client",
+ "jsonrpsee-ws-client",
+ "tracing",
+]
+
+[[package]]
+name = "jsonrpsee-client-transport"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abc5630e4fa0096f00ec7b44d520701fda4504170cb85e22dca603ae5d7ad0d7"
+dependencies = [
+ "futures-channel",
+ "futures-util",
+ "gloo-net",
+ "http 0.2.12",
+ "jsonrpsee-core",
+ "pin-project",
+ "rustls-native-certs 0.6.3",
+ "soketto",
+ "thiserror 1.0.69",
+ "tokio",
+ "tokio-rustls 0.24.1",
+ "tokio-util",
+ "tracing",
+ "webpki-roots",
+]
+
+[[package]]
+name = "jsonrpsee-core"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aaa4c4d5fb801dcc316d81f76422db259809037a86b3194ae538dd026b05ed7"
+dependencies = [
+ "anyhow",
+ "async-lock 2.8.0",
+ "async-trait",
+ "beef",
+ "futures-timer",
+ "futures-util",
+ "globset",
+ "hyper 0.14.32",
+ "jsonrpsee-types",
+ "parking_lot 0.12.5",
+ "rand 0.8.6",
+ "rustc-hash 1.1.0",
+ "serde",
+ "serde_json",
+ "soketto",
+ "thiserror 1.0.69",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "jsonrpsee-http-client"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa7165efcbfbc951d180162ff28fe91b657ed81925e37a35e4a396ce12109f96"
+dependencies = [
+ "async-trait",
+ "hyper 0.14.32",
+ "hyper-rustls 0.24.2",
+ "jsonrpsee-core",
+ "jsonrpsee-types",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "tokio",
+ "tower 0.4.13",
+ "tracing",
+]
+
+[[package]]
+name = "jsonrpsee-proc-macros"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21dc12b1d4f16a86e8c522823c4fab219c88c03eb7c924ec0501a64bf12e058b"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro-crate 1.3.1",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "jsonrpsee-server"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e79d78cfd5abd8394da10753723093c3ff64391602941c9c4b1d80a3414fd53"
+dependencies = [
+ "futures-util",
+ "hyper 0.14.32",
+ "jsonrpsee-core",
+ "jsonrpsee-types",
+ "serde",
+ "serde_json",
+ "soketto",
+ "tokio",
+ "tokio-stream",
+ "tokio-util",
+ "tower 0.4.13",
+ "tracing",
+]
+
+[[package]]
+name = "jsonrpsee-types"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00aa7cc87bc42e04e26c8ac3e7186142f7fd2949c763d9b6a7e64a69672d8fb2"
+dependencies = [
+ "anyhow",
+ "beef",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "tracing",
+]
+
+[[package]]
+name = "jsonrpsee-wasm-client"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fe953c2801356f214d3f4051f786b3d11134512a46763ee8c39a9e3fa2cc1c0"
+dependencies = [
+ "jsonrpsee-client-transport",
+ "jsonrpsee-core",
+ "jsonrpsee-types",
+]
+
+[[package]]
+name = "jsonrpsee-ws-client"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c71b2597ec1c958c6d5bc94bb61b44d74eb28e69dc421731ab0035706f13882"
+dependencies = [
+ "http 0.2.12",
+ "jsonrpsee-client-transport",
+ "jsonrpsee-core",
+ "jsonrpsee-types",
+]
+
[[package]]
name = "k256"
version = "0.13.4"
@@ -5342,7 +7328,7 @@ dependencies = [
"elliptic-curve",
"once_cell",
"serdect",
- "sha2",
+ "sha2 0.10.9",
"signature",
]
@@ -5479,6 +7465,12 @@ dependencies = [
"leak",
]
+[[package]]
+name = "leb128"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545"
+
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -5572,6 +7564,58 @@ dependencies = [
"libc",
]
+[[package]]
+name = "libsecp256k1"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139"
+dependencies = [
+ "arrayref",
+ "base64 0.22.1",
+ "digest 0.9.0",
+ "libsecp256k1-core",
+ "libsecp256k1-gen-ecmult",
+ "libsecp256k1-gen-genmult",
+ "rand 0.8.6",
+ "serde",
+ "sha2 0.9.9",
+]
+
+[[package]]
+name = "libsecp256k1-core"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451"
+dependencies = [
+ "crunchy",
+ "digest 0.9.0",
+ "subtle",
+]
+
+[[package]]
+name = "libsecp256k1-gen-ecmult"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809"
+dependencies = [
+ "libsecp256k1-core",
+]
+
+[[package]]
+name = "libsecp256k1-gen-genmult"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c"
+dependencies = [
+ "libsecp256k1-core",
+]
+
+[[package]]
+name = "libunwind"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c6639b70a7ce854b79c70d7e83f16b5dc0137cc914f3d7d03803b513ecc67ac"
+
[[package]]
name = "libxdo"
version = "0.6.0"
@@ -5747,6 +7791,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+[[package]]
+name = "mach2"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "mach2"
version = "0.5.0"
@@ -5756,6 +7809,17 @@ dependencies = [
"libc",
]
+[[package]]
+name = "macho-unwind-info"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4bdc8b0ce69932332cf76d24af69c3a155242af95c226b2ab6c2e371ed1149"
+dependencies = [
+ "thiserror 2.0.18",
+ "zerocopy",
+ "zerocopy-derive",
+]
+
[[package]]
name = "macro-string"
version = "0.2.0"
@@ -5838,11 +7902,11 @@ version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#86effffd34634945a4971e1c6c65cd45b21ce6a9"
dependencies = [
"anyhow",
- "bindgen",
+ "bindgen 0.71.1",
"core-foundation 0.10.0",
"core-video",
"ctor",
- "foreign-types",
+ "foreign-types 0.5.0",
"metal",
"objc",
]
@@ -5853,6 +7917,15 @@ version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+[[package]]
+name = "memmap2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d28bba84adfe6646737845bc5ebbfa2c08424eb1c37e94a1fd2a82adb56a872"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "memmap2"
version = "0.9.10"
@@ -5871,6 +7944,18 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "merlin"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d"
+dependencies = [
+ "byteorder",
+ "keccak 0.1.6",
+ "rand_core 0.6.4",
+ "zeroize",
+]
+
[[package]]
name = "metal"
version = "0.33.0"
@@ -5880,7 +7965,7 @@ dependencies = [
"bitflags 2.12.1",
"block",
"core-graphics-types 0.2.0",
- "foreign-types",
+ "foreign-types 0.5.0",
"log",
"objc",
"paste",
@@ -5930,6 +8015,29 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "moka"
+version = "0.12.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "equivalent",
+ "parking_lot 0.12.5",
+ "portable-atomic",
+ "smallvec",
+ "tagptr",
+ "uuid",
+]
+
+[[package]]
+name = "more-asserts"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7843ec2de400bcbc6a6328c958dc38e5359da6e93e72e37bc5246bf1ae776389"
+
[[package]]
name = "moxcms"
version = "0.8.1"
@@ -5958,7 +8066,27 @@ dependencies = [
"once_cell",
"png 0.18.1",
"thiserror 2.0.18",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "munge"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c"
+dependencies = [
+ "munge_macro",
+]
+
+[[package]]
+name = "munge_macro"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
@@ -5995,6 +8123,23 @@ dependencies = [
"getrandom 0.2.17",
]
+[[package]]
+name = "native-tls"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe 0.2.1",
+ "openssl-sys",
+ "schannel",
+ "security-framework 3.7.0",
+ "security-framework-sys",
+ "tempfile",
+]
+
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
@@ -6120,7 +8265,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]]
@@ -6145,6 +8290,8 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
+ "rand 0.8.6",
+ "serde",
]
[[package]]
@@ -6257,6 +8404,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
+ "proc-macro-crate 1.3.1",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -6574,6 +8722,20 @@ dependencies = [
"objc",
]
+[[package]]
+name = "object"
+version = "0.32.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "hashbrown 0.14.5",
+ "indexmap 2.14.0",
+ "memchr",
+ "ruzstd",
+]
+
[[package]]
name = "object"
version = "0.37.3"
@@ -6588,6 +8750,10 @@ name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+dependencies = [
+ "critical-section",
+ "portable-atomic",
+]
[[package]]
name = "oo7"
@@ -6599,7 +8765,7 @@ dependencies = [
"ashpd",
"async-fs",
"async-io",
- "async-lock",
+ "async-lock 3.4.2",
"blocking",
"cbc",
"cipher",
@@ -6616,7 +8782,7 @@ dependencies = [
"pbkdf2",
"serde",
"serde_bytes",
- "sha2",
+ "sha2 0.10.9",
"subtle",
"zbus",
"zbus_macros",
@@ -6630,12 +8796,90 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+[[package]]
+name = "open-fastrlp"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "786393f80485445794f6043fd3138854dd109cc6c4bd1a6383db304c9ce9b9ce"
+dependencies = [
+ "arrayvec",
+ "auto_impl",
+ "bytes",
+ "ethereum-types",
+ "open-fastrlp-derive",
+]
+
+[[package]]
+name = "open-fastrlp-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "003b2be5c6c53c1cfeb0a238b8a1c3915cd410feb684457a36c10038f764bb1c"
+dependencies = [
+ "bytes",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "openssl"
+version = "0.10.80"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
+dependencies = [
+ "bitflags 2.12.1",
+ "cfg-if",
+ "foreign-types 0.3.2",
+ "libc",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+[[package]]
+name = "openssl-src"
+version = "300.6.0+3.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.116"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
+dependencies = [
+ "cc",
+ "libc",
+ "openssl-src",
+ "pkg-config",
+ "vcpkg",
+]
+
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -6661,6 +8905,33 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "owo-colors"
+version = "4.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
+
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "pairing"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f"
+dependencies = [
+ "group",
+]
+
[[package]]
name = "pango"
version = "0.18.3"
@@ -6720,6 +8991,17 @@ version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+[[package]]
+name = "parking_lot"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
+dependencies = [
+ "instant",
+ "lock_api",
+ "parking_lot_core 0.8.6",
+]
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -6727,7 +9009,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
- "parking_lot_core",
+ "parking_lot_core 0.9.12",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
+dependencies = [
+ "cfg-if",
+ "instant",
+ "libc",
+ "redox_syscall 0.2.16",
+ "smallvec",
+ "winapi",
]
[[package]]
@@ -6795,6 +9091,29 @@ dependencies = [
"hmac",
]
+[[package]]
+name = "pear"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467"
+dependencies = [
+ "inlinable_string",
+ "pear_codegen",
+ "yansi",
+]
+
+[[package]]
+name = "pear_codegen"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147"
+dependencies = [
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -6827,7 +9146,9 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
+ "phf_macros 0.11.3",
"phf_shared 0.11.3",
+ "serde",
]
[[package]]
@@ -6836,7 +9157,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
- "phf_macros",
+ "phf_macros 0.13.1",
"phf_shared 0.13.1",
"serde",
]
@@ -6871,6 +9192,19 @@ dependencies = [
"phf_shared 0.13.1",
]
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "phf_macros"
version = "0.13.1"
@@ -7030,6 +9364,18 @@ dependencies = [
"universal-hash",
]
+[[package]]
+name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "opaque-debug",
+ "universal-hash",
+]
+
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -7045,17 +9391,32 @@ dependencies = [
"portable-atomic",
]
+[[package]]
+name = "poseidon-rust"
+version = "0.1.0"
+source = "git+https://github.com/ethereum/kohaku?rev=618c53facd0d44cf0f01d74e0dcc18d2242351c7#618c53facd0d44cf0f01d74e0dcc18d2242351c7"
+dependencies = [
+ "ark-bn254 0.6.0",
+ "ark-ff 0.6.0",
+ "ark-serialize 0.6.0",
+ "ark-std 0.6.0",
+ "itertools 0.14.0",
+ "num-bigint",
+ "num-traits",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "postage"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1"
dependencies = [
- "atomic",
+ "atomic 0.5.3",
"crossbeam-queue",
"futures",
"log",
- "parking_lot",
+ "parking_lot 0.12.5",
"pin-project",
"pollster 0.2.5",
"static_assertions",
@@ -7108,6 +9469,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
[[package]]
name = "primitive-types"
version = "0.12.2"
@@ -7116,6 +9486,9 @@ checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2"
dependencies = [
"fixed-hash",
"impl-codec",
+ "impl-rlp",
+ "impl-serde",
+ "scale-info",
"uint",
]
@@ -7202,6 +9575,19 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "version_check",
+ "yansi",
+]
+
[[package]]
name = "profiling"
version = "1.0.18"
@@ -7250,6 +9636,46 @@ dependencies = [
"cc",
]
+[[package]]
+name = "ptr_meta"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
+dependencies = [
+ "ptr_meta_derive 0.1.4",
+]
+
+[[package]]
+name = "ptr_meta"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79"
+dependencies = [
+ "ptr_meta_derive 0.3.1",
+]
+
+[[package]]
+name = "ptr_meta_derive"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "ptr_meta_derive"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "pxfm"
version = "0.1.29"
@@ -7317,8 +9743,8 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.2",
- "rustls",
- "socket2",
+ "rustls 0.23.40",
+ "socket2 0.6.4",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -7338,7 +9764,7 @@ dependencies = [
"rand 0.9.4",
"ring",
"rustc-hash 2.1.2",
- "rustls",
+ "rustls 0.23.40",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
@@ -7356,7 +9782,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2",
+ "socket2 0.6.4",
"tracing",
"windows-sys 0.60.2",
]
@@ -7386,7 +9812,65 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
+[[package]]
+name = "railgun"
+version = "0.1.0"
+source = "git+https://github.com/ethereum/kohaku?rev=618c53facd0d44cf0f01d74e0dcc18d2242351c7#618c53facd0d44cf0f01d74e0dcc18d2242351c7"
+dependencies = [
+ "aes",
+ "aes-gcm",
+ "alloy",
+ "ark-bn254 0.6.0",
+ "ark-circom",
+ "ark-ff 0.6.0",
+ "ark-groth16",
+ "ark-relations 0.6.0",
+ "ark-serialize 0.6.0",
+ "ark-std 0.6.0",
+ "async-trait",
+ "bech32 0.11.1",
+ "brotli",
+ "cfg_aliases",
+ "common",
+ "crypto",
+ "ctr",
+ "curve25519-dalek",
+ "ed25519-dalek",
+ "eip-1193-provider",
+ "futures",
+ "getrandom 0.2.17",
+ "getrandom 0.3.4",
+ "gloo-timers 0.3.0",
+ "hex",
+ "js-sys",
+ "num-bigint",
+ "rand 0.9.4",
+ "rand_chacha 0.9.0",
+ "reqwest 0.13.4",
+ "ruint",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "tracing-subscriber 0.3.23",
+ "tracing-wasm",
+ "userop-kit",
+ "wasmer",
+ "web-time",
+]
+
+[[package]]
+name = "rancor"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee"
+dependencies = [
+ "ptr_meta 0.3.1",
+]
[[package]]
name = "rand"
@@ -7477,6 +9961,7 @@ version = "4.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59"
dependencies = [
+ "rand 0.9.4",
"rustversion",
]
@@ -7647,6 +10132,19 @@ dependencies = [
"derive_refineable",
]
+[[package]]
+name = "regalloc2"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad156d539c879b7a24a363a2016d77961786e71f48f2e2fc8302a92abd2429a6"
+dependencies = [
+ "hashbrown 0.13.2",
+ "log",
+ "rustc-hash 1.1.0",
+ "slice-group-by",
+ "smallvec",
+]
+
[[package]]
name = "regex"
version = "1.12.3"
@@ -7676,12 +10174,75 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+[[package]]
+name = "region"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+ "mach2 0.4.3",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rend"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6"
+dependencies = [
+ "bytecheck 0.8.2",
+]
+
[[package]]
name = "renderdoc-sys"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "h2 0.4.14",
+ "hickory-resolver",
+ "http 1.4.1",
+ "http-body 1.0.1",
+ "http-body-util",
+ "hyper 1.10.1",
+ "hyper-rustls 0.27.9",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "once_cell",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tower 0.5.3",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -7691,26 +10252,26 @@ dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
- "http",
- "http-body",
+ "http 1.4.1",
+ "http-body 1.0.1",
"http-body-util",
- "hyper",
- "hyper-rustls",
+ "hyper 1.10.1",
+ "hyper-rustls 0.27.9",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
- "rustls",
+ "rustls 0.23.40",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
- "tokio-rustls",
- "tower",
+ "tokio-rustls 0.26.4",
+ "tower 0.5.3",
"tower-http",
"tower-service",
"url",
@@ -7719,6 +10280,49 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "reqwest-middleware"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "http 1.4.1",
+ "reqwest 0.12.28",
+ "serde",
+ "thiserror 1.0.69",
+ "tower-service",
+]
+
+[[package]]
+name = "reqwest-retry"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29c73e4195a6bfbcb174b790d9b3407ab90646976c55de58a6515da25d851178"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "futures",
+ "getrandom 0.2.17",
+ "http 1.4.1",
+ "hyper 1.10.1",
+ "parking_lot 0.11.2",
+ "reqwest 0.12.28",
+ "reqwest-middleware",
+ "retry-policies",
+ "thiserror 1.0.69",
+ "tokio",
+ "tracing",
+ "wasm-timer",
+]
+
+[[package]]
+name = "resolv-conf"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
+
[[package]]
name = "resvg"
version = "0.45.1"
@@ -7736,6 +10340,214 @@ dependencies = [
"zune-jpeg 0.4.21",
]
+[[package]]
+name = "retri"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c38316070fbd6504dcbb6042225f2608f2323c71155836c9b48f192113f68782"
+dependencies = [
+ "tokio",
+ "zduny-wasm-timer",
+]
+
+[[package]]
+name = "retry-policies"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5875471e6cab2871bc150ecb8c727db5113c9338cc3354dc5ee3425b6aa40a1c"
+dependencies = [
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "revm"
+version = "29.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "718d90dce5f07e115d0e66450b1b8aa29694c1cf3f89ebddaddccc2ccbd2f13e"
+dependencies = [
+ "revm-bytecode",
+ "revm-context",
+ "revm-context-interface",
+ "revm-database",
+ "revm-database-interface",
+ "revm-handler",
+ "revm-inspector",
+ "revm-interpreter",
+ "revm-precompile",
+ "revm-primitives",
+ "revm-state",
+]
+
+[[package]]
+name = "revm-bytecode"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66c52031b73cae95d84cd1b07725808b5fd1500da3e5e24574a3b2dc13d9f16d"
+dependencies = [
+ "bitvec",
+ "phf 0.11.3",
+ "revm-primitives",
+ "serde",
+]
+
+[[package]]
+name = "revm-context"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a20c98e7008591a6f012550c2a00aa36cba8c14cc88eb88dec32eb9102554b4"
+dependencies = [
+ "bitvec",
+ "cfg-if",
+ "derive-where",
+ "revm-bytecode",
+ "revm-context-interface",
+ "revm-database-interface",
+ "revm-primitives",
+ "revm-state",
+ "serde",
+]
+
+[[package]]
+name = "revm-context-interface"
+version = "10.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b50d241ed1ce647b94caf174fcd0239b7651318b2c4c06b825b59b973dfb8495"
+dependencies = [
+ "alloy-eip2930",
+ "alloy-eip7702",
+ "auto_impl",
+ "either",
+ "revm-database-interface",
+ "revm-primitives",
+ "revm-state",
+ "serde",
+]
+
+[[package]]
+name = "revm-database"
+version = "7.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39a276ed142b4718dcf64bc9624f474373ed82ef20611025045c3fb23edbef9c"
+dependencies = [
+ "alloy-eips 1.8.3",
+ "revm-bytecode",
+ "revm-database-interface",
+ "revm-primitives",
+ "revm-state",
+ "serde",
+]
+
+[[package]]
+name = "revm-database-interface"
+version = "7.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c523c77e74eeedbac5d6f7c092e3851dbe9c7fec6f418b85992bd79229db361"
+dependencies = [
+ "auto_impl",
+ "either",
+ "revm-primitives",
+ "revm-state",
+ "serde",
+]
+
+[[package]]
+name = "revm-handler"
+version = "10.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "550331ea85c1d257686e672081576172fe3d5a10526248b663bbf54f1bef226a"
+dependencies = [
+ "auto_impl",
+ "derive-where",
+ "revm-bytecode",
+ "revm-context",
+ "revm-context-interface",
+ "revm-database-interface",
+ "revm-interpreter",
+ "revm-precompile",
+ "revm-primitives",
+ "revm-state",
+ "serde",
+]
+
+[[package]]
+name = "revm-inspector"
+version = "10.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c0a6e9ccc2ae006f5bed8bd80cd6f8d3832cd55c5e861b9402fdd556098512f"
+dependencies = [
+ "auto_impl",
+ "either",
+ "revm-context",
+ "revm-database-interface",
+ "revm-handler",
+ "revm-interpreter",
+ "revm-primitives",
+ "revm-state",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "revm-interpreter"
+version = "25.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06575dc51b1d8f5091daa12a435733a90b4a132dca7ccee0666c7db3851bc30c"
+dependencies = [
+ "revm-bytecode",
+ "revm-context-interface",
+ "revm-primitives",
+ "serde",
+]
+
+[[package]]
+name = "revm-precompile"
+version = "27.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25b57d4bd9e6b5fe469da5452a8a137bc2d030a3cd47c46908efc615bbc699da"
+dependencies = [
+ "ark-bls12-381",
+ "ark-bn254 0.5.0",
+ "ark-ec 0.5.0",
+ "ark-ff 0.5.0",
+ "ark-serialize 0.5.0",
+ "arrayref",
+ "aurora-engine-modexp",
+ "c-kzg",
+ "cfg-if",
+ "k256",
+ "libsecp256k1",
+ "p256",
+ "revm-primitives",
+ "ripemd",
+ "rug",
+ "secp256k1 0.31.1",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "revm-primitives"
+version = "20.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aa29d9da06fe03b249b6419b33968ecdf92ad6428e2f012dc57bcd619b5d94e"
+dependencies = [
+ "alloy-primitives",
+ "num_enum",
+ "once_cell",
+ "serde",
+]
+
+[[package]]
+name = "revm-state"
+version = "7.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f64fbacb86008394aaebd3454f9643b7d5a782bd251135e17c5b33da592d84d"
+dependencies = [
+ "bitflags 2.12.1",
+ "revm-bytecode",
+ "revm-primitives",
+ "serde",
+]
+
[[package]]
name = "rfc6979"
version = "0.4.0"
@@ -7778,6 +10590,36 @@ dependencies = [
"digest 0.10.7",
]
+[[package]]
+name = "rkyv"
+version = "0.8.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3"
+dependencies = [
+ "bytecheck 0.8.2",
+ "bytes",
+ "hashbrown 0.17.1",
+ "indexmap 2.14.0",
+ "munge",
+ "ptr_meta 0.3.1",
+ "rancor",
+ "rend",
+ "rkyv_derive",
+ "tinyvec",
+ "uuid",
+]
+
+[[package]]
+name = "rkyv_derive"
+version = "0.8.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "rlp"
version = "0.5.2"
@@ -7785,9 +10627,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec"
dependencies = [
"bytes",
+ "rlp-derive",
"rustc-hex",
]
+[[package]]
+name = "rlp-derive"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e33d7b2abe0c340d8797fe2907d3f20d3b5ea5908683618bfe80df7f621f672a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "ropey"
version = "2.0.0-beta.1"
@@ -7803,16 +10657,28 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+[[package]]
+name = "rug"
+version = "1.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07a8857882aec59d27254b02481c709327c13de6fad1da60bfc4f9783eaaa61e"
+dependencies = [
+ "az",
+ "gmp-mpfr-sys",
+ "libc",
+ "libm",
+]
+
[[package]]
name = "ruint"
version = "1.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0298da754d1395046b0afdc2f20ee76d29a8ae310cd30ffa84ed42acba9cb12a"
+source = "git+https://github.com/Robert-MacWha/ruint#3dc44f0610de0770514ce92427436d21484c112c"
dependencies = [
"alloy-rlp",
"ark-ff 0.3.0",
"ark-ff 0.4.2",
"ark-ff 0.5.0",
+ "ark-ff 0.6.0",
"bytes",
"fastrlp 0.3.1",
"fastrlp 0.4.0",
@@ -7834,8 +10700,7 @@ dependencies = [
[[package]]
name = "ruint-macro"
version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18"
+source = "git+https://github.com/Robert-MacWha/ruint#3dc44f0610de0770514ce92427436d21484c112c"
[[package]]
name = "rust-embed"
@@ -7869,7 +10734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"globset",
- "sha2",
+ "sha2 0.10.9",
"walkdir",
]
@@ -7938,6 +10803,9 @@ name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+dependencies = [
+ "rand 0.8.6",
+]
[[package]]
name = "rustc-hex"
@@ -7973,7 +10841,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -7986,7 +10854,19 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.21.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
+dependencies = [
+ "log",
+ "ring",
+ "rustls-webpki 0.101.7",
+ "sct",
]
[[package]]
@@ -7999,21 +10879,42 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
- "rustls-webpki",
+ "rustls-webpki 0.103.13",
"subtle",
"zeroize",
]
+[[package]]
+name = "rustls-native-certs"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
+dependencies = [
+ "openssl-probe 0.1.6",
+ "rustls-pemfile 1.0.4",
+ "schannel",
+ "security-framework 2.11.1",
+]
+
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
- "openssl-probe",
+ "openssl-probe 0.2.1",
"rustls-pki-types",
"schannel",
- "security-framework",
+ "security-framework 3.7.0",
+]
+
+[[package]]
+name = "rustls-pemfile"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
+dependencies = [
+ "base64 0.21.7",
]
[[package]]
@@ -8046,14 +10947,14 @@ dependencies = [
"jni",
"log",
"once_cell",
- "rustls",
- "rustls-native-certs",
+ "rustls 0.23.40",
+ "rustls-native-certs 0.8.4",
"rustls-platform-verifier-android",
- "rustls-webpki",
- "security-framework",
+ "rustls-webpki 0.103.13",
+ "security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8062,6 +10963,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
+[[package]]
+name = "rustls-webpki"
+version = "0.101.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -8110,6 +11021,17 @@ dependencies = [
"unicode-script",
]
+[[package]]
+name = "ruzstd"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58c4eb8a81997cf040a091d1f7e1938aeab6749d3a0dfa73af43cdc32393483d"
+dependencies = [
+ "byteorder",
+ "derive_more 0.99.20",
+ "twox-hash",
+]
+
[[package]]
name = "ryu"
version = "1.0.23"
@@ -8125,6 +11047,30 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "scale-info"
+version = "2.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b"
+dependencies = [
+ "cfg-if",
+ "derive_more 1.0.0",
+ "parity-scale-codec",
+ "scale-info-derive",
+]
+
+[[package]]
+name = "scale-info-derive"
+version = "2.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "schannel"
version = "0.1.29"
@@ -8144,7 +11090,7 @@ dependencies = [
"chrono",
"flume",
"futures",
- "parking_lot",
+ "parking_lot 0.12.5",
"rand 0.9.4",
"web-time",
]
@@ -8187,6 +11133,17 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "schnellru"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "356285bbf17bea63d9e52e96bd18f039672ac92b55b8cb997d6162a2a37d1649"
+dependencies = [
+ "ahash",
+ "cfg-if",
+ "hashbrown 0.13.2",
+]
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -8216,6 +11173,16 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "sct"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
[[package]]
name = "seahash"
version = "4.1.0"
@@ -8230,7 +11197,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
- "generic-array",
+ "generic-array 0.14.7",
"pkcs8",
"serdect",
"subtle",
@@ -8278,6 +11245,19 @@ dependencies = [
"cc",
]
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.12.1",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -8335,6 +11315,12 @@ dependencies = [
"pest",
]
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
+
[[package]]
name = "serde"
version = "1.0.228"
@@ -8345,6 +11331,17 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "serde-wasm-bindgen"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
+dependencies = [
+ "js-sys",
+ "serde",
+ "wasm-bindgen",
+]
+
[[package]]
name = "serde_bytes"
version = "0.11.19"
@@ -8489,7 +11486,7 @@ version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
- "darling",
+ "darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -8518,12 +11515,38 @@ dependencies = [
"serde",
]
+[[package]]
+name = "sha-1"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6"
+dependencies = [
+ "block-buffer 0.9.0",
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.9.0",
+ "opaque-debug",
+]
+
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+[[package]]
+name = "sha2"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800"
+dependencies = [
+ "block-buffer 0.9.0",
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.9.0",
+ "opaque-debug",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -8574,13 +11597,23 @@ dependencies = [
"lazy_static",
]
+[[package]]
+name = "shared-buffer"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6c99835bad52957e7aa241d3975ed17c1e5f8c92026377d117a606f36b84b16"
+dependencies = [
+ "bytes",
+ "memmap2 0.6.2",
+]
+
[[package]]
name = "shellexpand"
version = "3.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
dependencies = [
- "dirs",
+ "dirs 6.0.0",
]
[[package]]
@@ -8677,6 +11710,12 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+[[package]]
+name = "slice-group-by"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "826167069c09b99d56f31e9ae5c99049e932a98c9dc2dac47645b08dbbf76ba7"
+
[[package]]
name = "slotmap"
version = "1.1.1"
@@ -8705,7 +11744,7 @@ dependencies = [
"async-executor",
"async-fs",
"async-io",
- "async-lock",
+ "async-lock 3.4.2",
"async-net",
"async-process",
"blocking",
@@ -8722,6 +11761,16 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "socket2"
version = "0.6.4"
@@ -8729,7 +11778,23 @@ 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]]
+name = "soketto"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d1c5305e39e09653383c2c7244f2f78b3bcae37cf50c64cb4789c9f5096ec2"
+dependencies = [
+ "base64 0.13.1",
+ "bytes",
+ "futures",
+ "http 0.2.12",
+ "httparse",
+ "log",
+ "rand 0.8.6",
+ "sha-1",
]
[[package]]
@@ -8769,6 +11834,22 @@ dependencies = [
"der",
]
+[[package]]
+name = "ssz_types"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b55bedc9a18ed2860a46d6beb4f4082416ee1d60be0cc364cebdcdddc7afd4"
+dependencies = [
+ "ethereum_serde_utils",
+ "ethereum_ssz",
+ "itertools 0.13.0",
+ "serde",
+ "serde_derive",
+ "smallvec",
+ "tree_hash",
+ "typenum",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -8785,7 +11866,7 @@ dependencies = [
"cfg-if",
"libc",
"psm",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8843,7 +11924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
- "parking_lot",
+ "parking_lot 0.12.5",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
@@ -8861,19 +11942,47 @@ dependencies = [
"quote",
]
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros 0.26.4",
+]
+
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
- "strum_macros",
+ "strum_macros 0.27.2",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.117",
]
[[package]]
@@ -8906,6 +12015,20 @@ dependencies = [
"ztracing",
]
+[[package]]
+name = "superstruct"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f4e1f478a7728f8855d7e620e9a152cf8932c6614f86564c886f9b8141f3201"
+dependencies = [
+ "darling 0.13.4",
+ "itertools 0.10.5",
+ "proc-macro2",
+ "quote",
+ "smallvec",
+ "syn 1.0.109",
+]
+
[[package]]
name = "sval"
version = "2.20.0"
@@ -9099,6 +12222,17 @@ dependencies = [
"system-configuration-sys",
]
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags 2.12.1",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
@@ -9134,6 +12268,12 @@ dependencies = [
"slotmap",
]
+[[package]]
+name = "tagptr"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+
[[package]]
name = "take-until"
version = "0.2.0"
@@ -9158,6 +12298,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr 1.6.1",
+]
+
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -9174,7 +12325,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -9300,6 +12451,15 @@ dependencies = [
"time-core",
]
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -9361,7 +12521,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
- "socket2",
+ "socket2 0.6.4",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -9377,13 +12537,33 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
+dependencies = [
+ "rustls 0.21.12",
+ "tokio",
+]
+
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
- "rustls",
+ "rustls 0.23.40",
"tokio",
]
@@ -9419,6 +12599,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
+ "futures-io",
"futures-sink",
"pin-project-lite",
"tokio",
@@ -9538,6 +12719,21 @@ version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
+[[package]]
+name = "tower"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project",
+ "pin-project-lite",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "tower"
version = "0.5.3"
@@ -9559,13 +12755,18 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
+ "async-compression",
"bitflags 2.12.1",
"bytes",
+ "futures-core",
"futures-util",
- "http",
- "http-body",
+ "http 1.4.1",
+ "http-body 1.0.1",
+ "http-body-util",
"pin-project-lite",
- "tower",
+ "tokio",
+ "tokio-util",
+ "tower 0.5.3",
"tower-layer",
"tower-service",
"url",
@@ -9616,6 +12817,16 @@ dependencies = [
"valuable",
]
+[[package]]
+name = "tracing-error"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db"
+dependencies = [
+ "tracing",
+ "tracing-subscriber 0.3.23",
+]
+
[[package]]
name = "tracing-log"
version = "0.2.0"
@@ -9627,6 +12838,15 @@ dependencies = [
"tracing-core",
]
+[[package]]
+name = "tracing-subscriber"
+version = "0.2.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0d2eaa99c3c2e41547cfa109e910a68ea03823cccad4a0525dcbc9b01e8c71"
+dependencies = [
+ "tracing-core",
+]
+
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
@@ -9641,6 +12861,17 @@ dependencies = [
"tracing-log",
]
+[[package]]
+name = "tracing-wasm"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07"
+dependencies = [
+ "tracing",
+ "tracing-subscriber 0.3.23",
+ "wasm-bindgen",
+]
+
[[package]]
name = "tray-icon"
version = "0.24.0"
@@ -9648,7 +12879,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47e6d063cfe4ad2e416fcbb310be3a37c5fd85c745b62cb562bfa4a003df674"
dependencies = [
"crossbeam-channel",
- "dirs",
+ "dirs 6.0.0",
"libappindicator",
"muda",
"objc2 0.6.4",
@@ -9659,7 +12890,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"thiserror 2.0.18",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -9692,6 +12923,31 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
+[[package]]
+name = "tree_hash"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee44f4cef85f88b4dea21c0b1f58320bdf35715cf56d840969487cff00613321"
+dependencies = [
+ "alloy-primitives",
+ "ethereum_hashing",
+ "ethereum_ssz",
+ "smallvec",
+ "typenum",
+]
+
+[[package]]
+name = "tree_hash_derive"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bee2ea1551f90040ab0e34b6fb7f2fa3bad8acc925837ac654f2c78a13e3089"
+dependencies = [
+ "darling 0.20.11",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "triomphe"
version = "0.1.15"
@@ -9718,6 +12974,16 @@ dependencies = [
"core_maths",
]
+[[package]]
+name = "twox-hash"
+version = "1.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675"
+dependencies = [
+ "cfg-if",
+ "static_assertions",
+]
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -9744,7 +13010,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -9765,6 +13031,15 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+[[package]]
+name = "uncased"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697"
+dependencies = [
+ "version_check",
+]
+
[[package]]
name = "unicase"
version = "2.9.0"
@@ -9887,6 +13162,25 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "userop-kit"
+version = "0.1.0"
+source = "git+https://github.com/ethereum/kohaku?rev=618c53facd0d44cf0f01d74e0dcc18d2242351c7#618c53facd0d44cf0f01d74e0dcc18d2242351c7"
+dependencies = [
+ "alloy",
+ "alloy-sol-types",
+ "async-trait",
+ "cfg_aliases",
+ "common",
+ "eip-1193-provider",
+ "futures",
+ "reqwest 0.13.4",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
[[package]]
name = "usvg"
version = "0.45.1"
@@ -9936,7 +13230,7 @@ dependencies = [
"async_zip",
"collections",
"command-fds",
- "dirs",
+ "dirs 6.0.0",
"dunce",
"futures",
"futures-lite 1.13.0",
@@ -9945,7 +13239,7 @@ dependencies = [
"itertools 0.14.0",
"libc",
"log",
- "mach2",
+ "mach2 0.5.0",
"nix 0.29.0",
"percent-encoding",
"regex",
@@ -10041,6 +13335,12 @@ dependencies = [
"sval_serde",
]
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -10193,7 +13493,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
- "wasmparser",
+ "wasmparser 0.244.0",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.251.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395"
+dependencies = [
+ "leb128fmt",
+ "wasmparser 0.251.0",
]
[[package]]
@@ -10204,8 +13514,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.14.0",
- "wasm-encoder",
- "wasmparser",
+ "wasm-encoder 0.244.0",
+ "wasmparser 0.244.0",
]
[[package]]
@@ -10221,6 +13531,21 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wasm-timer"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f"
+dependencies = [
+ "futures",
+ "js-sys",
+ "parking_lot 0.11.2",
+ "pin-utils",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "wasm_thread"
version = "0.3.3"
@@ -10233,6 +13558,160 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wasmer"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d85671948f8886a1cc946141c0b688a5617603c103699a5fceeebeb4e75b0b6"
+dependencies = [
+ "bindgen 0.70.1",
+ "bytes",
+ "cfg-if",
+ "cmake",
+ "derive_more 2.1.1",
+ "indexmap 2.14.0",
+ "js-sys",
+ "more-asserts",
+ "paste",
+ "rustc-demangle",
+ "serde",
+ "serde-wasm-bindgen",
+ "shared-buffer",
+ "tar",
+ "target-lexicon",
+ "thiserror 1.0.69",
+ "tracing",
+ "wasm-bindgen",
+ "wasmer-compiler",
+ "wasmer-compiler-cranelift",
+ "wasmer-derive",
+ "wasmer-types",
+ "wasmer-vm",
+ "wasmparser 0.224.1",
+ "wat",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "wasmer-compiler"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4946475adc0af265af8f10aadf4d4a3c64845bcd3801c655bdd81ce5e3ee869b"
+dependencies = [
+ "backtrace",
+ "bytes",
+ "cfg-if",
+ "enum-iterator 0.7.0",
+ "enumset",
+ "leb128",
+ "libc",
+ "macho-unwind-info",
+ "memmap2 0.6.2",
+ "more-asserts",
+ "object 0.32.2",
+ "region",
+ "rkyv",
+ "self_cell",
+ "shared-buffer",
+ "smallvec",
+ "target-lexicon",
+ "thiserror 1.0.69",
+ "wasmer-types",
+ "wasmer-vm",
+ "wasmparser 0.224.1",
+ "windows-sys 0.59.0",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "wasmer-compiler-cranelift"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "780f9c2050941b4e3f6bb82d32bd796a6c1750d2f97cbd892f07b384bb6af6f8"
+dependencies = [
+ "cranelift-codegen",
+ "cranelift-entity",
+ "cranelift-frontend",
+ "gimli 0.28.1",
+ "itertools 0.12.1",
+ "more-asserts",
+ "rayon",
+ "smallvec",
+ "target-lexicon",
+ "tracing",
+ "wasmer-compiler",
+ "wasmer-types",
+]
+
+[[package]]
+name = "wasmer-derive"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c546f3380840cd63fdcc390f04cd19002f2dfa19b4691b77ecbd27642bd93452"
+dependencies = [
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "wasmer-types"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94a4027ce165e8dc776dc5e2a3231a96983e6dc7330efd97b793cfc4e973ad0c"
+dependencies = [
+ "bytecheck 0.6.12",
+ "enum-iterator 0.7.0",
+ "enumset",
+ "getrandom 0.2.17",
+ "hex",
+ "indexmap 2.14.0",
+ "more-asserts",
+ "rkyv",
+ "sha2 0.10.9",
+ "target-lexicon",
+ "thiserror 1.0.69",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "wasmer-vm"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c37d5be291eea00a00d077ce3a427bb3074709ee386ec358f18f0b7da33be01"
+dependencies = [
+ "backtrace",
+ "cc",
+ "cfg-if",
+ "corosensei",
+ "crossbeam-queue",
+ "dashmap",
+ "enum-iterator 0.7.0",
+ "fnv",
+ "indexmap 2.14.0",
+ "libc",
+ "libunwind",
+ "mach2 0.4.3",
+ "memoffset",
+ "more-asserts",
+ "region",
+ "rustversion",
+ "scopeguard",
+ "thiserror 1.0.69",
+ "wasmer-types",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.224.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04f17a5917c2ddd3819e84c661fae0d6ba29d7b9c1f0e96c708c65a9c4188e11"
+dependencies = [
+ "bitflags 2.12.1",
+]
+
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -10245,6 +13724,17 @@ dependencies = [
"semver 1.0.28",
]
+[[package]]
+name = "wasmparser"
+version = "0.251.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff"
+dependencies = [
+ "bitflags 2.12.1",
+ "indexmap 2.14.0",
+ "semver 1.0.28",
+]
+
[[package]]
name = "wasmtimer"
version = "0.4.3"
@@ -10253,12 +13743,34 @@ checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b"
dependencies = [
"futures",
"js-sys",
- "parking_lot",
+ "parking_lot 0.12.5",
"pin-utils",
"slab",
"wasm-bindgen",
]
+[[package]]
+name = "wast"
+version = "251.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc7467dda0a96142eb2c980329dfb62480b1e1d3622fdeb1a44e2bca6ceed74"
+dependencies = [
+ "bumpalo",
+ "leb128fmt",
+ "memchr",
+ "unicode-width",
+ "wasm-encoder 0.251.0",
+]
+
+[[package]]
+name = "wat"
+version = "1.251.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81b1086c9e85b95bd6a229a928bc6c6d0662e42af0250c88d067b418831ea4d4"
+dependencies = [
+ "wast",
+]
+
[[package]]
name = "wayland-sys"
version = "0.31.11"
@@ -10300,6 +13812,15 @@ dependencies = [
"rustls-pki-types",
]
+[[package]]
+name = "webpki-roots"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b291546d5d9d1eab74f069c77749f2cb8504a12caa20f0f2de93ddbf6f411888"
+dependencies = [
+ "rustls-webpki 0.101.7",
+]
+
[[package]]
name = "weezl"
version = "0.1.12"
@@ -10321,7 +13842,7 @@ dependencies = [
"js-sys",
"log",
"naga",
- "parking_lot",
+ "parking_lot 0.12.5",
"portable-atomic",
"profiling",
"raw-window-handle",
@@ -10352,7 +13873,7 @@ dependencies = [
"log",
"naga",
"once_cell",
- "parking_lot",
+ "parking_lot 0.12.5",
"portable-atomic",
"profiling",
"raw-window-handle",
@@ -10424,7 +13945,7 @@ dependencies = [
"objc2-quartz-core 0.3.2",
"once_cell",
"ordered-float",
- "parking_lot",
+ "parking_lot 0.12.5",
"portable-atomic",
"portable-atomic-util",
"profiling",
@@ -10478,6 +13999,12 @@ dependencies = [
"winsafe",
]
+[[package]]
+name = "widestring"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -10500,7 +14027,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]]
@@ -10550,7 +14077,7 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24"
dependencies = [
- "parking_lot",
+ "parking_lot 0.12.5",
"rayon",
"thiserror 2.0.18",
"windows 0.61.3",
@@ -10733,6 +14260,17 @@ dependencies = [
"windows-strings 0.4.2",
]
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
[[package]]
name = "windows-result"
version = "0.1.2"
@@ -11158,9 +14696,9 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
- "wasm-encoder",
+ "wasm-encoder 0.244.0",
"wasm-metadata",
- "wasmparser",
+ "wasmparser 0.244.0",
"wit-parser",
]
@@ -11179,7 +14717,7 @@ dependencies = [
"serde_derive",
"serde_json",
"unicode-xid",
- "wasmparser",
+ "wasmparser 0.244.0",
]
[[package]]
@@ -11222,6 +14760,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix 1.1.4",
+]
+
[[package]]
name = "xcb"
version = "1.7.0"
@@ -11257,12 +14805,24 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
+[[package]]
+name = "xxhash-rust"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
+
[[package]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
+[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
[[package]]
name = "yazi"
version = "0.2.1"
@@ -11312,7 +14872,7 @@ dependencies = [
"async-broadcast",
"async-executor",
"async-io",
- "async-lock",
+ "async-lock 3.4.2",
"async-process",
"async-recursion",
"async-task",
@@ -11400,6 +14960,21 @@ dependencies = [
"zvariant",
]
+[[package]]
+name = "zduny-wasm-timer"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f52bd30296679f51dce4a4da2a5050d9401d09866d89b89d860da37bc3ec08df"
+dependencies = [
+ "futures",
+ "js-sys",
+ "parking_lot 0.12.5",
+ "pin-utils",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "zed-font-kit"
version = "0.14.1-zed"
@@ -11410,7 +14985,7 @@ dependencies = [
"core-foundation 0.10.0",
"core-graphics 0.24.0",
"core-text",
- "dirs",
+ "dirs 6.0.0",
"dwrote",
"float-ord",
"freetype-sys",
@@ -11434,12 +15009,12 @@ dependencies = [
"encoding_rs",
"futures-core",
"futures-util",
- "h2",
- "http",
- "http-body",
+ "h2 0.4.14",
+ "http 1.4.1",
+ "http-body 1.0.1",
"http-body-util",
- "hyper",
- "hyper-rustls",
+ "hyper 1.10.1",
+ "hyper-rustls 0.27.9",
"hyper-util",
"ipnet",
"js-sys",
@@ -11450,20 +15025,20 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
- "rustls",
- "rustls-native-certs",
- "rustls-pemfile",
+ "rustls 0.23.40",
+ "rustls-native-certs 0.8.4",
+ "rustls-pemfile 2.2.0",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
- "system-configuration",
+ "system-configuration 0.6.1",
"tokio",
- "tokio-rustls",
+ "tokio-rustls 0.26.4",
"tokio-socks",
"tokio-util",
- "tower",
+ "tower 0.5.3",
"tower-service",
"url",
"wasm-bindgen",
@@ -11623,13 +15198,41 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
[[package]]
name = "ztracing"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#86effffd34634945a4971e1c6c65cd45b21ce6a9"
dependencies = [
"tracing",
- "tracing-subscriber",
+ "tracing-subscriber 0.3.23",
"zlog",
"ztracing_macro",
]
diff --git a/Cargo.toml b/Cargo.toml
index 3456779..301b844 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,80 +1,54 @@
-[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)
+# - deckard-signerd the process-isolated signer daemon (owns the key; UDS server)
+#
+# `cargo run` from the repo root still launches the app via `default-members`.
[workspace]
-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"
-
-# --- 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"] }
+resolver = "2"
+members = [
+ "crates/deckard-app",
+ "crates/deckard-core",
+ "crates/deckard-contract",
+ "crates/deckard-signerd",
+]
+default-members = ["crates/deckard-app"]
+
+[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"
+# Mirror Helios's + Kohaku/Railgun's workspace patches. `[patch]` only resolves at the
+# workspace ROOT and does NOT inherit through a git dependency, so every patched git-dep
+# (helios-ethereum behind `verified-reads`; the ZK `railgun` tree behind deckard-core's
+# `shield` feature) needs its patches here or those crates fail to build.
+# - ethereum_hashing: Helios consensus crates (verified-reads).
+# - ruint + ark-circom: MANDATORY for the Railgun ZK tree (merkle tree / circom-compat) —
+# copied from the proven shield spike. With `shield` off these resolve but pull nothing
+# extra into the build.
+[patch.crates-io]
+ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" }
+ruint = { git = "https://github.com/Robert-MacWha/ruint" }
+ark-circom = { git = "https://github.com/Robert-MacWha/circom-compat", branch = "release/0.6.0" }
+
+# Redirect Kohaku's own `eip-1193-provider` to a native-only fork (vendor/) that DROPS the
+# wasm32-only `js` feature. Upstream's `js` default pins `wasm-bindgen = "=0.2.108"`, which
+# collides head-on with the GPUI app's `web-sys`→`wasm-bindgen = "=0.2.122"` exact pin — two
+# exact pins can't coexist in one workspace. Deckard never builds wasm32, so the fork removes
+# `js` (and only `js`); the native API railgun uses is byte-identical. See
+# vendor/eip-1193-provider/Cargo.toml for the full rationale. This is what lets the heavy
+# `railgun` shield tree live in the app workspace behind the default-on `shield` feature.
+[patch."https://github.com/ethereum/kohaku"]
+eip-1193-provider = { path = "vendor/eip-1193-provider" }
diff --git a/README.md b/README.md
index dfdfc23..a42940b 100644
--- a/README.md
+++ b/README.md
@@ -6,17 +6,15 @@ Native (macOS + Linux), trustless by construction, open source.
> Forked from the [`deck`](https://github.com/hellno/deck) GPUI starter (0BSD, which permits
> relicensing). Now its own project: Rust + [GPUI](https://www.gpui.rs/), licensed AGPL-3.0-or-later.
-## Status — v0 (working today)
+## Status
-- **Real self-custodial wallet** via [alloy](https://github.com/alloy-rs/alloy)
- (`alloy-signer-local`): a secp256k1 keypair, EIP-55 address, key persisted to the OS config
- dir. No hand-rolled crypto.
-- **Portfolio** screen: address, balance, Send / Receive / Swap, holdings.
-- **Receive**: a real scannable QR plus copy-to-clipboard.
-- **Command palette** (`cmd-K`) and the amber-on-near-black design system (see `DESIGN.md`).
-- Light / dark.
+**Live build status: [`STATUS.md`](STATUS.md) — the single source of truth** (demo beats, crates, risks).
-Representative / not yet wired (next): live balances, Send/Swap execution, BIP-39 seed backup.
+Working today: encrypted BIP-39 keystore + onboarding, live on-chain balances (Multicall3), receive (QR),
+command palette, and the amber-on-near-black design system (`DESIGN.md`). Reads are **Helios-verified** (no
+third-party RPC trusted by default). A process-isolated signer daemon (`deckard-signerd`) holds the key and
+gates every write. The **shield** hero (auto-private via Railgun) is wired + black-box tested on an anvil fork.
+Next: receive-watcher, the agent (MCP) surface, Send/Swap UI. See `STATUS.md` for the beat-by-beat picture.
## Roadmap
diff --git a/STATUS.md b/STATUS.md
new file mode 100644
index 0000000..5cd15b0
--- /dev/null
+++ b/STATUS.md
@@ -0,0 +1,70 @@
+# Deckard — STATUS
+
+> **Single source of truth for where the build stands.** Specs/design live in `specs/` + `docs/build/`;
+> the git log is the audit trail. The READMEs and `specs/*` are reference, not status — update *this* file
+> when a track changes state.
+>
+> Last updated: **2026-06-06** · at commit `a0a37fd`.
+
+Legend: ✅ done + tested · 🟡 partial / integrated-not-finished · 🧪 spiked (proven, not wired in) · ⬜ todo · ⏸ deferred
+
+> **Reality check (independent Codex audit, 2026-06-06):** the *mechanisms* are built + de-risked and the security
+> state machine is real — but the **recordable demo FLOW does not exist end-to-end yet**. No receive-watcher, no
+> agent/MCP trigger, no in-app live cut, and **shield is reachable only from the test/manual client, not the app
+> or an agent.** "Tested" below includes `#[ignore]` network tests (need anvil + an archive RPC, not run by default
+> `cargo test`) and some mocked/fake-daemon unit tests — noted per row. So: strong foundation, demo flow unwired.
+
+## The v1 demo — *receive → instantly private → can't switch it off* (agent-driven)
+
+| Beat | What | Status | Where | Next |
+|---|---|---|---|---|
+| **1 · receive** | detect inbound + surface it | 🟡 receive (addr + QR) built; **auto-detect watcher ⬜** | `deckard-app/receive.rs`; `policy.auto_shield_min_wei` exists | a Helios `get_logs` watcher that emits the shield intent |
+| **2 · shield (HERO)** | auto-shield received funds via Railgun | 🟡 mechanism built (core builder + daemon broadcast) + black-box test — **but test is `#[ignore]` and shield is NOT app/agent-reachable** | spike `c30cdd4`; `deckard-core/shield.rs` + signerd broadcast + `shield_e2e` (#[ignore]) `a0a37fd` | a **trigger** (button/watcher/agent) + a **shielded-balance view** |
+| **3 · walkaway** | cut the RPC on camera, stay verified | ✅ R2 spiked + verified-reads integrated; 🟡 in-app live cut | spike `5e3a16d`; reads `9e19e9a`; `ReadStatus` badge in app | failover supervisor (`Degraded`) + the in-app cut |
+| **agent surface (MCP)** | Claude Desktop drives `shield` via MCP | ⬜ `deckard-mcp` not built; contract + daemon socket ✅ | `deckard-contract`, `deckard-signerd` | build `deckard-mcp` (key-less) over the daemon socket + `simulate` |
+
+## Crates / tracks
+
+| Crate | State | Tests (passing) | Key commits |
+|---|---|---|---|
+| `deckard-contract` | ✅ `Intent`/`Decision`/`Policy` + `ReadStatus` + `calldata_ok` non-empty invariant | 32 | `da29a37` `9e19e9a` `a0a37fd` |
+| `deckard-core` | ✅ `EthProvider` (C1) + balances/Multicall3 (C2) + encrypted keystore (C3) + Helios verified reads + key-less shield builder | 13 | `e1aa079` `42e04ad` `57f21bc` `9e19e9a` `a0a37fd` |
+| `deckard-signerd` | ✅ process-isolated signer daemon + policy gate + STOP/zeroize + Helios read + calldata broadcast | `daemon_e2e` 9 · `parity` 1 · `anvil_e2e` 3 · `shield_e2e` 1 | `a24f62c` `9e19e9a` `a0a37fd` |
+| `deckard-app` (GPUI) | ✅ onboarding / portfolio / receive / palette / settings + `ReadStatus` badge + socket signer client; 🟡 Send UI gated ("next release"), Swap ⬜ | `send_path` | C1–C3 + this branch |
+
+> Test caveats (per the audit): `signerd/shield_e2e` is `#[ignore]` (network + anvil + archive RPC); `anvil_e2e` runs by default but **silently skips if `anvil` is missing**; `deckard-core`'s reads are tested against a *mocked* transport (live-Helios path is untested by default); `deckard-app`'s `send_path` test uses a *fake recording daemon*, not real signerd/chain. The daemon STOP/zeroize + propose→Decision→execute tests are real (run by default).
+
+## Spikes (de-risk, proven, standalone under `spikes/`)
+
+| Spike | Proves | Commit |
+|---|---|---|
+| `helios-walkaway` | R2 walkaway on mainnet (cut EL → keep verified; refuse a lying RPC) | `5e3a16d` |
+| `eip1193-railgun` | Helios's localhost server **is** Railgun's EIP-1193 provider (one-line `with_default_block(latest)` fix) | `a64d4c5` |
+| `shield-railgun` | full shield→sync→balance→transfer→unshield from our edge; shield is instant, proving is on the spend | `c30cdd4` |
+
+## v0 wallet base (`specs/SPEC-v0.md`)
+
+balances ✅ · receive ✅ · keystore + onboarding ✅ (BIP-39 vault) · **send** (daemon + app-socket path ✅/tested, UI gated 🟡) · **swap** ⬜ (CoW, deferred; button disabled)
+
+## Remaining for a minimum recordable demo
+
+The mechanisms are de-risked + built; the gap is **reachability + visible state** (turning terminal-proven paths into on-screen beats). In record order:
+
+- **A · Beat 2 (hero) — shield on screen** *(the one genuinely new build)*: a **trigger** (a "Shield" button → `build_shield_native_intent` → propose → execute, the path `shield_e2e` already drives) + a **shielded-balance view** (derive the railgun viewing key from the seed, in-app railgun `sync`, show the `0zk` balance rise while public drops + "link broken"). Without the shielded-balance view, "instantly private" has no visual.
+- **B · Beat 3 — in-app walkaway**: a demo control to cut the RPC + the `ReadStatus` badge flipping `Verified → failover → Verified` (or `NOT VERIFIED`). The badge already renders in the GUI; minimum viable is the single-client "cut → NOT VERIFIED → reconnect → Verified" framing (the Shape-A failover supervisor is the nicer version, deferred).
+- **C · Beat 1 — receive landing**: balance refreshes visibly when funds arrive (poll/refresh; QR exists). Auto-watcher → auto-shield is the fuller version; manual refresh suffices to record.
+- **D · the "agent-driven" spine**: minimum = a scripted/manual stand-in (the app triggers the shield, narrated as the agent); fuller = build `deckard-mcp` + drive from Claude Desktop.
+- **E · one continuous take + polish** to `DESIGN.md` (onboarding → funded → 3 beats).
+
+**Critical path for the smallest take:** A + B + C, with D as narration. **Biggest single build: the shielded-balance view (A).**
+
+## Open risks / track-before-ship
+
+- **CL is the fragile, no-SLA dependency** (walkaway): Nimbus + dRPC are the two proven CLs; **cut the EL on camera, never the CL.** [`20`]
+- **`vendor/eip-1193-provider`** native-only fork (dodges a `wasm-bindgen` exact-pin conflict) + **railgun license** (no upstream license field, same R1e) — resolve both before ship. [`10`]
+- Shield is instant (no client proof); spend proving ~10s cold / ~halved with `parallel` → a "spending…" UX for unshield. [`10`]
+- Daemon holds its mutex across a broadcast (documented v1 tradeoff). Receive-watcher, MCP, and railgun key-derivation for balance-display are deferred.
+
+## Deferred → `docs/research/roadmap.md`
+
+STOP-on-camera beat · allocate/donate · EIP-7702 session keys · x402/MPP plugins · stealth addresses · hardware-wallet signing · audit · Kurtosis hermetic-CI lane · production `HeliosEip1193` adapter · on-camera unshield/transfer.
diff --git a/crates/deckard-app/Cargo.toml b/crates/deckard-app/Cargo.toml
new file mode 100644
index 0000000..8b5d624
--- /dev/null
+++ b/crates/deckard-app/Cargo.toml
@@ -0,0 +1,92 @@
+[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" }
+# The signer daemon, used as a LIBRARY here: the app spawns + supervises the daemon binary
+# and talks to it over the socket (unlock / propose / execute). The app holds NO key — only
+# the client + the supervisor. The daemon binary is built as a sibling and launched at start.
+deckard-signerd = { path = "../deckard-signerd" }
+
+# Fresh GPUI, straight from Zed's git — the DEFAULT channel (Metal on macOS, wgpu on
+# Linux). `gpui-component` is developed against Zed's gpui HEAD, so the only way to pair
+# 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"
+# Error type that flows from deckard-core (keystore) + the signer client through the app's
+# background tasks.
+anyhow = "1"
+qrcode = "0.14"
+# Scrub the pending recovery phrase from memory after onboarding.
+zeroize = "1"
+
+# --- 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"
+
+[dev-dependencies]
+# The #9 test stands up a tiny recording UDS server to prove the app's send path issues
+# Propose/Execute over the socket (and signs nothing in-process).
+tokio = { version = "1", features = ["rt", "net", "io-util", "macros"] }
+
+[features]
+default = []
+tray = ["dep:tray-icon", "dep:objc2", "dep:objc2-app-kit", "dep:objc2-foundation"]
+
+# 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 97%
rename from src/main.rs
rename to crates/deckard-app/src/main.rs
index 581d06c..84f3564 100644
--- a/src/main.rs
+++ b/crates/deckard-app/src/main.rs
@@ -7,15 +7,16 @@
//! Fork checklist: rename the crate in `Cargo.toml`, change `APP_NAME` and the
//! bundle identifier, swap `assets/icon.png`, then start editing the views.
+mod onboarding;
+mod palette;
+mod receive;
mod settings;
mod settings_view;
mod shell;
+mod signer;
mod theme;
#[cfg(feature = "tray")]
mod tray;
-mod onboarding;
-mod palette;
-mod receive;
mod wallet;
mod welcome;
@@ -35,7 +36,15 @@ pub const APP_NAME: &str = "Deckard";
// to, hang a menu item off of, and handle in a view or globally. Add your own here.
gpui::actions!(
deckard,
- [Quit, About, OpenSettings, ToggleTheme, NewItem, GoBack, TogglePalette]
+ [
+ Quit,
+ About,
+ OpenSettings,
+ ToggleTheme,
+ NewItem,
+ GoBack,
+ TogglePalette
+ ]
);
fn main() {
diff --git a/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 77%
rename from src/shell.rs
rename to crates/deckard-app/src/shell.rs
index f8e2e50..2c10bf3 100644
--- a/src/shell.rs
+++ b/crates/deckard-app/src/shell.rs
@@ -15,13 +15,21 @@ use gpui_component::{
v_flex, ActiveTheme, IconName, TitleBar,
};
-use deckard_core::{Address, EthProvider, KdfParams, Portfolio, UnlockedVault, Vault, WordCount};
+use deckard_core::{Address, EthProvider, KdfParams, Portfolio, ReadStatus, Vault, WordCount};
use zeroize::Zeroizing;
+use deckard_signerd::SignerClient;
+
use crate::settings::{Settings, ThemeModePref};
+use crate::signer::{self, AppSigner};
use crate::theme::{self, Accent};
use crate::wallet;
-use crate::{GoBack, NewItem, OpenSettings, ToggleTheme, TogglePalette, APP_NAME};
+use crate::{GoBack, NewItem, OpenSettings, TogglePalette, ToggleTheme, APP_NAME};
+
+/// The chain the supervised daemon signs for. v1 is mainnet-first (the default RPC is
+/// mainnet); multi-chain app config that re-points both the reader and the daemon is a
+/// fast-follow.
+const DAEMON_CHAIN_ID: u64 = 1;
/// Trim a noisy provider error down to one short line for the UI.
fn short_err(e: impl std::fmt::Display) -> String {
@@ -30,6 +38,20 @@ fn short_err(e: impl std::fmt::Display) -> String {
line.chars().take(140).collect()
}
+/// Run `prework` (which seals + writes the keystore for create/import/migrate, or is a no-op
+/// for a plain unlock), then unlock OVER THE DAEMON SOCKET — the key is decrypted in the
+/// daemon, never here. Returns the wallet address or a one-line, user-facing error. Always
+/// called from a background thread (it blocks on Argon2 + a socket round-trip).
+fn write_then_unlock(
+ client: &SignerClient,
+ passphrase: &str,
+ prework: impl FnOnce() -> anyhow::Result<()>,
+) -> Result
{
+ prework().map_err(short_err)?;
+ let outcome = client.unlock_blocking(passphrase).map_err(short_err)?;
+ signer::address_or_error(outcome)
+}
+
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Route {
Welcome,
@@ -72,10 +94,13 @@ pub struct Shell {
pub auth_error: Option,
/// True while an Argon2 create/unlock runs on a background thread.
pub auth_busy: bool,
- /// The unlocked wallet's own address (for Receive / copy). `None` until unlocked.
+ /// The unlocked wallet's own address (for Receive / copy). `None` until unlocked. This is
+ /// the ONLY wallet identity the app holds — the key lives in the daemon, never here.
pub wallet_address: Option,
- /// The in-memory unlocked wallet; dropped (and zeroized) on lock.
- unlocked: Option,
+ /// The key-less bridge to the process-isolated signer daemon: the app spawns + supervises
+ /// it and talks over the socket (unlock / propose / execute). Unlock happens *in the
+ /// daemon*; the app only learns the address. Dropping this kills the daemon child.
+ signer: AppSigner,
/// During create: the sealed-but-unwritten vault and its phrase, pending backup.
pending_vault: Option,
pub pending_phrase: Option>,
@@ -106,6 +131,9 @@ pub struct Shell {
/// True only during the first sync (the one allowed loading state).
pub portfolio_loading: bool,
pub portfolio_error: Option,
+ /// Trust label for the last portfolio/block read: Helios-`Verified` vs visibly
+ /// `Unsynced`/`Degraded`. Never silently "trusted" — surfaced in the status line.
+ pub read_status: Option,
/// Latest block height — a liveness/sync indicator for the status line.
pub synced_block: Option,
/// Bumped on every `retarget`; a slow ENS resolution checks it before applying so a
@@ -142,18 +170,21 @@ impl Shell {
.placeholder("https://… (default: bundled public RPC)")
.default_value(settings.rpc_url.clone())
});
- cx.subscribe(&rpc_input, |this, state, event: &InputEvent, cx| match event {
- InputEvent::Change => {
- this.settings.rpc_url = state.read(cx).value().to_string();
- this.settings.save();
- }
- InputEvent::Blur => {
- this.settings.rpc_url = state.read(cx).value().to_string();
- this.settings.save();
- this.respawn_provider(cx);
- }
- _ => {}
- })
+ cx.subscribe(
+ &rpc_input,
+ |this, state, event: &InputEvent, cx| match event {
+ InputEvent::Change => {
+ this.settings.rpc_url = state.read(cx).value().to_string();
+ this.settings.save();
+ }
+ InputEvent::Blur => {
+ this.settings.rpc_url = state.read(cx).value().to_string();
+ this.settings.save();
+ this.respawn_provider(cx);
+ }
+ _ => {}
+ },
+ )
.detach();
// Watch address / ENS: persist as typed; re-target the portfolio on blur.
@@ -162,18 +193,21 @@ impl Shell {
.placeholder("0x… or name.eth (blank = your wallet)")
.default_value(settings.watch_address.clone())
});
- cx.subscribe(&watch_input, |this, state, event: &InputEvent, cx| match event {
- InputEvent::Change => {
- this.settings.watch_address = state.read(cx).value().to_string();
- this.settings.save();
- }
- InputEvent::Blur => {
- this.settings.watch_address = state.read(cx).value().to_string();
- this.settings.save();
- this.retarget(cx);
- }
- _ => {}
- })
+ cx.subscribe(
+ &watch_input,
+ |this, state, event: &InputEvent, cx| match event {
+ InputEvent::Change => {
+ this.settings.watch_address = state.read(cx).value().to_string();
+ this.settings.save();
+ }
+ InputEvent::Blur => {
+ this.settings.watch_address = state.read(cx).value().to_string();
+ this.settings.save();
+ this.retarget(cx);
+ }
+ _ => {}
+ },
+ )
.detach();
// Auth inputs — passphrases are masked and NEVER persisted to disk.
@@ -185,10 +219,12 @@ impl Shell {
let create_pass2 = masked(window, cx, "Confirm passphrase");
let import_pass = masked(window, cx, "Choose a passphrase (min 8 characters)");
let pass_input = masked(window, cx, "Passphrase");
- let confirm_words =
- cx.new(|cx| InputState::new(window, cx).placeholder("the requested words, space-separated"));
- let import_secret = cx
- .new(|cx| InputState::new(window, cx).placeholder("12 / 24-word phrase, or a 0x private key"));
+ let confirm_words = cx.new(|cx| {
+ InputState::new(window, cx).placeholder("the requested words, space-separated")
+ });
+ let import_secret = cx.new(|cx| {
+ InputState::new(window, cx).placeholder("12 / 24-word phrase, or a 0x private key")
+ });
// Submit-on-Enter for each auth field (keyboard-first).
cx.subscribe(&create_pass2, |this, _, event: &InputEvent, cx| {
@@ -231,6 +267,11 @@ impl Shell {
let current_rpc = settings.effective_rpc();
let eth = EthProvider::spawn(current_rpc.clone());
+ // Spawn + supervise the process-isolated signer daemon. It owns the key; the app is a
+ // key-less client that unlocks/signs over the socket. The daemon broadcasts via the
+ // same RPC the app reads from.
+ let signer = AppSigner::launch(current_rpc.clone(), DAEMON_CHAIN_ID);
+
Self {
focus_handle,
route: Route::Welcome,
@@ -244,7 +285,7 @@ impl Shell {
auth_error: None,
auth_busy: false,
wallet_address: None,
- unlocked: None,
+ signer,
pending_vault: None,
pending_phrase: None,
pending_pass: None,
@@ -263,6 +304,7 @@ impl Shell {
portfolio: None,
portfolio_loading: false,
portfolio_error: None,
+ read_status: None,
synced_block: None,
view_epoch: 0,
current_rpc,
@@ -303,9 +345,14 @@ impl Shell {
}
}
- /// Lock the wallet: drop (zeroize) the unlocked secret and return to the unlock gate.
+ /// Lock the wallet: tell the daemon to zeroize the key (best-effort, off the UI thread)
+ /// and return to the unlock gate. The app held no key to drop — locking is the daemon's job.
pub fn lock(&mut self, cx: &mut Context) {
- self.unlocked = None;
+ let client = self.signer.client();
+ cx.background_spawn(async move {
+ let _ = client.lock_blocking();
+ })
+ .detach();
self.wallet_address = None;
self.portfolio = None;
self.auth = AuthStep::Unlock;
@@ -401,24 +448,24 @@ impl Shell {
cx.notify();
return;
};
+ let client = self.signer.client();
let task = cx.background_spawn(async move {
- vault.write_atomic(&path)?;
- vault.unlock(pass.as_str())
+ write_then_unlock(&client, pass.as_str(), move || vault.write_atomic(&path))
});
cx.spawn(async move |this, cx| {
let res = task.await;
this.update(cx, |this, cx| {
this.auth_busy = false;
match res {
- Ok(unlocked) => {
+ Ok(addr) => {
wallet::delete_legacy_key();
this.pending_phrase = None;
this.pending_pass = None;
this.pending_vault = None;
- this.finish_unlock(unlocked, cx);
+ this.finish_unlock(addr, cx);
}
- Err(e) => {
- this.auth_error = Some(short_err(e));
+ Err(msg) => {
+ this.auth_error = Some(msg);
cx.notify();
}
}
@@ -456,34 +503,38 @@ impl Shell {
};
let secret = Zeroizing::new(secret);
let pass = Zeroizing::new(pass);
+ let seal_pass = pass.clone();
+ let client = self.signer.client();
let task = cx.background_spawn(async move {
- let trimmed = secret.trim();
- // Route by shape, not word count: a pure-hex string (optional 0x) is a raw key;
- // anything with spaces/words is a mnemonic, so a short/long phrase gets a real
- // BIP-39 error rather than a misleading "must be 32 bytes".
- let h = trimmed.strip_prefix("0x").unwrap_or(trimmed);
- let looks_like_hex_key = !trimmed.contains(char::is_whitespace)
- && !h.is_empty()
- && h.chars().all(|c| c.is_ascii_hexdigit());
- let vault = if looks_like_hex_key {
- Vault::import_raw_key(trimmed, pass.as_str(), KdfParams::PRODUCTION)?
- } else {
- Vault::import_mnemonic(trimmed, pass.as_str(), KdfParams::PRODUCTION)?
- };
- vault.write_atomic(&path)?;
- vault.unlock(pass.as_str())
+ write_then_unlock(&client, pass.as_str(), move || {
+ let trimmed = secret.trim();
+ // Route by shape, not word count: a pure-hex string (optional 0x) is a raw
+ // key; anything with spaces/words is a mnemonic, so a short/long phrase gets a
+ // real BIP-39 error rather than a misleading "must be 32 bytes".
+ let h = trimmed.strip_prefix("0x").unwrap_or(trimmed);
+ let looks_like_hex_key = !trimmed.contains(char::is_whitespace)
+ && !h.is_empty()
+ && h.chars().all(|c| c.is_ascii_hexdigit());
+ let vault = if looks_like_hex_key {
+ Vault::import_raw_key(trimmed, seal_pass.as_str(), KdfParams::PRODUCTION)?
+ } else {
+ Vault::import_mnemonic(trimmed, seal_pass.as_str(), KdfParams::PRODUCTION)?
+ };
+ vault.write_atomic(&path)?;
+ Ok(())
+ })
});
cx.spawn(async move |this, cx| {
let res = task.await;
this.update(cx, |this, cx| {
this.auth_busy = false;
match res {
- Ok(unlocked) => {
+ Ok(addr) => {
wallet::delete_legacy_key();
- this.finish_unlock(unlocked, cx);
+ this.finish_unlock(addr, cx);
}
- Err(e) => {
- this.auth_error = Some(short_err(e));
+ Err(msg) => {
+ this.auth_error = Some(msg);
cx.notify();
}
}
@@ -507,25 +558,19 @@ impl Shell {
self.auth_error = None;
self.auth_busy = true;
cx.notify();
- let Some(path) = wallet::vault_path() else {
- self.auth_error = Some("no config directory available".into());
- self.auth_busy = false;
- cx.notify();
- return;
- };
+ // No vault write: the daemon reads the existing keystore and decrypts it.
let pass = Zeroizing::new(pass);
- let task = cx.background_spawn(async move {
- let vault = Vault::read(&path)?;
- vault.unlock(pass.as_str())
- });
+ let client = self.signer.client();
+ let task = cx
+ .background_spawn(async move { write_then_unlock(&client, pass.as_str(), || Ok(())) });
cx.spawn(async move |this, cx| {
let res = task.await;
this.update(cx, |this, cx| {
this.auth_busy = false;
match res {
- Ok(unlocked) => this.finish_unlock(unlocked, cx),
- Err(e) => {
- this.auth_error = Some(short_err(e));
+ Ok(addr) => this.finish_unlock(addr, cx),
+ Err(msg) => {
+ this.auth_error = Some(msg);
cx.notify();
}
}
@@ -561,23 +606,28 @@ impl Shell {
return;
};
let pass = Zeroizing::new(pass);
+ let seal_pass = pass.clone();
let hex = Zeroizing::new(hex);
+ let client = self.signer.client();
let task = cx.background_spawn(async move {
- let vault = Vault::import_raw_key(hex.as_str(), pass.as_str(), KdfParams::PRODUCTION)?;
- vault.write_atomic(&path)?;
- vault.unlock(pass.as_str())
+ write_then_unlock(&client, pass.as_str(), move || {
+ let vault =
+ Vault::import_raw_key(hex.as_str(), seal_pass.as_str(), KdfParams::PRODUCTION)?;
+ vault.write_atomic(&path)?;
+ Ok(())
+ })
});
cx.spawn(async move |this, cx| {
let res = task.await;
this.update(cx, |this, cx| {
this.auth_busy = false;
match res {
- Ok(unlocked) => {
+ Ok(addr) => {
wallet::delete_legacy_key();
- this.finish_unlock(unlocked, cx);
+ this.finish_unlock(addr, cx);
}
- Err(e) => {
- this.auth_error = Some(short_err(e));
+ Err(msg) => {
+ this.auth_error = Some(msg);
cx.notify();
}
}
@@ -587,27 +637,21 @@ impl Shell {
.detach();
}
- /// Land in the unlocked app: stash the wallet, derive its address, fetch the portfolio.
- fn finish_unlock(&mut self, unlocked: UnlockedVault, cx: &mut Context) {
- match unlocked.primary_address() {
- Ok(addr) => {
- self.wallet_address = Some(addr);
- self.unlocked = Some(unlocked);
- self.auth = AuthStep::Ready;
- self.auth_error = None;
- self.route = Route::Welcome;
- self.retarget(cx);
- }
- Err(e) => {
- self.auth_error = Some(short_err(e));
- cx.notify();
- }
- }
+ /// Land in the unlocked app: stash the address the daemon returned and fetch the
+ /// portfolio. The key stays in the daemon — the app only holds this address.
+ fn finish_unlock(&mut self, address: Address, cx: &mut Context) {
+ self.wallet_address = Some(address);
+ self.auth = AuthStep::Ready;
+ self.auth_error = None;
+ self.route = Route::Welcome;
+ self.retarget(cx);
}
/// The unlocked wallet's own address as an EIP-55 string (empty until unlocked).
pub fn wallet_address_string(&self) -> String {
- self.wallet_address.map(|a| a.to_string()).unwrap_or_default()
+ self.wallet_address
+ .map(|a| a.to_string())
+ .unwrap_or_default()
}
/// Auto-focus the primary input for the current auth step (so the user — and the
@@ -640,11 +684,13 @@ impl Shell {
this.update(cx, |this, cx| {
this.portfolio_loading = false;
match res {
- Ok(Ok(p)) => {
+ Ok(Ok(read)) => {
// Ignore a stale reply for an address we're no longer viewing.
- if p.address == this.display_address {
- this.portfolio = Some(p);
+ if read.value.address == this.display_address {
+ this.portfolio = Some(read.value);
this.portfolio_error = None;
+ // Surface the trust label (Helios-verified vs unsynced).
+ this.read_status = Some(read.status);
}
}
Ok(Err(e)) => this.portfolio_error = Some(short_err(e)),
@@ -661,9 +707,10 @@ impl Shell {
fn kick_block_number(eth: &EthProvider, cx: &mut Context) {
let rx = eth.block_number();
cx.spawn(async move |this, cx| {
- if let Ok(Ok(n)) = rx.recv_async().await {
+ if let Ok(Ok(read)) = rx.recv_async().await {
this.update(cx, |this, cx| {
- this.synced_block = Some(n);
+ this.synced_block = Some(read.value);
+ this.read_status = Some(read.status);
cx.notify();
})
.ok();
@@ -715,20 +762,21 @@ impl Shell {
return;
}
match res {
- Ok(Ok(addr)) => {
- this.display_address = addr;
- this.refresh_portfolio(cx);
- }
- Ok(Err(e)) => {
- this.portfolio_loading = false;
- this.portfolio_error = Some(format!("couldn't resolve name — {}", short_err(e)));
- cx.notify();
- }
- Err(_) => {
- this.portfolio_loading = false;
- this.portfolio_error = Some("network worker stopped".into());
- cx.notify();
- }
+ Ok(Ok(addr)) => {
+ this.display_address = addr;
+ this.refresh_portfolio(cx);
+ }
+ Ok(Err(e)) => {
+ this.portfolio_loading = false;
+ this.portfolio_error =
+ Some(format!("couldn't resolve name — {}", short_err(e)));
+ cx.notify();
+ }
+ Err(_) => {
+ this.portfolio_loading = false;
+ this.portfolio_error = Some("network worker stopped".into());
+ cx.notify();
+ }
}
})
.ok();
@@ -739,6 +787,12 @@ impl Shell {
/// Re-spawn the network worker against the RPC URL, but only if it actually changed —
/// so a no-op blur of the RPC field doesn't tear down the live worker and refetch.
+ ///
+ /// v1 limitation: this re-points only the *reader*. The signer daemon's RPC + chain are
+ /// fixed at launch (mainnet-first), so changing the RPC here does NOT re-point where the
+ /// daemon would broadcast. There is no send UI yet (T-UX), so nothing broadcasts through a
+ /// diverged endpoint; re-pointing the daemon (and forcing a re-unlock) lands with the send
+ /// screen.
pub fn respawn_provider(&mut self, cx: &mut Context) {
let url = self.settings.effective_rpc();
if url == self.current_rpc {
diff --git a/crates/deckard-app/src/signer.rs b/crates/deckard-app/src/signer.rs
new file mode 100644
index 0000000..a236711
--- /dev/null
+++ b/crates/deckard-app/src/signer.rs
@@ -0,0 +1,198 @@
+//! The app's key-less bridge to `deckard-signerd`.
+//!
+//! This is the whole signing story for the GUI: the app **spawns + supervises** the daemon
+//! and talks to it over the socket. It holds NO key material — no `UnlockedVault`, no
+//! `PrivateKeySigner`. Unlock happens *in the daemon* (the app sends the passphrase and gets
+//! back only an address); the send path sends an `Intent` and gets back a `Decision`/tx hash.
+//! The keystore is only ever touched in-process by *onboarding* (to write `vault.bin`), never
+//! to sign.
+
+use alloy_primitives::{Address, B256};
+use deckard_contract::{Decision, ExecuteResult, Intent, RequestId, UnlockOutcome};
+use deckard_signerd::{DaemonSupervisor, SignerClient};
+
+/// Result of the app's send path (propose, then execute on `Allow`). The path is implemented
+/// and unit-tested here; the GUI send screen that calls it is T-UX (out of scope), so no view
+/// invokes it yet.
+#[allow(dead_code)]
+#[derive(Clone, Debug, PartialEq)]
+pub enum SendOutcome {
+ /// Signed + broadcast by the daemon.
+ Broadcast { tx_hash: B256 },
+ /// Over cap / approval-required: a card must be approved, then `execute(request_id)`.
+ NeedsApproval { request_id: RequestId },
+ /// Refused (locked, off-allowlist, chain mismatch, …).
+ Denied { reason: String },
+}
+
+/// Owns the supervised daemon child and a client to its socket. Dropping it stops the
+/// supervisor and kills the daemon.
+pub struct AppSigner {
+ _supervisor: DaemonSupervisor,
+ client: SignerClient,
+}
+
+impl AppSigner {
+ /// Launch + supervise the daemon and return a key-less handle to it. `rpc_url`/`chain_id`
+ /// are passed to the daemon so it broadcasts on the same chain the app reads from.
+ pub fn launch(rpc_url: String, chain_id: u64) -> Self {
+ let socket_path = deckard_signerd::socket::default_socket_path();
+ let supervisor = DaemonSupervisor::spawn(socket_path.clone(), rpc_url, chain_id);
+ let client = SignerClient::new(socket_path);
+ Self {
+ _supervisor: supervisor,
+ client,
+ }
+ }
+
+ /// A cloneable client for background tasks (the supervisor stays owned by the app). The
+ /// shell uses this for unlock/lock/send so the work runs off the UI thread.
+ pub fn client(&self) -> SignerClient {
+ self.client.clone()
+ }
+}
+
+/// The send path, key-less: `propose` → on `Allow`, `execute`. Never signs in-process — it
+/// only issues `Propose`/`Execute` over the socket. Free function over a [`SignerClient`] so
+/// background threads can call it with a cheap clone. (Awaiting the T-UX send screen; proven
+/// by the unit test below.)
+#[allow(dead_code)]
+pub fn send_blocking(client: &SignerClient, intent: &Intent) -> anyhow::Result {
+ use deckard_contract::SignerRequest;
+
+ let decision = match client.request_blocking(&SignerRequest::Propose {
+ intent: intent.clone(),
+ })? {
+ deckard_contract::SignerResponse::Decision(d) => d,
+ other => anyhow::bail!("unexpected propose response: {other:?}"),
+ };
+ match decision {
+ Decision::Deny { reason } => Ok(SendOutcome::Denied { reason }),
+ Decision::NeedsApproval { request_id } => Ok(SendOutcome::NeedsApproval { request_id }),
+ Decision::Allow => {
+ // The daemon assigns a deterministic id; derive it locally to execute the Allow.
+ let id = SignerClient::request_id_for_intent(intent);
+ match client.request_blocking(&SignerRequest::Execute { request_id: id })? {
+ deckard_contract::SignerResponse::Execute(ExecuteResult::Broadcast { tx_hash }) => {
+ Ok(SendOutcome::Broadcast { tx_hash })
+ }
+ deckard_contract::SignerResponse::Execute(ExecuteResult::Denied { reason }) => {
+ Ok(SendOutcome::Denied { reason })
+ }
+ other => anyhow::bail!("unexpected execute response: {other:?}"),
+ }
+ }
+ }
+}
+
+/// Interpret an [`UnlockOutcome`] into either the wallet address or a user-facing error.
+pub fn address_or_error(outcome: UnlockOutcome) -> Result {
+ match outcome {
+ UnlockOutcome::Unlocked { address } => Ok(address),
+ UnlockOutcome::BadPassphrase => {
+ Err("Wrong passphrase, or the vault was tampered with".to_string())
+ }
+ UnlockOutcome::NoVault => Err("No wallet found — create or import one first".to_string()),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use alloy_primitives::{Bytes, U256};
+ use deckard_contract::{ExecuteResult, IntentKind, SignerRequest, SignerResponse};
+ use deckard_signerd::frame;
+ use std::sync::mpsc;
+ use std::sync::{Arc, Mutex};
+
+ fn send_intent() -> Intent {
+ Intent {
+ chain_id: 31337,
+ to: Address::repeat_byte(0x22),
+ token: None,
+ value: U256::from(1_000u64),
+ calldata: Bytes::new(),
+ kind: IntentKind::Send,
+ }
+ }
+
+ /// #9: the app's send path issues `Propose` then `Execute` over the socket — proving it
+ /// signs nothing in-process (it holds no key; it only speaks the wire). A tiny recording
+ /// UDS server stands in for the daemon and replies `Allow` then `Broadcast`.
+ #[test]
+ fn send_path_issues_propose_then_execute_over_the_socket() {
+ let dir =
+ std::env::temp_dir().join(format!("deckard-appsigner-test-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let sock = dir.join("signerd.sock");
+
+ let seen: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let seen_srv = Arc::clone(&seen);
+ let sock_srv = sock.clone();
+ let (ready_tx, ready_rx) = mpsc::channel();
+
+ // Recording server on its own current-thread runtime; handles two per-call
+ // connections (Propose, then Execute).
+ let server = std::thread::spawn(move || {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .unwrap();
+ rt.block_on(async move {
+ let listener = tokio::net::UnixListener::bind(&sock_srv).unwrap();
+ ready_tx.send(()).unwrap();
+ for _ in 0..2 {
+ let (mut stream, _) = listener.accept().await.unwrap();
+ let buf = frame::read_frame(&mut stream).await.unwrap().unwrap();
+ let req: SignerRequest = frame::decode(&buf).unwrap();
+ let resp = match &req {
+ SignerRequest::Propose { .. } => {
+ seen_srv.lock().unwrap().push("Propose".into());
+ SignerResponse::Decision(Decision::Allow)
+ }
+ SignerRequest::Execute { .. } => {
+ seen_srv.lock().unwrap().push("Execute".into());
+ SignerResponse::Execute(ExecuteResult::Broadcast {
+ tx_hash: B256::repeat_byte(0xAB),
+ })
+ }
+ other => panic!("unexpected request on the wire: {other:?}"),
+ };
+ let body = frame::encode(&resp).unwrap();
+ frame::write_frame(&mut stream, &body).await.unwrap();
+ }
+ });
+ });
+
+ ready_rx.recv().unwrap();
+
+ let client = SignerClient::new(sock);
+ let outcome = send_blocking(&client, &send_intent()).unwrap();
+
+ assert_eq!(
+ outcome,
+ SendOutcome::Broadcast {
+ tx_hash: B256::repeat_byte(0xAB)
+ }
+ );
+ assert_eq!(
+ *seen.lock().unwrap(),
+ vec!["Propose".to_string(), "Execute".to_string()]
+ );
+
+ server.join().unwrap();
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn unlock_outcomes_map_to_address_or_message() {
+ let addr = Address::repeat_byte(0x11);
+ assert_eq!(
+ address_or_error(UnlockOutcome::Unlocked { address: addr }),
+ Ok(addr)
+ );
+ assert!(address_or_error(UnlockOutcome::BadPassphrase).is_err());
+ assert!(address_or_error(UnlockOutcome::NoVault).is_err());
+ }
+}
diff --git a/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 85%
rename from src/wallet.rs
rename to crates/deckard-app/src/wallet.rs
index d979336..8500e33 100644
--- a/src/wallet.rs
+++ b/crates/deckard-app/src/wallet.rs
@@ -7,19 +7,18 @@
use std::fs;
use std::path::PathBuf;
-use directories::ProjectDirs;
-
-/// The platform config dir (`~/Library/Application Support/com.deckard.Deckard` on macOS).
+/// The platform config dir (`~/Library/Application Support/com.deckard.Deckard` on macOS),
+/// created if missing. The path itself is resolved by `deckard-core` so the app, onboarding,
+/// and the signer daemon all agree on where `vault.bin` lives.
fn config_dir() -> Option {
- let dirs = ProjectDirs::from("com", "deckard", "Deckard")?;
- let dir = dirs.config_dir().to_path_buf();
+ let dir = deckard_core::config_dir()?;
fs::create_dir_all(&dir).ok()?;
Some(dir)
}
/// Where the encrypted keystore lives.
pub fn vault_path() -> Option {
- Some(config_dir()?.join("vault.bin"))
+ Some(config_dir()?.join(deckard_core::config::VAULT_FILE))
}
/// The legacy plaintext key from the pre-keystore build (raw 32-byte hex).
diff --git a/src/welcome.rs b/crates/deckard-app/src/welcome.rs
similarity index 93%
rename from src/welcome.rs
rename to crates/deckard-app/src/welcome.rs
index c5f20b6..a3d54a7 100644
--- a/src/welcome.rs
+++ b/crates/deckard-app/src/welcome.rs
@@ -90,18 +90,36 @@ impl Shell {
}
let has_tokens = self.portfolio.as_ref().map(|p| !p.tokens.is_empty()).unwrap_or(false);
- // Status sub-line: synced block, watching tag, or an error.
+ // Status sub-line: synced block, watching tag, or an error. When a read carries a
+ // non-Verified trust label, surface it: a balance is never shown as quietly trusted.
+ let trust_tag = match &self.read_status {
+ Some(deckard_core::ReadStatus::Verified) => " · verified",
+ Some(deckard_core::ReadStatus::Degraded { .. }) => " · degraded",
+ Some(deckard_core::ReadStatus::Unsynced { .. }) => " · NOT VERIFIED",
+ None => "",
+ };
let status_line = if let Some(err) = &self.portfolio_error {
format!("⚠ {err}")
} else if first_sync {
"Syncing over Ethereum…".to_string()
} else if let Some(block) = self.synced_block {
let net = if self.viewing_watch { "watching · " } else { "" };
- format!("{net}synced · block {block}")
+ format!("{net}synced · block {block}{trust_tag}")
} else {
"Ethereum mainnet".to_string()
};
- let status_color = if self.portfolio_error.is_some() { theme.danger } else { muted };
+ // An unverified read is a soft warning (the value may not be trustless), not a hard error.
+ let unverified = matches!(
+ self.read_status,
+ Some(deckard_core::ReadStatus::Unsynced { .. })
+ );
+ let status_color = if self.portfolio_error.is_some() {
+ theme.danger
+ } else if unverified {
+ theme.warning
+ } else {
+ muted
+ };
div()
.flex_1()
diff --git a/crates/deckard-contract/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..f7ac14b
--- /dev/null
+++ b/crates/deckard-contract/README.md
@@ -0,0 +1,25 @@
+# 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`).
+- **`evaluate(&Intent, &Policy) -> Decision`** — the **one** pure decision function. Both `MockSigner` and the real `deckard-signerd` call it, so the verdict can never drift between the mock and the daemon (parity is unit-asserted). It returns `RequestId::ZERO` as a placeholder for `NeedsApproval`; the stateful caller mints the real id.
+- **RPC enums** (`SignerRequest` / `SignerResponse` / `ExecuteResult` / `ApprovalStatus` / `BalanceReport` / `UnlockOutcome`) — the daemon socket API. `SignerRequest` includes `Unlock{passphrase}` / `Lock` / `Resolve{request_id, approved}` for the daemon's lock state machine + approval loop (`Unlock` → `SignerResponse::Unlock(UnlockOutcome)`; `Lock`/`Resolve` → `Ack`). serde-derived → CBOR (ciborium) on the UDS, JSON for MCP.
+- **`Signer`** — a *sync* trait (`unlock`/`lock`/`resolve`/`address`/`balance`/`policy`/`propose`/`execute`/`status`/`revoke_all`); the real UDS client does a fast blocking round-trip off the UI thread (an async wrapper is the daemon ticket's call).
+- **`MockSigner`** — an in-memory, deterministic implementation (calls `evaluate`, no duplicated decision logic) so T-Agent, T-UX, and the test harness can build and run the acceptance scenario **before** the real signer daemon exists.
+
+## Zero key material
+
+This crate carries **no key material at all** — types + a trait + a mock. It never signs, never holds a key. The key boundary is the daemon's process (`crates/deckard-signerd`; cross-process red-team 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..98ef372
--- /dev/null
+++ b/crates/deckard-contract/src/lib.rs
@@ -0,0 +1,251 @@
+//! # 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 read_status;
+pub mod rpc;
+pub mod signer;
+
+pub use decision::{Decision, RequestId};
+pub use intent::{Intent, IntentKind};
+pub use mock::MockSigner;
+pub use policy::{evaluate, ApprovalMode, Policy};
+pub use read_status::ReadStatus;
+pub use rpc::{
+ ApprovalStatus, BalanceReport, ExecuteResult, SignerRequest, SignerResponse, UnlockOutcome,
+};
+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::Unlock {
+ passphrase: "correct horse battery staple".into(),
+ });
+ roundtrip(&SignerRequest::Lock);
+ roundtrip(&SignerRequest::Resolve {
+ request_id: B256::repeat_byte(0x04),
+ approved: true,
+ });
+ roundtrip(&SignerRequest::Resolve {
+ request_id: B256::repeat_byte(0x05),
+ approved: false,
+ });
+ roundtrip(&SignerRequest::Propose {
+ intent: sample_intent(IntentKind::Shield),
+ });
+ 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::Unlock(UnlockOutcome::Unlocked {
+ address: Address::repeat_byte(0x11),
+ }));
+ roundtrip(&SignerResponse::Unlock(UnlockOutcome::BadPassphrase));
+ roundtrip(&SignerResponse::Unlock(UnlockOutcome::NoVault));
+ roundtrip(&SignerResponse::Decision(Decision::Allow));
+ roundtrip(&SignerResponse::Execute(ExecuteResult::Broadcast {
+ tx_hash: B256::repeat_byte(0xAB),
+ }));
+ 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),
+ read_status: ReadStatus::Verified,
+ }));
+ }
+
+ #[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() {
+ // Exercise every ReadStatus variant (incl. the owned-String reasons) so both
+ // CBOR and JSON coverage of the new field stays complete + byte-stable.
+ roundtrip(&BalanceReport {
+ public_wei: U256::from(0_u64),
+ shielded_wei: U256::from(0_u64),
+ read_status: ReadStatus::Verified,
+ });
+ roundtrip(&BalanceReport {
+ public_wei: U256::MAX,
+ shielded_wei: U256::from(42_u64),
+ read_status: ReadStatus::Unsynced {
+ reason: "head stale".into(),
+ },
+ });
+ roundtrip(&BalanceReport {
+ public_wei: U256::from(7_u64),
+ shielded_wei: U256::from(0_u64),
+ read_status: ReadStatus::Degraded {
+ reason: "failover→nimbus".into(),
+ },
+ });
+ }
+
+ #[test]
+ fn read_status_roundtrip() {
+ roundtrip(&ReadStatus::Verified);
+ roundtrip(&ReadStatus::Degraded {
+ reason: "failover→drpc".into(),
+ });
+ roundtrip(&ReadStatus::Unsynced {
+ reason: "verification disabled".into(),
+ });
+ }
+}
diff --git a/crates/deckard-contract/src/mock.rs b/crates/deckard-contract/src/mock.rs
new file mode 100644
index 0000000..c99b862
--- /dev/null
+++ b/crates/deckard-contract/src/mock.rs
@@ -0,0 +1,651 @@
+//! 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;
+use crate::policy::{self, Policy};
+use crate::read_status::ReadStatus;
+use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult, UnlockOutcome};
+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.
+///
+/// The mock holds no real key, so its `Locked`/`Unlocked` state is modelled by the
+/// `Policy::revoked` brake: `lock`/`revoke_all` trip it (deny everything), `unlock` clears
+/// it (re-arm). This mirrors the daemon, where `Lock` and `RevokeAll` both reach `Locked`
+/// and only a fresh `Unlock` re-arms.
+#[derive(Debug)]
+pub struct MockSigner {
+ policy: Mutex,
+ 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 mock is deterministic + offline; it never touches a chain, so
+ // it reports its canned balances as Verified (no untrusted RPC behind it).
+ read_status: ReadStatus::Verified,
+ }),
+ }
+ }
+
+ /// 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. Thin wrapper over [`Signer::resolve`].
+ pub fn approve(&self, request_id: RequestId) {
+ self.resolve(request_id, true);
+ }
+
+ /// 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
+ }
+}
+
+impl Signer for MockSigner {
+ fn unlock(&self, _passphrase: &str) -> UnlockOutcome {
+ // The mock holds no real keystore, so any passphrase "unlocks" it. A fresh unlock
+ // re-arms the session by clearing the `revoked` brake (mirrors the daemon's
+ // "re-unlock to re-arm").
+ self.policy
+ .lock()
+ .expect("mock policy mutex poisoned")
+ .revoked = false;
+ UnlockOutcome::Unlocked {
+ address: Self::mock_address(),
+ }
+ }
+
+ fn lock(&self) {
+ // Lock the session (trip the brake) and deny everything in flight — same as the
+ // daemon's `Lock`, which reaches `Locked` exactly like `RevokeAll`.
+ let mut policy = self.policy.lock().expect("mock policy mutex poisoned");
+ let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
+ policy.revoked = true;
+ deny_pending(&mut reqs);
+ }
+
+ fn resolve(&self, request_id: RequestId, approved: bool) {
+ let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
+ if let Some(req) = reqs.by_id.get_mut(&request_id) {
+ if req.status == ApprovalStatus::Pending {
+ req.status = if approved {
+ ApprovalStatus::Allowed
+ } else {
+ ApprovalStatus::Denied {
+ reason: "user_denied".into(),
+ }
+ };
+ }
+ }
+ }
+
+ 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 {
+ // The verdict comes from the ONE shared decision function — no logic lives here.
+ // (`revoked`, the mock's lock state, is one of the checks `evaluate` makes.)
+ let needs_card = {
+ let policy = self.policy.lock().expect("mock policy mutex poisoned");
+ match policy::evaluate(intent, &policy) {
+ // Terminal verdicts return straight through.
+ deny @ Decision::Deny { .. } => return deny,
+ Decision::Allow => false,
+ Decision::NeedsApproval { .. } => true,
+ }
+ }; // policy lock released before taking the requests lock (preserves lock order)
+
+ // Mint the real, trackable id (replacing `evaluate`'s placeholder) and store the
+ // pending record under it.
+ let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
+ let id = Self::mint_id(&mut reqs);
+ let status = if needs_card {
+ 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) {
+ // STOP: trip the policy brake, then deny everything in flight.
+ // Same lock order as execute(): policy before requests.
+ let mut policy = self.policy.lock().expect("mock policy mutex poisoned");
+ let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
+ policy.revoked = true;
+ deny_pending(&mut reqs);
+ }
+}
+
+/// Flip every still-`Pending` request to `Denied{revoked}` — shared by `lock`/`revoke_all`.
+fn deny_pending(reqs: &mut Requests) {
+ for req in reqs.by_id.values_mut() {
+ if req.status == ApprovalStatus::Pending {
+ req.status = ApprovalStatus::Denied {
+ reason: "revoked".into(),
+ };
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::intent::IntentKind;
+ use crate::policy::ApprovalMode;
+ 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),
+ // A real Shield always carries the RelayAdapt call; the policy gate now requires
+ // it (an empty payload would degrade into a bare native send). Stand-in bytes here.
+ calldata: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
+ kind: IntentKind::Shield,
+ }
+ }
+
+ 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()
+ }
+ );
+ // A Shield with EMPTY calldata is rejected: without the RelayAdapt call it would
+ // degrade into a bare native send to `to` (no private note) while labelled "Shield".
+ let empty_shield = Intent {
+ calldata: Bytes::new(),
+ ..shield(20)
+ };
+ assert_eq!(
+ s.propose(&empty_shield),
+ Decision::Deny {
+ reason: "undecodable".into()
+ }
+ );
+ }
+
+ #[test]
+ 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),
+ read_status: ReadStatus::Verified,
+ });
+ 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..b735280
--- /dev/null
+++ b/crates/deckard-contract/src/policy.rs
@@ -0,0 +1,128 @@
+//! The spending fence the agent is allowed to READ (so it can stay inside the fence) but
+//! never write. The daemon enforces it; `MockSigner` enforces the same rules in memory.
+//!
+//! [`evaluate`] is **the one decision function** — both `MockSigner` and the real
+//! `deckard-signerd` call it, so there is no mock⇄daemon drift in the verdict logic.
+
+use alloy_primitives::{Address, U256};
+use serde::{Deserialize, Serialize};
+
+use crate::decision::{Decision, RequestId};
+use crate::intent::{Intent, IntentKind};
+
+/// The agent-readable policy. All caps are in wei.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Policy {
+ /// 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,
+}
+
+/// **The** decision function. A *pure* `(Intent, Policy) -> Decision` with no I/O, no
+/// signing, no state — both [`MockSigner`](crate::MockSigner) and `deckard-signerd` call
+/// it so the verdict can never drift between the mock and the real daemon.
+///
+/// It owns the policy-level checks (`revoked`, allowlist, calldata shape, the caps × mode
+/// matrix). Process-level pre-checks that the policy can't express — the daemon being
+/// `Locked`, a `chain_id` mismatch, an unsupported `IntentKind` — are the daemon's job and
+/// run *before* this function (the mock has none of those states, so feeding both the same
+/// `(Intent, Policy)` yields identical `Decision`s; this is the parity contract).
+///
+/// For [`Decision::NeedsApproval`] the returned `request_id` is the **placeholder**
+/// [`RequestId::ZERO`](alloy_primitives::B256::ZERO): minting a real, trackable id is the
+/// stateful caller's job (it stores the pending record under that id). Callers must replace
+/// it before returning the decision on the wire.
+pub fn evaluate(intent: &Intent, policy: &Policy) -> Decision {
+ // 1. STOP / revoked overrides everything.
+ if policy.revoked {
+ return Decision::Deny {
+ reason: "revoked".into(),
+ };
+ }
+ // 2. Allowlist (empty = any address).
+ if !policy.allow_to.is_empty() && !policy.allow_to.contains(&intent.to) {
+ return Decision::Deny {
+ reason: "off_allowlist".into(),
+ };
+ }
+ // 3. Calldata must be decodable for the kind.
+ if !calldata_ok(intent) {
+ return Decision::Deny {
+ reason: "undecodable".into(),
+ };
+ }
+ // 4. Cap check: spent_today + value vs the per-tx and daily caps.
+ let projected = policy.spent_today_wei.saturating_add(intent.value);
+ let over = projected > policy.per_tx_cap_wei || projected > policy.daily_cap_wei;
+
+ match policy.require_approval {
+ // Never raises no card, so an over-cap write has nothing to authorise it → deny.
+ ApprovalMode::Never => {
+ if over {
+ Decision::Deny {
+ reason: "over_cap".into(),
+ }
+ } else {
+ Decision::Allow
+ }
+ }
+ ApprovalMode::OverCap => {
+ if over {
+ Decision::NeedsApproval {
+ request_id: RequestId::ZERO,
+ }
+ } else {
+ Decision::Allow
+ }
+ }
+ ApprovalMode::Always => Decision::NeedsApproval {
+ request_id: RequestId::ZERO,
+ },
+ }
+}
+
+/// Shape check: does the calldata match the kind? The real Railgun adapter calldata is
+/// validated downstream (`10-kohaku-shield.md`); this only enforces the coarse invariant
+/// the policy gate relies on.
+///
+/// The Shield invariant matters now that Shield routes to the signing path: a
+/// `Shield`/`Unshield` MUST carry non-empty calldata. Without it, an `Intent{kind:Shield,
+/// calldata: empty}` would fall through the daemon's broadcast as a **plain native ETH send**
+/// to `intent.to` (no private note ever created) while wire-labelled "Shield" — a key-less
+/// client could thereby move ETH to an arbitrary address under the Shield label. Requiring
+/// calldata closes that. (The deeper `to == RelayAdapt(chain)` check lives downstream — the
+/// contract crate is pure policy with zero chain knowledge and no railgun dep, by charter.)
+fn calldata_ok(intent: &Intent) -> bool {
+ match intent.kind {
+ // A plain send carries no calldata (the daemon builds the tx from to/value/token).
+ IntentKind::Send => intent.calldata.is_empty(),
+ // A contract write / Railgun deposit / withdraw all carry an encoded call. An empty
+ // payload for any of these would degrade into a bare native send — reject it.
+ IntentKind::ContractCall | IntentKind::Shield | IntentKind::Unshield => {
+ !intent.calldata.is_empty()
+ }
+ }
+}
diff --git a/crates/deckard-contract/src/read_status.rs b/crates/deckard-contract/src/read_status.rs
new file mode 100644
index 0000000..cb3fe10
--- /dev/null
+++ b/crates/deckard-contract/src/read_status.rs
@@ -0,0 +1,81 @@
+//! `ReadStatus` — Deckard-owned trust label attached to every chain read.
+//!
+//! This is the contract the UI and the MCP agent surface see. The hard rule:
+//! **never silently serve an untrusted read.** A read is either verified, or
+//! visibly degraded/unsynced — never quietly trusted.
+//!
+//! The three states map onto *observable* Helios behavior (verified against
+//! a16z/helios @ 0.11.1, `core/src/client/node.rs`):
+//!
+//! - `Verified` — Helios head is fresh (age ≤ 60s, the hard `check_head_age` gate)
+//! and the read came back from the verified light client.
+//! - `Degraded` — still cryptographically verified, but off the happy path: we
+//! failed over to a secondary EL, or we're on a community fallback checkpoint.
+//! Trust note shown. Rarely emitted in v1.
+//! - `Unsynced` — cannot produce a verified read right now: sync not finished, head
+//! stale past the 60s gate, the read failed, or verification is disabled. The UI
+//! shows a hard "NOT VERIFIED" state. Deckard MUST NOT fall back to a raw
+//! untrusted RPC and still claim it is verified.
+//!
+//! ## Portability
+//!
+//! `deckard-contract` is a **std** crate today (no `#![no_std]`), so `String`
+//! here resolves to `std::string::String`. The type is written to be no_std-
+//! *ready* — it leans only on `alloc`-available types (`String`) and `core::fmt`
+//! for `Display` — so a future `#![no_std]` + `extern crate alloc` flip would be
+//! mechanical, not a rewrite. Like every other wire type it carries the same
+//! `serde` derives, so it round-trips byte-stably across JSON (the MCP surface)
+//! and CBOR (the daemon UDS).
+
+use core::fmt;
+
+use serde::{Deserialize, Serialize};
+
+/// Trust label attached to every chain read. Maps onto observable Helios state
+/// (see deckard-core's verified read path).
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ReadStatus {
+ /// Helios head fresh and the read succeeded against the verified light client.
+ /// Fully trustless.
+ Verified,
+ /// Still cryptographically verified, but off the happy path (EL failover or
+ /// community-fallback checkpoint). `reason` is shown to the user. Rarely
+ /// emitted in v1.
+ Degraded { reason: String },
+ /// No verified read is possible right now (Helios unsynced, head stale past
+ /// the 60s gate, the read failed, or verification is disabled). `reason` is
+ /// shown to the user. Deckard MUST NOT fall back to a raw untrusted RPC and
+ /// still claim Verified.
+ Unsynced { reason: String },
+}
+
+impl ReadStatus {
+ /// Off-the-happy-path-but-still-verified label.
+ pub fn degraded(reason: impl Into) -> Self {
+ ReadStatus::Degraded {
+ reason: reason.into(),
+ }
+ }
+
+ /// No-verified-read-possible label.
+ pub fn unsynced(reason: impl Into) -> Self {
+ ReadStatus::Unsynced {
+ reason: reason.into(),
+ }
+ }
+
+ /// True only when a real, verified value backs the read.
+ pub fn is_trustworthy(&self) -> bool {
+ matches!(self, ReadStatus::Verified | ReadStatus::Degraded { .. })
+ }
+}
+
+impl fmt::Display for ReadStatus {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ ReadStatus::Verified => write!(f, "VERIFIED"),
+ ReadStatus::Degraded { reason } => write!(f, "DEGRADED ({reason})"),
+ ReadStatus::Unsynced { reason } => write!(f, "NOT VERIFIED ({reason})"),
+ }
+ }
+}
diff --git a/crates/deckard-contract/src/rpc.rs b/crates/deckard-contract/src/rpc.rs
new file mode 100644
index 0000000..110de44
--- /dev/null
+++ b/crates/deckard-contract/src/rpc.rs
@@ -0,0 +1,107 @@
+//! 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;
+use crate::read_status::ReadStatus;
+
+/// `deckard-mcp` → `deckard-signerd`. The key-less client only proposes; it never signs.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum SignerRequest {
+ /// Unlock the vault: the daemon reads the keystore, decrypts with `passphrase`, and
+ /// holds the key for the session → [`SignerResponse::Unlock`]\([`UnlockOutcome`]\).
+ ///
+ /// The wire passphrase is a plain `String` because `zeroize::Zeroizing` does
+ /// not derive `Serialize`. The daemon moves it into `Zeroizing` the instant the frame
+ /// is decoded and never retains the raw buffer; it never echoes the passphrase back.
+ Unlock { passphrase: String },
+ /// Lock: zeroize + drop the held key → `Locked`, and deny every in-flight approval.
+ /// Re-arm only via a fresh [`Unlock`](Self::Unlock). → `Ack`.
+ Lock,
+ /// Close an approval loop opened by a `NeedsApproval`: flip the `Pending` record to
+ /// `Allowed` (`approved: true`) or `Denied` (`approved: false`). → `Ack`.
+ Resolve {
+ request_id: RequestId,
+ approved: bool,
+ },
+ /// Policy check, NO signing yet → [`Decision`].
+ Propose { intent: Intent },
+ /// Sign + broadcast, only if `Allow`/approved → [`ExecuteResult`].
+ Execute { request_id: RequestId },
+ /// Poll for the native-card result → [`ApprovalStatus`].
+ Status { request_id: RequestId },
+ /// STOP: zeroize the key, lock the daemon, 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 {
+ /// Reply to `Unlock`.
+ Unlock(UnlockOutcome),
+ Decision(Decision),
+ Execute(ExecuteResult),
+ Status(ApprovalStatus),
+ /// Reply to `Lock`, `Resolve`, and `RevokeAll`.
+ Ack,
+ Policy(Policy),
+ Address(Address),
+ Balance(BalanceReport),
+}
+
+/// Outcome of `Unlock`. Carries the wallet address on success — never any key material,
+/// never the passphrase.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum UnlockOutcome {
+ /// Decrypted; the daemon now holds the key. `address` is the primary account.
+ Unlocked { address: Address },
+ /// The passphrase was wrong (or the vault was tampered with). No key is held.
+ BadPassphrase,
+ /// No keystore file exists yet — onboarding must create one first.
+ NoVault,
+}
+
+/// Outcome of `execute`.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum ExecuteResult {
+ /// 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,
+ /// Approved (by a human, or auto within cap); `execute` will sign — subject to fresh
+ /// re-checks at sign time (revoked, TTL expiry, and the spend caps for an auto-allow).
+ Allowed,
+ /// Terminal denial.
+ Denied { reason: String },
+ /// 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,
+ /// Trust label for this read (Helios-verified vs unsynced/degraded). The hard
+ /// rule: a balance is `Verified` only when a fresh Helios-verified read backs
+ /// it; otherwise it is visibly `Unsynced`/`Degraded`, never quietly trusted.
+ pub read_status: ReadStatus,
+}
diff --git a/crates/deckard-contract/src/signer.rs b/crates/deckard-contract/src/signer.rs
new file mode 100644
index 0000000..5136d62
--- /dev/null
+++ b/crates/deckard-contract/src/signer.rs
@@ -0,0 +1,39 @@
+//! 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, UnlockOutcome};
+
+/// The daemon-socket API expressed as a trait, so callers can hold a `Box` and
+/// swap the mock for the real UDS client without changing a line. Object-safe: every method
+/// takes `&self` and returns owned values.
+pub trait Signer {
+ /// Unlock the vault for the session (the daemon decrypts + holds the key). Returns the
+ /// wallet address on success — never key material.
+ fn unlock(&self, passphrase: &str) -> UnlockOutcome;
+ /// Lock: zeroize + drop the held key and deny in-flight approvals. Re-arm via `unlock`.
+ fn lock(&self);
+ /// Close an approval loop: flip a `Pending` request to `Allowed`/`Denied`.
+ fn resolve(&self, request_id: RequestId, approved: bool);
+ /// The wallet's public address (key-less to read).
+ fn address(&self) -> Address;
+ /// Public + shielded balances. `shielded` mirrors the wire request; the report carries
+ /// 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..b648a05
--- /dev/null
+++ b/crates/deckard-contract/tests/harness_slice.rs
@@ -0,0 +1,115 @@
+//! 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 {
+ // Send carries no calldata; every other kind (Shield/Unshield/ContractCall) must carry
+ // its encoded call — the policy gate now rejects an empty payload for those (an empty
+ // "Shield" would otherwise degrade into a bare native send). Stand-in bytes for non-Send.
+ let calldata = match kind {
+ IntentKind::Send => Bytes::new(),
+ _ => Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
+ };
+ Intent {
+ chain_id: 1,
+ to: Address::repeat_byte(0x22),
+ token: None,
+ value: U256::from(value),
+ calldata,
+ 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/crates/deckard-core/Cargo.toml b/crates/deckard-core/Cargo.toml
index 44721be..56b66be 100644
--- a/crates/deckard-core/Cargo.toml
+++ b/crates/deckard-core/Cargo.toml
@@ -5,15 +5,53 @@ edition = "2021"
license = "AGPL-3.0-or-later"
description = "Deckard's headless engine: Ethereum provider, balances, HD keys, and the encrypted keystore — no GPUI dependency, fully unit-testable."
+[features]
+# Embedded Helios light client → verified localhost reads. Heavy (revm/bls); ON by
+# default so the app + daemon get verified reads, but toggleable so the heavy build
+# can be skipped. When OFF, reads fall back to the raw RPC and are tagged
+# ReadStatus::Unsynced("verification disabled") — never silently Verified.
+default = ["verified-reads", "shield"]
+verified-reads = ["dep:helios-ethereum"]
+# Key-less Railgun shield-calldata builder. Heavy (the full ZK `railgun` tree); ON by
+# default so the app + daemon can build shield intents, but toggleable so the heavy build
+# can be skipped. When OFF, `build_shield_native_intent` returns "shield unavailable
+# (feature off)" — never a fake success. Pulls `rand_09` because railgun pins rand 0.9
+# while core's own `rand` stays 0.8.
+shield = ["dep:railgun", "dep:rand_09"]
+
[dependencies]
+# The frozen wire contract — only for the shared `ReadStatus` trust label attached
+# to every read. (Types-only; no key material, no logic.)
+deckard-contract = { path = "../deckard-contract" }
+
# The full alloy surface Deckard needs across chunks. Front-loaded so the heavy
# alloy/reqwest compile happens once: provider+http (reads), contract+sol-types
-# (Multicall3 / ERC-20), ens (name resolution). signer-local stays standalone
-# below to keep the `mnemonic` feature explicit.
-alloy = { version = "1", features = ["provider-http", "contract", "sol-types", "ens"] }
+# (Multicall3 / ERC-20), ens (name resolution), eips (BlockId for the Helios
+# `with_default_block(latest)` fix). signer-local stays standalone below to keep
+# the `mnemonic` feature explicit.
+alloy = { version = "1", features = ["provider-http", "contract", "sol-types", "ens", "eips"] }
alloy-signer-local = { version = "2.0.5", features = ["mnemonic"] }
alloy-primitives = "1.6.0"
+# Embedded Helios light client (verified localhost JSON-RPC server). Git-only, tag
+# "0.11.1" (crates.io is stale at 0.1.0); the umbrella `helios` crate pulls a yanked
+# core2, so depend on `helios-ethereum` directly. Heavy (revm/bls12_381) — gated
+# behind `verified-reads`. The `ethereum_hashing` patch it needs is at the workspace
+# root (a git-dep can't carry its own `[patch]`).
+helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1", optional = true }
+
+# Kohaku's pure-Rust Railgun client — the heavy ZK crate. Gated behind `shield` (DEFAULT
+# ON). We use ONLY the key-less `ShieldBuilder` (no sync, no proving, no key), but the crate
+# pulls the full ZK tree, so it is `optional`. `testing` matches the proven spike edge (rev
+# 618c53f) so the workspace-root `[patch.crates-io]` set resolves identically. The two
+# mandatory ZK patches (ruint, ark-circom) live at the workspace root — a git-dep can't
+# carry its own `[patch]`.
+railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7", features = ["testing"], optional = true }
+# railgun's `ShieldBuilder::build` requires a rand **0.9** Rng; core's own `rand`
+# (below) is 0.8 (RustCrypto-aligned), so the 0.9 crate is aliased `rand_09` and pulled only
+# with the `shield` feature.
+rand_09 = { package = "rand", version = "0.9", optional = true }
+
# A single background tokio runtime owns all network; the GUI thread never makes
# a network call. `rt` (current-thread) only — no multi-thread worker pool needed.
tokio = { version = "1", features = ["rt", "macros", "sync"] }
@@ -31,5 +69,9 @@ bip39 = { version = "2.2", features = ["zeroize"] }
zeroize = "1"
rand = "0.8"
+# Resolve the platform config dir (the keystore + signer policy live there). Single-sourced
+# here so the GUI app, onboarding, and the signer daemon all agree on the path.
+directories = "5"
+
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros", "sync", "rt-multi-thread"] }
diff --git a/crates/deckard-core/examples/smoke.rs b/crates/deckard-core/examples/smoke.rs
index b817eb5..2d3677a 100644
--- a/crates/deckard-core/examples/smoke.rs
+++ b/crates/deckard-core/examples/smoke.rs
@@ -19,7 +19,10 @@ fn main() {
println!("{name} -> {addr}");
match eth.portfolio(addr).recv() {
- Ok(Ok(p)) => {
+ Ok(Ok(read)) => {
+ // The trust label the read carries (Helios-Verified vs Unsynced).
+ println!("read status: {}", read.status);
+ let p = read.value;
println!("ETH: {}", format_amount(p.native_wei, 18, 6));
for t in &p.tokens {
println!("{:>5}: {}", t.symbol, format_amount(t.raw, t.decimals, 4));
diff --git a/crates/deckard-core/src/config.rs b/crates/deckard-core/src/config.rs
new file mode 100644
index 0000000..d3f98d4
--- /dev/null
+++ b/crates/deckard-core/src/config.rs
@@ -0,0 +1,33 @@
+//! Where Deckard keeps per-user state on disk. The encrypted keystore (`vault.bin`) and the
+//! signer policy (`policy.json`) live in the platform config dir; the GUI app, onboarding,
+//! and the signer daemon all resolve the **same** path through here so they never drift.
+//!
+//! This is a pure resolver — it does not create the directory. The writer (`Vault::write_atomic`)
+//! creates the parent as needed; readers treat a missing file as "not set up yet."
+
+use std::path::PathBuf;
+
+use directories::ProjectDirs;
+
+/// The encrypted keystore filename inside [`config_dir`].
+pub const VAULT_FILE: &str = "vault.bin";
+/// The signer policy filename inside [`config_dir`].
+pub const POLICY_FILE: &str = "policy.json";
+
+/// The platform config dir: `~/Library/Application Support/com.deckard.Deckard` on macOS,
+/// `$XDG_CONFIG_HOME/deckard` (or `~/.config/deckard`) on Linux. `None` only if the OS has
+/// no home directory at all.
+pub fn config_dir() -> Option {
+ let dirs = ProjectDirs::from("com", "deckard", "Deckard")?;
+ Some(dirs.config_dir().to_path_buf())
+}
+
+/// The encrypted keystore path (`/vault.bin`).
+pub fn vault_path() -> Option {
+ Some(config_dir()?.join(VAULT_FILE))
+}
+
+/// The signer policy path (`/policy.json`).
+pub fn policy_path() -> Option {
+ Some(config_dir()?.join(POLICY_FILE))
+}
diff --git a/crates/deckard-core/src/eth.rs b/crates/deckard-core/src/eth.rs
index 4f1f7d7..002e9d2 100644
--- a/crates/deckard-core/src/eth.rs
+++ b/crates/deckard-core/src/eth.rs
@@ -3,30 +3,78 @@
//! requests in and gets a `flume::Receiver` back; it awaits that receiver on its own
//! executor (`cx.spawn`), so a slow RPC never stalls a frame.
//!
-//! v0 points at a public mainnet RPC by default (overridable in settings). The
-//! trustless default — a bundled Helios light client serving localhost — is the next
-//! increment per the spec; swapping it in is just a different URL passed to `spawn`.
+//! ## Verified reads (the `verified-reads` feature, ON by default)
+//!
+//! When `verified-reads` is on, the worker stands up an **embedded Helios light
+//! client** (see [`crate::helios`]) whose localhost JSON-RPC server is what the alloy
+//! provider reads through — every read is proof-checked. The `rpc_url` passed to
+//! [`EthProvider::spawn`] becomes the *execution-layer* endpoint Helios proves against
+//! (it must serve `eth_getProof`); it is no longer read directly. Each read is tagged
+//! with a [`ReadStatus`]: `Verified` when a fresh Helios head backs it, `Unsynced`
+//! otherwise.
+//!
+//! When `verified-reads` is OFF, the worker keeps the original raw-RPC path but tags
+//! every read `ReadStatus::Unsynced("verification disabled")` — it never claims a raw
+//! read is Verified.
+//!
+//! Threading model (eng-review decision, preserved): a *single* background tokio
+//! current-thread runtime owns every network call — including Helios's spawned
+//! localhost server task, which runs cooperatively on the same runtime. The GUI never
+//! blocks and never touches tokio.
+//!
+//! TODO(post-v1): v1 runs an INDEPENDENT Helios instance per reader (this one + the
+//! daemon's). The "consolidate all reads into the daemon" refactor is deferred.
+//! TODO(post-v1): if Helios's server task starves under load on the shared
+//! current-thread runtime, consider `new_multi_thread`. Do NOT switch preemptively —
+//! it would break the "single current-thread runtime" decision without proven need.
use alloy::ens::ProviderEnsExt;
use alloy::primitives::{Address, U256};
use alloy::providers::{DynProvider, Provider, ProviderBuilder};
+use deckard_contract::ReadStatus;
+
use crate::balances::{fetch_portfolio, Portfolio};
-/// A reliable public mainnet RPC, used until the bundled Helios light client lands.
-/// Overridable via settings (bring-your-own-RPC).
+/// A reliable public mainnet RPC, used as the execution-layer endpoint Helios proves
+/// against (or, with `verified-reads` off, read directly). Overridable via settings.
pub const DEFAULT_RPC: &str = "https://ethereum-rpc.publicnode.com";
/// The reply half of a request: the worker sends the result here; the caller awaits it.
type Reply = flume::Sender>;
+/// A value read off-chain, with the trust label that read carries. Returned to the UI
+/// so it can render the verified/unsynced state alongside the value.
+#[derive(Clone, Debug)]
+pub struct Read {
+ pub value: T,
+ pub status: ReadStatus,
+}
+
+impl Read {
+ fn new(value: T, status: ReadStatus) -> Self {
+ Self { value, status }
+ }
+}
+
/// Typed requests the GUI sends to the network worker. Each carries its own reply
/// channel so call sites stay ergonomic and unrelated requests never head-of-line block.
enum EthReq {
- Balance { addr: Address, reply: Reply },
- BlockNumber { reply: Reply },
- Portfolio { addr: Address, reply: Reply },
- ResolveName { name: String, reply: Reply },
+ Balance {
+ addr: Address,
+ reply: Reply>,
+ },
+ BlockNumber {
+ reply: Reply>,
+ },
+ Portfolio {
+ addr: Address,
+ reply: Reply>,
+ },
+ ResolveName {
+ name: String,
+ reply: Reply,
+ },
}
/// A cloneable handle to the network worker thread. Clone it freely into UI views;
@@ -37,8 +85,9 @@ pub struct EthProvider {
}
impl EthProvider {
- /// Spawn the network worker pointed at `rpc_url`. Never blocks; the runtime and
- /// the alloy provider are built on the worker thread.
+ /// Spawn the network worker pointed at `rpc_url`. Never blocks; the runtime, the
+ /// embedded Helios client (when `verified-reads` is on), and the alloy provider are
+ /// all built on the worker thread.
pub fn spawn(rpc_url: impl Into) -> Self {
let rpc_url = rpc_url.into();
let (tx, rx) = flume::unbounded::();
@@ -49,25 +98,26 @@ impl EthProvider {
Self { tx }
}
- /// Fetch the native ETH balance (wei) of `addr`. Returns immediately; the caller
- /// awaits the receiver on its own executor. A dead worker resolves to an error
- /// rather than hanging.
- pub fn balance(&self, addr: Address) -> flume::Receiver> {
+ /// Fetch the native ETH balance (wei) of `addr`, with its trust label. Returns
+ /// immediately; the caller awaits the receiver on its own executor. A dead worker
+ /// resolves to an error rather than hanging.
+ pub fn balance(&self, addr: Address) -> flume::Receiver>> {
self.request(|reply| EthReq::Balance { addr, reply })
}
- /// Fetch the latest block number — a cheap liveness/sync probe for the status line.
- pub fn block_number(&self) -> flume::Receiver> {
+ /// Fetch the latest block number (a cheap liveness/sync probe) with its trust label.
+ pub fn block_number(&self) -> flume::Receiver>> {
self.request(|reply| EthReq::BlockNumber { reply })
}
/// Fetch the full portfolio (native + listed ERC-20 balances) in one Multicall3
- /// round-trip. Non-blocking; await the receiver on the UI executor.
- pub fn portfolio(&self, addr: Address) -> flume::Receiver> {
+ /// round-trip, with its trust label. Non-blocking; await on the UI executor.
+ pub fn portfolio(&self, addr: Address) -> flume::Receiver>> {
self.request(|reply| EthReq::Portfolio { addr, reply })
}
- /// Forward-resolve an ENS name (e.g. `vitalik.eth`) to an address.
+ /// Forward-resolve an ENS name (e.g. `vitalik.eth`) to an address. Not value-bearing,
+ /// so no trust label — the resulting address is then read with one.
pub fn resolve_name(&self, name: impl Into) -> flume::Receiver> {
let name = name.into();
self.request(|reply| EthReq::ResolveName { name, reply })
@@ -88,8 +138,8 @@ impl EthProvider {
}
}
-/// The worker entry point: build the runtime + provider, then service requests until
-/// every `EthProvider` handle has dropped (which closes `rx`).
+/// The worker entry point: build the runtime + the read provider (verified or raw),
+/// then service requests until every `EthProvider` handle has dropped (closing `rx`).
fn run_worker(rpc_url: String, rx: flume::Receiver) {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -97,48 +147,174 @@ fn run_worker(rpc_url: String, rx: flume::Receiver) {
.expect("build tokio current-thread runtime");
rt.block_on(async move {
- // A bad URL yields `None`; we still drain the queue and answer every request
- // with an error so the UI never hangs waiting on a reply that never comes.
- let provider: Option = rpc_url
- .parse()
- .ok()
- .map(|url| ProviderBuilder::new().connect_http(url).erased());
+ let read_path = ReadPath::build(&rpc_url).await;
while let Ok(req) = rx.recv_async().await {
match req {
EthReq::Balance { addr, reply } => {
- let _ = reply.send(fetch_balance(provider.as_ref(), addr).await);
+ let _ = reply.send(read_path.balance(addr).await);
}
EthReq::BlockNumber { reply } => {
- let _ = reply.send(fetch_block_number(provider.as_ref()).await);
+ let _ = reply.send(read_path.block_number().await);
}
EthReq::Portfolio { addr, reply } => {
- let res = match provider.as_ref() {
- Some(p) => fetch_portfolio(p, addr).await,
- None => Err(anyhow::anyhow!("invalid RPC URL")),
- };
- let _ = reply.send(res);
+ let _ = reply.send(read_path.portfolio(addr).await);
}
EthReq::ResolveName { name, reply } => {
- let res = match provider.as_ref() {
- Some(p) => p.resolve_name(&name).await.map_err(anyhow::Error::from),
- None => Err(anyhow::anyhow!("invalid RPC URL")),
- };
- let _ = reply.send(res);
+ let _ = reply.send(read_path.resolve_name(&name).await);
}
}
}
});
}
-async fn fetch_balance(provider: Option<&DynProvider>, addr: Address) -> anyhow::Result {
- let provider = provider.ok_or_else(|| anyhow::anyhow!("invalid RPC URL"))?;
- Ok(provider.get_balance(addr).await?)
+/// The worker's resolved read path. Holds the alloy provider it reads through and, when
+/// `verified-reads` is on, the embedded Helios client that owns the localhost server
+/// (kept alive for the worker's lifetime — its Drop tears the server down).
+struct ReadPath {
+ /// `None` when the URL was unparseable / Helios failed to come up. Every read then
+ /// answers with an error or an `Unsynced` status (fail-closed; the UI never hangs).
+ provider: Option,
+ /// `None` → this is the verified Helios path: the trust label is re-derived per read
+ /// from Helios head freshness. `Some(reason)` → a non-verified path (Helios down or
+ /// the feature disabled): every read is tagged `Unsynced(reason)`, NEVER `Verified`.
+ unverified_reason: Option,
+ /// Keeps the embedded Helios localhost server alive. `None` for the raw path.
+ #[cfg(feature = "verified-reads")]
+ _helios: Option,
+}
+
+impl ReadPath {
+ /// Build the read path on the worker thread, inside the worker's tokio runtime.
+ #[cfg(feature = "verified-reads")]
+ async fn build(rpc_url: &str) -> Self {
+ // The configured RPC is now the EXECUTION-layer endpoint Helios proves against
+ // (it must serve eth_getProof) — never read directly. CL drives the sync.
+ let data_dir = crate::config::config_dir()
+ .map(|d| d.join("helios"))
+ .unwrap_or_else(|| std::path::PathBuf::from(".deckard-helios"));
+
+ match crate::helios::launch_verified(
+ crate::helios::DEFAULT_CONSENSUS_RPC,
+ rpc_url,
+ data_dir,
+ )
+ .await
+ {
+ Ok(reader) => {
+ // Clone the verified localhost provider out for the read handlers; the
+ // VerifiedReader is retained so the server task stays alive.
+ let provider = reader.provider().clone();
+ Self {
+ provider: Some(provider),
+ unverified_reason: None, // verified path: label by head freshness
+ _helios: Some(reader),
+ }
+ }
+ Err(e) => {
+ // Helios never came up: serve reads as Unsynced. We do NOT fall back to a
+ // raw read of the (untrusted) RPC and call it Verified. We still build a
+ // raw provider so values can be shown, but always tagged Unsynced with an
+ // honest reason.
+ let reason = format!("helios unavailable: {}", one_line(&e));
+ let provider = rpc_url
+ .parse()
+ .ok()
+ .map(|url| ProviderBuilder::new().connect_http(url).erased());
+ Self {
+ provider,
+ unverified_reason: Some(reason),
+ _helios: None,
+ }
+ }
+ }
+ }
+
+ /// Feature-off build: the original raw-RPC path, always tagged Unsynced.
+ #[cfg(not(feature = "verified-reads"))]
+ async fn build(rpc_url: &str) -> Self {
+ let provider = rpc_url
+ .parse()
+ .ok()
+ .map(|url| ProviderBuilder::new().connect_http(url).erased());
+ Self {
+ provider,
+ unverified_reason: Some("verification disabled".to_string()),
+ }
+ }
+
+ /// The trust label for a read taken now. On the verified path (`unverified_reason ==
+ /// None`), re-derive from the Helios head (a head gone stale mid-session downgrades to
+ /// Unsynced). Otherwise the fixed honest reason. NEVER returns Verified without a
+ /// fresh Helios head behind it.
+ ///
+ /// Each value-bearing read (`balance`/`block_number`/`portfolio`) reads the value FIRST
+ /// and then calls `status()` — so a `Verified` tag is bound to a head observed *after*
+ /// the value came back (the daemon's read path uses the same ordering). A small
+ /// time-of-check/time-of-use window remains between the two round-trips: a head could go
+ /// stale in the gap. This is an accepted v1 limitation; it always fails toward "a fresh
+ /// verified head backed the value", never toward a false `Verified`.
+ async fn status(&self) -> ReadStatus {
+ match &self.unverified_reason {
+ None => {
+ #[cfg(feature = "verified-reads")]
+ if let Some(reader) = &self._helios {
+ return reader.head_status().await;
+ }
+ // Defensive: a verified path with no client shouldn't happen.
+ ReadStatus::unsynced("verification unavailable")
+ }
+ Some(reason) => ReadStatus::unsynced(reason.clone()),
+ }
+ }
+
+ async fn balance(&self, addr: Address) -> anyhow::Result> {
+ let provider = self
+ .provider
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?;
+ let value = provider.get_balance(addr).await?;
+ Ok(Read::new(value, self.status().await))
+ }
+
+ async fn block_number(&self) -> anyhow::Result> {
+ let provider = self
+ .provider
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?;
+ let value = provider.get_block_number().await?;
+ Ok(Read::new(value, self.status().await))
+ }
+
+ async fn portfolio(&self, addr: Address) -> anyhow::Result> {
+ let provider = self
+ .provider
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?;
+ let value = fetch_portfolio(provider, addr).await?;
+ Ok(Read::new(value, self.status().await))
+ }
+
+ async fn resolve_name(&self, name: &str) -> anyhow::Result {
+ let provider = self
+ .provider
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?;
+ provider.resolve_name(name).await.map_err(anyhow::Error::from)
+ }
}
-async fn fetch_block_number(provider: Option<&DynProvider>) -> anyhow::Result {
- let provider = provider.ok_or_else(|| anyhow::anyhow!("invalid RPC URL"))?;
- Ok(provider.get_block_number().await?)
+/// Collapse a multi-line error into one short line for a `reason` string. Only the
+/// verified-reads build constructs a reason from an error.
+#[cfg(feature = "verified-reads")]
+fn one_line(e: &impl std::fmt::Display) -> String {
+ e.to_string()
+ .lines()
+ .next()
+ .unwrap_or("")
+ .chars()
+ .take(160)
+ .collect()
}
#[cfg(test)]
@@ -146,23 +322,42 @@ mod tests {
use super::*;
use alloy::providers::mock::Asserter;
- /// The provider abstraction reads a balance off a mocked transport — no network,
- /// deterministic, fast. Proves the decode path without hitting a real RPC.
- #[tokio::test]
- async fn balance_reads_from_mocked_transport() {
- let asserter = Asserter::new();
- asserter.push_success(&U256::from(31_415u64));
+ /// Build a ReadPath over a mocked transport (no network, deterministic). Reads are
+ /// tagged Unsynced because there is no Helios behind a mock — the hard rule holds.
+ fn mocked_path(asserter: Asserter) -> ReadPath {
let provider = ProviderBuilder::new()
.connect_mocked_client(asserter)
.erased();
+ ReadPath {
+ provider: Some(provider),
+ unverified_reason: Some("test (no helios)".to_string()),
+ #[cfg(feature = "verified-reads")]
+ _helios: None,
+ }
+ }
+
+ /// The read path decodes a balance off a mocked transport and attaches a status.
+ #[tokio::test]
+ async fn balance_reads_from_mocked_transport_with_status() {
+ let asserter = Asserter::new();
+ asserter.push_success(&U256::from(31_415u64));
+ let path = mocked_path(asserter);
- let bal = fetch_balance(Some(&provider), Address::ZERO).await.unwrap();
- assert_eq!(bal, U256::from(31_415u64));
+ let read = path.balance(Address::ZERO).await.unwrap();
+ assert_eq!(read.value, U256::from(31_415u64));
+ // No Helios behind a mock → never Verified.
+ assert!(!read.status.is_trustworthy());
}
- /// A bad RPC URL fails closed with an error rather than panicking or hanging.
+ /// A missing provider fails closed with an error rather than panicking or hanging.
#[tokio::test]
- async fn invalid_url_errors_cleanly() {
- assert!(fetch_balance(None, Address::ZERO).await.is_err());
+ async fn no_provider_errors_cleanly() {
+ let path = ReadPath {
+ provider: None,
+ unverified_reason: Some("test (no provider)".to_string()),
+ #[cfg(feature = "verified-reads")]
+ _helios: None,
+ };
+ assert!(path.balance(Address::ZERO).await.is_err());
}
}
diff --git a/crates/deckard-core/src/helios.rs b/crates/deckard-core/src/helios.rs
new file mode 100644
index 0000000..016f530
--- /dev/null
+++ b/crates/deckard-core/src/helios.rs
@@ -0,0 +1,279 @@
+//! Stand up an embedded **verified** Helios light client whose localhost JSON-RPC
+//! server is the endpoint an alloy provider reads through — so every chain read is
+//! proof-checked instead of trusting a raw vendor RPC.
+//!
+//! Lifted from the verified `eip1193-railgun` spike (`spikes/eip1193-railgun/src/helios.rs`),
+//! ported from `eyre` → `anyhow` and trimmed to the helios-only consumer Deckard needs.
+//!
+//! Verified against `a16z/helios @ 0.11.1`:
+//! * `EthereumClientBuilder::rpc_address(SocketAddr)` records a bind addr;
+//! * on `.build()`, the client `tokio::spawn`s the localhost JSON-RPC server,
+//! serving the `eth_*` subset — every read proof-checked.
+//! * `.build()` is sync but MUST run inside a tokio runtime (it spawns the server
+//! task). Both callers (deckard-core's EthProvider worker and deckard-signerd's
+//! daemon) own a tokio runtime, so this holds.
+//!
+//! `wait_synced()` ≠ ready: after it returns, the first execution head lands ~1 slot
+//! later (≤12s); until then every `Latest` read fails the 60s `check_head_age` gate.
+//! So we poll `get_block_number()` until `Ok` before declaring the read path live.
+//!
+//! THE one-line consumer fix (see [`connect_verified_provider`]): build the alloy
+//! provider with `.with_default_block(BlockId::latest())`. alloy's `Provider::call`
+//! defaults the block tag to `pending`, which a light client cannot serve
+//! ("block not found: pending") — this rewrites the default to `latest` so the
+//! Multicall3 / ENS `eth_call` reads work.
+//!
+//! TODO(post-v1): v1 runs an INDEPENDENT Helios instance per reader (one behind
+//! deckard-core::EthProvider, one in the daemon). The "consolidate all reads into
+//! the daemon" refactor is deferred. The failover/community-checkpoint supervisor
+//! (spikes/helios-walkaway/src/upstreams.rs) that would emit `ReadStatus::Degraded`
+//! is also deferred — v1 runs a single client per reader.
+
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::time::{Duration, Instant};
+
+use alloy::eips::BlockId;
+use alloy::primitives::U256;
+use alloy::providers::{DynProvider, Provider, ProviderBuilder};
+use anyhow::{anyhow, Result};
+use deckard_contract::ReadStatus;
+use helios_ethereum::config::networks::Network;
+use helios_ethereum::database::FileDB;
+use helios_ethereum::{EthereumClient, EthereumClientBuilder};
+
+/// A consensus-layer (beacon) endpoint that actually drives a Helios sync. Nimbus's
+/// public testing beacon API is the spike's proven default; dRPC
+/// (`https://eth-beacon-chain.drpc.org`) is the documented alternate.
+pub const DEFAULT_CONSENSUS_RPC: &str = "http://testing.mainnet.beacon-api.nimbus.team";
+
+/// A live, verified Helios read path: the localhost provider an alloy consumer reads
+/// through, plus the owning `EthereumClient` whose Drop tears down the spawned server.
+///
+/// **The `_client` field is load-bearing**: dropping it kills the spawned localhost
+/// JSON-RPC server task. Keep this struct alive for as long as reads are served.
+pub struct VerifiedReader {
+ /// The localhost provider, already built with the `with_default_block(latest)` fix.
+ provider: DynProvider,
+ /// The `http://127.0.0.1:` URL the localhost JSON-RPC server is bound at — so a
+ /// caller (e.g. the daemon's `signing::read_balance`) can build its OWN consumer
+ /// provider against the same verified server.
+ localhost_url: String,
+ /// Owns the spawned localhost JSON-RPC server task; must outlive `provider`.
+ _client: EthereumClient,
+}
+
+impl VerifiedReader {
+ /// Borrow the verified localhost provider (alloy, `with_default_block(latest)`).
+ pub fn provider(&self) -> &DynProvider {
+ &self.provider
+ }
+
+ /// The verified localhost JSON-RPC URL (`http://127.0.0.1:`). Reads through
+ /// this are proof-checked by Helios.
+ pub fn localhost_url(&self) -> &str {
+ &self.localhost_url
+ }
+
+ /// Compute the trust label for a read taken *now*: `Verified` only when the Helios
+ /// head is fresh (age ≤ 60s), else `Unsynced`. v1 never emits `Degraded` here — that
+ /// is the deferred failover/community-checkpoint path (see the module TODO).
+ ///
+ /// Called once per read so a head that goes stale mid-session is caught: a value is
+ /// only ever labelled `Verified` when a fresh verified head actually backs it.
+ ///
+ /// We fetch the *latest block by tag* (not a bare `eth_blockNumber`): fetching the
+ /// `Latest` block exercises Helios's own `check_head_age` (60s) gate AND lets us derive
+ /// freshness from the block's timestamp directly, rather than trusting that a stored
+ /// height implies a fresh head. A bare height call can be answered by a stalled-but-
+ /// not-yet-expired client and would over-report `Verified`; the timestamp check closes
+ /// that gap.
+ pub async fn head_status(&self) -> ReadStatus {
+ let block = match self.provider.get_block(BlockId::latest()).await {
+ Ok(Some(b)) => b,
+ // No latest block: either still syncing or the head aged out of the gate.
+ Ok(None) => return ReadStatus::unsynced("helios head unavailable: no latest block"),
+ Err(e) => {
+ return ReadStatus::unsynced(format!("helios head unavailable: {}", one_line(&e)))
+ }
+ };
+
+ let head_ts = block.header.timestamp;
+ let now = now_unix();
+ // `now` can legitimately be < head_ts by a few seconds (clock skew / a head minted
+ // slightly ahead); saturating_sub treats that as age 0, never as a stale read.
+ let age = now.saturating_sub(head_ts);
+ if age <= MAX_HEAD_AGE_SECS {
+ ReadStatus::Verified
+ } else {
+ ReadStatus::unsynced(format!("helios head stale ({age}s > {MAX_HEAD_AGE_SECS}s)"))
+ }
+ }
+}
+
+/// Helios's own hard freshness gate for `Latest` reads (`check_head_age`, 60s). We mirror
+/// it here so a value is labelled `Verified` only when its backing head is within the gate.
+const MAX_HEAD_AGE_SECS: u64 = 60;
+
+/// Current wall-clock UNIX time in seconds. Used only to compare against the verified
+/// head's block timestamp for the freshness label.
+fn now_unix() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+/// Build a verified Helios **mainnet** client, launch its localhost JSON-RPC server,
+/// and return a [`VerifiedReader`] only once the server is actually serving a fresh
+/// verified head.
+///
+/// * `consensus_rpc` — the beacon (CL) endpoint that drives the sync (e.g. Nimbus).
+/// * `execution_rpc` — the EL endpoint Helios proves against (must serve `eth_getProof`).
+/// This is the *untrusted* RPC the app was previously reading directly — now it only
+/// feeds proofs that Helios verifies.
+/// * `data_dir` — FileDB cache dir → warm starts from a cached checkpoint.
+///
+/// On any failure (sync timeout, server never binds, head never fresh) returns an
+/// `Err` — the caller MUST then serve reads tagged `Unsynced`, NEVER fall back to a
+/// raw RPC and call it `Verified`.
+pub async fn launch_verified(
+ consensus_rpc: &str,
+ execution_rpc: &str,
+ data_dir: PathBuf,
+) -> Result {
+ let port = free_loopback_port()?;
+ let rpc_addr: SocketAddr = ([127, 0, 0, 1], port).into();
+ let helios_url = format!("http://127.0.0.1:{port}");
+
+ let client = build_with_server(consensus_rpc, execution_rpc, data_dir, rpc_addr)?;
+
+ // CL checkpoint bootstrapped...
+ client
+ .wait_synced()
+ .await
+ .map_err(|e| anyhow!("helios wait_synced: {e}"))?;
+ // ...then the typed client serves a fresh execution head (the honest "ready" moment;
+ // wait_synced alone isn't it — the first head lands ~1 slot later).
+ wait_until_serving(&client, Duration::from_secs(60)).await?;
+
+ // Build the CONSUMER provider against the localhost server, with THE fix.
+ let provider = connect_verified_provider(&helios_url).await?;
+
+ // Prove the spawned localhost server is actually answering (and the consumer
+ // provider's default-block layer works) before we declare the path live.
+ wait_provider_live(&provider, Duration::from_secs(30)).await?;
+
+ Ok(VerifiedReader {
+ provider,
+ localhost_url: helios_url,
+ _client: client,
+ })
+}
+
+/// Build a verified Helios mainnet client whose localhost JSON-RPC server is bound at
+/// `rpc_addr`. FileDB → warm starts from a cached checkpoint. Sync, but must run inside
+/// a tokio runtime (it spawns the server task on `.build()`).
+fn build_with_server(
+ cl: &str,
+ el: &str,
+ data_dir: PathBuf,
+ rpc_addr: SocketAddr,
+) -> Result {
+ EthereumClientBuilder::::new()
+ .network(Network::Mainnet)
+ .consensus_rpc(cl)
+ .map_err(|e| anyhow!("helios consensus_rpc {cl:?}: {e}"))?
+ .execution_rpc(el)
+ .map_err(|e| anyhow!("helios execution_rpc: {e}"))?
+ .data_dir(data_dir)
+ // strict: refuse a too-old checkpoint (hard failure, never a silent stale read).
+ .strict_checkpoint_age()
+ // No user-pinned checkpoint → community fallback (ethPandaOps). v1 labels reads
+ // off this path Verified-by-freshness; the Degraded community-checkpoint
+ // distinction is a deferred supervisor concern (see module TODO).
+ .load_external_fallback()
+ // THE mechanism the whole verified path rests on: spawn the localhost JSON-RPC
+ // server on build() so an alloy HTTP provider can read through it.
+ .rpc_address(rpc_addr)
+ .with_file_db()
+ .build()
+ .map_err(|e| anyhow!("helios build: {e}"))
+}
+
+/// Build the **consumer** alloy provider that reads through Helios's localhost server.
+///
+/// THE one-line fix: `.with_default_block(BlockId::latest())`. alloy's `Provider::call`
+/// defaults the block tag to `pending`; Helios (a light client) has no pending block and
+/// 404s on it ("block not found: pending"). This rewrites the default to `latest` so the
+/// Multicall3 `aggregate3` (portfolio) and ENS `eth_call` reads succeed. Applied
+/// uniformly so every read path is identical; plain `get_balance`/`get_block_number`
+/// reads are unaffected but harmless to layer.
+async fn connect_verified_provider(helios_url: &str) -> Result {
+ let url = helios_url
+ .parse()
+ .map_err(|e| anyhow!("bad helios url {helios_url:?}: {e}"))?;
+ Ok(ProviderBuilder::new()
+ .with_default_block(BlockId::latest())
+ .connect_http(url)
+ .erased())
+}
+
+/// Block until the typed client serves a fresh verified head (the honest "ready to serve
+/// verified reads" moment — `wait_synced` returning is NOT it).
+async fn wait_until_serving(client: &EthereumClient, timeout: Duration) -> Result {
+ let deadline = Instant::now() + timeout;
+ loop {
+ match client.get_block_number().await {
+ Ok(h) => return Ok(h),
+ Err(e) => {
+ if Instant::now() > deadline {
+ return Err(anyhow!("helios: no fresh head within {timeout:?}: {e}"));
+ }
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ }
+ }
+ }
+}
+
+/// Poll the CONSUMER alloy provider (built against the localhost server) until it answers
+/// `eth_blockNumber` — proving the spawned `jsonrpc::start` task has bound AND that the
+/// consumer provider talks to it. Uses the alloy provider directly so we don't pull in
+/// `reqwest`/`serde_json` just for a liveness probe (the spike used reqwest).
+async fn wait_provider_live(provider: &DynProvider, timeout: Duration) -> Result {
+ let deadline = Instant::now() + timeout;
+ loop {
+ match provider.get_block_number().await {
+ Ok(n) => return Ok(n),
+ Err(e) => {
+ if Instant::now() > deadline {
+ return Err(anyhow!(
+ "helios localhost server never answered via the consumer provider: {e}"
+ ));
+ }
+ tokio::time::sleep(Duration::from_millis(250)).await;
+ }
+ }
+ }
+}
+
+/// Grab a free loopback port by binding an ephemeral socket and dropping it. Helios
+/// discards the jsonrpsee `ServerHandle`, so a `:0` port can't be recovered after the
+/// fact — pick a concrete one up front. (Small TOCTOU window; acceptable.)
+fn free_loopback_port() -> Result {
+ let l = std::net::TcpListener::bind("127.0.0.1:0")?;
+ let port = l.local_addr()?.port();
+ drop(l);
+ Ok(port)
+}
+
+/// Collapse a multi-line error into one short line for a `reason` string.
+fn one_line(e: &impl std::fmt::Display) -> String {
+ e.to_string()
+ .lines()
+ .next()
+ .unwrap_or("")
+ .chars()
+ .take(160)
+ .collect()
+}
diff --git a/crates/deckard-core/src/lib.rs b/crates/deckard-core/src/lib.rs
index d317ebd..289d1b5 100644
--- a/crates/deckard-core/src/lib.rs
+++ b/crates/deckard-core/src/lib.rs
@@ -10,17 +10,49 @@
//! GUI thread never blocks and never touches tokio.
pub mod balances;
+pub mod config;
pub mod eth;
+/// Embedded Helios light client → verified localhost reads. Gated behind the
+/// default-on `verified-reads` feature so the heavy revm/bls build is toggleable.
+#[cfg(feature = "verified-reads")]
+pub mod helios;
pub mod keystore;
+/// Key-less Railgun shield-calldata builder. Gated behind the default-on `shield` feature
+/// so the heavy ZK `railgun` crate is toggleable. When the feature is off, the
+/// `build_shield_native_intent` stub below returns a clear error (never a fake success).
+#[cfg(feature = "shield")]
+pub mod shield;
pub mod tokens;
pub use balances::{fetch_portfolio, format_amount, Portfolio, TokenBalance};
-pub use eth::{EthProvider, DEFAULT_RPC};
-pub use keystore::{
- random_word_positions, KdfParams, SecretKind, UnlockedVault, Vault, WordCount,
-};
+pub use config::{config_dir, policy_path, vault_path};
+pub use eth::{EthProvider, Read, DEFAULT_RPC};
+#[cfg(feature = "verified-reads")]
+pub use helios::{launch_verified, VerifiedReader, DEFAULT_CONSENSUS_RPC};
+pub use keystore::{random_word_positions, KdfParams, SecretKind, UnlockedVault, Vault, WordCount};
+// The key-less shield-calldata builder + the 0zk recipient type, re-exported so the daemon
+// and its tests can name them through core without a direct `railgun` dependency.
+#[cfg(feature = "shield")]
+pub use shield::{build_shield_native_intent, RailgunAddress};
pub use tokens::{TokenInfo, DEFAULT_TOKENS};
+/// Feature-off stub: when `shield` is compiled out, the symbol still exists so the daemon
+/// and tests build, but it returns a clear error — NEVER a fake success. Mirrors the
+/// honest-failure pattern the `verified-reads`-off read path uses (a Deny/Unsynced label
+/// rather than a silent fabricated value).
+#[cfg(not(feature = "shield"))]
+pub fn build_shield_native_intent(
+ _chain_id: u64,
+ _recipient: (),
+ _value: alloy_primitives::U256,
+) -> anyhow::Result {
+ anyhow::bail!("shield unavailable (feature off)")
+}
+
+// The shared trust label, re-exported so the app + daemon can name it through core
+// without a direct deckard-contract dependency just to render a read status.
+pub use deckard_contract::ReadStatus;
+
// Re-export the alloy primitive types the UI renders, so the app layer doesn't
// need a direct alloy dependency just to name an `Address` or a `U256`.
pub use alloy_primitives::{Address, U256};
diff --git a/crates/deckard-core/src/shield.rs b/crates/deckard-core/src/shield.rs
new file mode 100644
index 0000000..fe26a02
--- /dev/null
+++ b/crates/deckard-core/src/shield.rs
@@ -0,0 +1,125 @@
+//! Key-less Railgun **shield**-calldata builder.
+//!
+//! A SHIELD (depositing public ETH into a Railgun `0zk` private balance) is **key-less**:
+//! it needs only the recipient [`RailgunAddress`], the chain, and the value — never the
+//! spending key. And — the de-risked hero finding — a shield does **NO client-side ZK
+//! proof**: [`ShieldBuilder::build`] only encrypts the note (`encrypt_shield`) and
+//! `abi_encode`s the calldata; the on-chain contract verifies the commitment and deducts
+//! the 25-bps fee. So this builder is pure, synchronous, and instant.
+//!
+//! It builds the calldata and wraps it as an [`Intent`] with [`IntentKind::Shield`]; the
+//! daemon (which never sees this heavy ZK crate) just signs + broadcasts the handed
+//! `{to, value, calldata}`. That split is deliberate: the heavy `railgun` dep + any sync
+//! stays OUT of the key-holding daemon.
+//!
+//! Gated behind the default-on `shield` Cargo feature so the heavy ZK `railgun` tree is
+//! toggleable. When the feature is off, [`build_shield_native_intent`] is replaced by a
+//! stub (declared in `lib.rs`) that returns a clear "shield unavailable (feature off)"
+//! error — never a fake success.
+
+use alloy_primitives::U256;
+use anyhow::{anyhow, ensure};
+
+use deckard_contract::{Intent, IntentKind};
+
+// Re-exported from `lib.rs` (gated) so the daemon's test can name the recipient type
+// without taking a direct `railgun` dependency.
+pub use railgun::account::address::RailgunAddress;
+
+/// Build the key-less Railgun native-shield calldata and wrap it as an
+/// `Intent { kind: Shield, .. }`.
+///
+/// Key-less: shielding native ETH to `recipient` (a `0zk…` [`RailgunAddress`]) needs only
+/// the recipient, the chain config, and the value — never the spending key. The on-chain
+/// 25-bps (0.25%) shield fee is deducted by the contract; the calldata carries the full
+/// pre-fee `value`, so the synced private balance reads `value - value*25/10000`.
+///
+/// `value` is wei. For a *native* shield the builder always produces **exactly one**
+/// `TxData` (a single RelayAdapt `wrapBase + shield` multicall), so the 1-intent:1-tx model
+/// holds; we assert that invariant rather than silently dropping a tx.
+pub fn build_shield_native_intent(
+ chain_id: u64,
+ recipient: RailgunAddress,
+ value: U256,
+) -> anyhow::Result {
+ let chain = railgun::chain_config::ChainConfig::from_chain_id(chain_id)
+ .ok_or_else(|| anyhow!("shield: unsupported chain_id {chain_id}"))?;
+
+ // The note preimage carries a u128 value; reject anything that can't fit (a shield of
+ // > ~3.4e20 ETH is not a real case, but never silently truncate).
+ let value_u128: u128 = value
+ .try_into()
+ .map_err(|_| anyhow!("shield: value exceeds u128"))?;
+
+ // Pure + synchronous: no provider, no sync, no key. `build` only does symmetric note
+ // encryption + ABI-encode (no ZK proof). NOTE: railgun pins `rand` 0.9; deckard-core's
+ // own `rand` is 0.8, so this rng comes from the 0.9 crate aliased as `rand_09` in
+ // Cargo.toml to satisfy the `R: rand::Rng` (0.9) bound on `build`.
+ let mut txs = railgun::transact::ShieldBuilder::new(chain)
+ .shield_native(recipient, value_u128)
+ .build(&mut rand_09::rng())
+ .map_err(|e| anyhow!("shield build: {e}"))?;
+
+ ensure!(
+ txs.len() == 1,
+ "shield_native produced {} txs, expected exactly 1",
+ txs.len()
+ );
+ // Safe: just asserted len == 1.
+ let tx = txs.pop().expect("len checked == 1");
+
+ Ok(Intent {
+ chain_id,
+ to: tx.to, // RelayAdapt contract
+ token: None, // native shield; the value rides as msg.value
+ value: tx.value, // == the gross native total (wei); contract deducts the fee
+ calldata: tx.data, // RelayAdapt.multicall(wrapBase + shield)
+ kind: IntentKind::Shield,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A fresh ephemeral 0zk recipient → a well-formed Shield intent (key-less, no network):
+ /// exactly one tx, native (token None), non-empty calldata, value preserved (gross).
+ #[test]
+ fn builds_a_single_native_shield_intent() {
+ let chain = railgun::chain_config::ChainConfig::sepolia();
+ let acct = railgun::account::signer::PrivateKeySigner::new_evm(
+ rand_09::random(),
+ rand_09::random(),
+ chain.id,
+ );
+ use railgun::account::signer::RailgunSigner;
+ let recipient = acct.address();
+
+ let value = U256::from(1_000_000u64);
+ let intent = build_shield_native_intent(chain.id, recipient, value).expect("build");
+
+ assert_eq!(intent.kind, IntentKind::Shield);
+ assert_eq!(intent.token, None, "native shield carries no token");
+ assert_eq!(intent.value, value, "calldata carries the GROSS (pre-fee) value");
+ assert!(!intent.calldata.is_empty(), "shield calldata must be present");
+ assert_eq!(
+ intent.to, chain.relay_adapt_contract,
+ "native shield targets the RelayAdapt contract"
+ );
+ }
+
+ /// An unsupported chain id is a clear error, not a panic.
+ #[test]
+ fn unsupported_chain_errors() {
+ let chain = railgun::chain_config::ChainConfig::sepolia();
+ let acct = railgun::account::signer::PrivateKeySigner::new_evm(
+ rand_09::random(),
+ rand_09::random(),
+ chain.id,
+ );
+ use railgun::account::signer::RailgunSigner;
+ let err = build_shield_native_intent(424242, acct.address(), U256::from(1u64))
+ .expect_err("unsupported chain must error");
+ assert!(err.to_string().contains("unsupported chain_id"));
+ }
+}
diff --git a/crates/deckard-signerd/Cargo.toml b/crates/deckard-signerd/Cargo.toml
new file mode 100644
index 0000000..5d66d4b
--- /dev/null
+++ b/crates/deckard-signerd/Cargo.toml
@@ -0,0 +1,73 @@
+[package]
+name = "deckard-signerd"
+version = "0.1.0"
+edition = "2021"
+license = "AGPL-3.0-or-later"
+description = "Deckard's process-isolated signer daemon: owns the decrypted key, runs the policy gate, signs + broadcasts, and answers STOP — over a same-uid Unix-domain socket. The app and the future MCP sidecar are key-less clients."
+
+# lib + bin: the lib holds the wire framing, socket/peer-cred plumbing, the daemon state
+# machine, and the client + supervisor the GUI app reuses; the bin is the daemon entry point.
+[lib]
+name = "deckard_signerd"
+path = "src/lib.rs"
+
+[[bin]]
+name = "deckard-signerd"
+path = "src/main.rs"
+
+[features]
+# Verified reads via the embedded Helios light client (shared launcher in deckard-core).
+# ON by default; threads through to deckard-core's `verified-reads`. When OFF, the
+# daemon's balance read falls back to the raw RPC, tagged Unsynced("verification disabled").
+default = ["verified-reads", "shield"]
+verified-reads = ["deckard-core/verified-reads"]
+# Threads deckard-core's key-less shield-calldata builder through (DEFAULT ON). The daemon
+# itself gains NO railgun dep — it only broadcasts handed calldata; this feature just lets the
+# black-box `shield_e2e` integration test drive deckard-core's builder + assert via railgun.
+shield = ["deckard-core/shield"]
+
+[dependencies]
+# The frozen wire contract (Intent / Decision / Policy / RPC + the shared `evaluate`).
+deckard-contract = { path = "../deckard-contract" }
+# The headless engine: the keystore (`Vault`/`UnlockedVault`) we reuse — never rebuilt
+# here — plus the shared Helios launcher (`launch_verified`) behind `verified-reads`.
+# `default-features = false` so this crate's own feature flags (below) are the SINGLE source
+# of truth for what core builds: toggling signerd's `shield` / `verified-reads` off actually
+# drops the heavy `railgun` / `helios` tree from core (no leaky default pulling them back in).
+deckard-core = { path = "../deckard-core", default-features = false }
+
+# Async UDS server + framing. multi-thread rt so the Argon2 unlock can run on the blocking
+# pool without starving the reactor.
+tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "sync", "time", "io-util"] }
+# CBOR on the wire (matches the contract crate's encoding); JSON for the policy file.
+serde = { workspace = true }
+ciborium = "0.2"
+serde_json = "1"
+
+# alloy for the broadcast path: HTTP provider + recommended fillers (nonce/gas/chain-id) +
+# wallet signing. The signer is reconstructed from the raw scalar into THIS alloy stack
+# (the keystore's signer is a different alloy-signer-local version; only the version-stable
+# B256 scalar crosses the boundary). Features mirror deckard-core (default features on, which
+# already bring the rustls TLS backend) so the workspace shares ONE alloy build + TLS stack —
+# no second TLS backend, no feature drift.
+alloy = { version = "1", features = ["provider-http", "network", "rpc-types", "signer-local", "eips"] }
+alloy-primitives = { workspace = true }
+
+# Peer-cred uid (geteuid) + the single-instance flock.
+nix = { version = "0.29", features = ["fs", "user"] }
+zeroize = "1"
+anyhow = "1"
+
+[dev-dependencies]
+# The black-box `shield_e2e` test drives deckard-core's key-less builder to get the Intent,
+# but to ASSERT the privacy property it talks to railgun directly (register an ephemeral 0zk
+# recipient, sync against the anvil fork, read the private balance). These mirror the proven
+# shield spike's edge (rev 618c53f, `testing` feature) so the [patch] set resolves identically.
+# Only the TEST gets railgun — never the daemon binary/lib.
+railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7", features = ["testing"] }
+# Match Kohaku's alloy (1.8.3 / alloy-primitives 1.6.0) + the erased provider the spike uses
+# to fund the EOA and to drive railgun's RPC syncer.
+alloy = { version = "1.8", features = ["eips", "rpc-types", "network", "providers", "provider-http", "sol-types", "signer-local", "contract"] }
+rand_09 = { package = "rand", version = "0.9" }
+eyre = "0.6"
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync"] }
diff --git a/crates/deckard-signerd/src/auth.rs b/crates/deckard-signerd/src/auth.rs
new file mode 100644
index 0000000..ec21763
--- /dev/null
+++ b/crates/deckard-signerd/src/auth.rs
@@ -0,0 +1,46 @@
+//! Caller authentication: only a process with the **same uid** as the daemon may connect.
+//!
+//! We use tokio's built-in peer-cred (`SO_PEERCRED` on Linux, `getpeereid(2)` /
+//! `LOCAL_PEERCRED` on macOS) — verified to return the peer's effective uid on both — and
+//! compare it against our own *effective* uid. The decision itself is a pure function so it
+//! can be unit-tested without a live different-uid connection.
+
+use tokio::net::UnixStream;
+
+/// The peer's (effective) uid for a connected stream.
+pub fn peer_uid(stream: &UnixStream) -> std::io::Result {
+ Ok(stream.peer_cred()?.uid())
+}
+
+/// Our own effective uid (paired with `peer_cred`'s effective semantics).
+pub fn our_uid() -> u32 {
+ nix::unistd::geteuid().as_raw()
+}
+
+/// The whole authorization rule, pure and testable: a connection is allowed iff the peer
+/// runs as the same uid as the daemon.
+#[inline]
+pub fn same_uid(peer_uid: u32, our_uid: u32) -> bool {
+ peer_uid == our_uid
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rejects_a_different_uid() {
+ let ours = 501;
+ // A foreign uid is refused (the load-bearing check) ...
+ assert!(!same_uid(ours + 1, ours));
+ assert!(!same_uid(0, ours)); // even root, if it isn't us
+ // ... and the same uid is accepted.
+ assert!(same_uid(ours, ours));
+ }
+
+ #[test]
+ fn our_uid_is_stable() {
+ // geteuid is infallible and constant within a process.
+ assert_eq!(our_uid(), our_uid());
+ }
+}
diff --git a/crates/deckard-signerd/src/client.rs b/crates/deckard-signerd/src/client.rs
new file mode 100644
index 0000000..62cbc6e
--- /dev/null
+++ b/crates/deckard-signerd/src/client.rs
@@ -0,0 +1,146 @@
+//! The key-less client the GUI app (and, later, `deckard-mcp`) use to talk to the daemon.
+//!
+//! One request → one response over a fresh connection (the daemon serializes everything
+//! behind its state, so per-call connections are correct and simple at this call frequency).
+//! [`SignerClient::request`] is async; [`SignerClient::request_blocking`] wraps it in a
+//! short-lived current-thread runtime for callers without one (the app's background thread).
+
+use std::path::{Path, PathBuf};
+use std::time::{Duration, Instant};
+
+use tokio::net::UnixStream;
+
+use deckard_contract::{
+ Decision, ExecuteResult, Intent, RequestId, SignerRequest, SignerResponse, UnlockOutcome,
+};
+
+use crate::frame;
+use crate::request_id::request_id_for;
+
+/// How long to keep retrying `connect` before giving up — covers the brief window where the
+/// app has spawned the daemon but it hasn't bound the socket yet.
+const CONNECT_DEADLINE: Duration = Duration::from_secs(3);
+
+/// A handle to the daemon socket. Cheap to clone; holds only the path.
+#[derive(Clone, Debug)]
+pub struct SignerClient {
+ path: PathBuf,
+}
+
+impl SignerClient {
+ pub fn new(path: impl Into) -> Self {
+ Self { path: path.into() }
+ }
+
+ /// The socket path this client dials.
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ /// Send one request and read one response (connecting with a short retry so a just-spawned
+ /// daemon is given a moment to bind).
+ pub async fn request(&self, req: &SignerRequest) -> anyhow::Result {
+ let mut stream = self.connect().await?;
+ let body = frame::encode(req)?;
+ frame::write_frame(&mut stream, &body).await?;
+ let resp = frame::read_frame(&mut stream)
+ .await?
+ .ok_or_else(|| anyhow::anyhow!("daemon closed without responding"))?;
+ frame::decode(&resp)
+ }
+
+ /// Connect, retrying with capped backoff until [`CONNECT_DEADLINE`] — so the first call
+ /// right after the app spawns the daemon doesn't lose a startup race.
+ async fn connect(&self) -> anyhow::Result {
+ let deadline = Instant::now() + CONNECT_DEADLINE;
+ let mut delay = Duration::from_millis(25);
+ loop {
+ match UnixStream::connect(&self.path).await {
+ Ok(stream) => return Ok(stream),
+ Err(e) => {
+ if Instant::now() >= deadline {
+ return Err(anyhow::anyhow!("connect {}: {e}", self.path.display()));
+ }
+ tokio::time::sleep(delay).await;
+ delay = (delay * 2).min(Duration::from_millis(200));
+ }
+ }
+ }
+ }
+
+ /// Blocking convenience for callers without a tokio runtime (e.g. a GUI background
+ /// thread). Spins a short-lived current-thread runtime for the round-trip.
+ pub fn request_blocking(&self, req: &SignerRequest) -> anyhow::Result {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .map_err(|e| anyhow::anyhow!("build runtime: {e}"))?;
+ rt.block_on(self.request(req))
+ }
+
+ // --- typed helpers over `request` (used by the app + tests) ---------------------------
+
+ /// Unlock the vault over the socket (the app's lock screen sends this; the key never
+ /// enters the app's address space — only the returned address does).
+ pub async fn unlock(&self, passphrase: &str) -> anyhow::Result {
+ match self
+ .request(&SignerRequest::Unlock {
+ passphrase: passphrase.to_string(),
+ })
+ .await?
+ {
+ SignerResponse::Unlock(outcome) => Ok(outcome),
+ other => Err(unexpected("Unlock", other)),
+ }
+ }
+
+ /// Blocking [`unlock`](Self::unlock).
+ pub fn unlock_blocking(&self, passphrase: &str) -> anyhow::Result {
+ match self.request_blocking(&SignerRequest::Unlock {
+ passphrase: passphrase.to_string(),
+ })? {
+ SignerResponse::Unlock(outcome) => Ok(outcome),
+ other => Err(unexpected("Unlock", other)),
+ }
+ }
+
+ /// Lock the session (STOP-lite): zeroize the key, deny in-flight approvals.
+ pub fn lock_blocking(&self) -> anyhow::Result<()> {
+ match self.request_blocking(&SignerRequest::Lock)? {
+ SignerResponse::Ack => Ok(()),
+ other => Err(unexpected("Lock", other)),
+ }
+ }
+
+ /// Propose an intent → a `Decision`. Note: the returned `request_id` for an `Allow` is
+ /// derivable locally via [`request_id_for_intent`](Self::request_id_for_intent).
+ pub async fn propose(&self, intent: &Intent) -> anyhow::Result {
+ match self
+ .request(&SignerRequest::Propose {
+ intent: intent.clone(),
+ })
+ .await?
+ {
+ SignerResponse::Decision(d) => Ok(d),
+ other => Err(unexpected("Propose", other)),
+ }
+ }
+
+ /// Execute a previously-proposed request id → sign + broadcast (or denial).
+ pub async fn execute(&self, request_id: RequestId) -> anyhow::Result {
+ match self.request(&SignerRequest::Execute { request_id }).await? {
+ SignerResponse::Execute(r) => Ok(r),
+ other => Err(unexpected("Execute", other)),
+ }
+ }
+
+ /// The deterministic request id for an intent — lets a client `execute` an `Allow` it
+ /// derived locally (the daemon assigns the very same id).
+ pub fn request_id_for_intent(intent: &Intent) -> RequestId {
+ request_id_for(intent)
+ }
+}
+
+fn unexpected(req: &str, got: SignerResponse) -> anyhow::Error {
+ anyhow::anyhow!("daemon returned an unexpected response to {req}: {got:?}")
+}
diff --git a/crates/deckard-signerd/src/config.rs b/crates/deckard-signerd/src/config.rs
new file mode 100644
index 0000000..35e76cd
--- /dev/null
+++ b/crates/deckard-signerd/src/config.rs
@@ -0,0 +1,111 @@
+//! Daemon configuration, all environment-driven so CI/tests point at a local anvil and
+//! production points at Sepolia/mainnet by config.
+//!
+//! - `DECKARD_RPC_URL` — JSON-RPC endpoint to broadcast through (default: the public RPC).
+//! - `DECKARD_CHAIN_ID` — the chain the daemon signs for (default: 1 = mainnet). A
+//! `propose` whose `intent.chain_id` differs is denied `chain_mismatch`.
+//! - `DECKARD_CONFIG_DIR` — where `vault.bin` + `policy.json` live (default: the platform
+//! config dir, shared with the GUI app via `deckard_core::config`). Tests set this.
+//! - `DECKARD_SOCKET_PATH`— explicit UDS path (default: the per-uid runtime path). Tests +
+//! the app set this so both ends agree.
+
+use std::path::PathBuf;
+
+/// Resolved daemon configuration.
+#[derive(Clone, Debug)]
+pub struct Config {
+ pub rpc_url: String,
+ pub chain_id: u64,
+ pub config_dir: PathBuf,
+ pub socket_path: PathBuf,
+}
+
+impl Config {
+ /// Resolve the config from the environment, applying the documented defaults.
+ pub fn from_env() -> anyhow::Result {
+ let rpc_url = std::env::var("DECKARD_RPC_URL")
+ .unwrap_or_else(|_| deckard_core::DEFAULT_RPC.to_string());
+
+ let chain_id = match std::env::var("DECKARD_CHAIN_ID") {
+ Ok(s) => s
+ .trim()
+ .parse::()
+ .map_err(|_| anyhow::anyhow!("DECKARD_CHAIN_ID must be a u64, got {s:?}"))?,
+ Err(_) => 1,
+ };
+
+ let config_dir = match std::env::var_os("DECKARD_CONFIG_DIR") {
+ Some(d) => PathBuf::from(d),
+ None => deckard_core::config_dir()
+ .ok_or_else(|| anyhow::anyhow!("no platform config directory available"))?,
+ };
+
+ let socket_path = match std::env::var_os("DECKARD_SOCKET_PATH") {
+ Some(p) => PathBuf::from(p),
+ None => crate::socket::default_socket_path(),
+ };
+
+ Ok(Self {
+ rpc_url,
+ chain_id,
+ config_dir,
+ socket_path,
+ })
+ }
+
+ /// The encrypted keystore path the daemon reads on `Unlock`.
+ pub fn vault_path(&self) -> PathBuf {
+ self.config_dir.join(deckard_core::config::VAULT_FILE)
+ }
+
+ /// The signer policy path (a sane default is used if absent).
+ pub fn policy_path(&self) -> PathBuf {
+ self.config_dir.join(deckard_core::config::POLICY_FILE)
+ }
+
+ /// The RPC endpoint with any embedded credentials/host elided — safe to log.
+ pub fn redacted_rpc(&self) -> String {
+ redact_url(&self.rpc_url)
+ }
+}
+
+/// Reduce an RPC URL to `scheme://host[:port]` so an embedded API key (e.g. an Infura
+/// project secret in the path/query) never reaches a log line.
+fn redact_url(url: &str) -> String {
+ let (scheme, rest) = match url.split_once("://") {
+ Some(parts) => parts,
+ None => return "".to_string(),
+ };
+ let authority = rest
+ .split(['/', '?', '#'])
+ .next()
+ .unwrap_or("")
+ // strip any userinfo (user:pass@host)
+ .rsplit('@')
+ .next()
+ .unwrap_or("");
+ if authority.is_empty() {
+ "".to_string()
+ } else {
+ format!("{scheme}://{authority}")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::redact_url;
+
+ #[test]
+ fn redaction_drops_paths_and_userinfo() {
+ assert_eq!(
+ redact_url("https://mainnet.infura.io/v3/SECRETKEY"),
+ "https://mainnet.infura.io"
+ );
+ assert_eq!(redact_url("http://127.0.0.1:8545"), "http://127.0.0.1:8545");
+ assert_eq!(
+ redact_url("https://user:pass@rpc.example.com/path?token=abc"),
+ "https://rpc.example.com"
+ );
+ assert_eq!(redact_url("not-a-url"), "");
+ }
+}
diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs
new file mode 100644
index 0000000..75d44bc
--- /dev/null
+++ b/crates/deckard-signerd/src/daemon.rs
@@ -0,0 +1,633 @@
+//! The daemon state machine: `Locked` ⇄ `Unlocked { vault }`, the in-flight request table,
+//! and the handlers for every [`SignerRequest`]. The verdict for a `propose` comes from the
+//! ONE shared [`deckard_contract::evaluate`] — the daemon adds only the process-level
+//! pre-checks the policy can't express (`Locked`, `chain_mismatch`, unsupported kind).
+//!
+//! All requests are serialized behind a single [`Daemon`] (the server holds it in a
+//! `tokio::sync::Mutex`), so `propose`/`execute` can never race. `execute` holds that lock
+//! across the broadcast — acceptable for v1 (anvil is instant); a STOP arriving *during* an
+//! in-progress broadcast can't unsend a tx already on the wire, but the TOCTOU guard refuses
+//! any execute whose STOP landed first.
+
+use std::collections::HashMap;
+#[cfg(feature = "verified-reads")]
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use alloy_primitives::{Address, B256, U256};
+#[cfg(feature = "verified-reads")]
+use tokio::sync::Mutex as AsyncMutex;
+use zeroize::Zeroizing;
+
+use deckard_contract::{
+ evaluate, ApprovalStatus, BalanceReport, Decision, ExecuteResult, Intent, IntentKind, Policy,
+ ReadStatus, RequestId, SignerRequest, SignerResponse, UnlockOutcome,
+};
+use deckard_core::{UnlockedVault, Vault};
+
+use crate::config::Config;
+use crate::policy_store::{self, current_utc_day};
+use crate::request_id::request_id_for;
+use crate::signing;
+
+/// Default lifetime of a `NeedsApproval` before `status`/`execute` report `Expired`.
+/// Overridable via `DECKARD_APPROVAL_TTL_SECS` (used by tests to exercise expiry quickly).
+const APPROVAL_TTL: Duration = Duration::from_secs(120);
+
+/// Resolve the approval TTL: `DECKARD_APPROVAL_TTL_SECS` if set + parseable, else the default.
+fn approval_ttl() -> Duration {
+ std::env::var("DECKARD_APPROVAL_TTL_SECS")
+ .ok()
+ .and_then(|s| s.parse::().ok())
+ .map(Duration::from_secs)
+ .unwrap_or(APPROVAL_TTL)
+}
+
+/// `Locked` holds no key; `Unlocked` owns the decrypted vault (dropped — and zeroized — on
+/// lock/STOP) plus its cached primary address.
+enum VaultState {
+ Locked,
+ Unlocked {
+ vault: UnlockedVault,
+ address: Address,
+ },
+}
+
+/// One tracked proposal. `status` is the wire-visible approval state; `broadcast` is `Some`
+/// once `execute` has signed it (so a second `execute` is idempotently refused). `approved`
+/// is `true` only once a human `Resolve`d it — an *auto*-allow (within-cap) is re-checked
+/// against the caps at execute time, while a human-approved overage is not.
+struct PendingReq {
+ intent: Intent,
+ status: ApprovalStatus,
+ expires_at: Instant,
+ broadcast: Option,
+ approved: bool,
+}
+
+/// Upper bound on a single broadcast round-trip. A hung/blackholed RPC fails after this
+/// rather than wedging the daemon (and STOP) forever behind the held state lock.
+const BROADCAST_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// The daemon's embedded Helios verified-read path, kept in a **separately-locked** cell so
+/// the multi-second-to-90s `launch_verified` bootstrap can run WITHOUT holding the daemon's
+/// own `Mutex`. The server clones this `Arc` and primes it (see [`HeliosCell::ensure`]) off
+/// the daemon lock before dispatching a `Balance`, so a slow first read can never serialize
+/// behind it — the STOP/Lock brake stays responsive.
+///
+/// Its own `Drop` (via the inner `VerifiedReader`) tears the spawned localhost server down.
+/// `None` inside the option means "not built / failed to come up" — reads then fall back,
+/// tagged Unsynced, never silently Verified.
+///
+/// TODO(post-v1): v1 runs an INDEPENDENT Helios instance here (separate from the app's
+/// deckard-core::EthProvider one). The "consolidate all reads into the daemon" refactor is
+/// deferred.
+#[cfg(feature = "verified-reads")]
+#[derive(Clone, Default)]
+pub struct HeliosCell {
+ inner: Arc>>,
+}
+
+#[cfg(feature = "verified-reads")]
+impl HeliosCell {
+ fn new() -> Self {
+ Self::default()
+ }
+
+ /// Bootstrap the embedded Helios client if it isn't up yet. Runs the long
+ /// `launch_verified` while holding ONLY this cell's lock — never the daemon's — so the
+ /// security brake (STOP/Lock) and every other request stay live during the bootstrap.
+ /// Idempotent: a second caller that finds the client already built returns immediately.
+ /// On failure leaves the cell empty and logs (so the read falls back to a raw,
+ /// Unsynced-tagged read — never a silent Verified).
+ pub async fn ensure(
+ &self,
+ consensus_rpc: &str,
+ execution_rpc: &str,
+ data_dir: std::path::PathBuf,
+ ) {
+ let mut guard = self.inner.lock().await;
+ if guard.is_some() {
+ return;
+ }
+ match deckard_core::launch_verified(consensus_rpc, execution_rpc, data_dir).await {
+ Ok(reader) => *guard = Some(reader),
+ Err(e) => {
+ // Stays None → the read path falls back to a raw, Unsynced read.
+ eprintln!("signerd: helios bootstrap failed (reads tagged unsynced): {}", one_line(&e));
+ }
+ }
+ }
+}
+
+/// The whole daemon: config, the lock state, the live policy (with in-memory daily spend),
+/// and the request table.
+pub struct Daemon {
+ cfg: Config,
+ state: VaultState,
+ policy: Policy,
+ /// UTC day of the current `spent_today_wei` window (for the midnight rollover).
+ spent_day: u64,
+ /// Lifetime of a `NeedsApproval` record.
+ approval_ttl: Duration,
+ requests: HashMap,
+ /// The daemon's embedded Helios verified-read path, held in a SEPARATELY-locked
+ /// [`HeliosCell`] so its slow bootstrap never blocks the daemon mutex (see the cell's
+ /// docs). The server primes it off the daemon lock before a `Balance` dispatch; the
+ /// `balance` handler then borrows the already-built reader for the quick read. Cloning
+ /// the `Arc` is cheap and lets the server hold a handle without the daemon lock.
+ #[cfg(feature = "verified-reads")]
+ helios: HeliosCell,
+}
+
+impl Daemon {
+ /// Build a `Locked` daemon, loading the policy (or its safe default) up front.
+ pub fn new(cfg: Config) -> Self {
+ let policy = policy_store::load_policy(&cfg.policy_path());
+ Self {
+ cfg,
+ state: VaultState::Locked,
+ policy,
+ spent_day: current_utc_day(),
+ approval_ttl: approval_ttl(),
+ requests: HashMap::new(),
+ #[cfg(feature = "verified-reads")]
+ helios: HeliosCell::new(),
+ }
+ }
+
+ /// A clone of the daemon's [`HeliosCell`] handle, so the server can prime the Helios
+ /// bootstrap OFF the daemon lock before dispatching a `Balance` (keeping the STOP/Lock
+ /// brake responsive — the long bootstrap never holds the daemon mutex).
+ #[cfg(feature = "verified-reads")]
+ pub fn helios_cell(&self) -> HeliosCell {
+ self.helios.clone()
+ }
+
+ /// The (consensus_rpc, execution_rpc, data_dir) the embedded Helios client bootstraps
+ /// with. Exposed so the server can prime the cell off the daemon lock.
+ #[cfg(feature = "verified-reads")]
+ pub fn helios_bootstrap_args(&self) -> (&'static str, String, std::path::PathBuf) {
+ (
+ deckard_core::DEFAULT_CONSENSUS_RPC,
+ self.cfg.rpc_url.clone(),
+ self.cfg.config_dir.join("helios-signerd"),
+ )
+ }
+
+ /// Dispatch one request to one response. `async` because `execute`/`balance` do network
+ /// I/O and `unlock` runs Argon2 on the blocking pool.
+ pub async fn handle(&mut self, req: SignerRequest) -> SignerResponse {
+ match req {
+ SignerRequest::Unlock { passphrase } => {
+ SignerResponse::Unlock(self.unlock(passphrase).await)
+ }
+ // Lock and RevokeAll are the same act in v1: zeroize the key → Locked, deny
+ // everything in flight. Only a fresh Unlock re-arms.
+ SignerRequest::Lock | SignerRequest::RevokeAll => {
+ self.lock();
+ SignerResponse::Ack
+ }
+ SignerRequest::Resolve {
+ request_id,
+ approved,
+ } => {
+ self.resolve(request_id, approved);
+ SignerResponse::Ack
+ }
+ SignerRequest::Propose { intent } => SignerResponse::Decision(self.propose(&intent)),
+ SignerRequest::Execute { request_id } => {
+ SignerResponse::Execute(self.execute(request_id).await)
+ }
+ SignerRequest::Status { request_id } => SignerResponse::Status(self.status(request_id)),
+ SignerRequest::PolicyGet => {
+ self.rollover();
+ SignerResponse::Policy(self.policy.clone())
+ }
+ SignerRequest::Address => match &self.state {
+ VaultState::Unlocked { address, .. } => SignerResponse::Address(*address),
+ // No Address-specific error variant exists; signal locked Deny-style.
+ VaultState::Locked => SignerResponse::Decision(Decision::Deny {
+ reason: "locked".into(),
+ }),
+ },
+ SignerRequest::Balance { shielded } => {
+ SignerResponse::Balance(self.balance(shielded).await)
+ }
+ }
+ }
+
+ /// Read the keystore, decrypt under `passphrase`, and hold the key. The raw passphrase is
+ /// moved into `Zeroizing` immediately and never echoed or logged.
+ async fn unlock(&mut self, passphrase: String) -> UnlockOutcome {
+ let pass = Zeroizing::new(passphrase);
+ let vault_path = self.cfg.vault_path();
+ if !vault_path.exists() {
+ return UnlockOutcome::NoVault;
+ }
+ // Argon2id is CPU-heavy: read + unlock on the blocking pool so the reactor stays free.
+ let pass_for_blocking = pass.clone();
+ let result = tokio::task::spawn_blocking(move || {
+ let vault = Vault::read(&vault_path)?;
+ vault.unlock(pass_for_blocking.as_str())
+ })
+ .await;
+
+ match result {
+ Ok(Ok(unlocked)) => match unlocked.primary_address() {
+ Ok(address) => {
+ self.state = VaultState::Unlocked {
+ vault: unlocked,
+ address,
+ };
+ self.policy.revoked = false; // a fresh unlock re-arms
+ self.requests.clear(); // fresh session: no stale approvals survive a re-unlock
+ UnlockOutcome::Unlocked { address }
+ }
+ // A successfully decrypted vault that can't derive an address is corrupt;
+ // treat as a failed unlock rather than holding an unusable key.
+ Err(_) => UnlockOutcome::BadPassphrase,
+ },
+ // Wrong passphrase, a tampered vault, or a read error: one generic outcome, no
+ // oracle, no key held.
+ Ok(Err(_)) | Err(_) => UnlockOutcome::BadPassphrase,
+ }
+ }
+
+ /// Zeroize + drop the key → `Locked`, deny EVERY non-broadcast approval (both `Pending`
+ /// and already-`Allowed`, so an approval granted before STOP can never execute — even via
+ /// `status` polling), and trip the policy brake (so `PolicyGet` honestly reports
+ /// `revoked`). Shared by `Lock` and `RevokeAll`.
+ fn lock(&mut self) {
+ self.state = VaultState::Locked; // dropping UnlockedVault zeroizes the secret
+ self.policy.revoked = true;
+ for req in self.requests.values_mut() {
+ if req.broadcast.is_none()
+ && matches!(
+ req.status,
+ ApprovalStatus::Pending | ApprovalStatus::Allowed
+ )
+ {
+ req.status = ApprovalStatus::Denied {
+ reason: "revoked".into(),
+ };
+ }
+ }
+ }
+
+ /// Close an approval loop: a human (or a test) flips a `Pending` record to
+ /// `Allowed`/`Denied`. No-op for any other state (already resolved/expired).
+ fn resolve(&mut self, request_id: RequestId, approved: bool) {
+ self.expire_stale();
+ if let Some(req) = self.requests.get_mut(&request_id) {
+ if req.status == ApprovalStatus::Pending {
+ if approved {
+ req.status = ApprovalStatus::Allowed;
+ req.approved = true; // explicit human consent: not re-capped at execute
+ } else {
+ req.status = ApprovalStatus::Denied {
+ reason: "user_denied".into(),
+ };
+ }
+ }
+ }
+ }
+
+ /// Policy check only — NEVER signs. Process-level pre-checks first, then the shared
+ /// `evaluate`. On `NeedsApproval`/`Allow` a pending record is stored under the intent's
+ /// deterministic id; on `Deny` nothing is stored.
+ fn propose(&mut self, intent: &Intent) -> Decision {
+ self.rollover();
+ self.expire_stale();
+
+ // Pre-checks the Policy can't express (the mock has none of these states, which is
+ // why feeding both the same (Intent, Policy) yields identical decisions — the parity
+ // contract). These run before `evaluate`.
+ if matches!(self.state, VaultState::Locked) {
+ return Decision::Deny {
+ reason: "locked".into(),
+ };
+ }
+ if intent.chain_id != self.cfg.chain_id {
+ return Decision::Deny {
+ reason: "chain_mismatch".into(),
+ };
+ }
+ // v1 admits a native Send and a Shield (the privacy hero). The Shield's RelayAdapt
+ // calldata is built key-less in deckard-core and rides in `intent.calldata`; the
+ // daemon never sees the ZK crate, it only signs+broadcasts the handed bytes. Unshield
+ // / ContractCall stay a fast-follow.
+ if !matches!(intent.kind, IntentKind::Send | IntentKind::Shield) {
+ return Decision::Deny {
+ reason: "unsupported_v1".into(),
+ };
+ }
+ // v1 spine is native ETH only; an ERC-20 (`token = Some`) Send is a fast-follow.
+ // A native shield is `token: None` (the value rides as msg.value via RelayAdapt
+ // wrapBase), so it passes this guard.
+ if intent.token.is_some() {
+ return Decision::Deny {
+ reason: "erc20_unsupported_v1".into(),
+ };
+ }
+
+ let id = request_id_for(intent);
+
+ // Idempotent re-propose: an identical intent maps to the same id, so an existing record
+ // is returned AS-IS — a re-propose can't reset a `Pending` card's TTL, downgrade a
+ // human approval, or re-raise a `Denied`/`Expired` request. Retrying a terminal intent
+ // needs a fresh session (`Unlock` clears the table).
+ if let Some(existing) = self.requests.get(&id) {
+ return match &existing.status {
+ _ if existing.broadcast.is_some() => Decision::Deny {
+ reason: "already_executed".into(),
+ },
+ ApprovalStatus::Pending => Decision::NeedsApproval { request_id: id },
+ ApprovalStatus::Allowed => Decision::Allow,
+ ApprovalStatus::Denied { reason } => Decision::Deny {
+ reason: reason.clone(),
+ },
+ ApprovalStatus::Expired => Decision::Deny {
+ reason: "expired".into(),
+ },
+ };
+ }
+
+ // No record yet: the ONE shared decision function decides.
+ let status = match evaluate(intent, &self.policy) {
+ deny @ Decision::Deny { .. } => return deny,
+ Decision::Allow => ApprovalStatus::Allowed,
+ Decision::NeedsApproval { .. } => ApprovalStatus::Pending,
+ };
+ self.requests.insert(
+ id,
+ PendingReq {
+ intent: intent.clone(),
+ status: status.clone(),
+ expires_at: Instant::now() + self.approval_ttl,
+ broadcast: None,
+ approved: false,
+ },
+ );
+
+ match status {
+ ApprovalStatus::Allowed => Decision::Allow,
+ _ => Decision::NeedsApproval { request_id: id },
+ }
+ }
+
+ /// Sign + broadcast, only for an `Allowed` request that survives the sign-time re-check.
+ async fn execute(&mut self, request_id: RequestId) -> ExecuteResult {
+ self.rollover();
+ self.expire_stale();
+
+ // Phase 1 (lock held): TOCTOU re-check + eligibility, then extract tx params and the
+ // raw scalar (transiently, into `Zeroizing`). Borrows end before the await.
+ let (to, value, calldata, scalar) = {
+ let vault = match &self.state {
+ // STOP landed first — refuse even a previously-approved request.
+ VaultState::Locked => {
+ return ExecuteResult::Denied {
+ reason: "revoked".into(),
+ }
+ }
+ VaultState::Unlocked { vault, .. } => vault,
+ };
+ let req = match self.requests.get(&request_id) {
+ None => {
+ return ExecuteResult::Denied {
+ reason: "unknown_request".into(),
+ }
+ }
+ Some(req) => req,
+ };
+ if req.broadcast.is_some() {
+ return ExecuteResult::Denied {
+ reason: "already_executed".into(),
+ };
+ }
+ match &req.status {
+ // The only state that signs (covers within-cap Allow + human-approved over-cap).
+ ApprovalStatus::Allowed => {}
+ ApprovalStatus::Pending => {
+ return ExecuteResult::Denied {
+ reason: "not_approved".into(),
+ }
+ }
+ ApprovalStatus::Denied { reason } => {
+ return ExecuteResult::Denied {
+ reason: reason.clone(),
+ }
+ }
+ ApprovalStatus::Expired => {
+ return ExecuteResult::Denied {
+ reason: "expired".into(),
+ }
+ }
+ }
+ // Spend TOCTOU: an *auto*-allow must still be within policy at sign time, so two
+ // within-cap proposals can't both execute past the daily cap (`spent_today` only
+ // grows on prior executes). A human-APPROVED request carries explicit consent for
+ // its overage and is not re-capped.
+ if !req.approved && evaluate(&req.intent, &self.policy) != Decision::Allow {
+ return ExecuteResult::Denied {
+ reason: "cap_exceeded".into(),
+ };
+ }
+ let signer = match vault.account_signer(0) {
+ Ok(s) => s,
+ Err(e) => {
+ return ExecuteResult::Denied {
+ reason: format!("signer_error: {e}"),
+ }
+ }
+ };
+ // Only the version-stable raw scalar crosses into our alloy stack; zeroized on drop.
+ let scalar = Zeroizing::new(signer.to_bytes().0);
+ // Calldata is empty for a native Send (→ broadcast is byte-identical to before) and
+ // carries the RelayAdapt call for a Shield. The empty-vs-non-empty input IS the
+ // native/contract-call discriminator, so no IntentKind branch is needed here.
+ (
+ req.intent.to,
+ req.intent.value,
+ req.intent.calldata.clone(),
+ scalar,
+ )
+ };
+
+ // Phase 2: sign + broadcast (lock held — serialized; acceptable for v1). A bounded
+ // timeout keeps a hung RPC from wedging the daemon (and STOP) behind the held lock.
+ let broadcast = signing::broadcast_intent(
+ scalar.as_slice(),
+ &self.cfg.rpc_url,
+ self.cfg.chain_id,
+ to,
+ value,
+ &calldata,
+ );
+ let tx_hash = match tokio::time::timeout(BROADCAST_TIMEOUT, broadcast).await {
+ Ok(Ok(hash)) => hash,
+ Ok(Err(e)) => {
+ return ExecuteResult::Denied {
+ reason: format!("broadcast_failed: {}", one_line(&e)),
+ }
+ }
+ Err(_elapsed) => {
+ return ExecuteResult::Denied {
+ reason: "broadcast_timeout".into(),
+ }
+ }
+ };
+
+ // Phase 3: record the broadcast + bump the daily spend.
+ if let Some(req) = self.requests.get_mut(&request_id) {
+ req.broadcast = Some(tx_hash);
+ }
+ self.policy.spent_today_wei = self.policy.spent_today_wei.saturating_add(value);
+ ExecuteResult::Broadcast { tx_hash }
+ }
+
+ /// Poll an approval handle. Unknown ids report `Denied{unknown_request}` (matching the
+ /// mock); a `Pending` past its TTL reports `Expired`.
+ fn status(&mut self, request_id: RequestId) -> ApprovalStatus {
+ self.expire_stale();
+ match self.requests.get(&request_id) {
+ Some(req) => req.status.clone(),
+ None => ApprovalStatus::Denied {
+ reason: "unknown_request".into(),
+ },
+ }
+ }
+
+ /// Public balance, key-less. `shielded_wei` is 0 until T-Privacy.
+ ///
+ /// With `verified-reads` on (the default), the read goes through the daemon's own
+ /// embedded Helios light client (built lazily here) and is tagged
+ /// [`ReadStatus::Verified`] only when a fresh Helios head backs it. If Helios isn't
+ /// up / the head is stale / the read fails, the value is tagged `Unsynced` — we
+ /// **stop the old silent `.unwrap_or(ZERO)`-as-truth**: a 0 is no longer reported as
+ /// a trusted balance. With the feature off, the read goes through the raw RPC and is
+ /// always tagged `Unsynced("verification disabled")` — never `Verified`.
+ ///
+ /// A locked daemon doesn't know which address to read, so it reports zeros tagged
+ /// `Unsynced("locked")` — honest non-verification, not a trusted zero.
+ async fn balance(&mut self, _shielded: bool) -> BalanceReport {
+ self.rollover();
+ let addr = match &self.state {
+ VaultState::Unlocked { address, .. } => *address,
+ VaultState::Locked => {
+ return BalanceReport {
+ public_wei: U256::ZERO,
+ shielded_wei: U256::ZERO,
+ read_status: ReadStatus::unsynced("locked"),
+ }
+ }
+ };
+
+ let (public_wei, read_status) = self.read_public_balance(addr).await;
+ BalanceReport {
+ public_wei,
+ shielded_wei: U256::ZERO,
+ read_status,
+ }
+ }
+
+ /// Resolve the read endpoint + trust label, then read the native balance. Verified
+ /// path: reuse the embedded Helios client (already primed off the daemon lock by the
+ /// server — see [`HeliosCell`]), read through its localhost server, and label by head
+ /// freshness. Feature-off / Helios-down: read the raw RPC, label `Unsynced`. NEVER
+ /// returns `Verified` without a fresh Helios-verified read.
+ ///
+ /// The configured rpc_url is the EXECUTION-layer endpoint Helios proves against (must
+ /// serve eth_getProof); Nimbus drives the CL sync (deckard-core's default).
+ #[cfg(feature = "verified-reads")]
+ async fn read_public_balance(&mut self, addr: Address) -> (U256, ReadStatus) {
+ // The server primes the cell off-lock before dispatch, so this is normally a fast
+ // already-built borrow. `ensure` here is a defensive no-op fallback for callers
+ // (e.g. unit tests) that drive `handle` directly without the server priming first.
+ let (cl, el, data_dir) = self.helios_bootstrap_args();
+ self.helios.ensure(cl, &el, data_dir).await;
+
+ let guard = self.helios.inner.lock().await;
+ let reader = match guard.as_ref() {
+ Some(reader) => reader,
+ None => {
+ // Helios isn't up. Read the raw RPC so a value can be shown, but tag it
+ // Unsynced — we do NOT claim a raw read is Verified.
+ drop(guard);
+ let wei = signing::read_balance(&self.cfg.rpc_url, addr)
+ .await
+ .unwrap_or(U256::ZERO);
+ return (wei, ReadStatus::unsynced("helios unavailable"));
+ }
+ };
+ // Read the value FIRST, then derive its freshness label, so a `Verified` tag is
+ // bound to a head observed *after* the value came back (consistent with the
+ // app-side path in deckard-core::eth). A small TOCTOU window remains between the
+ // two round-trips, but it always fails toward "fresh head backed the value".
+ let read_url = reader.localhost_url().to_string();
+ match signing::read_balance(&read_url, addr).await {
+ Ok(wei) => {
+ // head_status() re-probes Helios freshness; a head gone stale → Unsynced.
+ let status = reader.head_status().await;
+ (wei, status)
+ }
+ Err(e) => (
+ U256::ZERO,
+ ReadStatus::unsynced(format!("verified read failed: {}", one_line(&e))),
+ ),
+ }
+ }
+
+ /// Feature-off path: read the raw RPC directly, always tagged Unsynced — never claim
+ /// a raw read is Verified.
+ #[cfg(not(feature = "verified-reads"))]
+ async fn read_public_balance(&mut self, addr: Address) -> (U256, ReadStatus) {
+ match signing::read_balance(&self.cfg.rpc_url, addr).await {
+ Ok(wei) => (wei, ReadStatus::unsynced("verification disabled")),
+ Err(e) => (
+ U256::ZERO,
+ ReadStatus::unsynced(format!("read failed: {}", one_line(&e))),
+ ),
+ }
+ }
+
+ /// Expire any non-broadcast request past its TTL — both `Pending` (the card was never
+ /// answered) and `Allowed` (an approval/auto-allow that went stale). So a stale id can
+ /// never be executed later, matching the frozen `ApprovalStatus::Expired` guarantee.
+ fn expire_stale(&mut self) {
+ let now = Instant::now();
+ for req in self.requests.values_mut() {
+ if req.broadcast.is_none()
+ && matches!(
+ req.status,
+ ApprovalStatus::Pending | ApprovalStatus::Allowed
+ )
+ && now >= req.expires_at
+ {
+ req.status = ApprovalStatus::Expired;
+ }
+ }
+ }
+
+ /// Reset the daily spend window when the UTC day ticks over.
+ fn rollover(&mut self) {
+ let today = current_utc_day();
+ if today != self.spent_day {
+ self.spent_day = today;
+ self.policy.spent_today_wei = U256::ZERO;
+ }
+ }
+}
+
+/// Collapse a multi-line error into one short line for a `reason` string (never includes a
+/// secret — broadcast/signing errors carry only addresses/amounts/RPC text).
+fn one_line(e: &anyhow::Error) -> String {
+ e.to_string()
+ .lines()
+ .next()
+ .unwrap_or("")
+ .chars()
+ .take(160)
+ .collect()
+}
diff --git a/crates/deckard-signerd/src/frame.rs b/crates/deckard-signerd/src/frame.rs
new file mode 100644
index 0000000..e7c17b8
--- /dev/null
+++ b/crates/deckard-signerd/src/frame.rs
@@ -0,0 +1,107 @@
+//! Length-delimited CBOR framing for the UDS wire: a **4-byte big-endian length prefix**
+//! followed by the CBOR body, one request/response per frame. Frames over [`MAX_FRAME`]
+//! (1 MiB) are rejected — a hostile or buggy client can't make the daemon allocate
+//! unbounded memory.
+
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+/// Hard cap on a single frame body. The 4-byte prefix can express up to 4 GiB; we refuse
+/// anything past 1 MiB (the largest legitimate frame — a big `calldata` — is far smaller).
+pub const MAX_FRAME: usize = 1024 * 1024;
+
+/// Read one frame. Returns `Ok(None)` on a clean EOF (peer closed between frames) so the
+/// connection loop can exit quietly; any other short read is an error.
+pub async fn read_frame(r: &mut R) -> anyhow::Result>>
+where
+ R: AsyncReadExt + Unpin,
+{
+ let mut len_buf = [0u8; 4];
+ match r.read_exact(&mut len_buf).await {
+ Ok(_) => {}
+ Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
+ Err(e) => return Err(e.into()),
+ }
+ let len = u32::from_be_bytes(len_buf) as usize;
+ anyhow::ensure!(
+ len <= MAX_FRAME,
+ "frame too large: {len} bytes > {MAX_FRAME}"
+ );
+ let mut body = vec![0u8; len];
+ r.read_exact(&mut body).await?;
+ Ok(Some(body))
+}
+
+/// Write one frame (length prefix + body), then flush.
+pub async fn write_frame(w: &mut W, body: &[u8]) -> anyhow::Result<()>
+where
+ W: AsyncWriteExt + Unpin,
+{
+ anyhow::ensure!(
+ body.len() <= MAX_FRAME,
+ "frame too large: {} bytes",
+ body.len()
+ );
+ w.write_all(&(body.len() as u32).to_be_bytes()).await?;
+ w.write_all(body).await?;
+ w.flush().await?;
+ Ok(())
+}
+
+/// CBOR-encode a value into a frame body.
+pub fn encode(value: &T) -> anyhow::Result> {
+ let mut buf = Vec::new();
+ ciborium::into_writer(value, &mut buf).map_err(|e| anyhow::anyhow!("cbor encode: {e}"))?;
+ anyhow::ensure!(
+ buf.len() <= MAX_FRAME,
+ "encoded frame too large: {} bytes",
+ buf.len()
+ );
+ Ok(buf)
+}
+
+/// CBOR-decode a frame body into a value.
+pub fn decode(bytes: &[u8]) -> anyhow::Result {
+ ciborium::from_reader(bytes).map_err(|e| anyhow::anyhow!("cbor decode: {e}"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn round_trips_a_frame() {
+ let payload = b"hello deckard".to_vec();
+ let mut buf: Vec = Vec::new();
+ write_frame(&mut buf, &payload).await.unwrap();
+ // 4-byte prefix + body.
+ assert_eq!(buf.len(), 4 + payload.len());
+ assert_eq!(&buf[0..4], &(payload.len() as u32).to_be_bytes());
+
+ let mut cursor = std::io::Cursor::new(buf);
+ let got = read_frame(&mut cursor).await.unwrap().unwrap();
+ assert_eq!(got, payload);
+ }
+
+ #[tokio::test]
+ async fn clean_eof_is_none() {
+ let mut cursor = std::io::Cursor::new(Vec::::new());
+ assert!(read_frame(&mut cursor).await.unwrap().is_none());
+ }
+
+ #[tokio::test]
+ async fn oversize_length_is_rejected() {
+ // A 4-byte prefix claiming > 1 MiB must be refused before allocating the body.
+ let mut framed = ((MAX_FRAME as u32) + 1).to_be_bytes().to_vec();
+ framed.extend_from_slice(&[0u8; 8]); // some body bytes (never fully read)
+ let mut cursor = std::io::Cursor::new(framed);
+ assert!(read_frame(&mut cursor).await.is_err());
+ }
+
+ #[tokio::test]
+ async fn cbor_encode_decode_round_trips() {
+ let value = ("send", 42u64, true);
+ let body = encode(&value).unwrap();
+ let back: (String, u64, bool) = decode(&body).unwrap();
+ assert_eq!(back, ("send".to_string(), 42, true));
+ }
+}
diff --git a/crates/deckard-signerd/src/lib.rs b/crates/deckard-signerd/src/lib.rs
new file mode 100644
index 0000000..1f2405d
--- /dev/null
+++ b/crates/deckard-signerd/src/lib.rs
@@ -0,0 +1,37 @@
+//! # deckard-signerd
+//!
+//! The process-isolated signer daemon — Deckard's operator spine. It owns the decrypted key
+//! in its own address space, runs the real policy gate ([`deckard_contract::evaluate`]),
+//! signs + broadcasts `Send` transactions, and answers STOP. The GUI app and the future
+//! `deckard-mcp` are **key-less clients** that reach it over a same-uid Unix-domain socket
+//! (4-byte-BE-length CBOR frames). This crate is a `lib` + `bin`: the library carries the
+//! wire framing, socket/peer-cred plumbing, the daemon state machine, and the client +
+//! supervisor the app reuses; `main.rs` is the daemon entry point.
+//!
+//! ## Security model
+//! - **Key isolation:** only this process ever holds an [`deckard_core::UnlockedVault`]. The
+//! app/MCP never receive key bytes — only an [`deckard_contract::Address`] and decisions.
+//! - **Caller auth:** every connection is gated on `SO_PEERCRED`/`LOCAL_PEERCRED` same-uid
+//! ([`auth`]); the socket is `0600` inside a `0700` dir ([`socket`]).
+//! - **STOP = zeroize:** `Lock`/`RevokeAll` drop the `UnlockedVault` (zeroizing the secret)
+//! → `Locked`; re-arm only via a fresh `Unlock` ([`daemon`]).
+//! - **TOCTOU:** `execute` re-checks `Locked` at sign time, so an approval granted before a
+//! STOP is still refused.
+
+pub mod auth;
+pub mod client;
+pub mod config;
+pub mod daemon;
+pub mod frame;
+pub mod policy_store;
+pub mod request_id;
+pub mod server;
+pub mod signing;
+pub mod socket;
+pub mod supervise;
+
+pub use client::SignerClient;
+pub use config::Config;
+pub use daemon::Daemon;
+pub use request_id::request_id_for;
+pub use supervise::DaemonSupervisor;
diff --git a/crates/deckard-signerd/src/main.rs b/crates/deckard-signerd/src/main.rs
new file mode 100644
index 0000000..c184ef3
--- /dev/null
+++ b/crates/deckard-signerd/src/main.rs
@@ -0,0 +1,31 @@
+//! `deckard-signerd` — the process-isolated signer daemon entry point.
+//!
+//! Resolves config from the environment, prepares the `0700` runtime dir, takes the
+//! single-instance lock, binds the `0600` socket, and serves the CBOR socket API until
+//! killed. See the crate docs (`lib.rs`) for the security model.
+
+use std::sync::Arc;
+
+use tokio::sync::Mutex;
+
+use deckard_signerd::{config::Config, daemon::Daemon, server, socket};
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ let cfg = Config::from_env()?;
+ eprintln!(
+ "signerd: starting · socket={} · chain_id={} · rpc={}",
+ cfg.socket_path.display(),
+ cfg.chain_id,
+ cfg.redacted_rpc(),
+ );
+
+ socket::prepare_parent(&cfg.socket_path)?;
+ // Hold the single-instance lock for the whole process lifetime.
+ let _lock = socket::single_instance(&cfg.socket_path)?;
+ let listener = socket::bind(&cfg.socket_path)?;
+ eprintln!("signerd: listening (same-uid only)");
+
+ let daemon = Arc::new(Mutex::new(Daemon::new(cfg)));
+ server::serve(listener, daemon).await
+}
diff --git a/crates/deckard-signerd/src/policy_store.rs b/crates/deckard-signerd/src/policy_store.rs
new file mode 100644
index 0000000..ac92a22
--- /dev/null
+++ b/crates/deckard-signerd/src/policy_store.rs
@@ -0,0 +1,113 @@
+//! Loading the signer [`Policy`] and tracking the daily spend.
+//!
+//! The policy is read from `policy.json` in the config dir; a **sane default** is used if the
+//! file is absent or malformed (fail-safe: a tight cap, no allowlist, approval-over-cap).
+//! `spent_today_wei` is **in-memory only**, rolls over at UTC midnight, and resets on daemon
+//! restart — cross-restart persistence is a documented v1 limitation / fast-follow. There is
+//! no `SetPolicy` mutation API yet.
+
+use std::path::Path;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use alloy_primitives::U256;
+
+use deckard_contract::{ApprovalMode, Policy};
+
+/// Default per-tx cap: 0.05 ETH.
+pub const DEFAULT_PER_TX_CAP_WEI: u128 = 50_000_000_000_000_000;
+/// Default rolling daily cap: 0.2 ETH.
+pub const DEFAULT_DAILY_CAP_WEI: u128 = 200_000_000_000_000_000;
+/// Default auto-shield threshold: 0.01 ETH.
+pub const DEFAULT_AUTO_SHIELD_MIN_WEI: u128 = 10_000_000_000_000_000;
+
+/// The fail-safe default policy used when `policy.json` is absent or unreadable.
+pub fn default_policy() -> Policy {
+ Policy {
+ per_tx_cap_wei: U256::from(DEFAULT_PER_TX_CAP_WEI),
+ daily_cap_wei: U256::from(DEFAULT_DAILY_CAP_WEI),
+ spent_today_wei: U256::ZERO,
+ allow_to: vec![], // empty = any recipient; the caps still apply
+ auto_shield_min_wei: U256::from(DEFAULT_AUTO_SHIELD_MIN_WEI),
+ require_approval: ApprovalMode::OverCap,
+ revoked: false,
+ }
+}
+
+/// Load the policy from `path`, falling back to [`default_policy`] on any problem.
+///
+/// `spent_today_wei` and `revoked` are forced to their fresh-start values regardless of what
+/// the file says: spend tracking is in-memory, and a daemon boots *armed* (the brake is a
+/// live STOP, not a persisted flag).
+pub fn load_policy(path: &Path) -> Policy {
+ let bytes = match std::fs::read(path) {
+ Ok(b) => b,
+ Err(_) => return default_policy(),
+ };
+ match serde_json::from_slice::(&bytes) {
+ Ok(mut p) => {
+ p.spent_today_wei = U256::ZERO;
+ p.revoked = false;
+ p
+ }
+ Err(_) => default_policy(),
+ }
+}
+
+/// Days since the Unix epoch in UTC — the rollover key for `spent_today_wei`. (Chrono-free:
+/// integer division of the wall-clock seconds.)
+pub fn current_utc_day() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs() / 86_400)
+ .unwrap_or(0)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn absent_file_yields_default() {
+ let p = load_policy(Path::new("/nonexistent/deckard/policy.json"));
+ assert_eq!(p.per_tx_cap_wei, U256::from(DEFAULT_PER_TX_CAP_WEI));
+ assert_eq!(p.daily_cap_wei, U256::from(DEFAULT_DAILY_CAP_WEI));
+ assert!(!p.revoked);
+ assert!(p.allow_to.is_empty());
+ }
+
+ #[test]
+ fn load_resets_spent_and_revoked() {
+ let dir = std::env::temp_dir().join(format!("deckard-policy-test-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("policy.json");
+ // A file that (maliciously or staleley) claims spent + revoked.
+ let on_disk = Policy {
+ spent_today_wei: U256::from(999u64),
+ revoked: true,
+ ..default_policy()
+ };
+ std::fs::write(&path, serde_json::to_vec(&on_disk).unwrap()).unwrap();
+
+ let loaded = load_policy(&path);
+ assert_eq!(
+ loaded.spent_today_wei,
+ U256::ZERO,
+ "spend is in-memory; never trusted"
+ );
+ assert!(!loaded.revoked, "daemon boots armed");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn malformed_file_yields_default() {
+ let dir = std::env::temp_dir().join(format!("deckard-policy-bad-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("policy.json");
+ std::fs::write(&path, b"{ not valid json").unwrap();
+ assert_eq!(
+ load_policy(&path).per_tx_cap_wei,
+ U256::from(DEFAULT_PER_TX_CAP_WEI)
+ );
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/crates/deckard-signerd/src/request_id.rs b/crates/deckard-signerd/src/request_id.rs
new file mode 100644
index 0000000..ed65c94
--- /dev/null
+++ b/crates/deckard-signerd/src/request_id.rs
@@ -0,0 +1,85 @@
+//! How `propose` assigns a request id.
+//!
+//! The frozen `Decision::Allow` carries **no** id, yet `execute` is keyed by one. We close
+//! that gap deterministically: the request id is `keccak256` of a stable, unambiguous
+//! encoding of the intent. So a client that received `Allow` can derive the very same id
+//! locally to `execute` it, while a `NeedsApproval` id (returned on the wire) is identical.
+//! Both the daemon (server) and [`SignerClient`](crate::SignerClient) call this, so they
+//! never disagree.
+//!
+//! v1 caveats (documented, fast-follow): the id is deterministic, hence *guessable* from the
+//! intent — fine for a same-uid socket, but production should salt it (which needs the id to
+//! ride the `Allow` on the wire — a contract change). Two identical intents map to one id;
+//! the daemon coalesces them (it preserves an already-broadcast record, so this can never
+//! double-spend).
+
+use alloy_primitives::keccak256;
+
+use deckard_contract::{Intent, IntentKind, RequestId};
+
+/// Deterministic request id for an intent. Fixed-width fields first, variable `calldata`
+/// last, so no field boundary is ambiguous.
+pub fn request_id_for(intent: &Intent) -> RequestId {
+ let mut buf = Vec::with_capacity(8 + 21 + 32 + 1 + intent.calldata.len());
+ buf.extend_from_slice(&intent.chain_id.to_be_bytes()); // 8
+ buf.extend_from_slice(intent.to.as_slice()); // 20
+ match intent.token {
+ Some(token) => {
+ buf.push(1);
+ buf.extend_from_slice(token.as_slice()); // 20
+ }
+ None => buf.push(0),
+ }
+ buf.extend_from_slice(&intent.value.to_be_bytes::<32>()); // 32
+ buf.push(match intent.kind {
+ IntentKind::Send => 0,
+ IntentKind::Shield => 1,
+ IntentKind::Unshield => 2,
+ IntentKind::ContractCall => 3,
+ });
+ buf.extend_from_slice(&intent.calldata); // variable, last
+ keccak256(&buf)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use alloy_primitives::{Address, Bytes, U256};
+
+ fn send(value: u64) -> Intent {
+ Intent {
+ chain_id: 31337,
+ to: Address::repeat_byte(0x22),
+ token: None,
+ value: U256::from(value),
+ calldata: Bytes::new(),
+ kind: IntentKind::Send,
+ }
+ }
+
+ #[test]
+ fn deterministic_and_nonzero() {
+ let id = request_id_for(&send(100));
+ assert_eq!(id, request_id_for(&send(100)), "same intent → same id");
+ assert_ne!(id, RequestId::ZERO);
+ }
+
+ #[test]
+ fn distinguishes_fields() {
+ assert_ne!(
+ request_id_for(&send(100)),
+ request_id_for(&send(101)),
+ "value"
+ );
+ let mut other_to = send(100);
+ other_to.to = Address::repeat_byte(0x33);
+ assert_ne!(request_id_for(&send(100)), request_id_for(&other_to), "to");
+ let mut tokened = send(100);
+ tokened.token = Some(Address::repeat_byte(0x22));
+ assert_ne!(
+ request_id_for(&send(100)),
+ request_id_for(&tokened),
+ "token presence"
+ );
+ }
+}
diff --git a/crates/deckard-signerd/src/server.rs b/crates/deckard-signerd/src/server.rs
new file mode 100644
index 0000000..dac41a4
--- /dev/null
+++ b/crates/deckard-signerd/src/server.rs
@@ -0,0 +1,109 @@
+//! The UDS server: accept connections, gate each on same-uid peer-cred, then serve a stream
+//! of length-delimited CBOR request/response frames. All requests funnel through the single
+//! shared [`Daemon`] behind a `tokio::sync::Mutex`, so they are serialized and can't race.
+
+use std::sync::Arc;
+
+use tokio::net::{UnixListener, UnixStream};
+use tokio::sync::Mutex;
+use zeroize::Zeroize;
+
+use deckard_contract::{Decision, SignerRequest, SignerResponse};
+
+use crate::auth;
+use crate::daemon::Daemon;
+use crate::frame;
+
+/// Accept loop. Rejects (and drops) any connection whose peer uid differs from ours, then
+/// spawns a per-connection task. Runs until the listener errors fatally.
+pub async fn serve(listener: UnixListener, daemon: Arc>) -> anyhow::Result<()> {
+ let our = auth::our_uid();
+ loop {
+ let stream = match listener.accept().await {
+ Ok((stream, _addr)) => stream,
+ Err(e) => {
+ eprintln!("signerd: accept error: {e}");
+ continue;
+ }
+ };
+
+ match auth::peer_uid(&stream) {
+ Ok(uid) if auth::same_uid(uid, our) => {}
+ Ok(uid) => {
+ eprintln!("signerd: rejecting connection from uid {uid} (daemon uid {our})");
+ continue; // drop the stream → connection refused
+ }
+ Err(e) => {
+ eprintln!("signerd: peer-cred check failed, dropping connection: {e}");
+ continue;
+ }
+ }
+
+ let daemon = Arc::clone(&daemon);
+ tokio::spawn(async move {
+ if let Err(e) = handle_conn(stream, daemon).await {
+ eprintln!("signerd: connection closed: {e}");
+ }
+ });
+ }
+}
+
+/// Serve one connection: read a frame, decode it, zeroize the raw bytes (they may hold an
+/// `Unlock` passphrase), dispatch, write the response. A malformed/oversize frame gets one
+/// error response and the connection is closed.
+async fn handle_conn(mut stream: UnixStream, daemon: Arc>) -> anyhow::Result<()> {
+ loop {
+ let mut buf = match frame::read_frame(&mut stream).await {
+ Ok(Some(buf)) => buf,
+ Ok(None) => return Ok(()), // peer closed cleanly between frames
+ Err(e) => {
+ // Oversize/short read: best-effort error, then close.
+ let _ = reply_error(&mut stream, "malformed_request").await;
+ return Err(e);
+ }
+ };
+
+ let decoded: Result = frame::decode(&buf);
+ // Always scrub the raw frame: an Unlock frame carries the passphrase bytes.
+ buf.zeroize();
+
+ let req = match decoded {
+ Ok(req) => req,
+ Err(e) => {
+ let _ = reply_error(&mut stream, "malformed_request").await;
+ return Err(e);
+ }
+ };
+
+ // A `Balance` read needs the embedded Helios light client. Its first-time bootstrap
+ // (`launch_verified`) can take seconds-to-90s; prime it HERE, OFF the daemon lock, so
+ // the long bootstrap never serializes ahead of the security brake (STOP/Lock) or any
+ // other request. The cell is idempotent and separately locked — after this returns,
+ // the daemon's `balance` handler does only the quick verified read under its mutex.
+ #[cfg(feature = "verified-reads")]
+ if matches!(req, SignerRequest::Balance { .. }) {
+ let (cell, (cl, el, data_dir)) = {
+ let d = daemon.lock().await;
+ (d.helios_cell(), d.helios_bootstrap_args())
+ };
+ cell.ensure(cl, &el, data_dir).await;
+ }
+
+ // Dispatch behind the shared lock (serializes all requests). Note: we deliberately
+ // never log the request contents — an Unlock passphrase must never reach a log line.
+ let resp = daemon.lock().await.handle(req).await;
+
+ let body = frame::encode(&resp)?;
+ frame::write_frame(&mut stream, &body).await?;
+ }
+}
+
+/// Send a generic Deny-style error response (used when we can't even decode the request, so
+/// the precise response variant is unknown).
+async fn reply_error(stream: &mut UnixStream, reason: &str) -> anyhow::Result<()> {
+ let resp = SignerResponse::Decision(Decision::Deny {
+ reason: reason.to_string(),
+ });
+ let body = frame::encode(&resp)?;
+ frame::write_frame(stream, &body).await
+}
diff --git a/crates/deckard-signerd/src/signing.rs b/crates/deckard-signerd/src/signing.rs
new file mode 100644
index 0000000..0aede4d
--- /dev/null
+++ b/crates/deckard-signerd/src/signing.rs
@@ -0,0 +1,106 @@
+//! The broadcast path: build an **EIP-1559** transaction with alloy's recommended fillers
+//! (nonce from the pending count, gas from fee/gas estimation, chain id), sign it, and
+//! broadcast it through the configured RPC.
+//!
+//! ## The signer version bridge
+//! `deckard-core`'s keystore yields a signer from `alloy-signer-local` **2.0.5**, but the
+//! provider here is built from the `alloy` meta-crate (bundled signer-local **1.8.3**). The
+//! two `PrivateKeySigner` types are incompatible — so we never hand one to the other. The
+//! caller extracts the raw 32-byte secp256k1 scalar (the version-stable `B256` is the only
+//! thing that crosses), and we reconstruct the signer in *this* alloy stack from those
+//! bytes. The scalar is held in a `Zeroizing` buffer by the caller.
+
+use alloy::network::{Ethereum, EthereumWallet, TransactionBuilder};
+use alloy::providers::{Provider, ProviderBuilder};
+use alloy::rpc::types::TransactionRequest;
+use alloy::signers::local::PrivateKeySigner;
+use alloy_primitives::{Address, Bytes, B256, U256};
+
+/// Sign + broadcast a native-ETH send and return the broadcast tx hash.
+///
+/// Thin wrapper over [`broadcast_intent`] with empty calldata, so the native-send call
+/// sites (and their tests) keep byte-identical behaviour: no `input` field is set, the gas
+/// filler produces the same type-2 tx as before.
+///
+/// `scalar` is the raw 32-byte private key (the caller keeps it in `Zeroizing`).
+pub async fn broadcast_native_send(
+ scalar: &[u8],
+ rpc_url: &str,
+ chain_id: u64,
+ to: Address,
+ value_wei: U256,
+) -> anyhow::Result {
+ broadcast_intent(scalar, rpc_url, chain_id, to, value_wei, &Bytes::new()).await
+}
+
+/// Sign + broadcast an intent's `(to, value, calldata)` and return the broadcast tx hash.
+///
+/// Generalizes the native send to carry **calldata**, so a Shield / ContractCall intent
+/// broadcasts the RelayAdapt (or other) call the key-less builder handed over. The selection
+/// is implicit: empty `input` ⇒ a plain native send (identical to the old path); a non-empty
+/// `input` ⇒ a contract call. The daemon stays ZK-free — it only signs+broadcasts the bytes.
+///
+/// `scalar` is the raw 32-byte private key (the caller keeps it in `Zeroizing`).
+pub async fn broadcast_intent(
+ scalar: &[u8],
+ rpc_url: &str,
+ chain_id: u64,
+ to: Address,
+ value_wei: U256,
+ input: &Bytes,
+) -> anyhow::Result {
+ let signer = PrivateKeySigner::from_slice(scalar)
+ .map_err(|e| anyhow::anyhow!("reconstruct signer: {e}"))?;
+ let wallet = EthereumWallet::from(signer);
+
+ let url = rpc_url
+ .parse()
+ .map_err(|e| anyhow::anyhow!("bad RPC URL {rpc_url:?}: {e}"))?;
+ // `new()` installs the recommended fillers (nonce/gas/chain-id); `.wallet()` adds signing.
+ let provider = ProviderBuilder::new().wallet(wallet).connect_http(url);
+
+ // `to`/`value` (and, for a shield/contract call, `input`) set ⇒ the gas filler produces an
+ // EIP-1559 (type-2) tx and fills the fee fields — now estimating gas against the calldata
+ // too; the nonce filler uses the pending count; chain id is pinned explicitly.
+ //
+ // The `TransactionBuilder` methods are disambiguated to alloy's `Ethereum` network:
+ // pulling helios-ethereum into the tree (via deckard-core's `verified-reads`) adds a
+ // second `TransactionBuilder` impl for
+ // `TransactionRequest`, so the chained builder calls would otherwise be ambiguous.
+ // Setting via `&mut` with an `Ethereum`-typed binding anchors every call to alloy's impl.
+ let mut tx = TransactionRequest::default();
+ >::set_to(&mut tx, to);
+ >::set_value(&mut tx, value_wei);
+ >::set_chain_id(&mut tx, chain_id);
+ if !input.is_empty() {
+ >::set_input(&mut tx, input.clone());
+ }
+
+ let pending = provider
+ .send_transaction(tx)
+ .await
+ .map_err(|e| anyhow::anyhow!("broadcast: {e}"))?;
+ Ok(*pending.tx_hash())
+}
+
+/// Read an address's public (native) balance through `read_url` — key-less, read-only.
+///
+/// `read_url` is the endpoint the consumer provider reads through. With verified reads
+/// on (the default) the daemon passes Helios's **localhost** URL here, so this read is
+/// proof-checked; with the feature off it is the raw RPC (and the caller tags the result
+/// `Unsynced`). The `with_default_block(latest)` fix is applied uniformly: alloy defaults
+/// `eth_call`/`estimateGas` to the `pending` tag, which a Helios light client cannot
+/// serve — `get_balance` itself targets `latest`, but layering the default keeps every
+/// read path uniform with the eth_call-backed reads.
+pub async fn read_balance(read_url: &str, addr: Address) -> anyhow::Result {
+ let url = read_url
+ .parse()
+ .map_err(|e| anyhow::anyhow!("bad read URL {read_url:?}: {e}"))?;
+ let provider = ProviderBuilder::new()
+ .with_default_block(alloy::eips::BlockId::latest())
+ .connect_http(url);
+ provider
+ .get_balance(addr)
+ .await
+ .map_err(|e| anyhow::anyhow!("get_balance: {e}"))
+}
diff --git a/crates/deckard-signerd/src/socket.rs b/crates/deckard-signerd/src/socket.rs
new file mode 100644
index 0000000..9c81126
--- /dev/null
+++ b/crates/deckard-signerd/src/socket.rs
@@ -0,0 +1,122 @@
+//! Socket lifecycle: where the UDS lives, its permissions, stale-socket cleanup, and the
+//! single-instance lock.
+//!
+//! Path: `$XDG_RUNTIME_DIR/deckard/signerd.sock` (Linux); when `$XDG_RUNTIME_DIR` is unset
+//! (the usual macOS case) it falls back to `$TMPDIR/deckard-$UID/signerd.sock`. The parent
+//! dir is forced to `0700` and the socket to `0600` — `bind`/`mkdir` honor the umask, so we
+//! chmod explicitly rather than trusting it.
+
+use std::fs;
+use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
+use std::path::{Path, PathBuf};
+
+use nix::fcntl::{Flock, FlockArg};
+use tokio::net::UnixListener;
+
+/// The default socket path (pure — does not touch the filesystem). Call [`prepare_parent`]
+/// before binding.
+pub fn default_socket_path() -> PathBuf {
+ runtime_dir().join("signerd.sock")
+}
+
+/// The runtime directory that holds the socket + lockfile.
+fn runtime_dir() -> PathBuf {
+ if let Some(xdg) = std::env::var_os("XDG_RUNTIME_DIR") {
+ if !xdg.is_empty() {
+ return PathBuf::from(xdg).join("deckard");
+ }
+ }
+ // macOS (XDG_RUNTIME_DIR usually unset): per-uid dir under TMPDIR.
+ let tmp = std::env::var_os("TMPDIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(std::env::temp_dir);
+ let uid = nix::unistd::geteuid().as_raw();
+ tmp.join(format!("deckard-{uid}"))
+}
+
+/// Create the socket's parent dir (if needed) and force it to `0700`.
+pub fn prepare_parent(socket_path: &Path) -> std::io::Result<()> {
+ if let Some(dir) = socket_path.parent() {
+ fs::create_dir_all(dir)?;
+ fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
+ }
+ Ok(())
+}
+
+/// Acquire the single-instance lock — an exclusive, non-blocking `flock` on a sibling
+/// `signerd.lock`. The returned guard must be held for the daemon's whole lifetime; the OS
+/// releases the lock automatically on process exit (even on SIGKILL), so no stale-lock
+/// cleanup is needed. A second daemon fails fast instead of racing on the socket.
+pub fn single_instance(socket_path: &Path) -> anyhow::Result> {
+ let lock_path = socket_path.with_extension("lock");
+ let file = fs::OpenOptions::new()
+ .create(true)
+ .write(true)
+ .truncate(false) // it's only a lock handle; its contents are irrelevant
+ .mode(0o600)
+ .open(&lock_path)?;
+ Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_f, errno)| {
+ anyhow::anyhow!("another deckard-signerd is already running ({errno})")
+ })
+}
+
+/// Remove any stale socket node, then bind and chmod to `0600`.
+pub fn bind(socket_path: &Path) -> std::io::Result {
+ match fs::remove_file(socket_path) {
+ Ok(()) => {}
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => return Err(e),
+ }
+ let listener = UnixListener::bind(socket_path)?;
+ // bind honored the umask; force owner-only.
+ fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600))?;
+ Ok(listener)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn default_path_ends_in_signerd_sock() {
+ assert!(default_socket_path().ends_with("signerd.sock"));
+ }
+
+ #[tokio::test]
+ async fn bind_yields_a_0600_socket_in_a_0700_dir() {
+ // Use an isolated temp dir as the "runtime dir".
+ let base = std::env::temp_dir().join(format!("deckard-sock-test-{}", std::process::id()));
+ let sock = base.join("signerd.sock");
+ let _ = fs::remove_dir_all(&base);
+
+ prepare_parent(&sock).unwrap();
+ let dir_mode = fs::metadata(&base).unwrap().permissions().mode() & 0o777;
+ assert_eq!(dir_mode, 0o700, "parent dir must be 0700");
+
+ let _listener = bind(&sock).unwrap();
+ let sock_mode = fs::metadata(&sock).unwrap().permissions().mode() & 0o777;
+ assert_eq!(sock_mode, 0o600, "socket must be 0600");
+
+ let _ = fs::remove_dir_all(&base);
+ }
+
+ #[test]
+ fn single_instance_is_exclusive() {
+ let base = std::env::temp_dir().join(format!("deckard-lock-test-{}", std::process::id()));
+ let sock = base.join("signerd.sock");
+ let _ = fs::remove_dir_all(&base);
+ prepare_parent(&sock).unwrap();
+
+ let first = single_instance(&sock).expect("first lock");
+ // A second attempt on the same lockfile must fail while the first is held.
+ assert!(
+ single_instance(&sock).is_err(),
+ "second instance must be refused"
+ );
+ drop(first);
+ // Once released, a fresh lock succeeds.
+ assert!(single_instance(&sock).is_ok());
+
+ let _ = fs::remove_dir_all(&base);
+ }
+}
diff --git a/crates/deckard-signerd/src/supervise.rs b/crates/deckard-signerd/src/supervise.rs
new file mode 100644
index 0000000..3b8c472
--- /dev/null
+++ b/crates/deckard-signerd/src/supervise.rs
@@ -0,0 +1,159 @@
+//! Spawn + supervise the `deckard-signerd` child process from the GUI app.
+//!
+//! The app owns the daemon's lifecycle: it spawns the child, restarts it (capped backoff) if
+//! it crashes, and kills it on app exit (via `Drop`). The child binary is resolved from
+//! `DECKARD_SIGNERD_BIN`, else next to the app binary, else `deckard-signerd` on `PATH`. The
+//! socket path is passed explicitly so the app's [`SignerClient`](crate::SignerClient) and
+//! the daemon agree. The child inherits stdout/stderr → the app's log.
+
+use std::path::PathBuf;
+use std::process::{Child, Command, Stdio};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+/// Resolve the `deckard-signerd` binary: explicit override, then a sibling of the running
+/// app binary, then the bare name (PATH lookup).
+pub fn resolve_binary() -> PathBuf {
+ if let Some(p) = std::env::var_os("DECKARD_SIGNERD_BIN") {
+ return PathBuf::from(p);
+ }
+ if let Ok(exe) = std::env::current_exe() {
+ if let Some(dir) = exe.parent() {
+ let sibling = dir.join("deckard-signerd");
+ if sibling.exists() {
+ return sibling;
+ }
+ }
+ }
+ PathBuf::from("deckard-signerd")
+}
+
+/// A running, self-restarting daemon child. Dropping it stops the supervisor and kills the
+/// child.
+pub struct DaemonSupervisor {
+ shutdown: Arc,
+ child: Arc>>,
+ socket_path: PathBuf,
+}
+
+impl DaemonSupervisor {
+ /// Spawn the daemon bound to `socket_path` (broadcasting via `rpc_url` on `chain_id`) and a
+ /// monitor thread that respawns it on crash. The child inherits the current environment
+ /// plus `DECKARD_SOCKET_PATH`/`DECKARD_RPC_URL`/`DECKARD_CHAIN_ID`.
+ pub fn spawn(socket_path: PathBuf, rpc_url: String, chain_id: u64) -> Self {
+ let shutdown = Arc::new(AtomicBool::new(false));
+ let child: Arc>> = Arc::new(Mutex::new(None));
+ let bin = resolve_binary();
+ let env = ChildEnv {
+ socket_path: socket_path.clone(),
+ rpc_url,
+ chain_id,
+ };
+
+ let sup = Self {
+ shutdown: Arc::clone(&shutdown),
+ child: Arc::clone(&child),
+ socket_path,
+ };
+
+ std::thread::Builder::new()
+ .name("deckard-signerd-sup".into())
+ .spawn(move || monitor_loop(bin, env, shutdown, child))
+ .ok();
+
+ sup
+ }
+
+ /// The socket path the supervised daemon binds.
+ pub fn socket_path(&self) -> &std::path::Path {
+ &self.socket_path
+ }
+
+ /// A client wired to this daemon's socket.
+ pub fn client(&self) -> crate::SignerClient {
+ crate::SignerClient::new(self.socket_path.clone())
+ }
+}
+
+impl Drop for DaemonSupervisor {
+ fn drop(&mut self) {
+ self.shutdown.store(true, Ordering::SeqCst);
+ if let Ok(mut guard) = self.child.lock() {
+ if let Some(mut child) = guard.take() {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ }
+ }
+}
+
+/// The environment the daemon child needs to agree with the app on socket + chain.
+struct ChildEnv {
+ socket_path: PathBuf,
+ rpc_url: String,
+ chain_id: u64,
+}
+
+/// Spawn → poll-until-exit → backoff → respawn, until shutdown is signalled.
+fn monitor_loop(
+ bin: PathBuf,
+ env: ChildEnv,
+ shutdown: Arc,
+ child_slot: Arc>>,
+) {
+ let mut backoff = Duration::from_millis(200);
+ while !shutdown.load(Ordering::SeqCst) {
+ match Command::new(&bin)
+ .env("DECKARD_SOCKET_PATH", &env.socket_path)
+ .env("DECKARD_RPC_URL", &env.rpc_url)
+ .env("DECKARD_CHAIN_ID", env.chain_id.to_string())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()
+ {
+ Ok(child) => {
+ if let Ok(mut slot) = child_slot.lock() {
+ *slot = Some(child);
+ }
+ // Poll for exit, releasing the lock between polls so Drop can kill the child.
+ loop {
+ if shutdown.load(Ordering::SeqCst) {
+ return;
+ }
+ let exited = match child_slot.lock() {
+ Ok(mut slot) => match slot.as_mut() {
+ Some(c) => match c.try_wait() {
+ Ok(Some(_status)) => {
+ *slot = None;
+ true
+ }
+ Ok(None) => false,
+ Err(_) => {
+ *slot = None;
+ true
+ }
+ },
+ None => true, // Drop took it
+ },
+ Err(_) => true,
+ };
+ if exited {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(200));
+ backoff = Duration::from_millis(200); // healthy run resets the backoff
+ }
+ }
+ Err(e) => {
+ eprintln!("deckard: failed to spawn signerd ({}): {e}", bin.display());
+ }
+ }
+
+ if shutdown.load(Ordering::SeqCst) {
+ return;
+ }
+ std::thread::sleep(backoff);
+ backoff = (backoff * 2).min(Duration::from_secs(5));
+ }
+}
diff --git a/crates/deckard-signerd/tests/anvil_e2e.rs b/crates/deckard-signerd/tests/anvil_e2e.rs
new file mode 100644
index 0000000..3416425
--- /dev/null
+++ b/crates/deckard-signerd/tests/anvil_e2e.rs
@@ -0,0 +1,188 @@
+//! Broadcast tests on a local anvil node (the only assertions that need a chain): a within-cap
+//! send signs + broadcasts with a real receipt (#4), and an over-cap send broadcasts after
+//! approval (#5). Native ETH sends don't need a mainnet fork, so a plain local anvil suffices
+//! and CI needs no RPC secret. Skips gracefully when `anvil` isn't installed.
+
+mod common;
+
+use alloy_primitives::{Address, Bytes, U256};
+use deckard_contract::{
+ ApprovalMode, ApprovalStatus, Decision, ExecuteResult, Intent, IntentKind, Policy,
+ SignerRequest, SignerResponse,
+};
+use deckard_signerd::SignerClient;
+
+use common::*;
+
+const CHAIN: u64 = 31337;
+const PER_TX_CAP: u128 = 50_000_000_000_000_000; // 0.05 ETH
+
+fn send(to: Address, value: u128) -> Intent {
+ Intent {
+ chain_id: CHAIN,
+ to,
+ token: None,
+ value: U256::from(value),
+ calldata: Bytes::new(),
+ kind: IntentKind::Send,
+ }
+}
+
+#[tokio::test]
+async fn within_cap_send_broadcasts_with_receipt() {
+ if !anvil_available() {
+ eprintln!("SKIP within_cap_send_broadcasts_with_receipt: anvil not on PATH");
+ return;
+ }
+ let anvil = start_anvil();
+ wait_anvil_ready(&anvil.url()).await;
+
+ let dir = TempDir::new("anvil-send");
+ let (_wallet, recipient) = seal_account0(dir.path());
+ let d = spawn_daemon(dir.path(), &anvil.url(), CHAIN, &[]);
+ let client = SignerClient::new(d.socket_path.clone());
+ client.unlock(PASS).await.unwrap();
+
+ let value: u128 = 10_000_000_000_000_000; // 0.01 ETH, within cap
+ let intent = send(recipient, value);
+ assert_eq!(client.propose(&intent).await.unwrap(), Decision::Allow);
+ let id = SignerClient::request_id_for_intent(&intent);
+
+ let before = balance(&anvil.url(), recipient).await;
+ let tx_hash = match client.execute(id).await.unwrap() {
+ ExecuteResult::Broadcast { tx_hash } => tx_hash,
+ other => panic!("expected Broadcast, got {other:?}"),
+ };
+
+ let receipt = wait_receipt(&anvil.url(), tx_hash)
+ .await
+ .expect("a real receipt");
+ assert!(receipt.status(), "tx should have succeeded");
+ let after = balance(&anvil.url(), recipient).await;
+ assert_eq!(
+ after - before,
+ U256::from(value),
+ "recipient credited exactly the sent value"
+ );
+
+ // Idempotency: a second execute of the same id is refused.
+ assert_eq!(
+ client.execute(id).await.unwrap(),
+ ExecuteResult::Denied {
+ reason: "already_executed".into()
+ }
+ );
+}
+
+#[tokio::test]
+async fn over_cap_approve_then_execute_broadcasts() {
+ if !anvil_available() {
+ eprintln!("SKIP over_cap_approve_then_execute_broadcasts: anvil not on PATH");
+ return;
+ }
+ let anvil = start_anvil();
+ wait_anvil_ready(&anvil.url()).await;
+
+ let dir = TempDir::new("anvil-approve");
+ let (_wallet, recipient) = seal_account0(dir.path());
+ let d = spawn_daemon(dir.path(), &anvil.url(), CHAIN, &[]);
+ let client = SignerClient::new(d.socket_path.clone());
+ client.unlock(PASS).await.unwrap();
+
+ let value: u128 = PER_TX_CAP + 10_000_000_000_000_000; // 0.06 ETH > cap
+ let intent = send(recipient, value);
+ let id = match client.propose(&intent).await.unwrap() {
+ Decision::NeedsApproval { request_id } => request_id,
+ other => panic!("expected NeedsApproval, got {other:?}"),
+ };
+
+ // Approve (the native card / a test), then execute → broadcast.
+ assert_eq!(
+ client
+ .request(&SignerRequest::Resolve {
+ request_id: id,
+ approved: true
+ })
+ .await
+ .unwrap(),
+ SignerResponse::Ack
+ );
+ assert_eq!(
+ client
+ .request(&SignerRequest::Status { request_id: id })
+ .await
+ .unwrap(),
+ SignerResponse::Status(ApprovalStatus::Allowed)
+ );
+
+ let before = balance(&anvil.url(), recipient).await;
+ let tx_hash = match client.execute(id).await.unwrap() {
+ ExecuteResult::Broadcast { tx_hash } => tx_hash,
+ other => panic!("expected Broadcast, got {other:?}"),
+ };
+ let receipt = wait_receipt(&anvil.url(), tx_hash)
+ .await
+ .expect("a real receipt");
+ assert!(receipt.status());
+ let after = balance(&anvil.url(), recipient).await;
+ assert_eq!(after - before, U256::from(value));
+}
+
+#[tokio::test]
+async fn daily_cap_enforced_at_execute() {
+ // #1 regression: two within-cap proposals both Allow (spent=0 at propose), but once the
+ // first broadcasts, the second can't execute past the daily cap — the auto-allow is
+ // re-checked against the caps at sign time.
+ if !anvil_available() {
+ eprintln!("SKIP daily_cap_enforced_at_execute: anvil not on PATH");
+ return;
+ }
+ let anvil = start_anvil();
+ wait_anvil_ready(&anvil.url()).await;
+
+ let dir = TempDir::new("anvil-dailycap");
+ let (_wallet, recipient) = seal_account0(dir.path());
+ // Tight policy: per-tx 0.05, daily 0.05. 0.04 + 0.039 each pass at propose, but together
+ // exceed the 0.05 daily cap.
+ let policy = Policy {
+ per_tx_cap_wei: U256::from(50_000_000_000_000_000u128),
+ daily_cap_wei: U256::from(50_000_000_000_000_000u128),
+ spent_today_wei: U256::ZERO,
+ allow_to: vec![],
+ auto_shield_min_wei: U256::from(10_000_000_000_000_000u128),
+ require_approval: ApprovalMode::OverCap,
+ revoked: false,
+ };
+ std::fs::write(
+ dir.path().join("policy.json"),
+ serde_json::to_vec(&policy).unwrap(),
+ )
+ .unwrap();
+
+ let d = spawn_daemon(dir.path(), &anvil.url(), CHAIN, &[]);
+ let client = SignerClient::new(d.socket_path.clone());
+ client.unlock(PASS).await.unwrap();
+
+ let first = send(recipient, 40_000_000_000_000_000); // 0.04 ETH
+ let second = send(recipient, 39_000_000_000_000_000); // 0.039 ETH (distinct id)
+ assert_eq!(client.propose(&first).await.unwrap(), Decision::Allow);
+ assert_eq!(client.propose(&second).await.unwrap(), Decision::Allow);
+
+ // First executes (spends 0.04); the second now exceeds the 0.05 daily cap at sign time.
+ assert!(matches!(
+ client
+ .execute(SignerClient::request_id_for_intent(&first))
+ .await
+ .unwrap(),
+ ExecuteResult::Broadcast { .. }
+ ));
+ assert_eq!(
+ client
+ .execute(SignerClient::request_id_for_intent(&second))
+ .await
+ .unwrap(),
+ ExecuteResult::Denied {
+ reason: "cap_exceeded".into()
+ }
+ );
+}
diff --git a/crates/deckard-signerd/tests/common/mod.rs b/crates/deckard-signerd/tests/common/mod.rs
new file mode 100644
index 0000000..5c6eb3e
--- /dev/null
+++ b/crates/deckard-signerd/tests/common/mod.rs
@@ -0,0 +1,258 @@
+//! Shared helpers for the deckard-signerd integration tests: unique temp dirs, vault
+//! sealing, spawning the daemon binary, and (optionally) a local anvil node + chain reads.
+
+#![allow(dead_code)] // each test binary uses a different subset of these helpers
+
+use std::path::{Path, PathBuf};
+use std::process::{Child, Command, Stdio};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{Duration, Instant};
+
+use alloy::providers::{Provider, ProviderBuilder};
+use alloy::rpc::types::TransactionReceipt;
+use alloy_primitives::{Address, B256, U256};
+use deckard_core::{KdfParams, Vault};
+
+/// Anvil's default dev mnemonic — account 0 is prefunded with 10000 ETH at the same BIP-44
+/// path the keystore derives, so a vault sealed from this phrase controls a funded account.
+pub const MNEMONIC: &str = "test test test test test test test test test test test junk";
+/// The keystore passphrase the tests seal with and unlock over the socket.
+pub const PASS: &str = "integration-test-pass";
+
+static COUNTER: AtomicU64 = AtomicU64::new(0);
+
+/// Fast Argon2 params for tests (8 MiB / t=1) — the keystore's production params are too slow
+/// to run on every unlock here. (`KdfParams` fields are public, so we construct directly; this
+/// is the in-bounds minimum `validate()` accepts.)
+pub fn fast_kdf() -> KdfParams {
+ KdfParams {
+ m_kib: 8 * 1024,
+ t: 1,
+ p: 1,
+ }
+}
+
+/// A uniquely-named temp dir, removed on drop.
+pub struct TempDir {
+ dir: PathBuf,
+}
+
+impl TempDir {
+ pub fn new(tag: &str) -> Self {
+ let n = COUNTER.fetch_add(1, Ordering::SeqCst);
+ let dir = std::env::temp_dir().join(format!("deckard-it-{tag}-{}-{n}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).expect("create temp dir");
+ Self { dir }
+ }
+ pub fn path(&self) -> &Path {
+ &self.dir
+ }
+}
+
+impl Drop for TempDir {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.dir);
+ }
+}
+
+/// Seal a vault for anvil's account 0 into `/vault.bin` and return
+/// `(account0_address, account1_address)` (the wallet + a recipient), derived locally without
+/// any key leaving this process.
+pub fn seal_account0(dir: &Path) -> (Address, Address) {
+ let vault = Vault::import_mnemonic(MNEMONIC, PASS, fast_kdf()).expect("import mnemonic");
+ let unlocked = vault.unlock(PASS).expect("unlock");
+ let wallet = unlocked.primary_address().expect("account 0 address");
+ let recipient = unlocked.account_address(1).expect("account 1 address");
+ vault
+ .write_atomic(&dir.join("vault.bin"))
+ .expect("write vault");
+ (wallet, recipient)
+}
+
+/// A spawned daemon process; killed on drop.
+pub struct DaemonProc {
+ child: Child,
+ pub socket_path: PathBuf,
+}
+
+impl Drop for DaemonProc {
+ fn drop(&mut self) {
+ let _ = self.child.kill();
+ let _ = self.child.wait();
+ }
+}
+
+/// Spawn the real `deckard-signerd` binary against `dir` (config + socket) and `rpc_url`,
+/// waiting until it binds the socket.
+pub fn spawn_daemon(
+ dir: &Path,
+ rpc_url: &str,
+ chain_id: u64,
+ extra_env: &[(&str, &str)],
+) -> DaemonProc {
+ let socket = dir.join("signerd.sock");
+ let mut cmd = Command::new(env!("CARGO_BIN_EXE_deckard-signerd"));
+ cmd.env("DECKARD_CONFIG_DIR", dir)
+ .env("DECKARD_SOCKET_PATH", &socket)
+ .env("DECKARD_RPC_URL", rpc_url)
+ .env("DECKARD_CHAIN_ID", chain_id.to_string());
+ for (k, v) in extra_env {
+ cmd.env(k, v);
+ }
+ let child = cmd.spawn().expect("spawn deckard-signerd binary");
+ assert!(
+ wait_for(|| socket.exists(), Duration::from_secs(5)),
+ "daemon never bound its socket"
+ );
+ DaemonProc {
+ child,
+ socket_path: socket,
+ }
+}
+
+/// Poll `cond` until true or `timeout` elapses.
+pub fn wait_for(mut cond: impl FnMut() -> bool, timeout: Duration) -> bool {
+ let deadline = Instant::now() + timeout;
+ loop {
+ if cond() {
+ return true;
+ }
+ if Instant::now() >= deadline {
+ return false;
+ }
+ std::thread::sleep(Duration::from_millis(50));
+ }
+}
+
+// --- anvil lane ----------------------------------------------------------------------------
+
+/// Whether `anvil` is on PATH (tests that broadcast skip gracefully when it isn't).
+pub fn anvil_available() -> bool {
+ Command::new("anvil")
+ .arg("--version")
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+}
+
+fn free_port() -> u16 {
+ std::net::TcpListener::bind("127.0.0.1:0")
+ .unwrap()
+ .local_addr()
+ .unwrap()
+ .port()
+}
+
+/// A spawned anvil node; killed on drop.
+pub struct Anvil {
+ child: Child,
+ port: u16,
+ chain_id: u64,
+}
+
+impl Anvil {
+ pub fn url(&self) -> String {
+ format!("http://127.0.0.1:{}", self.port)
+ }
+}
+
+impl Drop for Anvil {
+ fn drop(&mut self) {
+ let _ = self.child.kill();
+ let _ = self.child.wait();
+ }
+}
+
+/// Start a local anvil (chain 31337, prefunded dev accounts, automine).
+pub fn start_anvil() -> Anvil {
+ let port = free_port();
+ let child = Command::new("anvil")
+ .args([
+ "--mnemonic",
+ MNEMONIC,
+ "--chain-id",
+ "31337",
+ "--accounts",
+ "10",
+ "--balance",
+ "10000",
+ "--port",
+ &port.to_string(),
+ ])
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .expect("spawn anvil");
+ Anvil {
+ child,
+ port,
+ chain_id: 31337,
+ }
+}
+
+impl Anvil {
+ pub fn chain_id(&self) -> u64 {
+ self.chain_id
+ }
+}
+
+/// Start a FRESH anvil forking a chain at a pinned block, prefunding account-0 of [`MNEMONIC`]
+/// (so a vault sealed from that phrase controls a funded EOA). A fresh fork each run is
+/// deterministic — re-using a non-reset fork would accumulate the EOA's balance and drift the
+/// asserts. Killed on drop. The fork preserves the upstream chain id (e.g. Sepolia 11155111).
+pub fn start_anvil_fork(fork_url: &str, fork_block: u64, chain_id: u64) -> Anvil {
+ let port = free_port();
+ let child = Command::new("anvil")
+ .args([
+ "--fork-url",
+ fork_url,
+ "--fork-block-number",
+ &fork_block.to_string(),
+ "--mnemonic",
+ MNEMONIC,
+ "--port",
+ &port.to_string(),
+ ])
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .expect("spawn anvil fork");
+ Anvil {
+ child,
+ port,
+ chain_id,
+ }
+}
+
+/// Wait until anvil answers JSON-RPC.
+pub async fn wait_anvil_ready(url: &str) {
+ let provider = ProviderBuilder::new().connect_http(url.parse().unwrap());
+ let deadline = Instant::now() + Duration::from_secs(15);
+ while Instant::now() < deadline {
+ if provider.get_block_number().await.is_ok() {
+ return;
+ }
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+ panic!("anvil never became ready at {url}");
+}
+
+/// Public balance of `addr` via `url`.
+pub async fn balance(url: &str, addr: Address) -> U256 {
+ let provider = ProviderBuilder::new().connect_http(url.parse().unwrap());
+ provider.get_balance(addr).await.expect("get_balance")
+}
+
+/// Poll for a mined receipt of `hash` via `url`.
+pub async fn wait_receipt(url: &str, hash: B256) -> Option {
+ let provider = ProviderBuilder::new().connect_http(url.parse().unwrap());
+ let deadline = Instant::now() + Duration::from_secs(10);
+ while Instant::now() < deadline {
+ if let Ok(Some(receipt)) = provider.get_transaction_receipt(hash).await {
+ return Some(receipt);
+ }
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+ None
+}
diff --git a/crates/deckard-signerd/tests/daemon_e2e.rs b/crates/deckard-signerd/tests/daemon_e2e.rs
new file mode 100644
index 0000000..a1dfcb4
--- /dev/null
+++ b/crates/deckard-signerd/tests/daemon_e2e.rs
@@ -0,0 +1,405 @@
+//! End-to-end daemon tests that drive the real `deckard-signerd` binary over the socket, but
+//! do NOT need a chain (no transaction is broadcast). Covers acceptance #2 (socket perms),
+//! #3 (unlock outcomes), the propose-decision half of #4 (chain mismatch / unsupported kind /
+//! allowlist / cap classification), #5 (resolve-false + TTL deny execute), #6 (STOP zeroize +
+//! re-arm), and #7 (TOCTOU). The successful broadcasts live in `anvil_e2e.rs`.
+
+mod common;
+
+use std::os::unix::fs::PermissionsExt;
+
+use alloy_primitives::{Address, Bytes, U256};
+use deckard_contract::{
+ ApprovalMode, ApprovalStatus, Decision, ExecuteResult, Intent, IntentKind, Policy,
+ SignerRequest, SignerResponse, UnlockOutcome,
+};
+use deckard_signerd::SignerClient;
+
+use common::*;
+
+const CHAIN: u64 = 31337;
+/// We never broadcast in this file, so the RPC is never contacted — a dead address is fine.
+const DUMMY_RPC: &str = "http://127.0.0.1:1";
+const PER_TX_CAP: u64 = 50_000_000_000_000_000; // 0.05 ETH (the default policy cap)
+
+fn send(to: Address, value: u64) -> Intent {
+ Intent {
+ chain_id: CHAIN,
+ to,
+ token: None,
+ value: U256::from(value),
+ calldata: Bytes::new(),
+ kind: IntentKind::Send,
+ }
+}
+
+#[tokio::test]
+async fn unlock_outcomes() {
+ // NoVault: empty config dir.
+ let dir = TempDir::new("novault");
+ let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]);
+ let client = SignerClient::new(d.socket_path.clone());
+ assert_eq!(
+ client.unlock("anything").await.unwrap(),
+ UnlockOutcome::NoVault
+ );
+ drop(d);
+
+ // Sealed vault: wrong → BadPassphrase; correct → Unlocked{account0}.
+ let dir = TempDir::new("unlock");
+ let (wallet, _recipient) = seal_account0(dir.path());
+ let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]);
+ let client = SignerClient::new(d.socket_path.clone());
+ assert_eq!(
+ client.unlock("wrong-pass").await.unwrap(),
+ UnlockOutcome::BadPassphrase
+ );
+ assert_eq!(
+ client.unlock(PASS).await.unwrap(),
+ UnlockOutcome::Unlocked { address: wallet }
+ );
+}
+
+#[tokio::test]
+async fn propose_decision_matrix() {
+ let dir = TempDir::new("propose");
+ let (_wallet, to) = seal_account0(dir.path());
+ let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]);
+ let client = SignerClient::new(d.socket_path.clone());
+
+ // Locked → Deny{locked}.
+ assert_eq!(
+ client.propose(&send(to, 1_000)).await.unwrap(),
+ Decision::Deny {
+ reason: "locked".into()
+ }
+ );
+ client.unlock(PASS).await.unwrap();
+
+ // Within cap → Allow.
+ assert_eq!(
+ client.propose(&send(to, 1_000)).await.unwrap(),
+ Decision::Allow
+ );
+
+ // Over per-tx cap → NeedsApproval.
+ assert!(matches!(
+ client.propose(&send(to, PER_TX_CAP + 1)).await.unwrap(),
+ Decision::NeedsApproval { .. }
+ ));
+
+ // chain mismatch.
+ let mut wrong_chain = send(to, 1_000);
+ wrong_chain.chain_id = 1;
+ assert_eq!(
+ client.propose(&wrong_chain).await.unwrap(),
+ Decision::Deny {
+ reason: "chain_mismatch".into()
+ }
+ );
+
+ // Shield (the privacy hero) is now admitted: a within-cap, on-allowlist native shield
+ // classifies like a send (its RelayAdapt calldata rides in `intent.calldata`). It is
+ // `token: None`, so it passes the ERC-20 guard; within cap → Allow.
+ let mut shield = send(to, 1_000);
+ shield.kind = IntentKind::Shield;
+ shield.calldata = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]); // stand-in RelayAdapt call
+ assert_eq!(client.propose(&shield).await.unwrap(), Decision::Allow);
+
+ // A Shield with EMPTY calldata is rejected (would otherwise broadcast as a bare native
+ // send to `to` — moving ETH to an arbitrary address under the "Shield" label, no note).
+ let mut empty_shield = send(to, 1_000);
+ empty_shield.kind = IntentKind::Shield; // calldata stays empty (from send())
+ assert_eq!(
+ client.propose(&empty_shield).await.unwrap(),
+ Decision::Deny {
+ reason: "undecodable".into()
+ }
+ );
+
+ // Unshield stays a fast-follow → Deny{unsupported_v1}.
+ let mut unshield = send(to, 1_000);
+ unshield.kind = IntentKind::Unshield;
+ assert_eq!(
+ client.propose(&unshield).await.unwrap(),
+ Decision::Deny {
+ reason: "unsupported_v1".into()
+ }
+ );
+
+ // ERC-20 send (token = Some) is a fast-follow.
+ let mut erc20 = send(to, 1_000);
+ erc20.token = Some(Address::repeat_byte(0xEE));
+ assert_eq!(
+ client.propose(&erc20).await.unwrap(),
+ Decision::Deny {
+ reason: "erc20_unsupported_v1".into()
+ }
+ );
+}
+
+#[tokio::test]
+async fn off_allowlist_denies() {
+ let dir = TempDir::new("allowlist");
+ let (_wallet, to) = seal_account0(dir.path());
+ // A policy whose allowlist excludes `to`.
+ let policy = Policy {
+ per_tx_cap_wei: U256::from(PER_TX_CAP),
+ daily_cap_wei: U256::from(200_000_000_000_000_000u64),
+ spent_today_wei: U256::ZERO,
+ allow_to: vec![Address::repeat_byte(0x99)],
+ auto_shield_min_wei: U256::from(10_000_000_000_000_000u64),
+ require_approval: ApprovalMode::OverCap,
+ revoked: false,
+ };
+ std::fs::write(
+ dir.path().join("policy.json"),
+ serde_json::to_vec(&policy).unwrap(),
+ )
+ .unwrap();
+
+ let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]);
+ let client = SignerClient::new(d.socket_path.clone());
+ client.unlock(PASS).await.unwrap();
+ assert_eq!(
+ client.propose(&send(to, 1_000)).await.unwrap(),
+ Decision::Deny {
+ reason: "off_allowlist".into()
+ }
+ );
+}
+
+#[tokio::test]
+async fn resolve_false_and_ttl_deny_execute() {
+ let dir = TempDir::new("ttl");
+ let (_wallet, to) = seal_account0(dir.path());
+ let d = spawn_daemon(
+ dir.path(),
+ DUMMY_RPC,
+ CHAIN,
+ &[("DECKARD_APPROVAL_TTL_SECS", "1")],
+ );
+ let client = SignerClient::new(d.socket_path.clone());
+ client.unlock(PASS).await.unwrap();
+
+ // resolve(false) → execute Denied{user_denied}.
+ let over = send(to, PER_TX_CAP + 1);
+ let id = needs_approval_id(&client, &over).await;
+ ack(
+ &client,
+ SignerRequest::Resolve {
+ request_id: id,
+ approved: false,
+ },
+ )
+ .await;
+ assert_eq!(
+ client.execute(id).await.unwrap(),
+ ExecuteResult::Denied {
+ reason: "user_denied".into()
+ }
+ );
+
+ // TTL expiry → status Expired, execute Denied{expired}. (Different value → different id.)
+ let over2 = send(to, PER_TX_CAP + 2);
+ let id2 = needs_approval_id(&client, &over2).await;
+ tokio::time::sleep(std::time::Duration::from_millis(1_500)).await;
+ assert_eq!(status(&client, id2).await, ApprovalStatus::Expired);
+ assert_eq!(
+ client.execute(id2).await.unwrap(),
+ ExecuteResult::Denied {
+ reason: "expired".into()
+ }
+ );
+}
+
+#[tokio::test]
+async fn stop_zeroizes_and_re_arms() {
+ let dir = TempDir::new("stop");
+ let (wallet, to) = seal_account0(dir.path());
+ let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]);
+ let client = SignerClient::new(d.socket_path.clone());
+ client.unlock(PASS).await.unwrap();
+
+ // An Allowed within-cap request, then STOP.
+ let intent = send(to, 1_000);
+ assert_eq!(client.propose(&intent).await.unwrap(), Decision::Allow);
+ let id = SignerClient::request_id_for_intent(&intent);
+ ack(&client, SignerRequest::RevokeAll).await;
+
+ // Address now reports locked (Deny-style, per the schema).
+ match client.request(&SignerRequest::Address).await.unwrap() {
+ SignerResponse::Decision(Decision::Deny { reason }) => assert_eq!(reason, "locked"),
+ other => panic!("expected locked Deny for Address, got {other:?}"),
+ }
+ // execute on the pre-STOP Allow → Denied{revoked}.
+ assert_eq!(
+ client.execute(id).await.unwrap(),
+ ExecuteResult::Denied {
+ reason: "revoked".into()
+ }
+ );
+
+ // A fresh unlock re-arms (and starts a clean session).
+ assert_eq!(
+ client.unlock(PASS).await.unwrap(),
+ UnlockOutcome::Unlocked { address: wallet }
+ );
+ assert_eq!(
+ client.propose(&send(to, 1_000)).await.unwrap(),
+ Decision::Allow
+ );
+}
+
+#[tokio::test]
+async fn toctou_resolve_then_revoke_then_execute_denied() {
+ let dir = TempDir::new("toctou");
+ let (_wallet, to) = seal_account0(dir.path());
+ let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]);
+ let client = SignerClient::new(d.socket_path.clone());
+ client.unlock(PASS).await.unwrap();
+
+ let over = send(to, PER_TX_CAP + 1);
+ let id = needs_approval_id(&client, &over).await;
+ ack(
+ &client,
+ SignerRequest::Resolve {
+ request_id: id,
+ approved: true,
+ },
+ )
+ .await;
+ assert_eq!(status(&client, id).await, ApprovalStatus::Allowed);
+ // STOP after approval but before execute.
+ ack(&client, SignerRequest::RevokeAll).await;
+ assert_eq!(
+ client.execute(id).await.unwrap(),
+ ExecuteResult::Denied {
+ reason: "revoked".into()
+ }
+ );
+}
+
+#[tokio::test]
+async fn socket_is_0600_in_0700_dir() {
+ let dir = TempDir::new("perms");
+ let _ = seal_account0(dir.path());
+ let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]);
+
+ let sock_mode = std::fs::metadata(&d.socket_path)
+ .unwrap()
+ .permissions()
+ .mode()
+ & 0o777;
+ assert_eq!(sock_mode, 0o600, "socket must be 0600");
+ let parent = d.socket_path.parent().unwrap();
+ let dir_mode = std::fs::metadata(parent).unwrap().permissions().mode() & 0o777;
+ assert_eq!(dir_mode, 0o700, "socket dir must be 0700");
+}
+
+#[tokio::test]
+async fn allowed_request_expires() {
+ // #2 regression: an APPROVED (Allowed) request goes stale after the TTL and can't execute.
+ let dir = TempDir::new("allowed-ttl");
+ let (_wallet, to) = seal_account0(dir.path());
+ let d = spawn_daemon(
+ dir.path(),
+ DUMMY_RPC,
+ CHAIN,
+ &[("DECKARD_APPROVAL_TTL_SECS", "1")],
+ );
+ let client = SignerClient::new(d.socket_path.clone());
+ client.unlock(PASS).await.unwrap();
+
+ let over = send(to, PER_TX_CAP + 1);
+ let id = needs_approval_id(&client, &over).await;
+ ack(
+ &client,
+ SignerRequest::Resolve {
+ request_id: id,
+ approved: true,
+ },
+ )
+ .await;
+ assert_eq!(status(&client, id).await, ApprovalStatus::Allowed);
+
+ tokio::time::sleep(std::time::Duration::from_millis(1_500)).await;
+ assert_eq!(status(&client, id).await, ApprovalStatus::Expired);
+ assert_eq!(
+ client.execute(id).await.unwrap(),
+ ExecuteResult::Denied {
+ reason: "expired".into()
+ }
+ );
+}
+
+#[tokio::test]
+async fn re_propose_is_idempotent() {
+ // #3 regression: a re-propose never resets a live card, re-raises a Deny, or downgrades
+ // an approval.
+ let dir = TempDir::new("idempotent");
+ let (_wallet, to) = seal_account0(dir.path());
+ let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]);
+ let client = SignerClient::new(d.socket_path.clone());
+ client.unlock(PASS).await.unwrap();
+
+ // (a) within-cap: re-propose returns the same Allow.
+ let within = send(to, 1_000);
+ assert_eq!(client.propose(&within).await.unwrap(), Decision::Allow);
+ assert_eq!(client.propose(&within).await.unwrap(), Decision::Allow);
+
+ // (b) a user Deny is sticky — re-propose does NOT re-raise the card.
+ let denied = send(to, PER_TX_CAP + 1);
+ let id = needs_approval_id(&client, &denied).await;
+ ack(
+ &client,
+ SignerRequest::Resolve {
+ request_id: id,
+ approved: false,
+ },
+ )
+ .await;
+ assert_eq!(
+ client.propose(&denied).await.unwrap(),
+ Decision::Deny {
+ reason: "user_denied".into()
+ }
+ );
+
+ // (c) an approval is not downgraded back to Pending by a re-propose.
+ let approved = send(to, PER_TX_CAP + 2);
+ let id2 = needs_approval_id(&client, &approved).await;
+ ack(
+ &client,
+ SignerRequest::Resolve {
+ request_id: id2,
+ approved: true,
+ },
+ )
+ .await;
+ assert_eq!(client.propose(&approved).await.unwrap(), Decision::Allow);
+}
+
+// --- small request helpers -----------------------------------------------------------------
+
+async fn needs_approval_id(client: &SignerClient, intent: &Intent) -> deckard_contract::RequestId {
+ match client.propose(intent).await.unwrap() {
+ Decision::NeedsApproval { request_id } => request_id,
+ other => panic!("expected NeedsApproval, got {other:?}"),
+ }
+}
+
+async fn ack(client: &SignerClient, req: SignerRequest) {
+ assert_eq!(client.request(&req).await.unwrap(), SignerResponse::Ack);
+}
+
+async fn status(client: &SignerClient, id: deckard_contract::RequestId) -> ApprovalStatus {
+ match client
+ .request(&SignerRequest::Status { request_id: id })
+ .await
+ .unwrap()
+ {
+ SignerResponse::Status(s) => s,
+ other => panic!("expected Status, got {other:?}"),
+ }
+}
diff --git a/crates/deckard-signerd/tests/parity.rs b/crates/deckard-signerd/tests/parity.rs
new file mode 100644
index 0000000..f6bc875
--- /dev/null
+++ b/crates/deckard-signerd/tests/parity.rs
@@ -0,0 +1,132 @@
+//! #8 — the ONE decision function. Identical `(Intent, Policy)` vectors fed to `MockSigner`
+//! and to the daemon's decision path must yield identical `Decision`s. Both route through
+//! `deckard_contract::evaluate` (the daemon calls it directly after its process-level
+//! pre-checks; the mock calls it in `propose`), so this pins that they never drift.
+//!
+//! The vectors all use an unlocked daemon, a matching chain id, and `kind = Send`, so the
+//! daemon's pre-checks (`locked`/`chain_mismatch`/`unsupported_v1`) don't fire and both sides
+//! reduce to `evaluate` — exactly the apples-to-apples parity contract.
+
+use alloy_primitives::{Address, Bytes, B256, U256};
+use deckard_contract::{
+ evaluate, ApprovalMode, Decision, Intent, IntentKind, MockSigner, Policy, Signer,
+};
+
+/// Normalize away the (stateful, impl-specific) `NeedsApproval` request id so the comparison
+/// is on the CLASSIFICATION, which is the parity contract.
+fn norm(d: Decision) -> Decision {
+ match d {
+ Decision::NeedsApproval { .. } => Decision::NeedsApproval {
+ request_id: B256::ZERO,
+ },
+ other => other,
+ }
+}
+
+fn intent(kind: IntentKind, to: Address, value: u64, calldata: Bytes) -> Intent {
+ Intent {
+ chain_id: 31337,
+ to,
+ token: None,
+ value: U256::from(value),
+ calldata,
+ kind,
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn policy(
+ per_tx: u64,
+ daily: u64,
+ spent: u64,
+ mode: ApprovalMode,
+ allow: Vec,
+ revoked: bool,
+) -> Policy {
+ Policy {
+ per_tx_cap_wei: U256::from(per_tx),
+ daily_cap_wei: U256::from(daily),
+ spent_today_wei: U256::from(spent),
+ allow_to: allow,
+ auto_shield_min_wei: U256::from(10u64),
+ require_approval: mode,
+ revoked,
+ }
+}
+
+#[test]
+fn mock_and_daemon_decision_logic_agree() {
+ let a = Address::repeat_byte(0x22);
+ let b = Address::repeat_byte(0x33);
+ let send = |v| intent(IntentKind::Send, a, v, Bytes::new());
+
+ let vectors: Vec<(&str, Intent, Policy)> = vec![
+ (
+ "within per-tx cap → Allow",
+ send(20),
+ policy(50, 1000, 0, ApprovalMode::OverCap, vec![], false),
+ ),
+ (
+ "over per-tx cap → NeedsApproval",
+ send(60),
+ policy(50, 1000, 0, ApprovalMode::OverCap, vec![], false),
+ ),
+ (
+ "over daily cap → NeedsApproval",
+ send(20),
+ policy(u64::MAX, 100, 90, ApprovalMode::OverCap, vec![], false),
+ ),
+ (
+ "exact per-tx cap boundary → Allow",
+ send(50),
+ policy(50, 1000, 0, ApprovalMode::OverCap, vec![], false),
+ ),
+ (
+ "Never + over cap → Deny over_cap",
+ send(60),
+ policy(50, 1000, 0, ApprovalMode::Never, vec![], false),
+ ),
+ (
+ "Never + within cap → Allow",
+ send(20),
+ policy(50, 1000, 0, ApprovalMode::Never, vec![], false),
+ ),
+ (
+ "Always + within cap → NeedsApproval",
+ send(20),
+ policy(50, 1000, 0, ApprovalMode::Always, vec![], false),
+ ),
+ (
+ "off allowlist → Deny",
+ send(20),
+ policy(50, 1000, 0, ApprovalMode::OverCap, vec![b], false),
+ ),
+ (
+ "on allowlist → Allow",
+ send(20),
+ policy(50, 1000, 0, ApprovalMode::OverCap, vec![a], false),
+ ),
+ (
+ "revoked → Deny revoked",
+ send(20),
+ policy(50, 1000, 0, ApprovalMode::OverCap, vec![], true),
+ ),
+ (
+ "undecodable (Send w/ calldata) → Deny",
+ intent(IntentKind::Send, a, 20, Bytes::from_static(&[1, 2, 3])),
+ policy(50, 1000, 0, ApprovalMode::OverCap, vec![], false),
+ ),
+ ];
+
+ for (label, it, pol) in vectors {
+ // The daemon's decision path IS `evaluate` (after pre-checks that don't apply here).
+ let daemon_decision = norm(evaluate(&it, &pol));
+ // MockSigner.propose routes through the same `evaluate` and mints a real id.
+ let mock = MockSigner::new(pol.clone());
+ let mock_decision = norm(mock.propose(&it));
+ assert_eq!(
+ daemon_decision, mock_decision,
+ "decision diverged for: {label}"
+ );
+ }
+}
diff --git a/crates/deckard-signerd/tests/shield_e2e.rs b/crates/deckard-signerd/tests/shield_e2e.rs
new file mode 100644
index 0000000..98917ba
--- /dev/null
+++ b/crates/deckard-signerd/tests/shield_e2e.rs
@@ -0,0 +1,234 @@
+//! Repeatable, black-box SHIELD integration test — the privacy hero, driven end-to-end
+//! through DECKARD'S OWN path against a fresh anvil fork of Sepolia.
+//!
+//! What it proves (the privacy property, shield-only — fast, NO ZK proving / artifacts):
+//! 1. deckard-core's KEY-LESS builder turns `(chain, 0zk recipient, value)` into an
+//! `Intent{kind:Shield, to=RelayAdapt, value, calldata}` — no spending key, no sync.
+//! 2. The daemon admits + signs + broadcasts that Intent (generalized broadcast carries the
+//! calldata; the daemon never touches the ZK crate — it just signs the handed bytes).
+//! 3. After `railgun.sync()`, the recipient's PRIVATE 0zk balance is up by exactly
+//! `value - value*25/10000` (the on-chain 25-bps shield fee; the calldata carried the
+//! gross value), and the EOA's PUBLIC balance is down by ~value + gas.
+//!
+//! Shield does NO client ZK proof (the de-risked finding): `ShieldBuilder::build` only
+//! encrypts the note + ABI-encodes — the contract verifies the commitment. So this is fast.
+//! Transfer/unshield (slow; download ZK artifacts) stay in `spikes/shield-railgun`, NOT here.
+//!
+//! `#[ignore]` (needs network + a fresh anvil). Run:
+//! RUSTUP_TOOLCHAIN=1.95.0-aarch64-apple-darwin \
+//! RPC_URL_SEPOLIA= \
+//! cargo test -p deckard-signerd --test shield_e2e -- --ignored --nocapture
+//!
+//! It spawns its OWN fresh anvil fork each run (deterministic — a re-used non-reset fork
+//! accumulates the EOA balance and drifts the asserts) and kills it on drop.
+
+#![cfg(feature = "shield")]
+
+mod common;
+
+use std::sync::Arc;
+
+use alloy::{
+ network::Ethereum,
+ providers::{Provider, ProviderBuilder},
+ signers::local::PrivateKeySigner,
+};
+use alloy_primitives::U256;
+
+use deckard_contract::{Decision, ExecuteResult};
+use deckard_core::build_shield_native_intent;
+use deckard_signerd::SignerClient;
+
+use railgun::{
+ account::signer::{PrivateKeySigner as RailgunKeySigner, RailgunSigner},
+ builder::RailgunBuilder,
+ caip::AssetId,
+ chain_config::ChainConfig,
+ indexer::syncer::{ChainedSyncer, RpcSyncer, SubsquidSyncer},
+};
+use rand_09::random;
+
+use common::*;
+
+/// Sepolia archive RPC the spec pre-verified to serve the pinned fork block. Overridable via
+/// `RPC_URL_SEPOLIA` (CI secret) so the literal isn't the only path.
+const DEFAULT_SEPOLIA_RPC: &str =
+ "https://eth-sepolia.g.alchemy.com/v2/xqR9JXkWao0ETLYaaZt9fye8yeE4Cxyd";
+/// Pinned fork block (pre-verified). A fixed block keeps Subsquid + the asserts deterministic.
+const FORK_BLOCK: u64 = 10_822_990;
+/// Sepolia chain id — the fork preserves it; the daemon's chain_id + the Intent must match it.
+const SEPOLIA_CHAIN_ID: u64 = 11_155_111;
+/// anvil dev key #0 — the EOA the daemon's account-0 maps to (sealed from [`MNEMONIC`]); the
+/// fork prefunds it with ETH. Used here only to read its public balance for the down-assert.
+const EOA_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
+
+/// On-chain shield fee in basis points (== `ChainConfig::sepolia().unshield_fee_bps`); the
+/// contract deducts it, so the synced private note reads `value - value*25/10000`.
+const SHIELD_FEE_BPS: u128 = 25;
+
+fn sepolia_rpc() -> String {
+ std::env::var("RPC_URL_SEPOLIA").unwrap_or_else(|_| DEFAULT_SEPOLIA_RPC.to_string())
+}
+
+#[tokio::test]
+#[ignore = "network: spawns a fresh anvil Sepolia fork + drives the full shield path"]
+async fn shield_e2e_privacy_property() {
+ if !anvil_available() {
+ eprintln!("SKIP shield_e2e_privacy_property: anvil not on PATH");
+ return;
+ }
+
+ // --- fresh anvil fork of Sepolia @ the pinned block (chain id preserved) ---
+ let anvil = start_anvil_fork(&sepolia_rpc(), FORK_BLOCK, SEPOLIA_CHAIN_ID);
+ wait_anvil_ready(&anvil.url()).await;
+ assert_eq!(anvil.chain_id(), SEPOLIA_CHAIN_ID);
+
+ // --- daemon, sealed for anvil account-0 (the funded EOA), pointed at the fork ---
+ let dir = TempDir::new("shield-e2e");
+ let (wallet, _recipient_eoa) = seal_account0(dir.path());
+ // verified-reads is irrelevant here (we never call Balance on the daemon), but the daemon
+ // would try to bootstrap Helios against the fork URL on a Balance request; we avoid that
+ // path entirely by reading public balance directly from the fork below.
+ let d = spawn_daemon(dir.path(), &anvil.url(), SEPOLIA_CHAIN_ID, &[]);
+ let client = SignerClient::new(d.socket_path.clone());
+ let unlocked = client.unlock(PASS).await.unwrap();
+ assert_eq!(
+ unlocked,
+ deckard_contract::UnlockOutcome::Unlocked { address: wallet },
+ "daemon's account-0 must be the funded EOA"
+ );
+
+ // --- test-side railgun provider: register an EPHEMERAL 0zk recipient, then sync+assert ---
+ let chain = ChainConfig::sepolia();
+ let weth = AssetId::Erc20(chain.wrapped_base_token);
+
+ // A plain alloy erased provider over the fork (for railgun's RPC syncer). The EOA wallet
+ // here is only used by the syncer's read path; the daemon owns the BROADCAST wallet.
+ let read_signer = PrivateKeySigner::from_str_eoa(EOA_KEY);
+ let provider = ProviderBuilder::new()
+ .network::()
+ .wallet(read_signer)
+ .connect(&anvil.url())
+ .await
+ .expect("connect provider")
+ .erased();
+
+ let syncer = Arc::new(
+ ChainedSyncer::new()
+ .then(SubsquidSyncer::new(&chain.subsquid_endpoint).with_latest_block(FORK_BLOCK))
+ .then(RpcSyncer::new(chain.clone(), provider.clone()).with_batch_size(1000)),
+ );
+ let mut railgun = RailgunBuilder::new(chain.clone(), provider.clone())
+ .with_utxo_syncer(syncer)
+ .build()
+ .await
+ .expect("build railgun");
+
+ // Ephemeral 0zk recipient (random spending/viewing keys). KEY-LESS shield: the builder
+ // takes only this account's RailgunAddress — never its keys.
+ let recipient_acct = RailgunKeySigner::new_evm(random(), random(), chain.id);
+ railgun
+ .register(recipient_acct.clone())
+ .await
+ .expect("register recipient");
+ let recipient_0zk = recipient_acct.address();
+
+ // Sanity: recipient has no private balance before the shield.
+ railgun.sync().await.expect("pre-sync");
+ let before_private = railgun.balance(recipient_0zk).await;
+ assert_eq!(
+ before_private.get(&weth),
+ None,
+ "recipient must have NO 0zk note before the shield"
+ );
+
+ // ============================ DECKARD'S OWN PATH ============================
+ // 1. deckard-core KEY-LESS builder → Intent{kind:Shield, ...}. The shield value (raw wei)
+ // is well within the default 0.05 ETH per-tx cap, so propose → Allow directly.
+ let shield_value: u128 = 1_000_000;
+ let intent = build_shield_native_intent(
+ SEPOLIA_CHAIN_ID,
+ recipient_0zk,
+ U256::from(shield_value),
+ )
+ .expect("build shield intent");
+ assert_eq!(
+ intent.kind,
+ deckard_contract::IntentKind::Shield,
+ "builder must produce a Shield intent"
+ );
+ assert_eq!(intent.to, chain.relay_adapt_contract);
+ assert!(!intent.calldata.is_empty());
+
+ // 2. propose → the daemon admits the Shield (within cap → Allow).
+ let decision = client.propose(&intent).await.expect("propose");
+ assert_eq!(
+ decision,
+ Decision::Allow,
+ "within-cap native shield must be admitted by the daemon"
+ );
+ let id = SignerClient::request_id_for_intent(&intent);
+
+ // Public balance of the EOA before broadcast (for the down-assert).
+ let eoa = wallet;
+ let public_before = balance(&anvil.url(), eoa).await;
+
+ // 3. execute → the daemon signs + broadcasts the Intent's calldata + value.
+ let tx_hash = match client.execute(id).await.expect("execute") {
+ ExecuteResult::Broadcast { tx_hash } => tx_hash,
+ other => panic!("expected Broadcast, got {other:?}"),
+ };
+ let receipt = wait_receipt(&anvil.url(), tx_hash)
+ .await
+ .expect("a mined shield receipt");
+ assert!(receipt.status(), "the shield tx must succeed on-chain");
+ // ============================================================================
+
+ // --- ASSERT THE PRIVACY PROPERTY ---
+ railgun.sync().await.expect("post-shield sync");
+
+ // Private balance UP by exactly value - 25bps fee (the recipient 0zk note now exists).
+ let expected_net = shield_value - shield_value * SHIELD_FEE_BPS / 10_000;
+ let after_private = railgun.balance(recipient_0zk).await;
+ println!(
+ "shield_e2e: recipient 0zk[weth] = {:?} (expect Some({expected_net}))",
+ after_private.get(&weth)
+ );
+ assert_eq!(
+ after_private.get(&weth),
+ Some(&expected_net),
+ "recipient private balance must be value minus the 25-bps on-chain shield fee \
+ ({shield_value} -> {expected_net})"
+ );
+
+ // Public balance DOWN by ~value + gas (strictly more than the gross value).
+ let public_after = balance(&anvil.url(), eoa).await;
+ let public_spent = public_before - public_after;
+ println!(
+ "shield_e2e: EOA public spent = {public_spent} wei (>= gross value {shield_value} + gas)"
+ );
+ assert!(
+ public_spent >= U256::from(shield_value),
+ "EOA public balance must drop by at least the gross shield value (+ gas)"
+ );
+
+ // Sanity that the drop is value + gas (not wildly more): bound it loosely (< value + 0.01 ETH gas).
+ assert!(
+ public_spent < U256::from(shield_value) + U256::from(10_000_000_000_000_000u128),
+ "EOA spend should be value + reasonable gas, got {public_spent}"
+ );
+
+ println!("=== shield_e2e PASSED: private +{expected_net}, public -{public_spent} ===");
+}
+
+/// Small helper so the alloy `PrivateKeySigner::from_str` import doesn't clash names with
+/// railgun's signer in scope.
+trait FromStrEoa {
+ fn from_str_eoa(s: &str) -> Self;
+}
+impl FromStrEoa for PrivateKeySigner {
+ fn from_str_eoa(s: &str) -> Self {
+ use std::str::FromStr;
+ PrivateKeySigner::from_str(s).expect("parse EOA key")
+ }
+}
diff --git a/docs/build/00-test-harness.md b/docs/build/00-test-harness.md
new file mode 100644
index 0000000..d0b0051
--- /dev/null
+++ b/docs/build/00-test-harness.md
@@ -0,0 +1,255 @@
+# 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
+```
+> **Update (cross-doc, from `20-helios-sidecar.md`):** two things below are now stale. (1) **LC support is resolved** — Lighthouse/Nimbus/Lodestar serve the light-client API **on by default** and ethereum-package runs all forks from genesis (no `--light-client-server` needed on current Lighthouse; it's disable-only now). (2) **Kurtosis is DEFERRED off the v1 critical path** — the mainnet spike proved the walkaway without it, so Lane B is a post-demo hermetic-CI nice-to-have, not a gate; v1 runs on mainnet + Sepolia (Lane C). The original text below is kept for when the Kurtosis lane is picked up.
+
+⚠ ~~**unverified:** that the chosen Kurtosis CL client serves the **light-client beaconchain API** out of the box~~ (resolved — see note above) — Lighthouse gates this behind `--light-client-server` (and the EL needs the light-client execution API). The harness's `kurtosis.rs` must set the CL/EL flags to enable both, and assert Helios reaches `synced` before proceeding. Spike this on day one of the Helios lane; if a client won't serve it, fall back to Lane C (Sepolia) for the walkaway integration.
+
+### Lane C — Sepolia
+
+`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..3e48bc9
--- /dev/null
+++ b/docs/build/10-kohaku-shield.md
@@ -0,0 +1,189 @@
+# 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: **R1 spike GREEN** — full shield→sync→balance→transfer→unshield runs from our own dep edge with the exact upstream asserts (`spikes/shield-railgun/`); **shield is instant (no client proof); ZK proving is on the *spend*, not the shield**. Part of the Deckard build docs.
+
+## Why this exists (2-4 sentences, concrete)
+
+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.**~~ → **RESOLVED (`spikes/shield-railgun/`, 2026-06-06):** the shield is **instant by design** — `ShieldBuilder::build` does **no** client ZK proof, only note-encrypt + ABI-encode (~2–13 ms); the contract verifies the commitment on-chain. The Groth16 proving cost lives entirely on the **spend** (transfer/unshield): ~9.6–11.7 s cold (debug), roughly **halved** by the `parallel` feature (~4.3–5.4 s). So "instant auto-shield, prove-on-spend" is honest; spend (unshield) is off-camera fast-follow. (Cold numbers include first-use artifact download; warm/release will be faster.) Still apply: `parallel` + pre-warm + a "spending…" state for unshield.
+- **R1e — licensing.** Crate `Cargo.toml` has **no `license` field**; root `package.json` and npm say **MIT**. Deckard is 0BSD. MIT is compatible to vendor/depend on, but **confirm the Rust crate inherits MIT** (open a clarifying issue / check the eventual crate publish) before shipping. ⚠ partial: per-crate license not explicitly declared in-tree.
+- **R1f — Subsquid/broadcaster centralization.** UTXO sync leans on a Subsquid endpoint and broadcast leans on a 4337 bundler — both are network deps that aren't Helios. For the demo, sync is a read of public events (acceptable); shield (the hero) needs no broadcaster. Flag for the "walkaway" narrative: shield-on-receive itself only needs the EOA + the pool contract.
+- **Alternate shielded path (c):** **Privacy Pools** (`@kohaku-eth/privacy-pools`, live on mainnet since Mar 2025) if Railgun is unworkable — but it's marked WIP in the SDK and uses the opposite (allowlist-inclusion) compliance model, so treat as a true last resort, not a drop-in.
+
+## Open questions
+
+- ~~**Proving wall-clock on a desktop:**~~ → **measured (`spikes/shield-railgun/`):** shield `ShieldBuilder::build` does no proof (~2–13 ms, encrypt+ABI only); spend `RailgunProvider::build` proving = transfer ~11.7 s / unshield ~9.6 s cold-debug, ~halved with `parallel` (~5.4 / ~4.3 s). Re-measure on the demo machine in `--release` (warm artifacts) for the final UX number.
+- **Spike gotchas (for the integration):** the `railgun` crate's `testing` feature is **required** by external consumers to reach `SubsquidSyncer::with_latest_block` (capping Subsquid at the fork block) — it's `#[cfg(any(test, feature="testing"))]` at rev 618c53f. And `.with_poi()` *constructs* fine but credits `None` balance on a local fork (the live POI service never sees fork commitments), so the spike defaults to no-POI for honest asserts with `WITH_POI=1` to exercise the path.
+- **Does the crate's EIP-1193 provider accept Helios cleanly,** or does it need methods Helios doesn't serve (e.g. heavy log ranges for UTXO sync that Helios proxies but Subsquid actually answers)? Verify in the `20-helios-sidecar.md` integration. ⚠ unverified.
+- **Per-crate license:** does `railgun` (no `license` field) inherit the monorepo MIT for a downstream Rust dependency? ⚠ partial.
+- **Mainnet broadcaster availability:** is there a public Railgun 4337 bundler/broadcaster Deckard can use for unshield, or must we run one? (Not needed for v1 shield-on-receive; needed for the unshield fast-follow.) ⚠ unverified.
+- **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..9965e70
--- /dev/null
+++ b/docs/build/20-helios-sidecar.md
@@ -0,0 +1,326 @@
+# 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: **core mechanism proven on mainnet** — the spike shows verified reads survive a cut EL (failover) and a lying RPC is rejected (cold ≈11s, warm ≈2s, cut→failover ≤1 block; `spikes/helios-walkaway/`). **App integration is still unbuilt** (EIP-1193 provider for Railgun, ReadStatus on the wire, CL-rebuild, receive-watcher, `simulate`) — see "Integration into the app." Part of the Deckard build docs.
+>
+> **Verification note (2026-06-05):** every API/architecture claim below was re-derived from the actual a16z/helios source at tag `0.11.1` (ref `204c998a`) and adversarially re-checked by a second pass — *not* from memory. The numbers come from a runnable spike that actually syncs mainnet and survives a cut EL on this desktop. Anything still unverifiable is flagged ⚠.
+
+## Why this exists (concrete)
+
+Deckard today reads chain state from whatever RPC it's pointed at and **believes the answer** — a trusted-server assumption Deckard's whole pitch rejects. [Helios](https://github.com/a16z/helios) (a16z, Rust, MIT) turns an *untrusted* execution-layer RPC into a *verified* local endpoint: it re-derives every balance from a Merkle proof and checks it against the consensus-layer sync committee, so **the RPC cannot lie to you.** We embed it as a Rust library and route **all** of Deckard's reads through it.
+
+**The core property is integrity, not availability.** Deckard can even *ship its own default RPC* and the user need not trust it — every instance runs Helios locally and verifies, so a Deckard-hosted (or any) RPC is a convenience, not a trust dependency (and the user can point at their own RPC with one setting). That is the moat: self-custody of *keys* is half the story; Helios makes your *view of the chain* self-custodial too.
+
+Two demonstrations of the one property, both proven by the spike in `spikes/helios-walkaway/`:
+- **Integrity (the moat) — `SCENARIO=lie`:** point Helios at a *malicious* RPC that rewrites the balance in every `eth_getProof`. Deckard **refuses the read** (`invalid account proof`) instead of showing the fake 1,000,000,000 ETH a centralized wallet would display. *No centralized wallet can do this.*
+- **Availability (the beat) — default scenario:** sync a real mainnet client, serve the verified deposit-contract balance (≈86.3M ETH), then **cut the primary EL RPC** on camera and keep returning that verified balance via a second EL. Headless, exit-coded PASS.
+
+## A naming caveat: our "walkaway beat" ≠ Vitalik's "walkaway test"
+
+Vitalik's **"walkaway test"** (X, Jan 2026) is a property of the **protocol**: Ethereum should be able to *ossify* — keep running safely and stay useful **even if core developers stop shipping upgrades** — which is why he frames **quantum resistance** as urgent (be safe for decades before a crisis forces rushed changes). That is *not* what Deckard's demo "walkaway beat" means.
+
+Deckard's beat is the **user-side analogue**: you don't depend on any *particular* infrastructure operator (RPC vendor / the EF's endpoints) to use or verify Ethereum. The two rhyme — both are "the system survives if a privileged party walks away" — and Helios is in fact a *component* of Vitalik's vision: an ossified chain only stays usable for normal people if anyone can **verify it cheaply without trusting operators**, which is exactly the light-client thesis ("don't trust, verify"). So position Deckard as **walkaway-test-*aligned*** (verify-it-yourself reads, no operator dependence, quantum-readiness on the roadmap via Kohaku's PQ account — see `06-privacy.md`) — **not** as "the walkaway test." To avoid the clash, prefer naming the beat **"verified reads / no-trusted-RPC"** (integrity) with **"cut-the-RPC"** as its availability demo; keep "walkaway" as an internal nickname only.
+
+## Where it sits — Depends on / Unblocks (cross-doc + demo)
+
+**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* — proven by reading the **cut primary's own client** after the cut (`head_of_primary` returns from the CL-pushed cache with its EL dead). State reads fail on EL1 and recover on EL2. The transition is `Verified → Degraded{failover→EL2}` and it **stays Degraded on the backup** — the supervisor does not auto-probe back to the primary yet (recovery-to-`Verified` is a TODO, see Integration). For the demo that's fine: the balance is still verified the whole time; only the trust label reads "degraded/failover."
+- **Cut the CL instead:** the head freezes; after 60 s every `Latest`-tag read hard-fails `OutOfSync` and `syncing()` flips to `Info`. **And Helios does not self-heal a dead CL** — when the consensus channel closes, the node logs *"consensus client stopped, shut Helios down manually"* and stops (`core/src/client/node.rs`); transient CL blips are retried inside the consensus loop, but a sustained CL death requires Deckard to **rebuild** the client against CL #2 (warm-start from the cached checkpoint, ~2 s). So cutting the CL is the *graceful-degradation* path ("verified locally, head frozen → NOT VERIFIED"), not a "keeps working" beat. **Don't cut the CL on camera.**
+
+**The cache cushion (measured, important for the shoot).** After the EL cut, reads stay `Verified` from the per-block proof cache until the head advances to a *new* block, which forces a cache-miss `eth_getProof` → that's when failover actually fires. So the **cut→failover wall-clock is gated by the block cadence (0–12 s), not the supervisor** (which adds ~250–500 ms once a real EL read is attempted). Two spike runs bracketed this exactly: **1998 ms** (cut landed late in a slot) and **14744 ms** (cut landed just after a block). On camera this reads *well*: the verified balance never blinks — it holds through the cut and re-verifies via the backup within a block. If you want an instant visible flip, the supervisor can proactively issue a `get_proof` on cut-detection instead of waiting for the cached read to expire.
+
+**`ReadStatus` transitions, mapped to real Helios observables** (this is the **target** contract; what the spike implements today is noted per row):
+
+| State | Condition (observable) | Demo meaning |
+|---|---|---|
+| State | Condition (observable) | Demo meaning | Spike today |
+|---|---|---|---|
+| `Verified` | served by the primary EL (head fresh) | trustless, happy path | ✅ implemented |
+| `Degraded { reason: "failover→EL2" }` | primary EL read errored, secondary EL read succeeded; head still fresh | **the walkaway** — re-verified via backup, balance unchanged (stays Degraded; no auto-probe back) | ✅ implemented |
+| `Degraded { reason: "checkpoint:community" }` | running on `load_external_fallback` (ethPandaOps) checkpoint | verified, but checkpoint source untrusted — show a trust note | ⛔ not yet — daemon build task |
+| `Unsynced { reason: "head frozen…" }` | every EL failed **and** `syncing()==Info` (head age >60 s, CL dark) | NOT VERIFIED — never serve raw RPC | ✅ classified via `syncing()` |
+| `Unsynced { reason: "all EL upstreams down" }` | every EL failed but head still fresh | NOT VERIFIED — can't produce a proof | ✅ implemented |
+| `Unsynced { reason: "checkpoint too old" }` | `strict_checkpoint_age` rejects a >14 d checkpoint at build/sync | NOT VERIFIED — re-bootstrap from a fresh checkpoint | ⛔ not yet (the builder *can* fail; not surfaced as a status) |
+
+(The spike uses `Verified` as "served by primary," not literally `syncing()==None` on every read; for the demo the two coincide. The checkpoint-status rows are the daemon's job, not the spike's.)
+
+Hard rule (unchanged): **never silently fall back to a raw untrusted RPC.** Verified-or-visibly-degraded, never quietly-trusted. The wire shape of how `ReadStatus` rides on a read response is **proposed, not yet frozen** in `30-mcp-shape.md` (see "Integration into the app").
+
+## Integration into the app (how this wires in)
+
+> Status: **designed here, not yet built** — the spike is standalone and `src/wallet.rs` is still a plaintext EOA (per `30`). This section closes the cross-doc seam the README lists as "20 provides the EIP-1193 provider + ReadStatus," and resolves the two open placement questions.
+
+**One read module, in the daemon, key-less.** A single `Upstreams` supervisor (the Shape-A failover wrapper, which itself holds 1–2 `EthereumClient`s) lives inside `deckard-signerd` as a **read-only module with no handle to the key.** This resolves the "daemon read path vs MCP `Decision` resolver" question in favor of the daemon — matching `30`'s lean (*"simulate in the daemon so the approval card and the agent see identical numbers"*). Helios is read-only and never touches the keystore (already a stated dependency), so co-locating the read module with the signer adds no key-access path; its only outbound traffic goes to the already-untrusted EL/CL over the private/proxied upstreams.
+
+```
+ ┌─────────────── deckard-signerd (one process) ───────────────┐
+ │ key module (isolated) read module (NO key) │
+ │ sign / policy gate Upstreams (Helios) │
+ └──────────────▲──────────────────────────────▲──────────────┘
+ UDS: propose/ │ UDS: wallet_balance/simulate │ (key-less,
+ execute │ + ReadStatus on every read │ ReadStatus-tagged)
+ ┌──────────────┴───────────┐ ┌────────────────┴───┐ ┌────────┴──────────────────┐
+ │ deckard-mcp (thin shell) │ │ GPUI app (UI badge)│ │ Railgun shield (EIP-1193) │
+ └──────────────────────────┘ └────────────────────┘ └───────────────────────────┘
+```
+
+**Three consumers, two read paths:**
+1. **Daemon socket reads** (`wallet_balance`, `simulate`) — typed `HeliosApi` calls through the supervisor, so they get EL-cut failover **and** a `ReadStatus`. The GPUI UI badge and the MCP agent both consume these → one source of truth, identical numbers.
+2. **Railgun's chain reads** (UTXO/TXID sync, balance, state) — Railgun wants `RailgunBuilder::new(chain, impl IntoEip1193Provider)`, but `EthereumClient` exposes the typed `HeliosApi`, **not** an EIP-1193 `request(method, params)` JSON interface. Decision:
+ - **v1 (demo) — Helios's built-in localhost JSON-RPC server.** Build the primary client with `.rpc_address(127.0.0.1:)` (verified: `EthereumClientBuilder::rpc_address(SocketAddr)`; `HeliosClient::new` then spawns `jsonrpc::start`, which serves the `eth_*` subset Helios implements — the methods Railgun needs for live/state reads, all proof-checked; it is **not** a full JSON-RPC surface, so ⚠ confirm Railgun only calls served methods) and hand Railgun an **alloy HTTP provider** pointed at it. Least code, reuses Helios's own correct mapping. Accepted tradeoffs: (i) a loopback hop + a port (bind `127.0.0.1`, same-uid only); (ii) the server is per-`EthereumClient`, so Railgun's reads hit the primary only and do **not** get the supervisor's EL-cut failover — fine, because the shield completes *before* the on-camera cut and Railgun's reads are never the thing being cut. ✅ **PROVEN end-to-end** (`spikes/eip1193-railgun/`, 2026-06-06, against `kohaku@618c53f`): `IntoEip1193Provider` is Kohaku's *own* narrow 7-method trait (its `eip-1193-provider` crate) — **not** alloy's and **not** a generic `request(method,params)`; an alloy `DynProvider` (`ProviderBuilder::new().connect(url).erased()`) satisfies it via Kohaku's **shipped** `Alloy` adapter (`impl IntoEip1193Provider for DynProvider`) — **no custom adapter for v1** (confirmed by upstream's own `sync_utxo.rs`). The read/sync/balance path calls exactly **3** of those methods, all in Helios's served set: `eth_blockNumber` (`RpcSyncer.latest_block`) + `eth_getLogs` (`RpcSyncer.events`, tail range only) + `eth_call` (`SmartWalletUtxoVerifier.verify_root`); `balance()`/`register()` are local. **One required fix:** alloy's `Provider::call` defaults to the `pending` block tag (`alloy-provider 1.8.3` `trait.rs:198`), which Helios (a light client) can't serve (`block not found: pending`) — build the provider with `ProviderBuilder::new().with_default_block(BlockId::latest())` (installs alloy's `BlockIdLayer`) so the **unmodified** adapter's `eth_call` targets `latest`. One line, Deckard-side, no Kohaku/Helios patch. Historical UTXO ranges go to Subsquid, not Helios (10).
+ - **production — a thin Rust adapter.** `struct HeliosEip1193(Arc)` implementing Kohaku's `Eip1193Provider` trait (7 methods) by mapping each → a typed `HeliosApi` call (`get_block_number`, `get_logs`, **`call` at `Latest`** — same pin-to-latest discipline, since it bypasses the alloy `pending` default entirely). Removes the loopback hop and puts Railgun's reads behind the same failover + `ReadStatus`. Build post-demo; keep it the single place a Helios↔Railgun API change touches.
+
+**`ReadStatus` on the wire — cross-doc proposal to `30` (it owns the contract).** For "every read carries Verified|Degraded|Unsynced" to be enforceable, `ReadStatus` must live in `deckard-contract` (the shared type home `30` owns) and ride on the read responses. Proposed delta:
+- define `enum ReadStatus { Verified, Degraded{reason}, Unsynced{reason} }` in `deckard-contract` — **20 owns the semantics/transitions** (table above); the **type lives with the contract** so it can serialize on the wire.
+- `wallet_balance` → `{ public_wei, shielded_wei, token_balances[], read_status }`
+- `simulate` → `{ asset_changes[], gas, warnings[], read_status }`
+
+Today `30`'s read responses omit `read_status`; without it the "never silently trust" rule can't be enforced at the wire. (30 owns the final shape — this is the ask.)
+
+**CL-death handling (build task).** The supervisor gains a frozen-head detector: when `syncing()` flips to `Info` (head age >60 s) and no EL failover recovers it, **rebuild** the client against CL #2 (warm from the cached checkpoint, ~2 s), surfacing `Unsynced{reason:"reconnecting CL"}` in the gap. Never serve a raw read while reconnecting.
+
+**Deckard-side file layout:**
+```
+src/chain/helios.rs # EthereumClient wrapper: build, wait_synced → servable, shutdown
+src/chain/upstreams.rs # Upstreams supervisor (Shape A) + CL-rebuild-on-frozen
+src/chain/read_status.rs # ReadStatus (re-exported from deckard-contract)
+src/chain/eip1193.rs # v1: localhost-server wiring · prod: HeliosEip1193 adapter
+~/.../Deckard/helios/ # data_dir: cached finalized checkpoint (with_file_db)
+```
+The spike (`spikes/helios-walkaway/`) already implements `read_status.rs` + `upstreams.rs` in portable form — lift them in, add `helios.rs` (build/servable wrapper) and `eip1193.rs`.
+
+## Inputs, trust, and the checkpoint
+
+**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) | **NO** — `sync failed: invalid sync committee period` | keyless, HTTPS, no-log policy, but the `/updates` bug breaks Helios bootstrap. Don't use as a Helios CL. |
+| `https://eth-beacon-chain.drpc.org` (dRPC) | yes | **yes (verified — cold 10.4 s, head 25253907)** | keyless, HTTPS. **The proven public second CL.** |
+| `https://www.lightclientdata.org` (a16z old default) | **503** | — | down. |
+| beaconcha.in / checkpoint-sync hosts (sigp, attestant, ethpandaops) | 404 on LC routes | — | checkpoint-sync only; **not** an LC API. |
+
+**Most commercial EL-RPC providers do NOT expose the light-client subset** (Ankr's beacon endpoint 404s on `light_client/*`; QuickNode serves it only if you provision your own Lighthouse-backed beacon endpoint; Chainstack/Blockdaemon/Nodereal unconfirmed). Of the keyless mainnet LC servers, only two are **proven to actually drive a Helios sync**: **Nimbus-testing** and **dRPC**. Lodestar and PublicNode return 200 on the routes but fail Helios sync (timestamp-0 head; `invalid sync committee period`, respectively). 200 ≠ syncs.
+
+**Chosen approach for the hero (CEO review): public CLs — and the prerequisite is now met.** Two independent, keyless, proven-to-sync public CLs:
+- **Primary: Nimbus** `http://testing.mainnet.beacon-api.nimbus.team` (cold ~11 s). Plain HTTP, no-SLA team box.
+- **Second: dRPC** `https://eth-beacon-chain.drpc.org` (cold ~10.4 s, verified 2026-06-05). HTTPS, keyless.
+
+Self-hosting a Lighthouse CL stays as the fallback only if a pre-shoot rehearsal shows both publics are flaky. Honest caveat: both are best-effort, **no-SLA** hosts; integrity is guaranteed by the sync committee + checkpoint regardless of which CL you use — only **liveness** and **metadata** depend on the provider. Still do a health-check of both in the hour before the take, and only ever cut the EL on camera, never the CL.
+
+**Self-host fallback (smallest path).** The `light_client/*` namespace is standard ([beacon-APIs spec](https://github.com/ethereum/beacon-APIs)). Which CLs serve it:
+
+| 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)
+
+> These are **observed values from real runs on this build host**, reproducible via the spike's commands — they are **not** asserted in CI or stored as committed artifacts. Re-measure on the actual demo machine. The spike prints current-run values; only the PASS/FAIL verdict is asserted.
+
+| Metric | Number | Notes |
+|---|---|---|
+| **Cold sync** (build → first servable verified head) | **≈ 10.9 s** | with `strict_checkpoint_age` + no user pin, the stale built-in default is rejected so `load_external_fallback` fetches a fresh community checkpoint; includes the ~12 s-bounded wait for the first execution head push. (`FileDB` otherwise falls back to the *built-in default* checkpoint, not the community one — the external fallback is conditional.) |
+| **Warm sync** (cached `data_dir/checkpoint`) | **≈ 2.2 s** | ~5× faster; this is the demo-day number — **pre-sync, ship warm** |
+| **Cut → failover (wall-clock)** | **≈ 2–15 s** | gated by block cadence (per-block proof cache), **not** the mechanism |
+| **Failover mechanism alone** | ~250–500 ms | one failed EL attempt + one success on EL2, once a real `eth_getProof` is forced |
+| **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.
+
+**Measured — `eip1193-railgun` spike (M-series, mainnet, `--release`, 2026-06-06).** Helios warm sync ≈ 2.1 s / cold ≈ 10.5 s (consistent with above). All read-path methods (`eth_chainId`/`eth_blockNumber`/`eth_getLogs`/`eth_call`) resolve through Helios's localhost server; a 2000-block `eth_getLogs` window on the live RAILGUN wallet returned ~100–326 verified events, and Kohaku's real `RpcSyncer` parsed 414 `SyncEvents` through it. **Loopback-hop overhead: direct typed `HeliosApi` head read ≈ 0.75 ms/call vs alloy→Helios-localhost ≈ 1.0 ms/call → Δ ≈ 0.27–0.31 ms/call** (HTTP-serialize + two loopback syscalls + jsonrpsee dispatch). That hop is **cheap enough to ship the v1 localhost path for the demo and defer the production `HeliosEip1193` adapter** (which removes this hop and adds failover + `ReadStatus`).
+
+## Local end-to-end testing (Kurtosis) — DEFERRED (not v1-critical)
+
+> **Decision (CEO review):** Kurtosis is **deferred off the v1 critical path.** The mainnet spike already proves the whole R2 beat (sync, verified balance, cut-the-EL failover, refuse-a-lie) with **zero** Kurtosis, so a local devnet is not required to ship the demo. Its only added value is a *fully offline, deterministic CI lane where you own the CL* (no public-beacon flakiness in tests) — a post-demo hardening nice-to-have, not a gate. v1 testing runs on mainnet + Sepolia public endpoints. **TODO (post-demo): build the hermetic Kurtosis CI lane** (needs the hand-written Helios devnet `Config` below). The findings below are kept so that build is cheap when we pick it up. Note: Kurtosis is *not* a wallet feature and is *not* mainnet — it's a private throwaway devnet (a few GB, laptop-fine) used only for testing; it can't be shipped to users and can't replace Helios (it's the thing Helios verifies *against* in a test).
+
+A plain **anvil** node has no consensus layer, so Helios cannot verify against it. The (now-answered) gating question was whether the Kurtosis `ethpandaops/ethereum-package` CL serves the LC API out of the box. **Answer: yes, with zero/near-zero flags** — Lighthouse, Nimbus, Lodestar all serve the LC API **on by default**, and ethereum-package runs **all forks from genesis** (Altair + sync committee live at slot 0). Minimal config:
+
+```yaml
+# lc-devnet.yaml — CL answers the light_client/* routes out of the box
+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 (the deferred hermetic-CI lane):** the full Kurtosis devnet — CL and EL are internally consistent, so you can cut the EL against a CL you fully control with no public dependency. **Requires a hand-built Helios `Config`** (the `Network` enum hardcodes mainnet's CL and the testnets are `None`) with the devnet `chain_id`, both RPCs, and a fresh checkpoint (genesis/first-finalized root). Not built (deferred). When picked up, coordinate with `00-test-harness.md`.
+- **Option B (anvil-fork EL + real mainnet CL) does NOT work** — and it's a trap worth stating: Helios verifies EL responses against the `state_root` the mainnet CL header attests to. A forked anvil matches that root only at the exact fork block with zero mutations; the instant it advances/mines, the root diverges and Helios's verification **fails** (not "works with stale data"). Plus the mainnet CL head keeps advancing while the fork doesn't, tripping the 60 s gate. Don't build the walkaway on it.
+- **Gotchas:** `finality_update` only returns meaningfully after ~2 epochs finalize (~12.8 min at 12 s slots) — don't assert on it immediately post-`kurtosis run`. Keep all fork epochs at 0 (default). For the EL-only failover logic, unit-test the supervisor with mocked clients (no real verify) — the spike already isolates it in `upstreams.rs`.
+
+## 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 **and optionally lying** HTTP/1.1 reverse proxy: the kill switch is the on-camera "cut"; `lie=true` rewrites the `balance` in every `eth_getProof` response (a malicious RPC).
+- `main.rs` — two scenarios + measurements; exit 0 = PASS.
+
+Run:
+- **Availability (cut-the-RPC):** `cargo run --release` (warm) / `WIPE=1 cargo run --release` (cold). Defaults to the privacy-correct posture (publicnode proxied + dRPC failover + Nimbus CL).
+- **Integrity (refuse a lie):** `SCENARIO=lie WIPE=1 cargo run --release`. Proven result: malicious RPC claims **1,000,000,000 ETH**, Deckard returns `REJECTED — invalid account proof` (a centralized wallet would show the billion). Note it still *syncs and serves the head through the lying RPC* — only the proof-bearing balance read catches the lie, because the head is CL-verified.
+
+See the README for the CL-choice and key-restricted-EL caveats.
+
+**Sibling spike — `spikes/eip1193-railgun/` (the Railgun EIP-1193 seam, T-Trustless #3).** Boots the *same* Helios localhost server (`.rpc_address`) and settles the "v1 localhost vs forced-adapter" question in "Integration" above. **Tier-2** (default, light): an alloy `DynProvider` through Kohaku's *own* `IntoEip1193Provider` adapter drives `eth_chainId`/`eth_blockNumber`/`eth_getLogs`/`eth_call` through Helios, logged by a method-recording pass-through proxy. **Tier-1** (`--features railgun`, heavy): links the *full* `railgun` ZK crate (ark-circom/wasmer/groth16) — `RailgunBuilder::new(ChainConfig::mainnet(), ).build()` OK, then Kohaku's real `RpcSyncer` drives `eth_getLogs` through Helios → 414 parsed `SyncEvents` from the live mainnet RAILGUN wallet. Verdict: **v1 WORKS** with the one-line `with_default_block(latest)` fix; the `railgun` crate compiles standalone from the spike's dep edge (mirrors 3 `[patch]`es). Numbers in "Measured" below.
+
+**Acceptance test (the R2 slice; the spike implements steps 1–3):**
+```
+Scenario "Helios verified reads" (mainnet hero):
+ 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. INTEGRITY (the moat): point Helios at a MALICIOUS RPC (tampered eth_getProof balance)
+ assert: get_balance REJECTS the read (invalid account proof); never returns the fake value
+ (head still syncs through the liar — only the proof-bearing read catches it)
+ 4. AVAILABILITY (cut-the-RPC): cut EL1 (kill the proxy)
+ assert: supervisor fails over to EL2, returns a VERIFIED balance, head still advances
+ (Verified -> Degraded{failover→EL2}; stays Degraded on the backup), within ≤1 block + mechanism
+ 5. STALE CHECKPOINT: start with a >14d checkpoint + strict_checkpoint_age
+ assert: build/sync FAILS visibly (Unsynced); NEVER silently serves raw RPC
+```
+Steps 2–4 are the on-camera beats (verified read, refuse-a-lie, cut-the-RPC); the same headless run + screen capture is the cut. The spike implements steps 1–4 across its two scenarios (default = 1/2/4, `SCENARIO=lie` = 3); step 5 is an unwritten guard test.
+
+## Risks & fallbacks
+
+- **R2 — no native EL/CL failover (verified).** Live "cut and continue" needs our supervisor (Shape A). *Status: the core is proven on mainnet (balance-read failover + lying-RPC rejection); the app-level wiring (EIP-1193, ReadStatus-on-wire, CL-rebuild, receive-watcher, simulate) is unbuilt.* Fallback for the EL: "verified locally, head frozen" badge if even (A) misbehaves.
+- **The CL is the fragile, no-SLA dependency** (but redundancy is now real). A single CL stalling >60 s on camera hard-fails *every* `Latest`-tag read — looks like a crash. And Helios doesn't auto-recover a dead CL (requires a rebuild against CL #2). *Chosen mitigation (CEO review): public CLs, redundancy proven* — **Nimbus primary + dRPC second, both verified to drive a Helios sync** (Nimbus ~11 s, dRPC ~10.4 s). Rehearse/health-check both in the hour before, and **only ever cut the EL on camera, never the CL.** Self-host a Lighthouse CL only as the fallback if both publics look flaky at rehearsal. (Lodestar + PublicNode beacon return 200 but fail Helios sync — don't use them as CLs.)
+- **Cache cushion shifts failover timing.** Cut→visible-failover is ≤1 block because of the per-block proof cache; the balance holds verified through the cut (good), but the visible `Degraded` flip waits for the next block. Force it with a proactive `get_proof` on cut-detection if an instant flip is wanted.
+- **Checkpoint trust / community fallback "not secure."** Ship a recent pinned default + user override; run `strict_checkpoint_age`; mark community-sourced checkpoints `Degraded`.
+- **API churn / git-pin.** Pre-1.0, git-pinned. Keep the thin wrapper (`upstreams.rs`/`read_status.rs`) so a Helios API change touches one place. Re-verify builder method names at each bump.
+- **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` (umbrella `alloy 1.8.3`). The `eip1193-railgun` spike confirms **Kohaku's `railgun` independently resolves to the identical `alloy 1.8.3`/`alloy-primitives 1.6.0`**, so Helios + Railgun link in one process with no version conflict (mirror 3 `[patch]`es: `ethereum_hashing` + the `ruint` & `ark-circom` forks).
+- ~~CL approach + redundant second~~ → **decided + proven:** public CLs, **Nimbus primary + dRPC second** (both verified to drive a Helios sync, ~11 s / ~10.4 s). Lodestar + PublicNode beacon fail (200 but no sync). Self-host = flaky-rehearsal fallback only. Remaining minor: resolve the **Teku** default-flag contradiction or just avoid Teku.
+- ~~Does Deckard auto-rebuild on a dead CL?~~ → **specced** as a supervisor build task (frozen-head detector → rebuild against CL #2, ~2 s warm) in "Integration into the app." Not yet built.
+- ~~Failover (Shape A) in the daemon read path vs MCP `Decision` resolver?~~ → **decided:** one key-less `Upstreams` in `deckard-signerd` (see "Integration into the app"). Matches `30`'s "daemon so the numbers match" lean.
+- ~~EIP-1193 adapter for Railgun~~ → **RESOLVED + PROVEN** (`spikes/eip1193-railgun/`): v1 = Helios localhost server + alloy `DynProvider` through Kohaku's *own* `IntoEip1193Provider` (no custom adapter), with the one-line `with_default_block(latest)` fix (alloy's `call` defaults to `pending`; Helios has none). Both the adapter-only path and the **full `railgun` crate** (linked under `--features railgun`; `RailgunBuilder::new(ChainConfig::mainnet(), ).build()` + Kohaku's real `RpcSyncer` drove `eth_getLogs` through Helios → 414 SyncEvents) verified on mainnet. The `railgun` crate **compiles standalone** from our dep edge (retires 10's R1c). Production `HeliosEip1193` adapter still wanted (drops the loopback hop + adds failover/ReadStatus) and must likewise pin `eth_call`→`latest`. Loopback-hop overhead: see "Measured."
+- **`read_status` on `30`'s read responses:** proposed (define `ReadStatus` in `deckard-contract`, add the field to `wallet_balance`/`simulate`). Needs `30`'s sign-off — it owns the contract.
+
+## Sources (repos + docs)
+
+- [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..31ed19c
--- /dev/null
+++ b/docs/build/30-mcp-shape.md
@@ -0,0 +1,262 @@
+# 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)
+
+> **Status: implemented in `crates/deckard-signerd` (issue #4).** This doc owns the wire; the daemon implements it. v1 scope = `Send` only (Shield → T-Privacy, Helios reads → `20`, `deckard-mcp` → `30`).
+
+UDS at `$XDG_RUNTIME_DIR/deckard/signerd.sock` (Linux) — macOS fallback `$TMPDIR/deckard-$UID/signerd.sock` — socket mode `0600` inside a `0700` dir, **length-delimited CBOR** (4-byte big-endian length prefix + body, max 1 MiB), one request per frame. Caller auth is `SO_PEERCRED`/`LOCAL_PEERCRED` **same-uid only**; single-instance via `flock` on a sibling `signerd.lock`.
+
+```rust
+// deckard-mcp / deckard-app (key-less) → deckard-signerd
+enum SignerRequest {
+ Unlock { passphrase: String }, // -> Unlock(UnlockOutcome) (decrypt + hold the key)
+ Lock, // -> Ack (zeroize the key → Locked; deny in-flight)
+ Resolve { request_id: RequestId, approved: bool }, // -> Ack (close an approval loop)
+ Propose { intent: Intent }, // -> Decision (policy check, NO signing yet)
+ Execute { request_id: RequestId }, // -> ExecuteResult (sign + broadcast; only if Allow/approved)
+ Status { request_id: RequestId }, // -> ApprovalStatus (poll for native-card result)
+ RevokeAll, // -> Ack (STOP: zeroize the key → Locked, deny in-flight)
+ PolicyGet, // -> Policy (read-only snapshot for the agent)
+ // read-only, key-less helpers:
+ Address, // -> Address (or Deny{"locked"} when Locked)
+ Balance { shielded: bool }, // -> BalanceReport (public only in v1; shielded_wei = 0)
+}
+
+enum ExecuteResult { Broadcast { tx_hash: B256 }, Denied { reason: String } }
+enum ApprovalStatus { Pending, Allowed, Denied { reason: String }, Expired }
+enum UnlockOutcome { Unlocked { address: Address }, BadPassphrase, NoVault }
+```
+
+**Unlock / Lock / Resolve (the operator state machine).** The daemon is `Locked` (no key) ⇄ `Unlocked { vault }`:
+- `Unlock{passphrase}` reads the keystore (`deckard-core`'s `vault.bin` in the config dir), decrypts, and holds the key → `Unlocked{address}`. The wire passphrase is a plain `String` (`Zeroizing` isn't `Serialize`); the daemon moves it into `Zeroizing` on receipt, scrubs the raw frame, and never echoes or logs it. Wrong passphrase / tampered vault → `BadPassphrase`; missing file → `NoVault`.
+- `Lock` and `RevokeAll` both zeroize + drop the key → `Locked` and deny every in-flight approval (`Pending` **and** `Allowed`). Re-arm only via a fresh `Unlock` (which also starts a clean request session).
+- `Resolve{request_id, approved}` closes the loop a `NeedsApproval` opened: it flips that `Pending` record to `Allowed`/`Denied`. Without it nothing turns `Pending` into executable; the native GPUI card (T-UX) is the human-facing caller.
+
+Invariants frozen here, asserted by `crates/deckard-signerd/tests/*` (cross-process red-team → `00-test-harness.md`):
+- **One decision function.** `policy::evaluate(&Intent, &Policy) -> Decision` is the single source of the verdict; both `MockSigner` and the daemon call it (parity is unit-asserted), so the mock and the real daemon can never drift. The daemon adds only process-level pre-checks `evaluate` can't express (`Locked` → `Deny{"locked"}`, `chain_id` mismatch → `Deny{"chain_mismatch"}`, `kind != Send` → `Deny{"unsupported_v1"}`).
+- `Propose` **never signs** and never broadcasts. It returns a `Decision`. A `Decision::Allow`/approved `RequestId` is the *only* token that lets `Execute` sign. (v1 `RequestId` = `keccak256` of a stable encoding of the intent, so a client that got `Allow` derives the id locally to execute it; a `NeedsApproval` id rides the wire. Production should switch to a salted, returned id.)
+- `Execute` re-checks **policy** at sign time (TOCTOU guard): an approval granted before `Lock`/`RevokeAll` is still denied (`Denied{"revoked"}`); an *auto*-allow is re-run against the spend caps against the **current** `spent_today` (`Denied{"cap_exceeded"}`) so two within-cap proposals can't both broadcast past the daily cap — a human-approved overage carries its own consent and isn't re-capped. A broadcast id never signs twice (`Denied{"already_executed"}`); a stale (TTL-expired) request — `Pending` **or** `Allowed` — is `Denied{"expired"}`. A re-`Propose` of an identical intent is idempotent (it never resets a live card's TTL, downgrades an approval, or re-raises a `Denied`). It builds an **EIP-1559** tx via alloy fillers (pending nonce, fee/gas estimation, `chain_id` from the intent) and broadcasts via the config RPC (`DECKARD_RPC_URL`/`DECKARD_CHAIN_ID`) under a bounded timeout.
+- **Policy (v1):** loaded from `policy.json` in the config dir, with a safe default if absent (per-tx 0.05 ETH, daily 0.2 ETH, empty allowlist = any, auto-shield-min 0.01 ETH, approval-over-cap). `spent_today_wei` is in-memory, UTC-midnight rollover, resets on restart (cross-restart persistence is a fast-follow). No `SetPolicy` yet.
+- `RevokeAll` is idempotent and irreversible for the session (re-arm only via `Unlock`, `08-security-keystores.md`).
+- The MCP/app process holds **no key, no decrypted seed, no signing capability** — verified by the red-team script in `00-test-harness.md` (memory + fd scan finds no key; there is no UDS method that returns raw key bytes).
+
+### MCP tool surface (concrete list)
+
+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 |
+
+> ⚠ **Cross-doc need (from `20-helios-sidecar.md` "Integration into the app"):** the `wallet_balance` and `simulate` responses must carry a `read_status` field (`ReadStatus { Verified | Degraded | Unsynced }`), and `ReadStatus` should be defined in `deckard-contract` (here) since it rides the wire. Without it the "never silently serve an untrusted read" rule isn't enforceable at the contract level. `20` owns the semantics/transitions; `30` owns the final type + field placement.
+
+Write tools (route through `propose` → `Decision`; "execute validated intents, not raw LLM suggestions", 05 [10]):
+
+| Tool | Builds | Approval |
+|---|---|---|
+| `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..8364b17
--- /dev/null
+++ b/docs/build/README.md
@@ -0,0 +1,97 @@
+# 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
+
+> **Live build status for every track lives in [`STATUS.md`](../../STATUS.md) — the single source of truth.**
+> This page is the spec *index* (what each doc owns), not a status surface; the deep docs are the spec.
+
+| Doc | Owns |
+|---|---|
+| [`00-test-harness.md`](00-test-harness.md) | The **test substrate**: local lanes (anvil fork / Sepolia) + a headless agentic runner that drives the shot-list and self-asserts; mainnet fixtures; CI. |
+| [`10-kohaku-shield.md`](10-kohaku-shield.md) | The **hero action**: auto-shield via Kohaku's pure-Rust `railgun` crate. **R1 retired** — full shield→unshield runs from our edge, and shield is *instant* (no client proof; ZK proving is on the spend). |
+| [`20-helios-sidecar.md`](20-helios-sidecar.md) | **Trustless reads + walkaway** via embedded `helios-ethereum` 0.11.1 (git-only). **R2 proven** + verified reads **integrated** into `EthProvider`/signerd behind `verified-reads`. |
+| [`30-mcp-shape.md`](30-mcp-shape.md) | The **agent surface** (one binary = CLI + MCP server, key-less) **and the freeze-first contract**. Contract + daemon socket built; the `deckard-mcp` binary is not yet. |
+
+## Build order (what gates what)
+
+```
+ ┌─ 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)`. Note (per `20`
+ "Integration into the app"): `EthereumClient` is *not* EIP-1193 natively, so v1 = Helios's localhost JSON-RPC
+ server on the primary client (no supervisor failover for Railgun's reads), prod = a `HeliosEip1193` adapter over
+ the supervisor. The daemon's own `wallet_balance`/`simulate` reads use the typed supervisor (with failover).
+ (20 provides; 10 + T-Core consume.)
+- **`ReadStatus { Verified | Degraded | Unsynced }`** — attached to every read; the UI/agent must see it;
+ **never silently fall back to untrusted RPC.** (20 owns the semantics; the type belongs in `deckard-contract` and
+ the `read_status` field on `wallet_balance`/`simulate` is **proposed, not yet frozen** in `30`.)
+
+## The two hero-beat spikes
+
+- **R1 — shield from Rust (10):** ✅ **retired + integrated.** Kohaku's `railgun` crate runs full
+ shield→transfer→unshield from our own dep edge (`spikes/shield-railgun/`), and the shield is now wired into
+ Deckard (key-less builder in `deckard-core` + signerd calldata-broadcast + a black-box `shield_e2e`). Proving
+ time *measured*: **shield is instant** (no client ZK proof, ~ms); ZK proving is on the *spend* (~10s cold,
+ ~halved with `parallel`) — so "instant auto-shield, prove-on-spend" is honest. Still open: railgun's per-crate
+ license (no upstream `license` field) + the `vendor/eip-1193-provider` fork — resolve before ship.
+- **R2 — walkaway (20):** ✅ proven on mainnet **+ verified reads integrated.** Helios has **no native multi-EL/CL
+ failover** (one client = one EL + one CL); the head is **consensus-driven and EL-independent** (served from
+ cache), so cutting the EL keeps the head live while a second synced client recovers state reads via Deckard's
+ own supervisor (Shape A). Both read paths (`EthProvider` + signerd) now route through Helios behind a
+ `verified-reads` feature. **Key finding: cut the *EL* on camera, never the *CL*** — a dead CL freezes the head
+ and Helios won't self-heal. The CL is the fragile, no-SLA dependency; Nimbus + dRPC are the two proven CLs.
+
+## Acceptance = the shot list (lives in `00-test-harness.md`)
+
+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 + DEFERRED in `20`:** yes, OOTB (Lighthouse/Nimbus/Lodestar serve LC by default; use `cl_type: lighthouse`). But the mainnet spike proved R2 **without** Kurtosis, so the Kurtosis lane is **deferred off the v1 critical path** (post-demo hermetic-CI nice-to-have). v1 tests on mainnet + Sepolia public endpoints. — `20`/`00`
+- Does Helios's EIP-1193 provider serve the log ranges Railgun UTXO sync needs, or does Subsquid carry history? — `10`/`20`
+- `simulate` in the MCP binary (key-less, calls Helios) vs in the daemon (agent + approval card see identical numbers)? — `30`/`20`
+- `railgun` crate license inheritance vs Deckard's 0BSD posture. — `10`
+- `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..3db2a14 100644
--- a/justfile
+++ b/justfile
@@ -1,28 +1,35 @@
-# 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:
@just --list
# Run the app (debug). This is the one you'll use 99% of the time.
+# Build the signer daemon first so the app can spawn it as a sibling binary (the app resolves
+# `deckard-signerd` next to its own binary, or via DECKARD_SIGNERD_BIN).
run:
+ cargo build -p deckard-signerd
cargo run
# Run optimized.
run-release:
+ cargo build -p deckard-signerd --release
cargo run --release
# Run as a menu-bar / tray app (no dock icon).
run-tray:
- cargo run --features tray
+ cargo build -p deckard-signerd
+ 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 +42,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
diff --git a/specs/HANDOFF-next-phase.md b/specs/HANDOFF-next-phase.md
index 72c27a6..7488947 100644
--- a/specs/HANDOFF-next-phase.md
+++ b/specs/HANDOFF-next-phase.md
@@ -1,5 +1,9 @@
# Deckard — next-phase build handoff
+> ⚠ **Stale snapshot.** Live build status is in [`/STATUS.md`](../STATUS.md) (single source of truth). The
+> "Built so far" / paths below predate the `crates/` workspace restructure, the encrypted keystore, the signer
+> daemon, and the Helios/shield integration. Kept for the GPUI gotchas + ground rules; use `/STATUS.md` for state.
+
For a fresh session to pick up implementation cold. Read this + `SPEC-v0-epic.md` (backlog C1–C9) +
the repo's `DESIGN.md`. Project memory also carries the state.
diff --git a/specs/SPEC-v0-epic.md b/specs/SPEC-v0-epic.md
index 5b0a4cd..e5b8db1 100644
--- a/specs/SPEC-v0-epic.md
+++ b/specs/SPEC-v0-epic.md
@@ -1,6 +1,10 @@
# Epic: Deckard v0 — native self-custodial operator wallet
-Status: spec (pre-implementation). Source requirements: `SPEC-v0.md`. Strategy: `strategy.md`.
+> ⚠ **Live build status is in [`/STATUS.md`](../STATUS.md)** (single source of truth). The status fields in
+> this epic are the *original plan* and are now stale (C1–C3 and much of v1 are built). Use this doc for the
+> backlog/requirements, `/STATUS.md` for where things actually stand.
+
+Status: spec (pre-implementation) — **see `/STATUS.md` for actual progress.** Source requirements: `SPEC-v0.md`. Strategy: `strategy.md`.
Target repo: fork of `hellno/deck`. License: AGPL-3.0. Chain: Ethereum mainnet only.
## Context
diff --git a/spikes/eip1193-railgun-spike.md b/spikes/eip1193-railgun-spike.md
new file mode 100644
index 0000000..4c376cc
--- /dev/null
+++ b/spikes/eip1193-railgun-spike.md
@@ -0,0 +1,117 @@
+# Spike prompt: Helios as Railgun's EIP-1193 provider (Deckard T-Trustless #3)
+
+> Feed this whole file to a fresh coding agent. It is self-contained. Goal: prove
+> (or disprove) that an embedded Helios light client can serve as the EIP-1193
+> provider Kohaku's `railgun` crate reads through, so Deckard's shield path gets
+> **verified** chain reads instead of trusting a raw vendor RPC. Standalone spike —
+> do **not** touch the app crates (`crates/`).
+
+## Context you can trust (already verified against source — don't re-litigate)
+
+- Helios is consumed as **`helios-ethereum`** (NOT the umbrella `helios` crate — the
+ umbrella pulls `helios-opstack` → libp2p → a yanked `core2 0.4.0` that won't resolve).
+ Git-only, tag `0.11.1` (no `v`). crates.io is stale at 0.1.0.
+ ```toml
+ helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1" }
+ alloy = { version = "1.0.37", features = ["eips", "rpc-types", "provider-http", "network"] }
+ [patch.crates-io]
+ ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" }
+ ```
+ Mirror the `[patch]` or the consensus crates fail to build. `alloy` unifies to one
+ `alloy-primitives 1.6.0`, so types match across helios + your spike. Add an empty
+ `[workspace]` table to the spike's `Cargo.toml` so it stays standalone (the repo root
+ is a workspace).
+- **Helios ships a localhost JSON-RPC server.** `EthereumClientBuilder::rpc_address(SocketAddr)`
+ + `with_file_db()` → on `build()`, `HeliosClient::new` spawns `jsonrpc::start(inner, addr)`
+ serving a **verified** local endpoint at `http://`. It serves the `eth_*` subset in
+ Helios's `core/src/jsonrpc/mod.rs` / `rpc.md` (getBalance, call, getCode, getLogs,
+ getBlockByNumber, getTransactionReceipt, getProof, chainId, blockNumber, estimateGas,
+ sendRawTransaction, subscribe(newHeads only), …) — **not** a full JSON-RPC surface.
+- **`wait_synced()` ≠ ready.** After it returns, poll `get_block_number()` until `Ok`
+ (the first execution head lands ~1 slot later, ≤12s; before that every `Latest` read
+ fails the 60s `check_head_age` gate).
+- **CL choice matters — 200 ≠ syncs.** On mainnet only **Nimbus**
+ (`http://testing.mainnet.beacon-api.nimbus.team`) and **dRPC**
+ (`https://eth-beacon-chain.drpc.org`) actually drive a Helios sync; Lodestar +
+ PublicNode return 200 but fail. For **Sepolia** you must supply a Sepolia beacon
+ light-client endpoint (`Network::Sepolia` has `consensus_rpc = None`).
+- Working reference: `spikes/helios-walkaway/` already embeds Helios, syncs mainnet, and
+ has a killable proxy + supervisor. Reuse its `Cargo.toml` shape and the
+ build-then-poll-until-servable pattern.
+
+## The two approaches (the spike must settle which one v1 uses)
+
+- **v1 (least code): Helios localhost server + an alloy HTTP/EIP-1193 provider.** Build
+ Helios with `.rpc_address(127.0.0.1:)`, then hand `RailgunBuilder::new(chain, …)`
+ an alloy provider pointed at `http://127.0.0.1:`. Tradeoff: a loopback hop, and the
+ server is per-`EthereumClient` (no failover supervisor in front).
+- **production: a Rust adapter** `struct HeliosEip1193(EthereumClient/Upstreams)` implementing
+ whatever trait `RailgunBuilder::new` wants, mapping `request(method, params)` → the typed
+ `HeliosApi`. No hop, behind the supervisor.
+
+The spike's job is to **prove v1 works end to end** and to discover exactly what (if anything)
+forces the adapter.
+
+## Tasks (do in order)
+
+1. **Pin Kohaku's `railgun` API against source.** Read
+ `github.com/ethereum/kohaku/tree/master/crates/railgun/src` (esp. `builder.rs`/`provider.rs`).
+ Confirm the EXACT signature of `RailgunBuilder::new` and **what `impl IntoEip1193Provider`
+ actually is** — is it alloy's provider trait, an `ethers`-style provider, or Kohaku's own
+ trait? Write down the concrete trait + which provider types satisfy it. (This is the crux;
+ everything else depends on it.) Note the crate is alpha (`0.1.0`/`rlib`) and Sepolia-oriented.
+2. **Stand up a verified Helios localhost endpoint.** Bin that builds `helios-ethereum`
+ `EthereumClientBuilder::::new().network(...).consensus_rpc(cl)?.execution_rpc(el)?`
+ `.checkpoint(b256)`/`.load_external_fallback().rpc_address("127.0.0.1:0".parse()?)`
+ `.with_file_db().build()?`, `wait_synced()`, poll until servable. Confirm
+ `curl http://127.0.0.1:` answers `eth_getBalance`/`eth_chainId` correctly (verified).
+ Prefer **Sepolia** if that's where the Railgun contracts/tests live (check task 1); else mainnet.
+3. **Wire Railgun to it.** Construct the alloy provider over the localhost URL and pass it to
+ `RailgunBuilder::new(chain, provider)`. Build the Railgun client and perform the smallest
+ read it supports (e.g. balance / a UTXO or pool-state sync init). Confirm the read resolves
+ **through Helios** (watch Helios logs / the localhost server) and returns sane data.
+4. **Enumerate the methods Railgun calls.** Instrument the localhost server (or a thin logging
+ pass-through in front of it) to record every JSON-RPC `method` Railgun invokes during
+ register/sync/balance. Cross-check each against Helios's served set. **Flag any method
+ Helios does NOT serve** (likely heavy `eth_getLogs` ranges for historical UTXO scan — note
+ `10-kohaku-shield.md` says Subsquid carries history, so confirm whether Railgun hits Helios
+ for logs at all, or only Subsquid).
+5. **Decide + measure.** Does the v1 localhost path work unmodified? If a method is missing or
+ the trait doesn't accept an alloy HTTP provider, write the minimal adapter and note it's
+ required for v1 (not just prod). Measure the per-call overhead of the loopback hop vs a
+ direct typed `HeliosApi` call (rough is fine).
+
+## Constraints
+
+- Standalone crate at `spikes/eip1193-railgun/` with its own `[workspace]`; `.gitignore`
+ `target/` + `Cargo.lock`. Do **not** edit anything under `crates/` or the app.
+- Read-API only — no signing, no broadcasting, no real funds. Reads/sync only.
+- If the Railgun crate can't be driven standalone from Rust (alpha API), say so plainly and
+ fall back to: stand up the Helios localhost server, point a generic alloy provider at it,
+ and prove the *provider* works for the eth_* methods Railgun's docs say it uses — i.e.
+ de-risk the seam even if the full RailgunBuilder path is blocked.
+
+## Success criteria (what "done" means)
+
+- A clear YES/NO on: **"Does Helios's localhost server satisfy Railgun's `IntoEip1193Provider`
+ and serve every method Railgun's read/sync path calls?"** with the exact trait + a method list.
+- A runnable spike that boots Helios + a verified localhost endpoint and drives at least one
+ Railgun (or generic alloy) read through it.
+- A short report: v1 works as-is? / adapter required (and why)? / methods missing (→ Subsquid)?
+ / loopback overhead. End with a recommendation for `20-helios-sidecar.md`'s "Integration"
+ section (v1 localhost vs forced-adapter).
+
+## Report format (return this)
+
+```
+
+ v1 localhost path: WORKS / NEEDS ADAPTER / BLOCKED
+
+
+
+
+
+
+
+
+```
diff --git a/spikes/eip1193-railgun/.gitignore b/spikes/eip1193-railgun/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/spikes/eip1193-railgun/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/spikes/eip1193-railgun/Cargo.toml b/spikes/eip1193-railgun/Cargo.toml
new file mode 100644
index 0000000..f012e9d
--- /dev/null
+++ b/spikes/eip1193-railgun/Cargo.toml
@@ -0,0 +1,64 @@
+# Deckard T-Trustless #3 spike — prove Helios's localhost JSON-RPC server can be
+# the EIP-1193 provider Kohaku's `railgun` crate reads through, so the shield path
+# gets *verified* chain reads instead of trusting a raw vendor RPC.
+#
+# Standalone crate (empty [workspace] table) so it can pull BOTH the heavy Helios
+# tree (revm, bls12_381) AND — under the `railgun` feature — Kohaku's ZK railgun
+# tree (ark-circom, wasmer) without bloating the app workspace at the repo root.
+[package]
+name = "eip1193-railgun"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+# Standalone — keep this spike's deps out of the repo-root workspace.
+[workspace]
+
+[[bin]]
+name = "eip1193-railgun"
+path = "src/main.rs"
+
+[features]
+default = []
+# Tier-1: ALSO link Kohaku's full `railgun` crate and drive the real
+# RailgunBuilder + RpcSyncer + SmartWalletUtxoVerifier through Helios. Off by
+# default so the crate always builds in the light Tier-2 mode (Kohaku's real
+# eip-1193-provider trait + alloy adapter, no ark-circom/wasmer compile).
+railgun = ["dep:railgun"]
+
+[dependencies]
+# Helios — depend on `helios-ethereum` directly (NOT the umbrella `helios` crate,
+# which pulls helios-opstack → libp2p → a yanked core2). Git-only, tag "0.11.1"
+# (no `v`; crates.io is stale at 0.1.0). This is the crate that ships the
+# localhost JSON-RPC server (`EthereumClientBuilder::rpc_address`).
+helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1" }
+
+# Kohaku's REAL EIP-1193 provider crate — the `Eip1193Provider`/`IntoEip1193Provider`
+# trait + the `Alloy`/`DynProvider` adapter Railgun reads through. Pinned to the
+# exact commit `railgun` is pinned to. default-features=false drops the `js`
+# (wasm-bindgen) feature; keep only `alloy` (the native adapter).
+eip-1193-provider = { git = "https://github.com/ethereum/kohaku", package = "eip-1193-provider", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7", default-features = false, features = ["alloy"] }
+
+# Tier-1 (optional): the full Railgun client. Same git rev so the [patch]es resolve.
+railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7", optional = true }
+
+# Match Kohaku + Helios's alloy: both already resolve to alloy 1.8.3 /
+# alloy-primitives 1.6.0 independently, so the whole tree unifies to ONE alloy.
+alloy = { version = "1.8", features = ["eips", "rpc-types", "network", "providers", "provider-http", "sol-types"] }
+
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync"] }
+reqwest = { version = "0.12", features = ["json"] }
+eyre = "0.6"
+serde_json = "1"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+# [patch] does NOT inherit through a git dependency, so we mirror every patch the
+# upstream workspaces apply, or their consensus/ZK crates fail to build:
+# * ethereum_hashing → Helios's workspace patch (consensus crates).
+# * ruint, ark-circom → Kohaku's workspace patches (only used under `railgun`;
+# harmless "unused patch" warning in the default Tier-2 build).
+[patch.crates-io]
+ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" }
+ruint = { git = "https://github.com/Robert-MacWha/ruint" }
+ark-circom = { git = "https://github.com/Robert-MacWha/circom-compat", branch = "release/0.6.0" }
diff --git a/spikes/eip1193-railgun/README.md b/spikes/eip1193-railgun/README.md
new file mode 100644
index 0000000..6d7e0f2
--- /dev/null
+++ b/spikes/eip1193-railgun/README.md
@@ -0,0 +1,97 @@
+# eip1193-railgun — Deckard T-Trustless #3 spike
+
+**Question:** can an embedded **Helios** light client be the EIP-1193 provider
+Kohaku's `railgun` crate reads through — so Deckard's shield path gets **verified**
+chain reads instead of trusting a raw vendor RPC?
+
+**Answer: YES — the v1 localhost path works, with one one-line provider fix.**
+
+This spike boots a verified Helios mainnet light client with its **localhost
+JSON-RPC server**, points an alloy provider at it, wraps that provider through
+**Kohaku's real `IntoEip1193Provider` adapter**, and drives every method Railgun's
+read/sync path calls THROUGH Helios — then (Tier-1) links the full `railgun` crate
+and drives its real `RpcSyncer`/`RailgunBuilder` against the live mainnet RAILGUN
+smart wallet through Helios.
+
+## The crux, settled against source (`ethereum/kohaku @ 618c53f`)
+
+- `RailgunBuilder::new(chain: ChainConfig, provider: impl IntoEip1193Provider)`.
+ **`IntoEip1193Provider` is Kohaku's OWN trait** (in its `eip-1193-provider`
+ crate), **not** alloy's. `Eip1193Provider` is a narrow **7-method typed trait**
+ (`get_chain_id`, `get_block_number`, `logs`, `eth_call`, `estimate_gas`,
+ `gas_price`, `transaction_count`) — **not** a generic `request(method, params)`.
+- Kohaku **ships an alloy adapter**: `impl IntoEip1193Provider for DynProvider`
+ wraps any alloy provider as `Arc`. So
+ `ProviderBuilder::new().connect(url).erased()` (a `DynProvider`) satisfies the
+ trait **with no custom adapter** — confirmed by upstream's own `sync_utxo.rs` test.
+- The **read/sync/balance** path touches the provider in exactly 3 places:
+ `RpcSyncer.latest_block` → **eth_blockNumber**, `RpcSyncer.events` →
+ **eth_getLogs** (tail range only; Subsquid carries history), and
+ `SmartWalletUtxoVerifier.verify_root` → **eth_call**. `balance()`/`register()`
+ are local (no RPC). All 7 trait methods — and all 3 read-path methods — are in
+ Helios 0.11.1's served set (`core/src/jsonrpc/mod.rs`).
+
+## The one required fix (the spike's real finding)
+
+alloy's `Provider::call` **defaults to the `pending` block tag**
+(`alloy-provider 1.8.3` `trait.rs:198`). Kohaku's `Alloy::eth_call` adapter calls
+`inner.call(req)` with no block override, so it sends `eth_call(…, "pending")`.
+Helios is a light client with **no pending block** → `"block not found: pending"`.
+This rides the read/sync path (`verify_root` → eth_call), so v1 must pin eth_call
+to `latest`:
+
+```rust
+// Deckard-side, one line — makes Kohaku's UNMODIFIED adapter work against Helios:
+ProviderBuilder::new()
+ .with_default_block(BlockId::latest()) // installs alloy's BlockIdLayer
+ .connect(helios_localhost_url).await?
+ .erased() // → DynProvider : IntoEip1193Provider
+```
+
+No Kohaku patch, no Helios patch. (The production `HeliosEip1193` adapter the
+sidecar doc plans would set `latest` itself; this is the v1 shortcut.)
+
+## Run
+
+```bash
+# Tier-2 (default, light): Helios localhost server + Kohaku's real eip-1193-provider
+# adapter; drives eth_chainId/blockNumber/getLogs/eth_call through Helios + logs
+# every JSON-RPC method via a pass-through proxy + measures the loopback hop.
+cargo run # warm if a cached checkpoint exists, else cold
+WIPE=1 cargo run # force a COLD start
+
+# Tier-1 (heavy): ALSO link Kohaku's full `railgun` ZK crate and drive the real
+# RailgunBuilder::build() + RpcSyncer (eth_blockNumber + eth_getLogs) through Helios.
+cargo run --features railgun
+```
+
+Exit 0 = PASS (every method the read path called is in Helios's served set).
+
+| env | meaning | default |
+|---|---|---|
+| `EL` | untrusted execution RPC (must serve `eth_getProof`) | `https://ethereum-rpc.publicnode.com` |
+| `CL` | beacon **light-client** API (200 ≠ syncs — see `20-helios-sidecar.md`) | `http://testing.mainnet.beacon-api.nimbus.team` |
+| `CHECKPOINT` | pinned weak-subjectivity root (`0x..` B256) | community fallback if unset |
+| `DATA_DIR` | FileDB dir (warm-start checkpoint cache) | `$TMPDIR/deckard-eip1193-railgun-spike` |
+| `WIPE` | force a COLD start | unset |
+| `WINDOW` | `eth_getLogs` window (#blocks back from head) | `2000` |
+
+Measured (M-series, mainnet, dev build): cold sync ≈12s, warm ≈3.5s; 323 RAILGUN
+events in a 2000-block window verified through Helios; loopback hop adds ≈1ms/call
+over the in-process typed `HeliosApi` call.
+
+## Why mainnet (not Sepolia)
+
+Helios is proven to sync only on **mainnet** public CLs (Nimbus/dRPC); `Network::Sepolia`
+has `consensus_rpc = None` (you must supply a Sepolia beacon LC endpoint). The RAILGUN
+smart wallet is live on mainnet (`ChainConfig::mainnet()` → `0xFA7093…`), so the seam
+is proven there. Upstream's shield **integration** tests fork **Sepolia** — that R1
+shield→unshield test (the `10-kohaku-shield.md` job) is separate and needs a Sepolia LC
+endpoint; this spike de-risks only the read/provider seam.
+
+## Files
+
+- `src/helios.rs` — build Helios with `.rpc_address()` localhost server; sync→servable.
+- `src/proxy.rs` — method-logging HTTP pass-through (the Task-4 enumeration tap).
+- `src/main.rs` — Tier-2: wire the adapter, drive the 3 reads, measure the hop.
+- `src/railgun_tier1.rs` — Tier-1 (`--features railgun`): real `RailgunBuilder` + `RpcSyncer`.
diff --git a/spikes/eip1193-railgun/src/helios.rs b/spikes/eip1193-railgun/src/helios.rs
new file mode 100644
index 0000000..b19384b
--- /dev/null
+++ b/spikes/eip1193-railgun/src/helios.rs
@@ -0,0 +1,114 @@
+//! Stand up a **verified** Helios light client whose localhost JSON-RPC server
+//! is the endpoint Railgun (or a generic alloy provider) reads through.
+//!
+//! Verified against `a16z/helios @ 0.11.1` source:
+//! * `EthereumClientBuilder::rpc_address(SocketAddr)` records a bind addr;
+//! * on `.build()`, `HeliosClient::new` does `tokio::spawn(jsonrpc::start(inner,
+//! addr))` (held alive by a `pending()`), serving the `eth_*` subset in
+//! `core/src/jsonrpc/mod.rs` at `http://` — every read proof-checked.
+//! * `.build()` is sync but MUST run inside a tokio runtime (it spawns the
+//! server task). We are, via `#[tokio::main]`.
+//!
+//! `wait_synced()` ≠ ready: after it returns, the first execution head lands
+//! ~1 slot later (≤12s); until then every `Latest` read fails the 60s
+//! `check_head_age` gate. So we poll `get_block_number()` until `Ok`.
+
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::time::{Duration, Instant};
+
+use alloy::primitives::{B256, U256};
+use eyre::{eyre, Result};
+use helios_ethereum::config::networks::Network;
+use helios_ethereum::database::FileDB;
+use helios_ethereum::{EthereumClient, EthereumClientBuilder};
+use tracing::info;
+
+/// Build a verified Helios **mainnet** client whose localhost JSON-RPC server is
+/// bound at `rpc_addr`. FileDB → warm starts from a cached checkpoint.
+pub fn build_with_server(
+ cl: &str,
+ el: &str,
+ checkpoint: Option,
+ data_dir: PathBuf,
+ rpc_addr: SocketAddr,
+) -> Result {
+ let b = EthereumClientBuilder::::new()
+ .network(Network::Mainnet)
+ .consensus_rpc(cl)?
+ .execution_rpc(el)?
+ .data_dir(data_dir)
+ // strict: refuse a too-old checkpoint (hard failure, never a silent stale read).
+ .strict_checkpoint_age()
+ // THE mechanism the whole v1 path rests on: spawn the localhost JSON-RPC
+ // server on build() so an alloy HTTP provider can read through it.
+ .rpc_address(rpc_addr);
+ let b = match checkpoint {
+ Some(cp) => b.checkpoint(cp),
+ // No user-pinned checkpoint → community fallback (ethPandaOps). Honest
+ // spike default; in Deckard this read path is labeled Degraded.
+ None => b.load_external_fallback(),
+ };
+ b.with_file_db().build()
+}
+
+/// Block until the client serves a fresh verified head (the honest "ready to
+/// serve verified reads" moment — see module docs on why `wait_synced` isn't it).
+pub async fn wait_until_serving(
+ client: &EthereumClient,
+ timeout: Duration,
+) -> Result {
+ let deadline = Instant::now() + timeout;
+ loop {
+ match client.get_block_number().await {
+ Ok(h) => return Ok(h),
+ Err(e) => {
+ if Instant::now() > deadline {
+ return Err(eyre!("no fresh head within {timeout:?}: {e}"));
+ }
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ }
+ }
+ }
+}
+
+/// Poll the localhost JSON-RPC **server** (not the typed client) over HTTP until
+/// it answers `eth_chainId` — proving the spawned `jsonrpc::start` task has bound
+/// and is serving verified reads at `url`.
+pub async fn wait_server_live(url: &str, timeout: Duration) -> Result {
+ let http = reqwest::Client::new();
+ let deadline = Instant::now() + timeout;
+ let req = serde_json::json!({"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]});
+ loop {
+ let got = async {
+ let resp = http.post(url).json(&req).send().await.ok()?;
+ let v: serde_json::Value = resp.json().await.ok()?;
+ let hex = v.get("result")?.as_str()?;
+ u64::from_str_radix(hex.trim_start_matches("0x"), 16).ok()
+ }
+ .await;
+ if let Some(chain_id) = got {
+ return Ok(chain_id);
+ }
+ if Instant::now() > deadline {
+ return Err(eyre!("Helios localhost server at {url} never answered eth_chainId"));
+ }
+ tokio::time::sleep(Duration::from_millis(250)).await;
+ }
+}
+
+/// Grab a free loopback port by binding an ephemeral socket and dropping it.
+/// (Helios discards the jsonrpsee `ServerHandle`, so we can't recover a `:0`
+/// port after the fact — pick a concrete one up front. Small TOCTOU window,
+/// fine for a spike.)
+pub fn free_loopback_port() -> Result {
+ let l = std::net::TcpListener::bind("127.0.0.1:0")?;
+ let port = l.local_addr()?.port();
+ drop(l);
+ Ok(port)
+}
+
+#[allow(dead_code)]
+pub fn log_ready(head: U256, url: &str) {
+ info!(head = %head, server = %url, "Helios verified + localhost server live");
+}
diff --git a/spikes/eip1193-railgun/src/main.rs b/spikes/eip1193-railgun/src/main.rs
new file mode 100644
index 0000000..9623aff
--- /dev/null
+++ b/spikes/eip1193-railgun/src/main.rs
@@ -0,0 +1,275 @@
+//! Deckard T-Trustless #3 spike — **Helios as Railgun's EIP-1193 provider.**
+//!
+//! Proves the v1 seam end to end, headless:
+//! 1. Boot a verified Helios mainnet light client whose **localhost JSON-RPC
+//! server** (`EthereumClientBuilder::rpc_address`) serves proof-checked reads.
+//! 2. Point an alloy HTTP provider at it, `.erased()` → `DynProvider`, and wrap
+//! it through Kohaku's **real** `IntoEip1193Provider` adapter
+//! (`eip-1193-provider` crate) — the exact trait `RailgunBuilder::new` takes.
+//! 3. Drive every method Railgun's read/sync path calls THROUGH Helios:
+//! get_block_number → eth_blockNumber (RpcSyncer.latest_block)
+//! logs → eth_getLogs (RpcSyncer.events, tail range)
+//! eth_call → eth_call (SmartWalletUtxoVerifier.verify_root)
+//! + get_chain_id (eth_chainId) for completeness.
+//! 4. A logging proxy in front of Helios records the actual JSON-RPC `method`s,
+//! cross-checked against Helios's served set.
+//! 5. Measure the loopback-hop overhead vs a direct typed `HeliosApi` call.
+//!
+//! With `--features railgun` it ALSO links Kohaku's full `railgun` crate and
+//! drives the real `RailgunBuilder` + `RpcSyncer` + `SmartWalletUtxoVerifier`
+//! against the live mainnet RAILGUN smart wallet through Helios (Tier-1).
+//!
+//! Read-only: no signing, no broadcasting, no funds.
+
+mod helios;
+mod proxy;
+#[cfg(feature = "railgun")]
+mod railgun_tier1;
+
+use std::path::PathBuf;
+use std::str::FromStr;
+use std::time::{Duration, Instant};
+
+use alloy::eips::BlockId;
+use alloy::primitives::{Address, B256, U256};
+use alloy::providers::{DynProvider, Provider, ProviderBuilder};
+use eip_1193_provider::provider::{Eip1193Caller, Eip1193Provider, IntoEip1193Provider};
+use eyre::{eyre, Result};
+use std::sync::Arc;
+use tracing::info;
+use tracing_subscriber::filter::{EnvFilter, LevelFilter};
+
+/// RAILGUN smart wallet on Ethereum mainnet (from Kohaku `ChainConfig::mainnet()`).
+const RAILGUN_SMART_WALLET_MAINNET: &str = "0xFA7093CDD9EE6932B4eb2c9e1cde7CE00B1FA4b9";
+
+// The one read Railgun's verifier makes through the provider: the `rootHistory`
+// getter on the smart wallet (`SmartWalletUtxoVerifier::verify_root` → sol_call →
+// eth_call). Same signature as Kohaku's ABI.
+alloy::sol! {
+ function rootHistory(uint256 treeNumber, bytes32 root) external view returns (bool);
+}
+
+struct Cfg {
+ el: String,
+ cl: String,
+ checkpoint: Option,
+ data_dir: PathBuf,
+ wipe: bool,
+ /// eth_getLogs window (#blocks back from head) for the demo read.
+ window: u64,
+ railgun_wallet: Address,
+}
+
+fn cfg() -> Result {
+ let env = |k: &str| std::env::var(k).ok().filter(|s| !s.is_empty());
+ Ok(Cfg {
+ el: env("EL").unwrap_or_else(|| "https://ethereum-rpc.publicnode.com".into()),
+ cl: env("CL").unwrap_or_else(|| "http://testing.mainnet.beacon-api.nimbus.team".into()),
+ checkpoint: match env("CHECKPOINT") {
+ Some(s) => Some(
+ B256::from_str(s.trim_start_matches("0x"))
+ .or_else(|_| B256::from_str(&s))
+ .map_err(|e| eyre!("bad CHECKPOINT: {e}"))?,
+ ),
+ None => None,
+ },
+ data_dir: env("DATA_DIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| std::env::temp_dir().join("deckard-eip1193-railgun-spike")),
+ wipe: env("WIPE").is_some(),
+ window: env("WINDOW").and_then(|s| s.parse().ok()).unwrap_or(2000),
+ railgun_wallet: Address::from_str(RAILGUN_SMART_WALLET_MAINNET)?,
+ })
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ EnvFilter::builder()
+ .with_default_directive(LevelFilter::INFO.into())
+ .from_env_lossy(),
+ )
+ .init();
+
+ let cfg = cfg()?;
+ if cfg.wipe {
+ let _ = std::fs::remove_dir_all(&cfg.data_dir);
+ info!("WIPE=1 → cleared {}", cfg.data_dir.display());
+ }
+
+ // ── Step 1: boot a verified Helios mainnet client + its localhost server ──
+ let helios_port = helios::free_loopback_port()?;
+ let helios_addr = format!("127.0.0.1:{helios_port}");
+ let helios_url = format!("http://{helios_addr}");
+ info!(server = %helios_url, el = %redact(&cfg.el), cl = %cfg.cl, "building Helios with localhost JSON-RPC server");
+
+ let t = Instant::now();
+ let client = helios::build_with_server(
+ &cfg.cl,
+ &cfg.el,
+ cfg.checkpoint,
+ cfg.data_dir.clone(),
+ helios_addr.parse()?,
+ )?;
+ client.wait_synced().await?;
+ let head = helios::wait_until_serving(&client, Duration::from_secs(60)).await?;
+ let sync_secs = t.elapsed().as_secs_f64();
+ let chain_id = helios::wait_server_live(&helios_url, Duration::from_secs(30)).await?;
+ info!(sync_secs = format!("{sync_secs:.1}"), head = %head, chain_id, "Helios verified + localhost server live");
+
+ // ── Step 2: logging proxy in front of Helios's server (Task 4 tap) ───────
+ let proxy_port = helios::free_loopback_port()?;
+ let proxy_addr = format!("127.0.0.1:{proxy_port}");
+ let proxy_url = format!("http://{proxy_addr}");
+ let method_log = proxy::spawn(&proxy_addr, helios_url.clone()).await?;
+ info!(proxy = %proxy_url, "→ Helios; logging every JSON-RPC method");
+
+ // ── Step 3: alloy provider → Kohaku's REAL IntoEip1193Provider adapter ───
+ // This is the exact v1 wiring `RailgunBuilder::new(chain, provider)` accepts:
+ // `ProviderBuilder::new().connect(url).erased()` is a `DynProvider`, and
+ // `impl IntoEip1193Provider for DynProvider` ships in the eip-1193-provider crate.
+ //
+ // ⚠ THE ONE REQUIRED v1 FIX (spike finding): alloy's `Provider::call` defaults
+ // to the `pending` block tag (alloy-provider 1.8.3 trait.rs:198), and Kohaku's
+ // `Alloy::eth_call` adapter calls `inner.call(req)` with no block override. Helios
+ // is a light client with NO pending block → `eth_call(pending)` fails with
+ // "block not found: pending". Since the read/sync path's `verify_root` → eth_call
+ // rides this, v1 needs eth_call pinned to `latest`. `.with_default_block(latest)`
+ // installs alloy's `BlockIdLayer`, which rewrites the default block for
+ // eth_call/estimateGas/etc to `latest` — making Kohaku's UNMODIFIED adapter work
+ // against Helios. One line, Deckard-side, no Kohaku/Helios patch.
+ let provider_proxied: DynProvider = ProviderBuilder::new()
+ .with_default_block(BlockId::latest())
+ .connect(&proxy_url)
+ .await?
+ .erased();
+ let eip: Arc = provider_proxied.into_eip1193();
+ info!("wrapped alloy DynProvider via Kohaku IntoEip1193Provider::into_eip1193()");
+
+ // ── Step 4: drive the 3 read-path methods THROUGH Helios via the adapter ─
+ // (a) eth_chainId
+ let got_chain = eip.get_chain_id().await.map_err(|e| eyre!("get_chain_id: {e}"))?;
+ // (b) eth_blockNumber — Railgun's RpcSyncer.latest_block
+ let got_head = eip.get_block_number().await.map_err(|e| eyre!("get_block_number: {e}"))?;
+ // (c) eth_getLogs — Railgun's RpcSyncer.events (tail range; Subsquid carries history)
+ let from = got_head.saturating_sub(cfg.window);
+ let logs = eip
+ .logs(cfg.railgun_wallet, None, Some(from), Some(got_head))
+ .await
+ .map_err(|e| eyre!("logs: {e}"))?;
+ // (d) eth_call — Railgun's SmartWalletUtxoVerifier.verify_root (rootHistory getter).
+ // ZERO root has never been "seen" → returns false. The point is the eth_call
+ // resolves THROUGH Helios and decodes to sane data.
+ let seen: bool = eip
+ .sol_call(cfg.railgun_wallet, rootHistoryCall { treeNumber: U256::ZERO, root: B256::ZERO })
+ .await
+ .map_err(|e| eyre!("eth_call rootHistory: {e}"))?;
+
+ info!(
+ chain_id = got_chain,
+ head = %got_head,
+ logs_in_window = logs.len(),
+ window = cfg.window,
+ root_history_zero = seen,
+ "STEP 4 — all read-path methods resolved through Helios"
+ );
+
+ // ── Step 5: loopback-hop overhead (Task 5) ───────────────────────────────
+ // direct = typed in-process HeliosApi call (0 hops, served from CL cache)
+ // loopback= alloy provider → Helios localhost server (1 hop), no proxy in path
+ let provider_direct: DynProvider = ProviderBuilder::new()
+ .with_default_block(BlockId::latest())
+ .connect(&helios_url)
+ .await?
+ .erased();
+ let eip_direct: Arc = provider_direct.into_eip1193();
+ let (direct_us, loop_us) = measure_overhead(&client, &eip_direct, 25).await?;
+
+ // ── Step 6 (Tier-1, --features railgun): real RailgunBuilder path ────────
+ #[cfg(feature = "railgun")]
+ let tier1 = railgun_tier1::run(&proxy_url, got_head, cfg.window, &method_log).await?;
+
+ // ── Report ────────────────────────────────────────────────────────────────
+ let served = method_log.snapshot();
+ println!("\n──────────── Deckard T-Trustless #3 — Helios⇄Railgun EIP-1193 seam ────────────");
+ println!(" Helios sync (build→servable) : {sync_secs:.1}s chain_id={chain_id} head={head}");
+ println!(" v1 wiring : alloy DynProvider → Kohaku IntoEip1193Provider::into_eip1193() ✅ (no custom adapter)");
+ println!(" required v1 fix : ProviderBuilder::with_default_block(latest) — alloy's call() defaults to `pending`,");
+ println!(" which Helios (light client) can't serve; the BlockIdLayer pins eth_call→latest. 1 line, Deckard-side.");
+ println!(" reads through Helios : eth_chainId={got_chain} eth_blockNumber={got_head}");
+ println!(" eth_getLogs([head-{}, head] on RAILGUN wallet) → {} logs", cfg.window, logs.len());
+ println!(" eth_call rootHistory(0,0x00..) → {seen}");
+ println!(" loopback-hop overhead : direct(typed)≈{direct_us}µs/call loopback(alloy→Helios)≈{loop_us}µs/call Δ≈{}µs", loop_us.saturating_sub(direct_us));
+ println!(" JSON-RPC methods seen (proxy):");
+ for (m, c) in &served {
+ let served_by_helios = HELIOS_SERVED.contains(&m.as_str());
+ println!(" {:<26} ×{:<4} {}", m, c, if served_by_helios { "served-by-Helios ✅" } else { "⚠ NOT in Helios served set" });
+ }
+ #[cfg(feature = "railgun")]
+ {
+ println!(" Tier-1 (real railgun crate) : {}", tier1.summary);
+ }
+ #[cfg(not(feature = "railgun"))]
+ {
+ println!(" Tier-1 (real railgun crate) : not linked (run with --features railgun)");
+ }
+ let all_served = served.iter().all(|(m, _)| HELIOS_SERVED.contains(&m.as_str()));
+ println!(" VERDICT : {}", if all_served {
+ "PASS ✅ — v1 localhost path WORKS: Helios serves every method the read path called"
+ } else {
+ "⚠ a called method is NOT in Helios's served set — see flags above"
+ });
+ println!("────────────────────────────────────────────────────────────────────────────────\n");
+
+ if all_served { Ok(()) } else { Err(eyre!("a called method is not served by Helios")) }
+}
+
+/// Methods Helios 0.11.1's localhost server serves (eth namespace, from
+/// `core/src/jsonrpc/mod.rs`). Used to flag any method the read path calls that
+/// Helios does NOT serve.
+const HELIOS_SERVED: &[&str] = &[
+ "eth_chainId", "eth_blockNumber", "eth_getLogs", "eth_call", "eth_estimateGas",
+ "eth_gasPrice", "eth_getTransactionCount", "eth_getBalance", "eth_getCode",
+ "eth_getProof", "eth_getStorageAt", "eth_getBlockByNumber", "eth_getBlockByHash",
+ "eth_getTransactionReceipt", "eth_getBlockReceipts", "eth_getTransactionByHash",
+ "eth_sendRawTransaction", "eth_maxPriorityFeePerGas", "eth_syncing",
+ "eth_createAccessList", "eth_newFilter", "eth_getFilterChanges", "eth_getFilterLogs",
+ "net_version", "web3_clientVersion",
+];
+
+/// Median per-call latency (µs) of a direct typed `HeliosApi` head read vs the
+/// same read over the alloy provider → Helios localhost loopback. Rough is fine.
+async fn measure_overhead(
+ client: &helios_ethereum::EthereumClient,
+ eip_direct: &Arc,
+ n: usize,
+) -> Result<(u128, u128)> {
+ let median = |mut v: Vec| {
+ v.sort_unstable();
+ v.get(v.len() / 2).copied().unwrap_or(0)
+ };
+ let mut direct = Vec::with_capacity(n);
+ let mut loopback = Vec::with_capacity(n);
+ for _ in 0..n {
+ let t = Instant::now();
+ let _ = client.get_block_number().await?;
+ direct.push(t.elapsed().as_micros());
+
+ let t = Instant::now();
+ let _ = eip_direct.get_block_number().await.map_err(|e| eyre!("loopback head: {e}"))?;
+ loopback.push(t.elapsed().as_micros());
+ }
+ Ok((median(direct), median(loopback)))
+}
+
+/// Hide API keys in logs (path segments after the host).
+fn redact(url: &str) -> String {
+ match url.split_once("://") {
+ Some((scheme, rest)) => {
+ let host = rest.split('/').next().unwrap_or(rest);
+ format!("{scheme}://{host}/…")
+ }
+ None => url.to_string(),
+ }
+}
diff --git a/spikes/eip1193-railgun/src/proxy.rs b/spikes/eip1193-railgun/src/proxy.rs
new file mode 100644
index 0000000..cef0999
--- /dev/null
+++ b/spikes/eip1193-railgun/src/proxy.rs
@@ -0,0 +1,197 @@
+//! A tiny HTTP/1.1 reverse proxy that **records every JSON-RPC `method`** it
+//! forwards, then passes the request through unchanged to Helios's localhost
+//! JSON-RPC server.
+//!
+//! This is Task 4 of the spike: instrument the seam so we can *empirically*
+//! enumerate exactly which methods the alloy provider (and, under `--features
+//! railgun`, the real Railgun read/sync path) invokes — and cross-check each
+//! against the set Helios's localhost server actually serves.
+//!
+//! Shape (same loopback hop v1 uses, plus a logging tap):
+//! alloy provider ──HTTP──▶ this proxy (logs `method`) ──HTTP──▶ Helios :H
+//!
+//! Adapted from `spikes/helios-walkaway/src/proxy.rs` (the killable/lying proxy),
+//! with the cut/lie machinery removed and method-logging added.
+
+use std::collections::BTreeMap;
+use std::sync::{Arc, Mutex};
+
+use eyre::{eyre, Result};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::{TcpListener, TcpStream};
+
+/// Shared record of the JSON-RPC methods seen, in first-seen order, with counts.
+#[derive(Clone, Default)]
+pub struct MethodLog {
+ inner: Arc>,
+}
+
+#[derive(Default)]
+struct Inner {
+ /// method -> call count
+ counts: BTreeMap,
+ /// methods in first-seen order
+ order: Vec,
+}
+
+impl MethodLog {
+ pub fn record(&self, method: &str) {
+ let mut g = self.inner.lock().unwrap();
+ if !g.counts.contains_key(method) {
+ g.order.push(method.to_string());
+ }
+ *g.counts.entry(method.to_string()).or_insert(0) += 1;
+ }
+
+ /// (method, count) pairs in first-seen order.
+ pub fn snapshot(&self) -> Vec<(String, u64)> {
+ let g = self.inner.lock().unwrap();
+ g.order
+ .iter()
+ .map(|m| (m.clone(), *g.counts.get(m).unwrap_or(&0)))
+ .collect()
+ }
+}
+
+/// Bind a logging proxy on `bind_addr`, forwarding JSON-RPC POSTs to `upstream`
+/// (Helios's localhost server). Returns the shared [`MethodLog`]. Returns once
+/// the listener is bound so the caller can immediately point a provider at it.
+pub async fn spawn(bind_addr: &str, upstream: String) -> Result {
+ let listener = TcpListener::bind(bind_addr)
+ .await
+ .map_err(|e| eyre!("proxy bind {bind_addr} failed: {e}"))?;
+ let log = MethodLog::default();
+
+ let http = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(60))
+ .build()?;
+
+ let log_for_task = log.clone();
+ tokio::spawn(async move {
+ loop {
+ match listener.accept().await {
+ Ok((sock, _peer)) => {
+ let http = http.clone();
+ let upstream = upstream.clone();
+ let log = log_for_task.clone();
+ tokio::spawn(async move {
+ let _ = handle_conn(sock, http, upstream, log).await;
+ });
+ }
+ Err(_) => break,
+ }
+ }
+ });
+
+ Ok(log)
+}
+
+async fn handle_conn(
+ mut sock: TcpStream,
+ http: reqwest::Client,
+ upstream: String,
+ log: MethodLog,
+) -> Result<()> {
+ loop {
+ let body = match read_request_body(&mut sock).await? {
+ Some(b) => b,
+ None => return Ok(()), // peer closed cleanly
+ };
+
+ // Record the method(s) — JSON-RPC requests are a single object or a batch array.
+ record_methods(&body, &log);
+
+ let resp = http
+ .post(&upstream)
+ .header("content-type", "application/json")
+ .body(body)
+ .send()
+ .await;
+
+ let out = match resp {
+ Ok(r) => r.bytes().await.unwrap_or_default().to_vec(),
+ Err(_) => {
+ let _ = sock.shutdown().await;
+ return Ok(());
+ }
+ };
+
+ let head = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n",
+ out.len()
+ );
+ sock.write_all(head.as_bytes()).await?;
+ sock.write_all(&out).await?;
+ sock.flush().await?;
+ }
+}
+
+/// Parse a JSON-RPC request body and record each `method` into the log.
+fn record_methods(body: &[u8], log: &MethodLog) {
+ let Ok(v) = serde_json::from_slice::(body) else {
+ return;
+ };
+ match v {
+ serde_json::Value::Array(batch) => {
+ for item in batch {
+ if let Some(m) = item.get("method").and_then(|m| m.as_str()) {
+ log.record(m);
+ }
+ }
+ }
+ obj => {
+ if let Some(m) = obj.get("method").and_then(|m| m.as_str()) {
+ log.record(m);
+ }
+ }
+ }
+}
+
+/// Minimal HTTP/1.1 request reader: read headers, parse `Content-Length`, read
+/// exactly that many body bytes. alloy's reqwest client always sends
+/// Content-Length JSON POSTs (never chunked), so this is sufficient. Returns
+/// `None` if the peer closed before sending anything.
+async fn read_request_body(sock: &mut TcpStream) -> Result>> {
+ let mut buf = Vec::with_capacity(2048);
+ let mut tmp = [0u8; 2048];
+
+ let header_end = loop {
+ if let Some(pos) = find_subsequence(&buf, b"\r\n\r\n") {
+ break pos + 4;
+ }
+ let n = sock.read(&mut tmp).await?;
+ if n == 0 {
+ return Ok(if buf.is_empty() { None } else { Some(Vec::new()) });
+ }
+ buf.extend_from_slice(&tmp[..n]);
+ };
+
+ let content_length = parse_content_length(&buf[..header_end]).unwrap_or(0);
+ while buf.len() < header_end + content_length {
+ let n = sock.read(&mut tmp).await?;
+ if n == 0 {
+ break;
+ }
+ buf.extend_from_slice(&tmp[..n]);
+ }
+
+ Ok(Some(
+ buf[header_end..(header_end + content_length).min(buf.len())].to_vec(),
+ ))
+}
+
+fn parse_content_length(header_bytes: &[u8]) -> Option {
+ let text = String::from_utf8_lossy(header_bytes);
+ for line in text.split("\r\n") {
+ if let Some((k, v)) = line.split_once(':') {
+ if k.trim().eq_ignore_ascii_case("content-length") {
+ return v.trim().parse::().ok();
+ }
+ }
+ }
+ None
+}
+
+fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option {
+ haystack.windows(needle.len()).position(|w| w == needle)
+}
diff --git a/spikes/eip1193-railgun/src/railgun_tier1.rs b/spikes/eip1193-railgun/src/railgun_tier1.rs
new file mode 100644
index 0000000..658b88b
--- /dev/null
+++ b/spikes/eip1193-railgun/src/railgun_tier1.rs
@@ -0,0 +1,79 @@
+//! Tier-1 (`--features railgun`): link Kohaku's **full `railgun` crate** and drive
+//! its real read path against the live mainnet RAILGUN smart wallet THROUGH Helios.
+//!
+//! Proves, beyond the Tier-2 adapter check:
+//! * `RailgunBuilder::new(ChainConfig::mainnet(), provider)` accepts the
+//! Helios-backed alloy `DynProvider` and `.build()` links + constructs a real
+//! `RailgunProvider` (the whole ZK crate compiles into our dependency edge).
+//! * Railgun's real `RpcSyncer` (`UtxoSyncer::sync`) drives `eth_blockNumber` +
+//! `eth_getLogs` through Helios over a bounded tail window — the exact code the
+//! production sync path runs (Subsquid carries history; only the tail hits Helios).
+//!
+//! Read-only: no register/full-sync (that would scan from the 2022 deployment
+//! block), no proving, no broadcast.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use alloy::eips::BlockId;
+use alloy::providers::{DynProvider, Provider, ProviderBuilder};
+use eyre::{eyre, Result};
+use railgun::builder::RailgunBuilder;
+use railgun::chain_config::ChainConfig;
+use railgun::indexer::syncer::{RpcSyncer, UtxoSyncer};
+use tracing::info;
+
+use crate::proxy::MethodLog;
+
+pub struct Tier1Out {
+ pub summary: String,
+}
+
+pub async fn run(proxy_url: &str, head: u64, window: u64, _log: &MethodLog) -> Result {
+ let chain = ChainConfig::mainnet();
+ let wallet = chain.railgun_smart_wallet;
+
+ // Fresh Helios-backed providers (DynProvider is Clone, but build fresh to keep
+ // each consumer independent). `.with_default_block(latest)` is the same v1 fix as
+ // Tier-2: it pins the adapter's eth_call (verify_root) to `latest` so it never
+ // hits Helios's absent `pending` block.
+ let p_build: DynProvider = ProviderBuilder::new()
+ .with_default_block(BlockId::latest())
+ .connect(proxy_url)
+ .await?
+ .erased();
+ let p_sync: DynProvider = ProviderBuilder::new()
+ .with_default_block(BlockId::latest())
+ .connect(proxy_url)
+ .await?
+ .erased();
+
+ // (1) Builder accepts the Helios-backed provider + the crate links. RPC-only
+ // syncer so build()/usage never depends on Subsquid. build() is network-free
+ // (MemoryDatabase), so this just proves construction + linkage.
+ let rpc_syncer_for_builder = Arc::new(RpcSyncer::new(chain.clone(), p_build.clone()));
+ let _railgun = RailgunBuilder::new(chain.clone(), p_build)
+ .with_utxo_syncer(rpc_syncer_for_builder)
+ .build()
+ .await
+ .map_err(|e| eyre!("RailgunBuilder::build: {e}"))?;
+ info!("Tier-1: RailgunBuilder::new(ChainConfig::mainnet(), ).build() OK");
+
+ // (2) Drive the REAL RpcSyncer over a bounded tail window → eth_blockNumber
+ // (latest_block) + eth_getLogs (events) through Helios. One getLogs call.
+ let syncer = RpcSyncer::new(chain.clone(), p_sync)
+ .with_batch_size(window.max(1))
+ .with_batch_delay(Duration::ZERO);
+ let from = head.saturating_sub(window);
+ let events = UtxoSyncer::sync(&syncer, from, head)
+ .await
+ .map_err(|e| eyre!("RpcSyncer::sync: {e}"))?;
+ info!(from, to = head, events = events.len(), "Tier-1: real RpcSyncer drove eth_getLogs through Helios");
+
+ Ok(Tier1Out {
+ summary: format!(
+ "RailgunBuilder::build() OK; real RpcSyncer synced [{from},{head}] on {wallet} → {} SyncEvents via Helios",
+ events.len()
+ ),
+ })
+}
diff --git a/spikes/helios-walkaway/.gitignore b/spikes/helios-walkaway/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/spikes/helios-walkaway/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/spikes/helios-walkaway/Cargo.toml b/spikes/helios-walkaway/Cargo.toml
new file mode 100644
index 0000000..10fb6ea
--- /dev/null
+++ b/spikes/helios-walkaway/Cargo.toml
@@ -0,0 +1,43 @@
+# Deckard R2 spike — embed Helios, serve a verified mainnet balance, prove the
+# "cut the centralized EL RPC on camera → keep serving verified reads" walkaway beat.
+#
+# Standalone crate (NOT a member of deck's package) so it can pull the heavy
+# Helios dependency tree (revm, bls12_381, alloy 1.0.37) without bloating the app.
+[package]
+name = "helios-walkaway"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+# Standalone — the parent dir (deck) is a [package], not a [workspace], so an empty
+# [workspace] table here keeps this spike's heavy Helios deps out of deck's build.
+[workspace]
+
+[[bin]]
+name = "helios-walkaway"
+path = "src/main.rs"
+
+[dependencies]
+# Depend on `helios-ethereum` directly — NOT the umbrella `helios` crate. The
+# umbrella pulls in `helios-opstack` → libp2p → a YANKED `core2 0.4.0`, which fails
+# to resolve today; ethereum-only avoids opstack/linea/libp2p entirely (smaller tree,
+# no p2p stack). Git-only at 0.11.1 (crates.io `helios-ethereum` is stale at 0.1.0,
+# Oct 2024). Tag is "0.11.1" — NOT "v0.11.1".
+helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1" }
+
+# Match Helios's alloy pin exactly so Address/B256/U256/BlockId unify to ONE
+# alloy-primitives in the tree (no duplicate-types mismatch at the builder boundary).
+alloy = { version = "1.0.37", features = ["eips", "rpc-types"] }
+
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync"] }
+reqwest = { version = "0.12", features = ["json"] }
+eyre = "0.6"
+serde_json = "1"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+# Helios's own workspace patches ethereum_hashing to ncitron's fork. `[patch]` does
+# NOT inherit through a git dependency, so we mirror it here or the consensus crates
+# fail to build. (Verified: this exact patch is in a16z/helios root Cargo.toml.)
+[patch.crates-io]
+ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" }
diff --git a/spikes/helios-walkaway/README.md b/spikes/helios-walkaway/README.md
new file mode 100644
index 0000000..c727005
--- /dev/null
+++ b/spikes/helios-walkaway/README.md
@@ -0,0 +1,78 @@
+# helios-walkaway — Deckard R2 spike
+
+Proves the **walkaway beat** on Ethereum **mainnet**, headless: embed Helios as a
+library, sync a verified light client, serve a verified `eth_getBalance`, then
+**cut the centralized EL RPC on camera and keep serving verified reads** by failing
+over to an independent second EL. The head never freezes (it's consensus-driven);
+only state reads drop and recover.
+
+It also **measures** the two numbers the demo timing hinges on: cold vs warm sync,
+and cut→failover latency.
+
+## Why this is honest
+
+Verified against `a16z/helios @ 0.11.1` source (not docs):
+
+- Helios has **no native multi-EL/CL failover** — one `EthereumClient` = one EL +
+ one CL. The failover here is **Deckard's own supervisor** (`src/upstreams.rs`).
+- The consensus client pushes each sync-committee-verified execution header into
+ the execution provider's cache, so **`get_block_number`/head is EL-independent**
+ (served from cache). Only `get_balance`→`eth_getProof` hits the EL. That's *why*
+ cutting EL1 leaves the head live and a second already-synced client recovers
+ state reads instantly — both clients re-verify against the same CL + checkpoint,
+ so failover is honest re-derivation, never a cached stale value.
+
+## Run
+
+Defaults are the two keyless, no-log public ELs (`publicnode` proxied/cuttable +
+`drpc` failover) and the Nimbus light-client beacon API — which is exactly the
+privacy-correct demo posture (no IP↔address-correlating vendor in the read path):
+
+```bash
+# Availability (cut-the-RPC): sync, cut the primary EL, keep serving verified reads
+cargo run --release # warm if a cached checkpoint exists, else cold
+WIPE=1 cargo run --release # force a COLD start (wipe cached checkpoint)
+
+# Integrity (the moat): point Helios at a MALICIOUS RPC, watch it refuse the lie
+SCENARIO=lie WIPE=1 cargo run --release
+# malicious RPC claims 1,000,000,000 ETH → Deckard: REJECTED (invalid account proof)
+# (a centralized wallet would display the billion; Deckard verifies the proof)
+```
+
+Measured on this M-series desktop (mainnet, 2026-06-05): **cold ≈ 11s, warm ≈ 2s**,
+cut→failover ≈ one block (≈2–15s, gated by the per-block proof cache; the supervisor
+mechanism itself is ~250–500ms). Verified deposit-contract balance ≈ 86,313,877 ETH.
+
+> ⚠ **CL choice matters — 200 ≠ syncs.** `CL` must serve the `light_client/*` routes
+> **and** full `/eth/v2/beacon/blocks/{slot}` blocks whose `tree_hash_root` matches the
+> verified header. Verified against Helios sync (2026-06-05):
+> - ✅ **Nimbus** `http://testing.mainnet.beacon-api.nimbus.team` (~11 s) — HTTP, no-SLA team box
+> - ✅ **dRPC** `https://eth-beacon-chain.drpc.org` (~10.4 s) — HTTPS, keyless (the proven second CL)
+> - ❌ **Lodestar** `lodestar-mainnet.chainsafe.io` — 200 but head stuck at timestamp 0
+> - ❌ **PublicNode** `ethereum-beacon-api.publicnode.com` — 200 but `invalid sync committee period`
+
+> ⚠ **Key-restricted ELs don't work through the proxy.** An Alchemy/Infura key with
+> an origin/IP allowlist returns `-32600 "origin not on whitelist"` for proxied (and
+> off-allowlist) requests. Use keyless no-log providers, or a key allowed for your IP.
+
+Env:
+
+| var | meaning | default |
+|--------------|--------------------------------------------|---------|
+| `EL1` | primary EL (proxied + cuttable) | `https://ethereum-rpc.publicnode.com` |
+| `EL2` | independent failover EL | `https://eth.drpc.org` |
+| `CL` | beacon **light-client API** endpoint | `http://testing.mainnet.beacon-api.nimbus.team` |
+| `CHECKPOINT` | pinned weak-subjectivity root (`0x..` B256) | community fallback (ethPandaOps) if unset |
+| `DATA_DIR` | FileDB dir (warm-start cache lives here) | `$TMPDIR/deckard-helios-spike` |
+| `WIPE` | set to force a COLD start | unset |
+| `ADDR` | address to read | deposit contract |
+
+Exit code 0 = PASS (`ReadStatus` went `Verified → Degraded{failover}` and the
+post-cut balance is still verified).
+
+## Files
+
+- `read_status.rs` — `ReadStatus { Verified | Degraded | Unsynced }` (Deckard-owned).
+- `upstreams.rs` — the failover supervisor (Shape A).
+- `proxy.rs` — a killable HTTP/1.1 reverse proxy = the on-camera "cut".
+- `main.rs` — the scenario + measurements.
diff --git a/spikes/helios-walkaway/src/main.rs b/spikes/helios-walkaway/src/main.rs
new file mode 100644
index 0000000..c8807df
--- /dev/null
+++ b/spikes/helios-walkaway/src/main.rs
@@ -0,0 +1,324 @@
+//! Deckard R2 spike — the Helios walkaway beat, end to end on mainnet.
+//!
+//! What it proves, headless:
+//! 1. Embed Helios as a library, sync a verified mainnet client, serve a
+//! verified `get_balance` (the deposit contract by default).
+//! 2. WALKAWAY: cut the primary (centralized) EL RPC on camera and keep serving
+//! a *verified* balance by failing over to an independent second EL — the
+//! head never freezes (it's consensus-driven), only state reads fail and
+//! recover. ReadStatus transitions Verified → Degraded{failover}.
+//! 3. Measure: cold vs warm sync time, and cut→failover latency.
+//!
+//! Run (env-configurable):
+//! EL1= EL2=
+//! CL= [CHECKPOINT=<0x.. B256>] [WIPE=1 for cold]
+//! cargo run --release
+//!
+//! Defaults wire EL2/CL to verified-live public endpoints; pass EL1 (e.g. your
+//! Alchemy key) via env. See README.md.
+
+mod proxy;
+mod read_status;
+mod upstreams;
+
+use std::path::PathBuf;
+use std::str::FromStr;
+use std::time::{Duration, Instant};
+
+use alloy::eips::BlockNumberOrTag;
+use alloy::primitives::{utils::format_ether, Address, B256, U256};
+use eyre::{eyre, Result};
+use helios_ethereum::config::networks::Network;
+use helios_ethereum::database::FileDB;
+use helios_ethereum::{EthereumClient, EthereumClientBuilder};
+use tracing::info;
+use tracing_subscriber::filter::{EnvFilter, LevelFilter};
+
+use read_status::ReadStatus;
+use upstreams::{Upstream, Upstreams};
+
+struct Cfg {
+ el1: String,
+ el2: String,
+ cl: String,
+ checkpoint: Option,
+ data_dir: PathBuf,
+ addr: Address,
+ wipe: bool,
+ proxy_bind: String,
+}
+
+fn cfg() -> Result {
+ let env = |k: &str| std::env::var(k).ok().filter(|s| !s.is_empty());
+ Ok(Cfg {
+ // EL1 is proxied + cuttable. Pass your own (e.g. Alchemy) for the hero.
+ el1: env("EL1").unwrap_or_else(|| "https://ethereum-rpc.publicnode.com".into()),
+ // EL2 is the independent failover EL, wired straight through.
+ el2: env("EL2").unwrap_or_else(|| "https://eth.drpc.org".into()),
+ // Beacon light-client API. Verified to actually drive a Helios sync: Nimbus-testing
+ // (default) + dRPC (eth-beacon-chain.drpc.org). Lodestar/PublicNode return 200 but
+ // fail Helios sync — don't use them as CL.
+ cl: env("CL").unwrap_or_else(|| "http://testing.mainnet.beacon-api.nimbus.team".into()),
+ checkpoint: match env("CHECKPOINT") {
+ Some(s) => Some(B256::from_str(s.trim_start_matches("0x").trim_start_matches("0X"))
+ .or_else(|_| B256::from_str(&s))
+ .map_err(|e| eyre!("bad CHECKPOINT: {e}"))?),
+ None => None,
+ },
+ data_dir: env("DATA_DIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| std::env::temp_dir().join("deckard-helios-spike")),
+ // Deposit contract — a large, stable, well-known balance.
+ addr: Address::from_str(
+ &env("ADDR").unwrap_or_else(|| "0x00000000219ab540356cBB839Cbe05303d7705Fa".into()),
+ )?,
+ wipe: env("WIPE").is_some(),
+ proxy_bind: env("PROXY_BIND").unwrap_or_else(|| "127.0.0.1:18545".into()),
+ })
+}
+
+/// `wait_synced()` only blocks until the **consensus** checkpoint is bootstrapped;
+/// the latest **execution** head isn't pushed into the cache until the next
+/// optimistic update (~one slot, ≤12s). Until then `get_block_number` fails the 60s
+/// head-age gate. Poll until a fresh head is actually servable — this is the honest
+/// "ready to serve verified reads" moment.
+async fn wait_until_serving(client: &EthereumClient, label: &str, timeout: Duration) -> Result {
+ let deadline = Instant::now() + timeout;
+ loop {
+ match client.get_block_number().await {
+ Ok(h) => return Ok(h),
+ Err(e) => {
+ if Instant::now() > deadline {
+ return Err(eyre!("{label}: no fresh head within {timeout:?}: {e}"));
+ }
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ }
+ }
+ }
+}
+
+/// Build a verified Helios mainnet client (FileDB → warm starts via cached checkpoint).
+fn build_client(cl: &str, el: &str, checkpoint: Option, data_dir: PathBuf) -> Result {
+ let b = EthereumClientBuilder::::new()
+ .network(Network::Mainnet)
+ .consensus_rpc(cl)?
+ .execution_rpc(el)?
+ .data_dir(data_dir)
+ // strict: refuse a too-old checkpoint instead of warning → surfaces as a
+ // hard failure rather than a silent stale read (demo-honest).
+ .strict_checkpoint_age();
+ let b = match checkpoint {
+ Some(cp) => b.checkpoint(cp),
+ // No user-pinned checkpoint → community fallback (ethPandaOps). Honest spike
+ // default; in Deckard this read path is labeled Degraded (untrusted source).
+ None => b.load_external_fallback(),
+ };
+ b.with_file_db().build()
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ EnvFilter::builder()
+ .with_default_directive(LevelFilter::INFO.into())
+ .from_env_lossy(),
+ )
+ .init();
+
+ let cfg = cfg()?;
+
+ // SCENARIO=lie → the integrity demo (a malicious RPC, Deckard refuses the lie).
+ // default → the walkaway/availability demo (cut the RPC, keep serving verified).
+ if std::env::var("SCENARIO").ok().as_deref() == Some("lie") {
+ if cfg.wipe {
+ let _ = std::fs::remove_dir_all(&cfg.data_dir);
+ }
+ return scenario_lie(&cfg).await;
+ }
+
+ let primary_dir = cfg.data_dir.join("primary");
+ let secondary_dir = cfg.data_dir.join("secondary");
+
+ if cfg.wipe {
+ let _ = std::fs::remove_dir_all(&cfg.data_dir);
+ info!("WIPE=1 → cleared {} (forcing COLD start)", cfg.data_dir.display());
+ }
+ let warm = primary_dir.join("checkpoint").exists();
+ info!(start = if warm { "WARM (cached checkpoint present)" } else { "COLD (no cached checkpoint)" }, "");
+
+ // ── Step 0: stand up the killable proxy in front of EL1 ───────────────────
+ let proxied_el1 = format!("http://{}", cfg.proxy_bind);
+ let kill = proxy::spawn(&cfg.proxy_bind, cfg.el1.clone(), false).await?;
+ info!(primary_el = %redact(&cfg.el1), via = %proxied_el1, secondary_el = %redact(&cfg.el2), cl = %cfg.cl, "upstreams");
+
+ // ── Step 1: build + sync the primary (proxied EL1), measure sync time ─────
+ let t = Instant::now();
+ let primary = build_client(&cfg.cl, &proxied_el1, cfg.checkpoint, primary_dir.clone())?;
+ primary.wait_synced().await?;
+ let head0 = wait_until_serving(&primary, "primary", Duration::from_secs(45)).await?;
+ let sync_secs = t.elapsed().as_secs_f64(); // build → first verified head servable
+ info!(
+ sync_secs = format!("{sync_secs:.1}"),
+ kind = if warm { "warm" } else { "cold" },
+ head = %head0,
+ "primary ready (verified head servable)"
+ );
+
+ // Secondary on the independent EL2 (wired straight through, not proxied).
+ let secondary = build_client(&cfg.cl, &cfg.el2, cfg.checkpoint, secondary_dir.clone())?;
+ secondary.wait_synced().await?;
+ wait_until_serving(&secondary, "secondary", Duration::from_secs(45)).await?;
+ info!("secondary ready");
+
+ let sup = Upstreams::new(
+ vec![
+ Upstream { label: "EL1(primary)".into(), client: primary },
+ Upstream { label: "EL2(failover)".into(), client: secondary },
+ ],
+ Duration::from_secs(8),
+ );
+
+ // ── Step 2: verified read from the primary ────────────────────────────────
+ let r = sup.get_balance(cfg.addr).await;
+ let pre = r.value.ok_or_else(|| eyre!("no verified balance before cut"))?;
+ assert_eq!(r.status, ReadStatus::Verified, "expected Verified before the cut");
+ info!(
+ addr = %cfg.addr,
+ balance_eth = %format_ether(pre),
+ status = %r.status,
+ served_by = %r.served_by.clone().unwrap_or_default(),
+ "STEP 2 — verified balance (primary)"
+ );
+
+ // ── Step 3: WALKAWAY — cut EL1, keep serving verified reads via EL2 ───────
+ info!("STEP 3 — ✂️ CUTTING primary EL RPC (on camera)…");
+ let cut_at = Instant::now();
+ kill.cut();
+
+ // Head keeps advancing (consensus-driven, EL-independent): prove liveness.
+ // State reads fail on EL1 and recover on EL2.
+ let deadline = Instant::now() + Duration::from_secs(30);
+ let (failover_latency, post, final_status) = loop {
+ let r = sup.get_balance(cfg.addr).await;
+ match (&r.status, r.value) {
+ (ReadStatus::Degraded { .. }, Some(v)) => {
+ let latency = cut_at.elapsed();
+ info!(
+ failover_ms = latency.as_millis() as u64,
+ served_by = %r.served_by.clone().unwrap_or_default(),
+ status = %r.status,
+ balance_eth = %format_ether(v),
+ "STEP 3 — failover read is VERIFIED (recovered after the cut)"
+ );
+ break (latency, v, r.status);
+ }
+ (status, _) => {
+ info!(status = %status, "…primary down, failing over");
+ }
+ }
+ if Instant::now() > deadline {
+ return Err(eyre!("failover did not produce a verified read within 30s"));
+ }
+ tokio::time::sleep(Duration::from_millis(250)).await;
+ };
+
+ // Head still live after the cut — proves it's consensus-driven, not EL-derived.
+ // Read it from the PRIMARY (whose EL we just cut): get_block_number returns from
+ // the CL-pushed cache, so the dead-EL client still serves a fresh head.
+ let head1 = sup.head_of_primary().await.unwrap_or(head0);
+
+ // ── Summary ───────────────────────────────────────────────────────────────
+ println!("\n──────────────── Deckard R2 walkaway — RESULT ────────────────");
+ println!(" start mode : {}", if warm { "WARM (cached checkpoint)" } else { "COLD (fresh checkpoint)" });
+ println!(" primary sync time : {sync_secs:.1}s");
+ println!(" pre-cut balance : {} ETH [{}]", format_ether(pre), ReadStatus::Verified);
+ println!(" cut→failover latency: {} ms", failover_latency.as_millis());
+ println!(" post-cut balance : {} ETH [{}]", format_ether(post), final_status);
+ println!(" head at sync : {head0}");
+ println!(" primary head post-cut: {head1} ({} — the EL1-cut client still serves the head from CL cache)",
+ if head1 >= head0 { "EL-independent ✓" } else { "?" });
+ let drift = if post >= pre { post - pre } else { pre - post };
+ let ok = matches!(final_status, ReadStatus::Degraded { .. }) && post > U256::ZERO;
+ println!(" balance drift : {} wei (deposits between blocks; verified either way)", drift);
+ println!(" VERDICT : {}", if ok { "PASS ✅ cut the RPC, still serving verified reads" } else { "FAIL ❌" });
+ println!("──────────────────────────────────────────────────────────────\n");
+
+ if ok { Ok(()) } else { Err(eyre!("walkaway scenario failed")) }
+}
+
+/// The integrity demo: point Helios at a MALICIOUS RPC (a proxy that rewrites the
+/// balance in every `eth_getProof`) and show Deckard **refuses the lie** — because
+/// it verifies the proof against the CL-signed state root, it never returns the fake
+/// number. A centralized wallet would just display it. This is the real moat;
+/// "cut the cable" (the default scenario) is the availability sibling of this.
+async fn scenario_lie(cfg: &Cfg) -> Result<()> {
+ let dir = cfg.data_dir.join("liar");
+ let bind = "127.0.0.1:18547";
+ let lying_el = format!("http://{bind}");
+
+ // The proxy forwards to a REAL EL but tampers eth_getProof balances (lie = true).
+ let _kill = proxy::spawn(bind, cfg.el1.clone(), true).await?;
+ info!(real_el = %redact(&cfg.el1), via_lying_proxy = %lying_el, cl = %cfg.cl, "MALICIOUS-RPC scenario");
+
+ let client = build_client(&cfg.cl, &lying_el, cfg.checkpoint, dir)?;
+ client.wait_synced().await?;
+ let head = wait_until_serving(&client, "client", Duration::from_secs(45)).await?;
+ info!(head = %head, "synced + head servable THROUGH the lying RPC (head is CL-verified, not from the EL)");
+
+ // What the malicious RPC claims (ask it directly).
+ let claimed = raw_get_proof_balance(&lying_el, cfg.addr)
+ .await
+ .unwrap_or_else(|| "".into());
+
+ // What Deckard does: verify the proof → reject the tampered account.
+ // Tighten the assertion: only a PROOF rejection counts as a pass. An unrelated
+ // transport/sync error must NOT be mistaken for "caught the lie."
+ let (verdict, rejected) = match client.get_balance(cfg.addr, BlockNumberOrTag::Latest.into()).await {
+ Ok(v) => (format!("LEAKED {} ETH — verification FAILED to catch the lie!", format_ether(v)), false),
+ Err(e) => {
+ let msg = e.to_string().to_lowercase();
+ let is_proof_rejection = msg.contains("proof");
+ if is_proof_rejection {
+ (format!("REJECTED — {e}"), true)
+ } else {
+ (format!("errored for an UNRELATED reason (not a proof rejection): {e}"), false)
+ }
+ }
+ };
+
+ println!("\n──────────── Deckard: malicious-RPC detection (integrity) ────────────");
+ println!(" address : {}", cfg.addr);
+ println!(" malicious RPC claims : {claimed} ETH (balance rewritten in eth_getProof; proof left intact)");
+ println!(" Deckard get_balance : {verdict}");
+ println!(" a centralized wallet : would display {claimed} ETH (no proof to check — trusts the RPC)");
+ println!(" VERDICT : {}", if rejected { "PASS ✅ — Deckard refuses to be lied to" } else { "FAIL ❌" });
+ println!("──────────────────────────────────────────────────────────────────────\n");
+
+ if rejected { Ok(()) } else { Err(eyre!("Deckard did not reject the lie")) }
+}
+
+/// Ask an RPC directly for an address's balance via `eth_getProof` (used to show
+/// what the malicious proxy claims, for contrast with what Deckard accepts).
+async fn raw_get_proof_balance(url: &str, addr: Address) -> Option {
+ let req = serde_json::json!({
+ "jsonrpc": "2.0", "id": 1, "method": "eth_getProof",
+ "params": [addr.to_string(), [], "latest"],
+ });
+ let resp = reqwest::Client::new().post(url).json(&req).send().await.ok()?;
+ let v: serde_json::Value = resp.json().await.ok()?;
+ let hex = v.get("result")?.get("balance")?.as_str()?;
+ let wei = U256::from_str_radix(hex.trim_start_matches("0x"), 16).ok()?;
+ Some(format_ether(wei).to_string())
+}
+
+/// Hide API keys in logs (path segments after the host).
+fn redact(url: &str) -> String {
+ match url.split_once("://") {
+ Some((scheme, rest)) => {
+ let host = rest.split('/').next().unwrap_or(rest);
+ format!("{scheme}://{host}/…")
+ }
+ None => url.to_string(),
+ }
+}
diff --git a/spikes/helios-walkaway/src/proxy.rs b/spikes/helios-walkaway/src/proxy.rs
new file mode 100644
index 0000000..2967aa0
--- /dev/null
+++ b/spikes/helios-walkaway/src/proxy.rs
@@ -0,0 +1,199 @@
+//! A tiny, *killable* — and optionally *lying* — HTTP/1.1 reverse proxy.
+//!
+//! Two roles, both used to demonstrate what embedding Helios actually buys:
+//!
+//! 1. **Killable** ("cut the centralized RPC on camera"): Helios's primary EL is
+//! pointed at `http://127.0.0.1:` instead of straight at the upstream;
+//! flipping the kill switch makes every request to that port fail at the
+//! transport layer — a yanked cable / revoked key / firewall rule. (Availability.)
+//!
+//! 2. **Lying** (`lie = true`): the proxy tampers the `balance` field of every
+//! `eth_getProof` response before returning it. This is a malicious/compromised
+//! RPC. Helios rebuilds the account from that tampered balance, RLP-encodes it,
+//! and checks it against the Merkle proof under the CL-signed state root — the
+//! check **fails** (`InvalidAccountProof`) and the read is **refused**. A
+//! centralized wallet would just display the lie. (Integrity — the real moat.)
+
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+
+use eyre::{eyre, Result};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::{TcpListener, TcpStream};
+
+/// Flip `.cut` to true to sever the proxied endpoint.
+#[derive(Clone)]
+pub struct KillSwitch {
+ cut: Arc,
+}
+
+impl KillSwitch {
+ pub fn cut(&self) {
+ self.cut.store(true, Ordering::SeqCst);
+ }
+ #[allow(dead_code)]
+ pub fn is_cut(&self) -> bool {
+ self.cut.load(Ordering::SeqCst)
+ }
+}
+
+/// Bind a proxy on `bind_addr` forwarding JSON-RPC POSTs to `upstream`. If `lie`,
+/// it tampers the balance in every `eth_getProof` response (a malicious RPC).
+/// Returns once the listener is bound, so the caller can immediately build a Helios
+/// client against the local address.
+pub async fn spawn(bind_addr: &str, upstream: String, lie: bool) -> Result {
+ let listener = TcpListener::bind(bind_addr)
+ .await
+ .map_err(|e| eyre!("proxy bind {bind_addr} failed: {e}"))?;
+ let cut = Arc::new(AtomicBool::new(false));
+ let switch = KillSwitch { cut: cut.clone() };
+
+ let http = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(20))
+ .build()?;
+
+ tokio::spawn(async move {
+ loop {
+ match listener.accept().await {
+ Ok((sock, _peer)) => {
+ let cut = cut.clone();
+ let http = http.clone();
+ let upstream = upstream.clone();
+ tokio::spawn(async move {
+ let _ = handle_conn(sock, cut, http, upstream, lie).await;
+ });
+ }
+ Err(_) => break,
+ }
+ }
+ });
+
+ Ok(switch)
+}
+
+async fn handle_conn(
+ mut sock: TcpStream,
+ cut: Arc,
+ http: reqwest::Client,
+ upstream: String,
+ lie: bool,
+) -> Result<()> {
+ loop {
+ // Read one HTTP/1.1 request (headers + Content-Length body).
+ let req = match read_request(&mut sock).await? {
+ Some(r) => r,
+ None => return Ok(()), // peer closed cleanly
+ };
+
+ // THE CUT: once severed, drop every request at the transport layer.
+ if cut.load(Ordering::SeqCst) {
+ let _ = sock.shutdown().await;
+ return Ok(());
+ }
+
+ let is_get_proof = lie && find_subsequence(&req.body, b"eth_getProof").is_some();
+
+ let resp = http
+ .post(&upstream)
+ .header("content-type", "application/json")
+ .body(req.body)
+ .send()
+ .await;
+
+ let mut body = match resp {
+ Ok(r) => r.bytes().await.unwrap_or_default().to_vec(),
+ // Upstream itself erred — surface a transport close so the client fails over.
+ Err(_) => {
+ let _ = sock.shutdown().await;
+ return Ok(());
+ }
+ };
+
+ // THE LIE: tamper the proven balance. Helios will reject it.
+ if is_get_proof {
+ if let Some(tampered) = tamper_balance(&body) {
+ body = tampered;
+ }
+ }
+
+ let head = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n",
+ body.len()
+ );
+ sock.write_all(head.as_bytes()).await?;
+ sock.write_all(&body).await?;
+ sock.flush().await?;
+ }
+}
+
+struct ParsedRequest {
+ body: Vec,
+}
+
+/// Minimal HTTP/1.1 request reader: reads until the header terminator, parses
+/// `Content-Length`, then reads exactly that many body bytes. Helios's reqwest
+/// client always sends Content-Length JSON POSTs (never chunked), so this is
+/// sufficient. Returns `None` if the peer closed before sending anything.
+async fn read_request(sock: &mut TcpStream) -> Result> {
+ let mut buf = Vec::with_capacity(2048);
+ let mut tmp = [0u8; 2048];
+
+ // Read until we have the full header block.
+ let header_end = loop {
+ if let Some(pos) = find_subsequence(&buf, b"\r\n\r\n") {
+ break pos + 4;
+ }
+ let n = sock.read(&mut tmp).await?;
+ if n == 0 {
+ return Ok(if buf.is_empty() { None } else { Some(ParsedRequest { body: Vec::new() }) });
+ }
+ buf.extend_from_slice(&tmp[..n]);
+ };
+
+ let content_length = parse_content_length(&buf[..header_end]).unwrap_or(0);
+
+ // Read the remaining body bytes.
+ while buf.len() < header_end + content_length {
+ let n = sock.read(&mut tmp).await?;
+ if n == 0 {
+ break;
+ }
+ buf.extend_from_slice(&tmp[..n]);
+ }
+
+ let body = buf[header_end..(header_end + content_length).min(buf.len())].to_vec();
+ Ok(Some(ParsedRequest { body }))
+}
+
+fn parse_content_length(header_bytes: &[u8]) -> Option {
+ let text = String::from_utf8_lossy(header_bytes);
+ for line in text.split("\r\n") {
+ if let Some((k, v)) = line.split_once(':') {
+ if k.trim().eq_ignore_ascii_case("content-length") {
+ return v.trim().parse::().ok();
+ }
+ }
+ }
+ None
+}
+
+fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option {
+ haystack
+ .windows(needle.len())
+ .position(|w| w == needle)
+}
+
+/// The bogus balance a malicious RPC claims: 1,000,000,000 ETH (1e27 wei).
+pub const LIE_BALANCE_HEX: &str = "0x33b2e3c9fd0803ce8000000";
+
+/// Rewrite `result.balance` in an `eth_getProof` JSON response to [`LIE_BALANCE_HEX`],
+/// leaving the (real) Merkle proof untouched — so Helios's proof check will reject it.
+fn tamper_balance(body: &[u8]) -> Option> {
+ let mut v: serde_json::Value = serde_json::from_slice(body).ok()?;
+ let bal = v.get_mut("result")?.get_mut("balance")?;
+ if !bal.is_string() {
+ return None;
+ }
+ *bal = serde_json::Value::String(LIE_BALANCE_HEX.to_string());
+ serde_json::to_vec(&v).ok()
+}
diff --git a/spikes/helios-walkaway/src/read_status.rs b/spikes/helios-walkaway/src/read_status.rs
new file mode 100644
index 0000000..1397a89
--- /dev/null
+++ b/spikes/helios-walkaway/src/read_status.rs
@@ -0,0 +1,55 @@
+//! `ReadStatus` — Deckard-owned trust label attached to every chain read.
+//!
+//! This is the contract the UI and the MCP agent surface see. The hard rule:
+//! **never silently serve an untrusted read.** A read is either verified, or
+//! visibly degraded/unsynced — never quietly trusted.
+//!
+//! The three states map onto *observable* Helios behavior (verified against
+//! a16z/helios @ 0.11.1, `core/src/client/node.rs`):
+//!
+//! - `Verified` — `syncing()` returns `SyncStatus::None` (head age ≤ 60s, the
+//! hard `check_head_age` gate) and the read came back from the
+//! primary upstream.
+//! - `Degraded` — still cryptographically verified, but off the happy path:
+//! we failed over to a secondary EL, or we're on a community
+//! fallback checkpoint. Trust note shown.
+//! - `Unsynced` — cannot produce a verified read: sync not finished, head
+//! stale past the 60s gate (CL dark / "head frozen"), or all
+//! upstreams down. The UI shows a hard "NOT VERIFIED" state.
+
+use std::fmt;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ReadStatus {
+ /// Helios head fresh, reading from the primary upstream. Fully trustless.
+ Verified,
+ /// Still verified, but not on the primary path. `reason` is shown to the user.
+ Degraded { reason: String },
+ /// No verified read is possible right now. `reason` is shown to the user.
+ /// Deckard MUST NOT fall back to a raw untrusted RPC to fill this gap.
+ Unsynced { reason: String },
+}
+
+impl ReadStatus {
+ pub fn degraded(reason: impl Into) -> Self {
+ ReadStatus::Degraded { reason: reason.into() }
+ }
+ pub fn unsynced(reason: impl Into) -> Self {
+ ReadStatus::Unsynced { reason: reason.into() }
+ }
+ /// True only when a real, verified value backs the read. (Deckard-facing API.)
+ #[allow(dead_code)]
+ pub fn is_trustworthy(&self) -> bool {
+ matches!(self, ReadStatus::Verified | ReadStatus::Degraded { .. })
+ }
+}
+
+impl fmt::Display for ReadStatus {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ ReadStatus::Verified => write!(f, "VERIFIED"),
+ ReadStatus::Degraded { reason } => write!(f, "DEGRADED ({reason})"),
+ ReadStatus::Unsynced { reason } => write!(f, "NOT VERIFIED ({reason})"),
+ }
+ }
+}
diff --git a/spikes/helios-walkaway/src/upstreams.rs b/spikes/helios-walkaway/src/upstreams.rs
new file mode 100644
index 0000000..e0505c0
--- /dev/null
+++ b/spikes/helios-walkaway/src/upstreams.rs
@@ -0,0 +1,151 @@
+//! `Upstreams` — Deckard's own multi-instance supervisor over N Helios clients.
+//!
+//! Helios has **no native multi-EL failover**: one `EthereumClient` is wired to
+//! exactly one execution RPC and one consensus RPC (verified: `execution_rpc`/
+//! `consensus_rpc` on `EthereumClientBuilder` are single `Url`s). So the walkaway
+//! beat ("cut the centralized EL RPC and keep serving verified reads") is *our*
+//! logic, not Helios's.
+//!
+//! Design — **Shape A (multi-instance + supervisor)**, chosen because of how
+//! Helios is built (verified against `core/src/client/node.rs`):
+//!
+//! * The **consensus** client pushes each sync-committee-verified execution
+//! header into the execution provider's in-memory cache. So `get_block_number`
+//! / head is **CL-driven and served from cache — it does NOT touch the EL RPC.**
+//! * Only **state reads** (`get_balance` → `get_account` → `eth_getProof`) hit
+//! the EL RPC, then verify the returned account against the cached header's
+//! state root.
+//!
+//! Therefore cutting the EL leaves the head live and advancing while only state
+//! reads fail — and a *second, already-synced* client recovers them instantly by
+//! re-deriving the proof from an independent untrusted EL and re-verifying against
+//! the same CL+checkpoint. Both clients are equally trustless; failover is honest
+//! re-verification, not a cached stale value.
+
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::time::Duration;
+
+use alloy::eips::BlockNumberOrTag;
+use alloy::primitives::{Address, U256};
+use alloy::rpc::types::SyncStatus;
+use helios_ethereum::EthereumClient;
+
+use crate::read_status::ReadStatus;
+
+/// A labeled Helios client + the human name of the EL it talks to.
+pub struct Upstream {
+ pub label: String,
+ pub client: EthereumClient,
+}
+
+pub struct Upstreams {
+ upstreams: Vec,
+ active: AtomicUsize,
+ /// Per-read timeout. After this, a hung EL is treated as down and we fail over.
+ read_timeout: Duration,
+}
+
+/// What a supervised read returns: the value (if any), its trust label, and the
+/// upstream that actually served it.
+pub struct Read {
+ pub value: Option,
+ pub status: ReadStatus,
+ pub served_by: Option,
+}
+
+impl Upstreams {
+ pub fn new(upstreams: Vec, read_timeout: Duration) -> Self {
+ Self {
+ upstreams,
+ active: AtomicUsize::new(0),
+ read_timeout,
+ }
+ }
+
+ #[allow(dead_code)] // Deckard-facing API (which upstream is live), shown in UI.
+ pub fn active_label(&self) -> &str {
+ &self.upstreams[self.active.load(Ordering::SeqCst)].label
+ }
+
+ /// Read a balance with failover. Tries the active upstream first; on error or
+ /// timeout, walks the remaining upstreams. The first success wins and becomes
+ /// the new active upstream. If every upstream fails, classifies the outage as
+ /// `Unsynced` — and never returns an untrusted value.
+ pub async fn get_balance(&self, addr: Address) -> Read {
+ let n = self.upstreams.len();
+ let start = self.active.load(Ordering::SeqCst);
+ let block = BlockNumberOrTag::Latest.into();
+
+ for offset in 0..n {
+ let idx = (start + offset) % n;
+ let up = &self.upstreams[idx];
+
+ let attempt = tokio::time::timeout(self.read_timeout, up.client.get_balance(addr, block)).await;
+
+ match attempt {
+ Ok(Ok(value)) => {
+ self.active.store(idx, Ordering::SeqCst);
+ let status = if offset == 0 && idx == 0 {
+ ReadStatus::Verified
+ } else {
+ ReadStatus::degraded(format!("failover→{}", up.label))
+ };
+ return Read { value: Some(value), status, served_by: Some(up.label.clone()) };
+ }
+ Ok(Err(e)) => {
+ tracing::warn!(upstream = %up.label, error = %e, "read failed, trying next upstream");
+ }
+ Err(_) => {
+ tracing::warn!(upstream = %up.label, timeout_ms = self.read_timeout.as_millis() as u64, "read timed out, trying next upstream");
+ }
+ }
+ }
+
+ // Every EL upstream failed. Classify the outage honestly using the
+ // consensus-side observable: is the head itself stale (CL dark / frozen),
+ // or are just the ELs down?
+ let reason = self.classify_outage().await;
+ Read { value: None, status: ReadStatus::unsynced(reason), served_by: None }
+ }
+
+ /// Current verified head (block number), with failover — the daemon's UI head.
+ /// (The spike proves EL-independence with `head_of_primary` instead; this is the
+ /// general failover variant the real app would use.)
+ #[allow(dead_code)]
+ pub async fn head(&self) -> Option