Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/deckard-contract/src/deny_reasons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions crates/deckard-contract/tests/deny_vocabulary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
);

Expand Down
9 changes: 9 additions & 0 deletions crates/deckard-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -60,6 +64,11 @@ pub fn policy_path() -> Option<PathBuf> {
Some(config_dir()?.join(POLICY_FILE))
}

/// The durable daily-spend counter path (`<config_dir>/spend.json`).
pub fn spend_path() -> Option<PathBuf> {
Some(config_dir()?.join(SPEND_FILE))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
69 changes: 41 additions & 28 deletions crates/deckard-core/src/keystore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<stem>.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]
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion crates/deckard-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
6 changes: 6 additions & 0 deletions crates/deckard-signerd/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
105 changes: 91 additions & 14 deletions crates/deckard-signerd/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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<RequestId, PendingReq>,
Expand All @@ -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,
Expand All @@ -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).
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1135,6 +1162,28 @@ 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 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) {
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(
Expand All @@ -1148,22 +1197,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 }
}

Expand Down Expand Up @@ -1393,12 +1469,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();
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/deckard-signerd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading