From 5c911b1f77016691095eabd07b8b1bd74d05f2c9 Mon Sep 17 00:00:00 2001 From: hellno Date: Fri, 12 Jun 2026 17:25:39 +0200 Subject: [PATCH] feat(contract): freeze the Deny-reason vocabulary (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep `Decision::Deny { reason: String }` on the wire unchanged (the byte-stable serde/CBOR/JSON round-trip stays frozen) and freeze the *vocabulary* in code instead. - deny_reasons module in deckard-contract: 30 documented static tag consts + 4 typed dynamic-prefix builders (railgun_keys/signer_error/ sign_failed/broadcast_failed -> "prefix: detail"). No open with_detail(prefix, ..) — an arbitrary string can't become a reason. - Migrate every Decision::Deny / *::Denied / reply_error construction in deckard-contract, deckard-signerd, deckard-mcp to the consts; the mcp failure-catalog match arms + sidecar guards use const patterns so a rename is a compile error. - tests/deny_vocabulary.rs: a dependency-free, structure-aware source scan (mask string/comment interiors, blank #[cfg(test)] blocks, then brace-aware require every Deny reason to route through deny_reasons::) + a reflective module<->snapshot cross-check so a new const/pub fn can't slip past. Fails under `cargo test --workspace` if a Deny is built from a string outside the frozen set. - Per-tag remediation table extended in docs/build/31-agent-quickstart.md; from_deny_reason gains matching arms for the prefix tags + malformed_request. unsupported_v1 is kept (reachable via Unshield/ContractCall). No wire-shape change, no new dependencies. --- crates/deckard-contract/src/deny_reasons.rs | 161 ++++++ crates/deckard-contract/src/lib.rs | 1 + crates/deckard-contract/src/mock.rs | 85 +-- crates/deckard-contract/src/policy.rs | 37 +- .../deckard-contract/tests/deny_vocabulary.rs | 539 ++++++++++++++++++ crates/deckard-mcp/src/failure.rs | 63 +- crates/deckard-mcp/src/sidecar.rs | 14 +- crates/deckard-signerd/src/daemon.rs | 106 ++-- crates/deckard-signerd/src/server.rs | 6 +- docs/build/31-agent-quickstart.md | 16 + 10 files changed, 891 insertions(+), 137 deletions(-) create mode 100644 crates/deckard-contract/src/deny_reasons.rs create mode 100644 crates/deckard-contract/tests/deny_vocabulary.rs diff --git a/crates/deckard-contract/src/deny_reasons.rs b/crates/deckard-contract/src/deny_reasons.rs new file mode 100644 index 0000000..6d55008 --- /dev/null +++ b/crates/deckard-contract/src/deny_reasons.rs @@ -0,0 +1,161 @@ +//! # Deny-reason vocabulary — the frozen tag set +//! +//! The single source of truth for every machine-readable tag that fills a +//! `Decision::Deny { reason }`, an [`ExecuteResult::Denied`](crate::ExecuteResult), +//! a [`SignOrderResult::Denied`](crate::SignOrderResult), or a wire-level `reply_error` +//! across `deckard-contract`, `deckard-signerd`, and `deckard-mcp`. +//! +//! ## Why a consts module, not an enum +//! +//! The wire shape stays `reason: String` — the byte-stable serde round-trip of +//! [`Decision`](crate::Decision) is frozen and worth more than enum exhaustiveness. So we +//! freeze the *vocabulary* in code instead: every production construction site references a +//! const here, and `tests/deny_vocabulary.rs` fails the build if a `Deny`/`Denied`/ +//! `reply_error` site is constructed from a raw string literal. +//! +//! ## Adding a tag is deliberate +//! +//! Minting a new refusal means two edits, on purpose: +//! 1. a `pub const` here with a doc comment (meaning + when it fires), and +//! 2. a row in `docs/build/31-agent-quickstart.md` (the agent-facing remediation table). +//! +//! Remediation guidance lives docs-side, never on the wire. An agent retrying against a +//! *stable* vocabulary can recover; one flailing against typos and synonyms cannot. +//! +//! ## Dynamic-prefix tags +//! +//! Four reasons carry a redacted one-line detail: `": "`. Each has a +//! dedicated builder ([`railgun_keys`], [`signer_error`], [`sign_failed`], +//! [`broadcast_failed`]) — there is deliberately no open `with_detail(prefix, …)`, so an +//! arbitrary string can never become a reason prefix. Consumers match on the prefix const +//! (e.g. `reason.starts_with(BROADCAST_FAILED)`), never the full string. + +// ───────────────────────── Policy gate ───────────────────────── +// Minted by the pure decision functions in `policy.rs` (`evaluate`, `evaluate_order`); both +// `MockSigner` and the daemon route through them, so these are the parity-shared verdicts. + +/// STOP / `revoke_all` is engaged: the panic brake denied this request. Fires whenever +/// `policy.revoked` is set — at propose and at the execute/sign TOCTOU re-check. +pub const REVOKED: &str = "revoked"; +/// The recipient is not in the (non-empty) `allow_to` allowlist. +pub const OFF_ALLOWLIST: &str = "off_allowlist"; +/// The intent's calldata does not match its `IntentKind` (e.g. a Shield with empty calldata, +/// or a Send carrying calldata). +pub const UNDECODABLE: &str = "undecodable"; +/// Over a spending cap while `require_approval = Never` — no card exists to authorise it. +pub const OVER_CAP: &str = "over_cap"; +/// Swap order receiver is the zero address. +pub const RECEIVER_ZERO: &str = "receiver_zero"; +/// Swap order receiver is not the daemon's unlocked wallet (funds would leave the operator). +pub const RECEIVER_NOT_WALLET: &str = "receiver_not_wallet"; +/// Swap order sell amount is zero (a garbage order). +pub const ZERO_AMOUNT: &str = "zero_amount"; +/// Swap sell or buy token is not in the (non-empty) `allow_swap_tokens` list. +pub const OFF_SWAP_LIST: &str = "off_swap_list"; +/// Swap order `valid_to` is more than 24h in the future. +pub const VALID_TO_TOO_FAR: &str = "valid_to_too_far"; + +// ─────────────────────── Daemon process-level ─────────────────────── +// Process-state pre-checks the pure policy can't express (`deckard-signerd`); they run +// before / around `evaluate` and are NOT part of the MockSigner parity contract. + +/// The daemon holds no key (it starts locked; lock/STOP zeroize the key). The `from_deny_reason` +/// catalog also distinguishes the "no vault yet" case under this same tag. +pub const LOCKED: &str = "locked"; +/// The sidecar and the daemon disagree on the chain id (key-less pre-check, conclusive even +/// while locked). +pub const CHAIN_MISMATCH: &str = "chain_mismatch"; +/// `IntentKind` unsupported in v0.1 (Unshield, or a non-shaped ContractCall). Reachable: an +/// Unshield or arbitrary ContractCall hits this (`daemon_e2e` asserts it for Unshield). +pub const UNSUPPORTED_V1: &str = "unsupported_v1"; +/// An ERC-20 (`token = Some`) Send — v0.1 signs native-ETH sends only. +pub const ERC20_UNSUPPORTED_V1: &str = "erc20_unsupported_v1"; +/// A Shield intent that does not target the chain's Railgun RelayAdapt contract. +pub const SHIELD_TO_MISMATCH: &str = "shield_to_mismatch"; +/// Sign-time caps re-check failed for an auto-allowed request (the spend TOCTOU guard: two +/// within-cap proposals can't both broadcast past the daily cap). +pub const CAP_EXCEEDED: &str = "cap_exceeded"; +/// The request is `Pending` — it needs a human approval that hasn't happened yet. +pub const NOT_APPROVED: &str = "not_approved"; +/// A human answered the approval card with Deny. +pub const USER_DENIED: &str = "user_denied"; +/// The request outlived its approval TTL before it was executed. +pub const EXPIRED: &str = "expired"; +/// No request is stored under this id (a re-unlock or daemon restart starts a clean session). +pub const UNKNOWN_REQUEST: &str = "unknown_request"; +/// This exact request was already broadcast (ids are deterministic per intent; do not retry). +pub const ALREADY_EXECUTED: &str = "already_executed"; +/// The RPC did not answer within the broadcast window — transaction status UNKNOWN, do not retry. +pub const BROADCAST_TIMEOUT: &str = "broadcast_timeout"; +/// The request frame could not be decoded at all (wire-level `reply_error`). +pub const MALFORMED_REQUEST: &str = "malformed_request"; +/// The Railgun derivation known-answer test failed, so a view grant is refused. Only on the +/// `#[cfg(feature = "shield")]` path. +pub const DERIVATION_UNVERIFIED: &str = "derivation_unverified"; +/// Built without the `shield` feature, so there is no Railgun derivation to grant. Only on the +/// `#[cfg(not(feature = "shield"))]` path. +pub const SHIELD_UNAVAILABLE: &str = "shield_unavailable"; + +// ───────────────────────── Swap v1 (CoW) ───────────────────────── +// Shaped-approve admission + order sign/cancel guards in the daemon, and the swap mock. + +/// A swap `approve` carrying a non-zero ETH value (would move ETH invisibly). +pub const APPROVE_WITH_VALUE: &str = "approve_with_value"; +/// A swap `approve` whose spender is not the GPv2 vault relayer. +pub const APPROVE_WRONG_SPENDER: &str = "approve_wrong_spender"; +/// A swap `approve` with no stored order matching its (sell_token, sell_amount). +pub const APPROVE_NO_MATCHING_ORDER: &str = "approve_no_matching_order"; +/// The stored order was already signed (idempotency guard against double-signing). +pub const ALREADY_SIGNED: &str = "already_signed"; +/// The request id refers to a Tx payload where an Order was required (or vice versa). +pub const NOT_AN_ORDER: &str = "not_an_order"; +/// The `MockSigner` used by the MCP test harness does not implement swaps. **Test surface +/// only** — never minted by a real daemon, so it is intentionally absent from the agent docs +/// table. +pub const SWAP_UNSUPPORTED_IN_MOCK: &str = "swap_unsupported_in_mock"; + +// ─────────────────── Dynamic-prefix reasons ─────────────────── +// Rendered as `": "` where `` is an already-redacted one-line error. +// Each prefix has a dedicated builder below; consumers match the prefix const, never the +// whole string. There is intentionally no `with_detail(prefix, …)` taking an arbitrary +// prefix — that would reopen the free-form hole this module exists to close. + +/// Railgun key derivation/grant error (shield feature). Built by [`railgun_keys`]. +pub const RAILGUN_KEYS: &str = "railgun_keys"; +/// The daemon could not obtain an account signer for the unlocked wallet. Built by [`signer_error`]. +pub const SIGNER_ERROR: &str = "signer_error"; +/// Offline EIP-712 order-digest signing failed. Built by [`sign_failed`]. +pub const SIGN_FAILED: &str = "sign_failed"; +/// The RPC refused the broadcast (nothing was consumed). Built by [`broadcast_failed`]. +pub const BROADCAST_FAILED: &str = "broadcast_failed"; + +/// The `": "` separator that joins a prefix to its detail — single-sourced so every +/// dynamic-prefix reason renders byte-identically and consumer `starts_with(PREFIX)` checks +/// keep matching. +fn prefixed(prefix: &str, detail: impl core::fmt::Display) -> String { + format!("{prefix}: {detail}") +} + +/// `"railgun_keys: "` — see [`RAILGUN_KEYS`]. +#[must_use] +pub fn railgun_keys(detail: impl core::fmt::Display) -> String { + prefixed(RAILGUN_KEYS, detail) +} + +/// `"signer_error: "` — see [`SIGNER_ERROR`]. +#[must_use] +pub fn signer_error(detail: impl core::fmt::Display) -> String { + prefixed(SIGNER_ERROR, detail) +} + +/// `"sign_failed: "` — see [`SIGN_FAILED`]. +#[must_use] +pub fn sign_failed(detail: impl core::fmt::Display) -> String { + prefixed(SIGN_FAILED, detail) +} + +/// `"broadcast_failed: "` — see [`BROADCAST_FAILED`]. +#[must_use] +pub fn broadcast_failed(detail: impl core::fmt::Display) -> String { + prefixed(BROADCAST_FAILED, detail) +} diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index d6a4517..41f04d9 100644 --- a/crates/deckard-contract/src/lib.rs +++ b/crates/deckard-contract/src/lib.rs @@ -23,6 +23,7 @@ //! as a float and rejected on decode. CBOR has no such limit. pub mod decision; +pub mod deny_reasons; pub mod intent; pub mod mock; pub mod policy; diff --git a/crates/deckard-contract/src/mock.rs b/crates/deckard-contract/src/mock.rs index 98c7c9d..a718d11 100644 --- a/crates/deckard-contract/src/mock.rs +++ b/crates/deckard-contract/src/mock.rs @@ -12,6 +12,7 @@ use std::sync::Mutex; use alloy_primitives::{Address, Bytes, B256, U256}; use crate::decision::{Decision, RequestId}; +use crate::deny_reasons; use crate::intent::Intent; use crate::policy::{self, Policy}; use crate::read_status::ReadStatus; @@ -189,7 +190,7 @@ impl Signer for MockSigner { ApprovalStatus::Allowed } else { ApprovalStatus::Denied { - reason: "user_denied".into(), + reason: deny_reasons::USER_DENIED.into(), } }; } @@ -261,7 +262,7 @@ impl Signer for MockSigner { let req = match reqs.by_id.get_mut(&request_id) { None => { return ExecuteResult::Denied { - reason: "unknown_request".into(), + reason: deny_reasons::UNKNOWN_REQUEST.into(), } } Some(req) => req, @@ -270,7 +271,7 @@ impl Signer for MockSigner { // Idempotency: a broadcast id never signs twice. if req.broadcast.is_some() { return ExecuteResult::Denied { - reason: "already_executed".into(), + reason: deny_reasons::ALREADY_EXECUTED.into(), }; } @@ -278,7 +279,7 @@ impl Signer for MockSigner { // revoke_all must still be denied here. if policy.revoked { return ExecuteResult::Denied { - reason: "revoked".into(), + reason: deny_reasons::REVOKED.into(), }; } @@ -288,7 +289,7 @@ impl Signer for MockSigner { ReqPayload::Tx(intent) => intent.value, ReqPayload::Order(_) => { return ExecuteResult::Denied { - reason: "not_an_order".into(), + reason: deny_reasons::NOT_AN_ORDER.into(), } } }; @@ -302,11 +303,11 @@ impl Signer for MockSigner { ExecuteResult::Broadcast { tx_hash: tx } } ApprovalStatus::Pending => ExecuteResult::Denied { - reason: "not_approved".into(), + reason: deny_reasons::NOT_APPROVED.into(), }, ApprovalStatus::Denied { reason } => ExecuteResult::Denied { reason }, ApprovalStatus::Expired => ExecuteResult::Denied { - reason: "expired".into(), + reason: deny_reasons::EXPIRED.into(), }, } } @@ -316,7 +317,7 @@ impl Signer for MockSigner { match reqs.by_id.get(&request_id) { Some(req) => req.status.clone(), None => ApprovalStatus::Denied { - reason: "unknown_request".into(), + reason: deny_reasons::UNKNOWN_REQUEST.into(), }, } } @@ -372,7 +373,7 @@ impl Signer for MockSigner { let req = match reqs.by_id.get_mut(&request_id) { None => { return SignOrderResult::Denied { - reason: "unknown_request".into(), + reason: deny_reasons::UNKNOWN_REQUEST.into(), } } Some(req) => req, @@ -383,7 +384,7 @@ impl Signer for MockSigner { ReqPayload::Order(_) => {} ReqPayload::Tx(_) => { return SignOrderResult::Denied { - reason: "not_an_order".into(), + reason: deny_reasons::NOT_AN_ORDER.into(), } } } @@ -393,7 +394,7 @@ impl Signer for MockSigner { // a still-Pending order to Denied{revoked}). if policy.revoked { return SignOrderResult::Denied { - reason: "revoked".into(), + reason: deny_reasons::REVOKED.into(), }; } @@ -405,11 +406,11 @@ impl Signer for MockSigner { SignOrderResult::Signed { signature: sig } } ApprovalStatus::Pending => SignOrderResult::Denied { - reason: "not_approved".into(), + reason: deny_reasons::NOT_APPROVED.into(), }, ApprovalStatus::Denied { reason } => SignOrderResult::Denied { reason }, ApprovalStatus::Expired => SignOrderResult::Denied { - reason: "expired".into(), + reason: deny_reasons::EXPIRED.into(), }, } } @@ -422,7 +423,7 @@ impl Signer for MockSigner { let req = match reqs.by_id.get_mut(&request_id) { None => { return ExecuteResult::Denied { - reason: "unknown_request".into(), + reason: deny_reasons::UNKNOWN_REQUEST.into(), } } Some(req) => req, @@ -433,7 +434,7 @@ impl Signer for MockSigner { ReqPayload::Order(_) => {} ReqPayload::Tx(_) => { return ExecuteResult::Denied { - reason: "not_an_order".into(), + reason: deny_reasons::NOT_AN_ORDER.into(), } } } @@ -441,7 +442,7 @@ impl Signer for MockSigner { // Idempotency: a cancelled order never broadcasts a second cancel. if req.broadcast.is_some() { return ExecuteResult::Denied { - reason: "already_executed".into(), + reason: deny_reasons::ALREADY_EXECUTED.into(), }; } @@ -458,11 +459,11 @@ impl Signer for MockSigner { match req.status.clone() { ApprovalStatus::Denied { reason } => ExecuteResult::Denied { reason }, ApprovalStatus::Expired => ExecuteResult::Denied { - reason: "expired".into(), + reason: deny_reasons::EXPIRED.into(), }, // Pending (and the already-handled Allowed) fall here as "nothing to cancel yet". ApprovalStatus::Pending | ApprovalStatus::Allowed => ExecuteResult::Denied { - reason: "not_approved".into(), + reason: deny_reasons::NOT_APPROVED.into(), }, } } @@ -473,7 +474,7 @@ 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(), + reason: deny_reasons::REVOKED.into(), }; } } @@ -569,7 +570,7 @@ mod tests { assert_eq!( s.propose(&send(20)), Decision::Deny { - reason: "off_allowlist".into() + reason: deny_reasons::OFF_ALLOWLIST.into() } ); } @@ -590,7 +591,7 @@ mod tests { assert_eq!( s.propose(&send(20)), Decision::Deny { - reason: "revoked".into() + reason: deny_reasons::REVOKED.into() } ); } @@ -604,7 +605,7 @@ mod tests { assert_eq!( s.propose(&bad_send), Decision::Deny { - reason: "undecodable".into() + reason: deny_reasons::UNDECODABLE.into() } ); // A ContractCall must have non-empty calldata. @@ -616,7 +617,7 @@ mod tests { assert_eq!( s.propose(&empty_call), Decision::Deny { - reason: "undecodable".into() + reason: deny_reasons::UNDECODABLE.into() } ); // A Shield with EMPTY calldata is rejected: without the RelayAdapt call it would @@ -628,7 +629,7 @@ mod tests { assert_eq!( s.propose(&empty_shield), Decision::Deny { - reason: "undecodable".into() + reason: deny_reasons::UNDECODABLE.into() } ); } @@ -639,7 +640,7 @@ mod tests { assert_eq!( s.propose(&send(60)), Decision::Deny { - reason: "over_cap".into() + reason: deny_reasons::OVER_CAP.into() } ); } @@ -660,7 +661,7 @@ mod tests { assert_eq!( s.execute(id), ExecuteResult::Denied { - reason: "not_approved".into() + reason: deny_reasons::NOT_APPROVED.into() } ); } @@ -688,7 +689,7 @@ mod tests { assert_eq!( s.execute(id), ExecuteResult::Denied { - reason: "revoked".into() + reason: deny_reasons::REVOKED.into() } ); // and nothing was spent @@ -701,7 +702,7 @@ mod tests { assert_eq!( s.execute(B256::repeat_byte(0xFF)), ExecuteResult::Denied { - reason: "unknown_request".into() + reason: deny_reasons::UNKNOWN_REQUEST.into() } ); } @@ -716,7 +717,7 @@ mod tests { assert_eq!( s.execute(id), ExecuteResult::Denied { - reason: "already_executed".into() + reason: deny_reasons::ALREADY_EXECUTED.into() } ); // spent incremented exactly once @@ -738,7 +739,7 @@ mod tests { assert_eq!( s.status(id), ApprovalStatus::Denied { - reason: "revoked".into() + reason: deny_reasons::REVOKED.into() } ); assert!(s.policy().revoked); @@ -813,7 +814,7 @@ mod tests { assert_eq!( s.execute(id), ExecuteResult::Denied { - reason: "revoked".into() + reason: deny_reasons::REVOKED.into() } ); assert_eq!(s.policy().spent_today_wei, U256::ZERO); @@ -889,7 +890,7 @@ mod tests { assert_eq!( s.sign_order(id), SignOrderResult::Denied { - reason: "not_approved".into() + reason: deny_reasons::NOT_APPROVED.into() } ); s.approve(id); @@ -909,7 +910,7 @@ mod tests { assert_eq!( s.sign_order(B256::repeat_byte(0xFF)), SignOrderResult::Denied { - reason: "unknown_request".into() + reason: deny_reasons::UNKNOWN_REQUEST.into() } ); } @@ -923,7 +924,7 @@ mod tests { assert_eq!( s.sign_order(id), SignOrderResult::Denied { - reason: "not_an_order".into() + reason: deny_reasons::NOT_AN_ORDER.into() } ); } @@ -938,7 +939,7 @@ mod tests { assert_eq!( s.sign_order(id), SignOrderResult::Denied { - reason: "revoked".into() + reason: deny_reasons::REVOKED.into() } ); } @@ -954,7 +955,7 @@ mod tests { assert_eq!( s.propose_order(&bad), Decision::Deny { - reason: "receiver_not_wallet".into() + reason: deny_reasons::RECEIVER_NOT_WALLET.into() } ); assert_eq!(s.last_request_id(), None); @@ -969,7 +970,7 @@ mod tests { assert_eq!( s.propose_order(&order()), Decision::Deny { - reason: "off_swap_list".into() + reason: deny_reasons::OFF_SWAP_LIST.into() } ); } @@ -988,7 +989,7 @@ mod tests { assert_eq!( s2.propose_order(&order()), Decision::Deny { - reason: "valid_to_too_far".into() + reason: deny_reasons::VALID_TO_TOO_FAR.into() } ); } @@ -1009,7 +1010,7 @@ mod tests { assert_eq!( s.cancel_order(id), ExecuteResult::Denied { - reason: "already_executed".into() + reason: deny_reasons::ALREADY_EXECUTED.into() } ); } @@ -1022,7 +1023,7 @@ mod tests { assert_eq!( s.cancel_order(id), ExecuteResult::Denied { - reason: "not_approved".into() + reason: deny_reasons::NOT_APPROVED.into() } ); } @@ -1035,7 +1036,7 @@ mod tests { assert_eq!( s.cancel_order(id), ExecuteResult::Denied { - reason: "not_an_order".into() + reason: deny_reasons::NOT_AN_ORDER.into() } ); } @@ -1050,7 +1051,7 @@ mod tests { assert_eq!( s.status(id), ApprovalStatus::Denied { - reason: "revoked".into() + reason: deny_reasons::REVOKED.into() } ); } diff --git a/crates/deckard-contract/src/policy.rs b/crates/deckard-contract/src/policy.rs index af18a18..0d50779 100644 --- a/crates/deckard-contract/src/policy.rs +++ b/crates/deckard-contract/src/policy.rs @@ -8,6 +8,7 @@ use alloy_primitives::{Address, U256}; use serde::{Deserialize, Serialize}; use crate::decision::{Decision, RequestId}; +use crate::deny_reasons; use crate::intent::{Intent, IntentKind}; use crate::swap_order::SwapOrder; @@ -64,19 +65,19 @@ pub fn evaluate(intent: &Intent, policy: &Policy) -> Decision { // 1. STOP / revoked overrides everything. if policy.revoked { return Decision::Deny { - reason: "revoked".into(), + reason: deny_reasons::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(), + reason: deny_reasons::OFF_ALLOWLIST.into(), }; } // 3. Calldata must be decodable for the kind. if !calldata_ok(intent) { return Decision::Deny { - reason: "undecodable".into(), + reason: deny_reasons::UNDECODABLE.into(), }; } // 4. Cap check: spent_today + value vs the per-tx and daily caps. @@ -88,7 +89,7 @@ pub fn evaluate(intent: &Intent, policy: &Policy) -> Decision { ApprovalMode::Never => { if over { Decision::Deny { - reason: "over_cap".into(), + reason: deny_reasons::OVER_CAP.into(), } } else { Decision::Allow @@ -115,17 +116,17 @@ pub fn evaluate(intent: &Intent, policy: &Policy) -> Decision { pub fn evaluate_order(order: &SwapOrder, policy: &Policy, wallet: Address, now: u64) -> Decision { if policy.revoked { return Decision::Deny { - reason: "revoked".into(), + reason: deny_reasons::REVOKED.into(), }; } if order.receiver == Address::ZERO { return Decision::Deny { - reason: "receiver_zero".into(), + reason: deny_reasons::RECEIVER_ZERO.into(), }; } if order.receiver != wallet { return Decision::Deny { - reason: "receiver_not_wallet".into(), + reason: deny_reasons::RECEIVER_NOT_WALLET.into(), }; } // A zero sell amount is a garbage order (nothing to sell) and would let the shaped-approve @@ -133,7 +134,7 @@ pub fn evaluate_order(order: &SwapOrder, policy: &Policy, wallet: Address, now: // valid: a max-slippage market sell is legitimate and the human sees it on the card.) if order.sell_amount.is_zero() { return Decision::Deny { - reason: "zero_amount".into(), + reason: deny_reasons::ZERO_AMOUNT.into(), }; } if !policy.allow_swap_tokens.is_empty() @@ -141,12 +142,12 @@ pub fn evaluate_order(order: &SwapOrder, policy: &Policy, wallet: Address, now: || !policy.allow_swap_tokens.contains(&order.buy_token)) { return Decision::Deny { - reason: "off_swap_list".into(), + reason: deny_reasons::OFF_SWAP_LIST.into(), }; } if order.valid_to as u64 > now.saturating_add(86_400) { return Decision::Deny { - reason: "valid_to_too_far".into(), + reason: deny_reasons::VALID_TO_TOO_FAR.into(), }; } Decision::NeedsApproval { @@ -228,7 +229,7 @@ mod evaluate_order_tests { assert_eq!( evaluate_order(&base_order(), &p, wallet(), NOW), Decision::Deny { - reason: "revoked".into() + reason: deny_reasons::REVOKED.into() } ); } @@ -242,7 +243,7 @@ mod evaluate_order_tests { assert_eq!( evaluate_order(&order, &base_policy(), wallet(), NOW), Decision::Deny { - reason: "receiver_zero".into() + reason: deny_reasons::RECEIVER_ZERO.into() } ); } @@ -256,7 +257,7 @@ mod evaluate_order_tests { assert_eq!( evaluate_order(&order, &base_policy(), wallet(), NOW), Decision::Deny { - reason: "receiver_not_wallet".into() + reason: deny_reasons::RECEIVER_NOT_WALLET.into() } ); } @@ -270,7 +271,7 @@ mod evaluate_order_tests { assert_eq!( evaluate_order(&order, &base_policy(), wallet(), NOW), Decision::Deny { - reason: "zero_amount".into() + reason: deny_reasons::ZERO_AMOUNT.into() } ); } @@ -292,7 +293,7 @@ mod evaluate_order_tests { assert_eq!( evaluate_order(&base_order(), &p, wallet(), NOW), Decision::Deny { - reason: "off_swap_list".into() + reason: deny_reasons::OFF_SWAP_LIST.into() } ); } @@ -305,7 +306,7 @@ mod evaluate_order_tests { assert_eq!( evaluate_order(&base_order(), &p, wallet(), NOW), Decision::Deny { - reason: "off_swap_list".into() + reason: deny_reasons::OFF_SWAP_LIST.into() } ); } @@ -317,7 +318,7 @@ mod evaluate_order_tests { assert_eq!( evaluate_order(&base_order(), &p, wallet(), NOW), Decision::Deny { - reason: "off_swap_list".into() + reason: deny_reasons::OFF_SWAP_LIST.into() } ); } @@ -355,7 +356,7 @@ mod evaluate_order_tests { assert_eq!( evaluate_order(&order, &base_policy(), wallet(), NOW), Decision::Deny { - reason: "valid_to_too_far".into() + reason: deny_reasons::VALID_TO_TOO_FAR.into() } ); } diff --git a/crates/deckard-contract/tests/deny_vocabulary.rs b/crates/deckard-contract/tests/deny_vocabulary.rs new file mode 100644 index 0000000..38fb124 --- /dev/null +++ b/crates/deckard-contract/tests/deny_vocabulary.rs @@ -0,0 +1,539 @@ +//! Freezes the Deny-reason vocabulary (issue #28). Three mutually-reinforcing guards, all +//! running under plain `cargo test --workspace` with no extra dependencies: +//! +//! 1. [`every_deny_reason_routes_through_the_frozen_vocabulary`] — a structure-aware scan of +//! the production sources of `deckard-contract`, `deckard-signerd`, and `deckard-mcp`. For +//! every `Decision::Deny` / `ExecuteResult::Denied` / `SignOrderResult::Denied` / +//! `ApprovalStatus::Denied` construction and every `reply_error(..)` call, the `reason` +//! value MUST be a [`deny_reasons`](deckard_contract::deny_reasons) const / builder, or a +//! passthrough of an already-frozen stored reason. A raw literal, a foreign variable, a +//! foreign const, or a free-form prefix all fail. It is precise: `ReadStatus`/ +//! `ShieldStatus` reasons (a different, free-form vocabulary) are never inspected. +//! +//! 2. [`frozen_set_matches_module_exports`] — parses `deny_reasons.rs` and asserts its +//! `pub const … : &str` set is exactly the hand-listed [`FROZEN`] snapshot, and that the +//! module exports exactly the four dynamic-prefix builders — so neither a new const nor a +//! new `pub fn` can be added to the trusted module and slip past the guards. +//! +//! 3. [`frozen_set_is_exactly_documented`] — pins the snapshot's size, uniqueness, shape, +//! and the dynamic-prefix separator. Bumping it is the deliberate gate that should also +//! add the tag's row to `docs/build/31-agent-quickstart.md`. +//! +//! ## How the scan stays honest +//! +//! The source is first *masked*: the interior of every string literal and every line comment +//! is blanked to spaces (length and `\n` preserved). Brace/paren/comma counting then can't be +//! fooled by `{`, `,`, or `//` that live inside a string or a comment, and a raw literal at a +//! Deny site still shows its `"` delimiters (so it's caught). Then every `#[cfg(test)]` block +//! is blanked in place, so in-file test modules (which legitimately assert against literals) +//! are skipped while production code *after* a test module is still scanned. + +use std::fs; +use std::path::{Path, PathBuf}; + +use deckard_contract::deny_reasons as r; + +/// `(crate dir relative to this crate's manifest, subdir to scan)`. +const SCAN: &[(&str, &str)] = &[ + (".", "src"), // deckard-contract (this crate) + ("../deckard-signerd", "src"), + ("../deckard-mcp", "src"), +]; + +/// The struct-literal heads that mint a Deny reason. Their match-PATTERN forms use shorthand +/// (`{ reason }`, `{ .. }`) with no `reason:` field and are skipped automatically. +const DENY_MARKERS: &[&str] = &[ + "Decision::Deny", + "ExecuteResult::Denied", + "SignOrderResult::Denied", + "ApprovalStatus::Denied", +]; + +/// The four dynamic-prefix builders `deny_reasons` is allowed to export (besides the consts). +const PREFIX_BUILDERS: &[&str] = &[ + "railgun_keys", + "signer_error", + "sign_failed", + "broadcast_failed", +]; + +/// The complete frozen vocabulary: 30 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. +const FROZEN: &[&str] = &[ + // policy gate + r::REVOKED, + r::OFF_ALLOWLIST, + r::UNDECODABLE, + r::OVER_CAP, + r::RECEIVER_ZERO, + r::RECEIVER_NOT_WALLET, + r::ZERO_AMOUNT, + r::OFF_SWAP_LIST, + r::VALID_TO_TOO_FAR, + // daemon process-level + r::LOCKED, + r::CHAIN_MISMATCH, + r::UNSUPPORTED_V1, + r::ERC20_UNSUPPORTED_V1, + r::SHIELD_TO_MISMATCH, + r::CAP_EXCEEDED, + r::NOT_APPROVED, + r::USER_DENIED, + r::EXPIRED, + r::UNKNOWN_REQUEST, + r::ALREADY_EXECUTED, + r::BROADCAST_TIMEOUT, + r::MALFORMED_REQUEST, + r::DERIVATION_UNVERIFIED, + r::SHIELD_UNAVAILABLE, + // swap v1 + r::APPROVE_WITH_VALUE, + r::APPROVE_WRONG_SPENDER, + r::APPROVE_NO_MATCHING_ORDER, + r::ALREADY_SIGNED, + r::NOT_AN_ORDER, + r::SWAP_UNSUPPORTED_IN_MOCK, + // dynamic prefixes + r::RAILGUN_KEYS, + r::SIGNER_ERROR, + r::SIGN_FAILED, + r::BROADCAST_FAILED, +]; + +// ───────────────────────── masking ───────────────────────── + +/// Blank the interior of every `"…"` string literal and every `// …` line comment to spaces, +/// preserving byte length and newlines. (The scanned crates contain no raw strings, block +/// comments, or `'"'`/`'{'`-style char literals — verified — so a normal-string + line-comment +/// masker is exact here.) +fn mask(src: &str) -> String { + let b = src.as_bytes(); + let n = b.len(); + let mut out = b.to_vec(); + let mut i = 0; + let mut in_str = false; + let mut in_line = false; + while i < n { + let c = b[i]; + if in_line { + if c == b'\n' { + in_line = false; + } else { + out[i] = b' '; + } + i += 1; + } else if in_str { + if c == b'\\' { + out[i] = b' '; + if i + 1 < n && b[i + 1] != b'\n' { + out[i + 1] = b' '; + } + i += 2; + } else if c == b'"' { + in_str = false; // keep the closing quote + i += 1; + } else { + if c != b'\n' { + out[i] = b' '; + } + i += 1; + } + } else if c == b'"' { + in_str = true; // keep the opening quote + i += 1; + } else if c == b'/' && i + 1 < n && b[i + 1] == b'/' { + in_line = true; + out[i] = b' '; + i += 1; + } else { + i += 1; + } + } + String::from_utf8(out).expect("masking preserves UTF-8 (only ASCII bytes blanked)") +} + +/// Blank every `#[cfg(test)]`-attributed block in place (spaces, newlines kept) so in-file +/// test modules are skipped while production code before AND after them is still scanned. +/// Runs on already-masked text, so the brace balance can't be thrown off by string braces. +fn blank_cfg_test(masked: &str) -> String { + let mut bytes = masked.as_bytes().to_vec(); + let mut from = 0usize; + while let Some(rel) = masked[from..].find("#[cfg(test)]") { + let attr = from + rel; + let Some(brace_rel) = masked[attr..].find('{') else { + blank_span(&mut bytes, attr, masked.len()); + break; + }; + let brace = attr + brace_rel; + let body = balanced(masked, brace, b'{', b'}'); + let end = (brace + 1 + body.len() + 1).min(masked.len()); + blank_span(&mut bytes, attr, end); + from = end; + } + String::from_utf8(bytes).expect("blanking preserves UTF-8") +} + +fn blank_span(bytes: &mut [u8], start: usize, end: usize) { + let end = end.min(bytes.len()); + for b in &mut bytes[start..end] { + if *b != b'\n' { + *b = b' '; + } + } +} + +// ───────────────────────── structure helpers ───────────────────────── + +/// Given the byte index of an opening delimiter, return the slice strictly inside the matching +/// close (balanced). "" if unbalanced. +fn balanced(text: &str, start: usize, open: u8, close: u8) -> &str { + let bytes = text.as_bytes(); + let mut depth = 0i32; + let mut i = start; + while i < bytes.len() { + let c = bytes[i]; + if c == open { + depth += 1; + } else if c == close { + depth -= 1; + if depth == 0 { + return &text[start + 1..i]; + } + } + i += 1; + } + "" +} + +/// Extract the `reason:` field value from a struct-literal body. `None` for a shorthand/pattern +/// body (`reason`, `..`) with no `reason:` field. +fn reason_value(body: &str) -> Option<&str> { + let key = body.find("reason:")?; + let after = &body[key + "reason:".len()..]; + let bytes = after.as_bytes(); + let (mut paren, mut brack, mut brace) = (0i32, 0i32, 0i32); + let mut end = after.len(); + for (i, &c) in bytes.iter().enumerate() { + match c { + b'(' => paren += 1, + b')' => paren -= 1, + b'[' => brack += 1, + b']' => brack -= 1, + b'{' => brace += 1, + b'}' => brace -= 1, + b',' if paren == 0 && brack == 0 && brace == 0 => { + end = i; + break; + } + _ => {} + } + } + Some(after[..end].trim()) +} + +/// Split a comma-separated argument list at top-level commas (paren/brace/bracket aware). +fn split_top_level(args: &str) -> Vec<&str> { + let bytes = args.as_bytes(); + let (mut paren, mut brack, mut brace) = (0i32, 0i32, 0i32); + let mut parts = Vec::new(); + let mut start = 0usize; + for (i, &c) in bytes.iter().enumerate() { + match c { + b'(' => paren += 1, + b')' => paren -= 1, + b'[' => brack += 1, + b']' => brack -= 1, + b'{' => brace += 1, + b'}' => brace -= 1, + b',' if paren == 0 && brack == 0 && brace == 0 => { + parts.push(args[start..i].trim()); + start = i + 1; + } + _ => {} + } + } + parts.push(args[start..].trim()); + parts +} + +/// Is `value` an allowed reason expression at a Deny construction site? +fn struct_reason_ok(value: &str) -> bool { + // A frozen const or a typed prefix builder, e.g. `deny_reasons::REVOKED.into()` or + // `deny_reasons::signer_error(one_line(&e))`. A raw literal would still carry its `"` + // delimiters even after masking, so it is rejected here. + if value.starts_with("deny_reasons::") && !value.contains('"') { + return true; + } + // Re-raising an already-frozen, stored reason: `reason`, `reason.clone()`, `reason.into()`. + value == "reason" || value.starts_with("reason.") +} + +/// Is the 2nd argument to `reply_error(stream, )` allowed? +fn reply_error_arg_ok(arg: &str) -> bool { + if arg.contains(": &str") { + return true; // the `reply_error` definition itself, not a call site + } + if arg.starts_with("deny_reasons::") && !arg.contains('"') { + return true; + } + // Exact passthroughs only — `&reason_code` (a foreign string) must NOT slip through. + arg == "reason" || arg == "&reason" || arg.starts_with("reason.") || arg.starts_with("&reason.") +} + +/// Char immediately after a marker must not continue an identifier. +fn boundary_ok(text: &str, end: usize) -> bool { + text.as_bytes() + .get(end) + .is_none_or(|&b| !(b.is_ascii_alphanumeric() || b == b'_')) +} + +fn line_of(text: &str, pos: usize) -> usize { + text[..pos].bytes().filter(|&b| b == b'\n').count() + 1 +} + +/// Scan one source string. `label` is used only in violation messages. +fn scan_source(label: &str, src: &str, violations: &mut Vec) { + let text = blank_cfg_test(&mask(src)); + + for marker in DENY_MARKERS { + for (idx, _) in text.match_indices(marker) { + let end = idx + marker.len(); + if !boundary_ok(&text, end) { + continue; + } + let rest = &text[end..]; + let trimmed = rest.trim_start(); + if !trimmed.starts_with('{') { + continue; + } + let brace_at = end + (rest.len() - trimmed.len()); + let body = balanced(&text, brace_at, b'{', b'}'); + if let Some(value) = reason_value(body) { + if !struct_reason_ok(value) { + violations.push(format!( + "{label}:{} {marker} {{ reason: {value} }}", + line_of(&text, idx) + )); + } + } + } + } + + for (idx, _) in text.match_indices("reply_error(") { + let paren_at = idx + "reply_error".len(); + let args = balanced(&text, paren_at, b'(', b')'); + if let Some(arg) = split_top_level(args).get(1) { + if !reply_error_arg_ok(arg) { + violations.push(format!( + "{label}:{} reply_error(.., {arg})", + line_of(&text, idx) + )); + } + } + } +} + +fn rs_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + rs_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} + +// ───────────────────────────── tests ───────────────────────────── + +#[test] +fn every_deny_reason_routes_through_the_frozen_vocabulary() { + let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut violations = Vec::new(); + for (rel, sub) in SCAN { + let dir = base.join(rel).join(sub); + assert!( + dir.is_dir(), + "scan target missing (workspace layout changed?): {}", + dir.display() + ); + let mut files = Vec::new(); + rs_files(&dir, &mut files); + for f in &files { + if let Ok(src) = fs::read_to_string(f) { + scan_source(&f.display().to_string(), &src, &mut violations); + } + } + } + assert!( + violations.is_empty(), + "every production Deny/Denied/reply_error reason must be a deckard_contract::deny_reasons \ + const or builder — never a raw literal, foreign variable, or free-form prefix. Add new \ + tags to that module + FROZEN + the docs table. Offenders:\n{}", + violations.join("\n") + ); +} + +#[test] +fn frozen_set_matches_module_exports() { + let module = + fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/deny_reasons.rs")) + .expect("read deny_reasons.rs"); + + let mut consts: Vec = Vec::new(); + let mut pub_fns: Vec = Vec::new(); + for line in module.lines() { + let t = line.trim_start(); + if let Some(rest) = t.strip_prefix("pub const ") { + // rest = `NAME: &str = "VALUE";` + if let Some(q1) = rest.find('"') { + if let Some(rel) = rest[q1 + 1..].find('"') { + consts.push(rest[q1 + 1..q1 + 1 + rel].to_string()); + } + } + } else if let Some(rest) = t.strip_prefix("pub fn ") { + if let Some(paren) = rest.find('(') { + pub_fns.push(rest[..paren].trim().to_string()); + } + } + } + + let mut consts_sorted = consts.clone(); + consts_sorted.sort(); + let mut frozen_sorted: Vec = FROZEN.iter().map(|s| (*s).to_string()).collect(); + frozen_sorted.sort(); + assert_eq!( + consts_sorted, frozen_sorted, + "deny_reasons.rs `pub const` tags and the FROZEN snapshot diverged — update both \ + (and the docs table for a real tag)" + ); + + let mut fns_sorted = pub_fns.clone(); + fns_sorted.sort(); + let mut expected_fns: Vec = PREFIX_BUILDERS.iter().map(|s| (*s).to_string()).collect(); + expected_fns.sort(); + assert_eq!( + fns_sorted, expected_fns, + "deny_reasons.rs must export exactly the four dynamic-prefix builders — a new `pub fn` \ + would be a reason source the scan trusts blindly; gate it deliberately" + ); +} + +#[test] +fn frozen_set_is_exactly_documented() { + assert_eq!( + FROZEN.len(), + 34, + "added/removed a Deny tag? update FROZEN, deny_reasons.rs, and the docs table" + ); + + let mut sorted: Vec<&str> = FROZEN.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + sorted.len(), + FROZEN.len(), + "duplicate tag string in the frozen set" + ); + + for tag in FROZEN { + assert!(!tag.is_empty(), "empty tag"); + assert!( + tag.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'), + "tag must be lowercase snake_case: {tag:?}" + ); + } + + // The dynamic-prefix builders pin the `": "` separator (consumers do `starts_with(PREFIX)`). + assert_eq!(r::signer_error("boom"), "signer_error: boom"); + assert_eq!( + r::broadcast_failed("connection refused"), + "broadcast_failed: connection refused" + ); + assert_eq!(r::railgun_keys("x"), "railgun_keys: x"); + assert_eq!(r::sign_failed("x"), "sign_failed: x"); +} + +// ── Self-tests: prove the scan catches each bypass class and accepts the legit forms. ── + +#[test] +fn scan_rejects_bypasses() { + let cases = [ + // raw literal (incl. one with `//` inside — the masker must not let it evade) + r#"fn f() { Decision::Deny { reason: "new_tag".into() } }"#, + r#"fn f() { Decision::Deny { reason: "http://evil".into() } }"#, + // foreign variable / const + r#"fn f() { let bad = x(); Decision::Deny { reason: bad.into() } }"#, + r#"fn f() { ExecuteResult::Denied { reason: format!("forged: {x}") } }"#, + // foreign const passed to reply_error, and a foreign &reason-prefixed var + r#"fn f() { reply_error(&mut s, BAD) }"#, + r#"fn f() { reply_error(&mut s, &reason_code) }"#, + ]; + for (i, src) in cases.iter().enumerate() { + let mut v = Vec::new(); + scan_source("case", src, &mut v); + assert_eq!( + v.len(), + 1, + "case {i} should flag exactly one violation, got {v:?}" + ); + } +} + +#[test] +fn scan_accepts_legit_and_skips_tests() { + let cases = [ + r#"fn f() { Decision::Deny { reason: deny_reasons::REVOKED.into() } }"#, + r#"fn f() { ExecuteResult::Denied { reason: deny_reasons::signer_error(one_line(&e)) } }"#, + r#"fn f() { Decision::Deny { reason: reason.clone() } }"#, + r#"fn f() { let _ = reply_error(&mut s, deny_reasons::MALFORMED_REQUEST); }"#, + // a Deny mentioned in a line comment must be ignored + "fn f() {} // Decision::Deny { reason: \"x\" }", + // a Deny pattern (match arm), not a construction + "fn f() { match d { Decision::Deny { reason } => g(reason) } }", + // a test-module literal is skipped, but production code AFTER it is still scanned + r#"#[cfg(test)] +mod tests { + fn t() { Decision::Deny { reason: "raw_in_test".into() } } +} +fn prod() { Decision::Deny { reason: deny_reasons::LOCKED.into() } }"#, + ]; + for (i, src) in cases.iter().enumerate() { + let mut v = Vec::new(); + scan_source("case", src, &mut v); + assert!(v.is_empty(), "case {i} should be clean, got {v:?}"); + } +} + +#[test] +fn mask_blanks_strings_and_comments() { + let m = mask("a // b"); + assert_eq!(m.len(), "a // b".len(), "masking preserves length"); + assert_eq!(&m[..2], "a "); + assert!( + m[2..].bytes().all(|b| b == b' '), + "the // comment is blanked: {m:?}" + ); + + // a `//` inside a string must NOT start a comment — code after the string survives + let m = mask(r#"let s = "x//y"; ok"#); + assert!( + m.contains("ok"), + "code after a //-bearing string must survive: {m:?}" + ); + assert!(m.contains('"'), "string delimiters are preserved: {m:?}"); + assert!( + !m.contains("//"), + "the // inside the string is blanked: {m:?}" + ); + + // braces inside a string are neutralised so brace-balancing stays correct + let m = mask(r#"f("{a}")"#); + assert_eq!(m, r#"f(" ")"#); +} diff --git a/crates/deckard-mcp/src/failure.rs b/crates/deckard-mcp/src/failure.rs index 9c3e813..2d7930d 100644 --- a/crates/deckard-mcp/src/failure.rs +++ b/crates/deckard-mcp/src/failure.rs @@ -5,6 +5,7 @@ use std::path::Path; +use deckard_contract::deny_reasons; use serde::Serialize; /// One catalog entry. Rendered as JSON for tool responses and as three lines for the CLI. @@ -61,7 +62,7 @@ pub fn socket_missing(socket_path: &Path) -> Failure { /// Map a daemon `Deny`/`Denied` reason tag to its catalog entry. `config_dir` (when known) /// lets `locked` distinguish the no-vault case from the merely-locked case. pub fn from_deny_reason(reason: &str, config_dir: Option<&Path>) -> Failure { - if reason.starts_with("broadcast_failed") { + if reason.starts_with(deny_reasons::BROADCAST_FAILED) { return Failure::new( format!("the transaction broadcast failed ({reason})"), "the daemon signed nothing or the RPC refused the transaction", @@ -69,8 +70,38 @@ pub fn from_deny_reason(reason: &str, config_dir: Option<&Path>) -> Failure { flow from deckard_shield — the request was not consumed by a failed broadcast", ); } + if reason.starts_with(deny_reasons::SIGNER_ERROR) { + return Failure::new( + format!("the daemon could not produce a signer for the wallet ({reason})"), + "the unlocked key could not be turned into a transaction signer — an internal \ + daemon error, not a policy refusal; nothing was signed", + "re-unlock the wallet in the Deckard app, then re-run the flow from \ + deckard_shield; if it recurs, restart the app", + ); + } + if reason.starts_with(deny_reasons::SIGN_FAILED) { + return Failure::new( + format!("signing the order digest failed ({reason})"), + "the offline EIP-712 signing step errored before anything was submitted", + "re-run the swap flow from the start; a recurring failure is a client/daemon bug", + ); + } + if reason.starts_with(deny_reasons::RAILGUN_KEYS) { + return Failure::new( + format!("a Railgun key operation failed ({reason})"), + "the shielded-key derivation or view grant errored", + "restart the Deckard app; if it recurs, the chain may be unsupported for shielding", + ); + } match reason { - "locked" => { + deny_reasons::MALFORMED_REQUEST => Failure::new( + "the daemon could not decode the request frame", + "the bytes the sidecar sent did not parse as a valid signer request — a \ + client/version mismatch, not a policy refusal", + "re-run the flow from deckard_shield; if it recurs, make sure the sidecar and \ + the Deckard app are the same version", + ), + deny_reasons::LOCKED => { let no_vault = config_dir .map(|d| !d.join(deckard_core::config::VAULT_FILE).exists()) .unwrap_or(false); @@ -90,88 +121,88 @@ pub fn from_deny_reason(reason: &str, config_dir: Option<&Path>) -> Failure { ) } } - "revoked" => Failure::new( + deny_reasons::REVOKED => Failure::new( "the signer is stopped (STOP / revoke_all is engaged)", "the panic brake zeroized the key and denied every in-flight request — \ including ones approved before the STOP", "this is irreversible for the session; a human must re-unlock the wallet in \ the Deckard app to re-arm, then re-run the flow from deckard_shield", ), - "expired" => Failure::new( + deny_reasons::EXPIRED => Failure::new( "this request expired before it was executed", "approvals have a TTL; a stale request_id can never be executed later", "re-run the flow from deckard_shield to get a fresh request_id", ), - "unknown_request" => Failure::new( + deny_reasons::UNKNOWN_REQUEST => Failure::new( "the daemon does not know this request_id", "the app re-unlocked (or the daemon restarted), which starts a clean session \ and clears all pending requests", "re-run the flow from deckard_shield — do not reuse old request_ids", ), - "already_executed" => Failure::new( + deny_reasons::ALREADY_EXECUTED => Failure::new( "this exact request was already broadcast", "request ids are deterministic per intent, so an identical re-shield in the \ same session maps to the already-broadcast request", "do NOT retry this request_id; to demo again, vary the amount (a different \ amount is a new request) or re-unlock in the app for a fresh session", ), - "broadcast_timeout" => Failure::new( + deny_reasons::BROADCAST_TIMEOUT => Failure::new( "the broadcast timed out — transaction status UNKNOWN", "the RPC did not answer within the daemon's broadcast window; the transaction \ MAY already be on-chain", "do NOT retry (a retry could double-spend); check the transaction in the \ Deckard app or with `just demo-check`, and only act once the status is known", ), - "not_approved" => Failure::new( + deny_reasons::NOT_APPROVED => Failure::new( "this request needs a human approval before it can execute", "the policy (or the mainnet guardrail) classified it NeedsApproval and no \ human has approved it yet", "a human must approve in the Deckard app; the approval UI is not in this \ alpha — lower the amount under the policy per-tx cap or edit policy.json", ), - "user_denied" => Failure::new( + deny_reasons::USER_DENIED => Failure::new( "a human denied this request", "the approval card was answered with Deny", "respect the denial; propose a different action only if the human asks for it", ), - "chain_mismatch" => Failure::new( + deny_reasons::CHAIN_MISMATCH => Failure::new( "the daemon signs for a different chain than this request targets", "this sidecar and the daemon disagree on the chain id (e.g. a demo sidecar \ talking to the real daemon, or vice versa)", "re-run `deckard-mcp install --demo` so Claude's config carries the demo \ socket + chain, and make sure `just demo` (not the everyday app) is running", ), - "over_cap" => Failure::new( + deny_reasons::OVER_CAP => Failure::new( "the amount is over the policy cap and the policy raises no approval card", "require_approval is Never, so an over-cap write has nothing to authorize it", "lower the amount under policy.per_tx_cap_wei (call deckard_policy_get to read \ it) or edit policy.json", ), - "cap_exceeded" => Failure::new( + deny_reasons::CAP_EXCEEDED => Failure::new( "executing this request would exceed the spending caps", "caps are re-checked at sign time against what was already spent today", "lower the amount or wait for the daily window to roll over (UTC midnight); \ call deckard_policy_get for the current numbers", ), - "off_allowlist" => Failure::new( + deny_reasons::OFF_ALLOWLIST => Failure::new( "the recipient is not on the policy allowlist", "the policy restricts recipients and this target is not listed", "use an allowed recipient, or a human must edit policy.json", ), - "undecodable" => Failure::new( + deny_reasons::UNDECODABLE => Failure::new( "the intent's calldata does not match its kind", "shape validation failed (e.g. a Shield without RelayAdapt calldata)", "this is a bug in the proposing client if it recurs — re-run the flow from \ deckard_shield", ), - "shield_to_mismatch" => Failure::new( + deny_reasons::SHIELD_TO_MISMATCH => Failure::new( "the shield does not target the Railgun RelayAdapt contract for this chain", "the daemon refuses Shield intents aimed anywhere else (or on chains it has \ no adapter table for)", "re-run the flow from deckard_shield (it builds the correct target); if it \ recurs, the chain is unsupported for shielding", ), - "erc20_unsupported_v1" | "unsupported_v1" => Failure::new( + deny_reasons::ERC20_UNSUPPORTED_V1 | deny_reasons::UNSUPPORTED_V1 => Failure::new( "that action is not supported in v0.1", "v0.1 supports native-ETH send and shield only", "stay with native-ETH deckard_shield / deckard_execute", diff --git a/crates/deckard-mcp/src/sidecar.rs b/crates/deckard-mcp/src/sidecar.rs index 7f0e1ea..9ec84e5 100644 --- a/crates/deckard-mcp/src/sidecar.rs +++ b/crates/deckard-mcp/src/sidecar.rs @@ -13,8 +13,8 @@ use serde_json::json; use zeroize::Zeroizing; use deckard_contract::{ - ApprovalMode, Decision, ExecuteResult, Intent, IntentKind, Policy, ReadStatus, SignerRequest, - SignerResponse, + deny_reasons, ApprovalMode, Decision, ExecuteResult, Intent, IntentKind, Policy, ReadStatus, + SignerRequest, SignerResponse, }; use deckard_signerd::SignerClient; @@ -116,13 +116,17 @@ impl Sidecar { .request(&SignerRequest::Propose { intent: probe }) .await? { - SignerResponse::Decision(Decision::Deny { reason }) if reason == "chain_mismatch" => { + SignerResponse::Decision(Decision::Deny { reason }) + if reason == deny_reasons::CHAIN_MISMATCH => + { Err(failure::from_deny_reason( - "chain_mismatch", + deny_reasons::CHAIN_MISMATCH, self.config_dir(), )) } - SignerResponse::Decision(Decision::Deny { reason }) if reason == "locked" => { + SignerResponse::Decision(Decision::Deny { reason }) + if reason == deny_reasons::LOCKED => + { // Conclusive: the daemon checks chain BEFORE locked, so a `locked` reply // means the chain matched. Cache the success; the real call surfaces `locked`. self.chain_checked.store(true, Ordering::Relaxed); diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index 33f701d..fedf7c1 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -20,9 +20,9 @@ use tokio::sync::Mutex as AsyncMutex; use zeroize::Zeroizing; use deckard_contract::{ - evaluate, evaluate_order, ApprovalStatus, BalanceReport, Decision, ExecuteResult, Intent, - IntentKind, PendingPayloadView, PendingRecord, Policy, ReadStatus, RequestId, SignOrderResult, - SignerRequest, SignerResponse, SwapOrder, UnlockOutcome, + deny_reasons, evaluate, evaluate_order, ApprovalStatus, BalanceReport, Decision, ExecuteResult, + Intent, IntentKind, PendingPayloadView, PendingRecord, Policy, ReadStatus, RequestId, + SignOrderResult, SignerRequest, SignerResponse, SwapOrder, UnlockOutcome, }; // Only the `shield`-gated view-grant handler constructs this; an unconditional import would // warn in the no-default-features build (e.g. deckard-mcp's dependency edge). @@ -255,7 +255,7 @@ impl Daemon { 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(), + reason: deny_reasons::LOCKED.into(), }), }, SignerRequest::Balance { shielded } => { @@ -287,13 +287,13 @@ impl Daemon { VaultState::Unlocked { vault, .. } => vault, VaultState::Locked => { return SignerResponse::Decision(Decision::Deny { - reason: "locked".into(), + reason: deny_reasons::LOCKED.into(), }) } }; if !deckard_core::known_answer_ok() { return SignerResponse::Decision(Decision::Deny { - reason: "derivation_unverified".into(), + reason: deny_reasons::DERIVATION_UNVERIFIED.into(), }); } match vault.railgun_view_grant(chain_id, index) { @@ -302,7 +302,7 @@ impl Daemon { viewing_key, }), Err(e) => SignerResponse::Decision(Decision::Deny { - reason: format!("railgun_keys: {}", one_line(&e)), + reason: deny_reasons::railgun_keys(one_line(&e)), }), } } @@ -311,7 +311,7 @@ impl Daemon { #[cfg(not(feature = "shield"))] fn railgun_view_grant(&self, _chain_id: u64, _index: u32) -> SignerResponse { SignerResponse::Decision(Decision::Deny { - reason: "shield_unavailable".into(), + reason: deny_reasons::SHIELD_UNAVAILABLE.into(), }) } @@ -367,7 +367,7 @@ impl Daemon { ) { req.status = ApprovalStatus::Denied { - reason: "revoked".into(), + reason: deny_reasons::REVOKED.into(), }; } } @@ -384,7 +384,7 @@ impl Daemon { req.approved = true; // explicit human consent: not re-capped at execute } else { req.status = ApprovalStatus::Denied { - reason: "user_denied".into(), + reason: deny_reasons::USER_DENIED.into(), }; } } @@ -409,12 +409,12 @@ impl Daemon { // means a `locked` deny now implies the chain matched. if intent.chain_id != self.cfg.chain_id { return Decision::Deny { - reason: "chain_mismatch".into(), + reason: deny_reasons::CHAIN_MISMATCH.into(), }; } if matches!(self.state, VaultState::Locked) { return Decision::Deny { - reason: "locked".into(), + reason: deny_reasons::LOCKED.into(), }; } // SHAPED APPROVE (swap v1): a `ContractCall` carrying an exact `approve(spender,amount)` @@ -442,7 +442,7 @@ impl Daemon { // / ContractCall stay a fast-follow. if !matches!(intent.kind, IntentKind::Send | IntentKind::Shield) { return Decision::Deny { - reason: "unsupported_v1".into(), + reason: deny_reasons::UNSUPPORTED_V1.into(), }; } // v1 spine is native ETH only; an ERC-20 (`token = Some`) Send is a fast-follow. @@ -450,7 +450,7 @@ impl Daemon { // wrapBase), so it passes this guard. if intent.token.is_some() { return Decision::Deny { - reason: "erc20_unsupported_v1".into(), + reason: deny_reasons::ERC20_UNSUPPORTED_V1.into(), }; } // A Shield must target the chain's RelayAdapt contract. The contract crate's policy @@ -458,7 +458,7 @@ impl Daemon { // within-cap "Shield" to an arbitrary address would be signed (see [`relay_adapt`]). if intent.kind == IntentKind::Shield && relay_adapt(intent.chain_id) != Some(intent.to) { return Decision::Deny { - reason: "shield_to_mismatch".into(), + reason: deny_reasons::SHIELD_TO_MISMATCH.into(), }; } @@ -486,7 +486,7 @@ impl Daemon { if let Some(existing) = self.requests.get(&id) { return match &existing.status { _ if existing.broadcast.is_some() => Decision::Deny { - reason: "already_executed".into(), + reason: deny_reasons::ALREADY_EXECUTED.into(), }, ApprovalStatus::Pending => Decision::NeedsApproval { request_id: id }, ApprovalStatus::Allowed => Decision::Allow, @@ -494,7 +494,7 @@ impl Daemon { reason: reason.clone(), }, ApprovalStatus::Expired => Decision::Deny { - reason: "expired".into(), + reason: deny_reasons::EXPIRED.into(), }, }; } @@ -516,7 +516,7 @@ impl Daemon { let status = if always_needs_card { if self.policy.revoked { return Decision::Deny { - reason: "revoked".into(), + reason: deny_reasons::REVOKED.into(), }; } ApprovalStatus::Pending @@ -574,12 +574,12 @@ impl Daemon { // on the card. Bounding it here is the only place the value is gated. if intent.value != U256::ZERO { return Some(Decision::Deny { - reason: "approve_with_value".into(), + reason: deny_reasons::APPROVE_WITH_VALUE.into(), }); } if spender != deckard_core::GPV2_VAULT_RELAYER { return Some(Decision::Deny { - reason: "approve_wrong_spender".into(), + reason: deny_reasons::APPROVE_WRONG_SPENDER.into(), }); } let has_matching_order = self.requests.values().any(|req| match &req.payload { @@ -590,7 +590,7 @@ impl Daemon { }); if !has_matching_order { return Some(Decision::Deny { - reason: "approve_no_matching_order".into(), + reason: deny_reasons::APPROVE_NO_MATCHING_ORDER.into(), }); } None @@ -610,7 +610,7 @@ impl Daemon { // `chain_mismatch` even while locked. if order.chain_id != self.cfg.chain_id { return Decision::Deny { - reason: "chain_mismatch".into(), + reason: deny_reasons::CHAIN_MISMATCH.into(), }; } // We need the unlocked wallet to bind owner/receiver, so a locked daemon can't propose. @@ -618,7 +618,7 @@ impl Daemon { VaultState::Unlocked { address, .. } => *address, VaultState::Locked => { return Decision::Deny { - reason: "locked".into(), + reason: deny_reasons::LOCKED.into(), } } }; @@ -635,7 +635,7 @@ impl Daemon { if let Some(existing) = self.requests.get(&id) { return match &existing.status { _ if existing.broadcast.is_some() => Decision::Deny { - reason: "already_executed".into(), + reason: deny_reasons::ALREADY_EXECUTED.into(), }, ApprovalStatus::Pending => Decision::NeedsApproval { request_id: id }, ApprovalStatus::Allowed => Decision::Allow, @@ -643,7 +643,7 @@ impl Daemon { reason: reason.clone(), }, ApprovalStatus::Expired => Decision::Deny { - reason: "expired".into(), + reason: deny_reasons::EXPIRED.into(), }, }; } @@ -683,7 +683,7 @@ impl Daemon { let vault = match &self.state { VaultState::Locked => { return SignOrderResult::Denied { - reason: "revoked".into(), + reason: deny_reasons::REVOKED.into(), } } VaultState::Unlocked { vault, .. } => vault, @@ -691,7 +691,7 @@ impl Daemon { let req = match self.requests.get(&request_id) { None => { return SignOrderResult::Denied { - reason: "unknown_request".into(), + reason: deny_reasons::UNKNOWN_REQUEST.into(), } } Some(req) => req, @@ -700,20 +700,20 @@ impl Daemon { PendingPayload::Order(order) => order, PendingPayload::Tx(_) => { return SignOrderResult::Denied { - reason: "not_an_order".into(), + reason: deny_reasons::NOT_AN_ORDER.into(), } } }; if req.signature.is_some() { return SignOrderResult::Denied { - reason: "already_signed".into(), + reason: deny_reasons::ALREADY_SIGNED.into(), }; } match &req.status { ApprovalStatus::Allowed => {} ApprovalStatus::Pending => { return SignOrderResult::Denied { - reason: "not_approved".into(), + reason: deny_reasons::NOT_APPROVED.into(), } } ApprovalStatus::Denied { reason } => { @@ -723,7 +723,7 @@ impl Daemon { } ApprovalStatus::Expired => { return SignOrderResult::Denied { - reason: "expired".into(), + reason: deny_reasons::EXPIRED.into(), } } } @@ -731,7 +731,7 @@ impl Daemon { // record's status if the brake landed via a policy path; refuse on the live flag. if self.policy.revoked { return SignOrderResult::Denied { - reason: "revoked".into(), + reason: deny_reasons::REVOKED.into(), }; } let digest = deckard_core::order_digest(order); @@ -739,7 +739,7 @@ impl Daemon { Ok(s) => s, Err(e) => { return SignOrderResult::Denied { - reason: format!("signer_error: {}", one_line(&e)), + reason: deny_reasons::signer_error(one_line(&e)), } } }; @@ -752,7 +752,7 @@ impl Daemon { Ok(sig) => sig, Err(e) => { return SignOrderResult::Denied { - reason: format!("sign_failed: {}", one_line(&e)), + reason: deny_reasons::sign_failed(one_line(&e)), } } }; @@ -774,12 +774,12 @@ impl Daemon { match self.requests.get(&request_id) { None => { return ExecuteResult::Denied { - reason: "unknown_request".into(), + reason: deny_reasons::UNKNOWN_REQUEST.into(), } } Some(req) if !matches!(req.payload, PendingPayload::Order(_)) => { return ExecuteResult::Denied { - reason: "not_an_order".into(), + reason: deny_reasons::NOT_AN_ORDER.into(), } } Some(_) => {} @@ -798,7 +798,7 @@ impl Daemon { let vault = match &self.state { VaultState::Locked => { return ExecuteResult::Denied { - reason: "revoked".into(), + reason: deny_reasons::REVOKED.into(), } } VaultState::Unlocked { vault, .. } => vault, @@ -808,13 +808,13 @@ impl Daemon { PendingPayload::Order(order) => order, PendingPayload::Tx(_) => { return ExecuteResult::Denied { - reason: "not_an_order".into(), + reason: deny_reasons::NOT_AN_ORDER.into(), } } }, None => { return ExecuteResult::Denied { - reason: "unknown_request".into(), + reason: deny_reasons::UNKNOWN_REQUEST.into(), } } }; @@ -825,7 +825,7 @@ impl Daemon { Ok(s) => s, Err(e) => { return ExecuteResult::Denied { - reason: format!("signer_error: {}", one_line(&e)), + reason: deny_reasons::signer_error(one_line(&e)), } } }; @@ -846,12 +846,12 @@ impl Daemon { Ok(Ok(hash)) => hash, Ok(Err(e)) => { return ExecuteResult::Denied { - reason: format!("broadcast_failed: {}", one_line(&e)), + reason: deny_reasons::broadcast_failed(one_line(&e)), } } Err(_elapsed) => { return ExecuteResult::Denied { - reason: "broadcast_timeout".into(), + reason: deny_reasons::BROADCAST_TIMEOUT.into(), } } }; @@ -915,7 +915,7 @@ impl Daemon { // STOP landed first — refuse even a previously-approved request. VaultState::Locked => { return ExecuteResult::Denied { - reason: "revoked".into(), + reason: deny_reasons::REVOKED.into(), } } VaultState::Unlocked { vault, .. } => vault, @@ -923,14 +923,14 @@ impl Daemon { let req = match self.requests.get(&request_id) { None => { return ExecuteResult::Denied { - reason: "unknown_request".into(), + reason: deny_reasons::UNKNOWN_REQUEST.into(), } } Some(req) => req, }; if req.broadcast.is_some() { return ExecuteResult::Denied { - reason: "already_executed".into(), + reason: deny_reasons::ALREADY_EXECUTED.into(), }; } match &req.status { @@ -938,7 +938,7 @@ impl Daemon { ApprovalStatus::Allowed => {} ApprovalStatus::Pending => { return ExecuteResult::Denied { - reason: "not_approved".into(), + reason: deny_reasons::NOT_APPROVED.into(), } } ApprovalStatus::Denied { reason } => { @@ -948,7 +948,7 @@ impl Daemon { } ApprovalStatus::Expired => { return ExecuteResult::Denied { - reason: "expired".into(), + reason: deny_reasons::EXPIRED.into(), } } } @@ -959,7 +959,7 @@ impl Daemon { PendingPayload::Tx(intent) => intent, PendingPayload::Order(_) => { return ExecuteResult::Denied { - reason: "not_an_order".into(), + reason: deny_reasons::NOT_AN_ORDER.into(), } } }; @@ -969,14 +969,14 @@ impl Daemon { // its overage and is not re-capped. if !req.approved && evaluate(intent, &self.policy) != Decision::Allow { return ExecuteResult::Denied { - reason: "cap_exceeded".into(), + reason: deny_reasons::CAP_EXCEEDED.into(), }; } let signer = match vault.account_signer(0) { Ok(s) => s, Err(e) => { return ExecuteResult::Denied { - reason: format!("signer_error: {}", one_line(&e)), + reason: deny_reasons::signer_error(one_line(&e)), } } }; @@ -1003,12 +1003,12 @@ impl Daemon { Ok(Ok(hash)) => hash, Ok(Err(e)) => { return ExecuteResult::Denied { - reason: format!("broadcast_failed: {}", one_line(&e)), + reason: deny_reasons::broadcast_failed(one_line(&e)), } } Err(_elapsed) => { return ExecuteResult::Denied { - reason: "broadcast_timeout".into(), + reason: deny_reasons::BROADCAST_TIMEOUT.into(), } } }; @@ -1028,7 +1028,7 @@ impl Daemon { match self.requests.get(&request_id) { Some(req) => req.status.clone(), None => ApprovalStatus::Denied { - reason: "unknown_request".into(), + reason: deny_reasons::UNKNOWN_REQUEST.into(), }, } } diff --git a/crates/deckard-signerd/src/server.rs b/crates/deckard-signerd/src/server.rs index 3672975..3599bd0 100644 --- a/crates/deckard-signerd/src/server.rs +++ b/crates/deckard-signerd/src/server.rs @@ -8,7 +8,7 @@ use tokio::net::{UnixListener, UnixStream}; use tokio::sync::Mutex; use zeroize::Zeroize; -use deckard_contract::{Decision, SignerRequest, SignerResponse}; +use deckard_contract::{deny_reasons, Decision, SignerRequest, SignerResponse}; use crate::auth; use crate::daemon::Daemon; @@ -58,7 +58,7 @@ async fn handle_conn(mut stream: UnixStream, daemon: Arc>) -> anyh Ok(None) => return Ok(()), // peer closed cleanly between frames Err(e) => { // Oversize/short read: best-effort error, then close. - let _ = reply_error(&mut stream, "malformed_request").await; + let _ = reply_error(&mut stream, deny_reasons::MALFORMED_REQUEST).await; return Err(e); } }; @@ -70,7 +70,7 @@ async fn handle_conn(mut stream: UnixStream, daemon: Arc>) -> anyh let req = match decoded { Ok(req) => req, Err(e) => { - let _ = reply_error(&mut stream, "malformed_request").await; + let _ = reply_error(&mut stream, deny_reasons::MALFORMED_REQUEST).await; return Err(e); } }; diff --git a/docs/build/31-agent-quickstart.md b/docs/build/31-agent-quickstart.md index 2c42798..3dac789 100644 --- a/docs/build/31-agent-quickstart.md +++ b/docs/build/31-agent-quickstart.md @@ -133,6 +133,22 @@ error is to retry — for two of these (marked **do NOT retry**) that instinct i | `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. | | `unsupported_v1` / `erc20_unsupported_v1` | v0.1 supports native-ETH shield/send only. | Stay with native-ETH `deckard_shield` / `deckard_execute`. | +| `malformed_request` | The daemon couldn't decode the request frame at all (wire-level). | Client/version bug — re-run from `deckard_shield`; make sure the sidecar and app versions match. | +| `off_swap_list` | A swap's sell or buy token isn't in `allow_swap_tokens`. | Use an allowed token, or a human edits `policy.json`. | +| `receiver_not_wallet` | The swap order would pay out to an address other than your wallet. | Re-run the swap flow — it binds the receiver to the operator wallet. | +| `receiver_zero` | The swap order receiver is the zero address. | Re-run the swap flow; a recurring case is a client bug. | +| `zero_amount` | The swap order's sell amount is zero. | Re-quote with a non-zero sell amount. | +| `valid_to_too_far` | The swap order's `valid_to` is more than 24h out. | Re-quote with a `valid_to` inside 24 hours. | +| `not_an_order` | The `request_id` points at a transaction where an order was expected (or vice versa). | Use the id returned by the matching propose call. | +| `already_signed` | The swap order was already signed. | Don't re-sign; cancel via the swap-cancel flow if you need to abort. | +| `approve_no_matching_order` | An `approve` arrived with no stored order matching its token + amount. | Propose the swap order first; the approve must match it exactly. | +| `approve_with_value` | A swap `approve` carried ETH value (would move ETH invisibly). | Re-issue a value-0 approve (the swap flow does this). | +| `approve_wrong_spender` | A swap `approve`'s spender isn't the CoW vault relayer. | Re-issue the approve to the correct spender (the swap flow does this). | +| `derivation_unverified` | The Railgun derivation self-check failed; a view grant was refused. | A bug — restart the app; don't trust a private balance until it clears. | +| `shield_unavailable` | This build has no shielding support. | Use a build with the `shield` feature enabled. | +| `railgun_keys: …` | A Railgun key/grant error (redacted detail appended). | Restart the app; if it recurs, the chain may be unsupported for shielding. | +| `signer_error: …` | The daemon couldn't get an account signer (redacted detail appended). | A human re-unlocks the wallet in the app, then retry. | +| `sign_failed: …` | Offline order-digest signing failed (redacted detail appended). | Re-run the swap flow; a recurring case is a client/daemon bug. | Two transport-level failures carry the same three-part shape: **socket missing** (the daemon isn't running — start the Deckard app, or `just demo`) and **connection lost during execute**