signerd: durable daily-cap — reserve-before-sign + forward-only rollover + crash-budget (#108) - #125
Merged
Conversation
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.
…ver + crash-budget (#108) 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes the signer daemon's daily spend cap durable (issue #108, reviewed + re-scoped to "Option A" via
/autoplan). The cap (spent_today_wei) was in-memory and force-zeroed on every daemon 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. This persists the counter and reserves the spend before signing: a newspend.json({chain_id, account, day, committed_wei, reserved_wei}) written with the same atomic recipe as the vault, recovered into the policy on boot. A crash between reserve and commit is counted as spent on reboot (conservative; recovery only ever tightens the cap). The UTC-day rollover is now forward-only (a backward wall-clock can't reset the window), and a non-resetting supervisor crash-budget stops respawning a crash-looping daemon instead of hot-looping at the 200ms backoff floor.Scope was deliberately held: no nonce-keyed RPC reconciliation (that restructures the frozen broadcast path to defend a sub-second window the file-delete attacker bypasses anyway), and the security value sequences with #72 (the cap values still live in an unauthenticated
policy.json). See the issue and its/autoplanreview comment for the full reasoning.Key changes: extract
deckard_core::atomic_write(path, &[u8])fromVault::write_atomic(reused for the counter); newdeckard-signerd/src/spend_store.rs; reserve/commit/release wired intoDaemon::execute(clean RPC rejection releases the reservation, a timeout keeps it counted); forward-onlyrollover; account binding at unlock; supervisor crash-budget; newreserve_faileddeny tag.Linked issue
Closes #108
Definition of Done
cargo fmt --all --checkis cleanjust checkis green — clippy-D warningson the default config,--features tray, and thedev-signerd-binarmcargo test --workspaceis green (incl. 11 newspend_storeunit tests, 2 supervisor crash-budget tests, and 2 anvil e2e durability tests)Cargo.toml/Cargo.lockuntouched — onlystd, and already-presentserde/serde_json/alloy-primitives/anyhow)Debug-printed (the counter holds only chain id, account address, day, and wei amounts — never key material)Evidence (paste command output here)
Notes for reviewers
/autoplancomment.spawn_blockingis a noted fast-follow if the latency bites.policy.jsonauthentication (Spike: make policy.json tamper-evident + define a secure edit/maintenance path (not in the vault) #72); the app-side UI surfacing ofDaemonSupervisor::is_crashed_out()(the flag is exposed; the banner is a follow-up so this PR doesn't touch the GPUI app).anvilisn't on PATH. The conservative crash-restart guarantee (orphaned reserve counts as spent) is proven at the store level (reserved_leftover_on_reload_counts_as_spent) rather than with a process-abort fault hook.