From 789188366ec59b97eea439d5613d7f1044acaf43 Mon Sep 17 00:00:00 2001 From: hellno Date: Sun, 21 Jun 2026 10:48:22 +0200 Subject: [PATCH 1/3] deckard-core: extract atomic_write free fn + SPEND_FILE const Pull the temp+fsync+rename+dir-sync recipe out of Vault::write_atomic into a free fn deckard_core::atomic_write(path, &[u8]) (Vault::write_atomic now delegates), so the signer daemon's durable spend counter (#108) reuses the identical durability recipe instead of re-deriving it. Add a SPEND_FILE ('spend.json') const + spend_path() next to the vault/policy ones. Prerequisite for #108. --- crates/deckard-core/src/config.rs | 9 ++++ crates/deckard-core/src/keystore.rs | 69 +++++++++++++++++------------ crates/deckard-core/src/lib.rs | 4 +- 3 files changed, 53 insertions(+), 29 deletions(-) diff --git a/crates/deckard-core/src/config.rs b/crates/deckard-core/src/config.rs index 6c46778..b6711af 100644 --- a/crates/deckard-core/src/config.rs +++ b/crates/deckard-core/src/config.rs @@ -14,6 +14,10 @@ use directories::ProjectDirs; pub const VAULT_FILE: &str = "vault.bin"; /// The signer policy filename inside [`config_dir`]. pub const POLICY_FILE: &str = "policy.json"; +/// The durable daily-spend counter filename inside [`config_dir`] (issue #108). Single-writer: +/// only the signer daemon writes it; it survives restart so the daily cap isn't zeroed on every +/// crash/OOM/update. +pub const SPEND_FILE: &str = "spend.json"; /// The config dir every Deckard process resolves through, so the GUI app, onboarding, the /// signer daemon, and the demo all agree on where `vault.bin` / `policy.json` / `settings.json` @@ -60,6 +64,11 @@ pub fn policy_path() -> Option { Some(config_dir()?.join(POLICY_FILE)) } +/// The durable daily-spend counter path (`/spend.json`). +pub fn spend_path() -> Option { + Some(config_dir()?.join(SPEND_FILE)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/deckard-core/src/keystore.rs b/crates/deckard-core/src/keystore.rs index 0de1f0a..8ea88b2 100644 --- a/crates/deckard-core/src/keystore.rs +++ b/crates/deckard-core/src/keystore.rs @@ -162,6 +162,43 @@ impl Header { } } +/// Atomically write `bytes` to `path` with `0600` perms: write a temp file, fsync it, rename +/// over the target, then fsync the parent dir so the rename itself survives a crash/power loss. +/// Never leaves a partially written file — a reader sees the old file or the whole new one. +/// +/// Extracted from [`Vault::write_atomic`] so any durable single-writer state (the signer's daily +/// spend counter, `deckard-signerd`) reuses the exact same recipe instead of re-deriving it. The +/// temp name is `.tmp`; two writers that share a directory must use distinct file stems +/// (e.g. `vault.bin` → `vault.tmp`, `spend.json` → `spend.tmp`) so their temps never collide. +pub fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { + use std::io::Write; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("tmp"); + { + // Open the temp file already at 0600 — no window where it exists world-readable. + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(&tmp)?; + f.write_all(bytes)?; + f.sync_all()?; + } + std::fs::rename(&tmp, path)?; + // fsync the directory so the rename itself is durable across a crash/power loss. + if let Some(dir) = path.parent() { + if let Ok(dirf) = std::fs::File::open(dir) { + let _ = dirf.sync_all(); + } + } + Ok(()) +} + /// The on-disk vault: header + the two ciphertexts. Carries no plaintext secret. #[derive(Clone)] #[must_use] @@ -373,35 +410,11 @@ impl Vault { }) } - /// Atomically write the vault to `path` with `0600` perms: write a temp file, fsync, - /// rename over the target. Never leaves a partially written vault. + /// Atomically write the vault to `path` with `0600` perms (temp file → fsync → rename → + /// dir-fsync). Never leaves a partially written vault. Delegates to the free [`atomic_write`] + /// so the daemon's spend counter shares the identical durability recipe. pub fn write_atomic(&self, path: &Path) -> anyhow::Result<()> { - use std::io::Write; - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; - } - let tmp = path.with_extension("tmp"); - { - // Open the temp file already at 0600 — no window where it exists world-readable. - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create(true).truncate(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); - } - let mut f = opts.open(&tmp)?; - f.write_all(&self.to_bytes())?; - f.sync_all()?; - } - std::fs::rename(&tmp, path)?; - // fsync the directory so the rename itself is durable across a crash/power loss. - if let Some(dir) = path.parent() { - if let Ok(dirf) = std::fs::File::open(dir) { - let _ = dirf.sync_all(); - } - } - Ok(()) + atomic_write(path, &self.to_bytes()) } /// Read and parse a vault from `path`, refusing an implausibly large file before diff --git a/crates/deckard-core/src/lib.rs b/crates/deckard-core/src/lib.rs index ee4ecf2..1b8a8e5 100644 --- a/crates/deckard-core/src/lib.rs +++ b/crates/deckard-core/src/lib.rs @@ -85,7 +85,9 @@ pub use env::{demo_fork_block, screen_capture_allowed, verified_reads_enabled}; pub use eth::{EthProvider, Read, DEFAULT_RPC}; #[cfg(feature = "verified-reads")] pub use helios::{launch_verified, VerifiedReader, DEFAULT_CONSENSUS_RPC}; -pub use keystore::{random_word_positions, KdfParams, SecretKind, UnlockedVault, Vault, WordCount}; +pub use keystore::{ + atomic_write, 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")] From 6b66f2ed06b44c391809304cc7eab2eea4c42ecf Mon Sep 17 00:00:00 2001 From: hellno Date: Sun, 21 Jun 2026 10:48:36 +0200 Subject: [PATCH 2/3] =?UTF-8?q?signerd:=20durable=20daily-cap=20=E2=80=94?= =?UTF-8?q?=20reserve-before-sign=20+=20forward-only=20rollover=20+=20cras?= =?UTF-8?q?h-budget=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily spend cap (spent_today_wei) was in-memory and force-zeroed on every load, so any restart (crash, OOM, app update, or a same-uid attacker crash-looping the auto-respawning daemon) silently reset the day's accounting and re-opened the within-cap drain. - New spend_store.rs: durable spend.json {chain_id, account, day, committed, reserved}, atomic-written; recovered into the policy on boot. - Reserve-before-sign in execute(): reserve durably before the signature is released (skip value==0; reserve-write failure denies fail-closed via a new reserve_failed deny tag). Success commits; a clean RPC rejection releases; a TIMEOUT keeps it counted and marks the request terminal (status UNKNOWN may have landed — no retry/double-spend). A crash between reserve and commit is counted as spent on reboot (conservative — recovery only tightens the cap). - Forward-only rollover: a backward wall-clock can no longer reset the window. - Account bound at unlock (re-key starts a fresh window); missing file -> fresh, corrupt -> fully-spent + loud until unlock/rollover. - Supervisor crash-budget (Option D): N crashes / M min with no sustained healthy run -> stop respawning + surface, instead of hot-looping at the backoff floor. Scope held per /autoplan review: no nonce-keyed RPC reconciliation (deferred, post-#72); the security value sequences with #72 (the cap values still live in an unauthenticated policy.json). Tests: 12 spend_store units, 2 crash-budget units, 2 anvil e2e (honest-restart recovery + cap-survives-restart). Closes #108. --- crates/deckard-contract/src/deny_reasons.rs | 4 + .../deckard-contract/tests/deny_vocabulary.rs | 5 +- crates/deckard-signerd/src/config.rs | 6 + crates/deckard-signerd/src/daemon.rs | 100 +++- crates/deckard-signerd/src/lib.rs | 1 + crates/deckard-signerd/src/spend_store.rs | 479 ++++++++++++++++++ crates/deckard-signerd/src/supervise.rs | 108 +++- .../deckard-signerd/tests/durable_cap_e2e.rs | 170 +++++++ docs/build/31-agent-quickstart.md | 1 + 9 files changed, 856 insertions(+), 18 deletions(-) create mode 100644 crates/deckard-signerd/src/spend_store.rs create mode 100644 crates/deckard-signerd/tests/durable_cap_e2e.rs diff --git a/crates/deckard-contract/src/deny_reasons.rs b/crates/deckard-contract/src/deny_reasons.rs index a2c8d31..d99acce 100644 --- a/crates/deckard-contract/src/deny_reasons.rs +++ b/crates/deckard-contract/src/deny_reasons.rs @@ -101,6 +101,10 @@ pub const SHIELD_UNAVAILABLE: &str = "shield_unavailable"; /// self-approve. The pending record is left untouched (`Pending`); the public caller is /// refused with this typed denial. pub const RESOLVE_NOT_AUTHORIZED: &str = "resolve_not_authorized"; +/// The durable daily-spend reservation could not be persisted before signing (issue #108): +/// the disk write/fsync failed, so the daemon refuses to sign rather than move funds it cannot +/// durably account against the cap. Fail-closed; the cause is logged, never put on the wire. +pub const RESERVE_FAILED: &str = "reserve_failed"; // ───────────────────────── Swap v1 (CoW) ───────────────────────── // Shaped-approve admission + order sign/cancel guards in the daemon, and the swap mock. diff --git a/crates/deckard-contract/tests/deny_vocabulary.rs b/crates/deckard-contract/tests/deny_vocabulary.rs index c9d3127..20262a1 100644 --- a/crates/deckard-contract/tests/deny_vocabulary.rs +++ b/crates/deckard-contract/tests/deny_vocabulary.rs @@ -57,7 +57,7 @@ const PREFIX_BUILDERS: &[&str] = &[ "broadcast_failed", ]; -/// The complete frozen vocabulary: 31 static tags + 4 dynamic-prefix tags. Editing this list +/// The complete frozen vocabulary: 32 static tags + 4 dynamic-prefix tags. Editing this list /// is the deliberate gate — change it here, in `deny_reasons.rs`, AND (for a real, non-test /// tag) in `docs/build/31-agent-quickstart.md`. `swap_unsupported_in_mock` is test-surface /// only and is intentionally absent from the docs table. @@ -89,6 +89,7 @@ const FROZEN: &[&str] = &[ r::DERIVATION_UNVERIFIED, r::SHIELD_UNAVAILABLE, r::RESOLVE_NOT_AUTHORIZED, + r::RESERVE_FAILED, // swap v1 r::APPROVE_WITH_VALUE, r::APPROVE_WRONG_SPENDER, @@ -429,7 +430,7 @@ fn frozen_set_matches_module_exports() { fn frozen_set_is_exactly_documented() { assert_eq!( FROZEN.len(), - 35, + 36, "added/removed a Deny tag? update FROZEN, deny_reasons.rs, and the docs table" ); diff --git a/crates/deckard-signerd/src/config.rs b/crates/deckard-signerd/src/config.rs index 2e6f563..081a2e6 100644 --- a/crates/deckard-signerd/src/config.rs +++ b/crates/deckard-signerd/src/config.rs @@ -97,6 +97,12 @@ impl Config { self.config_dir.join(deckard_core::config::POLICY_FILE) } + /// The durable daily-spend counter path (issue #108). Missing on first run; the daemon + /// recovers prior spend from it on boot so a restart can't silently zero the daily cap. + pub fn spend_path(&self) -> PathBuf { + self.config_dir.join(deckard_core::config::SPEND_FILE) + } + /// The RPC endpoint with any embedded credentials/host elided — safe to log. pub fn redacted_rpc(&self) -> String { redact_url(&self.rpc_url) diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index 54b9556..76d2e73 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -35,6 +35,7 @@ use crate::config::Config; use crate::policy_store::{self, current_utc_day}; use crate::request_id::{request_id_for, request_id_for_order}; use crate::signing; +use crate::spend_store::SpendStore; /// Default lifetime of a `NeedsApproval` before `status`/`execute` report `Expired`. /// Overridable via `DECKARD_APPROVAL_TTL_SECS` (used by tests to exercise expiry quickly). @@ -235,8 +236,10 @@ 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, + /// Durable daily-spend accounting (issue #108): persists the cap across restart and reserves a + /// spend before signing. Owns the forward-only UTC-day window; `policy.spent_today_wei` is a + /// clamped mirror of `spend.effective_spent()` kept in sync after every mutation. + spend: SpendStore, /// Lifetime of a `NeedsApproval` record. approval_ttl: Duration, requests: HashMap, @@ -254,14 +257,24 @@ pub struct Daemon { } impl Daemon { - /// Build a `Locked` daemon, loading the policy (or its safe default) up front. + /// Build a `Locked` daemon, loading the policy (or its safe default) and the durable spend + /// counter (issue #108) up front. The counter is recovered into `policy.spent_today_wei` so a + /// restart can't silently zero the day's accounting; a forward-only rollover applies if the + /// UTC day has advanced since the last write. The account is bound later at unlock. pub fn new(cfg: Config) -> Self { - let policy = policy_store::load_policy(&cfg.policy_path()); + let mut policy = policy_store::load_policy(&cfg.policy_path()); + let today = current_utc_day(); + let mut spend = SpendStore::load(cfg.spend_path(), cfg.chain_id, today); + spend.rollover(today); + // Recover the day's durable spend, clamped to the cap so the reported running total never + // exceeds it (a crash-orphaned over-cap reservation reads as "fully spent", not a ghost + // number above the cap). + policy.spent_today_wei = spend.effective_spent().min(policy.daily_cap_wei); Self { cfg, state: VaultState::Locked, policy, - spent_day: current_utc_day(), + spend, approval_ttl: approval_ttl(), requests: HashMap::new(), seq_counter: 0, @@ -270,6 +283,13 @@ impl Daemon { } } + /// Mirror the durable counter into the live policy's running total, clamped to the daily cap + /// (so `evaluate` and `PolicyGet` see the persisted accounting and the reported number never + /// exceeds the cap). Called after every spend mutation. + fn sync_policy_spend(&mut self) { + self.policy.spent_today_wei = self.spend.effective_spent().min(self.policy.daily_cap_wei); + } + /// 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). @@ -426,6 +446,13 @@ impl Daemon { }; self.policy.revoked = false; // a fresh unlock re-arms self.requests.clear(); // fresh session: no stale approvals survive a re-unlock + // Roll the window forward first (a re-unlock may cross a UTC midnight since + // boot), then bind the durable spend window to this account (issue #108): a + // different account (a re-key) starts a fresh window; the same account recovers + // the day's spend. Re-sync the live policy mirror to the durable counter. + self.rollover(); + self.spend.bind_account(address, current_utc_day()); + self.sync_policy_spend(); UnlockOutcome::Unlocked { address } } // A successfully decrypted vault that can't derive an address is corrupt; @@ -1135,6 +1162,23 @@ impl Daemon { (intent.to, intent.value, intent.calldata.clone(), scalar) }; + // Reserve the spend DURABLY before releasing the signature (issue #108): a crash between + // signing and the post-broadcast commit must not lose the accounting. Skip `value == 0` + // (shields / shaped approves / contract calls move no ETH via `value`). A reserve-write + // failure means we can't durably account this spend, so we fail CLOSED — deny rather than + // sign un-accounted funds. (The fsync lands under the daemon mutex; accepted for v1, like + // the broadcast below.) + let reserve_value = value; + if !reserve_value.is_zero() { + if let Err(e) = self.spend.reserve(reserve_value) { + eprintln!("signerd: ⚠ spend reserve failed ({e}); refusing to sign (fail-closed)"); + return ExecuteResult::Denied { + reason: deny_reasons::RESERVE_FAILED.into(), + }; + } + self.sync_policy_spend(); + } + // 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( @@ -1148,22 +1192,49 @@ impl Daemon { let tx_hash = match tokio::time::timeout(BROADCAST_TIMEOUT, broadcast).await { Ok(Ok(hash)) => hash, Ok(Err(e)) => { + // Clean RPC rejection: the tx did NOT go out → release the reservation (give the + // headroom back). Safe because nothing moved. + if !reserve_value.is_zero() { + self.spend.release(reserve_value); + self.sync_policy_spend(); + } return ExecuteResult::Denied { reason: deny_reasons::broadcast_failed(one_line(&e)), - } + }; } Err(_elapsed) => { + // Timeout: the tx MAY have landed (status UNKNOWN) → KEEP the reservation counted + // (commit it). Releasing here would re-open the double-spend the durable counter + // exists to close. Exact reconciliation is deferred (issue #108 / post-#72). + if !reserve_value.is_zero() { + self.spend.commit(reserve_value); + self.sync_policy_spend(); + } + // Mark the request TERMINAL so it can't be re-executed. The tx status is unknown + // and may have landed; a retry would reserve+broadcast a SECOND time (a double-spend + // — and for a human-approved over-cap request the sign-time cap re-check is skipped, + // so nothing else would stop it). This is the "do NOT retry" BROADCAST_TIMEOUT + // contract, enforced at the daemon instead of trusting the caller. + if let Some(req) = self.requests.get_mut(&request_id) { + req.status = ApprovalStatus::Denied { + reason: deny_reasons::BROADCAST_TIMEOUT.into(), + }; + } return ExecuteResult::Denied { reason: deny_reasons::BROADCAST_TIMEOUT.into(), - } + }; } }; - // Phase 3: record the broadcast + bump the daily spend. + // Phase 3: record the broadcast + commit the reserved spend (reserved → committed). The + // durable counter is the source of truth; `sync_policy_spend` mirrors it into the policy. 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); + if !reserve_value.is_zero() { + self.spend.commit(reserve_value); + self.sync_policy_spend(); + } ExecuteResult::Broadcast { tx_hash } } @@ -1393,12 +1464,13 @@ impl Daemon { !self.cfg.autonomy_override && !is_testnet_or_fork(self.cfg.chain_id) } - /// Reset the daily spend window when the UTC day ticks over. + /// Reset the daily spend window when the UTC day ticks over — **forward-only** (issue #108): + /// the durable counter resets only when the day advances, so a backward wall-clock can't reset + /// the cap. The counter is the single source of truth for the window; the policy mirror is + /// re-synced when it rolls. 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; + if self.spend.rollover(current_utc_day()) { + self.sync_policy_spend(); } } } diff --git a/crates/deckard-signerd/src/lib.rs b/crates/deckard-signerd/src/lib.rs index 56b5f0b..babe7c8 100644 --- a/crates/deckard-signerd/src/lib.rs +++ b/crates/deckard-signerd/src/lib.rs @@ -28,6 +28,7 @@ pub mod request_id; pub mod server; pub mod signing; pub mod socket; +pub mod spend_store; pub mod supervise; pub use client::SignerClient; diff --git a/crates/deckard-signerd/src/spend_store.rs b/crates/deckard-signerd/src/spend_store.rs new file mode 100644 index 0000000..bcecabb --- /dev/null +++ b/crates/deckard-signerd/src/spend_store.rs @@ -0,0 +1,479 @@ +//! Durable daily-spend accounting (issue #108). +//! +//! The signer's daily cap (`Policy::spent_today_wei`) is RAM-only; this module persists it so a +//! restart — crash, OOM, app update, sleep — doesn't silently zero the day's accounting, and so a +//! **reserve-before-sign** survives a crash between signing and the post-broadcast bump. +//! +//! ## Model +//! `effective_spent = committed_wei + reserved_wei`, mirrored into `Policy::spent_today_wei` so the +//! pure cap decision (`deckard_contract::evaluate`) is unchanged. The lifecycle around one +//! `execute`: +//! +//! ```text +//! reserve(v) reserved += v ; persist (BEFORE the signature is released) +//! broadcast ── Ok ──> commit(v) reserved -= v ; committed += v ; persist +//! ── clean Err ─> release(v) reserved -= v ; persist (RPC rejected — nothing moved) +//! ── timeout ──> commit(v) keep it counted (may have landed — fail safe) +//! crash (no commit) ──> reserved stays on disk ──> load() counts it as spent (conservative) +//! ``` +//! Exact chain reconciliation (releasing a genuinely-dropped tx's headroom) is deliberately +//! deferred to a post-#72 issue: recovery here only ever makes the cap *tighter*, never looser. +//! +//! ## Window +//! Bound to `(chain_id, account, UTC day)`. The day floor is **forward-only**: a backward +//! wall-clock can't reset the window. A different chain on load is discarded (a different cap); a +//! different account at unlock resets the window (per-account caps). A missing file is a fresh +//! window; a present-but-unparseable file fails closed (fully spent until the next rollover). + +use std::path::PathBuf; + +use alloy_primitives::{Address, U256}; +use serde::{Deserialize, Serialize}; + +use deckard_core::atomic_write; + +/// The on-disk record, JSON-serialized next to `policy.json` as `spend.json`. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +struct SpendRecord { + chain_id: u64, + /// The wallet this window accounts for; the zero address until first bound at unlock. + account: Address, + /// Forward-only UTC-day floor (days since the Unix epoch). + day: u64, + committed_wei: U256, + reserved_wei: U256, +} + +impl SpendRecord { + fn fresh(chain_id: u64, account: Address, day: u64) -> Self { + Self { + chain_id, + account, + day, + committed_wei: U256::ZERO, + reserved_wei: U256::ZERO, + } + } +} + +/// How a counter-file load resolved — split out so the fallbacks are unit-testable without a +/// real filesystem. +#[derive(Debug, PartialEq, Eq)] +enum LoadKind { + /// Parsed cleanly for THIS chain. + Loaded, + /// No file — a normal first run; a fresh window applies quietly. + Missing, + /// The file is for a different chain id — discarded (a different chain is a different cap). + WrongChain, + /// The file EXISTS but did not parse / could not be read (corrupt or hostile write). Fail + /// closed: fully spent until the next rollover. Loud, like the policy loader's `DefaultInvalid`. + Invalid(String), +} + +/// Pure load resolver: given the raw read result, the daemon's chain, and today's UTC day, +/// produce the in-memory record, a `corrupt` flag, and the classification (for logging/tests). +/// Never panics; an unreadable or unparseable file fails closed (`corrupt = true`). +fn resolve_load( + read: std::io::Result>, + chain_id: u64, + today: u64, +) -> (SpendRecord, bool, LoadKind) { + let fresh = || SpendRecord::fresh(chain_id, Address::ZERO, today); + match read { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (fresh(), false, LoadKind::Missing), + Err(e) => ( + fresh(), + true, + LoadKind::Invalid(format!("read failed: {e}")), + ), + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(rec) if rec.chain_id == chain_id => (rec, false, LoadKind::Loaded), + Ok(_other_chain) => (fresh(), false, LoadKind::WrongChain), + Err(e) => ( + fresh(), + true, + LoadKind::Invalid(format!("parse failed: {e}")), + ), + }, + } +} + +/// The durable daily-spend counter. Single-writer (the daemon, under its serializing mutex); every +/// mutation persists atomically (`atomic_write` → fsync + dir-sync). +pub struct SpendStore { + path: PathBuf, + chain_id: u64, + record: SpendRecord, + /// The file existed but did not load: the cap reads as fully spent (cap exhausted) until the + /// next rollover or a trusted unlock rebinds a fresh window. + corrupt: bool, +} + +impl SpendStore { + /// Load at boot. The daemon is `Locked` here, so the account is not yet known — it is bound and + /// validated later at unlock ([`bind_account`](Self::bind_account)). Never panics: a + /// corrupt/unreadable file fails closed (fully spent until rollover). `today` is injected for + /// testability. + pub fn load(path: PathBuf, chain_id: u64, today: u64) -> Self { + let (record, corrupt, kind) = resolve_load(std::fs::read(&path), chain_id, today); + match &kind { + LoadKind::Invalid(why) => eprintln!( + "signerd: ⚠ SPEND COUNTER FALLBACK — {} exists but did not load ({why}); \ + treating the daily cap as FULLY SPENT until the next UTC day (fail closed). \ + Delete the file to start a fresh window.", + path.display() + ), + LoadKind::WrongChain => eprintln!( + "signerd: spend counter at {} is for a different chain — starting a fresh window", + path.display() + ), + LoadKind::Missing | LoadKind::Loaded => {} + } + Self { + path, + chain_id, + record, + corrupt, + } + } + + /// Effective spend for the cap check = `committed + reserved`. A corrupt counter reads as fully + /// spent (`U256::MAX`) so every auto-allow is refused until rollover clears it. + pub fn effective_spent(&self) -> U256 { + if self.corrupt { + U256::MAX + } else { + self.record + .committed_wei + .saturating_add(self.record.reserved_wei) + } + } + + /// The forward-only UTC-day floor of the current window. + pub fn day(&self) -> u64 { + self.record.day + } + + /// Forward-only rollover. Resets the window ONLY when the day advances; a backward wall-clock + /// (`today <= day`) leaves the window intact — fail closed, not reset-to-zero. Returns true if + /// it reset (the caller re-syncs the policy mirror to zero). + pub fn rollover(&mut self, today: u64) -> bool { + if today > self.record.day { + self.record = SpendRecord::fresh(self.chain_id, self.record.account, today); + self.corrupt = false; // a genuinely new day clears a corrupt-file wedge + self.persist_best_effort(); + true + } else { + false + } + } + + /// Bind the unlocked account. If the stored account differs (a re-key, or a fresh/corrupt + /// record whose account is the zero default), reset the window for the new account — caps are + /// per-account. Returns true if it reset. + /// + /// Note this CLEARS a corrupt-file wedge: a corrupt record carries the zero-address default, so + /// the first real unlock mismatches and starts a fresh window. That is acceptable under the + /// conceded same-uid boundary — an attacker who can corrupt the counter file can equally delete + /// it, which already routes to a fresh window (the accepted residual, ADR 0004). So the + /// corrupt fail-closed (fully spent) holds only until the next unlock or rollover, not forever; + /// it buys tamper-*evidence* (the loud log at load), not tamper-resistance. + pub fn bind_account(&mut self, account: Address, today: u64) -> bool { + if self.record.account != account { + self.record = SpendRecord::fresh(self.chain_id, account, today); + self.corrupt = false; + self.persist_best_effort(); + true + } else { + false + } + } + + /// Reserve `value` before the signature is released. Persists durably (fsync). On a write + /// failure the caller MUST fail closed (deny) — nothing has been signed yet. + pub fn reserve(&mut self, value: U256) -> anyhow::Result<()> { + self.record.reserved_wei = self.record.reserved_wei.saturating_add(value); + self.persist() + } + + /// Commit a reserved spend after a successful — or timed-out, which may have landed — broadcast: + /// move it reserved → committed. Best-effort persist: the tx is already on the wire, and a + /// persist failure leaves the reservation on disk, which still counts as spent on reboot. + pub fn commit(&mut self, value: U256) { + self.record.reserved_wei = self.record.reserved_wei.saturating_sub(value); + self.record.committed_wei = self.record.committed_wei.saturating_add(value); + self.persist_best_effort(); + } + + /// Release a reservation after a clean pre-broadcast RPC rejection (the tx did not go out). + pub fn release(&mut self, value: U256) { + self.record.reserved_wei = self.record.reserved_wei.saturating_sub(value); + self.persist_best_effort(); + } + + fn persist(&self) -> anyhow::Result<()> { + let bytes = serde_json::to_vec(&self.record)?; + atomic_write(&self.path, &bytes) + } + + fn persist_best_effort(&self) { + if let Err(e) = self.persist() { + eprintln!( + "signerd: ⚠ spend counter persist failed ({e}); accounting may revert on restart" + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CHAIN: u64 = 31337; + const DAY: u64 = 20_000; + + fn acct(b: u8) -> Address { + Address::from([b; 20]) + } + + fn tmp(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("deckard-spend-test-{}-{name}", std::process::id())) + } + + // ── pure load resolver ── + + #[test] + fn missing_file_is_a_fresh_window() { + let read = Err(std::io::Error::from(std::io::ErrorKind::NotFound)); + let (rec, corrupt, kind) = resolve_load(read, CHAIN, DAY); + assert_eq!(kind, LoadKind::Missing); + assert!(!corrupt); + assert_eq!(rec, SpendRecord::fresh(CHAIN, Address::ZERO, DAY)); + } + + #[test] + fn unparseable_file_fails_closed_corrupt() { + let (_rec, corrupt, kind) = resolve_load(Ok(b"{ not json".to_vec()), CHAIN, DAY); + assert!(corrupt, "corrupt file must fail closed"); + assert!(matches!(kind, LoadKind::Invalid(_))); + } + + #[test] + fn unreadable_file_fails_closed_corrupt() { + let read = Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)); + let (_rec, corrupt, kind) = resolve_load(read, CHAIN, DAY); + assert!(corrupt); + assert!(matches!(kind, LoadKind::Invalid(_))); + } + + #[test] + fn wrong_chain_is_discarded_not_counted() { + let other = SpendRecord { + chain_id: 1, // mainnet record loaded by a 31337 daemon + account: acct(7), + day: DAY, + committed_wei: U256::from(999u64), + reserved_wei: U256::ZERO, + }; + let bytes = serde_json::to_vec(&other).unwrap(); + let (rec, corrupt, kind) = resolve_load(Ok(bytes), CHAIN, DAY); + assert_eq!(kind, LoadKind::WrongChain); + assert!(!corrupt); + assert_eq!( + rec.committed_wei, + U256::ZERO, + "a different chain's spend never throttles this one" + ); + assert_eq!(rec.chain_id, CHAIN); + } + + #[test] + fn same_chain_record_loads_verbatim() { + let rec0 = SpendRecord { + chain_id: CHAIN, + account: acct(3), + day: DAY, + committed_wei: U256::from(5u64), + reserved_wei: U256::from(2u64), + }; + let bytes = serde_json::to_vec(&rec0).unwrap(); + let (rec, corrupt, kind) = resolve_load(Ok(bytes), CHAIN, DAY); + assert_eq!(kind, LoadKind::Loaded); + assert!(!corrupt); + assert_eq!(rec, rec0); + } + + // ── effective spend + conservative recovery ── + + #[test] + fn effective_spent_counts_reserved_as_spent() { + let path = tmp("eff"); + let _ = std::fs::remove_file(&path); + let mut s = SpendStore::load(path.clone(), CHAIN, DAY); + s.bind_account(acct(1), DAY); + s.reserve(U256::from(10u64)).unwrap(); + assert_eq!(s.effective_spent(), U256::from(10u64)); + s.commit(U256::from(10u64)); + assert_eq!( + s.effective_spent(), + U256::from(10u64), + "commit keeps the total, moves reserved→committed" + ); + s.reserve(U256::from(4u64)).unwrap(); + s.release(U256::from(4u64)); + assert_eq!( + s.effective_spent(), + U256::from(10u64), + "release rolls back a reservation" + ); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn reserved_leftover_on_reload_counts_as_spent() { + // A crash between reserve and commit leaves reserved on disk; the reload counts it. + let path = tmp("leftover"); + let _ = std::fs::remove_file(&path); + let mut s = SpendStore::load(path.clone(), CHAIN, DAY); + s.bind_account(acct(1), DAY); + s.reserve(U256::from(7u64)).unwrap(); // persisted, never committed (simulated crash) + drop(s); + let s2 = SpendStore::load(path.clone(), CHAIN, DAY); + assert_eq!( + s2.effective_spent(), + U256::from(7u64), + "orphaned reserve is spent on reboot" + ); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn corrupt_reads_as_fully_spent() { + let path = tmp("corrupt"); + std::fs::write(&path, b"{ truncated").unwrap(); + let s = SpendStore::load(path.clone(), CHAIN, DAY); + assert_eq!( + s.effective_spent(), + U256::MAX, + "corrupt counter = cap exhausted" + ); + let _ = std::fs::remove_file(&path); + } + + // ── forward-only rollover ── + + #[test] + fn rollover_resets_only_when_day_advances() { + let path = tmp("roll"); + let _ = std::fs::remove_file(&path); + let mut s = SpendStore::load(path.clone(), CHAIN, DAY); + s.bind_account(acct(1), DAY); + s.reserve(U256::from(50u64)).unwrap(); + s.commit(U256::from(50u64)); + assert_eq!(s.effective_spent(), U256::from(50u64)); + + assert!(!s.rollover(DAY), "same day: no reset"); + assert_eq!(s.effective_spent(), U256::from(50u64)); + + assert!( + !s.rollover(DAY - 1), + "BACKWARD clock: must NOT reset (fail closed)" + ); + assert_eq!( + s.effective_spent(), + U256::from(50u64), + "clock rewind keeps the window" + ); + + assert!(s.rollover(DAY + 1), "forward day: resets"); + assert_eq!(s.effective_spent(), U256::ZERO); + assert_eq!(s.day(), DAY + 1); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn corrupt_wedge_clears_on_unlock_rebind() { + // A corrupt file fails closed (fully spent) at load, but the first real unlock rebinds a + // fresh window — the corrupt record's account is the zero default, so it mismatches. This + // pins the documented behavior: the wedge holds until unlock/rollover, not forever (an + // attacker who can corrupt the file can equally delete it — the accepted same-uid residual). + let path = tmp("corrupt-unlock"); + std::fs::write(&path, b"}{corrupt").unwrap(); + let mut s = SpendStore::load(path.clone(), CHAIN, DAY); + assert_eq!( + s.effective_spent(), + U256::MAX, + "corrupt → fully spent before unlock" + ); + assert!( + s.bind_account(acct(1), DAY), + "first unlock rebinds a fresh window" + ); + assert_eq!( + s.effective_spent(), + U256::ZERO, + "wedge cleared by the trusted unlock" + ); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn rollover_clears_a_corrupt_wedge_on_a_new_day() { + let path = tmp("roll-corrupt"); + std::fs::write(&path, b"garbage").unwrap(); + let mut s = SpendStore::load(path.clone(), CHAIN, DAY); + assert_eq!(s.effective_spent(), U256::MAX); + assert!(s.rollover(DAY + 1), "a new day clears the corrupt wedge"); + assert_eq!(s.effective_spent(), U256::ZERO); + let _ = std::fs::remove_file(&path); + } + + // ── account binding ── + + #[test] + fn bind_account_resets_window_on_account_change() { + let path = tmp("bind"); + let _ = std::fs::remove_file(&path); + let mut s = SpendStore::load(path.clone(), CHAIN, DAY); + assert!( + s.bind_account(acct(1), DAY), + "first bind from zero-default resets" + ); + s.reserve(U256::from(9u64)).unwrap(); + s.commit(U256::from(9u64)); + assert!( + !s.bind_account(acct(1), DAY), + "same account re-unlock keeps the window" + ); + assert_eq!( + s.effective_spent(), + U256::from(9u64), + "durability across re-unlock" + ); + assert!(s.bind_account(acct(2), DAY), "different account resets"); + assert_eq!(s.effective_spent(), U256::ZERO); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn reserve_persists_across_reload() { + let path = tmp("persist"); + let _ = std::fs::remove_file(&path); + let mut s = SpendStore::load(path.clone(), CHAIN, DAY); + s.bind_account(acct(1), DAY); + s.reserve(U256::from(3u64)).unwrap(); + s.commit(U256::from(3u64)); + drop(s); + // A fresh daemon (same chain, same account, same day) recovers the committed spend. + let mut s2 = SpendStore::load(path.clone(), CHAIN, DAY); + assert_eq!( + s2.effective_spent(), + U256::from(3u64), + "honest restart keeps the day's spend" + ); + assert!(!s2.bind_account(acct(1), DAY)); + assert_eq!(s2.effective_spent(), U256::from(3u64)); + let _ = std::fs::remove_file(&path); + } +} diff --git a/crates/deckard-signerd/src/supervise.rs b/crates/deckard-signerd/src/supervise.rs index f08ed3a..fc9af8a 100644 --- a/crates/deckard-signerd/src/supervise.rs +++ b/crates/deckard-signerd/src/supervise.rs @@ -36,7 +36,7 @@ use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use deckard_contract::{RequestId, SignerRequest, SignerResponse}; @@ -331,6 +331,24 @@ fn set_cloexec(fd: &F) -> anyhow::Result<()> { Ok(()) } +/// Crash-budget (issue #108, Option D): if the daemon exits this many times within +/// [`RESPAWN_WINDOW`] *without* a sustained healthy run in between, the supervisor stops respawning +/// and surfaces a loud error instead of hot-looping forever. The per-spawn `backoff` resets on any +/// healthy 200ms poll, so a same-uid attacker crash-looping the daemon to reset the in-memory cap +/// would otherwise be respawned at the floor indefinitely. +/// +/// Only a TIGHT loop trips it: any run that lasts at least [`HEALTHY_RUN_RESET`] clears the counter +/// (see `monitor_loop`), so a daemon that stays up for a while and then crashes — even repeatedly, +/// e.g. an intermittent RPC outage once a minute — is respawned each time and never permanently +/// bricked. The budget only fires when the daemon can't even stay alive `HEALTHY_RUN_RESET`, +/// `RESPAWN_BUDGET` times in a row inside the window. +const RESPAWN_BUDGET: usize = 5; +const RESPAWN_WINDOW: Duration = Duration::from_secs(300); +/// A child that stayed alive at least this long was a genuinely healthy run; its eventual exit +/// resets the crash-budget so sparse, recoverable crashes can't accumulate into a permanent brick. +/// Short enough that a tight crash-loop (each instance dies fast) never reaches it. +const HEALTHY_RUN_RESET: Duration = Duration::from_secs(60); + /// A running, self-restarting daemon child. Dropping it stops the supervisor and kills the /// child. pub struct DaemonSupervisor { @@ -338,6 +356,9 @@ pub struct DaemonSupervisor { child: Arc>>, socket_path: PathBuf, control: ControlChannel, + /// Set true once the crash-budget is exhausted (the daemon is no longer being respawned). The + /// app can poll [`is_crashed_out`](Self::is_crashed_out) to surface "wallet unavailable". + crashed_out: Arc, } impl DaemonSupervisor { @@ -351,6 +372,7 @@ impl DaemonSupervisor { pub fn spawn(socket_path: PathBuf, rpc_url: String, chain_id: u64) -> Self { let shutdown = Arc::new(AtomicBool::new(false)); let child: Arc>> = Arc::new(Mutex::new(None)); + let crashed_out = Arc::new(AtomicBool::new(false)); let control = ControlChannel::disconnected(); let env = ChildEnv { socket_path: socket_path.clone(), @@ -364,11 +386,12 @@ impl DaemonSupervisor { child: Arc::clone(&child), socket_path, control: control.clone(), + crashed_out: Arc::clone(&crashed_out), }; std::thread::Builder::new() .name("deckard-signerd-sup".into()) - .spawn(move || monitor_loop(env, shutdown, child, control)) + .spawn(move || monitor_loop(env, shutdown, child, control, crashed_out)) .ok(); sup @@ -390,6 +413,13 @@ impl DaemonSupervisor { pub fn control(&self) -> ControlChannel { self.control.clone() } + + /// True once the daemon crash-looped past its budget and the supervisor stopped respawning it + /// (issue #108, Option D). The wallet is unavailable until the app restarts; the app can poll + /// this to surface that instead of silently failing every request. + pub fn is_crashed_out(&self) -> bool { + self.crashed_out.load(Ordering::SeqCst) + } } impl Drop for DaemonSupervisor { @@ -501,6 +531,17 @@ fn backoff_and_continue(backoff: &mut Duration, shutdown: &AtomicBool) -> bool { true } +/// Record an instability event at `now` and report whether the crash-budget is exhausted (issue +/// #108, Option D). Prunes events older than [`RESPAWN_WINDOW`] first, so only a burst of +/// `RESPAWN_BUDGET` exits *within the window* trips it — a daemon healthy for long stretches that +/// then crashes once never does. Pure (instants injected) so the policy is unit-tested without +/// spawning processes. +fn over_crash_budget(crash_times: &mut Vec, now: Instant) -> bool { + crash_times.retain(|t| now.duration_since(*t) < RESPAWN_WINDOW); + crash_times.push(now); + crash_times.len() >= RESPAWN_BUDGET +} + /// Spawn → poll-until-exit → backoff → respawn, until shutdown is signalled. Each spawn mints a /// FRESH capability channel (an inherited fd serves exactly one daemon instance), publishes the /// app end while the daemon is alive, and disconnects it when the daemon exits — so a restarted @@ -510,8 +551,12 @@ fn monitor_loop( shutdown: Arc, child_slot: Arc>>, control: ControlChannel, + crashed_out: Arc, ) { let mut backoff = Duration::from_millis(200); + // Crash-budget window (issue #108): timestamps of recent unexpected exits / spawn failures. + // A clean shutdown (Drop → `shutdown`) returns out of the poll loop and never records here. + let mut crash_times: Vec = Vec::new(); while !shutdown.load(Ordering::SeqCst) { // Resolve + provenance-check the daemon binary on every spawn attempt. In a release build // this is the ONE canonical bundled path (ownership/permission/symlink verified); a failure @@ -561,6 +606,7 @@ fn monitor_loop( if let Ok(mut slot) = child_slot.lock() { *slot = Some(child); } + let started = Instant::now(); // Poll for exit, releasing the lock between polls so Drop can kill the child. loop { if shutdown.load(Ordering::SeqCst) { @@ -592,12 +638,36 @@ fn monitor_loop( // The daemon is gone: tear down its capability channel so a stale end is never // used against the next instance (the next iteration mints a fresh one). control.disconnect(); + // A sustained healthy run resets the crash-budget: only a TIGHT loop (each instance + // dying fast) should ever stop respawns. A daemon that stayed up past + // `HEALTHY_RUN_RESET` and then crashed — even repeatedly, e.g. an RPC outage once a + // minute — must keep being respawned, not accumulate toward a permanent brick. + if started.elapsed() >= HEALTHY_RUN_RESET { + crash_times.clear(); + } } Err(e) => { eprintln!("deckard: failed to spawn signerd ({}): {e}", bin.display()); } } + // Crash-budget (issue #108): the daemon just exited unexpectedly (or failed to spawn) — + // an instability event. Count events in a sliding window with NO sustained healthy run in + // between (a healthy run clears the window above); too many ⇒ stop respawning and surface, + // rather than hot-loop forever (the backoff resets on any healthy poll, so it can't stop a + // fast crash-loop on its own). A clean Drop-shutdown returns from the poll loop and never + // reaches here. + if over_crash_budget(&mut crash_times, Instant::now()) { + eprintln!( + "deckard: signerd exited {} times in under {}s — NOT respawning. The wallet is \ + unavailable until the app restarts; check the logs above for the cause.", + crash_times.len(), + RESPAWN_WINDOW.as_secs() + ); + crashed_out.store(true, Ordering::SeqCst); + return; + } + if !backoff_and_continue(&mut backoff, &shutdown) { return; } @@ -608,6 +678,40 @@ fn monitor_loop( mod tests { use super::*; + /// Crash-budget (issue #108): a burst of `RESPAWN_BUDGET` exits inside the window trips it; a + /// run of fewer does not. + #[test] + fn crash_budget_trips_only_on_a_burst() { + let base = Instant::now(); + let mut times = Vec::new(); + for _ in 0..(RESPAWN_BUDGET - 1) { + assert!( + !over_crash_budget(&mut times, base), + "under budget must not trip" + ); + } + assert!( + over_crash_budget(&mut times, base), + "the {RESPAWN_BUDGET}th exit within the window trips the budget" + ); + } + + /// Events older than the window are pruned, so a daemon that's stable for long stretches and + /// crashes occasionally never trips it (no permanent lockout from sparse failures). + #[test] + fn crash_budget_ages_out_old_events() { + let base = Instant::now(); + let mut times = Vec::new(); + for _ in 0..(RESPAWN_BUDGET - 1) { + over_crash_budget(&mut times, base); + } + let later = base + RESPAWN_WINDOW + Duration::from_secs(1); + assert!( + !over_crash_budget(&mut times, later), + "events older than the window are pruned → not over budget" + ); + } + fn child_env() -> ChildEnv { ChildEnv { socket_path: PathBuf::from("/tmp/deckard-test.sock"), diff --git a/crates/deckard-signerd/tests/durable_cap_e2e.rs b/crates/deckard-signerd/tests/durable_cap_e2e.rs new file mode 100644 index 0000000..268a779 --- /dev/null +++ b/crates/deckard-signerd/tests/durable_cap_e2e.rs @@ -0,0 +1,170 @@ +//! Durable daily-cap (issue #108) end-to-end on a local anvil, driving the REAL daemon binary. +//! +//! The headline guarantee: the daily spend cap survives a daemon restart. Before #108 the cap was +//! in-memory and force-zeroed on every load, so a restart (crash, OOM, app update — or a same-uid +//! attacker crash-looping the auto-respawning daemon) reset the day's accounting and re-opened the +//! within-cap drain. These tests broadcast real txs, kill the daemon, respawn it against the SAME +//! config dir, and assert the persisted `spend.json` is recovered. Skips when `anvil` isn't on PATH. + +mod common; + +use alloy_primitives::{Address, Bytes, U256}; +use deckard_contract::{ + ApprovalMode, Decision, ExecuteResult, Intent, IntentKind, Policy, ProposalOrigin, + SignerRequest, SignerResponse, +}; +use deckard_signerd::SignerClient; + +use common::*; + +const CHAIN: u64 = 31337; + +fn send(to: Address, value: u128) -> Intent { + Intent { + chain_id: CHAIN, + to, + token: None, + value: U256::from(value), + calldata: Bytes::new(), + kind: IntentKind::Send, + } +} + +/// Read the daemon's live policy (carries the running `spent_today_wei`). +async fn spent_today(client: &SignerClient) -> U256 { + match client.request(&SignerRequest::PolicyGet).await.unwrap() { + SignerResponse::Policy(p) => p.spent_today_wei, + other => panic!("expected Policy, got {other:?}"), + } +} + +/// Kill `d` and spawn a FRESH daemon against the same `dir` (a clean restart). Removes the stale +/// socket so the new daemon binds cleanly and the client doesn't connect to a dead socket. +fn restart(d: DaemonProc, dir: &std::path::Path, url: &str) -> DaemonProc { + let socket = d.socket_path.clone(); + drop(d); // kills the child + let _ = std::fs::remove_file(&socket); + spawn_daemon(dir, url, CHAIN, &[]) +} + +#[tokio::test] +async fn honest_restart_recovers_the_daily_spend() { + if !anvil_available() { + eprintln!("SKIP honest_restart_recovers_the_daily_spend: anvil not on PATH"); + return; + } + let anvil = start_anvil(); + wait_anvil_ready(&anvil.url()).await; + + let dir = TempDir::new("durable-honest"); + let (_wallet, recipient) = seal_account0(dir.path()); + + // Spend 0.01 ETH (within the default caps) → broadcast → committed to spend.json. + let value: u128 = 10_000_000_000_000_000; + let d = spawn_daemon(dir.path(), &anvil.url(), CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + let intent = send(recipient, value); + assert_eq!( + client.propose(&intent, ProposalOrigin::App).await.unwrap(), + Decision::Allow + ); + let id = SignerClient::request_id_for_intent(&intent); + let tx = match client.execute(id).await.unwrap() { + ExecuteResult::Broadcast { tx_hash } => tx_hash, + other => panic!("expected Broadcast, got {other:?}"), + }; + wait_receipt(&anvil.url(), tx).await.expect("receipt"); + assert_eq!( + spent_today(&client).await, + U256::from(value), + "spend recorded" + ); + assert!( + dir.path().join("spend.json").exists(), + "the durable counter file was written" + ); + + // Restart the daemon against the same dir — the in-memory cap is gone, but spend.json persists. + let d2 = restart(d, dir.path(), &anvil.url()); + let client2 = SignerClient::new(d2.socket_path.clone()); + client2.unlock(PASS).await.unwrap(); + assert_eq!( + spent_today(&client2).await, + U256::from(value), + "the day's spend is recovered across restart (was 0 before #108)" + ); +} + +#[tokio::test] +async fn restart_does_not_reset_the_cap() { + // The security-relevant proof: a same-uid attacker who crash-loops the daemon to reset the cap + // gains nothing — the persisted spend re-applies on boot, so a within-cap auto-allow that only + // fits if the cap were reset is instead held for human approval. + if !anvil_available() { + eprintln!("SKIP restart_does_not_reset_the_cap: anvil not on PATH"); + return; + } + let anvil = start_anvil(); + wait_anvil_ready(&anvil.url()).await; + + let dir = TempDir::new("durable-cap"); + let (_wallet, recipient) = seal_account0(dir.path()); + // Tight policy: per-tx 0.05, daily 0.05. 0.04 then 0.03 each fit per-tx, but together exceed + // the 0.05 daily cap. + let policy = Policy { + per_tx_cap_wei: U256::from(50_000_000_000_000_000u128), + daily_cap_wei: U256::from(50_000_000_000_000_000u128), + spent_today_wei: U256::ZERO, + allow_to: vec![], + auto_shield_min_wei: U256::from(10_000_000_000_000_000u128), + require_approval: ApprovalMode::OverCap, + revoked: false, + allow_swap_tokens: vec![], + }; + std::fs::write( + dir.path().join("policy.json"), + serde_json::to_vec(&policy).unwrap(), + ) + .unwrap(); + + // Spend 0.04 ETH (within both caps) and broadcast it. + let d = spawn_daemon(dir.path(), &anvil.url(), CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + let first = send(recipient, 40_000_000_000_000_000); + assert_eq!( + client.propose(&first, ProposalOrigin::App).await.unwrap(), + Decision::Allow + ); + let tx = match client + .execute(SignerClient::request_id_for_intent(&first)) + .await + .unwrap() + { + ExecuteResult::Broadcast { tx_hash } => tx_hash, + other => panic!("expected Broadcast, got {other:?}"), + }; + wait_receipt(&anvil.url(), tx).await.expect("receipt"); + + // Crash-loop stand-in: restart the daemon. Before #108 this zeroed the cap. + let d2 = restart(d, dir.path(), &anvil.url()); + let client2 = SignerClient::new(d2.socket_path.clone()); + client2.unlock(PASS).await.unwrap(); + assert_eq!( + spent_today(&client2).await, + U256::from(40_000_000_000_000_000u128), + "the 0.04 spend persisted across the restart" + ); + + // A 0.03 ETH send fits per-tx (0.05) and would auto-allow IF the cap had reset — but the + // recovered 0.04 + 0.03 exceeds the 0.05 daily cap, so it is HELD for approval, not drained. + let second = send(recipient, 30_000_000_000_000_000); + assert!( + matches!( + client2.propose(&second, ProposalOrigin::App).await.unwrap(), + Decision::NeedsApproval { .. } + ), + "the durable cap blocks the post-restart drain (would be Allow if the cap had reset)" + ); +} diff --git a/docs/build/31-agent-quickstart.md b/docs/build/31-agent-quickstart.md index 9f95b5d..4d66934 100644 --- a/docs/build/31-agent-quickstart.md +++ b/docs/build/31-agent-quickstart.md @@ -130,6 +130,7 @@ error is to retry — for two of these (marked **do NOT retry**) that instinct i | `chain_mismatch` | Sidecar and daemon disagree on the chain (e.g. demo sidecar → real daemon). | Re-run `deckard-mcp install --demo` and make sure `just demo` is what's running. | | `over_cap` | Over the cap with `require_approval = never` — nothing can authorize it. | Lower the amount under `per_tx_cap_wei` (read it with `deckard_policy_get`). | | `cap_exceeded` | Executing would pass the spending caps as re-checked at sign time. | Lower the amount or wait for the UTC-midnight rollover; re-read the policy for current numbers. | +| `reserve_failed` | The daemon could not durably record the spend before signing (a disk/fsync error), so it refused to sign rather than move funds it can't account against the cap. | Transient — check disk space, then re-run from `deckard_shield`. If it persists, a human checks the daemon host. | | `off_allowlist` | The recipient isn't in `allow_to`. | Use an allowed recipient, or a human edits `policy.json`. | | `undecodable` | The intent's calldata doesn't match its kind (client-side bug if it recurs). | Re-run the flow from `deckard_shield`. | | `shield_to_mismatch` | The shield doesn't target the official Railgun contract for this chain. | Re-run from `deckard_shield` (it builds the right target); recurring means the chain is unsupported. | From 25f99c8221765e1a7ed67037e31d07e03762061c Mon Sep 17 00:00:00 2001 From: hellno Date: Sun, 21 Jun 2026 12:16:00 +0200 Subject: [PATCH 3/3] signerd: note spawn_blocking off-lock fsync as a #108 follow-up TODO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the deferred 'move the reserve/commit fsyncs off the daemon mutex via spawn_blocking if STOP latency bites' as a greppable code TODO (per review of PR #125) rather than an issue — revisit only if measured latency hurts. --- crates/deckard-signerd/src/daemon.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index 76d2e73..76ab961 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -1166,8 +1166,13 @@ impl Daemon { // signing and the post-broadcast commit must not lose the accounting. Skip `value == 0` // (shields / shaped approves / contract calls move no ETH via `value`). A reserve-write // failure means we can't durably account this spend, so we fail CLOSED — deny rather than - // sign un-accounted funds. (The fsync lands under the daemon mutex; accepted for v1, like - // the broadcast below.) + // sign un-accounted funds. + // + // The reserve + commit fsyncs land under the daemon mutex that also serves STOP; accepted + // for v1 (anvil-instant, same posture as the broadcast below). + // TODO(#108 follow-up): if the STOP latency bites on a slow/contended disk, move these two + // fsyncs off the reactor via `tokio::task::spawn_blocking` instead of widening the brake's + // critical section. Deliberately NOT an issue yet — revisit only if measured latency hurts. let reserve_value = value; if !reserve_value.is_zero() { if let Err(e) = self.spend.reserve(reserve_value) {