{
+ prework().map_err(short_err)?;
+ let outcome = client.unlock_blocking(passphrase).map_err(short_err)?;
+ signer::address_or_error(outcome)
+}
+
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Route {
Welcome,
@@ -72,10 +94,13 @@ pub struct Shell {
pub auth_error: Option,
/// True while an Argon2 create/unlock runs on a background thread.
pub auth_busy: bool,
- /// The unlocked wallet's own address (for Receive / copy). `None` until unlocked.
+ /// The unlocked wallet's own address (for Receive / copy). `None` until unlocked. This is
+ /// the ONLY wallet identity the app holds — the key lives in the daemon, never here.
pub wallet_address: Option,
- /// The in-memory unlocked wallet; dropped (and zeroized) on lock.
- unlocked: Option,
+ /// The key-less bridge to the process-isolated signer daemon: the app spawns + supervises
+ /// it and talks over the socket (unlock / propose / execute). Unlock happens *in the
+ /// daemon*; the app only learns the address. Dropping this kills the daemon child.
+ signer: AppSigner,
/// During create: the sealed-but-unwritten vault and its phrase, pending backup.
pending_vault: Option,
pub pending_phrase: Option>,
@@ -106,6 +131,9 @@ pub struct Shell {
/// True only during the first sync (the one allowed loading state).
pub portfolio_loading: bool,
pub portfolio_error: Option,
+ /// Trust label for the last portfolio/block read: Helios-`Verified` vs visibly
+ /// `Unsynced`/`Degraded`. Never silently "trusted" — surfaced in the status line.
+ pub read_status: Option,
/// Latest block height — a liveness/sync indicator for the status line.
pub synced_block: Option,
/// Bumped on every `retarget`; a slow ENS resolution checks it before applying so a
@@ -142,18 +170,21 @@ impl Shell {
.placeholder("https://… (default: bundled public RPC)")
.default_value(settings.rpc_url.clone())
});
- cx.subscribe(&rpc_input, |this, state, event: &InputEvent, cx| match event {
- InputEvent::Change => {
- this.settings.rpc_url = state.read(cx).value().to_string();
- this.settings.save();
- }
- InputEvent::Blur => {
- this.settings.rpc_url = state.read(cx).value().to_string();
- this.settings.save();
- this.respawn_provider(cx);
- }
- _ => {}
- })
+ cx.subscribe(
+ &rpc_input,
+ |this, state, event: &InputEvent, cx| match event {
+ InputEvent::Change => {
+ this.settings.rpc_url = state.read(cx).value().to_string();
+ this.settings.save();
+ }
+ InputEvent::Blur => {
+ this.settings.rpc_url = state.read(cx).value().to_string();
+ this.settings.save();
+ this.respawn_provider(cx);
+ }
+ _ => {}
+ },
+ )
.detach();
// Watch address / ENS: persist as typed; re-target the portfolio on blur.
@@ -162,18 +193,21 @@ impl Shell {
.placeholder("0x… or name.eth (blank = your wallet)")
.default_value(settings.watch_address.clone())
});
- cx.subscribe(&watch_input, |this, state, event: &InputEvent, cx| match event {
- InputEvent::Change => {
- this.settings.watch_address = state.read(cx).value().to_string();
- this.settings.save();
- }
- InputEvent::Blur => {
- this.settings.watch_address = state.read(cx).value().to_string();
- this.settings.save();
- this.retarget(cx);
- }
- _ => {}
- })
+ cx.subscribe(
+ &watch_input,
+ |this, state, event: &InputEvent, cx| match event {
+ InputEvent::Change => {
+ this.settings.watch_address = state.read(cx).value().to_string();
+ this.settings.save();
+ }
+ InputEvent::Blur => {
+ this.settings.watch_address = state.read(cx).value().to_string();
+ this.settings.save();
+ this.retarget(cx);
+ }
+ _ => {}
+ },
+ )
.detach();
// Auth inputs — passphrases are masked and NEVER persisted to disk.
@@ -185,10 +219,12 @@ impl Shell {
let create_pass2 = masked(window, cx, "Confirm passphrase");
let import_pass = masked(window, cx, "Choose a passphrase (min 8 characters)");
let pass_input = masked(window, cx, "Passphrase");
- let confirm_words =
- cx.new(|cx| InputState::new(window, cx).placeholder("the requested words, space-separated"));
- let import_secret = cx
- .new(|cx| InputState::new(window, cx).placeholder("12 / 24-word phrase, or a 0x private key"));
+ let confirm_words = cx.new(|cx| {
+ InputState::new(window, cx).placeholder("the requested words, space-separated")
+ });
+ let import_secret = cx.new(|cx| {
+ InputState::new(window, cx).placeholder("12 / 24-word phrase, or a 0x private key")
+ });
// Submit-on-Enter for each auth field (keyboard-first).
cx.subscribe(&create_pass2, |this, _, event: &InputEvent, cx| {
@@ -231,6 +267,11 @@ impl Shell {
let current_rpc = settings.effective_rpc();
let eth = EthProvider::spawn(current_rpc.clone());
+ // Spawn + supervise the process-isolated signer daemon. It owns the key; the app is a
+ // key-less client that unlocks/signs over the socket. The daemon broadcasts via the
+ // same RPC the app reads from.
+ let signer = AppSigner::launch(current_rpc.clone(), DAEMON_CHAIN_ID);
+
Self {
focus_handle,
route: Route::Welcome,
@@ -244,7 +285,7 @@ impl Shell {
auth_error: None,
auth_busy: false,
wallet_address: None,
- unlocked: None,
+ signer,
pending_vault: None,
pending_phrase: None,
pending_pass: None,
@@ -263,6 +304,7 @@ impl Shell {
portfolio: None,
portfolio_loading: false,
portfolio_error: None,
+ read_status: None,
synced_block: None,
view_epoch: 0,
current_rpc,
@@ -303,9 +345,14 @@ impl Shell {
}
}
- /// Lock the wallet: drop (zeroize) the unlocked secret and return to the unlock gate.
+ /// Lock the wallet: tell the daemon to zeroize the key (best-effort, off the UI thread)
+ /// and return to the unlock gate. The app held no key to drop — locking is the daemon's job.
pub fn lock(&mut self, cx: &mut Context) {
- self.unlocked = None;
+ let client = self.signer.client();
+ cx.background_spawn(async move {
+ let _ = client.lock_blocking();
+ })
+ .detach();
self.wallet_address = None;
self.portfolio = None;
self.auth = AuthStep::Unlock;
@@ -401,24 +448,24 @@ impl Shell {
cx.notify();
return;
};
+ let client = self.signer.client();
let task = cx.background_spawn(async move {
- vault.write_atomic(&path)?;
- vault.unlock(pass.as_str())
+ write_then_unlock(&client, pass.as_str(), move || vault.write_atomic(&path))
});
cx.spawn(async move |this, cx| {
let res = task.await;
this.update(cx, |this, cx| {
this.auth_busy = false;
match res {
- Ok(unlocked) => {
+ Ok(addr) => {
wallet::delete_legacy_key();
this.pending_phrase = None;
this.pending_pass = None;
this.pending_vault = None;
- this.finish_unlock(unlocked, cx);
+ this.finish_unlock(addr, cx);
}
- Err(e) => {
- this.auth_error = Some(short_err(e));
+ Err(msg) => {
+ this.auth_error = Some(msg);
cx.notify();
}
}
@@ -456,34 +503,38 @@ impl Shell {
};
let secret = Zeroizing::new(secret);
let pass = Zeroizing::new(pass);
+ let seal_pass = pass.clone();
+ let client = self.signer.client();
let task = cx.background_spawn(async move {
- let trimmed = secret.trim();
- // Route by shape, not word count: a pure-hex string (optional 0x) is a raw key;
- // anything with spaces/words is a mnemonic, so a short/long phrase gets a real
- // BIP-39 error rather than a misleading "must be 32 bytes".
- let h = trimmed.strip_prefix("0x").unwrap_or(trimmed);
- let looks_like_hex_key = !trimmed.contains(char::is_whitespace)
- && !h.is_empty()
- && h.chars().all(|c| c.is_ascii_hexdigit());
- let vault = if looks_like_hex_key {
- Vault::import_raw_key(trimmed, pass.as_str(), KdfParams::PRODUCTION)?
- } else {
- Vault::import_mnemonic(trimmed, pass.as_str(), KdfParams::PRODUCTION)?
- };
- vault.write_atomic(&path)?;
- vault.unlock(pass.as_str())
+ write_then_unlock(&client, pass.as_str(), move || {
+ let trimmed = secret.trim();
+ // Route by shape, not word count: a pure-hex string (optional 0x) is a raw
+ // key; anything with spaces/words is a mnemonic, so a short/long phrase gets a
+ // real BIP-39 error rather than a misleading "must be 32 bytes".
+ let h = trimmed.strip_prefix("0x").unwrap_or(trimmed);
+ let looks_like_hex_key = !trimmed.contains(char::is_whitespace)
+ && !h.is_empty()
+ && h.chars().all(|c| c.is_ascii_hexdigit());
+ let vault = if looks_like_hex_key {
+ Vault::import_raw_key(trimmed, seal_pass.as_str(), KdfParams::PRODUCTION)?
+ } else {
+ Vault::import_mnemonic(trimmed, seal_pass.as_str(), KdfParams::PRODUCTION)?
+ };
+ vault.write_atomic(&path)?;
+ Ok(())
+ })
});
cx.spawn(async move |this, cx| {
let res = task.await;
this.update(cx, |this, cx| {
this.auth_busy = false;
match res {
- Ok(unlocked) => {
+ Ok(addr) => {
wallet::delete_legacy_key();
- this.finish_unlock(unlocked, cx);
+ this.finish_unlock(addr, cx);
}
- Err(e) => {
- this.auth_error = Some(short_err(e));
+ Err(msg) => {
+ this.auth_error = Some(msg);
cx.notify();
}
}
@@ -507,25 +558,19 @@ impl Shell {
self.auth_error = None;
self.auth_busy = true;
cx.notify();
- let Some(path) = wallet::vault_path() else {
- self.auth_error = Some("no config directory available".into());
- self.auth_busy = false;
- cx.notify();
- return;
- };
+ // No vault write: the daemon reads the existing keystore and decrypts it.
let pass = Zeroizing::new(pass);
- let task = cx.background_spawn(async move {
- let vault = Vault::read(&path)?;
- vault.unlock(pass.as_str())
- });
+ let client = self.signer.client();
+ let task = cx
+ .background_spawn(async move { write_then_unlock(&client, pass.as_str(), || Ok(())) });
cx.spawn(async move |this, cx| {
let res = task.await;
this.update(cx, |this, cx| {
this.auth_busy = false;
match res {
- Ok(unlocked) => this.finish_unlock(unlocked, cx),
- Err(e) => {
- this.auth_error = Some(short_err(e));
+ Ok(addr) => this.finish_unlock(addr, cx),
+ Err(msg) => {
+ this.auth_error = Some(msg);
cx.notify();
}
}
@@ -561,23 +606,28 @@ impl Shell {
return;
};
let pass = Zeroizing::new(pass);
+ let seal_pass = pass.clone();
let hex = Zeroizing::new(hex);
+ let client = self.signer.client();
let task = cx.background_spawn(async move {
- let vault = Vault::import_raw_key(hex.as_str(), pass.as_str(), KdfParams::PRODUCTION)?;
- vault.write_atomic(&path)?;
- vault.unlock(pass.as_str())
+ write_then_unlock(&client, pass.as_str(), move || {
+ let vault =
+ Vault::import_raw_key(hex.as_str(), seal_pass.as_str(), KdfParams::PRODUCTION)?;
+ vault.write_atomic(&path)?;
+ Ok(())
+ })
});
cx.spawn(async move |this, cx| {
let res = task.await;
this.update(cx, |this, cx| {
this.auth_busy = false;
match res {
- Ok(unlocked) => {
+ Ok(addr) => {
wallet::delete_legacy_key();
- this.finish_unlock(unlocked, cx);
+ this.finish_unlock(addr, cx);
}
- Err(e) => {
- this.auth_error = Some(short_err(e));
+ Err(msg) => {
+ this.auth_error = Some(msg);
cx.notify();
}
}
@@ -587,27 +637,21 @@ impl Shell {
.detach();
}
- /// Land in the unlocked app: stash the wallet, derive its address, fetch the portfolio.
- fn finish_unlock(&mut self, unlocked: UnlockedVault, cx: &mut Context) {
- match unlocked.primary_address() {
- Ok(addr) => {
- self.wallet_address = Some(addr);
- self.unlocked = Some(unlocked);
- self.auth = AuthStep::Ready;
- self.auth_error = None;
- self.route = Route::Welcome;
- self.retarget(cx);
- }
- Err(e) => {
- self.auth_error = Some(short_err(e));
- cx.notify();
- }
- }
+ /// Land in the unlocked app: stash the address the daemon returned and fetch the
+ /// portfolio. The key stays in the daemon — the app only holds this address.
+ fn finish_unlock(&mut self, address: Address, cx: &mut Context) {
+ self.wallet_address = Some(address);
+ self.auth = AuthStep::Ready;
+ self.auth_error = None;
+ self.route = Route::Welcome;
+ self.retarget(cx);
}
/// The unlocked wallet's own address as an EIP-55 string (empty until unlocked).
pub fn wallet_address_string(&self) -> String {
- self.wallet_address.map(|a| a.to_string()).unwrap_or_default()
+ self.wallet_address
+ .map(|a| a.to_string())
+ .unwrap_or_default()
}
/// Auto-focus the primary input for the current auth step (so the user — and the
@@ -640,11 +684,13 @@ impl Shell {
this.update(cx, |this, cx| {
this.portfolio_loading = false;
match res {
- Ok(Ok(p)) => {
+ Ok(Ok(read)) => {
// Ignore a stale reply for an address we're no longer viewing.
- if p.address == this.display_address {
- this.portfolio = Some(p);
+ if read.value.address == this.display_address {
+ this.portfolio = Some(read.value);
this.portfolio_error = None;
+ // Surface the trust label (Helios-verified vs unsynced).
+ this.read_status = Some(read.status);
}
}
Ok(Err(e)) => this.portfolio_error = Some(short_err(e)),
@@ -661,9 +707,10 @@ impl Shell {
fn kick_block_number(eth: &EthProvider, cx: &mut Context) {
let rx = eth.block_number();
cx.spawn(async move |this, cx| {
- if let Ok(Ok(n)) = rx.recv_async().await {
+ if let Ok(Ok(read)) = rx.recv_async().await {
this.update(cx, |this, cx| {
- this.synced_block = Some(n);
+ this.synced_block = Some(read.value);
+ this.read_status = Some(read.status);
cx.notify();
})
.ok();
@@ -715,20 +762,21 @@ impl Shell {
return;
}
match res {
- Ok(Ok(addr)) => {
- this.display_address = addr;
- this.refresh_portfolio(cx);
- }
- Ok(Err(e)) => {
- this.portfolio_loading = false;
- this.portfolio_error = Some(format!("couldn't resolve name — {}", short_err(e)));
- cx.notify();
- }
- Err(_) => {
- this.portfolio_loading = false;
- this.portfolio_error = Some("network worker stopped".into());
- cx.notify();
- }
+ Ok(Ok(addr)) => {
+ this.display_address = addr;
+ this.refresh_portfolio(cx);
+ }
+ Ok(Err(e)) => {
+ this.portfolio_loading = false;
+ this.portfolio_error =
+ Some(format!("couldn't resolve name — {}", short_err(e)));
+ cx.notify();
+ }
+ Err(_) => {
+ this.portfolio_loading = false;
+ this.portfolio_error = Some("network worker stopped".into());
+ cx.notify();
+ }
}
})
.ok();
@@ -739,6 +787,12 @@ impl Shell {
/// Re-spawn the network worker against the RPC URL, but only if it actually changed —
/// so a no-op blur of the RPC field doesn't tear down the live worker and refetch.
+ ///
+ /// v1 limitation: this re-points only the *reader*. The signer daemon's RPC + chain are
+ /// fixed at launch (mainnet-first), so changing the RPC here does NOT re-point where the
+ /// daemon would broadcast. There is no send UI yet (T-UX), so nothing broadcasts through a
+ /// diverged endpoint; re-pointing the daemon (and forcing a re-unlock) lands with the send
+ /// screen.
pub fn respawn_provider(&mut self, cx: &mut Context) {
let url = self.settings.effective_rpc();
if url == self.current_rpc {
diff --git a/crates/deckard-app/src/signer.rs b/crates/deckard-app/src/signer.rs
new file mode 100644
index 0000000..a236711
--- /dev/null
+++ b/crates/deckard-app/src/signer.rs
@@ -0,0 +1,198 @@
+//! The app's key-less bridge to `deckard-signerd`.
+//!
+//! This is the whole signing story for the GUI: the app **spawns + supervises** the daemon
+//! and talks to it over the socket. It holds NO key material — no `UnlockedVault`, no
+//! `PrivateKeySigner`. Unlock happens *in the daemon* (the app sends the passphrase and gets
+//! back only an address); the send path sends an `Intent` and gets back a `Decision`/tx hash.
+//! The keystore is only ever touched in-process by *onboarding* (to write `vault.bin`), never
+//! to sign.
+
+use alloy_primitives::{Address, B256};
+use deckard_contract::{Decision, ExecuteResult, Intent, RequestId, UnlockOutcome};
+use deckard_signerd::{DaemonSupervisor, SignerClient};
+
+/// Result of the app's send path (propose, then execute on `Allow`). The path is implemented
+/// and unit-tested here; the GUI send screen that calls it is T-UX (out of scope), so no view
+/// invokes it yet.
+#[allow(dead_code)]
+#[derive(Clone, Debug, PartialEq)]
+pub enum SendOutcome {
+ /// Signed + broadcast by the daemon.
+ Broadcast { tx_hash: B256 },
+ /// Over cap / approval-required: a card must be approved, then `execute(request_id)`.
+ NeedsApproval { request_id: RequestId },
+ /// Refused (locked, off-allowlist, chain mismatch, …).
+ Denied { reason: String },
+}
+
+/// Owns the supervised daemon child and a client to its socket. Dropping it stops the
+/// supervisor and kills the daemon.
+pub struct AppSigner {
+ _supervisor: DaemonSupervisor,
+ client: SignerClient,
+}
+
+impl AppSigner {
+ /// Launch + supervise the daemon and return a key-less handle to it. `rpc_url`/`chain_id`
+ /// are passed to the daemon so it broadcasts on the same chain the app reads from.
+ pub fn launch(rpc_url: String, chain_id: u64) -> Self {
+ let socket_path = deckard_signerd::socket::default_socket_path();
+ let supervisor = DaemonSupervisor::spawn(socket_path.clone(), rpc_url, chain_id);
+ let client = SignerClient::new(socket_path);
+ Self {
+ _supervisor: supervisor,
+ client,
+ }
+ }
+
+ /// A cloneable client for background tasks (the supervisor stays owned by the app). The
+ /// shell uses this for unlock/lock/send so the work runs off the UI thread.
+ pub fn client(&self) -> SignerClient {
+ self.client.clone()
+ }
+}
+
+/// The send path, key-less: `propose` → on `Allow`, `execute`. Never signs in-process — it
+/// only issues `Propose`/`Execute` over the socket. Free function over a [`SignerClient`] so
+/// background threads can call it with a cheap clone. (Awaiting the T-UX send screen; proven
+/// by the unit test below.)
+#[allow(dead_code)]
+pub fn send_blocking(client: &SignerClient, intent: &Intent) -> anyhow::Result {
+ use deckard_contract::SignerRequest;
+
+ let decision = match client.request_blocking(&SignerRequest::Propose {
+ intent: intent.clone(),
+ })? {
+ deckard_contract::SignerResponse::Decision(d) => d,
+ other => anyhow::bail!("unexpected propose response: {other:?}"),
+ };
+ match decision {
+ Decision::Deny { reason } => Ok(SendOutcome::Denied { reason }),
+ Decision::NeedsApproval { request_id } => Ok(SendOutcome::NeedsApproval { request_id }),
+ Decision::Allow => {
+ // The daemon assigns a deterministic id; derive it locally to execute the Allow.
+ let id = SignerClient::request_id_for_intent(intent);
+ match client.request_blocking(&SignerRequest::Execute { request_id: id })? {
+ deckard_contract::SignerResponse::Execute(ExecuteResult::Broadcast { tx_hash }) => {
+ Ok(SendOutcome::Broadcast { tx_hash })
+ }
+ deckard_contract::SignerResponse::Execute(ExecuteResult::Denied { reason }) => {
+ Ok(SendOutcome::Denied { reason })
+ }
+ other => anyhow::bail!("unexpected execute response: {other:?}"),
+ }
+ }
+ }
+}
+
+/// Interpret an [`UnlockOutcome`] into either the wallet address or a user-facing error.
+pub fn address_or_error(outcome: UnlockOutcome) -> Result {
+ match outcome {
+ UnlockOutcome::Unlocked { address } => Ok(address),
+ UnlockOutcome::BadPassphrase => {
+ Err("Wrong passphrase, or the vault was tampered with".to_string())
+ }
+ UnlockOutcome::NoVault => Err("No wallet found — create or import one first".to_string()),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use alloy_primitives::{Bytes, U256};
+ use deckard_contract::{ExecuteResult, IntentKind, SignerRequest, SignerResponse};
+ use deckard_signerd::frame;
+ use std::sync::mpsc;
+ use std::sync::{Arc, Mutex};
+
+ fn send_intent() -> Intent {
+ Intent {
+ chain_id: 31337,
+ to: Address::repeat_byte(0x22),
+ token: None,
+ value: U256::from(1_000u64),
+ calldata: Bytes::new(),
+ kind: IntentKind::Send,
+ }
+ }
+
+ /// #9: the app's send path issues `Propose` then `Execute` over the socket — proving it
+ /// signs nothing in-process (it holds no key; it only speaks the wire). A tiny recording
+ /// UDS server stands in for the daemon and replies `Allow` then `Broadcast`.
+ #[test]
+ fn send_path_issues_propose_then_execute_over_the_socket() {
+ let dir =
+ std::env::temp_dir().join(format!("deckard-appsigner-test-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let sock = dir.join("signerd.sock");
+
+ let seen: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let seen_srv = Arc::clone(&seen);
+ let sock_srv = sock.clone();
+ let (ready_tx, ready_rx) = mpsc::channel();
+
+ // Recording server on its own current-thread runtime; handles two per-call
+ // connections (Propose, then Execute).
+ let server = std::thread::spawn(move || {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .unwrap();
+ rt.block_on(async move {
+ let listener = tokio::net::UnixListener::bind(&sock_srv).unwrap();
+ ready_tx.send(()).unwrap();
+ for _ in 0..2 {
+ let (mut stream, _) = listener.accept().await.unwrap();
+ let buf = frame::read_frame(&mut stream).await.unwrap().unwrap();
+ let req: SignerRequest = frame::decode(&buf).unwrap();
+ let resp = match &req {
+ SignerRequest::Propose { .. } => {
+ seen_srv.lock().unwrap().push("Propose".into());
+ SignerResponse::Decision(Decision::Allow)
+ }
+ SignerRequest::Execute { .. } => {
+ seen_srv.lock().unwrap().push("Execute".into());
+ SignerResponse::Execute(ExecuteResult::Broadcast {
+ tx_hash: B256::repeat_byte(0xAB),
+ })
+ }
+ other => panic!("unexpected request on the wire: {other:?}"),
+ };
+ let body = frame::encode(&resp).unwrap();
+ frame::write_frame(&mut stream, &body).await.unwrap();
+ }
+ });
+ });
+
+ ready_rx.recv().unwrap();
+
+ let client = SignerClient::new(sock);
+ let outcome = send_blocking(&client, &send_intent()).unwrap();
+
+ assert_eq!(
+ outcome,
+ SendOutcome::Broadcast {
+ tx_hash: B256::repeat_byte(0xAB)
+ }
+ );
+ assert_eq!(
+ *seen.lock().unwrap(),
+ vec!["Propose".to_string(), "Execute".to_string()]
+ );
+
+ server.join().unwrap();
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn unlock_outcomes_map_to_address_or_message() {
+ let addr = Address::repeat_byte(0x11);
+ assert_eq!(
+ address_or_error(UnlockOutcome::Unlocked { address: addr }),
+ Ok(addr)
+ );
+ assert!(address_or_error(UnlockOutcome::BadPassphrase).is_err());
+ assert!(address_or_error(UnlockOutcome::NoVault).is_err());
+ }
+}
diff --git a/crates/deckard-app/src/wallet.rs b/crates/deckard-app/src/wallet.rs
index d979336..8500e33 100644
--- a/crates/deckard-app/src/wallet.rs
+++ b/crates/deckard-app/src/wallet.rs
@@ -7,19 +7,18 @@
use std::fs;
use std::path::PathBuf;
-use directories::ProjectDirs;
-
-/// The platform config dir (`~/Library/Application Support/com.deckard.Deckard` on macOS).
+/// The platform config dir (`~/Library/Application Support/com.deckard.Deckard` on macOS),
+/// created if missing. The path itself is resolved by `deckard-core` so the app, onboarding,
+/// and the signer daemon all agree on where `vault.bin` lives.
fn config_dir() -> Option {
- let dirs = ProjectDirs::from("com", "deckard", "Deckard")?;
- let dir = dirs.config_dir().to_path_buf();
+ let dir = deckard_core::config_dir()?;
fs::create_dir_all(&dir).ok()?;
Some(dir)
}
/// Where the encrypted keystore lives.
pub fn vault_path() -> Option {
- Some(config_dir()?.join("vault.bin"))
+ Some(config_dir()?.join(deckard_core::config::VAULT_FILE))
}
/// The legacy plaintext key from the pre-keystore build (raw 32-byte hex).
diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs
index c5f20b6..a3d54a7 100644
--- a/crates/deckard-app/src/welcome.rs
+++ b/crates/deckard-app/src/welcome.rs
@@ -90,18 +90,36 @@ impl Shell {
}
let has_tokens = self.portfolio.as_ref().map(|p| !p.tokens.is_empty()).unwrap_or(false);
- // Status sub-line: synced block, watching tag, or an error.
+ // Status sub-line: synced block, watching tag, or an error. When a read carries a
+ // non-Verified trust label, surface it: a balance is never shown as quietly trusted.
+ let trust_tag = match &self.read_status {
+ Some(deckard_core::ReadStatus::Verified) => " · verified",
+ Some(deckard_core::ReadStatus::Degraded { .. }) => " · degraded",
+ Some(deckard_core::ReadStatus::Unsynced { .. }) => " · NOT VERIFIED",
+ None => "",
+ };
let status_line = if let Some(err) = &self.portfolio_error {
format!("⚠ {err}")
} else if first_sync {
"Syncing over Ethereum…".to_string()
} else if let Some(block) = self.synced_block {
let net = if self.viewing_watch { "watching · " } else { "" };
- format!("{net}synced · block {block}")
+ format!("{net}synced · block {block}{trust_tag}")
} else {
"Ethereum mainnet".to_string()
};
- let status_color = if self.portfolio_error.is_some() { theme.danger } else { muted };
+ // An unverified read is a soft warning (the value may not be trustless), not a hard error.
+ let unverified = matches!(
+ self.read_status,
+ Some(deckard_core::ReadStatus::Unsynced { .. })
+ );
+ let status_color = if self.portfolio_error.is_some() {
+ theme.danger
+ } else if unverified {
+ theme.warning
+ } else {
+ muted
+ };
div()
.flex_1()
diff --git a/crates/deckard-contract/README.md b/crates/deckard-contract/README.md
index c193929..f7ac14b 100644
--- a/crates/deckard-contract/README.md
+++ b/crates/deckard-contract/README.md
@@ -7,13 +7,14 @@ This crate is the single source of truth for the wire every Deckard process spea
- **`Intent`** — the only thing that crosses `deckard-mcp → deckard-signerd` for a write. Carries `chain_id` (multi-chain ready); the daemon owns the nonce.
- **`Decision`** — the daemon's verdict from `propose`: `Allow` / `Deny{reason}` / `NeedsApproval{request_id}`.
- **`Policy`** — the agent-readable spending fence (caps, allowlist, approval mode, `revoked`).
-- **RPC enums** (`SignerRequest` / `SignerResponse` / `ExecuteResult` / `ApprovalStatus` / `BalanceReport`) — the daemon socket API. serde-derived → CBOR (ciborium) on the UDS, JSON for MCP.
-- **`Signer`** — a *sync* trait; the real UDS client does a fast blocking round-trip off the UI thread (an async wrapper is the daemon ticket's call).
-- **`MockSigner`** — an in-memory, deterministic implementation so T-Agent, T-UX, and the test harness can build and run the acceptance scenario **before** the real signer daemon exists.
+- **`evaluate(&Intent, &Policy) -> Decision`** — the **one** pure decision function. Both `MockSigner` and the real `deckard-signerd` call it, so the verdict can never drift between the mock and the daemon (parity is unit-asserted). It returns `RequestId::ZERO` as a placeholder for `NeedsApproval`; the stateful caller mints the real id.
+- **RPC enums** (`SignerRequest` / `SignerResponse` / `ExecuteResult` / `ApprovalStatus` / `BalanceReport` / `UnlockOutcome`) — the daemon socket API. `SignerRequest` includes `Unlock{passphrase}` / `Lock` / `Resolve{request_id, approved}` for the daemon's lock state machine + approval loop (`Unlock` → `SignerResponse::Unlock(UnlockOutcome)`; `Lock`/`Resolve` → `Ack`). serde-derived → CBOR (ciborium) on the UDS, JSON for MCP.
+- **`Signer`** — a *sync* trait (`unlock`/`lock`/`resolve`/`address`/`balance`/`policy`/`propose`/`execute`/`status`/`revoke_all`); the real UDS client does a fast blocking round-trip off the UI thread (an async wrapper is the daemon ticket's call).
+- **`MockSigner`** — an in-memory, deterministic implementation (calls `evaluate`, no duplicated decision logic) so T-Agent, T-UX, and the test harness can build and run the acceptance scenario **before** the real signer daemon exists.
## Zero key material
-This crate carries **no key material at all** — types + a trait + a mock. It never signs, never holds a key. The key boundary is the daemon's process (`deckard-signerd`, owned by `docs/build/00-test-harness.md`), not this crate.
+This crate carries **no key material at all** — types + a trait + a mock. It never signs, never holds a key. The key boundary is the daemon's process (`crates/deckard-signerd`; cross-process red-team owned by `docs/build/00-test-harness.md`), not this crate.
## Deterministic mock
diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs
index 932227e..98ef372 100644
--- a/crates/deckard-contract/src/lib.rs
+++ b/crates/deckard-contract/src/lib.rs
@@ -26,14 +26,18 @@ pub mod decision;
pub mod intent;
pub mod mock;
pub mod policy;
+pub mod read_status;
pub mod rpc;
pub mod signer;
pub use decision::{Decision, RequestId};
pub use intent::{Intent, IntentKind};
pub use mock::MockSigner;
-pub use policy::{ApprovalMode, Policy};
-pub use rpc::{ApprovalStatus, BalanceReport, ExecuteResult, SignerRequest, SignerResponse};
+pub use policy::{evaluate, ApprovalMode, Policy};
+pub use read_status::ReadStatus;
+pub use rpc::{
+ ApprovalStatus, BalanceReport, ExecuteResult, SignerRequest, SignerResponse, UnlockOutcome,
+};
pub use signer::Signer;
#[cfg(test)]
@@ -143,6 +147,18 @@ mod roundtrip_tests {
#[test]
fn signer_request_roundtrip() {
+ roundtrip(&SignerRequest::Unlock {
+ passphrase: "correct horse battery staple".into(),
+ });
+ roundtrip(&SignerRequest::Lock);
+ roundtrip(&SignerRequest::Resolve {
+ request_id: B256::repeat_byte(0x04),
+ approved: true,
+ });
+ roundtrip(&SignerRequest::Resolve {
+ request_id: B256::repeat_byte(0x05),
+ approved: false,
+ });
roundtrip(&SignerRequest::Propose {
intent: sample_intent(IntentKind::Shield),
});
@@ -161,6 +177,11 @@ mod roundtrip_tests {
#[test]
fn signer_response_roundtrip() {
+ roundtrip(&SignerResponse::Unlock(UnlockOutcome::Unlocked {
+ address: Address::repeat_byte(0x11),
+ }));
+ roundtrip(&SignerResponse::Unlock(UnlockOutcome::BadPassphrase));
+ roundtrip(&SignerResponse::Unlock(UnlockOutcome::NoVault));
roundtrip(&SignerResponse::Decision(Decision::Allow));
roundtrip(&SignerResponse::Execute(ExecuteResult::Broadcast {
tx_hash: B256::repeat_byte(0xAB),
@@ -172,6 +193,7 @@ mod roundtrip_tests {
roundtrip(&SignerResponse::Balance(BalanceReport {
public_wei: U256::from(1_u64),
shielded_wei: U256::from(2_u64),
+ read_status: ReadStatus::Verified,
}));
}
@@ -193,13 +215,37 @@ mod roundtrip_tests {
#[test]
fn balance_report_roundtrip() {
+ // Exercise every ReadStatus variant (incl. the owned-String reasons) so both
+ // CBOR and JSON coverage of the new field stays complete + byte-stable.
roundtrip(&BalanceReport {
public_wei: U256::from(0_u64),
shielded_wei: U256::from(0_u64),
+ read_status: ReadStatus::Verified,
});
roundtrip(&BalanceReport {
public_wei: U256::MAX,
shielded_wei: U256::from(42_u64),
+ read_status: ReadStatus::Unsynced {
+ reason: "head stale".into(),
+ },
+ });
+ roundtrip(&BalanceReport {
+ public_wei: U256::from(7_u64),
+ shielded_wei: U256::from(0_u64),
+ read_status: ReadStatus::Degraded {
+ reason: "failover→nimbus".into(),
+ },
+ });
+ }
+
+ #[test]
+ fn read_status_roundtrip() {
+ roundtrip(&ReadStatus::Verified);
+ roundtrip(&ReadStatus::Degraded {
+ reason: "failover→drpc".into(),
+ });
+ roundtrip(&ReadStatus::Unsynced {
+ reason: "verification disabled".into(),
});
}
}
diff --git a/crates/deckard-contract/src/mock.rs b/crates/deckard-contract/src/mock.rs
index 0479304..c99b862 100644
--- a/crates/deckard-contract/src/mock.rs
+++ b/crates/deckard-contract/src/mock.rs
@@ -12,9 +12,10 @@ use std::sync::Mutex;
use alloy_primitives::{Address, B256, U256};
use crate::decision::{Decision, RequestId};
-use crate::intent::{Intent, IntentKind};
-use crate::policy::{ApprovalMode, Policy};
-use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult};
+use crate::intent::Intent;
+use crate::policy::{self, Policy};
+use crate::read_status::ReadStatus;
+use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult, UnlockOutcome};
use crate::signer::Signer;
/// One tracked proposal. `status` is the wire-visible approval state; `broadcast` is `Some`
@@ -37,6 +38,11 @@ struct Requests {
/// An in-memory signer. The `policy` and `requests` locks are always acquired **policy
/// before requests**, so the pair can never deadlock; `balance` is only ever taken alone.
+///
+/// The mock holds no real key, so its `Locked`/`Unlocked` state is modelled by the
+/// `Policy::revoked` brake: `lock`/`revoke_all` trip it (deny everything), `unlock` clears
+/// it (re-arm). This mirrors the daemon, where `Lock` and `RevokeAll` both reach `Locked`
+/// and only a fresh `Unlock` re-arms.
#[derive(Debug)]
pub struct MockSigner {
policy: Mutex,
@@ -58,6 +64,9 @@ impl MockSigner {
balance: Mutex::new(BalanceReport {
public_wei: U256::ZERO,
shielded_wei: U256::ZERO,
+ // The mock is deterministic + offline; it never touches a chain, so
+ // it reports its canned balances as Verified (no untrusted RPC behind it).
+ read_status: ReadStatus::Verified,
}),
}
}
@@ -78,14 +87,9 @@ impl MockSigner {
}
/// Test helper: flip a `Pending` request to `Allowed`, simulating the human tapping
- /// Approve on the native card. No-op for any other state.
+ /// Approve on the native card. Thin wrapper over [`Signer::resolve`].
pub fn approve(&self, request_id: RequestId) {
- let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
- if let Some(req) = reqs.by_id.get_mut(&request_id) {
- if req.status == ApprovalStatus::Pending {
- req.status = ApprovalStatus::Allowed;
- }
- }
+ self.resolve(request_id, true);
}
/// Test helper: the id of the most recently minted request, or `None` if none yet.
@@ -113,20 +117,44 @@ impl MockSigner {
}
}
-/// Mock decodability rule. The real adapter calldata is validated by `deckard-signerd`
-/// (`10-kohaku-shield.md`); this just checks the shape matches the kind.
-fn calldata_ok(intent: &Intent) -> bool {
- match intent.kind {
- // A plain send carries no calldata.
- IntentKind::Send => intent.calldata.is_empty(),
- // A generic contract write needs calldata to call.
- IntentKind::ContractCall => !intent.calldata.is_empty(),
- // Railgun deposit/withdraw: the mock accepts whatever calldata it is handed.
- IntentKind::Shield | IntentKind::Unshield => true,
+impl Signer for MockSigner {
+ fn unlock(&self, _passphrase: &str) -> UnlockOutcome {
+ // The mock holds no real keystore, so any passphrase "unlocks" it. A fresh unlock
+ // re-arms the session by clearing the `revoked` brake (mirrors the daemon's
+ // "re-unlock to re-arm").
+ self.policy
+ .lock()
+ .expect("mock policy mutex poisoned")
+ .revoked = false;
+ UnlockOutcome::Unlocked {
+ address: Self::mock_address(),
+ }
+ }
+
+ fn lock(&self) {
+ // Lock the session (trip the brake) and deny everything in flight — same as the
+ // daemon's `Lock`, which reaches `Locked` exactly like `RevokeAll`.
+ let mut policy = self.policy.lock().expect("mock policy mutex poisoned");
+ let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
+ policy.revoked = true;
+ deny_pending(&mut reqs);
+ }
+
+ fn resolve(&self, request_id: RequestId, approved: bool) {
+ let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
+ if let Some(req) = reqs.by_id.get_mut(&request_id) {
+ if req.status == ApprovalStatus::Pending {
+ req.status = if approved {
+ ApprovalStatus::Allowed
+ } else {
+ ApprovalStatus::Denied {
+ reason: "user_denied".into(),
+ }
+ };
+ }
+ }
}
-}
-impl Signer for MockSigner {
fn address(&self) -> Address {
Self::mock_address()
}
@@ -146,46 +174,20 @@ impl Signer for MockSigner {
}
fn propose(&self, intent: &Intent) -> Decision {
- let needs_card;
- {
+ // The verdict comes from the ONE shared decision function — no logic lives here.
+ // (`revoked`, the mock's lock state, is one of the checks `evaluate` makes.)
+ let needs_card = {
let policy = self.policy.lock().expect("mock policy mutex poisoned");
-
- // 1. STOP overrides everything.
- if policy.revoked {
- return Decision::Deny {
- reason: "revoked".into(),
- };
- }
- // 2. Allowlist (empty = any address).
- if !policy.allow_to.is_empty() && !policy.allow_to.contains(&intent.to) {
- return Decision::Deny {
- reason: "off_allowlist".into(),
- };
+ match policy::evaluate(intent, &policy) {
+ // Terminal verdicts return straight through.
+ deny @ Decision::Deny { .. } => return deny,
+ Decision::Allow => false,
+ Decision::NeedsApproval { .. } => true,
}
- // 3. Calldata must be decodable for the kind.
- if !calldata_ok(intent) {
- return Decision::Deny {
- reason: "undecodable".into(),
- };
- }
- // 4. Cap check: spent_today + value vs the per-tx and daily caps.
- let projected = policy.spent_today_wei.saturating_add(intent.value);
- let over = projected > policy.per_tx_cap_wei || projected > policy.daily_cap_wei;
-
- needs_card = match policy.require_approval {
- ApprovalMode::Never => false,
- ApprovalMode::OverCap => over,
- ApprovalMode::Always => true,
- };
-
- // Never raises no card, so an over-cap write has nothing to authorise it → deny.
- if over && matches!(policy.require_approval, ApprovalMode::Never) {
- return Decision::Deny {
- reason: "over_cap".into(),
- };
- }
- } // policy lock released before taking the requests lock (preserves lock order)
+ }; // policy lock released before taking the requests lock (preserves lock order)
+ // Mint the real, trackable id (replacing `evaluate`'s placeholder) and store the
+ // pending record under it.
let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
let id = Self::mint_id(&mut reqs);
let status = if needs_card {
@@ -268,16 +270,22 @@ impl Signer for MockSigner {
}
fn revoke_all(&self) {
+ // STOP: trip the policy brake, then deny everything in flight.
// Same lock order as execute(): policy before requests.
let mut policy = self.policy.lock().expect("mock policy mutex poisoned");
let mut reqs = self.requests.lock().expect("mock requests mutex poisoned");
policy.revoked = true;
- for req in reqs.by_id.values_mut() {
- if req.status == ApprovalStatus::Pending {
- req.status = ApprovalStatus::Denied {
- reason: "revoked".into(),
- };
- }
+ deny_pending(&mut reqs);
+ }
+}
+
+/// Flip every still-`Pending` request to `Denied{revoked}` — shared by `lock`/`revoke_all`.
+fn deny_pending(reqs: &mut Requests) {
+ for req in reqs.by_id.values_mut() {
+ if req.status == ApprovalStatus::Pending {
+ req.status = ApprovalStatus::Denied {
+ reason: "revoked".into(),
+ };
}
}
}
@@ -285,6 +293,8 @@ impl Signer for MockSigner {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::intent::IntentKind;
+ use crate::policy::ApprovalMode;
use alloy_primitives::Bytes;
// --- builders -------------------------------------------------------------------
@@ -319,7 +329,9 @@ mod tests {
to: Address::repeat_byte(0x44),
token: None,
value: U256::from(value),
- calldata: Bytes::new(),
+ // A real Shield always carries the RelayAdapt call; the policy gate now requires
+ // it (an empty payload would degrade into a bare native send). Stand-in bytes here.
+ calldata: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
kind: IntentKind::Shield,
}
}
@@ -416,6 +428,18 @@ mod tests {
reason: "undecodable".into()
}
);
+ // A Shield with EMPTY calldata is rejected: without the RelayAdapt call it would
+ // degrade into a bare native send to `to` (no private note) while labelled "Shield".
+ let empty_shield = Intent {
+ calldata: Bytes::new(),
+ ..shield(20)
+ };
+ assert_eq!(
+ s.propose(&empty_shield),
+ Decision::Deny {
+ reason: "undecodable".into()
+ }
+ );
}
#[test]
@@ -557,6 +581,7 @@ mod tests {
s.set_balance(BalanceReport {
public_wei: U256::from(7u64),
shielded_wei: U256::from(3u64),
+ read_status: ReadStatus::Verified,
});
let b = s.balance(false);
assert_eq!(b.public_wei, U256::from(7u64));
diff --git a/crates/deckard-contract/src/policy.rs b/crates/deckard-contract/src/policy.rs
index 1c3a2d7..b735280 100644
--- a/crates/deckard-contract/src/policy.rs
+++ b/crates/deckard-contract/src/policy.rs
@@ -1,9 +1,15 @@
//! The spending fence the agent is allowed to READ (so it can stay inside the fence) but
//! never write. The daemon enforces it; `MockSigner` enforces the same rules in memory.
+//!
+//! [`evaluate`] is **the one decision function** — both `MockSigner` and the real
+//! `deckard-signerd` call it, so there is no mock⇄daemon drift in the verdict logic.
use alloy_primitives::{Address, U256};
use serde::{Deserialize, Serialize};
+use crate::decision::{Decision, RequestId};
+use crate::intent::{Intent, IntentKind};
+
/// The agent-readable policy. All caps are in wei.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Policy {
@@ -34,3 +40,89 @@ pub enum ApprovalMode {
/// Always raise a card, even within cap.
Always,
}
+
+/// **The** decision function. A *pure* `(Intent, Policy) -> Decision` with no I/O, no
+/// signing, no state — both [`MockSigner`](crate::MockSigner) and `deckard-signerd` call
+/// it so the verdict can never drift between the mock and the real daemon.
+///
+/// It owns the policy-level checks (`revoked`, allowlist, calldata shape, the caps × mode
+/// matrix). Process-level pre-checks that the policy can't express — the daemon being
+/// `Locked`, a `chain_id` mismatch, an unsupported `IntentKind` — are the daemon's job and
+/// run *before* this function (the mock has none of those states, so feeding both the same
+/// `(Intent, Policy)` yields identical `Decision`s; this is the parity contract).
+///
+/// For [`Decision::NeedsApproval`] the returned `request_id` is the **placeholder**
+/// [`RequestId::ZERO`](alloy_primitives::B256::ZERO): minting a real, trackable id is the
+/// stateful caller's job (it stores the pending record under that id). Callers must replace
+/// it before returning the decision on the wire.
+pub fn evaluate(intent: &Intent, policy: &Policy) -> Decision {
+ // 1. STOP / revoked overrides everything.
+ if policy.revoked {
+ return Decision::Deny {
+ reason: "revoked".into(),
+ };
+ }
+ // 2. Allowlist (empty = any address).
+ if !policy.allow_to.is_empty() && !policy.allow_to.contains(&intent.to) {
+ return Decision::Deny {
+ reason: "off_allowlist".into(),
+ };
+ }
+ // 3. Calldata must be decodable for the kind.
+ if !calldata_ok(intent) {
+ return Decision::Deny {
+ reason: "undecodable".into(),
+ };
+ }
+ // 4. Cap check: spent_today + value vs the per-tx and daily caps.
+ let projected = policy.spent_today_wei.saturating_add(intent.value);
+ let over = projected > policy.per_tx_cap_wei || projected > policy.daily_cap_wei;
+
+ match policy.require_approval {
+ // Never raises no card, so an over-cap write has nothing to authorise it → deny.
+ ApprovalMode::Never => {
+ if over {
+ Decision::Deny {
+ reason: "over_cap".into(),
+ }
+ } else {
+ Decision::Allow
+ }
+ }
+ ApprovalMode::OverCap => {
+ if over {
+ Decision::NeedsApproval {
+ request_id: RequestId::ZERO,
+ }
+ } else {
+ Decision::Allow
+ }
+ }
+ ApprovalMode::Always => Decision::NeedsApproval {
+ request_id: RequestId::ZERO,
+ },
+ }
+}
+
+/// Shape check: does the calldata match the kind? The real Railgun adapter calldata is
+/// validated downstream (`10-kohaku-shield.md`); this only enforces the coarse invariant
+/// the policy gate relies on.
+///
+/// The Shield invariant matters now that Shield routes to the signing path: a
+/// `Shield`/`Unshield` MUST carry non-empty calldata. Without it, an `Intent{kind:Shield,
+/// calldata: empty}` would fall through the daemon's broadcast as a **plain native ETH send**
+/// to `intent.to` (no private note ever created) while wire-labelled "Shield" — a key-less
+/// client could thereby move ETH to an arbitrary address under the Shield label. Requiring
+/// calldata closes that. (The deeper `to == RelayAdapt(chain)` check lives downstream — the
+/// contract crate is pure policy with zero chain knowledge and no railgun dep, by charter.)
+fn calldata_ok(intent: &Intent) -> bool {
+ match intent.kind {
+ // A plain send carries no calldata (the daemon builds the tx from to/value/token).
+ IntentKind::Send => intent.calldata.is_empty(),
+ // A contract write / Railgun deposit / withdraw all carry an encoded call. An empty
+ // payload for any of these would degrade into a bare native send — reject it.
+ IntentKind::ContractCall | IntentKind::Shield | IntentKind::Unshield => {
+ !intent.calldata.is_empty()
+ }
+ }
+}
diff --git a/crates/deckard-contract/src/read_status.rs b/crates/deckard-contract/src/read_status.rs
new file mode 100644
index 0000000..cb3fe10
--- /dev/null
+++ b/crates/deckard-contract/src/read_status.rs
@@ -0,0 +1,81 @@
+//! `ReadStatus` — Deckard-owned trust label attached to every chain read.
+//!
+//! This is the contract the UI and the MCP agent surface see. The hard rule:
+//! **never silently serve an untrusted read.** A read is either verified, or
+//! visibly degraded/unsynced — never quietly trusted.
+//!
+//! The three states map onto *observable* Helios behavior (verified against
+//! a16z/helios @ 0.11.1, `core/src/client/node.rs`):
+//!
+//! - `Verified` — Helios head is fresh (age ≤ 60s, the hard `check_head_age` gate)
+//! and the read came back from the verified light client.
+//! - `Degraded` — still cryptographically verified, but off the happy path: we
+//! failed over to a secondary EL, or we're on a community fallback checkpoint.
+//! Trust note shown. Rarely emitted in v1.
+//! - `Unsynced` — cannot produce a verified read right now: sync not finished, head
+//! stale past the 60s gate, the read failed, or verification is disabled. The UI
+//! shows a hard "NOT VERIFIED" state. Deckard MUST NOT fall back to a raw
+//! untrusted RPC and still claim it is verified.
+//!
+//! ## Portability
+//!
+//! `deckard-contract` is a **std** crate today (no `#![no_std]`), so `String`
+//! here resolves to `std::string::String`. The type is written to be no_std-
+//! *ready* — it leans only on `alloc`-available types (`String`) and `core::fmt`
+//! for `Display` — so a future `#![no_std]` + `extern crate alloc` flip would be
+//! mechanical, not a rewrite. Like every other wire type it carries the same
+//! `serde` derives, so it round-trips byte-stably across JSON (the MCP surface)
+//! and CBOR (the daemon UDS).
+
+use core::fmt;
+
+use serde::{Deserialize, Serialize};
+
+/// Trust label attached to every chain read. Maps onto observable Helios state
+/// (see deckard-core's verified read path).
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ReadStatus {
+ /// Helios head fresh and the read succeeded against the verified light client.
+ /// Fully trustless.
+ Verified,
+ /// Still cryptographically verified, but off the happy path (EL failover or
+ /// community-fallback checkpoint). `reason` is shown to the user. Rarely
+ /// emitted in v1.
+ Degraded { reason: String },
+ /// No verified read is possible right now (Helios unsynced, head stale past
+ /// the 60s gate, the read failed, or verification is disabled). `reason` is
+ /// shown to the user. Deckard MUST NOT fall back to a raw untrusted RPC and
+ /// still claim Verified.
+ Unsynced { reason: String },
+}
+
+impl ReadStatus {
+ /// Off-the-happy-path-but-still-verified label.
+ pub fn degraded(reason: impl Into) -> Self {
+ ReadStatus::Degraded {
+ reason: reason.into(),
+ }
+ }
+
+ /// No-verified-read-possible label.
+ pub fn unsynced(reason: impl Into) -> Self {
+ ReadStatus::Unsynced {
+ reason: reason.into(),
+ }
+ }
+
+ /// True only when a real, verified value backs the read.
+ pub fn is_trustworthy(&self) -> bool {
+ matches!(self, ReadStatus::Verified | ReadStatus::Degraded { .. })
+ }
+}
+
+impl fmt::Display for ReadStatus {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ ReadStatus::Verified => write!(f, "VERIFIED"),
+ ReadStatus::Degraded { reason } => write!(f, "DEGRADED ({reason})"),
+ ReadStatus::Unsynced { reason } => write!(f, "NOT VERIFIED ({reason})"),
+ }
+ }
+}
diff --git a/crates/deckard-contract/src/rpc.rs b/crates/deckard-contract/src/rpc.rs
index ed67b12..110de44 100644
--- a/crates/deckard-contract/src/rpc.rs
+++ b/crates/deckard-contract/src/rpc.rs
@@ -8,17 +8,34 @@ use serde::{Deserialize, Serialize};
use crate::decision::{Decision, RequestId};
use crate::intent::Intent;
use crate::policy::Policy;
+use crate::read_status::ReadStatus;
/// `deckard-mcp` → `deckard-signerd`. The key-less client only proposes; it never signs.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum SignerRequest {
+ /// Unlock the vault: the daemon reads the keystore, decrypts with `passphrase`, and
+ /// holds the key for the session → [`SignerResponse::Unlock`]\([`UnlockOutcome`]\).
+ ///
+ /// The wire passphrase is a plain `String` because `zeroize::Zeroizing` does
+ /// not derive `Serialize`. The daemon moves it into `Zeroizing` the instant the frame
+ /// is decoded and never retains the raw buffer; it never echoes the passphrase back.
+ Unlock { passphrase: String },
+ /// Lock: zeroize + drop the held key → `Locked`, and deny every in-flight approval.
+ /// Re-arm only via a fresh [`Unlock`](Self::Unlock). → `Ack`.
+ Lock,
+ /// Close an approval loop opened by a `NeedsApproval`: flip the `Pending` record to
+ /// `Allowed` (`approved: true`) or `Denied` (`approved: false`). → `Ack`.
+ Resolve {
+ request_id: RequestId,
+ approved: bool,
+ },
/// Policy check, NO signing yet → [`Decision`].
Propose { intent: Intent },
/// Sign + broadcast, only if `Allow`/approved → [`ExecuteResult`].
Execute { request_id: RequestId },
/// Poll for the native-card result → [`ApprovalStatus`].
Status { request_id: RequestId },
- /// STOP: set `policy.revoked`, drop in-flight approvals → `Ack`.
+ /// STOP: zeroize the key, lock the daemon, drop in-flight approvals → `Ack`.
RevokeAll,
/// Read-only snapshot for the agent → [`Policy`].
PolicyGet,
@@ -31,16 +48,30 @@ pub enum SignerRequest {
/// `deckard-signerd` → `deckard-mcp`. One variant per request shape.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum SignerResponse {
+ /// Reply to `Unlock`.
+ Unlock(UnlockOutcome),
Decision(Decision),
Execute(ExecuteResult),
Status(ApprovalStatus),
- /// Reply to `RevokeAll`.
+ /// Reply to `Lock`, `Resolve`, and `RevokeAll`.
Ack,
Policy(Policy),
Address(Address),
Balance(BalanceReport),
}
+/// Outcome of `Unlock`. Carries the wallet address on success — never any key material,
+/// never the passphrase.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub enum UnlockOutcome {
+ /// Decrypted; the daemon now holds the key. `address` is the primary account.
+ Unlocked { address: Address },
+ /// The passphrase was wrong (or the vault was tampered with). No key is held.
+ BadPassphrase,
+ /// No keystore file exists yet — onboarding must create one first.
+ NoVault,
+}
+
/// Outcome of `execute`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ExecuteResult {
@@ -55,7 +86,8 @@ pub enum ExecuteResult {
pub enum ApprovalStatus {
/// Awaiting the human on the native card.
Pending,
- /// The human approved; `execute` will sign (subject to a fresh `revoked` re-check).
+ /// Approved (by a human, or auto within cap); `execute` will sign — subject to fresh
+ /// re-checks at sign time (revoked, TTL expiry, and the spend caps for an auto-allow).
Allowed,
/// Terminal denial.
Denied { reason: String },
@@ -68,4 +100,8 @@ pub enum ApprovalStatus {
pub struct BalanceReport {
pub public_wei: U256,
pub shielded_wei: U256,
+ /// Trust label for this read (Helios-verified vs unsynced/degraded). The hard
+ /// rule: a balance is `Verified` only when a fresh Helios-verified read backs
+ /// it; otherwise it is visibly `Unsynced`/`Degraded`, never quietly trusted.
+ pub read_status: ReadStatus,
}
diff --git a/crates/deckard-contract/src/signer.rs b/crates/deckard-contract/src/signer.rs
index 07c18e2..5136d62 100644
--- a/crates/deckard-contract/src/signer.rs
+++ b/crates/deckard-contract/src/signer.rs
@@ -7,12 +7,19 @@ use alloy_primitives::Address;
use crate::decision::{Decision, RequestId};
use crate::intent::Intent;
use crate::policy::Policy;
-use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult};
+use crate::rpc::{ApprovalStatus, BalanceReport, ExecuteResult, UnlockOutcome};
/// The daemon-socket API expressed as a trait, so callers can hold a `Box` and
/// swap the mock for the real UDS client without changing a line. Object-safe: every method
/// takes `&self` and returns owned values.
pub trait Signer {
+ /// Unlock the vault for the session (the daemon decrypts + holds the key). Returns the
+ /// wallet address on success — never key material.
+ fn unlock(&self, passphrase: &str) -> UnlockOutcome;
+ /// Lock: zeroize + drop the held key and deny in-flight approvals. Re-arm via `unlock`.
+ fn lock(&self);
+ /// Close an approval loop: flip a `Pending` request to `Allowed`/`Denied`.
+ fn resolve(&self, request_id: RequestId, approved: bool);
/// The wallet's public address (key-less to read).
fn address(&self) -> Address;
/// Public + shielded balances. `shielded` mirrors the wire request; the report carries
diff --git a/crates/deckard-contract/tests/harness_slice.rs b/crates/deckard-contract/tests/harness_slice.rs
index 0d70231..b648a05 100644
--- a/crates/deckard-contract/tests/harness_slice.rs
+++ b/crates/deckard-contract/tests/harness_slice.rs
@@ -34,12 +34,19 @@ fn demo_signer() -> MockSigner {
}
fn intent(kind: IntentKind, value: u64) -> Intent {
+ // Send carries no calldata; every other kind (Shield/Unshield/ContractCall) must carry
+ // its encoded call — the policy gate now rejects an empty payload for those (an empty
+ // "Shield" would otherwise degrade into a bare native send). Stand-in bytes for non-Send.
+ let calldata = match kind {
+ IntentKind::Send => Bytes::new(),
+ _ => Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
+ };
Intent {
chain_id: 1,
to: Address::repeat_byte(0x22),
token: None,
value: U256::from(value),
- calldata: Bytes::new(),
+ calldata,
kind,
}
}
diff --git a/crates/deckard-core/Cargo.toml b/crates/deckard-core/Cargo.toml
index 44721be..56b66be 100644
--- a/crates/deckard-core/Cargo.toml
+++ b/crates/deckard-core/Cargo.toml
@@ -5,15 +5,53 @@ edition = "2021"
license = "AGPL-3.0-or-later"
description = "Deckard's headless engine: Ethereum provider, balances, HD keys, and the encrypted keystore — no GPUI dependency, fully unit-testable."
+[features]
+# Embedded Helios light client → verified localhost reads. Heavy (revm/bls); ON by
+# default so the app + daemon get verified reads, but toggleable so the heavy build
+# can be skipped. When OFF, reads fall back to the raw RPC and are tagged
+# ReadStatus::Unsynced("verification disabled") — never silently Verified.
+default = ["verified-reads", "shield"]
+verified-reads = ["dep:helios-ethereum"]
+# Key-less Railgun shield-calldata builder. Heavy (the full ZK `railgun` tree); ON by
+# default so the app + daemon can build shield intents, but toggleable so the heavy build
+# can be skipped. When OFF, `build_shield_native_intent` returns "shield unavailable
+# (feature off)" — never a fake success. Pulls `rand_09` because railgun pins rand 0.9
+# while core's own `rand` stays 0.8.
+shield = ["dep:railgun", "dep:rand_09"]
+
[dependencies]
+# The frozen wire contract — only for the shared `ReadStatus` trust label attached
+# to every read. (Types-only; no key material, no logic.)
+deckard-contract = { path = "../deckard-contract" }
+
# The full alloy surface Deckard needs across chunks. Front-loaded so the heavy
# alloy/reqwest compile happens once: provider+http (reads), contract+sol-types
-# (Multicall3 / ERC-20), ens (name resolution). signer-local stays standalone
-# below to keep the `mnemonic` feature explicit.
-alloy = { version = "1", features = ["provider-http", "contract", "sol-types", "ens"] }
+# (Multicall3 / ERC-20), ens (name resolution), eips (BlockId for the Helios
+# `with_default_block(latest)` fix). signer-local stays standalone below to keep
+# the `mnemonic` feature explicit.
+alloy = { version = "1", features = ["provider-http", "contract", "sol-types", "ens", "eips"] }
alloy-signer-local = { version = "2.0.5", features = ["mnemonic"] }
alloy-primitives = "1.6.0"
+# Embedded Helios light client (verified localhost JSON-RPC server). Git-only, tag
+# "0.11.1" (crates.io is stale at 0.1.0); the umbrella `helios` crate pulls a yanked
+# core2, so depend on `helios-ethereum` directly. Heavy (revm/bls12_381) — gated
+# behind `verified-reads`. The `ethereum_hashing` patch it needs is at the workspace
+# root (a git-dep can't carry its own `[patch]`).
+helios-ethereum = { git = "https://github.com/a16z/helios", tag = "0.11.1", optional = true }
+
+# Kohaku's pure-Rust Railgun client — the heavy ZK crate. Gated behind `shield` (DEFAULT
+# ON). We use ONLY the key-less `ShieldBuilder` (no sync, no proving, no key), but the crate
+# pulls the full ZK tree, so it is `optional`. `testing` matches the proven spike edge (rev
+# 618c53f) so the workspace-root `[patch.crates-io]` set resolves identically. The two
+# mandatory ZK patches (ruint, ark-circom) live at the workspace root — a git-dep can't
+# carry its own `[patch]`.
+railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7", features = ["testing"], optional = true }
+# railgun's `ShieldBuilder::build` requires a rand **0.9** Rng; core's own `rand`
+# (below) is 0.8 (RustCrypto-aligned), so the 0.9 crate is aliased `rand_09` and pulled only
+# with the `shield` feature.
+rand_09 = { package = "rand", version = "0.9", optional = true }
+
# A single background tokio runtime owns all network; the GUI thread never makes
# a network call. `rt` (current-thread) only — no multi-thread worker pool needed.
tokio = { version = "1", features = ["rt", "macros", "sync"] }
@@ -31,5 +69,9 @@ bip39 = { version = "2.2", features = ["zeroize"] }
zeroize = "1"
rand = "0.8"
+# Resolve the platform config dir (the keystore + signer policy live there). Single-sourced
+# here so the GUI app, onboarding, and the signer daemon all agree on the path.
+directories = "5"
+
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros", "sync", "rt-multi-thread"] }
diff --git a/crates/deckard-core/examples/smoke.rs b/crates/deckard-core/examples/smoke.rs
index b817eb5..2d3677a 100644
--- a/crates/deckard-core/examples/smoke.rs
+++ b/crates/deckard-core/examples/smoke.rs
@@ -19,7 +19,10 @@ fn main() {
println!("{name} -> {addr}");
match eth.portfolio(addr).recv() {
- Ok(Ok(p)) => {
+ Ok(Ok(read)) => {
+ // The trust label the read carries (Helios-Verified vs Unsynced).
+ println!("read status: {}", read.status);
+ let p = read.value;
println!("ETH: {}", format_amount(p.native_wei, 18, 6));
for t in &p.tokens {
println!("{:>5}: {}", t.symbol, format_amount(t.raw, t.decimals, 4));
diff --git a/crates/deckard-core/src/config.rs b/crates/deckard-core/src/config.rs
new file mode 100644
index 0000000..d3f98d4
--- /dev/null
+++ b/crates/deckard-core/src/config.rs
@@ -0,0 +1,33 @@
+//! Where Deckard keeps per-user state on disk. The encrypted keystore (`vault.bin`) and the
+//! signer policy (`policy.json`) live in the platform config dir; the GUI app, onboarding,
+//! and the signer daemon all resolve the **same** path through here so they never drift.
+//!
+//! This is a pure resolver — it does not create the directory. The writer (`Vault::write_atomic`)
+//! creates the parent as needed; readers treat a missing file as "not set up yet."
+
+use std::path::PathBuf;
+
+use directories::ProjectDirs;
+
+/// The encrypted keystore filename inside [`config_dir`].
+pub const VAULT_FILE: &str = "vault.bin";
+/// The signer policy filename inside [`config_dir`].
+pub const POLICY_FILE: &str = "policy.json";
+
+/// The platform config dir: `~/Library/Application Support/com.deckard.Deckard` on macOS,
+/// `$XDG_CONFIG_HOME/deckard` (or `~/.config/deckard`) on Linux. `None` only if the OS has
+/// no home directory at all.
+pub fn config_dir() -> Option {
+ let dirs = ProjectDirs::from("com", "deckard", "Deckard")?;
+ Some(dirs.config_dir().to_path_buf())
+}
+
+/// The encrypted keystore path (`/vault.bin`).
+pub fn vault_path() -> Option {
+ Some(config_dir()?.join(VAULT_FILE))
+}
+
+/// The signer policy path (`/policy.json`).
+pub fn policy_path() -> Option {
+ Some(config_dir()?.join(POLICY_FILE))
+}
diff --git a/crates/deckard-core/src/eth.rs b/crates/deckard-core/src/eth.rs
index 4f1f7d7..002e9d2 100644
--- a/crates/deckard-core/src/eth.rs
+++ b/crates/deckard-core/src/eth.rs
@@ -3,30 +3,78 @@
//! requests in and gets a `flume::Receiver` back; it awaits that receiver on its own
//! executor (`cx.spawn`), so a slow RPC never stalls a frame.
//!
-//! v0 points at a public mainnet RPC by default (overridable in settings). The
-//! trustless default — a bundled Helios light client serving localhost — is the next
-//! increment per the spec; swapping it in is just a different URL passed to `spawn`.
+//! ## Verified reads (the `verified-reads` feature, ON by default)
+//!
+//! When `verified-reads` is on, the worker stands up an **embedded Helios light
+//! client** (see [`crate::helios`]) whose localhost JSON-RPC server is what the alloy
+//! provider reads through — every read is proof-checked. The `rpc_url` passed to
+//! [`EthProvider::spawn`] becomes the *execution-layer* endpoint Helios proves against
+//! (it must serve `eth_getProof`); it is no longer read directly. Each read is tagged
+//! with a [`ReadStatus`]: `Verified` when a fresh Helios head backs it, `Unsynced`
+//! otherwise.
+//!
+//! When `verified-reads` is OFF, the worker keeps the original raw-RPC path but tags
+//! every read `ReadStatus::Unsynced("verification disabled")` — it never claims a raw
+//! read is Verified.
+//!
+//! Threading model (eng-review decision, preserved): a *single* background tokio
+//! current-thread runtime owns every network call — including Helios's spawned
+//! localhost server task, which runs cooperatively on the same runtime. The GUI never
+//! blocks and never touches tokio.
+//!
+//! TODO(post-v1): v1 runs an INDEPENDENT Helios instance per reader (this one + the
+//! daemon's). The "consolidate all reads into the daemon" refactor is deferred.
+//! TODO(post-v1): if Helios's server task starves under load on the shared
+//! current-thread runtime, consider `new_multi_thread`. Do NOT switch preemptively —
+//! it would break the "single current-thread runtime" decision without proven need.
use alloy::ens::ProviderEnsExt;
use alloy::primitives::{Address, U256};
use alloy::providers::{DynProvider, Provider, ProviderBuilder};
+use deckard_contract::ReadStatus;
+
use crate::balances::{fetch_portfolio, Portfolio};
-/// A reliable public mainnet RPC, used until the bundled Helios light client lands.
-/// Overridable via settings (bring-your-own-RPC).
+/// A reliable public mainnet RPC, used as the execution-layer endpoint Helios proves
+/// against (or, with `verified-reads` off, read directly). Overridable via settings.
pub const DEFAULT_RPC: &str = "https://ethereum-rpc.publicnode.com";
/// The reply half of a request: the worker sends the result here; the caller awaits it.
type Reply = flume::Sender>;
+/// A value read off-chain, with the trust label that read carries. Returned to the UI
+/// so it can render the verified/unsynced state alongside the value.
+#[derive(Clone, Debug)]
+pub struct Read {
+ pub value: T,
+ pub status: ReadStatus,
+}
+
+impl Read {
+ fn new(value: T, status: ReadStatus) -> Self {
+ Self { value, status }
+ }
+}
+
/// Typed requests the GUI sends to the network worker. Each carries its own reply
/// channel so call sites stay ergonomic and unrelated requests never head-of-line block.
enum EthReq {
- Balance { addr: Address, reply: Reply },
- BlockNumber { reply: Reply },
- Portfolio { addr: Address, reply: Reply },
- ResolveName { name: String, reply: Reply },
+ Balance {
+ addr: Address,
+ reply: Reply>,
+ },
+ BlockNumber {
+ reply: Reply>,
+ },
+ Portfolio {
+ addr: Address,
+ reply: Reply>,
+ },
+ ResolveName {
+ name: String,
+ reply: Reply,
+ },
}
/// A cloneable handle to the network worker thread. Clone it freely into UI views;
@@ -37,8 +85,9 @@ pub struct EthProvider {
}
impl EthProvider {
- /// Spawn the network worker pointed at `rpc_url`. Never blocks; the runtime and
- /// the alloy provider are built on the worker thread.
+ /// Spawn the network worker pointed at `rpc_url`. Never blocks; the runtime, the
+ /// embedded Helios client (when `verified-reads` is on), and the alloy provider are
+ /// all built on the worker thread.
pub fn spawn(rpc_url: impl Into) -> Self {
let rpc_url = rpc_url.into();
let (tx, rx) = flume::unbounded::();
@@ -49,25 +98,26 @@ impl EthProvider {
Self { tx }
}
- /// Fetch the native ETH balance (wei) of `addr`. Returns immediately; the caller
- /// awaits the receiver on its own executor. A dead worker resolves to an error
- /// rather than hanging.
- pub fn balance(&self, addr: Address) -> flume::Receiver> {
+ /// Fetch the native ETH balance (wei) of `addr`, with its trust label. Returns
+ /// immediately; the caller awaits the receiver on its own executor. A dead worker
+ /// resolves to an error rather than hanging.
+ pub fn balance(&self, addr: Address) -> flume::Receiver>> {
self.request(|reply| EthReq::Balance { addr, reply })
}
- /// Fetch the latest block number — a cheap liveness/sync probe for the status line.
- pub fn block_number(&self) -> flume::Receiver> {
+ /// Fetch the latest block number (a cheap liveness/sync probe) with its trust label.
+ pub fn block_number(&self) -> flume::Receiver>> {
self.request(|reply| EthReq::BlockNumber { reply })
}
/// Fetch the full portfolio (native + listed ERC-20 balances) in one Multicall3
- /// round-trip. Non-blocking; await the receiver on the UI executor.
- pub fn portfolio(&self, addr: Address) -> flume::Receiver> {
+ /// round-trip, with its trust label. Non-blocking; await on the UI executor.
+ pub fn portfolio(&self, addr: Address) -> flume::Receiver>> {
self.request(|reply| EthReq::Portfolio { addr, reply })
}
- /// Forward-resolve an ENS name (e.g. `vitalik.eth`) to an address.
+ /// Forward-resolve an ENS name (e.g. `vitalik.eth`) to an address. Not value-bearing,
+ /// so no trust label — the resulting address is then read with one.
pub fn resolve_name(&self, name: impl Into) -> flume::Receiver> {
let name = name.into();
self.request(|reply| EthReq::ResolveName { name, reply })
@@ -88,8 +138,8 @@ impl EthProvider {
}
}
-/// The worker entry point: build the runtime + provider, then service requests until
-/// every `EthProvider` handle has dropped (which closes `rx`).
+/// The worker entry point: build the runtime + the read provider (verified or raw),
+/// then service requests until every `EthProvider` handle has dropped (closing `rx`).
fn run_worker(rpc_url: String, rx: flume::Receiver) {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -97,48 +147,174 @@ fn run_worker(rpc_url: String, rx: flume::Receiver) {
.expect("build tokio current-thread runtime");
rt.block_on(async move {
- // A bad URL yields `None`; we still drain the queue and answer every request
- // with an error so the UI never hangs waiting on a reply that never comes.
- let provider: Option = rpc_url
- .parse()
- .ok()
- .map(|url| ProviderBuilder::new().connect_http(url).erased());
+ let read_path = ReadPath::build(&rpc_url).await;
while let Ok(req) = rx.recv_async().await {
match req {
EthReq::Balance { addr, reply } => {
- let _ = reply.send(fetch_balance(provider.as_ref(), addr).await);
+ let _ = reply.send(read_path.balance(addr).await);
}
EthReq::BlockNumber { reply } => {
- let _ = reply.send(fetch_block_number(provider.as_ref()).await);
+ let _ = reply.send(read_path.block_number().await);
}
EthReq::Portfolio { addr, reply } => {
- let res = match provider.as_ref() {
- Some(p) => fetch_portfolio(p, addr).await,
- None => Err(anyhow::anyhow!("invalid RPC URL")),
- };
- let _ = reply.send(res);
+ let _ = reply.send(read_path.portfolio(addr).await);
}
EthReq::ResolveName { name, reply } => {
- let res = match provider.as_ref() {
- Some(p) => p.resolve_name(&name).await.map_err(anyhow::Error::from),
- None => Err(anyhow::anyhow!("invalid RPC URL")),
- };
- let _ = reply.send(res);
+ let _ = reply.send(read_path.resolve_name(&name).await);
}
}
}
});
}
-async fn fetch_balance(provider: Option<&DynProvider>, addr: Address) -> anyhow::Result {
- let provider = provider.ok_or_else(|| anyhow::anyhow!("invalid RPC URL"))?;
- Ok(provider.get_balance(addr).await?)
+/// The worker's resolved read path. Holds the alloy provider it reads through and, when
+/// `verified-reads` is on, the embedded Helios client that owns the localhost server
+/// (kept alive for the worker's lifetime — its Drop tears the server down).
+struct ReadPath {
+ /// `None` when the URL was unparseable / Helios failed to come up. Every read then
+ /// answers with an error or an `Unsynced` status (fail-closed; the UI never hangs).
+ provider: Option,
+ /// `None` → this is the verified Helios path: the trust label is re-derived per read
+ /// from Helios head freshness. `Some(reason)` → a non-verified path (Helios down or
+ /// the feature disabled): every read is tagged `Unsynced(reason)`, NEVER `Verified`.
+ unverified_reason: Option,
+ /// Keeps the embedded Helios localhost server alive. `None` for the raw path.
+ #[cfg(feature = "verified-reads")]
+ _helios: Option,
+}
+
+impl ReadPath {
+ /// Build the read path on the worker thread, inside the worker's tokio runtime.
+ #[cfg(feature = "verified-reads")]
+ async fn build(rpc_url: &str) -> Self {
+ // The configured RPC is now the EXECUTION-layer endpoint Helios proves against
+ // (it must serve eth_getProof) — never read directly. CL drives the sync.
+ let data_dir = crate::config::config_dir()
+ .map(|d| d.join("helios"))
+ .unwrap_or_else(|| std::path::PathBuf::from(".deckard-helios"));
+
+ match crate::helios::launch_verified(
+ crate::helios::DEFAULT_CONSENSUS_RPC,
+ rpc_url,
+ data_dir,
+ )
+ .await
+ {
+ Ok(reader) => {
+ // Clone the verified localhost provider out for the read handlers; the
+ // VerifiedReader is retained so the server task stays alive.
+ let provider = reader.provider().clone();
+ Self {
+ provider: Some(provider),
+ unverified_reason: None, // verified path: label by head freshness
+ _helios: Some(reader),
+ }
+ }
+ Err(e) => {
+ // Helios never came up: serve reads as Unsynced. We do NOT fall back to a
+ // raw read of the (untrusted) RPC and call it Verified. We still build a
+ // raw provider so values can be shown, but always tagged Unsynced with an
+ // honest reason.
+ let reason = format!("helios unavailable: {}", one_line(&e));
+ let provider = rpc_url
+ .parse()
+ .ok()
+ .map(|url| ProviderBuilder::new().connect_http(url).erased());
+ Self {
+ provider,
+ unverified_reason: Some(reason),
+ _helios: None,
+ }
+ }
+ }
+ }
+
+ /// Feature-off build: the original raw-RPC path, always tagged Unsynced.
+ #[cfg(not(feature = "verified-reads"))]
+ async fn build(rpc_url: &str) -> Self {
+ let provider = rpc_url
+ .parse()
+ .ok()
+ .map(|url| ProviderBuilder::new().connect_http(url).erased());
+ Self {
+ provider,
+ unverified_reason: Some("verification disabled".to_string()),
+ }
+ }
+
+ /// The trust label for a read taken now. On the verified path (`unverified_reason ==
+ /// None`), re-derive from the Helios head (a head gone stale mid-session downgrades to
+ /// Unsynced). Otherwise the fixed honest reason. NEVER returns Verified without a
+ /// fresh Helios head behind it.
+ ///
+ /// Each value-bearing read (`balance`/`block_number`/`portfolio`) reads the value FIRST
+ /// and then calls `status()` — so a `Verified` tag is bound to a head observed *after*
+ /// the value came back (the daemon's read path uses the same ordering). A small
+ /// time-of-check/time-of-use window remains between the two round-trips: a head could go
+ /// stale in the gap. This is an accepted v1 limitation; it always fails toward "a fresh
+ /// verified head backed the value", never toward a false `Verified`.
+ async fn status(&self) -> ReadStatus {
+ match &self.unverified_reason {
+ None => {
+ #[cfg(feature = "verified-reads")]
+ if let Some(reader) = &self._helios {
+ return reader.head_status().await;
+ }
+ // Defensive: a verified path with no client shouldn't happen.
+ ReadStatus::unsynced("verification unavailable")
+ }
+ Some(reason) => ReadStatus::unsynced(reason.clone()),
+ }
+ }
+
+ async fn balance(&self, addr: Address) -> anyhow::Result> {
+ let provider = self
+ .provider
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?;
+ let value = provider.get_balance(addr).await?;
+ Ok(Read::new(value, self.status().await))
+ }
+
+ async fn block_number(&self) -> anyhow::Result> {
+ let provider = self
+ .provider
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?;
+ let value = provider.get_block_number().await?;
+ Ok(Read::new(value, self.status().await))
+ }
+
+ async fn portfolio(&self, addr: Address) -> anyhow::Result> {
+ let provider = self
+ .provider
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?;
+ let value = fetch_portfolio(provider, addr).await?;
+ Ok(Read::new(value, self.status().await))
+ }
+
+ async fn resolve_name(&self, name: &str) -> anyhow::Result {
+ let provider = self
+ .provider
+ .as_ref()
+ .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?;
+ provider.resolve_name(name).await.map_err(anyhow::Error::from)
+ }
}
-async fn fetch_block_number(provider: Option<&DynProvider>) -> anyhow::Result {
- let provider = provider.ok_or_else(|| anyhow::anyhow!("invalid RPC URL"))?;
- Ok(provider.get_block_number().await?)
+/// Collapse a multi-line error into one short line for a `reason` string. Only the
+/// verified-reads build constructs a reason from an error.
+#[cfg(feature = "verified-reads")]
+fn one_line(e: &impl std::fmt::Display) -> String {
+ e.to_string()
+ .lines()
+ .next()
+ .unwrap_or("")
+ .chars()
+ .take(160)
+ .collect()
}
#[cfg(test)]
@@ -146,23 +322,42 @@ mod tests {
use super::*;
use alloy::providers::mock::Asserter;
- /// The provider abstraction reads a balance off a mocked transport — no network,
- /// deterministic, fast. Proves the decode path without hitting a real RPC.
- #[tokio::test]
- async fn balance_reads_from_mocked_transport() {
- let asserter = Asserter::new();
- asserter.push_success(&U256::from(31_415u64));
+ /// Build a ReadPath over a mocked transport (no network, deterministic). Reads are
+ /// tagged Unsynced because there is no Helios behind a mock — the hard rule holds.
+ fn mocked_path(asserter: Asserter) -> ReadPath {
let provider = ProviderBuilder::new()
.connect_mocked_client(asserter)
.erased();
+ ReadPath {
+ provider: Some(provider),
+ unverified_reason: Some("test (no helios)".to_string()),
+ #[cfg(feature = "verified-reads")]
+ _helios: None,
+ }
+ }
+
+ /// The read path decodes a balance off a mocked transport and attaches a status.
+ #[tokio::test]
+ async fn balance_reads_from_mocked_transport_with_status() {
+ let asserter = Asserter::new();
+ asserter.push_success(&U256::from(31_415u64));
+ let path = mocked_path(asserter);
- let bal = fetch_balance(Some(&provider), Address::ZERO).await.unwrap();
- assert_eq!(bal, U256::from(31_415u64));
+ let read = path.balance(Address::ZERO).await.unwrap();
+ assert_eq!(read.value, U256::from(31_415u64));
+ // No Helios behind a mock → never Verified.
+ assert!(!read.status.is_trustworthy());
}
- /// A bad RPC URL fails closed with an error rather than panicking or hanging.
+ /// A missing provider fails closed with an error rather than panicking or hanging.
#[tokio::test]
- async fn invalid_url_errors_cleanly() {
- assert!(fetch_balance(None, Address::ZERO).await.is_err());
+ async fn no_provider_errors_cleanly() {
+ let path = ReadPath {
+ provider: None,
+ unverified_reason: Some("test (no provider)".to_string()),
+ #[cfg(feature = "verified-reads")]
+ _helios: None,
+ };
+ assert!(path.balance(Address::ZERO).await.is_err());
}
}
diff --git a/crates/deckard-core/src/helios.rs b/crates/deckard-core/src/helios.rs
new file mode 100644
index 0000000..016f530
--- /dev/null
+++ b/crates/deckard-core/src/helios.rs
@@ -0,0 +1,279 @@
+//! Stand up an embedded **verified** Helios light client whose localhost JSON-RPC
+//! server is the endpoint an alloy provider reads through — so every chain read is
+//! proof-checked instead of trusting a raw vendor RPC.
+//!
+//! Lifted from the verified `eip1193-railgun` spike (`spikes/eip1193-railgun/src/helios.rs`),
+//! ported from `eyre` → `anyhow` and trimmed to the helios-only consumer Deckard needs.
+//!
+//! Verified against `a16z/helios @ 0.11.1`:
+//! * `EthereumClientBuilder::rpc_address(SocketAddr)` records a bind addr;
+//! * on `.build()`, the client `tokio::spawn`s the localhost JSON-RPC server,
+//! serving the `eth_*` subset — every read proof-checked.
+//! * `.build()` is sync but MUST run inside a tokio runtime (it spawns the server
+//! task). Both callers (deckard-core's EthProvider worker and deckard-signerd's
+//! daemon) own a tokio runtime, so this holds.
+//!
+//! `wait_synced()` ≠ ready: after it returns, the first execution head lands ~1 slot
+//! later (≤12s); until then every `Latest` read fails the 60s `check_head_age` gate.
+//! So we poll `get_block_number()` until `Ok` before declaring the read path live.
+//!
+//! THE one-line consumer fix (see [`connect_verified_provider`]): build the alloy
+//! provider with `.with_default_block(BlockId::latest())`. alloy's `Provider::call`
+//! defaults the block tag to `pending`, which a light client cannot serve
+//! ("block not found: pending") — this rewrites the default to `latest` so the
+//! Multicall3 / ENS `eth_call` reads work.
+//!
+//! TODO(post-v1): v1 runs an INDEPENDENT Helios instance per reader (one behind
+//! deckard-core::EthProvider, one in the daemon). The "consolidate all reads into
+//! the daemon" refactor is deferred. The failover/community-checkpoint supervisor
+//! (spikes/helios-walkaway/src/upstreams.rs) that would emit `ReadStatus::Degraded`
+//! is also deferred — v1 runs a single client per reader.
+
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::time::{Duration, Instant};
+
+use alloy::eips::BlockId;
+use alloy::primitives::U256;
+use alloy::providers::{DynProvider, Provider, ProviderBuilder};
+use anyhow::{anyhow, Result};
+use deckard_contract::ReadStatus;
+use helios_ethereum::config::networks::Network;
+use helios_ethereum::database::FileDB;
+use helios_ethereum::{EthereumClient, EthereumClientBuilder};
+
+/// A consensus-layer (beacon) endpoint that actually drives a Helios sync. Nimbus's
+/// public testing beacon API is the spike's proven default; dRPC
+/// (`https://eth-beacon-chain.drpc.org`) is the documented alternate.
+pub const DEFAULT_CONSENSUS_RPC: &str = "http://testing.mainnet.beacon-api.nimbus.team";
+
+/// A live, verified Helios read path: the localhost provider an alloy consumer reads
+/// through, plus the owning `EthereumClient` whose Drop tears down the spawned server.
+///
+/// **The `_client` field is load-bearing**: dropping it kills the spawned localhost
+/// JSON-RPC server task. Keep this struct alive for as long as reads are served.
+pub struct VerifiedReader {
+ /// The localhost provider, already built with the `with_default_block(latest)` fix.
+ provider: DynProvider,
+ /// The `http://127.0.0.1:` URL the localhost JSON-RPC server is bound at — so a
+ /// caller (e.g. the daemon's `signing::read_balance`) can build its OWN consumer
+ /// provider against the same verified server.
+ localhost_url: String,
+ /// Owns the spawned localhost JSON-RPC server task; must outlive `provider`.
+ _client: EthereumClient,
+}
+
+impl VerifiedReader {
+ /// Borrow the verified localhost provider (alloy, `with_default_block(latest)`).
+ pub fn provider(&self) -> &DynProvider {
+ &self.provider
+ }
+
+ /// The verified localhost JSON-RPC URL (`http://127.0.0.1:`). Reads through
+ /// this are proof-checked by Helios.
+ pub fn localhost_url(&self) -> &str {
+ &self.localhost_url
+ }
+
+ /// Compute the trust label for a read taken *now*: `Verified` only when the Helios
+ /// head is fresh (age ≤ 60s), else `Unsynced`. v1 never emits `Degraded` here — that
+ /// is the deferred failover/community-checkpoint path (see the module TODO).
+ ///
+ /// Called once per read so a head that goes stale mid-session is caught: a value is
+ /// only ever labelled `Verified` when a fresh verified head actually backs it.
+ ///
+ /// We fetch the *latest block by tag* (not a bare `eth_blockNumber`): fetching the
+ /// `Latest` block exercises Helios's own `check_head_age` (60s) gate AND lets us derive
+ /// freshness from the block's timestamp directly, rather than trusting that a stored
+ /// height implies a fresh head. A bare height call can be answered by a stalled-but-
+ /// not-yet-expired client and would over-report `Verified`; the timestamp check closes
+ /// that gap.
+ pub async fn head_status(&self) -> ReadStatus {
+ let block = match self.provider.get_block(BlockId::latest()).await {
+ Ok(Some(b)) => b,
+ // No latest block: either still syncing or the head aged out of the gate.
+ Ok(None) => return ReadStatus::unsynced("helios head unavailable: no latest block"),
+ Err(e) => {
+ return ReadStatus::unsynced(format!("helios head unavailable: {}", one_line(&e)))
+ }
+ };
+
+ let head_ts = block.header.timestamp;
+ let now = now_unix();
+ // `now` can legitimately be < head_ts by a few seconds (clock skew / a head minted
+ // slightly ahead); saturating_sub treats that as age 0, never as a stale read.
+ let age = now.saturating_sub(head_ts);
+ if age <= MAX_HEAD_AGE_SECS {
+ ReadStatus::Verified
+ } else {
+ ReadStatus::unsynced(format!("helios head stale ({age}s > {MAX_HEAD_AGE_SECS}s)"))
+ }
+ }
+}
+
+/// Helios's own hard freshness gate for `Latest` reads (`check_head_age`, 60s). We mirror
+/// it here so a value is labelled `Verified` only when its backing head is within the gate.
+const MAX_HEAD_AGE_SECS: u64 = 60;
+
+/// Current wall-clock UNIX time in seconds. Used only to compare against the verified
+/// head's block timestamp for the freshness label.
+fn now_unix() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+/// Build a verified Helios **mainnet** client, launch its localhost JSON-RPC server,
+/// and return a [`VerifiedReader`] only once the server is actually serving a fresh
+/// verified head.
+///
+/// * `consensus_rpc` — the beacon (CL) endpoint that drives the sync (e.g. Nimbus).
+/// * `execution_rpc` — the EL endpoint Helios proves against (must serve `eth_getProof`).
+/// This is the *untrusted* RPC the app was previously reading directly — now it only
+/// feeds proofs that Helios verifies.
+/// * `data_dir` — FileDB cache dir → warm starts from a cached checkpoint.
+///
+/// On any failure (sync timeout, server never binds, head never fresh) returns an
+/// `Err` — the caller MUST then serve reads tagged `Unsynced`, NEVER fall back to a
+/// raw RPC and call it `Verified`.
+pub async fn launch_verified(
+ consensus_rpc: &str,
+ execution_rpc: &str,
+ data_dir: PathBuf,
+) -> Result {
+ let port = free_loopback_port()?;
+ let rpc_addr: SocketAddr = ([127, 0, 0, 1], port).into();
+ let helios_url = format!("http://127.0.0.1:{port}");
+
+ let client = build_with_server(consensus_rpc, execution_rpc, data_dir, rpc_addr)?;
+
+ // CL checkpoint bootstrapped...
+ client
+ .wait_synced()
+ .await
+ .map_err(|e| anyhow!("helios wait_synced: {e}"))?;
+ // ...then the typed client serves a fresh execution head (the honest "ready" moment;
+ // wait_synced alone isn't it — the first head lands ~1 slot later).
+ wait_until_serving(&client, Duration::from_secs(60)).await?;
+
+ // Build the CONSUMER provider against the localhost server, with THE fix.
+ let provider = connect_verified_provider(&helios_url).await?;
+
+ // Prove the spawned localhost server is actually answering (and the consumer
+ // provider's default-block layer works) before we declare the path live.
+ wait_provider_live(&provider, Duration::from_secs(30)).await?;
+
+ Ok(VerifiedReader {
+ provider,
+ localhost_url: helios_url,
+ _client: client,
+ })
+}
+
+/// Build a verified Helios mainnet client whose localhost JSON-RPC server is bound at
+/// `rpc_addr`. FileDB → warm starts from a cached checkpoint. Sync, but must run inside
+/// a tokio runtime (it spawns the server task on `.build()`).
+fn build_with_server(
+ cl: &str,
+ el: &str,
+ data_dir: PathBuf,
+ rpc_addr: SocketAddr,
+) -> Result {
+ EthereumClientBuilder::::new()
+ .network(Network::Mainnet)
+ .consensus_rpc(cl)
+ .map_err(|e| anyhow!("helios consensus_rpc {cl:?}: {e}"))?
+ .execution_rpc(el)
+ .map_err(|e| anyhow!("helios execution_rpc: {e}"))?
+ .data_dir(data_dir)
+ // strict: refuse a too-old checkpoint (hard failure, never a silent stale read).
+ .strict_checkpoint_age()
+ // No user-pinned checkpoint → community fallback (ethPandaOps). v1 labels reads
+ // off this path Verified-by-freshness; the Degraded community-checkpoint
+ // distinction is a deferred supervisor concern (see module TODO).
+ .load_external_fallback()
+ // THE mechanism the whole verified path rests on: spawn the localhost JSON-RPC
+ // server on build() so an alloy HTTP provider can read through it.
+ .rpc_address(rpc_addr)
+ .with_file_db()
+ .build()
+ .map_err(|e| anyhow!("helios build: {e}"))
+}
+
+/// Build the **consumer** alloy provider that reads through Helios's localhost server.
+///
+/// THE one-line fix: `.with_default_block(BlockId::latest())`. alloy's `Provider::call`
+/// defaults the block tag to `pending`; Helios (a light client) has no pending block and
+/// 404s on it ("block not found: pending"). This rewrites the default to `latest` so the
+/// Multicall3 `aggregate3` (portfolio) and ENS `eth_call` reads succeed. Applied
+/// uniformly so every read path is identical; plain `get_balance`/`get_block_number`
+/// reads are unaffected but harmless to layer.
+async fn connect_verified_provider(helios_url: &str) -> Result {
+ let url = helios_url
+ .parse()
+ .map_err(|e| anyhow!("bad helios url {helios_url:?}: {e}"))?;
+ Ok(ProviderBuilder::new()
+ .with_default_block(BlockId::latest())
+ .connect_http(url)
+ .erased())
+}
+
+/// Block until the typed client serves a fresh verified head (the honest "ready to serve
+/// verified reads" moment — `wait_synced` returning is NOT it).
+async fn wait_until_serving(client: &EthereumClient, timeout: Duration) -> Result {
+ let deadline = Instant::now() + timeout;
+ loop {
+ match client.get_block_number().await {
+ Ok(h) => return Ok(h),
+ Err(e) => {
+ if Instant::now() > deadline {
+ return Err(anyhow!("helios: no fresh head within {timeout:?}: {e}"));
+ }
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ }
+ }
+ }
+}
+
+/// Poll the CONSUMER alloy provider (built against the localhost server) until it answers
+/// `eth_blockNumber` — proving the spawned `jsonrpc::start` task has bound AND that the
+/// consumer provider talks to it. Uses the alloy provider directly so we don't pull in
+/// `reqwest`/`serde_json` just for a liveness probe (the spike used reqwest).
+async fn wait_provider_live(provider: &DynProvider, timeout: Duration) -> Result {
+ let deadline = Instant::now() + timeout;
+ loop {
+ match provider.get_block_number().await {
+ Ok(n) => return Ok(n),
+ Err(e) => {
+ if Instant::now() > deadline {
+ return Err(anyhow!(
+ "helios localhost server never answered via the consumer provider: {e}"
+ ));
+ }
+ tokio::time::sleep(Duration::from_millis(250)).await;
+ }
+ }
+ }
+}
+
+/// Grab a free loopback port by binding an ephemeral socket and dropping it. Helios
+/// discards the jsonrpsee `ServerHandle`, so a `:0` port can't be recovered after the
+/// fact — pick a concrete one up front. (Small TOCTOU window; acceptable.)
+fn free_loopback_port() -> Result {
+ let l = std::net::TcpListener::bind("127.0.0.1:0")?;
+ let port = l.local_addr()?.port();
+ drop(l);
+ Ok(port)
+}
+
+/// Collapse a multi-line error into one short line for a `reason` string.
+fn one_line(e: &impl std::fmt::Display) -> String {
+ e.to_string()
+ .lines()
+ .next()
+ .unwrap_or("")
+ .chars()
+ .take(160)
+ .collect()
+}
diff --git a/crates/deckard-core/src/lib.rs b/crates/deckard-core/src/lib.rs
index d317ebd..289d1b5 100644
--- a/crates/deckard-core/src/lib.rs
+++ b/crates/deckard-core/src/lib.rs
@@ -10,17 +10,49 @@
//! GUI thread never blocks and never touches tokio.
pub mod balances;
+pub mod config;
pub mod eth;
+/// Embedded Helios light client → verified localhost reads. Gated behind the
+/// default-on `verified-reads` feature so the heavy revm/bls build is toggleable.
+#[cfg(feature = "verified-reads")]
+pub mod helios;
pub mod keystore;
+/// Key-less Railgun shield-calldata builder. Gated behind the default-on `shield` feature
+/// so the heavy ZK `railgun` crate is toggleable. When the feature is off, the
+/// `build_shield_native_intent` stub below returns a clear error (never a fake success).
+#[cfg(feature = "shield")]
+pub mod shield;
pub mod tokens;
pub use balances::{fetch_portfolio, format_amount, Portfolio, TokenBalance};
-pub use eth::{EthProvider, DEFAULT_RPC};
-pub use keystore::{
- random_word_positions, KdfParams, SecretKind, UnlockedVault, Vault, WordCount,
-};
+pub use config::{config_dir, policy_path, vault_path};
+pub use eth::{EthProvider, Read, DEFAULT_RPC};
+#[cfg(feature = "verified-reads")]
+pub use helios::{launch_verified, VerifiedReader, DEFAULT_CONSENSUS_RPC};
+pub use keystore::{random_word_positions, KdfParams, SecretKind, UnlockedVault, Vault, WordCount};
+// The key-less shield-calldata builder + the 0zk recipient type, re-exported so the daemon
+// and its tests can name them through core without a direct `railgun` dependency.
+#[cfg(feature = "shield")]
+pub use shield::{build_shield_native_intent, RailgunAddress};
pub use tokens::{TokenInfo, DEFAULT_TOKENS};
+/// Feature-off stub: when `shield` is compiled out, the symbol still exists so the daemon
+/// and tests build, but it returns a clear error — NEVER a fake success. Mirrors the
+/// honest-failure pattern the `verified-reads`-off read path uses (a Deny/Unsynced label
+/// rather than a silent fabricated value).
+#[cfg(not(feature = "shield"))]
+pub fn build_shield_native_intent(
+ _chain_id: u64,
+ _recipient: (),
+ _value: alloy_primitives::U256,
+) -> anyhow::Result {
+ anyhow::bail!("shield unavailable (feature off)")
+}
+
+// The shared trust label, re-exported so the app + daemon can name it through core
+// without a direct deckard-contract dependency just to render a read status.
+pub use deckard_contract::ReadStatus;
+
// Re-export the alloy primitive types the UI renders, so the app layer doesn't
// need a direct alloy dependency just to name an `Address` or a `U256`.
pub use alloy_primitives::{Address, U256};
diff --git a/crates/deckard-core/src/shield.rs b/crates/deckard-core/src/shield.rs
new file mode 100644
index 0000000..fe26a02
--- /dev/null
+++ b/crates/deckard-core/src/shield.rs
@@ -0,0 +1,125 @@
+//! Key-less Railgun **shield**-calldata builder.
+//!
+//! A SHIELD (depositing public ETH into a Railgun `0zk` private balance) is **key-less**:
+//! it needs only the recipient [`RailgunAddress`], the chain, and the value — never the
+//! spending key. And — the de-risked hero finding — a shield does **NO client-side ZK
+//! proof**: [`ShieldBuilder::build`] only encrypts the note (`encrypt_shield`) and
+//! `abi_encode`s the calldata; the on-chain contract verifies the commitment and deducts
+//! the 25-bps fee. So this builder is pure, synchronous, and instant.
+//!
+//! It builds the calldata and wraps it as an [`Intent`] with [`IntentKind::Shield`]; the
+//! daemon (which never sees this heavy ZK crate) just signs + broadcasts the handed
+//! `{to, value, calldata}`. That split is deliberate: the heavy `railgun` dep + any sync
+//! stays OUT of the key-holding daemon.
+//!
+//! Gated behind the default-on `shield` Cargo feature so the heavy ZK `railgun` tree is
+//! toggleable. When the feature is off, [`build_shield_native_intent`] is replaced by a
+//! stub (declared in `lib.rs`) that returns a clear "shield unavailable (feature off)"
+//! error — never a fake success.
+
+use alloy_primitives::U256;
+use anyhow::{anyhow, ensure};
+
+use deckard_contract::{Intent, IntentKind};
+
+// Re-exported from `lib.rs` (gated) so the daemon's test can name the recipient type
+// without taking a direct `railgun` dependency.
+pub use railgun::account::address::RailgunAddress;
+
+/// Build the key-less Railgun native-shield calldata and wrap it as an
+/// `Intent { kind: Shield, .. }`.
+///
+/// Key-less: shielding native ETH to `recipient` (a `0zk…` [`RailgunAddress`]) needs only
+/// the recipient, the chain config, and the value — never the spending key. The on-chain
+/// 25-bps (0.25%) shield fee is deducted by the contract; the calldata carries the full
+/// pre-fee `value`, so the synced private balance reads `value - value*25/10000`.
+///
+/// `value` is wei. For a *native* shield the builder always produces **exactly one**
+/// `TxData` (a single RelayAdapt `wrapBase + shield` multicall), so the 1-intent:1-tx model
+/// holds; we assert that invariant rather than silently dropping a tx.
+pub fn build_shield_native_intent(
+ chain_id: u64,
+ recipient: RailgunAddress,
+ value: U256,
+) -> anyhow::Result {
+ let chain = railgun::chain_config::ChainConfig::from_chain_id(chain_id)
+ .ok_or_else(|| anyhow!("shield: unsupported chain_id {chain_id}"))?;
+
+ // The note preimage carries a u128 value; reject anything that can't fit (a shield of
+ // > ~3.4e20 ETH is not a real case, but never silently truncate).
+ let value_u128: u128 = value
+ .try_into()
+ .map_err(|_| anyhow!("shield: value exceeds u128"))?;
+
+ // Pure + synchronous: no provider, no sync, no key. `build` only does symmetric note
+ // encryption + ABI-encode (no ZK proof). NOTE: railgun pins `rand` 0.9; deckard-core's
+ // own `rand` is 0.8, so this rng comes from the 0.9 crate aliased as `rand_09` in
+ // Cargo.toml to satisfy the `R: rand::Rng` (0.9) bound on `build`.
+ let mut txs = railgun::transact::ShieldBuilder::new(chain)
+ .shield_native(recipient, value_u128)
+ .build(&mut rand_09::rng())
+ .map_err(|e| anyhow!("shield build: {e}"))?;
+
+ ensure!(
+ txs.len() == 1,
+ "shield_native produced {} txs, expected exactly 1",
+ txs.len()
+ );
+ // Safe: just asserted len == 1.
+ let tx = txs.pop().expect("len checked == 1");
+
+ Ok(Intent {
+ chain_id,
+ to: tx.to, // RelayAdapt contract
+ token: None, // native shield; the value rides as msg.value
+ value: tx.value, // == the gross native total (wei); contract deducts the fee
+ calldata: tx.data, // RelayAdapt.multicall(wrapBase + shield)
+ kind: IntentKind::Shield,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A fresh ephemeral 0zk recipient → a well-formed Shield intent (key-less, no network):
+ /// exactly one tx, native (token None), non-empty calldata, value preserved (gross).
+ #[test]
+ fn builds_a_single_native_shield_intent() {
+ let chain = railgun::chain_config::ChainConfig::sepolia();
+ let acct = railgun::account::signer::PrivateKeySigner::new_evm(
+ rand_09::random(),
+ rand_09::random(),
+ chain.id,
+ );
+ use railgun::account::signer::RailgunSigner;
+ let recipient = acct.address();
+
+ let value = U256::from(1_000_000u64);
+ let intent = build_shield_native_intent(chain.id, recipient, value).expect("build");
+
+ assert_eq!(intent.kind, IntentKind::Shield);
+ assert_eq!(intent.token, None, "native shield carries no token");
+ assert_eq!(intent.value, value, "calldata carries the GROSS (pre-fee) value");
+ assert!(!intent.calldata.is_empty(), "shield calldata must be present");
+ assert_eq!(
+ intent.to, chain.relay_adapt_contract,
+ "native shield targets the RelayAdapt contract"
+ );
+ }
+
+ /// An unsupported chain id is a clear error, not a panic.
+ #[test]
+ fn unsupported_chain_errors() {
+ let chain = railgun::chain_config::ChainConfig::sepolia();
+ let acct = railgun::account::signer::PrivateKeySigner::new_evm(
+ rand_09::random(),
+ rand_09::random(),
+ chain.id,
+ );
+ use railgun::account::signer::RailgunSigner;
+ let err = build_shield_native_intent(424242, acct.address(), U256::from(1u64))
+ .expect_err("unsupported chain must error");
+ assert!(err.to_string().contains("unsupported chain_id"));
+ }
+}
diff --git a/crates/deckard-signerd/Cargo.toml b/crates/deckard-signerd/Cargo.toml
new file mode 100644
index 0000000..5d66d4b
--- /dev/null
+++ b/crates/deckard-signerd/Cargo.toml
@@ -0,0 +1,73 @@
+[package]
+name = "deckard-signerd"
+version = "0.1.0"
+edition = "2021"
+license = "AGPL-3.0-or-later"
+description = "Deckard's process-isolated signer daemon: owns the decrypted key, runs the policy gate, signs + broadcasts, and answers STOP — over a same-uid Unix-domain socket. The app and the future MCP sidecar are key-less clients."
+
+# lib + bin: the lib holds the wire framing, socket/peer-cred plumbing, the daemon state
+# machine, and the client + supervisor the GUI app reuses; the bin is the daemon entry point.
+[lib]
+name = "deckard_signerd"
+path = "src/lib.rs"
+
+[[bin]]
+name = "deckard-signerd"
+path = "src/main.rs"
+
+[features]
+# Verified reads via the embedded Helios light client (shared launcher in deckard-core).
+# ON by default; threads through to deckard-core's `verified-reads`. When OFF, the
+# daemon's balance read falls back to the raw RPC, tagged Unsynced("verification disabled").
+default = ["verified-reads", "shield"]
+verified-reads = ["deckard-core/verified-reads"]
+# Threads deckard-core's key-less shield-calldata builder through (DEFAULT ON). The daemon
+# itself gains NO railgun dep — it only broadcasts handed calldata; this feature just lets the
+# black-box `shield_e2e` integration test drive deckard-core's builder + assert via railgun.
+shield = ["deckard-core/shield"]
+
+[dependencies]
+# The frozen wire contract (Intent / Decision / Policy / RPC + the shared `evaluate`).
+deckard-contract = { path = "../deckard-contract" }
+# The headless engine: the keystore (`Vault`/`UnlockedVault`) we reuse — never rebuilt
+# here — plus the shared Helios launcher (`launch_verified`) behind `verified-reads`.
+# `default-features = false` so this crate's own feature flags (below) are the SINGLE source
+# of truth for what core builds: toggling signerd's `shield` / `verified-reads` off actually
+# drops the heavy `railgun` / `helios` tree from core (no leaky default pulling them back in).
+deckard-core = { path = "../deckard-core", default-features = false }
+
+# Async UDS server + framing. multi-thread rt so the Argon2 unlock can run on the blocking
+# pool without starving the reactor.
+tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "sync", "time", "io-util"] }
+# CBOR on the wire (matches the contract crate's encoding); JSON for the policy file.
+serde = { workspace = true }
+ciborium = "0.2"
+serde_json = "1"
+
+# alloy for the broadcast path: HTTP provider + recommended fillers (nonce/gas/chain-id) +
+# wallet signing. The signer is reconstructed from the raw scalar into THIS alloy stack
+# (the keystore's signer is a different alloy-signer-local version; only the version-stable
+# B256 scalar crosses the boundary). Features mirror deckard-core (default features on, which
+# already bring the rustls TLS backend) so the workspace shares ONE alloy build + TLS stack —
+# no second TLS backend, no feature drift.
+alloy = { version = "1", features = ["provider-http", "network", "rpc-types", "signer-local", "eips"] }
+alloy-primitives = { workspace = true }
+
+# Peer-cred uid (geteuid) + the single-instance flock.
+nix = { version = "0.29", features = ["fs", "user"] }
+zeroize = "1"
+anyhow = "1"
+
+[dev-dependencies]
+# The black-box `shield_e2e` test drives deckard-core's key-less builder to get the Intent,
+# but to ASSERT the privacy property it talks to railgun directly (register an ephemeral 0zk
+# recipient, sync against the anvil fork, read the private balance). These mirror the proven
+# shield spike's edge (rev 618c53f, `testing` feature) so the [patch] set resolves identically.
+# Only the TEST gets railgun — never the daemon binary/lib.
+railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev = "618c53facd0d44cf0f01d74e0dcc18d2242351c7", features = ["testing"] }
+# Match Kohaku's alloy (1.8.3 / alloy-primitives 1.6.0) + the erased provider the spike uses
+# to fund the EOA and to drive railgun's RPC syncer.
+alloy = { version = "1.8", features = ["eips", "rpc-types", "network", "providers", "provider-http", "sol-types", "signer-local", "contract"] }
+rand_09 = { package = "rand", version = "0.9" }
+eyre = "0.6"
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync"] }
diff --git a/crates/deckard-signerd/src/auth.rs b/crates/deckard-signerd/src/auth.rs
new file mode 100644
index 0000000..ec21763
--- /dev/null
+++ b/crates/deckard-signerd/src/auth.rs
@@ -0,0 +1,46 @@
+//! Caller authentication: only a process with the **same uid** as the daemon may connect.
+//!
+//! We use tokio's built-in peer-cred (`SO_PEERCRED` on Linux, `getpeereid(2)` /
+//! `LOCAL_PEERCRED` on macOS) — verified to return the peer's effective uid on both — and
+//! compare it against our own *effective* uid. The decision itself is a pure function so it
+//! can be unit-tested without a live different-uid connection.
+
+use tokio::net::UnixStream;
+
+/// The peer's (effective) uid for a connected stream.
+pub fn peer_uid(stream: &UnixStream) -> std::io::Result {
+ Ok(stream.peer_cred()?.uid())
+}
+
+/// Our own effective uid (paired with `peer_cred`'s effective semantics).
+pub fn our_uid() -> u32 {
+ nix::unistd::geteuid().as_raw()
+}
+
+/// The whole authorization rule, pure and testable: a connection is allowed iff the peer
+/// runs as the same uid as the daemon.
+#[inline]
+pub fn same_uid(peer_uid: u32, our_uid: u32) -> bool {
+ peer_uid == our_uid
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rejects_a_different_uid() {
+ let ours = 501;
+ // A foreign uid is refused (the load-bearing check) ...
+ assert!(!same_uid(ours + 1, ours));
+ assert!(!same_uid(0, ours)); // even root, if it isn't us
+ // ... and the same uid is accepted.
+ assert!(same_uid(ours, ours));
+ }
+
+ #[test]
+ fn our_uid_is_stable() {
+ // geteuid is infallible and constant within a process.
+ assert_eq!(our_uid(), our_uid());
+ }
+}
diff --git a/crates/deckard-signerd/src/client.rs b/crates/deckard-signerd/src/client.rs
new file mode 100644
index 0000000..62cbc6e
--- /dev/null
+++ b/crates/deckard-signerd/src/client.rs
@@ -0,0 +1,146 @@
+//! The key-less client the GUI app (and, later, `deckard-mcp`) use to talk to the daemon.
+//!
+//! One request → one response over a fresh connection (the daemon serializes everything
+//! behind its state, so per-call connections are correct and simple at this call frequency).
+//! [`SignerClient::request`] is async; [`SignerClient::request_blocking`] wraps it in a
+//! short-lived current-thread runtime for callers without one (the app's background thread).
+
+use std::path::{Path, PathBuf};
+use std::time::{Duration, Instant};
+
+use tokio::net::UnixStream;
+
+use deckard_contract::{
+ Decision, ExecuteResult, Intent, RequestId, SignerRequest, SignerResponse, UnlockOutcome,
+};
+
+use crate::frame;
+use crate::request_id::request_id_for;
+
+/// How long to keep retrying `connect` before giving up — covers the brief window where the
+/// app has spawned the daemon but it hasn't bound the socket yet.
+const CONNECT_DEADLINE: Duration = Duration::from_secs(3);
+
+/// A handle to the daemon socket. Cheap to clone; holds only the path.
+#[derive(Clone, Debug)]
+pub struct SignerClient {
+ path: PathBuf,
+}
+
+impl SignerClient {
+ pub fn new(path: impl Into) -> Self {
+ Self { path: path.into() }
+ }
+
+ /// The socket path this client dials.
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ /// Send one request and read one response (connecting with a short retry so a just-spawned
+ /// daemon is given a moment to bind).
+ pub async fn request(&self, req: &SignerRequest) -> anyhow::Result {
+ let mut stream = self.connect().await?;
+ let body = frame::encode(req)?;
+ frame::write_frame(&mut stream, &body).await?;
+ let resp = frame::read_frame(&mut stream)
+ .await?
+ .ok_or_else(|| anyhow::anyhow!("daemon closed without responding"))?;
+ frame::decode(&resp)
+ }
+
+ /// Connect, retrying with capped backoff until [`CONNECT_DEADLINE`] — so the first call
+ /// right after the app spawns the daemon doesn't lose a startup race.
+ async fn connect(&self) -> anyhow::Result {
+ let deadline = Instant::now() + CONNECT_DEADLINE;
+ let mut delay = Duration::from_millis(25);
+ loop {
+ match UnixStream::connect(&self.path).await {
+ Ok(stream) => return Ok(stream),
+ Err(e) => {
+ if Instant::now() >= deadline {
+ return Err(anyhow::anyhow!("connect {}: {e}", self.path.display()));
+ }
+ tokio::time::sleep(delay).await;
+ delay = (delay * 2).min(Duration::from_millis(200));
+ }
+ }
+ }
+ }
+
+ /// Blocking convenience for callers without a tokio runtime (e.g. a GUI background
+ /// thread). Spins a short-lived current-thread runtime for the round-trip.
+ pub fn request_blocking(&self, req: &SignerRequest) -> anyhow::Result {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .map_err(|e| anyhow::anyhow!("build runtime: {e}"))?;
+ rt.block_on(self.request(req))
+ }
+
+ // --- typed helpers over `request` (used by the app + tests) ---------------------------
+
+ /// Unlock the vault over the socket (the app's lock screen sends this; the key never
+ /// enters the app's address space — only the returned address does).
+ pub async fn unlock(&self, passphrase: &str) -> anyhow::Result {
+ match self
+ .request(&SignerRequest::Unlock {
+ passphrase: passphrase.to_string(),
+ })
+ .await?
+ {
+ SignerResponse::Unlock(outcome) => Ok(outcome),
+ other => Err(unexpected("Unlock", other)),
+ }
+ }
+
+ /// Blocking [`unlock`](Self::unlock).
+ pub fn unlock_blocking(&self, passphrase: &str) -> anyhow::Result {
+ match self.request_blocking(&SignerRequest::Unlock {
+ passphrase: passphrase.to_string(),
+ })? {
+ SignerResponse::Unlock(outcome) => Ok(outcome),
+ other => Err(unexpected("Unlock", other)),
+ }
+ }
+
+ /// Lock the session (STOP-lite): zeroize the key, deny in-flight approvals.
+ pub fn lock_blocking(&self) -> anyhow::Result<()> {
+ match self.request_blocking(&SignerRequest::Lock)? {
+ SignerResponse::Ack => Ok(()),
+ other => Err(unexpected("Lock", other)),
+ }
+ }
+
+ /// Propose an intent → a `Decision`. Note: the returned `request_id` for an `Allow` is
+ /// derivable locally via [`request_id_for_intent`](Self::request_id_for_intent).
+ pub async fn propose(&self, intent: &Intent) -> anyhow::Result {
+ match self
+ .request(&SignerRequest::Propose {
+ intent: intent.clone(),
+ })
+ .await?
+ {
+ SignerResponse::Decision(d) => Ok(d),
+ other => Err(unexpected("Propose", other)),
+ }
+ }
+
+ /// Execute a previously-proposed request id → sign + broadcast (or denial).
+ pub async fn execute(&self, request_id: RequestId) -> anyhow::Result {
+ match self.request(&SignerRequest::Execute { request_id }).await? {
+ SignerResponse::Execute(r) => Ok(r),
+ other => Err(unexpected("Execute", other)),
+ }
+ }
+
+ /// The deterministic request id for an intent — lets a client `execute` an `Allow` it
+ /// derived locally (the daemon assigns the very same id).
+ pub fn request_id_for_intent(intent: &Intent) -> RequestId {
+ request_id_for(intent)
+ }
+}
+
+fn unexpected(req: &str, got: SignerResponse) -> anyhow::Error {
+ anyhow::anyhow!("daemon returned an unexpected response to {req}: {got:?}")
+}
diff --git a/crates/deckard-signerd/src/config.rs b/crates/deckard-signerd/src/config.rs
new file mode 100644
index 0000000..35e76cd
--- /dev/null
+++ b/crates/deckard-signerd/src/config.rs
@@ -0,0 +1,111 @@
+//! Daemon configuration, all environment-driven so CI/tests point at a local anvil and
+//! production points at Sepolia/mainnet by config.
+//!
+//! - `DECKARD_RPC_URL` — JSON-RPC endpoint to broadcast through (default: the public RPC).
+//! - `DECKARD_CHAIN_ID` — the chain the daemon signs for (default: 1 = mainnet). A
+//! `propose` whose `intent.chain_id` differs is denied `chain_mismatch`.
+//! - `DECKARD_CONFIG_DIR` — where `vault.bin` + `policy.json` live (default: the platform
+//! config dir, shared with the GUI app via `deckard_core::config`). Tests set this.
+//! - `DECKARD_SOCKET_PATH`— explicit UDS path (default: the per-uid runtime path). Tests +
+//! the app set this so both ends agree.
+
+use std::path::PathBuf;
+
+/// Resolved daemon configuration.
+#[derive(Clone, Debug)]
+pub struct Config {
+ pub rpc_url: String,
+ pub chain_id: u64,
+ pub config_dir: PathBuf,
+ pub socket_path: PathBuf,
+}
+
+impl Config {
+ /// Resolve the config from the environment, applying the documented defaults.
+ pub fn from_env() -> anyhow::Result {
+ let rpc_url = std::env::var("DECKARD_RPC_URL")
+ .unwrap_or_else(|_| deckard_core::DEFAULT_RPC.to_string());
+
+ let chain_id = match std::env::var("DECKARD_CHAIN_ID") {
+ Ok(s) => s
+ .trim()
+ .parse::()
+ .map_err(|_| anyhow::anyhow!("DECKARD_CHAIN_ID must be a u64, got {s:?}"))?,
+ Err(_) => 1,
+ };
+
+ let config_dir = match std::env::var_os("DECKARD_CONFIG_DIR") {
+ Some(d) => PathBuf::from(d),
+ None => deckard_core::config_dir()
+ .ok_or_else(|| anyhow::anyhow!("no platform config directory available"))?,
+ };
+
+ let socket_path = match std::env::var_os("DECKARD_SOCKET_PATH") {
+ Some(p) => PathBuf::from(p),
+ None => crate::socket::default_socket_path(),
+ };
+
+ Ok(Self {
+ rpc_url,
+ chain_id,
+ config_dir,
+ socket_path,
+ })
+ }
+
+ /// The encrypted keystore path the daemon reads on `Unlock`.
+ pub fn vault_path(&self) -> PathBuf {
+ self.config_dir.join(deckard_core::config::VAULT_FILE)
+ }
+
+ /// The signer policy path (a sane default is used if absent).
+ pub fn policy_path(&self) -> PathBuf {
+ self.config_dir.join(deckard_core::config::POLICY_FILE)
+ }
+
+ /// The RPC endpoint with any embedded credentials/host elided — safe to log.
+ pub fn redacted_rpc(&self) -> String {
+ redact_url(&self.rpc_url)
+ }
+}
+
+/// Reduce an RPC URL to `scheme://host[:port]` so an embedded API key (e.g. an Infura
+/// project secret in the path/query) never reaches a log line.
+fn redact_url(url: &str) -> String {
+ let (scheme, rest) = match url.split_once("://") {
+ Some(parts) => parts,
+ None => return "".to_string(),
+ };
+ let authority = rest
+ .split(['/', '?', '#'])
+ .next()
+ .unwrap_or("")
+ // strip any userinfo (user:pass@host)
+ .rsplit('@')
+ .next()
+ .unwrap_or("");
+ if authority.is_empty() {
+ "".to_string()
+ } else {
+ format!("{scheme}://{authority}")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::redact_url;
+
+ #[test]
+ fn redaction_drops_paths_and_userinfo() {
+ assert_eq!(
+ redact_url("https://mainnet.infura.io/v3/SECRETKEY"),
+ "https://mainnet.infura.io"
+ );
+ assert_eq!(redact_url("http://127.0.0.1:8545"), "http://127.0.0.1:8545");
+ assert_eq!(
+ redact_url("https://user:pass@rpc.example.com/path?token=abc"),
+ "https://rpc.example.com"
+ );
+ assert_eq!(redact_url("not-a-url"), "");
+ }
+}
diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs
new file mode 100644
index 0000000..75d44bc
--- /dev/null
+++ b/crates/deckard-signerd/src/daemon.rs
@@ -0,0 +1,633 @@
+//! The daemon state machine: `Locked` ⇄ `Unlocked { vault }`, the in-flight request table,
+//! and the handlers for every [`SignerRequest`]. The verdict for a `propose` comes from the
+//! ONE shared [`deckard_contract::evaluate`] — the daemon adds only the process-level
+//! pre-checks the policy can't express (`Locked`, `chain_mismatch`, unsupported kind).
+//!
+//! All requests are serialized behind a single [`Daemon`] (the server holds it in a
+//! `tokio::sync::Mutex`), so `propose`/`execute` can never race. `execute` holds that lock
+//! across the broadcast — acceptable for v1 (anvil is instant); a STOP arriving *during* an
+//! in-progress broadcast can't unsend a tx already on the wire, but the TOCTOU guard refuses
+//! any execute whose STOP landed first.
+
+use std::collections::HashMap;
+#[cfg(feature = "verified-reads")]
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use alloy_primitives::{Address, B256, U256};
+#[cfg(feature = "verified-reads")]
+use tokio::sync::Mutex as AsyncMutex;
+use zeroize::Zeroizing;
+
+use deckard_contract::{
+ evaluate, ApprovalStatus, BalanceReport, Decision, ExecuteResult, Intent, IntentKind, Policy,
+ ReadStatus, RequestId, SignerRequest, SignerResponse, UnlockOutcome,
+};
+use deckard_core::{UnlockedVault, Vault};
+
+use crate::config::Config;
+use crate::policy_store::{self, current_utc_day};
+use crate::request_id::request_id_for;
+use crate::signing;
+
+/// Default lifetime of a `NeedsApproval` before `status`/`execute` report `Expired`.
+/// Overridable via `DECKARD_APPROVAL_TTL_SECS` (used by tests to exercise expiry quickly).
+const APPROVAL_TTL: Duration = Duration::from_secs(120);
+
+/// Resolve the approval TTL: `DECKARD_APPROVAL_TTL_SECS` if set + parseable, else the default.
+fn approval_ttl() -> Duration {
+ std::env::var("DECKARD_APPROVAL_TTL_SECS")
+ .ok()
+ .and_then(|s| s.parse::().ok())
+ .map(Duration::from_secs)
+ .unwrap_or(APPROVAL_TTL)
+}
+
+/// `Locked` holds no key; `Unlocked` owns the decrypted vault (dropped — and zeroized — on
+/// lock/STOP) plus its cached primary address.
+enum VaultState {
+ Locked,
+ Unlocked {
+ vault: UnlockedVault,
+ address: Address,
+ },
+}
+
+/// One tracked proposal. `status` is the wire-visible approval state; `broadcast` is `Some`
+/// once `execute` has signed it (so a second `execute` is idempotently refused). `approved`
+/// is `true` only once a human `Resolve`d it — an *auto*-allow (within-cap) is re-checked
+/// against the caps at execute time, while a human-approved overage is not.
+struct PendingReq {
+ intent: Intent,
+ status: ApprovalStatus,
+ expires_at: Instant,
+ broadcast: Option,
+ approved: bool,
+}
+
+/// Upper bound on a single broadcast round-trip. A hung/blackholed RPC fails after this
+/// rather than wedging the daemon (and STOP) forever behind the held state lock.
+const BROADCAST_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// The daemon's embedded Helios verified-read path, kept in a **separately-locked** cell so
+/// the multi-second-to-90s `launch_verified` bootstrap can run WITHOUT holding the daemon's
+/// own `Mutex`. The server clones this `Arc` and primes it (see [`HeliosCell::ensure`]) off
+/// the daemon lock before dispatching a `Balance`, so a slow first read can never serialize
+/// behind it — the STOP/Lock brake stays responsive.
+///
+/// Its own `Drop` (via the inner `VerifiedReader`) tears the spawned localhost server down.
+/// `None` inside the option means "not built / failed to come up" — reads then fall back,
+/// tagged Unsynced, never silently Verified.
+///
+/// TODO(post-v1): v1 runs an INDEPENDENT Helios instance here (separate from the app's
+/// deckard-core::EthProvider one). The "consolidate all reads into the daemon" refactor is
+/// deferred.
+#[cfg(feature = "verified-reads")]
+#[derive(Clone, Default)]
+pub struct HeliosCell {
+ inner: Arc>>,
+}
+
+#[cfg(feature = "verified-reads")]
+impl HeliosCell {
+ fn new() -> Self {
+ Self::default()
+ }
+
+ /// Bootstrap the embedded Helios client if it isn't up yet. Runs the long
+ /// `launch_verified` while holding ONLY this cell's lock — never the daemon's — so the
+ /// security brake (STOP/Lock) and every other request stay live during the bootstrap.
+ /// Idempotent: a second caller that finds the client already built returns immediately.
+ /// On failure leaves the cell empty and logs (so the read falls back to a raw,
+ /// Unsynced-tagged read — never a silent Verified).
+ pub async fn ensure(
+ &self,
+ consensus_rpc: &str,
+ execution_rpc: &str,
+ data_dir: std::path::PathBuf,
+ ) {
+ let mut guard = self.inner.lock().await;
+ if guard.is_some() {
+ return;
+ }
+ match deckard_core::launch_verified(consensus_rpc, execution_rpc, data_dir).await {
+ Ok(reader) => *guard = Some(reader),
+ Err(e) => {
+ // Stays None → the read path falls back to a raw, Unsynced read.
+ eprintln!("signerd: helios bootstrap failed (reads tagged unsynced): {}", one_line(&e));
+ }
+ }
+ }
+}
+
+/// The whole daemon: config, the lock state, the live policy (with in-memory daily spend),
+/// and the request table.
+pub struct Daemon {
+ cfg: Config,
+ state: VaultState,
+ policy: Policy,
+ /// UTC day of the current `spent_today_wei` window (for the midnight rollover).
+ spent_day: u64,
+ /// Lifetime of a `NeedsApproval` record.
+ approval_ttl: Duration,
+ requests: HashMap,
+ /// The daemon's embedded Helios verified-read path, held in a SEPARATELY-locked
+ /// [`HeliosCell`] so its slow bootstrap never blocks the daemon mutex (see the cell's
+ /// docs). The server primes it off the daemon lock before a `Balance` dispatch; the
+ /// `balance` handler then borrows the already-built reader for the quick read. Cloning
+ /// the `Arc` is cheap and lets the server hold a handle without the daemon lock.
+ #[cfg(feature = "verified-reads")]
+ helios: HeliosCell,
+}
+
+impl Daemon {
+ /// Build a `Locked` daemon, loading the policy (or its safe default) up front.
+ pub fn new(cfg: Config) -> Self {
+ let policy = policy_store::load_policy(&cfg.policy_path());
+ Self {
+ cfg,
+ state: VaultState::Locked,
+ policy,
+ spent_day: current_utc_day(),
+ approval_ttl: approval_ttl(),
+ requests: HashMap::new(),
+ #[cfg(feature = "verified-reads")]
+ helios: HeliosCell::new(),
+ }
+ }
+
+ /// A clone of the daemon's [`HeliosCell`] handle, so the server can prime the Helios
+ /// bootstrap OFF the daemon lock before dispatching a `Balance` (keeping the STOP/Lock
+ /// brake responsive — the long bootstrap never holds the daemon mutex).
+ #[cfg(feature = "verified-reads")]
+ pub fn helios_cell(&self) -> HeliosCell {
+ self.helios.clone()
+ }
+
+ /// The (consensus_rpc, execution_rpc, data_dir) the embedded Helios client bootstraps
+ /// with. Exposed so the server can prime the cell off the daemon lock.
+ #[cfg(feature = "verified-reads")]
+ pub fn helios_bootstrap_args(&self) -> (&'static str, String, std::path::PathBuf) {
+ (
+ deckard_core::DEFAULT_CONSENSUS_RPC,
+ self.cfg.rpc_url.clone(),
+ self.cfg.config_dir.join("helios-signerd"),
+ )
+ }
+
+ /// Dispatch one request to one response. `async` because `execute`/`balance` do network
+ /// I/O and `unlock` runs Argon2 on the blocking pool.
+ pub async fn handle(&mut self, req: SignerRequest) -> SignerResponse {
+ match req {
+ SignerRequest::Unlock { passphrase } => {
+ SignerResponse::Unlock(self.unlock(passphrase).await)
+ }
+ // Lock and RevokeAll are the same act in v1: zeroize the key → Locked, deny
+ // everything in flight. Only a fresh Unlock re-arms.
+ SignerRequest::Lock | SignerRequest::RevokeAll => {
+ self.lock();
+ SignerResponse::Ack
+ }
+ SignerRequest::Resolve {
+ request_id,
+ approved,
+ } => {
+ self.resolve(request_id, approved);
+ SignerResponse::Ack
+ }
+ SignerRequest::Propose { intent } => SignerResponse::Decision(self.propose(&intent)),
+ SignerRequest::Execute { request_id } => {
+ SignerResponse::Execute(self.execute(request_id).await)
+ }
+ SignerRequest::Status { request_id } => SignerResponse::Status(self.status(request_id)),
+ SignerRequest::PolicyGet => {
+ self.rollover();
+ SignerResponse::Policy(self.policy.clone())
+ }
+ SignerRequest::Address => match &self.state {
+ VaultState::Unlocked { address, .. } => SignerResponse::Address(*address),
+ // No Address-specific error variant exists; signal locked Deny-style.
+ VaultState::Locked => SignerResponse::Decision(Decision::Deny {
+ reason: "locked".into(),
+ }),
+ },
+ SignerRequest::Balance { shielded } => {
+ SignerResponse::Balance(self.balance(shielded).await)
+ }
+ }
+ }
+
+ /// Read the keystore, decrypt under `passphrase`, and hold the key. The raw passphrase is
+ /// moved into `Zeroizing` immediately and never echoed or logged.
+ async fn unlock(&mut self, passphrase: String) -> UnlockOutcome {
+ let pass = Zeroizing::new(passphrase);
+ let vault_path = self.cfg.vault_path();
+ if !vault_path.exists() {
+ return UnlockOutcome::NoVault;
+ }
+ // Argon2id is CPU-heavy: read + unlock on the blocking pool so the reactor stays free.
+ let pass_for_blocking = pass.clone();
+ let result = tokio::task::spawn_blocking(move || {
+ let vault = Vault::read(&vault_path)?;
+ vault.unlock(pass_for_blocking.as_str())
+ })
+ .await;
+
+ match result {
+ Ok(Ok(unlocked)) => match unlocked.primary_address() {
+ Ok(address) => {
+ self.state = VaultState::Unlocked {
+ vault: unlocked,
+ address,
+ };
+ self.policy.revoked = false; // a fresh unlock re-arms
+ self.requests.clear(); // fresh session: no stale approvals survive a re-unlock
+ UnlockOutcome::Unlocked { address }
+ }
+ // A successfully decrypted vault that can't derive an address is corrupt;
+ // treat as a failed unlock rather than holding an unusable key.
+ Err(_) => UnlockOutcome::BadPassphrase,
+ },
+ // Wrong passphrase, a tampered vault, or a read error: one generic outcome, no
+ // oracle, no key held.
+ Ok(Err(_)) | Err(_) => UnlockOutcome::BadPassphrase,
+ }
+ }
+
+ /// Zeroize + drop the key → `Locked`, deny EVERY non-broadcast approval (both `Pending`
+ /// and already-`Allowed`, so an approval granted before STOP can never execute — even via
+ /// `status` polling), and trip the policy brake (so `PolicyGet` honestly reports
+ /// `revoked`). Shared by `Lock` and `RevokeAll`.
+ fn lock(&mut self) {
+ self.state = VaultState::Locked; // dropping UnlockedVault zeroizes the secret
+ self.policy.revoked = true;
+ for req in self.requests.values_mut() {
+ if req.broadcast.is_none()
+ && matches!(
+ req.status,
+ ApprovalStatus::Pending | ApprovalStatus::Allowed
+ )
+ {
+ req.status = ApprovalStatus::Denied {
+ reason: "revoked".into(),
+ };
+ }
+ }
+ }
+
+ /// Close an approval loop: a human (or a test) flips a `Pending` record to
+ /// `Allowed`/`Denied`. No-op for any other state (already resolved/expired).
+ fn resolve(&mut self, request_id: RequestId, approved: bool) {
+ self.expire_stale();
+ if let Some(req) = self.requests.get_mut(&request_id) {
+ if req.status == ApprovalStatus::Pending {
+ if approved {
+ req.status = ApprovalStatus::Allowed;
+ req.approved = true; // explicit human consent: not re-capped at execute
+ } else {
+ req.status = ApprovalStatus::Denied {
+ reason: "user_denied".into(),
+ };
+ }
+ }
+ }
+ }
+
+ /// Policy check only — NEVER signs. Process-level pre-checks first, then the shared
+ /// `evaluate`. On `NeedsApproval`/`Allow` a pending record is stored under the intent's
+ /// deterministic id; on `Deny` nothing is stored.
+ fn propose(&mut self, intent: &Intent) -> Decision {
+ self.rollover();
+ self.expire_stale();
+
+ // Pre-checks the Policy can't express (the mock has none of these states, which is
+ // why feeding both the same (Intent, Policy) yields identical decisions — the parity
+ // contract). These run before `evaluate`.
+ if matches!(self.state, VaultState::Locked) {
+ return Decision::Deny {
+ reason: "locked".into(),
+ };
+ }
+ if intent.chain_id != self.cfg.chain_id {
+ return Decision::Deny {
+ reason: "chain_mismatch".into(),
+ };
+ }
+ // v1 admits a native Send and a Shield (the privacy hero). The Shield's RelayAdapt
+ // calldata is built key-less in deckard-core and rides in `intent.calldata`; the
+ // daemon never sees the ZK crate, it only signs+broadcasts the handed bytes. Unshield
+ // / ContractCall stay a fast-follow.
+ if !matches!(intent.kind, IntentKind::Send | IntentKind::Shield) {
+ return Decision::Deny {
+ reason: "unsupported_v1".into(),
+ };
+ }
+ // v1 spine is native ETH only; an ERC-20 (`token = Some`) Send is a fast-follow.
+ // A native shield is `token: None` (the value rides as msg.value via RelayAdapt
+ // wrapBase), so it passes this guard.
+ if intent.token.is_some() {
+ return Decision::Deny {
+ reason: "erc20_unsupported_v1".into(),
+ };
+ }
+
+ let id = request_id_for(intent);
+
+ // Idempotent re-propose: an identical intent maps to the same id, so an existing record
+ // is returned AS-IS — a re-propose can't reset a `Pending` card's TTL, downgrade a
+ // human approval, or re-raise a `Denied`/`Expired` request. Retrying a terminal intent
+ // needs a fresh session (`Unlock` clears the table).
+ if let Some(existing) = self.requests.get(&id) {
+ return match &existing.status {
+ _ if existing.broadcast.is_some() => Decision::Deny {
+ reason: "already_executed".into(),
+ },
+ ApprovalStatus::Pending => Decision::NeedsApproval { request_id: id },
+ ApprovalStatus::Allowed => Decision::Allow,
+ ApprovalStatus::Denied { reason } => Decision::Deny {
+ reason: reason.clone(),
+ },
+ ApprovalStatus::Expired => Decision::Deny {
+ reason: "expired".into(),
+ },
+ };
+ }
+
+ // No record yet: the ONE shared decision function decides.
+ let status = match evaluate(intent, &self.policy) {
+ deny @ Decision::Deny { .. } => return deny,
+ Decision::Allow => ApprovalStatus::Allowed,
+ Decision::NeedsApproval { .. } => ApprovalStatus::Pending,
+ };
+ self.requests.insert(
+ id,
+ PendingReq {
+ intent: intent.clone(),
+ status: status.clone(),
+ expires_at: Instant::now() + self.approval_ttl,
+ broadcast: None,
+ approved: false,
+ },
+ );
+
+ match status {
+ ApprovalStatus::Allowed => Decision::Allow,
+ _ => Decision::NeedsApproval { request_id: id },
+ }
+ }
+
+ /// Sign + broadcast, only for an `Allowed` request that survives the sign-time re-check.
+ async fn execute(&mut self, request_id: RequestId) -> ExecuteResult {
+ self.rollover();
+ self.expire_stale();
+
+ // Phase 1 (lock held): TOCTOU re-check + eligibility, then extract tx params and the
+ // raw scalar (transiently, into `Zeroizing`). Borrows end before the await.
+ let (to, value, calldata, scalar) = {
+ let vault = match &self.state {
+ // STOP landed first — refuse even a previously-approved request.
+ VaultState::Locked => {
+ return ExecuteResult::Denied {
+ reason: "revoked".into(),
+ }
+ }
+ VaultState::Unlocked { vault, .. } => vault,
+ };
+ let req = match self.requests.get(&request_id) {
+ None => {
+ return ExecuteResult::Denied {
+ reason: "unknown_request".into(),
+ }
+ }
+ Some(req) => req,
+ };
+ if req.broadcast.is_some() {
+ return ExecuteResult::Denied {
+ reason: "already_executed".into(),
+ };
+ }
+ match &req.status {
+ // The only state that signs (covers within-cap Allow + human-approved over-cap).
+ ApprovalStatus::Allowed => {}
+ ApprovalStatus::Pending => {
+ return ExecuteResult::Denied {
+ reason: "not_approved".into(),
+ }
+ }
+ ApprovalStatus::Denied { reason } => {
+ return ExecuteResult::Denied {
+ reason: reason.clone(),
+ }
+ }
+ ApprovalStatus::Expired => {
+ return ExecuteResult::Denied {
+ reason: "expired".into(),
+ }
+ }
+ }
+ // Spend TOCTOU: an *auto*-allow must still be within policy at sign time, so two
+ // within-cap proposals can't both execute past the daily cap (`spent_today` only
+ // grows on prior executes). A human-APPROVED request carries explicit consent for
+ // its overage and is not re-capped.
+ if !req.approved && evaluate(&req.intent, &self.policy) != Decision::Allow {
+ return ExecuteResult::Denied {
+ reason: "cap_exceeded".into(),
+ };
+ }
+ let signer = match vault.account_signer(0) {
+ Ok(s) => s,
+ Err(e) => {
+ return ExecuteResult::Denied {
+ reason: format!("signer_error: {e}"),
+ }
+ }
+ };
+ // Only the version-stable raw scalar crosses into our alloy stack; zeroized on drop.
+ let scalar = Zeroizing::new(signer.to_bytes().0);
+ // Calldata is empty for a native Send (→ broadcast is byte-identical to before) and
+ // carries the RelayAdapt call for a Shield. The empty-vs-non-empty input IS the
+ // native/contract-call discriminator, so no IntentKind branch is needed here.
+ (
+ req.intent.to,
+ req.intent.value,
+ req.intent.calldata.clone(),
+ scalar,
+ )
+ };
+
+ // Phase 2: sign + broadcast (lock held — serialized; acceptable for v1). A bounded
+ // timeout keeps a hung RPC from wedging the daemon (and STOP) behind the held lock.
+ let broadcast = signing::broadcast_intent(
+ scalar.as_slice(),
+ &self.cfg.rpc_url,
+ self.cfg.chain_id,
+ to,
+ value,
+ &calldata,
+ );
+ let tx_hash = match tokio::time::timeout(BROADCAST_TIMEOUT, broadcast).await {
+ Ok(Ok(hash)) => hash,
+ Ok(Err(e)) => {
+ return ExecuteResult::Denied {
+ reason: format!("broadcast_failed: {}", one_line(&e)),
+ }
+ }
+ Err(_elapsed) => {
+ return ExecuteResult::Denied {
+ reason: "broadcast_timeout".into(),
+ }
+ }
+ };
+
+ // Phase 3: record the broadcast + bump the daily spend.
+ if let Some(req) = self.requests.get_mut(&request_id) {
+ req.broadcast = Some(tx_hash);
+ }
+ self.policy.spent_today_wei = self.policy.spent_today_wei.saturating_add(value);
+ ExecuteResult::Broadcast { tx_hash }
+ }
+
+ /// Poll an approval handle. Unknown ids report `Denied{unknown_request}` (matching the
+ /// mock); a `Pending` past its TTL reports `Expired`.
+ fn status(&mut self, request_id: RequestId) -> ApprovalStatus {
+ self.expire_stale();
+ match self.requests.get(&request_id) {
+ Some(req) => req.status.clone(),
+ None => ApprovalStatus::Denied {
+ reason: "unknown_request".into(),
+ },
+ }
+ }
+
+ /// Public balance, key-less. `shielded_wei` is 0 until T-Privacy.
+ ///
+ /// With `verified-reads` on (the default), the read goes through the daemon's own
+ /// embedded Helios light client (built lazily here) and is tagged
+ /// [`ReadStatus::Verified`] only when a fresh Helios head backs it. If Helios isn't
+ /// up / the head is stale / the read fails, the value is tagged `Unsynced` — we
+ /// **stop the old silent `.unwrap_or(ZERO)`-as-truth**: a 0 is no longer reported as
+ /// a trusted balance. With the feature off, the read goes through the raw RPC and is
+ /// always tagged `Unsynced("verification disabled")` — never `Verified`.
+ ///
+ /// A locked daemon doesn't know which address to read, so it reports zeros tagged
+ /// `Unsynced("locked")` — honest non-verification, not a trusted zero.
+ async fn balance(&mut self, _shielded: bool) -> BalanceReport {
+ self.rollover();
+ let addr = match &self.state {
+ VaultState::Unlocked { address, .. } => *address,
+ VaultState::Locked => {
+ return BalanceReport {
+ public_wei: U256::ZERO,
+ shielded_wei: U256::ZERO,
+ read_status: ReadStatus::unsynced("locked"),
+ }
+ }
+ };
+
+ let (public_wei, read_status) = self.read_public_balance(addr).await;
+ BalanceReport {
+ public_wei,
+ shielded_wei: U256::ZERO,
+ read_status,
+ }
+ }
+
+ /// Resolve the read endpoint + trust label, then read the native balance. Verified
+ /// path: reuse the embedded Helios client (already primed off the daemon lock by the
+ /// server — see [`HeliosCell`]), read through its localhost server, and label by head
+ /// freshness. Feature-off / Helios-down: read the raw RPC, label `Unsynced`. NEVER
+ /// returns `Verified` without a fresh Helios-verified read.
+ ///
+ /// The configured rpc_url is the EXECUTION-layer endpoint Helios proves against (must
+ /// serve eth_getProof); Nimbus drives the CL sync (deckard-core's default).
+ #[cfg(feature = "verified-reads")]
+ async fn read_public_balance(&mut self, addr: Address) -> (U256, ReadStatus) {
+ // The server primes the cell off-lock before dispatch, so this is normally a fast
+ // already-built borrow. `ensure` here is a defensive no-op fallback for callers
+ // (e.g. unit tests) that drive `handle` directly without the server priming first.
+ let (cl, el, data_dir) = self.helios_bootstrap_args();
+ self.helios.ensure(cl, &el, data_dir).await;
+
+ let guard = self.helios.inner.lock().await;
+ let reader = match guard.as_ref() {
+ Some(reader) => reader,
+ None => {
+ // Helios isn't up. Read the raw RPC so a value can be shown, but tag it
+ // Unsynced — we do NOT claim a raw read is Verified.
+ drop(guard);
+ let wei = signing::read_balance(&self.cfg.rpc_url, addr)
+ .await
+ .unwrap_or(U256::ZERO);
+ return (wei, ReadStatus::unsynced("helios unavailable"));
+ }
+ };
+ // Read the value FIRST, then derive its freshness label, so a `Verified` tag is
+ // bound to a head observed *after* the value came back (consistent with the
+ // app-side path in deckard-core::eth). A small TOCTOU window remains between the
+ // two round-trips, but it always fails toward "fresh head backed the value".
+ let read_url = reader.localhost_url().to_string();
+ match signing::read_balance(&read_url, addr).await {
+ Ok(wei) => {
+ // head_status() re-probes Helios freshness; a head gone stale → Unsynced.
+ let status = reader.head_status().await;
+ (wei, status)
+ }
+ Err(e) => (
+ U256::ZERO,
+ ReadStatus::unsynced(format!("verified read failed: {}", one_line(&e))),
+ ),
+ }
+ }
+
+ /// Feature-off path: read the raw RPC directly, always tagged Unsynced — never claim
+ /// a raw read is Verified.
+ #[cfg(not(feature = "verified-reads"))]
+ async fn read_public_balance(&mut self, addr: Address) -> (U256, ReadStatus) {
+ match signing::read_balance(&self.cfg.rpc_url, addr).await {
+ Ok(wei) => (wei, ReadStatus::unsynced("verification disabled")),
+ Err(e) => (
+ U256::ZERO,
+ ReadStatus::unsynced(format!("read failed: {}", one_line(&e))),
+ ),
+ }
+ }
+
+ /// Expire any non-broadcast request past its TTL — both `Pending` (the card was never
+ /// answered) and `Allowed` (an approval/auto-allow that went stale). So a stale id can
+ /// never be executed later, matching the frozen `ApprovalStatus::Expired` guarantee.
+ fn expire_stale(&mut self) {
+ let now = Instant::now();
+ for req in self.requests.values_mut() {
+ if req.broadcast.is_none()
+ && matches!(
+ req.status,
+ ApprovalStatus::Pending | ApprovalStatus::Allowed
+ )
+ && now >= req.expires_at
+ {
+ req.status = ApprovalStatus::Expired;
+ }
+ }
+ }
+
+ /// Reset the daily spend window when the UTC day ticks over.
+ fn rollover(&mut self) {
+ let today = current_utc_day();
+ if today != self.spent_day {
+ self.spent_day = today;
+ self.policy.spent_today_wei = U256::ZERO;
+ }
+ }
+}
+
+/// Collapse a multi-line error into one short line for a `reason` string (never includes a
+/// secret — broadcast/signing errors carry only addresses/amounts/RPC text).
+fn one_line(e: &anyhow::Error) -> String {
+ e.to_string()
+ .lines()
+ .next()
+ .unwrap_or("")
+ .chars()
+ .take(160)
+ .collect()
+}
diff --git a/crates/deckard-signerd/src/frame.rs b/crates/deckard-signerd/src/frame.rs
new file mode 100644
index 0000000..e7c17b8
--- /dev/null
+++ b/crates/deckard-signerd/src/frame.rs
@@ -0,0 +1,107 @@
+//! Length-delimited CBOR framing for the UDS wire: a **4-byte big-endian length prefix**
+//! followed by the CBOR body, one request/response per frame. Frames over [`MAX_FRAME`]
+//! (1 MiB) are rejected — a hostile or buggy client can't make the daemon allocate
+//! unbounded memory.
+
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+/// Hard cap on a single frame body. The 4-byte prefix can express up to 4 GiB; we refuse
+/// anything past 1 MiB (the largest legitimate frame — a big `calldata` — is far smaller).
+pub const MAX_FRAME: usize = 1024 * 1024;
+
+/// Read one frame. Returns `Ok(None)` on a clean EOF (peer closed between frames) so the
+/// connection loop can exit quietly; any other short read is an error.
+pub async fn read_frame(r: &mut R) -> anyhow::Result