diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6259b5f..7b327f5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,6 @@
name: CI
-# Builds the Deck on macOS *and* Linux so cross-platform stays honest.
+# Builds + tests the Deckard workspace on macOS *and* Linux so cross-platform stays honest.
# (GPUI renders with Metal on macOS and Vulkan via wgpu on Linux.)
#
# COST: free. Standard GitHub-hosted runners (macos-latest, ubuntu-latest) are
@@ -30,9 +30,11 @@ jobs:
# source of truth. rustup (preinstalled on GitHub runners) auto-installs the
# pinned version and its clippy/rustfmt components on the first cargo call.
- uses: Swatinem/rust-cache@v2
- - run: cargo build
- - run: cargo build --features tray
- - run: cargo clippy --all-targets --features tray -- -D warnings
+ - run: cargo build --workspace
+ - run: cargo build -p deckard-app --features tray
+ - run: cargo test --workspace
+ - run: cargo clippy --workspace --all-targets -- -D warnings
+ - run: cargo clippy -p deckard-app --all-targets --features tray -- -D warnings
linux:
runs-on: ubuntu-latest
@@ -56,6 +58,8 @@ jobs:
libfontconfig1-dev libfreetype6-dev \
libssl-dev \
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev
- - run: cargo build
- - run: cargo build --features tray
- - run: cargo clippy --all-targets --features tray -- -D warnings
+ - run: cargo build --workspace
+ - run: cargo build -p deckard-app --features tray
+ - run: cargo test --workspace
+ - run: cargo clippy --workspace --all-targets -- -D warnings
+ - run: cargo clippy -p deckard-app --all-targets --features tray -- -D warnings
diff --git a/Cargo.lock b/Cargo.lock
index 0179916..ebe8538 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -741,7 +741,7 @@ dependencies = [
"alloy-rlp",
"alloy-serde 1.8.3",
"alloy-sol-types",
- "itertools 0.13.0",
+ "itertools 0.14.0",
"serde",
"serde_json",
"serde_with",
@@ -762,7 +762,7 @@ dependencies = [
"alloy-rlp",
"alloy-serde 2.0.5",
"alloy-sol-types",
- "itertools 0.13.0",
+ "itertools 0.14.0",
"serde",
"serde_json",
"serde_with",
@@ -960,7 +960,7 @@ checksum = "e8597d36d546e1dab822345ad563243ec3920e199322cb554ce56c8ef1a1e2e7"
dependencies = [
"alloy-json-rpc 1.8.3",
"alloy-transport",
- "itertools 0.13.0",
+ "itertools 0.14.0",
"reqwest",
"serde_json",
"tower",
@@ -1783,7 +1783,7 @@ dependencies = [
"bitflags 2.12.1",
"cexpr",
"clang-sys",
- "itertools 0.11.0",
+ "itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
@@ -2261,6 +2261,33 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
[[package]]
name = "cipher"
version = "0.4.4"
@@ -2884,11 +2911,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
-name = "deckard"
+name = "deckard-app"
version = "0.1.0"
dependencies = [
"alloy-primitives",
"alloy-signer-local 2.0.5",
+ "deckard-contract",
"deckard-core",
"directories",
"gpui",
@@ -2905,6 +2933,16 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "deckard-contract"
+version = "0.1.0"
+dependencies = [
+ "alloy-primitives",
+ "ciborium",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "deckard-core"
version = "0.1.0"
@@ -3062,7 +3100,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3332,7 +3370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5958,7 +5996,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"thiserror 2.0.18",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6120,7 +6158,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7973,7 +8011,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -7986,7 +8024,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8053,7 +8091,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8729,7 +8767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8785,7 +8823,7 @@ dependencies = [
"cfg-if",
"libc",
"psm",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -9174,7 +9212,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -9659,7 +9697,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"thiserror 2.0.18",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -9744,7 +9782,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -10500,7 +10538,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 3456779..c28441d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,80 +1,25 @@
-[package]
-name = "deckard"
-version = "0.1.0"
-edition = "2021"
-description = "Deckard — a native, self-custodial Ethereum wallet for onchain operators (GPUI + Rust). Forked from the deck starter; now its own project."
-license = "AGPL-3.0-or-later"
-default-run = "deckard"
-
-# The GUI app is the workspace root; `deckard-core` is the headless engine (no GPUI).
+# Deckard — virtual Cargo workspace.
+#
+# The root carries NO `[package]`; every crate lives under `crates/`:
+# - deckard-app the GPUI desktop app (binary `deckard`)
+# - deckard-core the headless engine (Ethereum provider, balances, keystore)
+# - deckard-contract the frozen wire contract (Intent / Decision / Policy / RPC)
+#
+# `cargo run` from the repo root still launches the app via `default-members`.
[workspace]
-members = ["crates/deckard-core"]
-
-[[bin]]
-name = "deckard"
-path = "src/main.rs"
-
-[dependencies]
-# The headless engine: Ethereum provider, balances, HD keys, keystore. No GPUI.
-deckard-core = { path = "crates/deckard-core" }
-
-# Fresh GPUI, straight from Zed's git — the DEFAULT channel (Metal on macOS, wgpu on
-# Linux). `gpui-component` is developed against Zed's gpui HEAD, so the only way to pair
-# fresh gpui with the component kit is this matched git pair. Reproducibility comes from
-# the committed Cargo.lock (it pins exact commits); bump on a cadence with `just bump-gpui`.
-# Prefer the simpler-but-stale pure-crates.io pair instead? See docs/UPGRADING.md.
-gpui = { git = "https://github.com/zed-industries/zed" }
-# After Zed split gpui into multiple crates, the windowing + renderer backend (and the
-# `Application` bootstrap) live in `gpui_platform`.
-gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit"] }
-gpui-component = { git = "https://github.com/longbridge/gpui-component" }
-# Prebuilt asset source: all gpui-component icon SVGs + bundled fonts.
-# Register it with `.with_assets(...)` so `IconName::*` renders.
-gpui-component-assets = { git = "https://github.com/longbridge/gpui-component" }
-
-# Preferences: serialize a Settings struct to the platform config dir.
-serde = { version = "1", features = ["derive"] }
-serde_json = "1"
-directories = "5"
-qrcode = "0.14"
-# Scrub the pending recovery phrase from memory after onboarding.
-zeroize = "1"
+resolver = "2"
+members = ["crates/deckard-app", "crates/deckard-core", "crates/deckard-contract"]
+default-members = ["crates/deckard-app"]
-# --- Optional: menu-bar / tray apps (`--features tray`) — cross-platform ---
-# tray-icon draws a native status item on every OS: NSStatusItem (macOS),
-# libappindicator / StatusNotifierItem (Linux), Shell_NotifyIcon (Windows).
-# There is no second renderer — your windows stay GPUI everywhere.
-tray-icon = { version = "0.24", optional = true }
-alloy-signer-local = { version = "2.0.5", features = ["mnemonic"] }
+[workspace.dependencies]
+# Single-sourced versions shared across crates. deckard-contract pins to these;
+# the app and core may layer extra features on top (e.g. alloy-primitives `serde`).
alloy-primitives = "1.6.0"
+serde = { version = "1", features = ["derive"] }
-[features]
-default = []
-tray = ["dep:tray-icon", "dep:objc2", "dep:objc2-app-kit", "dep:objc2-foundation"]
-
-# objc2 is only used to hide the macOS dock icon — pull it on macOS only, so a
-# Linux/Windows `--features tray` build never tries to compile Apple crates.
-[target.'cfg(target_os = "macos")'.dependencies]
-objc2 = { version = "0.6", optional = true }
-objc2-app-kit = { version = "0.3", optional = true }
-objc2-foundation = { version = "0.3", optional = true }
-
+# Profiles are only honoured at the workspace root, so they live here (not in the
+# app crate). These shrink the release binary: strip symbols, thin-LTO, one codegen unit.
[profile.release]
strip = true
lto = "thin"
codegen-units = 1
-
-# ---------------------------------------------------------------------------
-# `cargo bundle` config (batteries included). On macOS → Deck.app; cargo
-# bundle can also emit `deb` on Linux. Run `just bundle`.
-# cargo install cargo-bundle
-# Drop your own 1024x1024 PNG at assets/icon.png and re-bundle to rebrand.
-# ---------------------------------------------------------------------------
-[package.metadata.bundle]
-name = "Deckard"
-identifier = "com.deckard.app"
-icon = ["assets/icon.png"]
-category = "public.app-category.finance"
-short_description = "A native, self-custodial Ethereum wallet for onchain operators."
-long_description = "Deckard is a fast, keyboard-first, self-custodial Ethereum wallet for people who live onchain. Native (macOS + Linux), trustless by construction, open source (AGPL-3.0)."
-osx_minimum_system_version = "11.0"
diff --git a/crates/deckard-app/Cargo.toml b/crates/deckard-app/Cargo.toml
new file mode 100644
index 0000000..f9edaca
--- /dev/null
+++ b/crates/deckard-app/Cargo.toml
@@ -0,0 +1,80 @@
+[package]
+name = "deckard-app"
+version = "0.1.0"
+edition = "2021"
+description = "Deckard — a native, self-custodial Ethereum wallet for onchain operators (GPUI + Rust). The GUI app crate; the binary is `deckard`."
+license = "AGPL-3.0-or-later"
+default-run = "deckard"
+
+# The binary name stays `deckard` even though the package is `deckard-app`, so
+# `cargo run` / the bundle / `claude mcp` registrations are unchanged.
+[[bin]]
+name = "deckard"
+path = "src/main.rs"
+
+[dependencies]
+# The headless engine: Ethereum provider, balances, HD keys, keystore. No GPUI.
+deckard-core = { path = "../deckard-core" }
+# The frozen wire contract (Intent / Decision / Policy / RPC + Signer + MockSigner).
+# The app builds the native approval card against these types; carries zero key material.
+deckard-contract = { path = "../deckard-contract" }
+
+# Fresh GPUI, straight from Zed's git — the DEFAULT channel (Metal on macOS, wgpu on
+# Linux). `gpui-component` is developed against Zed's gpui HEAD, so the only way to pair
+# fresh gpui with the component kit is this matched git pair. Reproducibility comes from
+# the committed Cargo.lock (it pins exact commits); bump on a cadence with `just bump-gpui`.
+# Prefer the simpler-but-stale pure-crates.io pair instead? See docs/UPGRADING.md.
+gpui = { git = "https://github.com/zed-industries/zed" }
+# After Zed split gpui into multiple crates, the windowing + renderer backend (and the
+# `Application` bootstrap) live in `gpui_platform`.
+gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit"] }
+gpui-component = { git = "https://github.com/longbridge/gpui-component" }
+# Prebuilt asset source: all gpui-component icon SVGs + bundled fonts.
+# Register it with `.with_assets(...)` so `IconName::*` renders.
+gpui-component-assets = { git = "https://github.com/longbridge/gpui-component" }
+
+# Preferences: serialize a Settings struct to the platform config dir.
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+directories = "5"
+qrcode = "0.14"
+# Scrub the pending recovery phrase from memory after onboarding.
+zeroize = "1"
+
+# --- Optional: menu-bar / tray apps (`--features tray`) — cross-platform ---
+# tray-icon draws a native status item on every OS: NSStatusItem (macOS),
+# libappindicator / StatusNotifierItem (Linux), Shell_NotifyIcon (Windows).
+# There is no second renderer — your windows stay GPUI everywhere.
+tray-icon = { version = "0.24", optional = true }
+alloy-signer-local = { version = "2.0.5", features = ["mnemonic"] }
+alloy-primitives = "1.6.0"
+
+[features]
+default = []
+tray = ["dep:tray-icon", "dep:objc2", "dep:objc2-app-kit", "dep:objc2-foundation"]
+
+# objc2 is only used to hide the macOS dock icon — pull it on macOS only, so a
+# Linux/Windows `--features tray` build never tries to compile Apple crates.
+[target.'cfg(target_os = "macos")'.dependencies]
+objc2 = { version = "0.6", optional = true }
+objc2-app-kit = { version = "0.3", optional = true }
+objc2-foundation = { version = "0.3", optional = true }
+
+# ---------------------------------------------------------------------------
+# `cargo bundle` config. On macOS → Deckard.app. Run `just bundle`, which cd's
+# into this crate first so cargo-bundle resolves the relative icon path against
+# crates/deckard-app/ — it resolves icons against the CWD, not the manifest dir.
+# cargo install cargo-bundle
+# We point at the prebuilt assets/icon.icns directly: cargo-bundle 0.11's PNG→icns
+# converter rejects the 1024px icon.png ("No matching IconType"), but copies a
+# ready .icns straight through. To rebrand: edit assets/icon.svg, run `just icon`
+# (regenerates icon.png + icon.icns), then `just bundle`.
+# ---------------------------------------------------------------------------
+[package.metadata.bundle]
+name = "Deckard"
+identifier = "com.deckard.app"
+icon = ["assets/icon.icns"]
+category = "public.app-category.finance"
+short_description = "A native, self-custodial Ethereum wallet for onchain operators."
+long_description = "Deckard is a fast, keyboard-first, self-custodial Ethereum wallet for people who live onchain. Native (macOS + Linux), trustless by construction, open source (AGPL-3.0)."
+osx_minimum_system_version = "11.0"
diff --git a/assets/icon.icns b/crates/deckard-app/assets/icon.icns
similarity index 100%
rename from assets/icon.icns
rename to crates/deckard-app/assets/icon.icns
diff --git a/assets/icon.png b/crates/deckard-app/assets/icon.png
similarity index 100%
rename from assets/icon.png
rename to crates/deckard-app/assets/icon.png
diff --git a/assets/icon.svg b/crates/deckard-app/assets/icon.svg
similarity index 100%
rename from assets/icon.svg
rename to crates/deckard-app/assets/icon.svg
diff --git a/src/main.rs b/crates/deckard-app/src/main.rs
similarity index 100%
rename from src/main.rs
rename to crates/deckard-app/src/main.rs
diff --git a/src/onboarding.rs b/crates/deckard-app/src/onboarding.rs
similarity index 100%
rename from src/onboarding.rs
rename to crates/deckard-app/src/onboarding.rs
diff --git a/src/palette.rs b/crates/deckard-app/src/palette.rs
similarity index 100%
rename from src/palette.rs
rename to crates/deckard-app/src/palette.rs
diff --git a/src/receive.rs b/crates/deckard-app/src/receive.rs
similarity index 100%
rename from src/receive.rs
rename to crates/deckard-app/src/receive.rs
diff --git a/src/settings.rs b/crates/deckard-app/src/settings.rs
similarity index 100%
rename from src/settings.rs
rename to crates/deckard-app/src/settings.rs
diff --git a/src/settings_view.rs b/crates/deckard-app/src/settings_view.rs
similarity index 100%
rename from src/settings_view.rs
rename to crates/deckard-app/src/settings_view.rs
diff --git a/src/shell.rs b/crates/deckard-app/src/shell.rs
similarity index 100%
rename from src/shell.rs
rename to crates/deckard-app/src/shell.rs
diff --git a/src/theme.rs b/crates/deckard-app/src/theme.rs
similarity index 100%
rename from src/theme.rs
rename to crates/deckard-app/src/theme.rs
diff --git a/src/tray.rs b/crates/deckard-app/src/tray.rs
similarity index 100%
rename from src/tray.rs
rename to crates/deckard-app/src/tray.rs
diff --git a/src/wallet.rs b/crates/deckard-app/src/wallet.rs
similarity index 100%
rename from src/wallet.rs
rename to crates/deckard-app/src/wallet.rs
diff --git a/src/welcome.rs b/crates/deckard-app/src/welcome.rs
similarity index 100%
rename from src/welcome.rs
rename to crates/deckard-app/src/welcome.rs
diff --git a/crates/deckard-contract/Cargo.toml b/crates/deckard-contract/Cargo.toml
new file mode 100644
index 0000000..ae7d145
--- /dev/null
+++ b/crates/deckard-contract/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "deckard-contract"
+version = "0.1.0"
+edition = "2021"
+license = "AGPL-3.0-or-later"
+description = "Deckard's frozen wire contract: Intent / Decision / Policy, the signer-daemon RPC enums, a sync Signer trait, and an in-memory MockSigner. Zero key material. Owned by docs/build/30-mcp-shape.md."
+
+[dependencies]
+# EVM value types (Address / U256 / Bytes / B256). The `serde` feature is what lets
+# these cross the wire as JSON (MCP) and CBOR (the daemon UDS).
+alloy-primitives = { workspace = true, features = ["serde"] }
+serde = { workspace = true }
+
+[dev-dependencies]
+# Wire-format round-trip tests only — NOT a runtime dependency of the crate, so they
+# never appear in `cargo tree -p deckard-contract -e normal`. JSON is the MCP encoding,
+# CBOR (ciborium) is the daemon-socket encoding.
+serde_json = "1"
+ciborium = "0.2"
diff --git a/crates/deckard-contract/README.md b/crates/deckard-contract/README.md
new file mode 100644
index 0000000..c193929
--- /dev/null
+++ b/crates/deckard-contract/README.md
@@ -0,0 +1,24 @@
+# deckard-contract
+
+Frozen contract owned by `docs/build/30-mcp-shape.md` — do not redefine these types elsewhere.
+
+This crate is the single source of truth for the wire every Deckard process speaks:
+
+- **`Intent`** — the only thing that crosses `deckard-mcp → deckard-signerd` for a write. Carries `chain_id` (multi-chain ready); the daemon owns the nonce.
+- **`Decision`** — the daemon's verdict from `propose`: `Allow` / `Deny{reason}` / `NeedsApproval{request_id}`.
+- **`Policy`** — the agent-readable spending fence (caps, allowlist, approval mode, `revoked`).
+- **RPC enums** (`SignerRequest` / `SignerResponse` / `ExecuteResult` / `ApprovalStatus` / `BalanceReport`) — the daemon socket API. serde-derived → CBOR (ciborium) on the UDS, JSON for MCP.
+- **`Signer`** — a *sync* trait; the real UDS client does a fast blocking round-trip off the UI thread (an async wrapper is the daemon ticket's call).
+- **`MockSigner`** — an in-memory, deterministic implementation so T-Agent, T-UX, and the test harness can build and run the acceptance scenario **before** the real signer daemon exists.
+
+## Zero key material
+
+This crate carries **no key material at all** — types + a trait + a mock. It never signs, never holds a key. The key boundary is the daemon's process (`deckard-signerd`, owned by `docs/build/00-test-harness.md`), not this crate.
+
+## Deterministic mock
+
+`MockSigner` is pinned for byte-stable tests: `address = 0x1111…11`, broadcast `tx_hash = 0xABAB…AB`, and `request_id`s assigned `0x0101…01`, `0x0202…02`, … in order. See `mock.rs` for the policy decision matrix (caps, allowlist, approval, and the TOCTOU revoke guard).
+
+## Encodings
+
+Every type round-trips through both `serde_json` (the MCP encoding) and `ciborium` / CBOR (the daemon-socket encoding); see the tests. Normal dependencies are exactly `alloy-primitives` + `serde`; `serde_json` and `ciborium` are dev-dependencies only.
diff --git a/crates/deckard-contract/src/decision.rs b/crates/deckard-contract/src/decision.rs
new file mode 100644
index 0000000..887a05f
--- /dev/null
+++ b/crates/deckard-contract/src/decision.rs
@@ -0,0 +1,21 @@
+//! The daemon's verdict, returned by `propose`. The agent cannot forge `Allow`.
+
+use alloy_primitives::B256;
+use serde::{Deserialize, Serialize};
+
+/// What `propose` decided about an [`crate::Intent`]. A `Decision::Allow` or an approved
+/// `RequestId` is the *only* token that lets `execute` sign.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum Decision {
+ /// Within policy → safe to `execute`.
+ Allow,
+ /// Policy violation; terminal. `reason` is a short machine-readable tag
+ /// (e.g. `revoked`, `off_allowlist`, `undecodable`, `over_cap`).
+ Deny { reason: String },
+ /// A human must approve via the native card before `execute` will sign.
+ NeedsApproval { request_id: RequestId },
+}
+
+/// Opaque approval handle; the agent polls `status` on it. (A 32-byte hash so the daemon
+/// can make it unguessable in production.)
+pub type RequestId = B256;
diff --git a/crates/deckard-contract/src/intent.rs b/crates/deckard-contract/src/intent.rs
new file mode 100644
index 0000000..b49bf97
--- /dev/null
+++ b/crates/deckard-contract/src/intent.rs
@@ -0,0 +1,39 @@
+//! What the agent wants to do — the ONLY thing that crosses `mcp → daemon` for a write.
+//! The agent never sends raw signed bytes, only intent; the daemon decides and signs.
+
+use alloy_primitives::{Address, Bytes, U256};
+use serde::{Deserialize, Serialize};
+
+/// A proposed write. Carries `chain_id` (multi-chain ready); the daemon owns the nonce
+/// and assigns it at sign time — there is deliberately no nonce field here.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Intent {
+ /// EVM chain the daemon must sign for. The agent picks the chain; the daemon picks
+ /// the nonce.
+ pub chain_id: u64,
+ /// Target: recipient, token contract, or Railgun adapter, depending on `kind`.
+ pub to: Address,
+ /// `None` = native ETH; `Some` = an ERC-20 contract.
+ pub token: Option
,
+ /// Wei (native) or token base units.
+ pub value: U256,
+ /// Empty for a plain send; the encoded call otherwise.
+ pub calldata: Bytes,
+ /// The discriminator the policy gate switches on.
+ pub kind: IntentKind,
+}
+
+/// The class of write. The Railgun deposit/withdraw calldata for `Shield`/`Unshield`
+/// rides in [`Intent::calldata`] (owned by `docs/build/10-kohaku-shield.md`); this enum
+/// is purely the discriminator.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum IntentKind {
+ /// Plain transfer (native or ERC-20). Calldata is empty for native sends.
+ Send,
+ /// Railgun deposit — the demo hero. Calldata carries the adapter call.
+ Shield,
+ /// Railgun withdraw back to a public balance.
+ Unshield,
+ /// Generic contract write (forward-compat for plugins). Calldata is the call.
+ ContractCall,
+}
diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs
new file mode 100644
index 0000000..932227e
--- /dev/null
+++ b/crates/deckard-contract/src/lib.rs
@@ -0,0 +1,205 @@
+//! # deckard-contract
+//!
+//! The **freeze-first wire** every Deckard process speaks: the `Intent` / `Decision` /
+//! `Policy` types, the signer-daemon RPC enums, a sync [`Signer`] trait, and an in-memory
+//! [`MockSigner`]. Published as a standalone crate so the agent surface (`deckard-mcp`),
+//! the desktop app, and the test harness can build and run the acceptance scenario
+//! **before** the real signer daemon (`deckard-signerd`) exists.
+//!
+//! Frozen contract owned by `docs/build/30-mcp-shape.md` — do not redefine these types
+//! elsewhere. This crate carries **zero key material**: types + a trait + a mock. It never
+//! signs and never holds a key; the key boundary is the daemon's process.
+//!
+//! ## Encodings
+//!
+//! The types are `serde`-derived so the same definitions serialize to **JSON** (the MCP
+//! surface) and **CBOR** (the daemon's Unix-domain-socket framing, via `ciborium`). Both
+//! encodings round-trip byte-stably; see the crate tests.
+//!
+//! **Wei on the JSON wire are 0x-hex strings, not bare numbers.** `alloy-primitives`
+//! encodes every `U256` (e.g. [`Intent::value`], the [`Policy`] caps, [`BalanceReport`])
+//! as a `"0x…"` string in JSON. A JSON producer (a JS/Python MCP client) MUST emit wei that
+//! way: a bare number literal above `u64::MAX` — routine for wei (> ~18.4 ETH) — is parsed
+//! as a float and rejected on decode. CBOR has no such limit.
+
+pub mod decision;
+pub mod intent;
+pub mod mock;
+pub mod policy;
+pub mod rpc;
+pub mod signer;
+
+pub use decision::{Decision, RequestId};
+pub use intent::{Intent, IntentKind};
+pub use mock::MockSigner;
+pub use policy::{ApprovalMode, Policy};
+pub use rpc::{ApprovalStatus, BalanceReport, ExecuteResult, SignerRequest, SignerResponse};
+pub use signer::Signer;
+
+#[cfg(test)]
+mod roundtrip_tests {
+ //! Every wire type must survive both encodings unchanged: JSON (the MCP surface) and
+ //! CBOR (the daemon UDS, via ciborium). Both are also asserted byte-stable (re-encoding
+ //! the same value yields identical bytes) — the wire types contain no maps/sets, so
+ //! encoding is deterministic.
+
+ use super::*;
+ use alloy_primitives::{Address, Bytes, B256, U256};
+ use core::fmt::Debug;
+ use serde::de::DeserializeOwned;
+ use serde::Serialize;
+
+ fn roundtrip(value: &T) {
+ // JSON (human-readable): encode → decode → assert_eq, and assert byte-stability.
+ let json = serde_json::to_vec(value).expect("json encode");
+ let from_json: T = serde_json::from_slice(&json).expect("json decode");
+ assert_eq!(&from_json, value, "json round-trip changed the value");
+ assert_eq!(
+ json,
+ serde_json::to_vec(value).unwrap(),
+ "json not byte-stable"
+ );
+
+ // CBOR (binary): encode → decode → assert_eq, and assert byte-stability.
+ let mut cbor = Vec::new();
+ ciborium::into_writer(value, &mut cbor).expect("cbor encode");
+ let from_cbor: T = ciborium::from_reader(&cbor[..]).expect("cbor decode");
+ assert_eq!(&from_cbor, value, "cbor round-trip changed the value");
+ let mut cbor2 = Vec::new();
+ ciborium::into_writer(value, &mut cbor2).unwrap();
+ assert_eq!(cbor, cbor2, "cbor not byte-stable");
+ }
+
+ fn sample_intent(kind: IntentKind) -> Intent {
+ Intent {
+ chain_id: 8453,
+ to: Address::repeat_byte(0x22),
+ token: Some(Address::repeat_byte(0x33)),
+ value: U256::from(123_456_789_u64),
+ calldata: Bytes::from_static(&[0x01, 0x02, 0x03, 0x04]),
+ kind,
+ }
+ }
+
+ fn sample_policy() -> Policy {
+ Policy {
+ per_tx_cap_wei: U256::from(50_000_000_000_000_000_u64),
+ daily_cap_wei: U256::from(1_000_000_000_000_000_000_u64),
+ spent_today_wei: U256::from(7_u64),
+ allow_to: vec![Address::repeat_byte(0xAA), Address::repeat_byte(0xBB)],
+ auto_shield_min_wei: U256::from(10_000_000_000_000_000_u64),
+ require_approval: ApprovalMode::OverCap,
+ revoked: false,
+ }
+ }
+
+ #[test]
+ fn intent_and_kind_roundtrip() {
+ for kind in [
+ IntentKind::Send,
+ IntentKind::Shield,
+ IntentKind::Unshield,
+ IntentKind::ContractCall,
+ ] {
+ roundtrip(&kind);
+ roundtrip(&sample_intent(kind));
+ }
+ // native ETH (token = None) and empty calldata
+ roundtrip(&Intent {
+ token: None,
+ calldata: Bytes::new(),
+ ..sample_intent(IntentKind::Send)
+ });
+ }
+
+ #[test]
+ fn decision_roundtrip() {
+ roundtrip(&Decision::Allow);
+ roundtrip(&Decision::Deny {
+ reason: "off_allowlist".into(),
+ });
+ roundtrip(&Decision::NeedsApproval {
+ request_id: B256::repeat_byte(0x01),
+ });
+ }
+
+ #[test]
+ fn policy_and_mode_roundtrip() {
+ for mode in [
+ ApprovalMode::Never,
+ ApprovalMode::OverCap,
+ ApprovalMode::Always,
+ ] {
+ roundtrip(&mode);
+ }
+ roundtrip(&sample_policy());
+ // empty allowlist + revoked variant
+ roundtrip(&Policy {
+ allow_to: vec![],
+ revoked: true,
+ ..sample_policy()
+ });
+ }
+
+ #[test]
+ fn signer_request_roundtrip() {
+ roundtrip(&SignerRequest::Propose {
+ intent: sample_intent(IntentKind::Shield),
+ });
+ roundtrip(&SignerRequest::Execute {
+ request_id: B256::repeat_byte(0x02),
+ });
+ roundtrip(&SignerRequest::Status {
+ request_id: B256::repeat_byte(0x03),
+ });
+ roundtrip(&SignerRequest::RevokeAll);
+ roundtrip(&SignerRequest::PolicyGet);
+ roundtrip(&SignerRequest::Address);
+ roundtrip(&SignerRequest::Balance { shielded: true });
+ roundtrip(&SignerRequest::Balance { shielded: false });
+ }
+
+ #[test]
+ fn signer_response_roundtrip() {
+ roundtrip(&SignerResponse::Decision(Decision::Allow));
+ roundtrip(&SignerResponse::Execute(ExecuteResult::Broadcast {
+ tx_hash: B256::repeat_byte(0xAB),
+ }));
+ roundtrip(&SignerResponse::Status(ApprovalStatus::Pending));
+ roundtrip(&SignerResponse::Ack);
+ roundtrip(&SignerResponse::Policy(sample_policy()));
+ roundtrip(&SignerResponse::Address(Address::repeat_byte(0x11)));
+ roundtrip(&SignerResponse::Balance(BalanceReport {
+ public_wei: U256::from(1_u64),
+ shielded_wei: U256::from(2_u64),
+ }));
+ }
+
+ #[test]
+ fn execute_result_and_status_roundtrip() {
+ roundtrip(&ExecuteResult::Broadcast {
+ tx_hash: B256::repeat_byte(0xAB),
+ });
+ roundtrip(&ExecuteResult::Denied {
+ reason: "already_executed".into(),
+ });
+ roundtrip(&ApprovalStatus::Pending);
+ roundtrip(&ApprovalStatus::Allowed);
+ roundtrip(&ApprovalStatus::Denied {
+ reason: "revoked".into(),
+ });
+ roundtrip(&ApprovalStatus::Expired);
+ }
+
+ #[test]
+ fn balance_report_roundtrip() {
+ roundtrip(&BalanceReport {
+ public_wei: U256::from(0_u64),
+ shielded_wei: U256::from(0_u64),
+ });
+ roundtrip(&BalanceReport {
+ public_wei: U256::MAX,
+ shielded_wei: U256::from(42_u64),
+ });
+ }
+}
diff --git a/crates/deckard-contract/src/mock.rs b/crates/deckard-contract/src/mock.rs
new file mode 100644
index 0000000..0479304
--- /dev/null
+++ b/crates/deckard-contract/src/mock.rs
@@ -0,0 +1,626 @@
+//! An in-memory, deterministic [`Signer`] so the agent surface, the desktop app, and the
+//! test harness can run the acceptance scenario before the real `deckard-signerd` exists.
+//!
+//! Pinned for byte-stable tests: address `0x1111…11`, broadcast tx hash `0xABAB…AB`, and
+//! `request_id`s assigned `0x0101…01`, `0x0202…02`, … in propose order. Holds a `Mutex`
+//! and a `Mutex` of in-flight requests; **carries no key material** and never signs anything —
+//! `execute` just returns the pinned hash.
+
+use std::collections::HashMap;
+use std::sync::Mutex;
+
+use alloy_primitives::{Address, B256, U256};
+
+use crate::decision::{Decision, RequestId};
+use crate::intent::{Intent, IntentKind};
+use crate::policy::{ApprovalMode, Policy};
+use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult};
+use crate::signer::Signer;
+
+/// One tracked proposal. `status` is the wire-visible approval state; `broadcast` is `Some`
+/// once `execute` has signed it (so a second `execute` is idempotently refused).
+#[derive(Clone, Debug)]
+struct Request {
+ intent: Intent,
+ status: ApprovalStatus,
+ broadcast: Option,
+}
+
+/// The request table, the deterministic id counter (`1, 2, …`), and the most recently
+/// minted id. The pinned single-byte `repeat_byte(n)` scheme tops out at 255 ids.
+#[derive(Debug)]
+struct Requests {
+ by_id: HashMap,
+ next_id: u8,
+ last_id: Option,
+}
+
+/// An in-memory signer. The `policy` and `requests` locks are always acquired **policy
+/// before requests**, so the pair can never deadlock; `balance` is only ever taken alone.
+#[derive(Debug)]
+pub struct MockSigner {
+ policy: Mutex,
+ requests: Mutex,
+ balance: Mutex,
+}
+
+impl MockSigner {
+ /// Build a mock from a starting policy. Balances default to zero; set them with
+ /// [`MockSigner::set_balance`].
+ pub fn new(policy: Policy) -> Self {
+ Self {
+ policy: Mutex::new(policy),
+ requests: Mutex::new(Requests {
+ by_id: HashMap::new(),
+ next_id: 1,
+ last_id: None,
+ }),
+ balance: Mutex::new(BalanceReport {
+ public_wei: U256::ZERO,
+ shielded_wei: U256::ZERO,
+ }),
+ }
+ }
+
+ /// The pinned deterministic address (`0x1111…11`).
+ pub fn mock_address() -> Address {
+ Address::repeat_byte(0x11)
+ }
+
+ /// The pinned broadcast tx hash every successful `execute` returns (`0xABAB…AB`).
+ pub fn broadcast_tx_hash() -> B256 {
+ B256::repeat_byte(0xAB)
+ }
+
+ /// Overwrite the reported balances (setup helper).
+ pub fn set_balance(&self, report: BalanceReport) {
+ *self.balance.lock().expect("mock balance mutex poisoned") = report;
+ }
+
+ /// Test helper: flip a `Pending` request to `Allowed`, simulating the human tapping
+ /// Approve on the native card. No-op for any other state.
+ pub fn approve(&self, request_id: RequestId) {
+ let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
+ if let Some(req) = reqs.by_id.get_mut(&request_id) {
+ if req.status == ApprovalStatus::Pending {
+ req.status = ApprovalStatus::Allowed;
+ }
+ }
+ }
+
+ /// Test helper: the id of the most recently minted request, or `None` if none yet.
+ /// Useful for executing an `Allow` decision, which does not carry the id on the wire.
+ pub fn last_request_id(&self) -> Option {
+ self.requests
+ .lock()
+ .expect("mock requests mutex poisoned")
+ .last_id
+ }
+
+ /// Mint the next deterministic id (`repeat_byte(1)`, `repeat_byte(2)`, …). Caller holds
+ /// the requests lock. The pinned single-byte scheme yields at most 255 distinct ids;
+ /// minting a 256th would collide with a live entry, so we panic loudly rather than
+ /// silently wrap (which would clobber an in-flight request and defeat idempotency).
+ fn mint_id(reqs: &mut Requests) -> RequestId {
+ assert!(
+ reqs.next_id != 0,
+ "MockSigner request-id space (u8) exhausted: this mock supports at most 255 proposals"
+ );
+ let id = B256::repeat_byte(reqs.next_id);
+ reqs.next_id = reqs.next_id.wrapping_add(1);
+ reqs.last_id = Some(id);
+ id
+ }
+}
+
+/// Mock decodability rule. The real adapter calldata is validated by `deckard-signerd`
+/// (`10-kohaku-shield.md`); this just checks the shape matches the kind.
+fn calldata_ok(intent: &Intent) -> bool {
+ match intent.kind {
+ // A plain send carries no calldata.
+ IntentKind::Send => intent.calldata.is_empty(),
+ // A generic contract write needs calldata to call.
+ IntentKind::ContractCall => !intent.calldata.is_empty(),
+ // Railgun deposit/withdraw: the mock accepts whatever calldata it is handed.
+ IntentKind::Shield | IntentKind::Unshield => true,
+ }
+}
+
+impl Signer for MockSigner {
+ fn address(&self) -> Address {
+ Self::mock_address()
+ }
+
+ fn balance(&self, _shielded: bool) -> BalanceReport {
+ self.balance
+ .lock()
+ .expect("mock balance mutex poisoned")
+ .clone()
+ }
+
+ fn policy(&self) -> Policy {
+ self.policy
+ .lock()
+ .expect("mock policy mutex poisoned")
+ .clone()
+ }
+
+ fn propose(&self, intent: &Intent) -> Decision {
+ let needs_card;
+ {
+ let policy = self.policy.lock().expect("mock policy mutex poisoned");
+
+ // 1. STOP overrides everything.
+ if policy.revoked {
+ return Decision::Deny {
+ reason: "revoked".into(),
+ };
+ }
+ // 2. Allowlist (empty = any address).
+ if !policy.allow_to.is_empty() && !policy.allow_to.contains(&intent.to) {
+ return Decision::Deny {
+ reason: "off_allowlist".into(),
+ };
+ }
+ // 3. Calldata must be decodable for the kind.
+ if !calldata_ok(intent) {
+ return Decision::Deny {
+ reason: "undecodable".into(),
+ };
+ }
+ // 4. Cap check: spent_today + value vs the per-tx and daily caps.
+ let projected = policy.spent_today_wei.saturating_add(intent.value);
+ let over = projected > policy.per_tx_cap_wei || projected > policy.daily_cap_wei;
+
+ needs_card = match policy.require_approval {
+ ApprovalMode::Never => false,
+ ApprovalMode::OverCap => over,
+ ApprovalMode::Always => true,
+ };
+
+ // Never raises no card, so an over-cap write has nothing to authorise it → deny.
+ if over && matches!(policy.require_approval, ApprovalMode::Never) {
+ return Decision::Deny {
+ reason: "over_cap".into(),
+ };
+ }
+ } // policy lock released before taking the requests lock (preserves lock order)
+
+ let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
+ let id = Self::mint_id(&mut reqs);
+ let status = if needs_card {
+ ApprovalStatus::Pending
+ } else {
+ ApprovalStatus::Allowed
+ };
+ reqs.by_id.insert(
+ id,
+ Request {
+ intent: intent.clone(),
+ status,
+ broadcast: None,
+ },
+ );
+
+ if needs_card {
+ Decision::NeedsApproval { request_id: id }
+ } else {
+ Decision::Allow
+ }
+ }
+
+ fn execute(&self, request_id: RequestId) -> ExecuteResult {
+ // Always policy-before-requests so execute and revoke_all can't deadlock.
+ let mut policy = self.policy.lock().expect("mock policy mutex poisoned");
+ let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
+
+ let req = match reqs.by_id.get_mut(&request_id) {
+ None => {
+ return ExecuteResult::Denied {
+ reason: "unknown_request".into(),
+ }
+ }
+ Some(req) => req,
+ };
+
+ // Idempotency: a broadcast id never signs twice.
+ if req.broadcast.is_some() {
+ return ExecuteResult::Denied {
+ reason: "already_executed".into(),
+ };
+ }
+
+ // TOCTOU guard: re-check `revoked` at sign time. An approval granted before
+ // revoke_all must still be denied here.
+ if policy.revoked {
+ return ExecuteResult::Denied {
+ reason: "revoked".into(),
+ };
+ }
+
+ match req.status.clone() {
+ // The only state that signs (covers allow-equivalent and human-approved).
+ ApprovalStatus::Allowed => {
+ let tx = Self::broadcast_tx_hash();
+ let value = req.intent.value;
+ req.broadcast = Some(tx);
+ policy.spent_today_wei = policy.spent_today_wei.saturating_add(value);
+ ExecuteResult::Broadcast { tx_hash: tx }
+ }
+ ApprovalStatus::Pending => ExecuteResult::Denied {
+ reason: "not_approved".into(),
+ },
+ ApprovalStatus::Denied { reason } => ExecuteResult::Denied { reason },
+ ApprovalStatus::Expired => ExecuteResult::Denied {
+ reason: "expired".into(),
+ },
+ }
+ }
+
+ fn status(&self, request_id: RequestId) -> ApprovalStatus {
+ let reqs = self.requests.lock().expect("mock requests mutex poisoned");
+ match reqs.by_id.get(&request_id) {
+ Some(req) => req.status.clone(),
+ None => ApprovalStatus::Denied {
+ reason: "unknown_request".into(),
+ },
+ }
+ }
+
+ fn revoke_all(&self) {
+ // Same lock order as execute(): policy before requests.
+ let mut policy = self.policy.lock().expect("mock policy mutex poisoned");
+ let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
+ policy.revoked = true;
+ for req in reqs.by_id.values_mut() {
+ if req.status == ApprovalStatus::Pending {
+ req.status = ApprovalStatus::Denied {
+ reason: "revoked".into(),
+ };
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use alloy_primitives::Bytes;
+
+ // --- builders -------------------------------------------------------------------
+
+ /// A policy with an empty allowlist and `auto_shield_min_wei = 10`.
+ fn policy(per_tx: u64, daily: u64, spent: u64, mode: ApprovalMode) -> Policy {
+ Policy {
+ per_tx_cap_wei: U256::from(per_tx),
+ daily_cap_wei: U256::from(daily),
+ spent_today_wei: U256::from(spent),
+ allow_to: vec![],
+ auto_shield_min_wei: U256::from(10u64),
+ require_approval: mode,
+ revoked: false,
+ }
+ }
+
+ fn send(value: u64) -> Intent {
+ Intent {
+ chain_id: 1,
+ to: Address::repeat_byte(0x22),
+ token: None,
+ value: U256::from(value),
+ calldata: Bytes::new(),
+ kind: IntentKind::Send,
+ }
+ }
+
+ fn shield(value: u64) -> Intent {
+ Intent {
+ chain_id: 1,
+ to: Address::repeat_byte(0x44),
+ token: None,
+ value: U256::from(value),
+ calldata: Bytes::new(),
+ kind: IntentKind::Shield,
+ }
+ }
+
+ fn unwrap_needs(d: Decision) -> RequestId {
+ match d {
+ Decision::NeedsApproval { request_id } => request_id,
+ other => panic!("expected NeedsApproval, got {other:?}"),
+ }
+ }
+
+ // --- decision matrix ------------------------------------------------------------
+
+ #[test]
+ fn within_cap_allows() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ assert_eq!(s.propose(&send(20)), Decision::Allow);
+ }
+
+ #[test]
+ fn over_per_tx_cap_needs_approval() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ assert!(matches!(
+ s.propose(&send(60)),
+ Decision::NeedsApproval { .. }
+ ));
+ }
+
+ #[test]
+ fn over_daily_cap_needs_approval() {
+ // per-tx effectively unbounded so only the daily cap can bind.
+ let s = MockSigner::new(policy(u64::MAX, 100, 90, ApprovalMode::OverCap));
+ assert!(matches!(
+ s.propose(&send(20)),
+ Decision::NeedsApproval { .. }
+ ));
+ }
+
+ #[test]
+ fn off_allowlist_denies() {
+ let mut p = policy(50, 1000, 0, ApprovalMode::OverCap);
+ p.allow_to = vec![Address::repeat_byte(0x33)]; // send() targets 0x22
+ let s = MockSigner::new(p);
+ assert_eq!(
+ s.propose(&send(20)),
+ Decision::Deny {
+ reason: "off_allowlist".into()
+ }
+ );
+ }
+
+ #[test]
+ fn on_allowlist_allows() {
+ let mut p = policy(50, 1000, 0, ApprovalMode::OverCap);
+ p.allow_to = vec![Address::repeat_byte(0x22)]; // matches send()'s target
+ let s = MockSigner::new(p);
+ assert_eq!(s.propose(&send(20)), Decision::Allow);
+ }
+
+ #[test]
+ fn revoked_policy_denies_propose() {
+ let mut p = policy(50, 1000, 0, ApprovalMode::OverCap);
+ p.revoked = true;
+ let s = MockSigner::new(p);
+ assert_eq!(
+ s.propose(&send(20)),
+ Decision::Deny {
+ reason: "revoked".into()
+ }
+ );
+ }
+
+ #[test]
+ fn undecodable_calldata_denies() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ // A Send must have empty calldata.
+ let mut bad_send = send(20);
+ bad_send.calldata = Bytes::from_static(&[0x01, 0x02]);
+ assert_eq!(
+ s.propose(&bad_send),
+ Decision::Deny {
+ reason: "undecodable".into()
+ }
+ );
+ // A ContractCall must have non-empty calldata.
+ let empty_call = Intent {
+ kind: IntentKind::ContractCall,
+ calldata: Bytes::new(),
+ ..send(20)
+ };
+ assert_eq!(
+ s.propose(&empty_call),
+ Decision::Deny {
+ reason: "undecodable".into()
+ }
+ );
+ }
+
+ #[test]
+ fn never_over_cap_denies() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::Never));
+ assert_eq!(
+ s.propose(&send(60)),
+ Decision::Deny {
+ reason: "over_cap".into()
+ }
+ );
+ }
+
+ #[test]
+ fn always_within_cap_needs_approval() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::Always));
+ assert!(matches!(
+ s.propose(&send(20)),
+ Decision::NeedsApproval { .. }
+ ));
+ }
+
+ #[test]
+ fn execute_on_pending_denied() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ let id = unwrap_needs(s.propose(&send(60)));
+ assert_eq!(
+ s.execute(id),
+ ExecuteResult::Denied {
+ reason: "not_approved".into()
+ }
+ );
+ }
+
+ #[test]
+ fn approve_then_execute_broadcasts_and_increments_spent() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ let id = unwrap_needs(s.propose(&send(60)));
+ s.approve(id);
+ assert_eq!(
+ s.execute(id),
+ ExecuteResult::Broadcast {
+ tx_hash: MockSigner::broadcast_tx_hash()
+ }
+ );
+ assert_eq!(s.policy().spent_today_wei, U256::from(60u64));
+ }
+
+ #[test]
+ fn toctou_revoke_then_execute_denied() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ let id = unwrap_needs(s.propose(&send(60)));
+ s.approve(id); // human approved BEFORE the STOP
+ s.revoke_all();
+ assert_eq!(
+ s.execute(id),
+ ExecuteResult::Denied {
+ reason: "revoked".into()
+ }
+ );
+ // and nothing was spent
+ assert_eq!(s.policy().spent_today_wei, U256::ZERO);
+ }
+
+ #[test]
+ fn unknown_id_denied() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ assert_eq!(
+ s.execute(B256::repeat_byte(0xFF)),
+ ExecuteResult::Denied {
+ reason: "unknown_request".into()
+ }
+ );
+ }
+
+ #[test]
+ fn double_execute_denied() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ // within-cap → Allow, stored as allow-equivalent and executable by its minted id.
+ assert_eq!(s.propose(&send(20)), Decision::Allow);
+ let id = s.last_request_id().expect("an id was minted");
+ assert!(matches!(s.execute(id), ExecuteResult::Broadcast { .. }));
+ assert_eq!(
+ s.execute(id),
+ ExecuteResult::Denied {
+ reason: "already_executed".into()
+ }
+ );
+ // spent incremented exactly once
+ assert_eq!(s.policy().spent_today_wei, U256::from(20u64));
+ }
+
+ #[test]
+ fn auto_shield_within_cap_never_allows() {
+ // The demo beat: an inbound shield within cap, hands-free (Never).
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::Never));
+ assert_eq!(s.propose(&shield(20)), Decision::Allow);
+ }
+
+ #[test]
+ fn revoke_all_flips_pending_to_denied() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ let id = unwrap_needs(s.propose(&send(60))); // Pending
+ s.revoke_all();
+ assert_eq!(
+ s.status(id),
+ ApprovalStatus::Denied {
+ reason: "revoked".into()
+ }
+ );
+ assert!(s.policy().revoked);
+ }
+
+ #[test]
+ fn pinned_constants_and_first_id() {
+ assert_eq!(MockSigner::mock_address(), Address::repeat_byte(0x11));
+ assert_eq!(MockSigner::broadcast_tx_hash(), B256::repeat_byte(0xAB));
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ assert_eq!(s.address(), Address::repeat_byte(0x11));
+ // The first minted request_id is 0x0101…01.
+ let id = unwrap_needs(s.propose(&send(60)));
+ assert_eq!(id, B256::repeat_byte(0x01));
+ }
+
+ #[test]
+ fn box_dyn_signer_is_usable() {
+ let s: Box =
+ Box::new(MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)));
+ assert_eq!(s.address(), MockSigner::mock_address());
+ assert_eq!(s.propose(&send(20)), Decision::Allow);
+ assert!(!s.policy().revoked);
+ s.revoke_all();
+ assert!(s.policy().revoked);
+ }
+
+ #[test]
+ fn balance_reads_what_was_set() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ s.set_balance(BalanceReport {
+ public_wei: U256::from(7u64),
+ shielded_wei: U256::from(3u64),
+ });
+ let b = s.balance(false);
+ assert_eq!(b.public_wei, U256::from(7u64));
+ assert_eq!(b.shielded_wei, U256::from(3u64));
+ }
+
+ // --- boundary + guard coverage (added after review) -----------------------------
+
+ #[test]
+ fn exact_per_tx_cap_is_within() {
+ // projected == per_tx_cap is "within" (strict `>`): pins against a `>=` regression.
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ assert_eq!(s.propose(&send(50)), Decision::Allow);
+ }
+
+ #[test]
+ fn exact_daily_cap_is_within_one_over_needs_approval() {
+ // per-tx unbounded so only the daily cap binds. projected == daily → Allow;
+ // projected == daily + 1 → NeedsApproval.
+ let s = MockSigner::new(policy(u64::MAX, 100, 90, ApprovalMode::OverCap));
+ assert_eq!(s.propose(&send(10)), Decision::Allow); // 90 + 10 == 100
+ let s2 = MockSigner::new(policy(u64::MAX, 100, 90, ApprovalMode::OverCap));
+ assert!(matches!(
+ s2.propose(&send(11)), // 90 + 11 == 101 > 100
+ Decision::NeedsApproval { .. }
+ ));
+ }
+
+ #[test]
+ fn toctou_revoke_then_execute_within_cap_allow_denied() {
+ // STOP must also block an unexecuted within-cap Allow (status=Allowed), not just the
+ // human-approved over-cap path: the execute-time revoked guard is the only thing
+ // standing between an Allow and a broadcast after revoke_all.
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ assert_eq!(s.propose(&send(20)), Decision::Allow);
+ let id = s.last_request_id().expect("Allow minted an id");
+ s.revoke_all();
+ assert_eq!(
+ s.execute(id),
+ ExecuteResult::Denied {
+ reason: "revoked".into()
+ }
+ );
+ assert_eq!(s.policy().spent_today_wei, U256::ZERO);
+ }
+
+ #[test]
+ fn last_request_id_tracks_latest_mint() {
+ let s = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap));
+ assert_eq!(s.last_request_id(), None);
+ s.propose(&send(20)); // mints 0x01
+ assert_eq!(s.last_request_id(), Some(B256::repeat_byte(0x01)));
+ s.propose(&send(20)); // mints 0x02
+ assert_eq!(s.last_request_id(), Some(B256::repeat_byte(0x02)));
+ }
+
+ #[test]
+ #[should_panic(expected = "exhausted")]
+ fn request_id_space_exhaustion_panics_instead_of_wrapping() {
+ // The pinned single-byte id scheme supports 255 ids; the 256th proposal must panic
+ // loudly rather than silently wrap and clobber a live request.
+ let s = MockSigner::new(policy(u64::MAX, u64::MAX, 0, ApprovalMode::OverCap));
+ for _ in 0..256 {
+ let _ = s.propose(&send(1)); // within cap → Allow → mints an id each time
+ }
+ }
+}
diff --git a/crates/deckard-contract/src/policy.rs b/crates/deckard-contract/src/policy.rs
new file mode 100644
index 0000000..1c3a2d7
--- /dev/null
+++ b/crates/deckard-contract/src/policy.rs
@@ -0,0 +1,36 @@
+//! The spending fence the agent is allowed to READ (so it can stay inside the fence) but
+//! never write. The daemon enforces it; `MockSigner` enforces the same rules in memory.
+
+use alloy_primitives::{Address, U256};
+use serde::{Deserialize, Serialize};
+
+/// The agent-readable policy. All caps are in wei.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Policy {
+ /// Per-transaction ceiling.
+ pub per_tx_cap_wei: U256,
+ /// Rolling daily ceiling.
+ pub daily_cap_wei: U256,
+ /// Spent so far today; the cap check compares `spent_today_wei + value`.
+ pub spent_today_wei: U256,
+ /// Allowed recipients. **EMPTY = any address allowed.**
+ pub allow_to: Vec,
+ /// Demo rule: auto-shield inbound ETH ≥ this. Read by the agent to decide *whether to
+ /// propose a shield*; the policy gate itself does not switch on it.
+ pub auto_shield_min_wei: U256,
+ /// When a write needs a human approval card.
+ pub require_approval: ApprovalMode,
+ /// Set true by `revoke_all` / STOP. Re-checked at execute time (TOCTOU guard).
+ pub revoked: bool,
+}
+
+/// When the policy gate raises a native approval card.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum ApprovalMode {
+ /// Never raise a card. Within cap → allow; over cap → deny (no card to override it).
+ Never,
+ /// Raise a card only when over a cap; within cap → allow.
+ OverCap,
+ /// Always raise a card, even within cap.
+ Always,
+}
diff --git a/crates/deckard-contract/src/rpc.rs b/crates/deckard-contract/src/rpc.rs
new file mode 100644
index 0000000..ed67b12
--- /dev/null
+++ b/crates/deckard-contract/src/rpc.rs
@@ -0,0 +1,71 @@
+//! The daemon socket API — the wire `deckard-mcp` (key-less) speaks to `deckard-signerd`.
+//! serde-derived so it frames as CBOR (ciborium) on the UDS and JSON for MCP. One request
+//! per frame, one response per frame.
+
+use alloy_primitives::{Address, B256, U256};
+use serde::{Deserialize, Serialize};
+
+use crate::decision::{Decision, RequestId};
+use crate::intent::Intent;
+use crate::policy::Policy;
+
+/// `deckard-mcp` → `deckard-signerd`. The key-less client only proposes; it never signs.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum SignerRequest {
+ /// Policy check, NO signing yet → [`Decision`].
+ Propose { intent: Intent },
+ /// Sign + broadcast, only if `Allow`/approved → [`ExecuteResult`].
+ Execute { request_id: RequestId },
+ /// Poll for the native-card result → [`ApprovalStatus`].
+ Status { request_id: RequestId },
+ /// STOP: set `policy.revoked`, drop in-flight approvals → `Ack`.
+ RevokeAll,
+ /// Read-only snapshot for the agent → [`Policy`].
+ PolicyGet,
+ /// → [`Address`](alloy_primitives::Address).
+ Address,
+ /// → [`BalanceReport`].
+ Balance { shielded: bool },
+}
+
+/// `deckard-signerd` → `deckard-mcp`. One variant per request shape.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum SignerResponse {
+ Decision(Decision),
+ Execute(ExecuteResult),
+ Status(ApprovalStatus),
+ /// Reply to `RevokeAll`.
+ Ack,
+ Policy(Policy),
+ Address(Address),
+ Balance(BalanceReport),
+}
+
+/// Outcome of `execute`.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum ExecuteResult {
+ /// Signed + broadcast.
+ Broadcast { tx_hash: B256 },
+ /// Refused at sign time (e.g. `revoked`, `already_executed`, `unknown_request`).
+ Denied { reason: String },
+}
+
+/// Result of polling a `RequestId`. Approvals expire so a stale id can't be executed later.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum ApprovalStatus {
+ /// Awaiting the human on the native card.
+ Pending,
+ /// The human approved; `execute` will sign (subject to a fresh `revoked` re-check).
+ Allowed,
+ /// Terminal denial.
+ Denied { reason: String },
+ /// The approval window elapsed.
+ Expired,
+}
+
+/// Public + shielded balances, both in wei.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct BalanceReport {
+ pub public_wei: U256,
+ pub shielded_wei: U256,
+}
diff --git a/crates/deckard-contract/src/signer.rs b/crates/deckard-contract/src/signer.rs
new file mode 100644
index 0000000..07c18e2
--- /dev/null
+++ b/crates/deckard-contract/src/signer.rs
@@ -0,0 +1,32 @@
+//! The signer abstraction. **Sync on purpose**: it keeps this crate runtime-free. The real
+//! UDS client does a fast blocking round-trip off the UI thread; wrapping it in async is the
+//! daemon ticket's call, not this contract's. `MockSigner` is the in-memory implementation.
+
+use alloy_primitives::Address;
+
+use crate::decision::{Decision, RequestId};
+use crate::intent::Intent;
+use crate::policy::Policy;
+use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult};
+
+/// The daemon-socket API expressed as a trait, so callers can hold a `Box` and
+/// swap the mock for the real UDS client without changing a line. Object-safe: every method
+/// takes `&self` and returns owned values.
+pub trait Signer {
+ /// The wallet's public address (key-less to read).
+ fn address(&self) -> Address;
+ /// Public + shielded balances. `shielded` mirrors the wire request; the report carries
+ /// both fields regardless.
+ fn balance(&self, shielded: bool) -> BalanceReport;
+ /// A read-only snapshot of the spending fence.
+ fn policy(&self) -> Policy;
+ /// Policy check only — NEVER signs, NEVER broadcasts. Returns a [`Decision`].
+ fn propose(&self, intent: &Intent) -> Decision;
+ /// Sign + broadcast, only for an allow-equivalent or approved request. Re-checks
+ /// `revoked` at sign time (TOCTOU guard).
+ fn execute(&self, request_id: RequestId) -> ExecuteResult;
+ /// Poll an approval handle.
+ fn status(&self, request_id: RequestId) -> ApprovalStatus;
+ /// STOP: revoke all agent authority for the session and drop in-flight approvals.
+ fn revoke_all(&self);
+}
diff --git a/crates/deckard-contract/tests/harness_slice.rs b/crates/deckard-contract/tests/harness_slice.rs
new file mode 100644
index 0000000..0d70231
--- /dev/null
+++ b/crates/deckard-contract/tests/harness_slice.rs
@@ -0,0 +1,108 @@
+//! The daemon-free slice of the `docs/build/30-mcp-shape.md` acceptance scenario
+//! ("MCP surface: read-free, write-gated, secret-tight"), run against `MockSigner`.
+//!
+//! This exercises the **signer half** of T1–T8 — every step that is a daemon RPC. The
+//! steps that live in the (out-of-scope) MCP server are called out inline:
+//! * T1 `list_tools` — MCP tool registry, not a signer op.
+//! * T5 `simulate` — Helios eth_call preview, not a signer op.
+//! * T7 secret-refusal of `--passphrase`/`--key` flags — MCP-mode CLI parsing.
+//! * T9 transcript key-leak scan — asserted over the MCP JSON-RPC transcript, where a
+//! 64-hex private key would leak; note a `tx_hash`/`request_id` is legitimately 64-hex,
+//! so that gate belongs to the MCP server ticket, not this contract.
+
+use alloy_primitives::{Address, Bytes, U256};
+use deckard_contract::{
+ ApprovalStatus, Decision, ExecuteResult, Intent, IntentKind, MockSigner, Policy, Signer,
+};
+
+const PER_TX_CAP: u64 = 50_000_000_000_000_000; // 0.05 ETH
+const DAILY_CAP: u64 = 1_000_000_000_000_000_000; // 1 ETH
+const AUTO_SHIELD_MIN: u64 = 10_000_000_000_000_000; // 0.01 ETH
+const OVER_CAP_VALUE: u64 = 200_000_000_000_000_000; // 0.2 ETH (> per-tx cap)
+const WITHIN_CAP_VALUE: u64 = 20_000_000_000_000_000; // 0.02 ETH (< per-tx cap)
+
+fn demo_signer() -> MockSigner {
+ MockSigner::new(Policy {
+ per_tx_cap_wei: U256::from(PER_TX_CAP),
+ daily_cap_wei: U256::from(DAILY_CAP),
+ spent_today_wei: U256::ZERO,
+ allow_to: vec![], // empty = any address
+ auto_shield_min_wei: U256::from(AUTO_SHIELD_MIN),
+ require_approval: deckard_contract::ApprovalMode::OverCap,
+ revoked: false,
+ })
+}
+
+fn intent(kind: IntentKind, value: u64) -> Intent {
+ Intent {
+ chain_id: 1,
+ to: Address::repeat_byte(0x22),
+ token: None,
+ value: U256::from(value),
+ calldata: Bytes::new(),
+ kind,
+ }
+}
+
+#[test]
+fn mcp_surface_daemon_free_slice() {
+ let s = demo_signer();
+
+ // ---- T2: read tools succeed, deterministic, carry no secret -----------------------
+ assert_eq!(s.address(), MockSigner::mock_address());
+ let pol = s.policy();
+ assert_eq!(pol.per_tx_cap_wei, U256::from(PER_TX_CAP));
+ assert!(!pol.revoked);
+ let bal = s.balance(false);
+ assert_eq!(bal.public_wei, U256::ZERO); // unset → zero, never a key
+
+ // The read responses serialize without any "passphrase"/secret field.
+ let json = serde_json::to_string(&pol).unwrap();
+ assert!(!json.contains("passphrase"));
+
+ // ---- T3: propose an over-cap Send → NeedsApproval (NOT Allow) ----------------------
+ let over = s.propose(&intent(IntentKind::Send, OVER_CAP_VALUE));
+ let req_id = match over {
+ Decision::NeedsApproval { request_id } => request_id,
+ other => panic!("T3 expected NeedsApproval, got {other:?}"),
+ };
+
+ // ---- T4: execute before approval → Denied (never signs on Pending) -----------------
+ assert!(matches!(s.execute(req_id), ExecuteResult::Denied { .. }));
+ assert_eq!(s.status(req_id), ApprovalStatus::Pending);
+
+ // ---- T6: shield within cap with OverCap → Allow; execute → broadcast ---------------
+ let shield = s.propose(&intent(IntentKind::Shield, WITHIN_CAP_VALUE));
+ assert_eq!(shield, Decision::Allow);
+ let shield_id = s.last_request_id().expect("Allow minted a request id");
+ assert_eq!(
+ s.execute(shield_id),
+ ExecuteResult::Broadcast {
+ tx_hash: MockSigner::broadcast_tx_hash()
+ }
+ );
+ // the shield spend is now reflected in policy
+ assert_eq!(s.policy().spent_today_wei, U256::from(WITHIN_CAP_VALUE));
+
+ // ---- T8: approve an over-cap write, STOP, then execute → Denied{revoked} (TOCTOU) --
+ let pending = match s.propose(&intent(IntentKind::Send, OVER_CAP_VALUE)) {
+ Decision::NeedsApproval { request_id } => request_id,
+ other => panic!("T8 setup expected NeedsApproval, got {other:?}"),
+ };
+ s.approve(pending); // human approved BEFORE the STOP
+ assert_eq!(s.status(pending), ApprovalStatus::Allowed);
+ s.revoke_all();
+ assert_eq!(
+ s.execute(pending),
+ ExecuteResult::Denied {
+ reason: "revoked".into()
+ }
+ );
+ // STOP is sticky: further proposes are denied too.
+ assert_eq!(
+ s.propose(&intent(IntentKind::Send, WITHIN_CAP_VALUE)),
+ Decision::Deny {
+ reason: "revoked".into()
+ }
+ );
+}
diff --git a/docs/build/00-test-harness.md b/docs/build/00-test-harness.md
new file mode 100644
index 0000000..7de104a
--- /dev/null
+++ b/docs/build/00-test-harness.md
@@ -0,0 +1,253 @@
+# 00 · v0 Test Harness — local devnet + agentic self-test
+
+> Purpose: a fully-controlled local chain + a headless agentic runner so the agent, CI, and an AI coding agent can self-test every later feature. · Serves: the whole demo acceptance test (shot-list steps 1–5 of [v1-demo-plan.md](../research/v1-demo-plan.md)) — this doc is the substrate the other three build docs test against. · Status: spec. Part of the Deckard build docs.
+
+## Why this exists
+
+The v1 demo is one continuous mainnet recording of `receive → instant auto-shield → walkaway` (v1-demo-plan §"The video"). You cannot rehearse that on mainnet — it costs gas, you can't *trigger* an inbound payment on cue, and you can't safely cut RPCs. So before any feature lands we build a local environment we fully control plus a headless runner that drives the exact shot-list and asserts pass/fail. The shot-list **is** both the CI gate and the storyboard (v1-demo-plan §"Acceptance test = the shot list"), so the harness is the single thing that proves "shootable, today."
+
+## Where it sits — Depends on / Unblocks
+
+- **Depends on:** Foundry (anvil/cast/forge), Docker + Kurtosis (for the Helios lane), Sepolia RPC keys. The Intent/Decision/daemon-socket contract is **owned by [30-mcp-shape.md](30-mcp-shape.md)** — this harness drives it but does not define it.
+- **Unblocks:** [10-kohaku-shield.md](10-kohaku-shield.md) (shield/unshield spiked on the anvil-fork lane), [20-helios-sidecar.md](20-helios-sidecar.md) (verified reads + walkaway on the Kurtosis/Sepolia lanes), [30-mcp-shape.md](30-mcp-shape.md) (the runner is a deterministic, LLM-free client of the daemon socket — proves the contract before Claude Desktop is in the loop).
+- **Demo:** every beat. Step 1 receive-watcher, step 2 shield, step 3 walkaway, fast-follow steps 4 STOP / 5 allocate.
+
+## Architecture / approach
+
+Three lanes, because **one chain can't do everything** and the real constraint is Helios.
+
+> **The Helios constraint, stated honestly.** Helios is a light client: its consensus layer verifies the execution layer against the beacon chain's **sync committee**, rooted at a trusted weak-subjectivity **checkpoint**; the execution layer then uses an *untrusted* EL RPC for verified data ([a16z/helios README](https://github.com/a16z/helios/blob/master/README.md), [config.md](https://github.com/a16z/helios/blob/master/config.md)). Concretely Helios needs **two** upstreams: a `--consensus-rpc` that "must be a consensus node that supports the light client beaconchain api," and an `--execution-rpc` that "must be an execution node that supports the light client execution api" (README/config.md). **A bare `anvil --fork-url` has no beacon chain and no CL at all**, so Helios cannot point at plain anvil. This is the single fact that shapes the lane split — do not paper over it.
+
+| Lane | Chain | Helios? | What it's for | Cost/speed |
+|---|---|---|---|---|
+| **A · anvil-fork** | `anvil --fork-url ` | **No** (no CL) | Fast EL iteration: shield/unshield spikes, receive-watcher, send tx, MCP/daemon contract, agent loop. The default dev + CI lane. | seconds, free |
+| **B · Kurtosis devnet** | `ethpandaops/ethereum-package` (EL + CL over Docker) | **Yes**, end-to-end local | Full Helios integration + walkaway with zero external dependencies; CL light-client API local. | minutes to spin up |
+| **C · Sepolia** | public Sepolia | **Yes** (public beacon + EL light-client RPC) | Helios/walkaway integration against a real network; Kohaku/Kohaku-extension is Sepolia-only ([03-kohaku.md](../research/03-kohaku.md)); shield fallback target if the alpha Railgun crate misbehaves on mainnet. | live testnet |
+
+**Recommended default split:** Lane A for everyday dev + the per-push CI gate (fast, deterministic, can trigger receives on demand via cheatcodes). Lane B/C for the Helios+walkaway integration, run nightly/gated. The mainnet hero is shot only after A is green and B **or** C proves Helios continuation (per v1-demo-plan §"Reliability plan").
+
+The **agentic runner** is a headless Rust driver (a `#[tokio::test]` integration test plus a `deckard-harness` bin) that: brings up a lane, runs the scenario DSL, and asserts. It speaks to the signer daemon over the socket defined in 30-mcp-shape.md. It has a **deterministic mode** (a `FakeModel` adapter that replays scripted intents) so the gate never depends on a live LLM, and a **live mode** that drives Claude Desktop via the MCP sidecar for the real take.
+
+## Concrete interface
+
+### File layout
+
+```
+crates/harness/ # the runner (new crate)
+ src/lib.rs # Lane, Scenario, Runner, asserts
+ src/lanes/anvil.rs # spawn anvil --fork-url, cast helpers
+ src/lanes/kurtosis.rs # kurtosis run + endpoint discovery
+ src/lanes/sepolia.rs # env-driven endpoints
+ src/model.rs # ModelAdapter trait: FakeModel | ClaudeMcp
+ tests/shot_list.rs # #[tokio::test] the acceptance scenario
+fixtures/
+ addresses.mainnet.json # USDC, Railgun contracts (see below)
+ accounts.json # HD mnemonic + derived payer/wallet/extra
+ scenarios/shield_on_receive.json
+scripts/
+ anvil-fork.sh kurtosis-up.sh fund.sh trigger-receive.sh
+.github/workflows/harness.yml
+```
+
+### Lane A — anvil fork (the controllable chain)
+
+```bash
+# Fork mainnet at a pinned block so contracts (USDC, Railgun) exist and fixtures are deterministic.
+anvil --fork-url "$MAINNET_RPC_URL" --fork-block-number 22000000 \
+ --mnemonic "test test test test test test test test test test test junk" \
+ --accounts 10 --balance 10000 --chain-id 31337 --port 8545
+```
+
+Default mnemonic gives 10 accounts × 10000 ETH; it is public — dev only ([Foundry: Anvil overview](https://getfoundry.sh/anvil/overview/)).
+
+**Cheatcodes that make the chain fully controllable** (exact names verified against [Foundry · Anvil custom methods](https://getfoundry.sh/anvil/custom-methods)). These drive the demo's "live receive" beat in tests — we *trigger* inbound payments on command:
+
+| Need | Method | Use in the harness |
+|---|---|---|
+| Fund any address | `anvil_setBalance` | top up payer / wallet |
+| Send *as* a whale (e.g. a USDC holder) | `anvil_impersonateAccount` / `anvil_stopImpersonatingAccount` | move real USDC into the wallet to fire the receive watcher |
+| Force-mine | `anvil_mine` / `evm_mine` | confirm the inbound tx, advance state |
+| Advance time | `evm_increaseTime` / `evm_setNextBlockTimestamp` | age checkpoints, test timeouts |
+| Poke storage directly | `anvil_setStorageAt` | set an ERC-20 balance slot without a transfer (fastest "receive") |
+| Inject code / nonce | `anvil_setCode` / `anvil_setNonce` | mock a contract if needed |
+| Mining policy | `evm_setAutomine` / `evm_setIntervalMining` | step-mode vs interval for deterministic tests |
+| Save/restore | `evm_snapshot` / `evm_revert` | reset between scenario steps cheaply |
+
+Two ways to "trigger a live receive," fastest first:
+1. **Storage poke** — compute the ERC-20 balance slot and `anvil_setStorageAt` (no real holder needed). Best for ETH/native and for an instant deterministic bump.
+2. **Impersonate a real holder** — `anvil_impersonateAccount()` then `cast send "transfer(address,uint256)" --from --unlocked`, then `anvil_mine`. Best for an end-to-end `Transfer` log the receive-watcher consumes (drives step 1 from real logs).
+
+cast/forge scripting examples:
+```bash
+cast rpc anvil_setBalance "$PAYER" 0xDE0B6B3A7640000 # 1 ETH
+cast rpc anvil_impersonateAccount "$USDC_WHALE"
+cast send "$USDC" "transfer(address,uint256)" "$WALLET" 1000000 \
+ --from "$USDC_WHALE" --unlocked --rpc-url http://127.0.0.1:8545 # 1 USDC (6 dp)
+cast rpc anvil_mine 1
+```
+
+### Lane B — Kurtosis local EL+CL devnet (Helios can point at it)
+
+```bash
+# Spins up EL (geth/reth) + CL (lighthouse/teku/…) over Docker, exposes beacon + EL RPC.
+kurtosis run --enclave deckard-devnet github.com/ethpandaops/ethereum-package
+kurtosis enclave inspect deckard-devnet # discover EL RPC + beacon (CL) ports
+```
+
+`ethpandaops/ethereum-package` deploys both layers and exposes a Beacon API (CL) and JSON-RPC (EL); it supports a fresh `kurtosis` genesis or a public-network shadowfork, and light clients like Helios can point at the local endpoints ([ethpandaops/ethereum-package](https://github.com/ethpandaops/ethereum-package)). Then point Helios at the local endpoints:
+```bash
+helios ethereum --network kurtosis \
+ --consensus-rpc http://127.0.0.1: \
+ --execution-rpc http://127.0.0.1: \
+ --checkpoint
+# Helios serves a verified local JSON-RPC on http://127.0.0.1:8545
+```
+⚠ **unverified:** that the chosen Kurtosis CL client serves the **light-client beaconchain API** out of the box — Lighthouse gates this behind `--light-client-server` (and the EL needs the light-client execution API). The harness's `kurtosis.rs` must set the CL/EL flags to enable both, and assert Helios reaches `synced` before proceeding. Spike this on day one of the Helios lane; if a client won't serve it, fall back to Lane C (Sepolia) for the walkaway integration.
+
+### Lane C — Sepolia
+
+`MAINNET_RPC_URL` unused; set `SEPOLIA_EXECUTION_RPC` + `SEPOLIA_CONSENSUS_RPC` (a Nimbus/Lodestar beacon supporting the light-client API) and run `helios ethereum --network sepolia --checkpoint `. This is the Kohaku-compatible lane (Sepolia-only) and the shield fallback.
+
+### Helios as a library (Rust, in-process)
+
+Latest Helios is **0.11.1** (Feb 2026); the crate was restructured from the umbrella `helios` into `helios-ethereum` exposing `EthereumClientBuilder` ([docs](https://docs.rs/zemse-helios-ethereum/latest/zemse_helios_ethereum/) shows the `EthereumClientBuilder` re-export). The README's `ClientBuilder::new().network(...).consensus_rpc(...).execution_rpc(...).build()` + `client.start()` + `client.get_balance(addr, BlockTag::Latest)` pattern is the shape; the harness depends on it as a lib so reads are verified in-process. ⚠ **unverified:** exact 0.11.x builder type path and method signatures — pin the version and confirm against `helios-ethereum` docs when wiring 20-helios-sidecar.md. Useful flag: `--strict-checkpoint-age` (`-s`) errors on >2-week-old checkpoints (README).
+
+### Fixtures (mainnet, available via fork)
+
+`fixtures/addresses.mainnet.json` — verified mainnet addresses:
+```json
+{
+ "USDC": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ "RailgunSmartWallet": "0xc0BEF2D373A1EfaDE8B952f33c1370E486f209Cc",
+ "RailgunRelayProxy": "0xfa7093cdd9ee6932b4eb2c9e1cde7ce00b1fa4b9",
+ "USDC_WHALE": ""
+}
+```
+Railgun addresses verified via Etherscan: SmartWallet `0xc0BEF2D373A1EfaDE8B952f33c1370E486f209Cc`, Relay proxy `0xfa7093cd…` ([Etherscan: Railgun Relay](https://etherscan.io/address/0xfa7093cdd9ee6932b4eb2c9e1cde7ce00b1fa4b9)). USDC is the canonical mainnet token. The exact Railgun contracts 10-kohaku-shield.md targets are **owned by that doc** — keep this fixture file the single source and let 10-kohaku-shield.md add what it needs. ⚠ **unverified:** pick + verify a `USDC_WHALE` holding ≥ demo amount at the pinned fork block before relying on the impersonate path.
+
+`fixtures/accounts.json` — deterministic roles off the anvil mnemonic:
+```json
+{ "mnemonic": "test test test test test test test test test test test junk",
+ "wallet": "m/44'/60'/0'/0/0", "payer": "m/44'/60'/0'/0/1", "extra": ["…/0/2","…/0/3"] }
+```
+`wallet` is the address under test (the one Deckard's keystore holds); `payer` sends the inbound tx.
+
+### Scenario DSL (`fixtures/scenarios/*.json`)
+
+A flat list of steps the runner executes; each step has a `lane` op and an `assert`. Mirrors v1-demo-plan's shot-list verbatim:
+```json
+{
+ "name": "Shield-on-Receive, Trustless",
+ "lane": "anvil-fork",
+ "setup": { "unlock_keystore": true, "helios": "kurtosis|sepolia|none",
+ "agent_policy": "auto-shield inbound ETH above 0.01" },
+ "steps": [
+ { "op": "receive", "from": "payer", "asset": "ETH", "amount": "0.05",
+ "assert": "watcher_fires_within_seconds <= 5 && source == verified_logs" },
+ { "op": "agent_intent", "intent": "shield", "amount": "0.05",
+ "assert": "private_balance_up && public_balance_down && link_broken && tx_confirmed" },
+ { "op": "cut_rpc", "target": "primary",
+ "assert": "balances_still_verified_via_helios && no_crash" },
+ { "op": "agent_intent", "intent": "execute", "after": "stop",
+ "assert": "denied" },
+ { "op": "allocate", "fraction": 0.1, "assert": "rule_honored" }
+ ]
+}
+```
+`op: "agent_intent"` is dispatched through the daemon socket **as defined in 30-mcp-shape.md** — this harness does not define the Intent/Decision shape, it constructs and submits whatever that doc specifies. In deterministic mode `FakeModel` emits the intent directly; in live mode the same intent originates from Claude Desktop via the MCP sidecar.
+
+### Model adapter (LLM-free determinism)
+
+```rust
+pub trait ModelAdapter {
+ /// Given the observed receive event, produce the next intent to submit to the daemon.
+ async fn next_intent(&mut self, ctx: &ScenarioCtx) -> Intent; // Intent type owned by 30-mcp-shape.md
+}
+pub struct FakeModel { script: Vec } // replays fixtures, no network, CI default
+pub struct ClaudeMcp { /* drives Claude Desktop over the MCP sidecar */ } // live take only
+```
+
+## v0 baseline / spike plan + acceptance test
+
+**Build order (this is the v0 baseline — build it before features):**
+1. `scripts/anvil-fork.sh` + `fixtures/` + cast helpers → can fork, fund, and *trigger a receive* on demand.
+2. `crates/harness` Lane A + `FakeModel` + the daemon-socket client → run the scenario headless, deterministic.
+3. `tests/shot_list.rs` asserting steps 1–2 against the anvil-fork lane (no Helios).
+4. Lane B (Kurtosis) + Helios-as-lib → add step 3 (walkaway) end-to-end local; Lane C as fallback.
+5. CI wiring.
+
+**Agent-runnable acceptance test (run this to self-verify the harness itself):**
+```bash
+# A0 · tools present
+anvil --version && cast --version && forge --version # assert: exit 0
+# A1 · fork comes up with real contracts
+bash scripts/anvil-fork.sh & sleep 3
+cast code 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --rpc-url http://127.0.0.1:8545 \
+ | grep -q '0x60' # assert: USDC bytecode present (non-empty)
+# A2 · cheatcode-driven receive works
+WALLET=$(cast wallet address --mnemonic "test test test test test test test test test test test junk" --mnemonic-index 0)
+cast rpc anvil_setBalance "$WALLET" 0x16345785D8A0000 --rpc-url http://127.0.0.1:8545 # 0.1 ETH
+cast balance "$WALLET" --rpc-url http://127.0.0.1:8545 | grep -q 100000000000000000 # assert: balance set
+# A3 · the deterministic scenario passes with no LLM and no Helios
+cargo test -p harness --test shot_list -- --nocapture # assert: steps 1–2 PASS (FakeModel, anvil-fork)
+# A4 · the Helios lane reaches synced and survives an RPC cut (Kurtosis or Sepolia)
+HARNESS_LANE=sepolia cargo test -p harness --test shot_list helios_walkaway -- --ignored --nocapture
+# assert: helios.status == synced; after cut_rpc, get_balance still returns a verified value; no panic
+```
+A0–A3 are the per-push gate. A4 is the nightly/gated Helios lane.
+
+### CI wiring (`.github/workflows/harness.yml`)
+
+```yaml
+on: [push, pull_request]
+jobs:
+ anvil-fork-gate: # every push — fast, deterministic, the real gate
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: foundry-rs/foundry-toolchain@v1
+ - run: bash scripts/anvil-fork.sh & sleep 3
+ - run: cargo test -p harness --test shot_list # A1–A3, FakeModel, no Helios
+ env: { MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} }
+ helios-walkaway-nightly: # nightly + manual — the integration lane
+ if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: foundry-rs/foundry-toolchain@v1
+ - run: cargo test -p harness --test shot_list helios_walkaway -- --ignored # A4
+ env:
+ SEPOLIA_EXECUTION_RPC: ${{ secrets.SEPOLIA_EXECUTION_RPC }}
+ SEPOLIA_CONSENSUS_RPC: ${{ secrets.SEPOLIA_CONSENSUS_RPC }}
+# add `on: schedule: - cron: '0 6 * * *'` at top level for nightly.
+```
+Pin `--fork-block-number` so the fork lane is reproducible and doesn't hammer the upstream RPC.
+
+## Risks & fallbacks
+
+- **Helios won't run on plain anvil** (no CL). *Fallback:* that's why Lanes B/C exist; the anvil-fork gate runs `helios: "none"` and step 3 is exercised on Kurtosis/Sepolia only.
+- **Kurtosis CL doesn't expose the light-client beacon API by default** (⚠ unverified). *Fallback:* enable the flags (`--light-client-server` on Lighthouse + EL light-client API), else use Lane C (Sepolia) for walkaway; the mainnet hero only needs *one* of B/C green.
+- **Helios crate API moved** (umbrella → `helios-ethereum`/`EthereumClientBuilder`, latest 0.11.1). *Fallback:* pin the version; 20-helios-sidecar.md owns the exact builder wiring and confirms signatures.
+- **Live LLM flakiness** in the take. *Fallback:* `FakeModel` is the CI default; an in-app agent loop can drive the same MCP tools if Claude Desktop flakes on stage (v1-demo-plan §"Reliability plan").
+- **Fork RPC rate limits / drift.** *Fallback:* pinned block + a cached fork; run a local reth archive if needed.
+- **USDC_WHALE balance changes by block.** *Fallback:* prefer the `anvil_setStorageAt` balance-slot poke for determinism; reserve impersonation for the real-`Transfer`-log path.
+
+## Open questions
+
+- Which Kurtosis CL client (Lighthouse/Teku/Nimbus) reliably serves the **light-client beaconchain API** for Helios with minimal flags, and how long is its spin-up vs Sepolia? (⚠ unverified — spike both.)
+- Is fork-lane block-pinning compatible with 10-kohaku-shield.md's Railgun proof generation (do circuits/POI need live state newer than the pin)?
+- Does the daemon socket (30-mcp-shape.md) expose a test/inject hook the runner can use to fast-path `agent_intent` without a full MCP round-trip in deterministic mode?
+- For the walkaway beat, what's the cleanest "cut the RPC" primitive in tests — drop the upstream via a local proxy (toxiproxy) we can kill, or swap Helios's `execution_rpc` to a dead URL and assert it continues from cache/secondary?
+- Do we need a second EL upstream for Helios to *continue* after a cut, or does Helios serve cached/verified reads from its last finalized state (determines whether step 3 is "continues live" vs "shows last-verified")?
+
+## Sources
+
+- v1 demo plan (shot-list, lanes, reliability) — [docs/research/v1-demo-plan.md](../research/v1-demo-plan.md)
+- Kohaku research (Sepolia-only extension; Railgun alpha crate) — [docs/research/03-kohaku.md](../research/03-kohaku.md)
+- Foundry · Anvil overview (default mnemonic, 10×10000 ETH, fork) — https://getfoundry.sh/anvil/overview/
+- Foundry · Anvil custom methods (exact cheatcode names) — https://getfoundry.sh/anvil/custom-methods
+- a16z/helios README (consensus+execution light-client API requirement, CLI flags, custom networks) — https://github.com/a16z/helios/blob/master/README.md
+- a16z/helios config.md (`consensus_rpc`/`execution_rpc`/`checkpoint`, `max_checkpoint_age`, fallbacks) — https://github.com/a16z/helios/blob/master/config.md
+- helios-ethereum `EthereumClientBuilder` (restructured crate; latest 0.11.1) — https://docs.rs/zemse-helios-ethereum/latest/zemse_helios_ethereum/
+- ethpandaops/ethereum-package (Kurtosis EL+CL devnet, beacon + EL RPC, fresh genesis or shadowfork) — https://github.com/ethpandaops/ethereum-package
+- Etherscan · Railgun Relay/SmartWallet mainnet contracts — https://etherscan.io/address/0xfa7093cdd9ee6932b4eb2c9e1cde7ce00b1fa4b9
diff --git a/docs/build/10-kohaku-shield.md b/docs/build/10-kohaku-shield.md
new file mode 100644
index 0000000..6ed92c4
--- /dev/null
+++ b/docs/build/10-kohaku-shield.md
@@ -0,0 +1,188 @@
+# Kohaku / Railgun Shield Integration
+
+> Auto-shield received funds into an owner-only private balance using Kohaku's pure-Rust `railgun` crate · serves demo beat 2 (HERO "receive → instantly private") and acceptance step 2 (`shield(amount)` → private ↑, public ↓, link broken) · status: spec. Part of the Deckard build docs.
+
+## Why this exists (2-4 sentences, concrete)
+
+Beat 2 of the video is the hero action: a payment lands, the agent calls `shield(amount)`, and the funds move into a Railgun shielded pool where the balance is visible only to the owner's `0zk` viewing key. We consume the pure-Rust `railgun` crate inside `ethereum/kohaku` (an alloy-based `rlib`), **not** the `@kohaku-eth/railgun` WASM/TS wrapper, so Deckard's native GPUI/Rust process links it directly with no JS bridge. This is risk **R1** (alpha tooling, `@kohaku-eth/railgun@0.0.1-alpha.22`): the open question was whether the crate is standalone-consumable from Rust — the repo's integration tests answer **yes** (verified below), so the spec is "wire it up + spike on a fork," not "reverse-engineer a TS lib."
+
+## Where it sits — Depends on / Unblocks (cross-doc + demo)
+
+- **Depends on `20-helios-sidecar.md`** — all chain reads (UTXO/TXID sync, balance, on-chain state) go through an EIP-1193 provider; in the demo that provider is Helios over a private RPC. `RailgunBuilder::new(chain, provider)` takes `impl IntoEip1193Provider`, which is the seam (verified, see Interface).
+- **Depends on `30-mcp-shape.md`** — owns the `Intent` / `Decision` / daemon-socket CONTRACT. The `shield(amount)` intent shape and the rule "auto-shield inbound ETH above X" are defined there; this doc only describes what the daemon does when it receives a `Shield` decision. Do **not** redefine the Intent enum here.
+- **Depends on `00-test-harness.md`** — the anvil-mainnet-fork / Sepolia-fork harness, env vars (`RPC_URL_SEPOLIA`, `RPC_URL_MAINNET`), and the assertion runner used by the R1 spike below.
+- **Unblocks** beat 2 / acceptance step 2 entirely. Without a working shield, the HERO action does not exist and the video has no payload.
+- **Sibling, not dependency:** the **receive watcher** (deliverable #3) detects the inbound tx and emits the intent; it lives in T-Core, not here.
+
+## Architecture / approach
+
+The signer daemon (the only process holding key material — see `30-mcp-shape.md`) owns a long-lived `RailgunProvider`. The flow per the verified integration test `crates/railgun/tests/integration/transact_utxo.rs`:
+
+```
+inbound ETH lands at the EOA
+ → receive watcher fires (Helios-verified logs) [T-Core, 20-helios]
+ → MCP: agent issues shield intent [30-mcp-shape]
+ → daemon: railgun.shield().shield_native(zk_addr, value)
+ .build(rng) → Vec [this doc]
+ → daemon signs + submits the shield deposit txs (EOA pays) [alloy / 08]
+ → railgun.sync() [reads via Helios]
+ → railgun.balance(zk_addr) → private balance ↑, public ↓
+ → UI renders "before/after, trail broken" [T-UX, #9]
+```
+
+Two distinct key materials, do not conflate:
+1. the **EOA secp256k1 key** (`src/wallet.rs`, alloy `PrivateKeySigner`) — pays gas and signs the public shield-deposit tx;
+2. the **Railgun spending+viewing keypair** (`railgun::account::signer::PrivateKeySigner`, a `RailgunSigner` over Poseidon/babyjubjub) — owns the `0zk` address and the private balance. Both live only in the signer daemon. The Railgun keys can be derived deterministically from the EOA seed (BIP-39 next increment) via `spending_key_path(index)` / `viewing_key_path(index)`, so backup is one seed.
+
+**Shield is the only on-camera operation.** Shield is a *public* deposit tx from the EOA into the `railgun_smart_wallet` contract — the EOA paying it does not leak the private balance (that's the point of the pool). Private **transfer** and **unshield** must go through a **broadcaster** (4337 bundler) to avoid linking the EOA at withdrawal; those are fast-follow (see Shield lifecycle).
+
+### Shield lifecycle — v1 vs fast-follow
+
+| Step | What it does | EOA exposure | v1 demo? |
+|---|---|---|---|
+| **shield** | deposit ERC-20/ETH into the pool, credit a `0zk` note owned by the viewing key | EOA visibly deposits (expected) | **YES — the HERO** |
+| **balance/sync** | sync UTXO/TXID state, decrypt owner notes, report private balance | read-only | **YES** (proves "trail broken") |
+| private **transfer** | move value `0zk → 0zk`, encrypted | must use broadcaster or EOA leaks | fast-follow |
+| **unshield** | withdraw `0zk → EOA/any address` | must use broadcaster or EOA leaks at exit | fast-follow (needed for R1 acceptance assert + fund recovery) |
+
+The v1 demo needs **shield-on-receive + private-balance proof** only. Unshield is in the spike (to assert the link can be broken *and* funds recovered) but is not on camera.
+
+### Proving
+
+Proof generation (Groth16/BN254, ark-* + the patched ZK deps in the workspace `Cargo.toml`) happens **locally, in-process** inside `.build()` / `railgun.build(tx)`. There is no remote prover, so no metadata leak from proving — consistent with Deckard's local-first posture. **Cost is the open question** (see Open questions): the circuit + witness generation for a 1-in/2-out shield is the latency budget for "instant" auto-shield. Mitigation if proving is slow: enable the crate's `parallel` feature (the workspace patches `ark-*` for parallel proving), pre-warm the prover, and have the UI show "shielding…" between the deposit-tx confirmation and the proof landing rather than promising sub-second.
+
+### Broadcasters / relayers (fast-follow, but spec'd now)
+
+Transfer/unshield must not originate from the owner EOA or the privacy is lost at the edges. The crate submits these via a **4337-style broadcast**: `railgun.prepare_userop(tx, bundler, delegator_address, signer, fee_token, tail_calls, rng)` → a signable UserOperation; a *separate* `delegator` signer (the broadcaster's relay account, not the owner EOA) signs and the **bundler** submits it. A `TailCall` can atomically unwrap WETH→ETH on unshield. This is exactly what an unattended agent needs: it can move private funds without ever exposing the owner address (verified in `broadcast_utxo.rs`).
+
+Compliance model: Railgun uses **Private Proof of Innocence (PPOI)** — a non-membership proof against blocklists, submitted to a POI node via JSON-RPC `ppoi_submit_transact_proof` (verified in `poi/client.rs`). Opt in with `RailgunBuilder::with_poi()`. v1 shield-on-receive does not strictly require POI submission to credit the private balance, but transfers/unshields out of the pool are gated on POI in production; build the daemon with `.with_poi()` so the path is exercised in the spike.
+
+### RPC
+
+Reads (UTXO sync, TXID sync, balance, `eth_call`) go through the `Eip1193Provider` Deckard hands `RailgunBuilder` — in the demo that is **Helios over a private RPC** (see `20-helios-sidecar.md`). UTXO syncing additionally uses a **Subsquid endpoint** (`ChainConfig.subsquid_endpoint`, with an `RpcSyncer` fallback chained after it) for fast historical scan — that path is a read of public pool events, not address-bearing, but note it as a second network dependency. Shield deposit tx submission is a normal alloy `send_transaction`. Transfer/unshield submission goes through the broadcaster/bundler.
+
+## Concrete interface (commands, types, crate names, RPC methods, file layout)
+
+**Crate:** `railgun`, version `0.1.0`, edition `2024`, `[lib] crate-type = ["rlib"]`, `default = []`, feature `js = ["dep:tsify","dep:wasm-bindgen"]` (we do **not** enable `js`), plus `parallel`, `testing`, `bench`. Depends on `alloy = "1.8"`, `ark-bn254 = "0.6"`, `tokio = "1.49"`. There is **no `license` field in the crate's own `Cargo.toml`**; the monorepo root `package.json` declares `"license": "MIT"` and the npm artifact is MIT — confirm the Rust crate inherits MIT before depending (see Risks). [crate Cargo.toml, root package.json]
+
+**Cargo dependency** (no published crate; vendor or git-pin a commit):
+```toml
+[dependencies]
+railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev = "" }
+# transitive: the workspace [patch] block patches ark-* for parallel/ZK — pin the rev so patches resolve
+```
+
+**Public API actually verified in `crates/railgun/src/`:**
+
+```rust
+// builder.rs
+RailgunBuilder::new(chain: ChainConfig, provider: impl IntoEip1193Provider) -> RailgunBuilder
+ .with_utxo_syncer(syncer: Arc) // ChainedSyncer of Subsquid + RpcSyncer
+ .with_database(db: Arc) // persists synced UTXOs / POI proofs
+ .with_poi() // enable PPOI submission
+ .build().await -> Result
+
+// provider.rs — the handle the daemon holds
+impl RailgunProvider {
+ async fn register(&mut self, signer) -> ... // register a 0zk account to track
+ async fn sync(&mut self) -> Result<(), _> // sync UTXO/TXID state via the provider
+ async fn balance(&mut self, addr: RailgunAddress) -> HashMap
+ fn shield(&self) -> ShieldBuilder
+ fn transact(&self) -> TransactionBuilder
+ async fn build(tx, rng) -> ProvedTransaction { tx_data, .. } // direct (EOA-submitted)
+ async fn prepare_userop(tx, bundler, delegator, signer, fee_token, tail_calls: Vec, rng)
+ -> SignableUserOp // broadcaster path (4337)
+}
+
+// transact/shield_builder.rs
+ShieldBuilder::new(chain)
+ .shield(recipient: RailgunAddress, asset: AssetId, value: u128) -> Self
+ .shield_native(recipient: RailgunAddress, value: u128) -> Self
+ .build(rng) -> Result, ShieldError> // submit each TxData via alloy
+
+// transact/transaction_builder.rs
+TransactionBuilder::new()
+ .transfer(from_signer, to: RailgunAddress, asset, value, memo) -> Self
+ .unshield(from_signer, to: Address, asset, value) -> Result
+
+// account/signer.rs
+trait RailgunSigner { fn sign(&self, inputs: U256) -> Result; fn address(&self) -> RailgunAddress; }
+PrivateKeySigner::new_evm(spending_key, viewing_key, chain_id: u64) -> Arc // in-memory railgun keypair
+spending_key_path(index: u32) -> String; viewing_key_path(index: u32) -> String // derivation paths
+
+// chain_config.rs
+ChainConfig::mainnet() -> Self // railgun_smart_wallet, wrapped_base_token, subsquid_endpoint
+ChainConfig::sepolia() -> Self
+ChainConfig::from_chain_id(id) -> Option
+```
+
+**RPC / methods touched:** standard `eth_*` reads through the EIP-1193 provider (→ Helios); Subsquid GraphQL for historical UTXO scan; `eth_sendTransaction`/`eth_sendRawTransaction` for the shield deposit; 4337 bundler `eth_sendUserOperation` for broadcast transfer/unshield; POI JSON-RPC `ppoi_submit_transact_proof`.
+
+**Deckard file layout (proposed):**
+```
+src/shield/
+ mod.rs // pub fn handle_shield_decision(value, asset) -> ShieldResult (called by daemon)
+ client.rs // owns RailgunProvider lifecycle: build once, sync, expose shield/balance
+ keys.rs // derive railgun spending+viewing keys from the EOA seed
+spikes/r1_shield/ // standalone bin for the acceptance test below (or a #[test] in tests/)
+```
+
+## v0 baseline / spike plan + acceptance test (agent-runnable asserts)
+
+**v0 baseline:** none. `src/wallet.rs` is a bare alloy EOA persisting plaintext hex; there is no Railgun code yet.
+
+**R1 spike** — port `crates/railgun/tests/integration/transact_utxo.rs` into Deckard against `00-test-harness.md`'s anvil fork. That test already does the full shield→transfer→unshield with concrete balance asserts; reproducing it from *our* dependency edge proves standalone-consumability. Spike on **Sepolia fork first** (the upstream test forks Sepolia at block `10822990` and uses `RPC_URL_SEPOLIA`), then repeat on an **anvil mainnet fork** with `ChainConfig::mainnet()` before the mainnet hero.
+
+```
+Scenario R1 "shield + unshield, standalone Rust" (anvil fork; Sepolia first, then mainnet fork):
+ setup: anvil --fork-url $RPC; RailgunProvider built with our Eip1193 provider (Helios in demo,
+ plain alloy provider in spike); two railgun accounts registered; WETH deposited+approved
+ to chain.railgun_smart_wallet.
+
+ 1. railgun.shield().shield(acct1, weth, 1_000_000).build(rng); submit each TxData; railgun.sync()
+ assert: railgun.balance(acct1)[weth] == 997_500 # pool fee taken, private balance up
+ assert: railgun.balance(acct2)[weth] == None
+ 2. shield_native(acct1, 100_000) → submit → sync
+ assert: railgun.balance(acct1)[weth] increases # native wrapped + shielded
+ 3. TransactionBuilder::transfer(acct1, acct2, weth, 5_000, "..") → railgun.build → submit → sync
+ assert: balance(acct1) down 5_000; balance(acct2)[weth] == 5_000 # private transfer, no public trace
+ 4. TransactionBuilder::unshield(acct1, EOA, weth, 1_000) → railgun.build → submit → sync
+ assert: WETH.balanceOf(EOA) increased (~998 after fee); balance(acct1) down # link broken, funds recovered
+
+ GREEN = R1 passes → attempt mainnet hero. RED = take a Fallback (below).
+```
+
+The exact numeric asserts (`997_500`, `5_000`, `998`) are copied from the verified upstream test, so a regression in our integration edge is immediately visible. Mark the spike `#[ignore]` (network) and run it explicitly in CI like upstream does.
+
+**Demo acceptance (mirrors `v1-demo-plan.md` step 2):** after R1 is green, the on-camera assert is `private balance ↑, public ↓, link broken; tx confirms`, driven by the receive watcher → MCP `shield` decision → `handle_shield_decision`.
+
+## Risks & fallbacks
+
+- **R1a — alpha API churn.** `0.0.1-alpha.x` (latest `alpha.22`, 2026-05-26); the Rust crate is `0.1.0` and unpublished. *Mitigation:* git-pin a specific commit `rev`; vendor the crate if needed. Do not track `master`.
+- **R1b — mainnet reliability of the alpha crate.** *Fallback (a):* shield on **Sepolia** for the video (`ChainConfig::sepolia()`), keep the Helios walkaway beat on mainnet — explicitly sanctioned by `v1-demo-plan.md`. The upstream test is itself Sepolia, so Sepolia is the better-trodden path.
+- **R1c — crate not standalone-consumable / build breaks.** Largely *retired* by the verified `rlib` + alloy + integration tests, but if the workspace `[patch]` deps or edition-2024 toolchain fight Deckard's build: *Fallback (b):* a thin Node bridge to `@kohaku-eth/railgun@0.0.1-alpha.22` (MIT, published, WASM) spoken to over the daemon socket — slower and adds a JS runtime, last resort.
+- **R1d — proving cost makes "instant" a lie.** *Mitigation:* `parallel` feature + pre-warm; UI shows a "shielding…" state. *Fallback:* shrink the demo amount / pre-shield a warm pool note so the on-camera proof is a 1-out path.
+- **R1e — licensing.** Crate `Cargo.toml` has **no `license` field**; root `package.json` and npm say **MIT**. Deckard is 0BSD. MIT is compatible to vendor/depend on, but **confirm the Rust crate inherits MIT** (open a clarifying issue / check the eventual crate publish) before shipping. ⚠ partial: per-crate license not explicitly declared in-tree.
+- **R1f — Subsquid/broadcaster centralization.** UTXO sync leans on a Subsquid endpoint and broadcast leans on a 4337 bundler — both are network deps that aren't Helios. For the demo, sync is a read of public events (acceptable); shield (the hero) needs no broadcaster. Flag for the "walkaway" narrative: shield-on-receive itself only needs the EOA + the pool contract.
+- **Alternate shielded path (c):** **Privacy Pools** (`@kohaku-eth/privacy-pools`, live on mainnet since Mar 2025) if Railgun is unworkable — but it's marked WIP in the SDK and uses the opposite (allowlist-inclusion) compliance model, so treat as a true last resort, not a drop-in.
+
+## Open questions
+
+- **Proving wall-clock on a desktop:** how long does `ShieldBuilder::build()` / `RailgunProvider::build(tx)` take for a 1-in/2-out shield on an M-series Mac, with and without `parallel`? This sets the "instant" UX claim. (Bench in the R1 spike with `criterion` — the crate already ships `benches/`.) ⚠ unmeasured.
+- **Does the crate's EIP-1193 provider accept Helios cleanly,** or does it need methods Helios doesn't serve (e.g. heavy log ranges for UTXO sync that Helios proxies but Subsquid actually answers)? Verify in the `20-helios-sidecar.md` integration. ⚠ unverified.
+- **Per-crate license:** does `railgun` (no `license` field) inherit the monorepo MIT for a downstream Rust dependency? ⚠ partial.
+- **Mainnet broadcaster availability:** is there a public Railgun 4337 bundler/broadcaster Deckard can use for unshield, or must we run one? (Not needed for v1 shield-on-receive; needed for the unshield fast-follow.) ⚠ unverified.
+- **POI standby:** Railgun's ~1-hour unshield-only standby period (per `06-privacy.md`) — does it affect the on-camera unshield in the spike? (Shield + private-balance proof are unaffected.) ⚠ unverified against the crate.
+
+## Sources (repos + docs, linked)
+
+- `ethereum/kohaku` — privacy SDK monorepo, workspace `Cargo.toml` (8 crates, `alloy 1.8`, `[profile.release-wasm]`) — https://github.com/ethereum/kohaku/blob/master/Cargo.toml — (source, verified)
+- `crates/railgun/Cargo.toml` — `name = "railgun"`, `0.1.0`, edition 2024, `crate-type = ["rlib"]`, `js`/`parallel`/`testing` features, `[[bin]] main` — https://github.com/ethereum/kohaku/blob/master/crates/railgun/Cargo.toml — (source, verified)
+- `crates/railgun/src/{builder,provider}.rs` — `RailgunBuilder::new(chain, impl IntoEip1193Provider)`, `RailgunProvider::{register,sync,balance,shield,transact,build,prepare_userop}` — https://github.com/ethereum/kohaku/tree/master/crates/railgun/src — (source, verified)
+- `crates/railgun/src/transact/{shield_builder,transaction_builder}.rs` — `ShieldBuilder::{shield,shield_native,build}`, `TransactionBuilder::{transfer,unshield}` — https://github.com/ethereum/kohaku/tree/master/crates/railgun/src/transact — (source, verified)
+- `crates/railgun/tests/integration/transact_utxo.rs` — full shield→transfer→unshield with balance asserts on a Sepolia anvil fork (the R1 reference) — https://github.com/ethereum/kohaku/blob/master/crates/railgun/tests/integration/transact_utxo.rs — (source, verified)
+- `crates/railgun/tests/integration/broadcast_utxo.rs` — 4337 broadcaster transfer/unshield via `prepare_userop` + bundler + `delegator` (EOA-unlinking path) — https://github.com/ethereum/kohaku/blob/master/crates/railgun/tests/integration/broadcast_utxo.rs — (source, verified)
+- `crates/railgun/src/poi/client.rs` — PPOI submission via JSON-RPC `ppoi_submit_transact_proof` — https://github.com/ethereum/kohaku/blob/master/crates/railgun/src/poi/client.rs — (source, verified)
+- `@kohaku-eth/railgun` npm — latest `0.0.1-alpha.22` (2026-05-26), 20 versions, license MIT (maturity signal) — https://www.npmjs.com/package/@kohaku-eth/railgun — (registry, verified via registry.npmjs.org)
+- Railgun PPOI (non-membership compliance model, broadcasters, 1h standby) — https://docs.railgun.org/wiki/assurance/private-proofs-of-innocence — (docs, high)
+- Internal: `docs/research/03-kohaku.md`, `docs/research/06-privacy.md`, `docs/research/v1-demo-plan.md`
diff --git a/docs/build/20-helios-sidecar.md b/docs/build/20-helios-sidecar.md
new file mode 100644
index 0000000..d9dd8e4
--- /dev/null
+++ b/docs/build/20-helios-sidecar.md
@@ -0,0 +1,248 @@
+# Helios Light-Client Sidecar
+
+> Embed a16z Helios so every read is verified locally, and to power the demo's WALKAWAY beat (cut the centralized RPC on camera, keep working). Serves demo beat 3 + acceptance step 3. This is risk **R2**. Status: **spike proven on mainnet** (cold ≈11s, warm ≈2s, cut→failover ≤1 block; runnable spike in `spikes/helios-walkaway/`). Part of the Deckard build docs.
+>
+> **Verification note (2026-06-05):** every API/architecture claim below was re-derived from the actual a16z/helios source at tag `0.11.1` (ref `204c998a`) and adversarially re-checked by a second pass — *not* from memory. The numbers come from a runnable spike that actually syncs mainnet and survives a cut EL on this desktop. Anything still unverifiable is flagged ⚠.
+
+## Why this exists (concrete)
+
+Deckard today reads chain state from whatever RPC it's pointed at — a trusted-server assumption Deckard's whole pitch rejects. [Helios](https://github.com/a16z/helios) (a16z, Rust, MIT) turns an *untrusted* execution-layer RPC into a *verified* local endpoint by checking EL state against the consensus-layer sync committee. We embed it as a Rust library and point **all** of Deckard's reads at the local verified client; the demo then cuts the upstream RPC on camera and Deckard keeps serving verified balances. Without this, beat 3 ("works even if Infura — or the EF — disappears") is theater, not a property.
+
+**This is now proven, not hoped.** The spike in `spikes/helios-walkaway/` syncs a real mainnet Helios client, serves the verified deposit-contract balance (86,313,877.35 ETH), then cuts the primary EL RPC and keeps returning that verified balance via a second EL — headless, exit-coded PASS.
+
+## Where it sits — Depends on / Unblocks (cross-doc + demo)
+
+**Depends on:**
+- A private/proxied upstream EL RPC URL and a CL light-client RPC URL — see "Privacy interplay" below and the network plumbing in `00-test-harness.md`.
+- Nothing in the signer/keystore path: Helios is read-only. It never touches the key.
+
+**Unblocks:**
+- **Beat 2 / receive watcher** (`10-kohaku-shield.md`, deliverable #3): the watcher polls `eth_getLogs` / `eth_getBlockByNumber` through the Helios client so the "payment landed" event is itself verified. (Useful nuance, verified below: `get_logs` does **not** go through Helios's 60s head-age gate, so the watcher keeps working a bit differently from `Latest`-tag state reads.)
+- **Beat 3 / walkaway** (deliverable #2): this doc *is* beat 3.
+- **MCP `balance` / `simulate` reads** (`30-mcp-shape.md`): the daemon answers read intents from the Helios client. The `Intent`/`Decision`/daemon-socket contract is **owned by `30-mcp-shape.md`** — this doc only specifies that read intents resolve against the local Helios endpoint and that read status carries a `Verified|Degraded|Unsynced` flag.
+- **`00-test-harness.md`**: owns spinning up the CL for a local devnet so this client has a consensus source to verify against (the Kurtosis section below now has the exact answer).
+
+## Crate + API — verified against source at tag `0.11.1`
+
+This section supersedes the earlier (memory-written) spec. The earlier version had three concrete errors, now fixed: the git tag (`0.11.1`, **no** `v`), `.checkpoint()` did not take a `?`, and the mainnet CL default is not `lightclientdata.org`.
+
+**Crate — depend on `helios-ethereum`, NOT the umbrella `helios`.**
+```toml
+# Cargo.toml
+helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1" }
+
+# Helios's workspace patches ethereum_hashing; [patch] does NOT inherit through a
+# git dependency, so mirror it or the consensus crates fail to build:
+[patch.crates-io]
+ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" }
+```
+- The umbrella `helios` crate re-exports everything (`helios::ethereum::*`) **but also pulls `helios-opstack` → libp2p → a yanked `core2 0.4.0`, which fails to resolve today.** Depending on `helios-ethereum` directly avoids opstack/linea/libp2p entirely (smaller tree, no p2p stack) and builds clean. Verified: the spike builds against `helios-ethereum` in ~2.5 min release.
+- **Not on crates.io.** `helios-ethereum` on crates.io is stale at `0.1.0` (published 2024-10-27); `0.11.1` is **git-only**. Pin the tag (`0.11.1`, not `v0.11.1` — the `v`-prefixed tag is a 404). Re-verify the builder API at any bump (pre-1.0).
+- **alloy alignment is a non-issue (resolved).** Helios pins the `alloy` meta-crate `1.0.37` (caret), which resolves `alloy-primitives` up to Deckard's pinned `1.6.0` — they **unify to a single `alloy-primitives 1.6.0`** in the lock. `Address`/`B256`/`U256`/`BlockId` are the same type at the Helios↔Deckard boundary; no duplicate-types conflict. (Verified in the spike's `Cargo.lock`: one `alloy-primitives`, version `1.6.0`.) revm pins `29.0.1`.
+
+**Library construction — verbatim shape, corrected (`ethereum/src/builder.rs`):**
+```rust
+use helios_ethereum::config::networks::Network;
+use helios_ethereum::database::FileDB;
+use helios_ethereum::{EthereumClient, EthereumClientBuilder};
+use alloy::primitives::B256;
+
+// Turbofish pins the builder's DB type param up front (the builder is
+// generic `EthereumClientBuilder`; `.with_file_db()` exists ONLY on
+// ``, `.with_config_db()` only on ``).
+let client: EthereumClient = EthereumClientBuilder::::new()
+ .network(Network::Mainnet) // Mainnet | Sepolia | Holesky | Hoodi
+ .consensus_rpc(consensus_rpc)? // -> Result (needs ?) our private CL LC-API
+ .execution_rpc(untrusted_el_rpc)? // -> Result (needs ?) our private/proxied EL
+ .checkpoint(trusted_checkpoint_b256) // -> Self (NO ?) takes a B256
+ .strict_checkpoint_age() // -> Self refuse a >14d checkpoint, don't warn
+ // .load_external_fallback() // -> Self community checkpoints — gate behind a flag
+ .data_dir(deckard_data_dir().join("helios"))
+ .with_file_db() // pins DB=FileDB; persists last finalized checkpoint
+ .build()?; // -> Result
+
+client.wait_synced().await?; // returns once CONSENSUS bootstrapped (see caveat ↓)
+```
+Fallible setters returning `Result` (need `?`): `consensus_rpc`, `execution_rpc`, `fallback`, `verifiable_api` (all generic over `T: IntoUrl`, so `&str`/`String`/`Url` all work). Infallible setters returning `Self`: `network`, `checkpoint(B256)`, `data_dir(PathBuf)`, `rpc_address(SocketAddr)`, `config(Config)`, `load_external_fallback()`, `strict_checkpoint_age()`, `with_file_db()`, `with_config_db()`.
+
+**⚠ `wait_synced()` is NOT "ready to serve reads."** It returns once the *consensus* checkpoint is bootstrapped; the latest *execution* head isn't pushed into cache until the next optimistic update (≤1 slot, ~12 s). Until then `get_block_number()` / any `Latest`-tag read fails the 60 s head-age gate with `OutOfSync`. **Poll `get_block_number()` until `Ok` after `wait_synced()`** (the basic.rs example sleeps 15 s for exactly this; the spike polls). This caught us — measure "time to first servable head," not "time to `wait_synced`."
+
+**The read surface lives on the `HeliosApi` trait** (`EthereumClient = HeliosClient` derefs to `Arc>`). The methods Deckard uses, with signatures:
+- `get_balance(Address, BlockId) -> Result` · `get_nonce(..) -> Result` · `get_code(..) -> Result` · `get_storage_at(..) -> Result` · `get_proof(..) -> Result`
+- `get_block_number() -> Result` · `get_block(BlockId, full) -> Result