From 59f66c119d17cbda40fc5c36fd1d66c9f457f924 Mon Sep 17 00:00:00 2001 From: hellno Date: Mon, 15 Jun 2026 18:35:29 +0200 Subject: [PATCH 1/3] app-layer refactor + swap core plumbing + devex + docs Restructure the GPUI app's commit-flow surfaces and lay the groundwork for the Swap GUI (#25). Tree green: just check (default + tray) + workspace tests. App-layer refactor (Send + Shield onto a shared CommitFlow): - commit_flow.rs: CommitFlow + entity-free CommitState core (epoch/hold/reset state machine) + Proposal (collapses the two identical proposal structs) + unit tests for the epoch/hold/reset invariants. - commit_view.rs: one generic render_commit + shared hold-to-confirm widget, driven by a &'static CommitView descriptor (fee/net rows, honesty lines, static + dynamic compose hints, glyph tone). - errors.rs: short_err/humanize_deny/is_session_ended extracted from shell.rs + tests. - send_view.rs / shield_view.rs reduced to SEND_VIEW / SHIELD_VIEW descriptors. - shell.rs: send_*/shield_* flat fields -> pub send/shield: CommitFlow; shared finish_review tail; ~470 lines of duplicated handlers/views collapsed. Swap core plumbing (Unit A, deckard-core): - chain-keyed fetch_portfolio(chain_id) via tokens_for; TokenBalance.address. - EthProvider::spawn(rpc, chain_id) + EthProvider::allowance(owner, spender, token). - CowOrderbook wrapper (owns the reqwest::Client; app never names reqwest). DevEx + docs: - just qa-vault / just qa: pre-sealed fast-KDF throwaway vault (anvil acct 0, prefunded) so clicky QA skips onboarding. examples/qa-vault.rs. - docs/dev/railgun-local-testing.md + README: keyless sepolia.drpc.org fork path. --- README.md | 15 +- crates/deckard-app/src/commit_flow.rs | 287 +++++++++++ crates/deckard-app/src/commit_view.rs | 575 ++++++++++++++++++++++ crates/deckard-app/src/errors.rs | 158 ++++++ crates/deckard-app/src/main.rs | 3 + crates/deckard-app/src/send_view.rs | 529 ++++---------------- crates/deckard-app/src/shell.rs | 597 +++++++++-------------- crates/deckard-app/src/shield_view.rs | 583 +++++----------------- crates/deckard-app/src/signer.rs | 177 ++++++- crates/deckard-core/examples/qa-vault.rs | 80 +++ crates/deckard-core/examples/smoke.rs | 3 +- crates/deckard-core/src/balances.rs | 50 +- crates/deckard-core/src/cow_client.rs | 68 +++ crates/deckard-core/src/eth.rs | 111 ++++- crates/deckard-core/src/lib.rs | 5 +- docs/dev/railgun-local-testing.md | 77 +++ justfile | 23 + 17 files changed, 2052 insertions(+), 1289 deletions(-) create mode 100644 crates/deckard-app/src/commit_flow.rs create mode 100644 crates/deckard-app/src/commit_view.rs create mode 100644 crates/deckard-app/src/errors.rs create mode 100644 crates/deckard-core/examples/qa-vault.rs create mode 100644 docs/dev/railgun-local-testing.md diff --git a/README.md b/README.md index 215bb4a..7fbefbe 100644 --- a/README.md +++ b/README.md @@ -52,14 +52,21 @@ hands the wheel to Claude Desktop. - **`just`** — `brew install just` (macOS) or `cargo install just` (any platform). - **Foundry** (`anvil` + `cast`) — `brew install foundry` (macOS), or `curl -L https://foundry.paradigm.xyz | bash && foundryup` (any platform). -- **A free Sepolia _archive_ RPC** — this is anvil's upstream fork source (it is **not** read by - any Deckard binary). A free archive endpoint works, e.g. [drpc.org](https://drpc.org), - Alchemy, or Infura Sepolia. Export it: +- **A Sepolia _archive_ RPC** — this is anvil's upstream fork source (it is **not** read by + any Deckard binary). The **zero-setup option is the keyless public endpoint** — no signup, no + key (it may rate-limit; for heavy use get a free keyed endpoint from Alchemy/Infura/dRPC): ```sh - export RPC_URL_SEPOLIA=https://eth-sepolia.g.alchemy.com/v2/ + export RPC_URL_SEPOLIA=https://sepolia.drpc.org # keyless — works out of the box + # or a free keyed endpoint: + # export RPC_URL_SEPOLIA=https://eth-sepolia.g.alchemy.com/v2/ ``` + > It only needs **archive depth at the pinned fork block** (`10822990`, where the Railgun + > contracts live) — verify with `cast block 10822990 --rpc-url "$RPC_URL_SEPOLIA"`. + > Local Railgun shield/swap testing is documented in + > [`docs/dev/railgun-local-testing.md`](docs/dev/railgun-local-testing.md). + ### macOS (Claude Desktop) ```sh diff --git a/crates/deckard-app/src/commit_flow.rs b/crates/deckard-app/src/commit_flow.rs new file mode 100644 index 0000000..81ad971 --- /dev/null +++ b/crates/deckard-app/src/commit_flow.rs @@ -0,0 +1,287 @@ +//! commit_flow — the shared, GPUI-light machinery behind every "compose → review → hold-to-confirm" +//! surface (Shield, Send, and later Swap). It collapses the two field-identical proposal structs +//! into one [`Proposal`] and lifts the epoch/hold/reset state-machine out of `shell.rs` into a +//! testable [`CommitFlow`] (with its pure, entity-free [`CommitState`] core). +//! +//! Step 0 is additive: this module is `pub` but not yet wired into `Shell` — the flat `shield_*` / +//! `send_*` fields and their handlers stay exactly as they are. A later step migrates them onto +//! `CommitFlow`. The state methods below reproduce the EXACT semantics of the shell handlers they +//! mirror (cited per-method); behavior must be identical once migration happens. + +use alloy_primitives::B256; +use deckard_contract::{Intent, RequestId}; +use gpui::Entity; +use gpui_component::input::InputState; + +/// A reviewed-and-allowed action, ready to sign. Carries a **recipient snapshot** taken at review +/// time so the clear-signing card always shows the recipient that is actually inside `intent` — +/// never a value the user edited in the input after `propose` landed. +/// +/// This is the single type behind the (formerly duplicated) `ShieldProposal` / `SendProposal` — see +/// the `pub type` aliases in `shell.rs`. Every existing construction/use site is unchanged: the +/// fields here are byte-identical to both old structs (`{ intent, request_id, recipient, needs_resolve }`). +#[derive(Clone)] +pub struct Proposal { + pub intent: Intent, + pub request_id: RequestId, + pub recipient: String, + /// True when the daemon answered `NeedsApproval` (over-cap, or a mainnet-guardrail downgrade + /// of an auto-allow). The completed hold-to-confirm IS the human approval — the app is the wire + /// contract's designated resolver — so confirm sends `Resolve{approved: true}` before `Execute`. + pub needs_resolve: bool, + // TODO(swap): extras — Swap needs to carry its quote/min_out alongside the proposal. Adding a + // `pub extra: ProposalExtra` field now would force every existing `ShieldProposal { .. }` / + // `SendProposal { .. }` construction site in shell.rs to name the new field, which Step 0 + // forbids (behavior + call sites must stay identical). Deferred to the Swap migration step. +} + +/// The entity-free core of a commit flow: the proposal, the in-flight/hold flags, the surfaced +/// error + broadcast result, and the two monotonic epochs that fence out stale background replies +/// and stale hold timers. Split out from [`CommitFlow`] so the state machine is unit-testable +/// without a GPUI context (the `Entity` handles need a `Window`/`cx` to construct). +/// +/// `CommitFlow` derefs to this, so callers still write `flow.proposal`, `flow.busy`, +/// `flow.begin_review()`, etc. — the requested flat surface, with a testable core underneath. +pub struct CommitState { + /// Set once `propose` returns `Allow`/`NeedsApproval`. `Some` means the review card + + /// hold-to-confirm are live; it carries the recipient snapshot. + pub proposal: Option, + /// True while a `propose`/`resolve`/`execute` round-trip runs on a background thread. + pub busy: bool, + /// One-line, user-facing error (parse / resolve / deny / broadcast). + pub error: Option, + /// Set on a successful `execute` broadcast — the "on its way" confirmation state. + pub tx: Option, + /// True while the confirm button is being held; drives the amber fill-sweep. + pub holding: bool, + /// Bumped on each review (and on reset) so a slow propose/resolve reply for a + /// since-cancelled/re-issued review can't install a stale proposal. + review_epoch: u64, + /// Bumped on each hold-start (and on cancel/reset) so a stale hold timer can't fire a later + /// confirm. + hold_epoch: u64, +} + +impl CommitState { + fn new() -> Self { + Self { + proposal: None, + busy: false, + error: None, + tx: None, + holding: false, + review_epoch: 0, + hold_epoch: 0, + } + } + + /// Clear all transient state (proposal, error, broadcast, busy, hold). Bumps BOTH the hold + + /// review epochs so any in-flight hold timer or propose/resolve reply lands as a no-op. + /// Mirrors `Shell::reset_shield` (shell.rs:1360-1368) / `reset_send` (shell.rs:1578-…). + pub fn reset(&mut self) { + self.proposal = None; + self.error = None; + self.tx = None; + self.busy = false; + self.holding = false; + self.hold_epoch = self.hold_epoch.wrapping_add(1); + self.review_epoch = self.review_epoch.wrapping_add(1); + } + + /// Begin a review: bump the review epoch (each review supersedes the last), set `busy`, and + /// return the new epoch for the caller to capture and re-check before installing the reply. + /// Mirrors the epoch bump + `busy = true` in `Shell::review_shield` (shell.rs:1399-1403). + /// + /// Note: the shell handler also clears `error`/`proposal` *before* this bump, after its + /// parse/validation early-returns; those concerns stay in the (impure, `cx`-bound) handler. + pub fn begin_review(&mut self) -> u64 { + self.review_epoch = self.review_epoch.wrapping_add(1); + self.busy = true; + self.review_epoch + } + + /// True when `epoch` is the current review epoch — i.e. this background reply is not stale. + /// Mirrors the `this.shield_review_epoch != epoch` guard in `review_shield` (shell.rs:1418). + pub fn review_is_current(&self, epoch: u64) -> bool { + epoch == self.review_epoch + } + + /// Begin a confirm hold. Returns `None` (a no-op) when already busy, already holding, or there + /// is no proposal to confirm; otherwise sets `holding`, bumps the hold epoch, and returns the + /// new epoch for the caller's timer to re-check. Mirrors the guard + state in + /// `Shell::shield_hold_start` (shell.rs:1527-1533). + pub fn begin_hold(&mut self) -> Option { + if self.busy || self.holding || self.proposal.is_none() { + return None; + } + self.holding = true; + self.hold_epoch = self.hold_epoch.wrapping_add(1); + Some(self.hold_epoch) + } + + /// True when a hold timer firing for `epoch` should still complete: this hold is active, the + /// epoch hasn't been superseded, and a proposal is still present. Mirrors the timer-fire guard + /// in `shield_hold_start` (shell.rs:1541-1545) MINUS the `surface == Surface::Shield` check, + /// which stays in `Shell` (it depends on the live surface, not the flow state). + pub fn hold_still_valid(&self, epoch: u64) -> bool { + self.holding && self.hold_epoch == epoch && self.proposal.is_some() + } + + /// Release an in-progress hold before it completed. Returns true when a hold was actually + /// cancelled (so the caller can `cx.notify()`), bumping the hold epoch to cancel the pending + /// timer. Mirrors `Shell::shield_hold_cancel` (shell.rs:1557-1563). + pub fn cancel_hold(&mut self) -> bool { + if self.holding { + self.holding = false; + self.hold_epoch = self.hold_epoch.wrapping_add(1); + true + } else { + false + } + } +} + +/// A commit flow's full state: the two text inputs plus the entity-free [`CommitState`] core. Holds +/// no key — it only carries what a "compose → review → hold-to-confirm" surface renders and the +/// epochs that fence its background work. Derefs to [`CommitState`] for the flat field/method +/// surface (`flow.proposal`, `flow.begin_review()`, …). +pub struct CommitFlow { + /// Amount (ETH, free text). + pub amount: Entity, + /// Recipient input (a `0x…`/ENS address for Send, a `0zk…` address for Shield). + pub recipient: Entity, + state: CommitState, +} + +impl CommitFlow { + /// A fresh flow: no proposal, not busy, no error, no broadcast, not holding, epochs at 0. + pub fn new(amount: Entity, recipient: Entity) -> Self { + Self { + amount, + recipient, + state: CommitState::new(), + } + } +} + +impl std::ops::Deref for CommitFlow { + type Target = CommitState; + fn deref(&self) -> &CommitState { + &self.state + } +} + +impl std::ops::DerefMut for CommitFlow { + fn deref_mut(&mut self) -> &mut CommitState { + &mut self.state + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Address, Bytes, U256}; + use deckard_contract::IntentKind; + + /// A `CommitState` carrying a proposal, for the hold-guard tests. The `Intent`/`Proposal` + /// contents are irrelevant to the epoch/guard logic — only `proposal.is_some()` matters. + fn state_with_proposal() -> CommitState { + let mut s = CommitState::new(); + s.proposal = Some(Proposal { + intent: Intent { + chain_id: 31337, + to: Address::repeat_byte(0x11), + token: None, + value: U256::from(1u64), + calldata: Bytes::new(), + kind: IntentKind::Shield, + }, + request_id: RequestId::ZERO, + recipient: "0zk…".into(), + needs_resolve: false, + }); + s + } + + #[test] + fn review_epoch_supersedes_the_previous_review() { + let mut s = CommitState::new(); + let epoch1 = s.begin_review(); + let epoch2 = s.begin_review(); + // A reply for the first, superseded review must be rejected; the latest passes. + assert!(!s.review_is_current(epoch1)); + assert!(s.review_is_current(epoch2)); + // begin_review sets busy (mirrors review_shield). + assert!(s.busy); + } + + #[test] + fn begin_hold_guards_match_shield_hold_start() { + // No proposal → no-op. + let mut empty = CommitState::new(); + assert_eq!(empty.begin_hold(), None); + assert!(!empty.holding); + + // Busy (even with a proposal) → no-op. + let mut busy = state_with_proposal(); + busy.busy = true; + assert_eq!(busy.begin_hold(), None); + assert!(!busy.holding); + + // Proposal present, not busy, not holding → starts the hold and returns the epoch. + let mut ready = state_with_proposal(); + let held = ready.begin_hold(); + assert!(held.is_some()); + assert!(ready.holding); + + // Already holding → no-op (a second press doesn't restart). + assert_eq!(ready.begin_hold(), None); + } + + #[test] + fn cancelling_or_restarting_a_hold_invalidates_the_old_hold_epoch() { + let mut s = state_with_proposal(); + let first = s.begin_hold().expect("first hold starts"); + assert!(s.hold_still_valid(first)); + + // Cancel: the old hold epoch is now stale (its pending timer would fire as a no-op). + assert!(s.cancel_hold()); + assert!(!s.hold_still_valid(first)); + assert!(!s.holding); + + // cancel_hold is itself a no-op once nothing is held. + assert!(!s.cancel_hold()); + + // Restarting also invalidates an even older epoch: start, then start-again-after-cancel. + let second = s.begin_hold().expect("second hold starts"); + assert!(s.hold_still_valid(second)); + assert!(s.cancel_hold()); + let third = s.begin_hold().expect("third hold starts"); + assert!(s.hold_still_valid(third)); + assert!(!s.hold_still_valid(second)); + } + + #[test] + fn reset_invalidates_every_pre_reset_epoch_and_clears_state() { + let mut s = state_with_proposal(); + let review = s.begin_review(); + // A review sets `busy`; `finish_review` clears it on completion. A hold can only start once + // the review is done (begin_hold refuses while busy), so simulate that before holding. + s.busy = false; + let hold = s.begin_hold().expect("hold starts"); + s.error = Some("boom".into()); + s.tx = Some(B256::repeat_byte(0xab)); + + s.reset(); + + // Both epochs moved past their pre-reset values. + assert!(!s.review_is_current(review)); + assert!(!s.hold_still_valid(hold)); + // Everything transient is cleared. + assert!(s.proposal.is_none()); + assert!(s.tx.is_none()); + assert!(!s.busy); + assert!(!s.holding); + assert!(s.error.is_none()); + } +} diff --git a/crates/deckard-app/src/commit_view.rs b/crates/deckard-app/src/commit_view.rs new file mode 100644 index 0000000..2716bdb --- /dev/null +++ b/crates/deckard-app/src/commit_view.rs @@ -0,0 +1,575 @@ +//! commit_view — the generic "compose → review → done" renderer that drives every +//! [`CommitFlow`](crate::commit_flow) surface (Send now; Shield joins in Step 2). A single +//! [`CommitView`] descriptor (a `&'static` table of copy, button ids, the heading glyph, and a +//! few per-surface hooks) feeds [`Shell::render_commit`], which reproduces the hand-written +//! surface view BYTE-FOR-BYTE — same layout, same strings, same widget ids. +//! +//! The clear-signing contract is unchanged from `shield_view`/`send_view`: plain language, exact +//! mono figures, danger early, and confirm is a hold (never a tap) — the hand-built +//! [`Shell::hold_to_confirm`] sweep animates an amber fill over [`SHIELD_HOLD`] as the action +//! signs (amber = the human-confirm signal). +//! +//! Step 1 migrates ONLY Send onto this renderer; Shield still uses its flat `shield_*` fields and +//! `shield_view.rs`. The descriptor already carries the slots Shield needs (optional fee/net rows, +//! a variable honesty-line list, an optional conditional compose-hint hook) so Step 2 is a pure +//! descriptor + handler swap with no renderer changes. + +use gpui::{ + div, px, relative, Animation, AnimationExt, ClipboardItem, Context, FontWeight, Hsla, + InteractiveElement, IntoElement, MouseButton, ParentElement, SharedString, Styled, +}; +use gpui_component::{ + button::{Button, ButtonVariants}, + h_flex, + input::Input, + v_flex, ActiveTheme, Disableable, Icon, IconName, +}; + +use deckard_core::U256; + +use crate::commit_flow::{CommitFlow, Proposal}; +use crate::money::money; +use crate::shell::{Shell, SHIELD_HOLD}; + +/// A single label/value money row in the review card: label left (muted), value right (mono). +/// One signature shared by every commit surface's card. +fn kv_money_row( + label: &'static str, + wei: U256, + mono: SharedString, + fg: Hsla, + muted: Hsla, +) -> impl IntoElement { + h_flex() + .w_full() + .justify_between() + .items_center() + .py_1p5() + .child(div().text_sm().text_color(muted).child(label)) + .child( + div() + .text_sm() + .child(money(wei, 18, 6, Some("ETH"), false, mono, fg, muted)), + ) +} + +/// A money figure derived from the proposal's gross value, rendered as one extra review-card row +/// (e.g. Shield's "Railgun fee" and "You'll receive (private)"). `compute` turns the gross intent +/// value into the row's wei figure. +pub struct MoneyRow { + pub label: &'static str, + pub compute: fn(gross: U256) -> U256, +} + +/// One honesty line in the calm neutral surface: `emphasized` lines use the foreground tone, the +/// rest are muted (matches `send_honesty` / `shield_honesty` exactly). +pub struct HonestyLine { + pub text: &'static str, + pub emphasized: bool, +} + +/// The `&'static` descriptor that turns the generic renderer into a specific surface. Every field +/// reproduces a hand-written value from the corresponding `*_view.rs`; the function-pointer hooks +/// re-acquire the live [`CommitFlow`] and route the buttons to the surface's existing handlers, so +/// the renderer never needs to know which surface it is drawing. +pub struct CommitView { + // --- per-surface state access --- + /// Re-acquire this surface's flow from the shell (`&mut self.send`, later `&mut self.shield`). + /// Read-only here; the renderer only reads flow state. + pub flow: fn(&Shell) -> &CommitFlow, + /// The neutral, low-chroma heading glyph tone (NOT cyan/amber — these surfaces sit off the + /// actor axis). `dark` is `theme.is_dark()`. + pub glyph_tone: fn(dark: bool) -> Hsla, + + // --- compose --- + pub compose_title: &'static str, + pub compose_subtitle: &'static str, + pub recipient_label: &'static str, + /// The Review button id + its idle/busy labels (busy reuses `"Reviewing…"` across surfaces). + pub review_button_id: &'static str, + pub review_label: &'static str, + pub cancel_button_id: &'static str, + /// The static compose hint (Send). `None` when a surface drives its hint conditionally via + /// `compose_hint`. + pub compose_hint: Option<&'static str>, + /// The conditional compose hint (Shield's 3-way line). Takes precedence over `compose_hint` + /// when set; picks the line from live shell state + the recipient text the renderer already + /// read (`recipient_raw`, passed so the hook needs no `cx`). `None` for a static-hint surface. + pub compose_hint_dynamic: Option &'static str>, + + // --- review --- + pub review_title: &'static str, + pub review_subtitle: &'static str, + /// Extra money rows below "Amount" / "To" (Shield's fee + net). Empty for Send. + pub extra_rows: &'static [MoneyRow], + /// The honesty lines (2 for Send, 3 for Shield), in render order. + pub honesty: &'static [HonestyLine], + /// The hold-to-confirm widget + fill-animation ids, and the idle/holding/busy labels. + pub hold_id: &'static str, + pub hold_fill_id: &'static str, + pub hold_label_idle: &'static str, + pub hold_label_holding: &'static str, + pub hold_label_busy: &'static str, + pub edit_button_id: &'static str, + + // --- done --- + pub done_title: &'static str, + pub done_body: &'static str, + pub copy_button_id: &'static str, + pub done_button_id: &'static str, + + // --- handlers (route to the surface's existing `impl Shell` methods) --- + pub on_review: fn(&mut Shell, &mut Context), + pub on_edit: fn(&mut Shell, &mut Context), + pub on_cancel: fn(&mut Shell, &mut Context), + pub on_done: fn(&mut Shell, &mut Context), + pub on_hold_start: fn(&mut Shell, &mut Context), + pub on_hold_cancel: fn(&mut Shell, &mut Context), +} + +/// Middle-truncate a long address (`0x…` / `0zk…`) for a tight row (matches the per-view helper). +fn short_mid(s: &str) -> String { + if s.len() >= 16 { + format!("{}…{}", &s[..10], &s[s.len() - 6..]) + } else { + s.to_string() + } +} + +/// A tiny field label (matches the per-view `field_label`). +fn field_label(text: &'static str, muted: Hsla) -> impl IntoElement { + div().text_xs().text_color(muted).child(text) +} + +/// A one-line error, in `danger` (matches the per-view `error_line`). +fn error_line(msg: &str, cx: &mut Context) -> impl IntoElement { + div() + .text_sm() + .text_color(cx.theme().danger) + .child(format!("⚠ {msg}")) +} + +impl Shell { + /// Dispatch to the active commit state: done (broadcast) → review (proposed) → compose. + /// Reads the surface's flow via `view.flow`. The render arms below are byte-identical to the + /// hand-written `render_send` (and, in Step 2, `render_shield`). + pub fn render_commit( + &self, + view: &'static CommitView, + cx: &mut Context, + ) -> impl IntoElement { + let flow = (view.flow)(self); + if let Some(tx) = flow.tx { + return self + .render_commit_done(view, tx.to_string(), cx) + .into_any_element(); + } + if let Some(proposal) = flow.proposal.clone() { + return self + .render_commit_review(view, proposal, cx) + .into_any_element(); + } + self.render_commit_compose(view, cx).into_any_element() + } + + /// Compose: amount (ETH) + a recipient, then Review. Validity drives the Review button's + /// disabled state, re-evaluated live via the input subscriptions. + fn render_commit_compose( + &self, + view: &'static CommitView, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let flow = (view.flow)(self); + let busy = flow.busy; + + let amount_raw = flow.amount.read(cx).value().to_string(); + let recipient_raw = flow.recipient.read(cx).value().to_string(); + let can_review = crate::signer::parse_eth_to_wei(&amount_raw) + .map(|w| w > U256::ZERO) + .unwrap_or(false) + && !recipient_raw.trim().is_empty(); + + // The compose hint: a dynamic (Shield 3-way) hook takes precedence over the static line. + // The dynamic hook reuses the `recipient_raw` the renderer already read (no second `cx` + // borrow of the input). + let hint: &'static str = match view.compose_hint_dynamic { + Some(f) => f(self, &recipient_raw), + None => view.compose_hint.unwrap_or(""), + }; + + self.commit_shell( + view, + v_flex() + .w_full() + .gap_5() + .child(self.commit_heading(view, view.compose_title, view.compose_subtitle, cx)) + .child( + v_flex() + .w_full() + .gap_2() + .child(field_label("Amount", muted)) + .child(Input::new(&flow.amount).w_full()), + ) + .child( + v_flex() + .w_full() + .gap_2() + .child(field_label(view.recipient_label, muted)) + .child(Input::new(&flow.recipient).w_full()), + ) + .children(flow.error.as_ref().map(|e| error_line(e, cx))) + .child( + h_flex() + .w_full() + .gap_2() + .child( + Button::new(view.review_button_id) + .primary() + .label(if busy { + "Reviewing…" + } else { + view.review_label + }) + .disabled(busy || !can_review) + .on_click(cx.listener(|this, _, _, cx| (view.on_review)(this, cx))), + ) + .child( + Button::new(view.cancel_button_id) + .ghost() + .label("Cancel") + .on_click(cx.listener(|this, _, _, cx| (view.on_cancel)(this, cx))), + ), + ) + .child(div().text_xs().text_color(muted).child(hint)) + .into_any_element(), + ) + } + + /// Review: the clear-signing card (amount / recipient [+ extra rows]) + the honesty lines + + /// a deliberate hold-to-confirm. Rendered from the proposal SNAPSHOT — never the live input. + fn render_commit_review( + &self, + view: &'static CommitView, + proposal: Proposal, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let border = theme.border; + let surface = theme.secondary; + let mono = theme.mono_font_family.clone(); + let flow = (view.flow)(self); + + let gross = proposal.intent.value; + let recipient = proposal.recipient.clone(); + + // The card: Amount, To, then any extra money rows (Shield's fee + net). + let mut card = v_flex() + .w_full() + .p_4() + .rounded_lg() + .border_1() + .border_color(border) + .bg(surface) + .child(kv_money_row("Amount", gross, mono.clone(), fg, muted)) + .child( + h_flex() + .w_full() + .justify_between() + .items_center() + .py_1p5() + .child(div().text_sm().text_color(muted).child("To")) + .child( + div() + .font_family(mono.clone()) + .text_sm() + .text_color(fg) + .child(short_mid(recipient.trim())), + ), + ); + for row in view.extra_rows { + card = card.child(kv_money_row( + row.label, + (row.compute)(gross), + mono.clone(), + fg, + muted, + )); + } + + self.commit_shell( + view, + v_flex() + .w_full() + .gap_4() + .child(self.commit_heading(view, view.review_title, view.review_subtitle, cx)) + .child(card) + .child(self.commit_honesty(view, cx)) + .children(flow.error.as_ref().map(|e| error_line(e, cx))) + .child(self.hold_to_confirm(view, cx)) + .child( + Button::new(view.edit_button_id) + .ghost() + .w_full() + .label("Edit") + .on_click(cx.listener(|this, _, _, cx| (view.on_edit)(this, cx))), + ) + .into_any_element(), + ) + } + + /// Done: the action broadcast — on its way. The success copy is per-surface. + fn render_commit_done( + &self, + view: &'static CommitView, + tx: String, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let border = theme.border; + let surface = theme.secondary; + let success = theme.success; + let mono = theme.mono_font_family.clone(); + + self.commit_shell( + view, + v_flex() + .w_full() + .items_center() + .gap_4() + .child( + Icon::new(IconName::CircleCheck) + .text_color(success) + .flex_shrink_0(), + ) + .child( + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child(view.done_title), + ) + .child( + div() + .text_sm() + .text_color(muted) + .text_center() + .child(view.done_body), + ) + .child( + div() + .w_full() + .px_3() + .py_2() + .rounded_lg() + .border_1() + .border_color(border) + .bg(surface) + .font_family(mono) + .text_xs() + .text_color(muted) + .child(short_mid(&tx)), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new(view.copy_button_id) + .ghost() + .label("Copy tx hash") + .on_click(cx.listener(move |_, _, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(tx.clone())); + })), + ) + .child( + Button::new(view.done_button_id) + .primary() + .label("Done") + .on_click(cx.listener(|this, _, _, cx| (view.on_done)(this, cx))), + ), + ) + .into_any_element(), + ) + } + + /// The honesty lines in a calm neutral surface (no keyline). `emphasized` lines use the + /// foreground tone; the rest are muted — matching `send_honesty` / `shield_honesty`. + fn commit_honesty( + &self, + view: &'static CommitView, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let surface = theme.secondary; + + let mut col = v_flex() + .w_full() + .gap_1p5() + .px_3() + .py_2p5() + .rounded_lg() + .bg(surface); + for line in view.honesty { + let color = if line.emphasized { fg } else { muted }; + col = col.child(div().text_xs().text_color(color).child(line.text)); + } + col + } + + /// The hand-built hold-to-confirm: an amber fill sweeps the button width over [`SHIELD_HOLD`] + /// while held; completing the hold fires the surface's confirm (via the surface-checked + /// timer in `*_hold_start`), releasing early resets it. The label sits above the sweep. + /// + /// `pub(crate)` so Swap's bespoke review screen (`swap_view.rs`) can reuse the exact same + /// amber hold widget without re-implementing the sweep + mouse wiring (it routes to + /// [`SWAP_VIEW`](crate::swap_view::SWAP_VIEW)'s hold handlers). + pub(crate) fn hold_to_confirm( + &self, + view: &'static CommitView, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let border = theme.border; + let surface = theme.secondary; + let amber_tint = crate::theme::amber_tint(theme.is_dark()); + let flow = (view.flow)(self); + let holding = flow.holding; + let busy = flow.busy; + + let label = if busy { + view.hold_label_busy + } else if holding { + view.hold_label_holding + } else { + view.hold_label_idle + }; + + let fill = if holding { + div() + .absolute() + .left_0() + .top_0() + .h_full() + .bg(amber_tint) + .with_animation( + view.hold_fill_id, + Animation::new(SHIELD_HOLD), + |el, delta| el.w(relative(delta)), + ) + .into_any_element() + } else { + div() + .absolute() + .left_0() + .top_0() + .h_full() + .w(relative(0.0)) + .into_any_element() + }; + + div() + .id(view.hold_id) + .relative() + .overflow_hidden() + .w_full() + .h(px(44.0)) + .rounded_md() + .border_1() + .border_color(border) + .bg(surface) + .cursor_pointer() + .child(fill) + .child( + div() + .relative() + .size_full() + .flex() + .items_center() + .justify_center() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child(label), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| (view.on_hold_start)(this, cx)), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, cx| (view.on_hold_cancel)(this, cx)), + ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _, _, cx| (view.on_hold_cancel)(this, cx)), + ) + } + + /// The shared centered shell for every commit state (mirrors `send_shell` / `shield_shell`). + /// `pub(crate)` so Swap's bespoke compose/review/done can sit in the same centered card frame. + pub(crate) fn commit_shell( + &self, + _view: &'static CommitView, + inner: gpui::AnyElement, + ) -> impl IntoElement { + div() + .flex_1() + .flex() + .flex_col() + .items_center() + .justify_center() + .p_8() + .child(v_flex().w(px(460.0)).items_start().child(inner)) + } + + /// The commit heading: a neutral low-chroma glyph + H1 + muted subtitle. The glyph is + /// deliberately NOT cyan/amber — these surfaces sit off the actor axis (DESIGN); the human + /// signal lives on the hold-to-confirm. `pub(crate)` so Swap's bespoke screens share the + /// identical heading treatment. + pub(crate) fn commit_heading( + &self, + view: &'static CommitView, + title: &'static str, + subtitle: &'static str, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let glyph_tone = (view.glyph_tone)(theme.is_dark()); + + h_flex() + .w_full() + .items_start() + .gap_3() + .child( + div() + .size(px(28.0)) + .rounded(px(6.0)) + .bg(glyph_tone) + .flex_shrink_0(), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + div() + .text_xl() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child(title), + ) + .child(div().text_sm().text_color(muted).child(subtitle)), + ) + } +} diff --git a/crates/deckard-app/src/errors.rs b/crates/deckard-app/src/errors.rs new file mode 100644 index 0000000..a57f3ad --- /dev/null +++ b/crates/deckard-app/src/errors.rs @@ -0,0 +1,158 @@ +//! errors — UI-facing error shaping shared across the funds-touching surfaces. Trims noisy +//! provider errors to one line (`short_err`), maps terse daemon deny `reason` tags to calm +//! user-facing copy (`humanize_deny`), and flags the deny reasons that mean the unlock session +//! ended (`is_session_ended`). Moved verbatim out of `shell.rs` so Shield/Send/Swap share one copy. + +/// Trim a noisy provider error down to one short line for the UI. +pub fn short_err(e: impl std::fmt::Display) -> String { + let line = e.to_string(); + let line = line.lines().next().unwrap_or("").trim(); + line.chars().take(140).collect() +} + +/// Map a daemon deny/`reason` tag to a calm, user-facing line (the wire tags are terse + +/// machine-readable; the UI shouldn't show `chain_mismatch` raw). +pub fn humanize_deny(reason: &str) -> String { + // The broadcast error carries a variable RPC suffix, so match it by prefix. + if reason.starts_with("broadcast_failed") { + return "the deposit couldn't be broadcast — check your network, then review again".into(); + } + match reason { + "locked" => "unlock your wallet first".into(), + "revoked" => "the signer is paused (STOP is active)".into(), + "chain_mismatch" => { + "the signer is on a different chain than this deposit — reconcile the chain first" + .into() + } + "over_cap" | "cap_exceeded" => "it exceeds the agent's spending cap".into(), + "off_allowlist" => "the recipient isn't on the allowlist".into(), + "undecodable" => "the deposit calldata didn't validate".into(), + "shield_to_mismatch" => { + "the deposit doesn't target the Railgun contract for this chain".into() + } + "not_approved" => "this deposit hasn't been approved yet — review it again".into(), + "unknown_request" => { + "the signer session was reset — review the deposit again".into() + } + "erc20_unsupported_v1" => "only native-ETH shields are supported in v1".into(), + "unsupported_v1" => "that action isn't supported in v1".into(), + "broadcast_timeout" => { + "the network didn't confirm in time — your deposit may already be in flight, so check your activity before retrying" + .into() + } + "already_executed" => "this deposit was already submitted".into(), + other => other.to_string(), + } +} + +/// True for a daemon `reason` that means the unlock **session ended** — the key was zeroized +/// by a STOP (an external `RevokeAll` from an MCP client, or the daemon is otherwise `Locked`). +/// The app must return to the unlock gate, not just show an inline error: a propose against a +/// locked daemon answers `locked`; an execute of a prior request answers `revoked`. +pub fn is_session_ended(reason: &str) -> bool { + matches!(reason, "locked" | "revoked") +} + +#[cfg(test)] +mod tests { + use super::{humanize_deny, is_session_ended}; + + #[test] + fn session_ended_matches_only_stop_states() { + // A locked daemon answers `locked` to a propose; an execute after STOP answers + // `revoked`. Both must bounce the app back to the unlock gate. + assert!(is_session_ended("locked")); + assert!(is_session_ended("revoked")); + // Ordinary policy denials stay inline (the app stays Ready, shows the reason). + for inline in [ + "over_cap", + "off_allowlist", + "chain_mismatch", + "shield_to_mismatch", + "not_approved", + "already_executed", + "broadcast_timeout", + ] { + assert!(!is_session_ended(inline), "{inline} must stay inline"); + } + } + + #[test] + fn humanize_deny_maps_known_tags_to_their_lines() { + // A representative arm from each match clause — the exact copy the UI must show. + assert_eq!(humanize_deny("locked"), "unlock your wallet first"); + assert_eq!( + humanize_deny("revoked"), + "the signer is paused (STOP is active)" + ); + assert_eq!( + humanize_deny("chain_mismatch"), + "the signer is on a different chain than this deposit — reconcile the chain first" + ); + // The two-tag arm collapses to one line. + assert_eq!( + humanize_deny("over_cap"), + "it exceeds the agent's spending cap" + ); + assert_eq!( + humanize_deny("cap_exceeded"), + "it exceeds the agent's spending cap" + ); + assert_eq!( + humanize_deny("off_allowlist"), + "the recipient isn't on the allowlist" + ); + assert_eq!( + humanize_deny("undecodable"), + "the deposit calldata didn't validate" + ); + assert_eq!( + humanize_deny("shield_to_mismatch"), + "the deposit doesn't target the Railgun contract for this chain" + ); + assert_eq!( + humanize_deny("not_approved"), + "this deposit hasn't been approved yet — review it again" + ); + assert_eq!( + humanize_deny("unknown_request"), + "the signer session was reset — review the deposit again" + ); + assert_eq!( + humanize_deny("erc20_unsupported_v1"), + "only native-ETH shields are supported in v1" + ); + assert_eq!( + humanize_deny("unsupported_v1"), + "that action isn't supported in v1" + ); + assert_eq!( + humanize_deny("broadcast_timeout"), + "the network didn't confirm in time — your deposit may already be in flight, so check your activity before retrying" + ); + assert_eq!( + humanize_deny("already_executed"), + "this deposit was already submitted" + ); + } + + #[test] + fn humanize_deny_matches_broadcast_failed_by_prefix() { + // The broadcast error carries a variable RPC suffix, so any `broadcast_failed*` maps to + // the same calm line. + let line = "the deposit couldn't be broadcast — check your network, then review again"; + assert_eq!(humanize_deny("broadcast_failed"), line); + assert_eq!( + humanize_deny("broadcast_failed: connection refused (http://localhost:8545)"), + line + ); + } + + #[test] + fn humanize_deny_passes_unknown_tags_through() { + // An unrecognised tag falls through unchanged (the `other => other.to_string()` arm) — + // the UI shows it raw rather than swallowing a new, un-mapped reason. + assert_eq!(humanize_deny("some_new_reason"), "some_new_reason"); + assert_eq!(humanize_deny(""), ""); + } +} diff --git a/crates/deckard-app/src/main.rs b/crates/deckard-app/src/main.rs index 88cce70..a936612 100644 --- a/crates/deckard-app/src/main.rs +++ b/crates/deckard-app/src/main.rs @@ -8,6 +8,9 @@ //! bundle identifier, swap `assets/icon.png`, then start editing the views. mod capture; +mod commit_flow; +mod commit_view; +mod errors; mod money; mod onboarding; mod palette; diff --git a/crates/deckard-app/src/send_view.rs b/crates/deckard-app/src/send_view.rs index b981dc4..b6c2778 100644 --- a/crates/deckard-app/src/send_view.rs +++ b/crates/deckard-app/src/send_view.rs @@ -1,451 +1,98 @@ -//! Send — the native-ETH transfer flow. Three states over one centered card: **compose** -//! (amount + a `0x…`/ENS recipient) → **review** (a clear-signing card: amount / recipient + an -//! honesty line + a deliberate hold-to-confirm) → **done** (the transfer is broadcast and on -//! its way). +//! Send — the native-ETH transfer flow's [`CommitView`] descriptor. The actual compose → review +//! → done rendering lives in `commit_view.rs` (the generic renderer shared with Shield in Step 2); +//! this file is now just the byte-for-byte table of Send's copy, button ids, the heading glyph, +//! and the handler hooks. //! -//! Mirrors `shield_view`: the honesty + deliberate-hold model is DESIGN's clear-signing engine -//! (plain language, exact mono figures, danger early, confirm is a hold never a tap). The amber -//! fill-sweep animates over the same [`SHIELD_HOLD`] span so the bar fills exactly as the -//! transfer signs. A send has no Railgun fee and no private side — so there is no fee row and no -//! net line, just what leaves and where it goes. +//! A send has no Railgun fee and no private side — so `extra_rows` is empty (no fee row, no net +//! line), the compose hint is a single static line, and the honesty surface has two lines. The +//! heading glyph is the neutral low-chroma "public" identity tone (a public transfer sits off the +//! cyan/agent axis; the human signal lives on the amber hold-to-confirm, not the heading). -use gpui::{ - div, px, relative, Animation, AnimationExt, ClipboardItem, Context, FontWeight, - InteractiveElement, IntoElement, MouseButton, ParentElement, Styled, -}; -use gpui_component::{ - button::{Button, ButtonVariants}, - h_flex, - input::Input, - v_flex, ActiveTheme, Disableable, Icon, IconName, -}; +use gpui::Context; -use deckard_core::U256; - -use crate::money::money; -use crate::shell::{SendProposal, Shell, Surface, SHIELD_HOLD}; +use crate::commit_view::{CommitView, HonestyLine}; +use crate::shell::{Shell, Surface}; use crate::theme; -/// Middle-truncate a long address (`0x…`) for a tight row (matches `shield_view`). -fn short_mid(s: &str) -> String { - if s.len() >= 16 { - format!("{}…{}", &s[..10], &s[s.len() - 6..]) - } else { - s.to_string() - } -} - -impl Shell { - /// Dispatch to the active send state: done (broadcast) → review (proposed) → compose. - pub fn render_send(&self, cx: &mut Context) -> impl IntoElement { - if let Some(tx) = self.send_tx { - return self.render_send_done(tx.to_string(), cx).into_any_element(); - } - if let Some(proposal) = self.send_proposal.clone() { - return self.render_send_review(proposal, cx).into_any_element(); - } - self.render_send_compose(cx).into_any_element() - } - - /// Compose: amount (ETH) + a `0x…`/ENS recipient, then Review. The send glyph is a neutral, - /// low-chroma "public" mark (DESIGN: a public transfer sits off the cyan/agent axis; the - /// human signal lives on the amber hold-to-confirm, not the heading). - fn render_send_compose(&self, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let muted = theme.muted_foreground; - let busy = self.send_busy; - - // Validity drives the Review button's disabled state, re-evaluated live via the input - // subscriptions (same as the shield compose screen). - let amount_raw = self.send_amount.read(cx).value().to_string(); - let recipient_raw = self.send_recipient.read(cx).value().to_string(); - let can_review = crate::signer::parse_eth_to_wei(&amount_raw) - .map(|w| w > U256::ZERO) - .unwrap_or(false) - && !recipient_raw.trim().is_empty(); - - self.send_shell( - v_flex() - .w_full() - .gap_5() - .child(self.send_heading( - "Send ETH", - "Transfer native ETH from your wallet. This transaction is public on Ethereum and can't be undone.", - cx, - )) - .child( - v_flex() - .w_full() - .gap_2() - .child(field_label("Amount", muted)) - .child(Input::new(&self.send_amount).w_full()), - ) - .child( - v_flex() - .w_full() - .gap_2() - .child(field_label("Recipient (0x address or ENS name)", muted)) - .child(Input::new(&self.send_recipient).w_full()), - ) - .children(self.send_error.as_ref().map(|e| error_line(e, cx))) - .child( - h_flex() - .w_full() - .gap_2() - .child( - Button::new("send-review") - .primary() - .label(if busy { "Reviewing…" } else { "Review transfer" }) - .disabled(busy || !can_review) - .on_click(cx.listener(|this, _, _, cx| this.review_send(cx))), - ) - .child( - Button::new("send-cancel") - .ghost() - .label("Cancel") - .on_click( - cx.listener(|this, _, _, cx| this.open(Surface::Home, cx)), - ), - ), - ) - .child( - div().text_xs().text_color(muted).child( - "An ENS name is resolved when you review — you'll confirm the exact address before sending.", - ), - ) - .into_any_element(), - ) - } - - /// Review: the clear-signing card (amount / recipient) + an honesty line + a deliberate - /// hold-to-confirm. Rendered from the proposal SNAPSHOT — the amount + resolved recipient - /// that are actually inside the signed intent — never the live input. - fn render_send_review( - &self, - proposal: SendProposal, - cx: &mut Context, - ) -> impl IntoElement { - let theme = cx.theme(); - let fg = theme.foreground; - let muted = theme.muted_foreground; - let border = theme.border; - let surface = theme.secondary; - let mono = theme.mono_font_family.clone(); - - let amount = proposal.intent.value; - let recipient = proposal.recipient.clone(); - - self.send_shell( - v_flex() - .w_full() - .gap_4() - .child(self.send_heading( - "Review transfer", - "Confirm the amount and the destination address. Hold to send.", - cx, - )) - // The clear-signing card: one frame, no interior grid lines. - .child( - v_flex() - .w_full() - .p_4() - .rounded_lg() - .border_1() - .border_color(border) - .bg(surface) - .child( - h_flex() - .w_full() - .justify_between() - .items_center() - .py_1p5() - .child(div().text_sm().text_color(muted).child("Amount")) - .child(div().text_sm().child(money( - amount, - 18, - 6, - Some("ETH"), - false, - mono.clone(), - fg, - muted, - ))), - ) - .child( - h_flex() - .w_full() - .justify_between() - .items_center() - .py_1p5() - .child(div().text_sm().text_color(muted).child("To")) - .child( - div() - .font_family(mono.clone()) - .text_sm() - .text_color(fg) - .child(short_mid(recipient.trim())), - ), - ), - ) - .child(self.send_honesty(cx)) - .children(self.send_error.as_ref().map(|e| error_line(e, cx))) - .child(self.send_hold_to_confirm(cx)) - .child( - Button::new("send-edit") - .ghost() - .w_full() - .label("Edit") - .on_click(cx.listener(|this, _, _, cx| this.open_send(cx))), - ) - .into_any_element(), - ) - } - - /// Done: the transfer broadcast — on its way. Mirrors `render_shield_done`, minus the - /// private-sync reassurance (a public send has no note to settle). - fn render_send_done(&self, tx: String, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let fg = theme.foreground; - let muted = theme.muted_foreground; - let border = theme.border; - let surface = theme.secondary; - let success = theme.success; - let mono = theme.mono_font_family.clone(); - - self.send_shell( - v_flex() - .w_full() - .items_center() - .gap_4() - .child( - Icon::new(IconName::CircleCheck) - .text_color(success) - .flex_shrink_0(), - ) - .child( - div() - .text_lg() - .font_weight(FontWeight::SEMIBOLD) - .text_color(fg) - .child("Transfer broadcast"), - ) - .child( - div() - .text_sm() - .text_color(muted) - .text_center() - .child("Your ETH is on its way. It settles after on-chain confirmation; your balance updates on the next sync."), - ) - .child( - div() - .w_full() - .px_3() - .py_2() - .rounded_lg() - .border_1() - .border_color(border) - .bg(surface) - .font_family(mono) - .text_xs() - .text_color(muted) - .child(short_mid(&tx)), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("send-copy-tx") - .ghost() - .label("Copy tx hash") - .on_click(cx.listener(move |_, _, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(tx.clone())); - })), - ) - .child( - Button::new("send-done") - .primary() - .label("Done") - .on_click( - cx.listener(|this, _, _, cx| this.open(Surface::Home, cx)), - ), - ), - ) - .into_any_element(), - ) - } - - /// The honesty lines in a calm neutral surface (no keyline): a send is public and final. - fn send_honesty(&self, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let fg = theme.foreground; - let muted = theme.muted_foreground; - let surface = theme.secondary; - - v_flex() - .w_full() - .gap_1p5() - .px_3() - .py_2p5() - .rounded_lg() - .bg(surface) - .child( - div() - .text_xs() - .text_color(fg) - .child("This transfer is public on Ethereum and can't be undone."), - ) - .child(div().text_xs().text_color(muted).child( - "Double-check the destination address — funds sent to the wrong address are lost.", - )) - } - - /// The hand-built hold-to-confirm: an amber fill sweeps the button width over - /// [`SHIELD_HOLD`] while held; completing the hold fires `confirm_send`, releasing early - /// resets it. Mirrors `shield_view::hold_to_confirm` (the amber = human-confirm signal). - fn send_hold_to_confirm(&self, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let fg = theme.foreground; - let border = theme.border; - let surface = theme.secondary; - let amber_tint = theme::amber_tint(theme.is_dark()); - let holding = self.send_holding; - let busy = self.send_busy; - - let label = if busy { - "Sending…" - } else if holding { - "Keep holding…" - } else { - "Hold to send" - }; - - let fill = if holding { - div() - .absolute() - .left_0() - .top_0() - .h_full() - .bg(amber_tint) - .with_animation("send-fill", Animation::new(SHIELD_HOLD), |el, delta| { - el.w(relative(delta)) - }) - .into_any_element() - } else { - div() - .absolute() - .left_0() - .top_0() - .h_full() - .w(relative(0.0)) - .into_any_element() - }; - - div() - .id("send-hold") - .relative() - .overflow_hidden() - .w_full() - .h(px(44.0)) - .rounded_md() - .border_1() - .border_color(border) - .bg(surface) - .cursor_pointer() - .child(fill) - .child( - div() - .relative() - .size_full() - .flex() - .items_center() - .justify_center() - .text_sm() - .font_weight(FontWeight::SEMIBOLD) - .text_color(fg) - .child(label), - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, _, _, cx| this.send_hold_start(cx)), - ) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _, _, cx| this.send_hold_cancel(cx)), - ) - .on_mouse_up_out( - MouseButton::Left, - cx.listener(|this, _, _, cx| this.send_hold_cancel(cx)), - ) - } - - /// The shared centered shell for every send state (mirrors `shield_shell`). - fn send_shell(&self, inner: gpui::AnyElement) -> impl IntoElement { - div() - .flex_1() - .flex() - .flex_col() - .items_center() - .justify_center() - .p_8() - .child(v_flex().w(px(460.0)).items_start().child(inner)) - } - - /// The send heading: a neutral low-chroma "public" glyph + H1 + muted subtitle. The glyph - /// is the desaturated identity tone (the public/your-wallet tone used by the balance hero), - /// deliberately NOT cyan/amber — the human signal lives on the hold-to-confirm. - fn send_heading( - &self, - title: &str, - subtitle: &str, - cx: &mut Context, - ) -> impl IntoElement { - let theme = cx.theme(); - let fg = theme.foreground; - let muted = theme.muted_foreground; - let public_tone = theme::identity_square(theme.is_dark()); +/// The Send surface descriptor. Reproduces the shipped Send flow (#54) EXACTLY: same strings, +/// button ids, layout, and the amber hold-to-confirm. Routed from `Shell::render` via +/// `render_commit(&SEND_VIEW, cx)`. +pub static SEND_VIEW: CommitView = CommitView { + // The send flow's live state + the neutral "public" heading glyph. + flow: send_flow, + glyph_tone: theme::identity_square, + + // --- compose --- + compose_title: "Send ETH", + compose_subtitle: + "Transfer native ETH from your wallet. This transaction is public on Ethereum and can't be undone.", + recipient_label: "Recipient (0x address or ENS name)", + review_button_id: "send-review", + review_label: "Review transfer", + cancel_button_id: "send-cancel", + compose_hint: Some( + "An ENS name is resolved when you review — you'll confirm the exact address before sending.", + ), + compose_hint_dynamic: None, + + // --- review --- + review_title: "Review transfer", + review_subtitle: "Confirm the amount and the destination address. Hold to send.", + // No Railgun fee / private net line for a public send. + extra_rows: &[], + honesty: &[ + HonestyLine { + text: "This transfer is public on Ethereum and can't be undone.", + emphasized: true, + }, + HonestyLine { + text: "Double-check the destination address — funds sent to the wrong address are lost.", + emphasized: false, + }, + ], + hold_id: "send-hold", + hold_fill_id: "send-fill", + hold_label_idle: "Hold to send", + hold_label_holding: "Keep holding…", + hold_label_busy: "Sending…", + edit_button_id: "send-edit", + + // --- done --- + done_title: "Transfer broadcast", + done_body: + "Your ETH is on its way. It settles after on-chain confirmation; your balance updates on the next sync.", + copy_button_id: "send-copy-tx", + done_button_id: "send-done", + + // --- handlers (the existing `impl Shell` send methods) --- + on_review: review_send, + on_edit: open_send, + on_cancel: open_home, + on_done: open_home, + on_hold_start: send_hold_start, + on_hold_cancel: send_hold_cancel, +}; - h_flex() - .w_full() - .items_start() - .gap_3() - .child( - div() - .size(px(28.0)) - .rounded(px(6.0)) - .bg(public_tone) - .flex_shrink_0(), - ) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child( - div() - .text_xl() - .font_weight(FontWeight::SEMIBOLD) - .text_color(fg) - .child(title.to_string()), - ) - .child( - div() - .text_sm() - .text_color(muted) - .child(subtitle.to_string()), - ), - ) - } +/// Re-acquire the send flow's state from the shell (the descriptor's `flow` selector). +fn send_flow(shell: &Shell) -> &crate::commit_flow::CommitFlow { + &shell.send } -/// A tiny uppercase field label (matches the shield/sidebar section-label treatment). -fn field_label(text: &'static str, muted: gpui::Hsla) -> impl IntoElement { - div().text_xs().text_color(muted).child(text) +// Thin free-function adapters so the descriptor's `fn(&mut Shell, &mut Context)` slots can +// name the surface's handlers (a `&'static` descriptor can't hold a closure, and the methods take +// `&mut self`). Each is a one-line forward to the existing handler. +fn review_send(shell: &mut Shell, cx: &mut Context) { + shell.review_send(cx); } - -/// A one-line send error, in `danger`. -fn error_line(msg: &str, cx: &mut Context) -> impl IntoElement { - div() - .text_sm() - .text_color(cx.theme().danger) - .child(format!("⚠ {msg}")) +fn open_send(shell: &mut Shell, cx: &mut Context) { + shell.open_send(cx); +} +fn open_home(shell: &mut Shell, cx: &mut Context) { + shell.open(Surface::Home, cx); +} +fn send_hold_start(shell: &mut Shell, cx: &mut Context) { + shell.send_hold_start(cx); +} +fn send_hold_cancel(shell: &mut Shell, cx: &mut Context) { + shell.send_hold_cancel(cx); } diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index 41d44fb..fadd521 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -17,9 +17,8 @@ use gpui_component::{ v_flex, ActiveTheme, TitleBar, }; -use alloy_primitives::B256; use deckard_contract::{ - Decision, ExecuteResult, Intent, Policy, RequestId, ShieldStatus, SignerRequest, SignerResponse, + Decision, ExecuteResult, Intent, Policy, ShieldStatus, SignerRequest, SignerResponse, }; use deckard_core::{ Address, EthProvider, KdfParams, Portfolio, ReadStatus, ShieldedHandle, Vault, WordCount, U256, @@ -28,6 +27,8 @@ use zeroize::Zeroizing; use deckard_signerd::SignerClient; +use crate::commit_flow::CommitFlow; +use crate::errors::{humanize_deny, is_session_ended, short_err}; use crate::settings::{Settings, ThemeModePref}; use crate::signer::{self, AppSigner}; use crate::theme; @@ -42,56 +43,6 @@ use crate::{ /// runs for the same span so the bar fills exactly as the action fires. pub(crate) const SHIELD_HOLD: Duration = Duration::from_millis(900); -/// Trim a noisy provider error down to one short line for the UI. -fn short_err(e: impl std::fmt::Display) -> String { - let line = e.to_string(); - let line = line.lines().next().unwrap_or("").trim(); - line.chars().take(140).collect() -} - -/// Map a daemon deny/`reason` tag to a calm, user-facing line (the wire tags are terse + -/// machine-readable; the UI shouldn't show `chain_mismatch` raw). -fn humanize_deny(reason: &str) -> String { - // The broadcast error carries a variable RPC suffix, so match it by prefix. - if reason.starts_with("broadcast_failed") { - return "the deposit couldn't be broadcast — check your network, then review again".into(); - } - match reason { - "locked" => "unlock your wallet first".into(), - "revoked" => "the signer is paused (STOP is active)".into(), - "chain_mismatch" => { - "the signer is on a different chain than this deposit — reconcile the chain first" - .into() - } - "over_cap" | "cap_exceeded" => "it exceeds the agent's spending cap".into(), - "off_allowlist" => "the recipient isn't on the allowlist".into(), - "undecodable" => "the deposit calldata didn't validate".into(), - "shield_to_mismatch" => { - "the deposit doesn't target the Railgun contract for this chain".into() - } - "not_approved" => "this deposit hasn't been approved yet — review it again".into(), - "unknown_request" => { - "the signer session was reset — review the deposit again".into() - } - "erc20_unsupported_v1" => "only native-ETH shields are supported in v1".into(), - "unsupported_v1" => "that action isn't supported in v1".into(), - "broadcast_timeout" => { - "the network didn't confirm in time — your deposit may already be in flight, so check your activity before retrying" - .into() - } - "already_executed" => "this deposit was already submitted".into(), - other => other.to_string(), - } -} - -/// True for a daemon `reason` that means the unlock **session ended** — the key was zeroized -/// by a STOP (an external `RevokeAll` from an MCP client, or the daemon is otherwise `Locked`). -/// The app must return to the unlock gate, not just show an inline error: a propose against a -/// locked daemon answers `locked`; an execute of a prior request answers `revoked`. -fn is_session_ended(reason: &str) -> bool { - matches!(reason, "locked" | "revoked") -} - /// Run `prework` (which seals + writes the keystore for create/import/migrate, or is a no-op /// for a plain unlock), then unlock OVER THE DAEMON SOCKET — the key is decrypted in the /// daemon, never here. Returns the wallet address or a one-line, user-facing error. Always @@ -128,37 +79,32 @@ pub enum Surface { Send, /// The shield trigger flow (T5): compose a deposit → review card → hold-to-confirm. Shield, + /// The CoW swap flow (#25): compose (sell amount + token pickers) → get a quote → review the + /// priced order → hold-to-confirm (propose + approve + resolve + sign + submit). The daemon + /// signs the EIP-712 order; the app posts it to the orderbook. The app holds no key. + Swap, Settings, } /// A reviewed-and-allowed shield, ready to sign. Carries a **recipient snapshot** taken at /// review time so the clear-signing card always shows the recipient that is actually inside /// `intent` — never a value the user edited in the input after `propose` landed. -#[derive(Clone)] -pub struct ShieldProposal { - pub intent: Intent, - pub request_id: RequestId, - pub recipient: String, - /// True when the daemon answered `NeedsApproval` (over-cap, or the mainnet guardrail - /// downgrading an auto-allow). The completed hold-to-confirm IS the human approval — - /// the app is the wire contract's designated resolver — so confirm sends - /// `Resolve{approved: true}` before `Execute`. - pub needs_resolve: bool, -} +/// +/// Now the shared [`crate::commit_flow::Proposal`] (Shield + Send were field-identical and +/// collapsed in Step 0); the alias keeps every `ShieldProposal { .. }` construction site unchanged. +pub type ShieldProposal = crate::commit_flow::Proposal; /// A reviewed-and-allowed native send, ready to sign. Carries a **recipient snapshot** (the /// resolved, checksummed destination address that is actually inside `intent.to`) so the /// clear-signing card always shows where the ETH is going — never the raw `0x…`/ENS text the -/// user could have since edited. The `needs_resolve` flag mirrors [`ShieldProposal`]: an -/// over-cap (or `Always`-approval) send returns `NeedsApproval`, and the completed -/// hold-to-confirm IS that human approval, so confirm sends `Resolve{approved: true}` first. -#[derive(Clone)] -pub struct SendProposal { - pub intent: Intent, - pub request_id: RequestId, - pub recipient: String, - pub needs_resolve: bool, -} +/// user could have since edited. The `needs_resolve` flag: an over-cap (or `Always`-approval) +/// send returns `NeedsApproval`, and the completed hold-to-confirm IS that human approval, so +/// confirm sends `Resolve{approved: true}` first. +/// +/// The same shared [`crate::commit_flow::Proposal`] as [`ShieldProposal`] — the two flows were +/// field-identical and collapsed in Step 0; the alias keeps every `SendProposal { .. }` site +/// unchanged. +pub type SendProposal = crate::commit_flow::Proposal; /// The auth gate that wraps the whole app. Until it reaches `Ready`, the portfolio and /// every funds-touching surface are hidden behind onboarding or the unlock screen. @@ -236,48 +182,47 @@ pub struct Shell { pub allow_screen_capture: bool, // --- shield trigger flow (T5) --- - /// Deposit amount (ETH, free text) and the `0zk…` recipient. Free-text recipient is v1; - /// auto-filling the user's OWN railgun address is Wave 2. - pub shield_amount: Entity, - pub shield_recipient: Entity, - /// Set once `propose` returns `Allow`. `Some` means the review card + hold-to-confirm are - /// live; it carries a recipient snapshot so the card can't show a since-edited address. - pub shield_proposal: Option, - /// Bumped on each `review_shield` (and on reset) so a slow propose reply for a - /// since-cancelled/re-issued review can't install a stale proposal. - shield_review_epoch: u64, - /// True while a `propose`/`execute` round-trip runs on a background thread. - pub shield_busy: bool, - /// One-line, user-facing shield error (parse / build / deny / broadcast). - pub shield_error: Option, - /// Set on a successful `execute` broadcast — the demo's "deposit is moving private" state. - pub shield_tx: Option, - /// True while the confirm button is being held; drives the amber fill-sweep. - pub shield_holding: bool, - /// Bumped on each hold-start so a stale hold timer can't fire a later confirm. - shield_hold_epoch: u64, + /// The shield trigger flow's state machine: the amount + `0zk…` recipient inputs (the + /// recipient auto-fills the wallet's own 0zk address; free-text edit is allowed), the reviewed + /// proposal, the busy/hold flags, the surfaced error/broadcast, and the review/hold epochs that + /// fence stale background replies and stale hold timers. Migrated off the flat `shield_*` fields + /// onto [`CommitFlow`] (Step 2); access its state via deref (`self.shield.proposal`, + /// `self.shield.busy`, …). The deposit's private-side broadcast wiring (`shield_status` / + /// resync / `watch_shielded_sync`) stays inline in `confirm_shield`. + pub shield: CommitFlow, // --- native-ETH send flow (mirrors the shield trigger flow above) --- - /// Send amount (ETH, free text) and the recipient (a `0x…` address or an ENS name, which - /// is forward-resolved at review time — reuse of the watch-address resolve path). - pub send_amount: Entity, - pub send_recipient: Entity, - /// Set once `propose` returns `Allow`/`NeedsApproval`; `Some` means the review card + - /// hold-to-confirm are live. Carries the resolved-recipient snapshot for the card. - pub send_proposal: Option, - /// Bumped on each `review_send` (and on reset) so a slow propose/resolve reply for a - /// since-cancelled/re-issued review can't install a stale proposal. - send_review_epoch: u64, - /// True while a `resolve`/`propose`/`execute` round-trip runs on a background thread. - pub send_busy: bool, - /// One-line, user-facing send error (parse / resolve / deny / broadcast). - pub send_error: Option, - /// Set on a successful `execute` broadcast — the send's "on its way" confirmation state. - pub send_tx: Option, - /// True while the confirm button is being held; drives the amber fill-sweep. - pub send_holding: bool, - /// Bumped on each hold-start so a stale hold timer can't fire a later confirm. - send_hold_epoch: u64, + /// The native-ETH send flow's state machine: the amount + recipient inputs (a `0x…` address + /// or an ENS name, forward-resolved at review time), the reviewed proposal, the busy/hold + /// flags, the surfaced error/broadcast, and the review/hold epochs that fence stale background + /// replies and stale hold timers. Migrated off the flat `send_*` fields onto [`CommitFlow`] + /// (Step 1); access its state via deref (`self.send.proposal`, `self.send.busy`, …). + pub send: CommitFlow, + + // --- CoW swap flow (#25) --- + /// The swap flow's commit state machine. Reuses [`CommitFlow`]'s `amount` input (the sell + /// amount) + the proposal/busy/hold/error/tx core for the review→hold→sign lifecycle; the + /// `recipient` input is unused (a swap's receiver is always your own wallet). The bespoke + /// compose (token pickers, quote summary) and the bespoke review/done live in `swap_view.rs`; + /// the amber hold widget is shared via [`SWAP_VIEW`](crate::swap_view::SWAP_VIEW). + pub swap: CommitFlow, + /// The sell-side token, an address from [`tokens_for(chain_id)`](deckard_core::tokens_for). + /// `None` until a chain with a curated list is active (mainnet/Sepolia) and the picker seeds it. + pub swap_sell_token: Option
, + /// The buy-side token (same source). Seeded distinct from `swap_sell_token`. + pub swap_buy_token: Option
, + /// The last fetched quote (drives the quote summary + the bound order). Cleared on every + /// compose edit so a stale quote can't be signed against changed inputs. + pub swap_quote: Option, + /// True while a `Get quote` round-trip runs on a background thread (the one allowed loading + /// state on compose; never a spinner-forever — it clears on reply/error). + pub swap_quoting: bool, + /// The created order's CoW uid, set on a successful submit — drives the bespoke done screen + /// (a swap produces a uid string, not a B256 tx hash, so it can't ride `CommitState.tx`). + pub swap_uid: Option, + /// Bumped on every quote request (and on reset) so a slow quote reply for a since-changed + /// compose can't install a stale quote. Mirrors the review/hold epoch pattern. + swap_quote_epoch: u64, // --- shielded balance (Wave 2: T9 sync + T10 lifecycle) --- /// The read-only Railgun sync actor (None until the view grant is fetched post-unlock, @@ -438,7 +383,8 @@ impl Shell { InputState::new(window, cx).placeholder("12 / 24-word phrase, or a 0x private key") }); - // Shield flow inputs (T5): amount in ETH + the 0zk recipient (free text in v1). + // Shield flow inputs (T5): amount in ETH + the 0zk recipient (auto-filled with the wallet's + // own 0zk address; free-text edit is allowed). let shield_amount = cx.new(|cx| InputState::new(window, cx).placeholder("Amount in ETH, e.g. 0.05")); let shield_recipient = @@ -460,6 +406,10 @@ impl Shell { }, ) .detach(); + // The subscriptions above reference the entities (Enter-to-review / live-validity); now + // move the two inputs into the shield flow's state machine (Step 2). The subscriptions keep + // firing — they hold their own entity handles, independent of where the inputs now live. + let shield = CommitFlow::new(shield_amount, shield_recipient); // Send flow inputs: amount in ETH + a `0x…`/ENS recipient. Same live-validity + // Enter-to-review wiring as the shield fields above. @@ -482,6 +432,10 @@ impl Shell { }, ) .detach(); + // The subscriptions above reference the entities (Enter-to-review / live-validity); now + // move the two inputs into the send flow's state machine (Step 1). The subscriptions keep + // firing — they hold their own entity handles, independent of where the inputs now live. + let send = CommitFlow::new(send_amount, send_recipient); // Submit-on-Enter for each auth field (keyboard-first). cx.subscribe(&create_pass2, |this, _, event: &InputEvent, cx| { @@ -525,7 +479,7 @@ impl Shell { // One resolved runtime chain id (env > settings > default), threaded to the daemon // launch, the shield builder, and the Railgun sync. let chain_id = settings.effective_chain_id(); - let eth = EthProvider::spawn(current_rpc.clone()); + let eth = EthProvider::spawn(current_rpc.clone(), chain_id); // Log the resolved runtime config once (the RPC is REDACTED to scheme://host — it may // carry an API key). Makes "which chain / RPC / mode am I on?" answerable from the log, @@ -585,24 +539,8 @@ impl Shell { agent_policy: None, capture_applied: false, allow_screen_capture, - shield_amount, - shield_recipient, - shield_proposal: None, - shield_review_epoch: 0, - shield_busy: false, - shield_error: None, - shield_tx: None, - shield_holding: false, - shield_hold_epoch: 0, - send_amount, - send_recipient, - send_proposal: None, - send_review_epoch: 0, - send_busy: false, - send_error: None, - send_tx: None, - send_holding: false, - send_hold_epoch: 0, + shield, + send, shielded: None, railgun_address: None, recipient_autofilled: false, @@ -692,8 +630,8 @@ impl Shell { // Invalidate any in-flight grant fetch and clear shield inputs on the next render. self.auth_epoch = self.auth_epoch.wrapping_add(1); self.pending_shield_clear = true; - self.reset_shield(); - self.reset_send(); + self.shield.reset(); + self.send.reset(); self.auth = AuthStep::Unlock; self.palette_open = false; cx.notify(); @@ -1276,7 +1214,7 @@ impl Shell { return; } self.current_rpc = url.clone(); - self.eth = EthProvider::spawn(url); + self.eth = EthProvider::spawn(url, self.settings.effective_chain_id()); self.retarget(cx); // Re-point the shielded sync at the new RPC too (drops the old worker, clears stale // private state) so public and private reads can't diverge across endpoints. @@ -1302,15 +1240,13 @@ impl Shell { pub fn open(&mut self, surface: Surface, cx: &mut Context) { // Leaving Shield (back, palette, a nav click) cancels any in-progress hold so its // timer can't fire a confirm after the screen is gone. - if surface != Surface::Shield && self.shield_holding { - self.shield_holding = false; - self.shield_hold_epoch = self.shield_hold_epoch.wrapping_add(1); + if surface != Surface::Shield && self.shield.holding { + self.shield.cancel_hold(); } // Same for the send hold: leaving the Send surface must cancel an in-progress hold so // its timer can't fire a confirm after the screen is gone. - if surface != Surface::Send && self.send_holding { - self.send_holding = false; - self.send_hold_epoch = self.send_hold_epoch.wrapping_add(1); + if surface != Surface::Send && self.send.holding { + self.send.cancel_hold(); } self.surface = surface; cx.notify(); @@ -1351,56 +1287,43 @@ impl Shell { if self.viewing_watch { return; } - self.reset_shield(); + self.shield.reset(); self.open(Surface::Shield, cx); } - /// Clear all transient shield state (proposal, error, broadcast, hold). Bumps the hold + - /// review epochs so any in-flight hold timer or propose reply lands as a no-op. - fn reset_shield(&mut self) { - self.shield_proposal = None; - self.shield_error = None; - self.shield_tx = None; - self.shield_busy = false; - self.shield_holding = false; - self.shield_hold_epoch = self.shield_hold_epoch.wrapping_add(1); - self.shield_review_epoch = self.shield_review_epoch.wrapping_add(1); - } - - /// Build + `propose` the shield off-thread. On `Allow`, stash the proposal so the review - /// card + hold-to-confirm appear; on `NeedsApproval`/`Deny`/parse error, surface a clear - /// line. Mirrors `do_unlock` (build off-thread, fold the result on the UI thread). + /// Build + `propose` the shield off-thread. On `Allow`/`NeedsApproval`, stash the proposal so + /// the review card + hold-to-confirm appear; on a parse/`Deny` error, surface a clear line. + /// The recipient is validated SYNCHRONOUSLY (a non-empty 0zk string — no ENS resolution, + /// unlike Send); the review TAIL is shared via [`Shell::finish_review`]. pub fn review_shield(&mut self, cx: &mut Context) { - if self.shield_busy { + if self.shield.busy { return; } - let amount = self.shield_amount.read(cx).value().to_string(); - let recipient = self.shield_recipient.read(cx).value().to_string(); + let amount = self.shield.amount.read(cx).value().to_string(); + let recipient = self.shield.recipient.read(cx).value().to_string(); let value_wei = match signer::parse_eth_to_wei(&amount) { Ok(w) if w > U256::ZERO => w, Ok(_) => { - self.shield_error = Some("Enter an amount greater than zero".into()); + self.shield.error = Some("Enter an amount greater than zero".into()); cx.notify(); return; } Err(e) => { - self.shield_error = Some(e); + self.shield.error = Some(e); cx.notify(); return; } }; if recipient.trim().is_empty() { - self.shield_error = Some("Enter a 0zk recipient address".into()); + self.shield.error = Some("Enter a 0zk recipient address".into()); cx.notify(); return; } - self.shield_error = None; - self.shield_proposal = None; - self.shield_busy = true; - // Each review supersedes the last; a slow reply for a since-cancelled/re-issued - // review checks this epoch before installing (and before touching `busy`). - self.shield_review_epoch = self.shield_review_epoch.wrapping_add(1); - let epoch = self.shield_review_epoch; + self.shield.error = None; + self.shield.proposal = None; + // begin_review bumps the epoch (each review supersedes the last) and sets `busy`; a slow + // reply for a since-cancelled/re-issued review checks this epoch before installing. + let epoch = self.shield.begin_review(); let recipient_snapshot = recipient.clone(); cx.notify(); let client = self.signer.client(); @@ -1408,72 +1331,39 @@ impl Shell { let task = cx.background_spawn(async move { let intent = signer::build_shield_intent(chain_id, &recipient, value_wei)?; let decision = client.propose_blocking(&intent)?; - Ok::<(Intent, Decision), anyhow::Error>((intent, decision)) + // The recipient SNAPSHOT inside the signed intent: the 0zk string the user reviewed — + // never a value they could have since edited in the input. + Ok::<(Intent, String, Decision), anyhow::Error>((intent, recipient_snapshot, decision)) }); cx.spawn(async move |this, cx| { let res = task.await; this.update(cx, |this, cx| { - // Guard FIRST: a stale review must not even clear `busy` (a newer review may - // own it now). - if this.shield_review_epoch != epoch { - return; - } - this.shield_busy = false; - match res { - Ok((intent, Decision::Allow)) => { - let request_id = SignerClient::request_id_for_intent(&intent); - this.shield_proposal = Some(ShieldProposal { - intent, - request_id, - recipient: recipient_snapshot, - needs_resolve: false, - }); - } - // NeedsApproval (over-cap, or the daemon's mainnet guardrail): the - // review card + hold-to-confirm ARE the human approval surface — the - // hold resolves the pending record, then executes. - Ok((intent, Decision::NeedsApproval { request_id })) => { - this.shield_proposal = Some(ShieldProposal { - intent, - request_id, - recipient: recipient_snapshot, - needs_resolve: true, - }); - } - Ok((_, Decision::Deny { reason })) => { - // An external STOP/lock ends the session — bounce to the unlock gate. - if is_session_ended(&reason) { - this.handle_session_revoked(cx); - } else { - this.shield_error = - Some(format!("Can't shield: {}", humanize_deny(&reason))); - } - } - Err(e) => this.shield_error = Some(short_err(e)), - } - cx.notify(); + this.finish_review(|s| &mut s.shield, epoch, res, "Can't shield: ", cx); }) .ok(); }) .detach(); } - /// Sign + broadcast the reviewed shield off-thread (the hold-to-confirm completed). On - /// success the deposit is on its way to a private note; surface the broadcast. + /// Sign + broadcast the reviewed shield off-thread (the hold-to-confirm completed). For a + /// `NeedsApproval` proposal the completed hold IS the approval (resolve, then execute); an + /// `Allow` goes straight to execute. On success the deposit is broadcast and on its way to a + /// private note — set `Sending`, re-sync the private balance, and start the settle watcher. + /// Mirrors `confirm_send` (plus the private-side broadcast wiring a send doesn't have). pub fn confirm_shield(&mut self, cx: &mut Context) { let Some(ShieldProposal { request_id, needs_resolve, .. - }) = self.shield_proposal.clone() + }) = self.shield.proposal.clone() else { return; }; - if self.shield_busy { + if self.shield.busy { return; } - self.shield_busy = true; - self.shield_error = None; + self.shield.busy = true; + self.shield.error = None; cx.notify(); let client = self.signer.client(); let control = self.signer.control(); @@ -1486,14 +1376,14 @@ impl Shell { cx.spawn(async move |this, cx| { let res = task.await; this.update(cx, |this, cx| { - this.shield_busy = false; + this.shield.busy = false; // Invalidate the proposal on EVERY execute attempt: a second hold must not be // able to re-broadcast. On an ambiguous timeout the deposit may already be in // flight, so retrying requires a fresh, deliberate review (new request id). - this.shield_proposal = None; + this.shield.proposal = None; match res { Ok(ExecuteResult::Broadcast { tx_hash }) => { - this.shield_tx = Some(tx_hash); + this.shield.tx = Some(tx_hash); // Just broadcast — honestly `Sending` (we don't track confirmations). // The re-sync surfaces the note; the watcher then settles to // PrivateSpendable (or Failed), never a fabricated "spendable $0". @@ -1508,11 +1398,11 @@ impl Shell { if is_session_ended(&reason) { this.handle_session_revoked(cx); } else { - this.shield_error = + this.shield.error = Some(format!("Shield denied: {}", humanize_deny(&reason))); } } - Err(e) => this.shield_error = Some(short_err(e)), + Err(e) => this.shield.error = Some(short_err(e)), } cx.notify(); }) @@ -1521,29 +1411,24 @@ impl Shell { .detach(); } - /// Begin a confirm hold: start the amber fill-sweep and a timer that fires - /// `confirm_shield` only if the hold survives [`SHIELD_HOLD`]. A per-hold epoch guards - /// against a stale timer firing after an early release / re-press. + /// Begin a confirm hold: start the amber fill-sweep and a timer that fires `confirm_shield` + /// only if the hold survives [`SHIELD_HOLD`]. A per-hold epoch guards against a stale timer + /// firing after an early release / re-press. pub fn shield_hold_start(&mut self, cx: &mut Context) { - if self.shield_holding || self.shield_busy || self.shield_proposal.is_none() { + // begin_hold guards (no-op while busy / already holding / no proposal), sets `holding`, + // bumps the hold epoch, and returns it for the timer to re-check. + let Some(epoch) = self.shield.begin_hold() else { return; - } - self.shield_holding = true; - self.shield_hold_epoch = self.shield_hold_epoch.wrapping_add(1); - let epoch = self.shield_hold_epoch; + }; cx.notify(); cx.spawn(async move |this, cx| { cx.background_executor().timer(SHIELD_HOLD).await; this.update(cx, |this, cx| { - // Only fire if THIS hold is still active (not released, not superseded) AND - // the user is still on the Shield surface — leaving via ⌘[ / palette / a - // surface change must never let a held confirm sign after the screen is gone. - if this.shield_holding - && this.shield_hold_epoch == epoch - && this.surface == Surface::Shield - && this.shield_proposal.is_some() - { - this.shield_holding = false; + // Fire only if THIS hold is still valid AND the user is still on Shield — leaving + // via ⌘[ / palette / a surface change must never sign after the screen is gone. + // (The surface check stays here — it depends on the live surface, not flow state.) + if this.surface == Surface::Shield && this.shield.hold_still_valid(epoch) { + this.shield.holding = false; this.confirm_shield(cx); } }) @@ -1555,9 +1440,7 @@ impl Shell { /// Release the confirm hold before it completed — reset the sweep; the epoch bump /// cancels the pending timer. pub fn shield_hold_cancel(&mut self, cx: &mut Context) { - if self.shield_holding { - self.shield_holding = false; - self.shield_hold_epoch = self.shield_hold_epoch.wrapping_add(1); + if self.shield.cancel_hold() { cx.notify(); } } @@ -1569,20 +1452,68 @@ impl Shell { if self.viewing_watch { return; } - self.reset_send(); + self.send.reset(); self.open(Surface::Send, cx); } - /// Clear all transient send state (proposal, error, broadcast, hold). Bumps the hold + - /// review epochs so any in-flight hold timer or propose/resolve reply lands as a no-op. - fn reset_send(&mut self) { - self.send_proposal = None; - self.send_error = None; - self.send_tx = None; - self.send_busy = false; - self.send_holding = false; - self.send_hold_epoch = self.send_hold_epoch.wrapping_add(1); - self.send_review_epoch = self.send_review_epoch.wrapping_add(1); + /// The shared review TAIL: fold a `propose` reply into a [`CommitFlow`] on the UI thread. + /// Every commit surface runs an identical post-`propose` sequence — re-acquire the flow, + /// drop a stale (superseded) reply, clear `busy`, then install the proposal on + /// `Allow`/`NeedsApproval`, bounce on a session-ended `Deny`, surface a humanized deny line + /// otherwise, or a short error. Factored out of the per-surface `review_*` so Send (now) and + /// Shield (Step 2) share one copy. + /// + /// `flow` re-acquires the surface's flow (it's only ever called *inside* `update`, never held + /// across an await, so a `fn(&mut Shell) -> &mut CommitFlow` is sound). The + /// `propose_result` carries the intent, the **recipient snapshot** (built in the prelude — the + /// checksummed `to` for Send, the input string for Shield), and the daemon `Decision`. + /// `review_deny_prefix` is the surface's leading copy on an inline deny ("Can't send: "). + fn finish_review( + &mut self, + flow: fn(&mut Shell) -> &mut CommitFlow, + epoch: u64, + propose_result: Result<(Intent, String, Decision), anyhow::Error>, + review_deny_prefix: &str, + cx: &mut Context, + ) { + // Guard FIRST: a stale review must not even clear `busy` (a newer review may own it now). + if !flow(self).review_is_current(epoch) { + return; + } + flow(self).busy = false; + match propose_result { + Ok((intent, recipient, Decision::Allow)) => { + let request_id = SignerClient::request_id_for_intent(&intent); + flow(self).proposal = Some(crate::commit_flow::Proposal { + intent, + request_id, + recipient, + needs_resolve: false, + }); + } + // NeedsApproval (over-cap, or the daemon's mainnet guardrail): the review card + + // hold-to-confirm ARE the human approval surface — the hold resolves the pending + // record, then executes. + Ok((intent, recipient, Decision::NeedsApproval { request_id })) => { + flow(self).proposal = Some(crate::commit_flow::Proposal { + intent, + request_id, + recipient, + needs_resolve: true, + }); + } + Ok((_, _, Decision::Deny { reason })) => { + // An external STOP/lock ends the session — bounce to the unlock gate. + if is_session_ended(&reason) { + self.handle_session_revoked(cx); + } else { + flow(self).error = + Some(format!("{review_deny_prefix}{}", humanize_deny(&reason))); + } + } + Err(e) => flow(self).error = Some(short_err(e)), + } + cx.notify(); } /// Resolve the recipient, then build + `propose` the send off-thread. A `0x…` recipient is @@ -1592,37 +1523,35 @@ impl Shell { /// review card + hold-to-confirm appear; on a parse/resolve/`Deny` error, surface a clear /// line. Mirrors `review_shield` (epoch-guarded; the guard is checked before `busy`). pub fn review_send(&mut self, cx: &mut Context) { - if self.send_busy { + if self.send.busy { return; } - let amount = self.send_amount.read(cx).value().to_string(); - let recipient = self.send_recipient.read(cx).value().to_string(); + let amount = self.send.amount.read(cx).value().to_string(); + let recipient = self.send.recipient.read(cx).value().to_string(); let value_wei = match signer::parse_eth_to_wei(&amount) { Ok(w) if w > U256::ZERO => w, Ok(_) => { - self.send_error = Some("Enter an amount greater than zero".into()); + self.send.error = Some("Enter an amount greater than zero".into()); cx.notify(); return; } Err(e) => { - self.send_error = Some(e); + self.send.error = Some(e); cx.notify(); return; } }; let recipient = recipient.trim().to_string(); if recipient.is_empty() { - self.send_error = Some("Enter a recipient address or ENS name".into()); + self.send.error = Some("Enter a recipient address or ENS name".into()); cx.notify(); return; } - self.send_error = None; - self.send_proposal = None; - self.send_busy = true; - // Each review supersedes the last; a slow reply for a since-cancelled/re-issued review - // checks this epoch before installing (and before touching `busy`). - self.send_review_epoch = self.send_review_epoch.wrapping_add(1); - let epoch = self.send_review_epoch; + self.send.error = None; + self.send.proposal = None; + // begin_review bumps the epoch (each review supersedes the last) and sets `busy`; a slow + // reply for a since-cancelled/re-issued review checks this epoch before installing. + let epoch = self.send.begin_review(); cx.notify(); let client = self.signer.client(); let chain_id = self.chain_id; @@ -1642,49 +1571,18 @@ impl Shell { }; let intent = signer::build_native_send_intent(chain_id, to, value_wei); let decision = client.propose_blocking(&intent)?; - Ok::<(Intent, Address, Decision), anyhow::Error>((intent, to, decision)) + // The recipient SNAPSHOT inside the signed intent: the checksummed destination — never + // the raw `0x…`/ENS text the user could have since edited. + Ok::<(Intent, String, Decision), anyhow::Error>(( + intent, + to.to_checksum(None), + decision, + )) }); cx.spawn(async move |this, cx| { let res = task.await; this.update(cx, |this, cx| { - // Guard FIRST: a stale review must not even clear `busy`. - if this.send_review_epoch != epoch { - return; - } - this.send_busy = false; - match res { - Ok((intent, to, Decision::Allow)) => { - let request_id = SignerClient::request_id_for_intent(&intent); - this.send_proposal = Some(SendProposal { - intent, - request_id, - recipient: to.to_checksum(None), - needs_resolve: false, - }); - } - // NeedsApproval (over-cap, or `Always` approval): the review card + - // hold-to-confirm ARE the human approval surface — the hold resolves the - // pending record, then executes. - Ok((intent, to, Decision::NeedsApproval { request_id })) => { - this.send_proposal = Some(SendProposal { - intent, - request_id, - recipient: to.to_checksum(None), - needs_resolve: true, - }); - } - Ok((_, _, Decision::Deny { reason })) => { - // An external STOP/lock ends the session — bounce to the unlock gate. - if is_session_ended(&reason) { - this.handle_session_revoked(cx); - } else { - this.send_error = - Some(format!("Can't send: {}", humanize_deny(&reason))); - } - } - Err(e) => this.send_error = Some(short_err(e)), - } - cx.notify(); + this.finish_review(|s| &mut s.send, epoch, res, "Can't send: ", cx); }) .ok(); }) @@ -1700,15 +1598,15 @@ impl Shell { request_id, needs_resolve, .. - }) = self.send_proposal.clone() + }) = self.send.proposal.clone() else { return; }; - if self.send_busy { + if self.send.busy { return; } - self.send_busy = true; - self.send_error = None; + self.send.busy = true; + self.send.error = None; cx.notify(); let client = self.signer.client(); let control = self.signer.control(); @@ -1720,14 +1618,14 @@ impl Shell { cx.spawn(async move |this, cx| { let res = task.await; this.update(cx, |this, cx| { - this.send_busy = false; + this.send.busy = false; // Invalidate the proposal on EVERY execute attempt: a second hold must not be // able to re-broadcast. On an ambiguous timeout the transfer may already be in // flight, so retrying requires a fresh, deliberate review (new request id). - this.send_proposal = None; + this.send.proposal = None; match res { Ok(ExecuteResult::Broadcast { tx_hash }) => { - this.send_tx = Some(tx_hash); + this.send.tx = Some(tx_hash); // The transfer left the wallet — re-fetch the public balance so home // reflects it (a send has no private side to sync, unlike shield). this.refresh_portfolio(cx); @@ -1737,11 +1635,11 @@ impl Shell { if is_session_ended(&reason) { this.handle_session_revoked(cx); } else { - this.send_error = + this.send.error = Some(format!("Send denied: {}", humanize_deny(&reason))); } } - Err(e) => this.send_error = Some(short_err(e)), + Err(e) => this.send.error = Some(short_err(e)), } cx.notify(); }) @@ -1754,24 +1652,20 @@ impl Shell { /// only if the hold survives [`SHIELD_HOLD`]. A per-hold epoch guards against a stale timer /// firing after an early release / re-press. pub fn send_hold_start(&mut self, cx: &mut Context) { - if self.send_holding || self.send_busy || self.send_proposal.is_none() { + // begin_hold guards (no-op while busy / already holding / no proposal), sets `holding`, + // bumps the hold epoch, and returns it for the timer to re-check. + let Some(epoch) = self.send.begin_hold() else { return; - } - self.send_holding = true; - self.send_hold_epoch = self.send_hold_epoch.wrapping_add(1); - let epoch = self.send_hold_epoch; + }; cx.notify(); cx.spawn(async move |this, cx| { cx.background_executor().timer(SHIELD_HOLD).await; this.update(cx, |this, cx| { - // Fire only if THIS hold is still active AND the user is still on Send — leaving + // Fire only if THIS hold is still valid AND the user is still on Send — leaving // via ⌘[ / palette / a surface change must never sign after the screen is gone. - if this.send_holding - && this.send_hold_epoch == epoch - && this.surface == Surface::Send - && this.send_proposal.is_some() - { - this.send_holding = false; + // (The surface check stays here — it depends on the live surface, not flow state.) + if this.surface == Surface::Send && this.send.hold_still_valid(epoch) { + this.send.holding = false; this.confirm_send(cx); } }) @@ -1783,9 +1677,7 @@ impl Shell { /// Release the confirm hold before it completed — reset the sweep; the epoch bump /// cancels the pending timer. pub fn send_hold_cancel(&mut self, cx: &mut Context) { - if self.send_holding { - self.send_holding = false; - self.send_hold_epoch = self.send_hold_epoch.wrapping_add(1); + if self.send.cancel_hold() { cx.notify(); } } @@ -1981,22 +1873,26 @@ impl Shell { fn prepare_shield_inputs(&mut self, window: &mut Window, cx: &mut Context) { if self.pending_shield_clear { self.pending_shield_clear = false; - self.shield_amount + self.shield + .amount .update(cx, |i, cx| i.set_value("", window, cx)); - self.shield_recipient + self.shield + .recipient .update(cx, |i, cx| i.set_value("", window, cx)); // The send inputs share the lock-clear: a prior wallet's recipient/amount must not // linger into the next unlock. - self.send_amount + self.send + .amount .update(cx, |i, cx| i.set_value("", window, cx)); - self.send_recipient + self.send + .recipient .update(cx, |i, cx| i.set_value("", window, cx)); } if self.recipient_autofilled { return; } if let Some(addr) = self.railgun_address.clone() { - self.shield_recipient.update(cx, |input, cx| { + self.shield.recipient.update(cx, |input, cx| { input.set_value(addr.as_str(), window, cx); }); self.recipient_autofilled = true; @@ -2060,8 +1956,12 @@ impl Render for Shell { .child(self.render_settings(window, cx)) .into_any_element(), (_, Surface::Receive) => self.render_receive(cx).into_any_element(), - (_, Surface::Send) => self.render_send(cx).into_any_element(), - (_, Surface::Shield) => self.render_shield(cx).into_any_element(), + (_, Surface::Send) => self + .render_commit(&crate::send_view::SEND_VIEW, cx) + .into_any_element(), + (_, Surface::Shield) => self + .render_commit(&crate::shield_view::SHIELD_VIEW, cx) + .into_any_element(), (Selection::Wallet, Surface::Home) => div() .id("scroll-wallet") .size_full() @@ -2136,28 +2036,3 @@ impl Render for Shell { .child(body) } } - -#[cfg(test)] -mod tests { - use super::is_session_ended; - - #[test] - fn session_ended_matches_only_stop_states() { - // A locked daemon answers `locked` to a propose; an execute after STOP answers - // `revoked`. Both must bounce the app back to the unlock gate. - assert!(is_session_ended("locked")); - assert!(is_session_ended("revoked")); - // Ordinary policy denials stay inline (the app stays Ready, shows the reason). - for inline in [ - "over_cap", - "off_allowlist", - "chain_mismatch", - "shield_to_mismatch", - "not_approved", - "already_executed", - "broadcast_timeout", - ] { - assert!(!is_session_ended(inline), "{inline} must stay inline"); - } - } -} diff --git a/crates/deckard-app/src/shield_view.rs b/crates/deckard-app/src/shield_view.rs index dcdf5a1..495ddbe 100644 --- a/crates/deckard-app/src/shield_view.rs +++ b/crates/deckard-app/src/shield_view.rs @@ -1,29 +1,20 @@ -//! Shield — the privacy hero's trigger flow (T5). Three states over one centered card: -//! **compose** (amount + 0zk recipient) → **review** (a clear-signing card: amount / -//! recipient / 0.25% fee + the three honesty lines + a deliberate hold-to-confirm) → -//! **done** (the deposit is broadcast and on its way to a private note). +//! Shield — the privacy hero's trigger flow (T5)'s [`CommitView`] descriptor. The actual +//! compose → review → done rendering lives in `commit_view.rs` (the generic renderer shared with +//! Send); this file is now just the byte-for-byte table of Shield's copy, button ids, the heading +//! glyph, the fee/net money rows, the 3-way conditional compose hint, and the handler hooks. //! -//! The honesty + deliberate-hold model is DESIGN's clear-signing engine (plain language, -//! exact mono figures, danger early, confirm is a hold never a tap). The hold-to-confirm is -//! hand-built (no existing widget): `on_mouse_down`/`up` drive an epoch-guarded timer in -//! `shell.rs`, and an amber `theme::amber_tint` fill-sweep animates over the same -//! `SHIELD_HOLD` span so the bar fills exactly as the deposit signs. +//! A shield carries a Railgun fee and a private side — so `extra_rows` holds the 0.25% fee row + +//! the net "you'll receive (private)" line, the compose hint is the 3-way conditional line (own +//! 0zk address vs double-check vs enter), and the honesty surface has three lines. The heading +//! glyph is the neutral low-chroma "shield / private" tone (privacy sits off the cyan/agent + +//! amber/human actor axis; the human signal lives on the amber hold-to-confirm, not the heading). -use gpui::{ - div, px, relative, Animation, AnimationExt, ClipboardItem, Context, FontWeight, - InteractiveElement, IntoElement, MouseButton, ParentElement, Styled, -}; -use gpui_component::{ - button::{Button, ButtonVariants}, - h_flex, - input::Input, - v_flex, ActiveTheme, Disableable, Icon, IconName, -}; +use gpui::Context; use deckard_core::U256; -use crate::money::money; -use crate::shell::{Shell, ShieldProposal, Surface, SHIELD_HOLD}; +use crate::commit_view::{CommitView, HonestyLine, MoneyRow}; +use crate::shell::{Shell, Surface}; use crate::theme; /// The Railgun shield fee, 25 bps (0.25%) — matches `deckard_core::shield`'s on-chain @@ -32,454 +23,120 @@ fn shield_fee(value: U256) -> U256 { value * U256::from(25u64) / U256::from(10_000u64) } -/// Middle-truncate a long address (0zk… / 0x…) for a tight row. -fn short_mid(s: &str) -> String { - if s.len() >= 16 { - format!("{}…{}", &s[..10], &s[s.len() - 6..]) - } else { - s.to_string() - } +/// The net the recipient receives after the Railgun fee (gross − fee). Mirrors the old +/// `render_shield_review`'s `gross.saturating_sub(fee)`. +fn shield_net(value: U256) -> U256 { + value.saturating_sub(shield_fee(value)) } -impl Shell { - /// Dispatch to the active shield state: done (broadcast) → review (proposed) → compose. - pub fn render_shield(&self, cx: &mut Context) -> impl IntoElement { - if let Some(tx) = self.shield_tx { - return self - .render_shield_done(tx.to_string(), cx) - .into_any_element(); - } - if let Some(proposal) = self.shield_proposal.clone() { - return self.render_shield_review(proposal, cx).into_any_element(); - } - self.render_shield_compose(cx).into_any_element() - } - - /// Compose: amount (ETH) + 0zk recipient, then Review. The shield glyph is a neutral, - /// low-chroma mark (DESIGN: private ≠ cyan/agent and ≠ amber/human — it stays off the - /// actor axis). - fn render_shield_compose(&self, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let muted = theme.muted_foreground; - let busy = self.shield_busy; - - // Validity drives the Review button's disabled state (DESIGN: disable a primary - // action on incomplete/invalid input). Re-evaluated live via the input subscriptions. - let amount_raw = self.shield_amount.read(cx).value().to_string(); - let recipient_raw = self.shield_recipient.read(cx).value().to_string(); - let can_review = crate::signer::parse_eth_to_wei(&amount_raw) - .map(|w| w > U256::ZERO) - .unwrap_or(false) - && !recipient_raw.trim().is_empty(); - - self.shield_shell( - v_flex() - .w_full() - .gap_5() - .child(self.shield_heading( - "Shield to private", - "Move public ETH into a Railgun private balance. The deposit itself is visible on Ethereum; the balance after is not.", - cx, - )) - .child( - v_flex() - .w_full() - .gap_2() - .child(field_label("Amount", muted)) - .child(Input::new(&self.shield_amount).w_full()), - ) - .child( - v_flex() - .w_full() - .gap_2() - .child(field_label("Recipient (your 0zk address)", muted)) - .child(Input::new(&self.shield_recipient).w_full()), - ) - .children(self.shield_error.as_ref().map(|e| error_line(e, cx))) - .child( - h_flex() - .w_full() - .gap_2() - .child( - Button::new("shield-review") - .primary() - .label(if busy { "Reviewing…" } else { "Review deposit" }) - .disabled(busy || !can_review) - .on_click(cx.listener(|this, _, _, cx| this.review_shield(cx))), - ) - .child( - Button::new("shield-cancel") - .ghost() - .label("Cancel") - .on_click(cx.listener(|this, _, _, cx| { - this.open(Surface::Home, cx) - })), - ), - ) - .child( - // Only call the recipient "your own 0zk address" when it actually matches the - // wallet's auto-filled address — a user-typed/edited recipient gets neutral copy - // so the line never misrepresents where the deposit is going. - div().text_xs().text_color(muted).child({ - let recipient = recipient_raw.trim(); - let is_own_address = - self.railgun_address.as_deref().map(str::trim) == Some(recipient); - if recipient.is_empty() { - "Enter the 0zk address that will receive the private balance." - } else if is_own_address { - "Pre-filled with your own 0zk address — edit it to shield to a different recipient." - } else { - "Shielding to the 0zk address above — double-check it before you continue." - } - }), - ) - .into_any_element(), - ) - } - - /// Review: the clear-signing card (amount / recipient / fee / net) + the three honesty - /// lines + a deliberate hold-to-confirm. `intent` carries the gross (pre-fee) value. - fn render_shield_review( - &self, - proposal: ShieldProposal, - cx: &mut Context, - ) -> impl IntoElement { - let theme = cx.theme(); - let fg = theme.foreground; - let muted = theme.muted_foreground; - let border = theme.border; - let surface = theme.secondary; - let mono = theme.mono_font_family.clone(); - - // Render from the proposal SNAPSHOT — the amount + recipient that are actually inside - // the signed intent — never the live input (which the user could have since edited). - let gross = proposal.intent.value; - let fee = shield_fee(gross); - let net = gross.saturating_sub(fee); - let recipient = proposal.recipient.clone(); - - // One key/value row: label left (muted), value right (mono-for-money or mono text). - let kv_money = |label: &'static str, wei: U256| { - h_flex() - .w_full() - .justify_between() - .items_center() - .py_1p5() - .child(div().text_sm().text_color(muted).child(label)) - .child(div().text_sm().child(money( - wei, - 18, - 6, - Some("ETH"), - false, - mono.clone(), - fg, - muted, - ))) - }; - - self.shield_shell( - v_flex() - .w_full() - .gap_4() - .child(self.shield_heading( - "Review deposit", - "Confirm what leaves, where it goes, and the fee. Hold to shield.", - cx, - )) - // The clear-signing card: one frame, no interior grid lines. - .child( - v_flex() - .w_full() - .p_4() - .rounded_lg() - .border_1() - .border_color(border) - .bg(surface) - .child(kv_money("Amount", gross)) - .child( - h_flex() - .w_full() - .justify_between() - .items_center() - .py_1p5() - .child(div().text_sm().text_color(muted).child("To")) - .child( - div() - .font_family(mono.clone()) - .text_sm() - .text_color(fg) - .child(short_mid(recipient.trim())), - ), - ) - .child(kv_money("Railgun fee · 0.25%", fee)) - .child(kv_money("You'll receive (private)", net)), - ) - .child(self.shield_honesty(cx)) - .children(self.shield_error.as_ref().map(|e| error_line(e, cx))) - .child(self.hold_to_confirm(cx)) - .child( - Button::new("shield-edit") - .ghost() - .w_full() - .label("Edit") - .on_click(cx.listener(|this, _, _, cx| this.open_shield(cx))), - ) - .into_any_element(), - ) - } - - /// Done: the deposit broadcast — on its way to a private note (reassurance copy mirrors - /// `ShieldStatus`). The full lifecycle drive lands in Wave 2. - fn render_shield_done(&self, tx: String, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let fg = theme.foreground; - let muted = theme.muted_foreground; - let border = theme.border; - let surface = theme.secondary; - let success = theme.success; - let mono = theme.mono_font_family.clone(); - - self.shield_shell( - v_flex() - .w_full() - .items_center() - .gap_4() - .child( - Icon::new(IconName::CircleCheck) - .text_color(success) - .flex_shrink_0(), - ) - .child( - div() - .text_lg() - .font_weight(FontWeight::SEMIBOLD) - .text_color(fg) - .child("Deposit broadcast"), - ) - .child( - div() - .text_sm() - .text_color(muted) - .text_center() - .child("Your deposit is on its way to a private balance. It becomes spendable after on-chain confirmation and a private sync."), - ) - .child( - div() - .w_full() - .px_3() - .py_2() - .rounded_lg() - .border_1() - .border_color(border) - .bg(surface) - .font_family(mono) - .text_xs() - .text_color(muted) - .child(short_mid(&tx)), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("shield-copy-tx") - .ghost() - .label("Copy tx hash") - .on_click(cx.listener(move |_, _, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(tx.clone())); - })), - ) - .child( - Button::new("shield-done") - .primary() - .label("Done") - .on_click(cx.listener(|this, _, _, cx| this.open(Surface::Home, cx))), - ), - ) - .into_any_element(), - ) - } - - /// The three honesty lines in a calm neutral surface (no keyline). - fn shield_honesty(&self, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let fg = theme.foreground; - let muted = theme.muted_foreground; - let surface = theme.secondary; - - v_flex() - .w_full() - .gap_1p5() - .px_3() - .py_2p5() - .rounded_lg() - .bg(surface) - .child( - div() - .text_xs() - .text_color(fg) - .child("This deposit is public on Ethereum."), - ) - .child( - div() - .text_xs() - .text_color(fg) - .child("Avoid round or unusual amounts."), - ) - .child(div().text_xs().text_color(muted).child( - "A 0.25% Railgun fee is deducted; your private balance will read slightly less.", - )) - } - - /// The hand-built hold-to-confirm: an amber fill sweeps the button width over - /// [`SHIELD_HOLD`] while held; completing the hold fires `confirm_shield`, releasing - /// early resets it. The label sits above the sweep. - fn hold_to_confirm(&self, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let fg = theme.foreground; - let border = theme.border; - let surface = theme.secondary; - let amber_tint = theme::amber_tint(theme.is_dark()); - let holding = self.shield_holding; - let busy = self.shield_busy; - - let label = if busy { - "Shielding…" - } else if holding { - "Keep holding…" - } else { - "Hold to shield" - }; - - // The amber fill: 0→full width over SHIELD_HOLD while holding; empty otherwise. - let fill = if holding { - div() - .absolute() - .left_0() - .top_0() - .h_full() - .bg(amber_tint) - .with_animation("shield-fill", Animation::new(SHIELD_HOLD), |el, delta| { - el.w(relative(delta)) - }) - .into_any_element() - } else { - div() - .absolute() - .left_0() - .top_0() - .h_full() - .w(relative(0.0)) - .into_any_element() - }; - - div() - .id("shield-hold") - .relative() - .overflow_hidden() - .w_full() - .h(px(44.0)) - .rounded_md() - .border_1() - .border_color(border) - .bg(surface) - .cursor_pointer() - .child(fill) - .child( - div() - .relative() - .size_full() - .flex() - .items_center() - .justify_center() - .text_sm() - .font_weight(FontWeight::SEMIBOLD) - .text_color(fg) - .child(label), - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(|this, _, _, cx| this.shield_hold_start(cx)), - ) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _, _, cx| this.shield_hold_cancel(cx)), - ) - .on_mouse_up_out( - MouseButton::Left, - cx.listener(|this, _, _, cx| this.shield_hold_cancel(cx)), - ) - } - - /// The shared centered shell for every shield state (mirrors `render_receive`'s layout). - fn shield_shell(&self, inner: gpui::AnyElement) -> impl IntoElement { - div() - .flex_1() - .flex() - .flex_col() - .items_center() - .justify_center() - .p_8() - .child(v_flex().w(px(460.0)).items_start().child(inner)) - } +/// The Shield surface descriptor. Reproduces the shipped Shield flow (T5) EXACTLY: same strings, +/// button ids, layout, the fee + net rows, the three honesty lines, the 3-way compose hint, and +/// the amber hold-to-confirm. Routed from `Shell::render` via `render_commit(&SHIELD_VIEW, cx)`. +pub static SHIELD_VIEW: CommitView = CommitView { + // The shield flow's live state + the neutral "shield / private" heading glyph. + flow: shield_flow, + glyph_tone: theme::shield, + + // --- compose --- + compose_title: "Shield to private", + compose_subtitle: + "Move public ETH into a Railgun private balance. The deposit itself is visible on Ethereum; the balance after is not.", + recipient_label: "Recipient (your 0zk address)", + review_button_id: "shield-review", + review_label: "Review deposit", + cancel_button_id: "shield-cancel", + // The hint is conditional (3-way), driven by `shield_compose_hint`; no static line. + compose_hint: None, + compose_hint_dynamic: Some(shield_compose_hint), + + // --- review --- + review_title: "Review deposit", + review_subtitle: "Confirm what leaves, where it goes, and the fee. Hold to shield.", + // The Railgun fee + the net private receipt, computed from the proposal's gross value. + extra_rows: &[ + MoneyRow { + label: "Railgun fee · 0.25%", + compute: shield_fee, + }, + MoneyRow { + label: "You'll receive (private)", + compute: shield_net, + }, + ], + honesty: &[ + HonestyLine { + text: "This deposit is public on Ethereum.", + emphasized: true, + }, + HonestyLine { + text: "Avoid round or unusual amounts.", + emphasized: true, + }, + HonestyLine { + text: "A 0.25% Railgun fee is deducted; your private balance will read slightly less.", + emphasized: false, + }, + ], + hold_id: "shield-hold", + hold_fill_id: "shield-fill", + hold_label_idle: "Hold to shield", + hold_label_holding: "Keep holding…", + hold_label_busy: "Shielding…", + edit_button_id: "shield-edit", + + // --- done --- + done_title: "Deposit broadcast", + done_body: + "Your deposit is on its way to a private balance. It becomes spendable after on-chain confirmation and a private sync.", + copy_button_id: "shield-copy-tx", + done_button_id: "shield-done", + + // --- handlers (the existing `impl Shell` shield methods) --- + on_review: review_shield, + on_edit: open_shield, + on_cancel: open_home, + on_done: open_home, + on_hold_start: shield_hold_start, + on_hold_cancel: shield_hold_cancel, +}; - /// The shield heading: a neutral low-chroma shield glyph + H1 + muted subtitle. The - /// glyph is deliberately NOT cyan/amber — privacy sits off the actor axis (DESIGN). - fn shield_heading( - &self, - title: &str, - subtitle: &str, - cx: &mut Context, - ) -> impl IntoElement { - let theme = cx.theme(); - let fg = theme.foreground; - let muted = theme.muted_foreground; - let shield_tone = theme::shield(theme.is_dark()); +/// Re-acquire the shield flow's state from the shell (the descriptor's `flow` selector). +fn shield_flow(shell: &Shell) -> &crate::commit_flow::CommitFlow { + &shell.shield +} - h_flex() - .w_full() - .items_start() - .gap_3() - // A small neutral shield mark (no shield icon ships in the kit): a rounded - // square in the low-chroma shield tone. - .child( - div() - .size(px(28.0)) - .rounded(px(6.0)) - .bg(shield_tone) - .flex_shrink_0(), - ) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child( - div() - .text_xl() - .font_weight(FontWeight::SEMIBOLD) - .text_color(fg) - .child(title.to_string()), - ) - .child( - div() - .text_sm() - .text_color(muted) - .child(subtitle.to_string()), - ), - ) +/// The 3-way conditional compose hint: only call the recipient "your own 0zk address" when it +/// actually matches the wallet's auto-filled address — a user-typed/edited recipient gets neutral +/// copy so the line never misrepresents where the deposit is going. Mirrors the old +/// `render_shield_compose`'s inline block exactly. `recipient_raw` is the recipient text the +/// renderer already read from the input. +fn shield_compose_hint(shell: &Shell, recipient_raw: &str) -> &'static str { + let recipient = recipient_raw.trim(); + let is_own_address = shell.railgun_address.as_deref().map(str::trim) == Some(recipient); + if recipient.is_empty() { + "Enter the 0zk address that will receive the private balance." + } else if is_own_address { + "Pre-filled with your own 0zk address — edit it to shield to a different recipient." + } else { + "Shielding to the 0zk address above — double-check it before you continue." } } -/// A tiny uppercase field label (matches the sidebar/section label treatment). -fn field_label(text: &'static str, muted: gpui::Hsla) -> impl IntoElement { - div().text_xs().text_color(muted).child(text) +// Thin free-function adapters so the descriptor's `fn(&mut Shell, &mut Context)` slots can +// name the surface's handlers (a `&'static` descriptor can't hold a closure, and the methods take +// `&mut self`). Each is a one-line forward to the existing handler. +fn review_shield(shell: &mut Shell, cx: &mut Context) { + shell.review_shield(cx); } - -/// A one-line shield error, in `danger`. -fn error_line(msg: &str, cx: &mut Context) -> impl IntoElement { - div() - .text_sm() - .text_color(cx.theme().danger) - .child(format!("⚠ {msg}")) +fn open_shield(shell: &mut Shell, cx: &mut Context) { + shell.open_shield(cx); +} +fn open_home(shell: &mut Shell, cx: &mut Context) { + shell.open(Surface::Home, cx); +} +fn shield_hold_start(shell: &mut Shell, cx: &mut Context) { + shell.shield_hold_start(cx); +} +fn shield_hold_cancel(shell: &mut Shell, cx: &mut Context) { + shell.shield_hold_cancel(cx); } diff --git a/crates/deckard-app/src/signer.rs b/crates/deckard-app/src/signer.rs index 2011646..20b0149 100644 --- a/crates/deckard-app/src/signer.rs +++ b/crates/deckard-app/src/signer.rs @@ -11,7 +11,11 @@ use std::ffi::OsString; use std::path::PathBuf; use alloy_primitives::{Address, Bytes, B256, U256}; -use deckard_contract::{Decision, ExecuteResult, Intent, IntentKind, RequestId, UnlockOutcome}; +use deckard_contract::{ + Decision, ExecuteResult, Intent, IntentKind, RequestId, SignOrderResult, SwapOrder, + UnlockOutcome, +}; +use deckard_core::{APPROVE_SELECTOR, GPV2_VAULT_RELAYER}; use deckard_signerd::{ControlChannel, DaemonSupervisor, SignerClient}; /// Result of the app's send path (propose, then execute on `Allow`). The path is implemented @@ -171,6 +175,80 @@ pub fn build_native_send_intent(chain_id: u64, to: Address, value_wei: U256) -> } } +/// Build the **exact-gross** ERC-20 approve intent for a swap (#25). The CoW vault relayer must +/// be allowed to move the order's GROSS sell amount (= `quote.sellAmount + quote.feeAmount`, which +/// is exactly `order.sell_amount`) of the sell token before the order can settle. Shape (codex +/// must-do #1): +/// - `to` = the sell token (the ERC-20 contract the approve targets), +/// - `value` = 0 (a value-bearing approve is denied `approve_with_value`), +/// - `kind` = `ContractCall`, `token` = None, +/// - `calldata` = `approve(GPV2_VAULT_RELAYER, gross)`. +/// +/// The daemon admits this approve ONLY when a matching pending `Order` exists (same sell token + +/// same gross sell amount), so the caller MUST `propose_order` BEFORE proposing this approve, else +/// the daemon answers `approve_no_matching_order`. The calldata layout mirrors +/// [`deckard_core::decode_approve`] byte-for-byte (selector ‖ 12 pad ‖ 20-byte spender ‖ 32-byte +/// amount), so the daemon's `decode_approve` round-trips it back to `(GPV2_VAULT_RELAYER, gross)`. +pub fn build_exact_approve_intent(chain_id: u64, sell_token: Address, gross: U256) -> Intent { + Intent { + chain_id, + to: sell_token, + token: None, + value: U256::ZERO, + calldata: encode_approve(GPV2_VAULT_RELAYER, gross), + kind: IntentKind::ContractCall, + } +} + +/// ABI-encode `approve(address spender, uint256 amount)`: the 4-byte selector, the spender +/// left-padded to a 32-byte word, then the amount as a big-endian 32-byte word. Built by hand +/// (no `sol!`) so the bytes are the exact inverse of [`deckard_core::decode_approve`]'s manual +/// decode — the daemon's shaped-approve admission decodes this back to `(spender, amount)`. +fn encode_approve(spender: Address, amount: U256) -> Bytes { + let mut calldata = Vec::with_capacity(4 + 32 + 32); + calldata.extend_from_slice(&APPROVE_SELECTOR); + // address arg: left-pad the 20-byte address to a 32-byte word. + calldata.extend_from_slice(&[0u8; 12]); + calldata.extend_from_slice(spender.as_slice()); + // uint256 arg: the big-endian 32-byte amount. + calldata.extend_from_slice(&amount.to_be_bytes::<32>()); + Bytes::from(calldata) +} + +/// Authorize + sign a stored swap order, key-less. The completed hold-to-confirm IS the human +/// approval (swaps are ALWAYS `NeedsApproval` in v1), so this first sends `Resolve{approved: true}` +/// over the **private capability channel** (the daemon authenticates approvals only there, PRD-01) +/// to flip the `Pending` record to `Allowed`, then `SignOrder` over the public socket to get the +/// order's 65-byte EIP-712 signature. NO HTTP, NO broadcast: the app posts the signed order to the +/// CoW orderbook itself. Mirrors [`approve_and_execute_blocking`]'s control-then-public split, but +/// the public step is `sign_order` (signature) instead of `execute` (broadcast). Blocking; called +/// from a background thread. +pub fn sign_and_resolve_blocking( + client: &SignerClient, + control: &ControlChannel, + request_id: RequestId, +) -> anyhow::Result { + control.resolve(request_id, true)?; + match client.sign_order_blocking(request_id)? { + SignOrderResult::Signed { signature } => Ok(signature), + SignOrderResult::Denied { reason } => { + anyhow::bail!("{reason}") + } + } +} + +/// Bind a swap order's `owner`/`receiver` to the wallet, exactly as the daemon does before it +/// hashes the record, so the caller can derive the matching `request_id`. The daemon never trusts +/// a client-supplied owner — it rebinds owner = wallet (and `evaluate_order` enforces receiver == +/// wallet) — so an id derived from the UN-bound order would not match the stored record. Returns +/// the bound order; derive its id with [`SignerClient::request_id_for_swap_order`]. +pub fn bind_swap_order(order: &SwapOrder, wallet: Address) -> SwapOrder { + let mut bound = order.clone(); + bound.owner = wallet; + bound.receiver = wallet; + bound +} + /// Parse a decimal ETH amount (`"0.05"`, `"1"`, `"1.234"`) into wei. Pure + total: rejects /// empties, signs, non-digits, a second dot, and >18 fractional places, so the shield amount /// field never builds a wrong-magnitude intent. Returns a short, user-facing error string. @@ -452,6 +530,103 @@ mod tests { assert_eq!(socket_path_from(Some(OsString::new())), default); } + /// The exact-gross approve intent (codex must-do #1): targets the SELL TOKEN, carries no ETH + /// (`value == 0`), is a `ContractCall` with `token: None`, and its calldata decodes — through + /// the daemon's own `decode_approve` — back to `(GPV2_VAULT_RELAYER, gross)`. A regression to + /// the wrong spender or the after-fee amount would be caught here AND denied by the daemon. + #[test] + fn exact_approve_intent_targets_relayer_for_the_gross_amount() { + let sell_token = Address::repeat_byte(0x55); + let gross = U256::from(1_005_000_000_000_000_000u128); // after-fee 1e18 + fee 5e15 + let intent = build_exact_approve_intent(11155111, sell_token, gross); + + assert_eq!(intent.chain_id, 11155111); + assert_eq!(intent.to, sell_token, "approve targets the sell-token ERC-20"); + assert_eq!(intent.value, U256::ZERO, "approve must carry no ETH"); + assert_eq!(intent.token, None); + assert_eq!(intent.kind, IntentKind::ContractCall); + + // The daemon decodes this back to (spender, amount) for its shaped-approve admission. + let (spender, amount) = + deckard_core::decode_approve(&intent.calldata).expect("calldata is a valid approve"); + assert_eq!( + spender, + deckard_core::GPV2_VAULT_RELAYER, + "spender must be the GPv2 vault relayer" + ); + assert_eq!(amount, gross, "approve amount is the GROSS sell amount"); + } + + /// The approve amount tracks the order's GROSS `sell_amount` exactly (built off a quote via + /// `swap_order_from_quote`), not the after-fee quote amount — so the on-chain allowance covers + /// the full amount the vault relayer pulls. + #[test] + fn approve_amount_equals_order_gross_sell_amount() { + let quote = deckard_core::QuoteResponse { + quote: deckard_core::QuoteOrderParameters { + sell_token: Address::repeat_byte(0x55), + buy_token: Address::repeat_byte(0x66), + receiver: None, + sell_amount: U256::from(37_989_365_556_267_132u64), // after-fee + buy_amount: U256::from(1_953_742_300_219_817_002u64), + valid_to: 1_781_261_340, + fee_amount: U256::from(12_010_634_443_732_868u64), // fee + }, + from: None, + expiration: None, + id: Some(1), + verified: Some(true), + }; + let wallet = Address::repeat_byte(0x11); + let order = deckard_core::swap_order_from_quote( + "e, + 11155111, + wallet, + wallet, + deckard_core::DEFAULT_SLIPPAGE_BPS, + ); + let gross = U256::from(50_000_000_000_000_000u64); // == sellAmountBeforeFee + assert_eq!(order.sell_amount, gross); + + let intent = build_exact_approve_intent(11155111, order.sell_token, order.sell_amount); + let (_, amount) = deckard_core::decode_approve(&intent.calldata).expect("valid approve"); + assert_eq!(amount, gross, "the approve covers the full gross sell amount"); + } + + /// `bind_swap_order` pins BOTH owner and receiver to the wallet. The app builds the order with + /// `receiver = wallet` (so `evaluate_order` doesn't deny `receiver_not_wallet`), and the daemon + /// rebinds `owner = wallet` before hashing. Pinning both locally gives the SAME bytes the daemon + /// hashes its stored record under — so the derived `request_id` matches and we can resolve/sign. + #[test] + fn bind_swap_order_pins_owner_and_receiver_to_wallet() { + let wallet = Address::repeat_byte(0x11); + // The order as the app builds it: receiver already == wallet; owner is a placeholder the + // daemon will rebind. `bind_swap_order` produces the post-bind canonical form. + let order = SwapOrder { + chain_id: 11155111, + owner: Address::repeat_byte(0xEE), // a placeholder owner the daemon rebinds to `wallet` + receiver: wallet, // app already binds receiver (else receiver_not_wallet) + sell_token: Address::repeat_byte(0x55), + buy_token: Address::repeat_byte(0x66), + sell_amount: U256::from(1_000u64), + buy_amount_min: U256::from(990u64), + valid_to: 1_700_000_000, + app_data: deckard_core::APP_DATA_HASH, + }; + let bound = bind_swap_order(&order, wallet); + assert_eq!(bound.owner, wallet); + assert_eq!(bound.receiver, wallet); + // The daemon binds only owner (receiver is already wallet) before hashing the stored + // record; our locally-bound id must equal that. + let id_from_bound = SignerClient::request_id_for_swap_order(&bound); + let mut daemon_bound = order.clone(); + daemon_bound.owner = wallet; // daemon's pre-hash binding (receiver untouched, already wallet) + assert_eq!( + id_from_bound, + SignerClient::request_id_for_swap_order(&daemon_bound) + ); + } + #[test] fn unlock_outcomes_map_to_address_or_message() { let addr = Address::repeat_byte(0x11); diff --git a/crates/deckard-core/examples/qa-vault.rs b/crates/deckard-core/examples/qa-vault.rs new file mode 100644 index 0000000..896509e --- /dev/null +++ b/crates/deckard-core/examples/qa-vault.rs @@ -0,0 +1,80 @@ +//! QA helper — seal a THROWAWAY test vault so clicky GUI QA skips onboarding. +//! +//! Seals anvil's well-known dev mnemonic under a FIXED passphrase with FAST Argon2 +//! params, into `DECKARD_CONFIG_DIR`. Because the KDF cost is baked into the vault +//! header, the resulting vault unlocks near-instantly everywhere. The app then boots +//! straight to the **Unlock** screen (no Create / seed-reveal / backup challenge) — +//! the tester just types the passphrase this prints. +//! +//! just qa-vault # seal the vault (this example) +//! just qa # launch the app against the same DECKARD_CONFIG_DIR +//! +//! Account 0 of this mnemonic (`0xf39Fd6…92266`) is prefunded with 10000 ETH on any +//! anvil chain — including a Sepolia fork — so the QA wallet needs no funding step. +//! +//! WARNING: throwaway test seed, NEVER for real funds. This file lives only under +//! `examples/`, so it is never linked into the shipped `deckard` binary. The +//! production create/import paths are untouched and keep `KdfParams::PRODUCTION`. + +use std::path::PathBuf; + +use deckard_core::{config::VAULT_FILE, KdfParams, Vault}; + +/// Anvil's canonical dev mnemonic. Account 0 = m/44'/60'/0'/0/0 = +/// `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266`, prefunded by anvil on every chain. +const MNEMONIC: &str = "test test test test test test test test test test test junk"; +/// Fixed QA passphrase (>= 8 chars). Type this on the Unlock screen. +const PASS: &str = "deckard-qa"; + +fn main() { + // Resolve the target config dir. NEVER fall back to the real platform keystore: + // honour DECKARD_CONFIG_DIR, else use an explicit throwaway temp dir. + let dir = match std::env::var_os("DECKARD_CONFIG_DIR") { + Some(v) if !v.is_empty() => PathBuf::from(v), + _ => PathBuf::from("/tmp/deckard-qa"), + }; + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!("qa-vault: cannot create {}: {e}", dir.display()); + std::process::exit(1); + } + + // Fast Argon2 (8 MiB / t=1 / p=1) — the floor `validate()` allows. Baked into the + // vault header, so unlock is fast in both the app and the daemon. + let kdf = KdfParams { + m_kib: 8 * 1024, + t: 1, + p: 1, + }; + + let vault = match Vault::import_mnemonic(MNEMONIC, PASS, kdf) { + Ok(v) => v, + Err(e) => { + eprintln!("qa-vault: seal failed: {e}"); + std::process::exit(1); + } + }; + + // Derive the address for the QA log (we print only the address + the known QA + // passphrase reminder — never seed/key material). + let addr = match vault.unlock(PASS).and_then(|u| u.primary_address()) { + Ok(a) => a, + Err(e) => { + eprintln!("qa-vault: derive address failed: {e}"); + std::process::exit(1); + } + }; + + let path = dir.join(VAULT_FILE); + if let Err(e) = vault.write_atomic(&path) { + eprintln!("qa-vault: write {} failed: {e}", path.display()); + std::process::exit(1); + } + + println!("qa-vault: sealed a throwaway QA vault (fast KDF)"); + println!(" config dir : {}", dir.display()); + println!(" vault file : {}", path.display()); + println!(" address : {addr} (anvil account 0 — prefunded on any anvil/fork)"); + println!(" passphrase : {PASS}"); + println!(); + println!("Next: `just qa` -> the app boots to Unlock; type the passphrase above."); +} diff --git a/crates/deckard-core/examples/smoke.rs b/crates/deckard-core/examples/smoke.rs index 2d3677a..865e242 100644 --- a/crates/deckard-core/examples/smoke.rs +++ b/crates/deckard-core/examples/smoke.rs @@ -6,7 +6,8 @@ use deckard_core::{format_amount, EthProvider, DEFAULT_RPC}; fn main() { - let eth = EthProvider::spawn(DEFAULT_RPC); + // DEFAULT_RPC is a public mainnet endpoint, so read against mainnet (chain 1). + let eth = EthProvider::spawn(DEFAULT_RPC, 1); let name = "vitalik.eth"; let addr = match eth.resolve_name(name).recv() { diff --git a/crates/deckard-core/src/balances.rs b/crates/deckard-core/src/balances.rs index 86ceff9..abe9915 100644 --- a/crates/deckard-core/src/balances.rs +++ b/crates/deckard-core/src/balances.rs @@ -8,7 +8,7 @@ use alloy::providers::{DynProvider, Provider}; use alloy::sol; use alloy::sol_types::SolCall; -use crate::tokens::DEFAULT_TOKENS; +use crate::tokens::tokens_for; sol! { #[sol(rpc)] @@ -27,9 +27,11 @@ sol! { /// Multicall3 is deployed at the same address on every chain it supports. const MULTICALL3: Address = address!("0xcA11bde05977b3631167028862bE2a173976CA11"); -/// One token holding: enough to render a row and (later) value it. +/// One token holding: enough to render a row, (later) value it, and prefill a swap. #[derive(Clone, Debug)] pub struct TokenBalance { + /// The token's ERC-20 contract address — the GUI needs it to prefill a swap's sell token. + pub address: Address, pub symbol: &'static str, pub name: &'static str, pub decimals: u8, @@ -45,14 +47,18 @@ pub struct Portfolio { pub tokens: Vec, } -/// Read the full portfolio for `address` in one Multicall3 round-trip. +/// Read the full portfolio for `address` on `chain_id` in one Multicall3 round-trip. The +/// curated ERC-20 set is keyed by chain via [`tokens_for`] (mainnet majors, the Sepolia +/// swap-test set, or empty for an unknown chain — in which case only native ETH is read). pub async fn fetch_portfolio( provider: &DynProvider, address: Address, + chain_id: u64, ) -> anyhow::Result { + let listed = tokens_for(chain_id); let mc = IMulticall3::new(MULTICALL3, provider); - let mut calls = Vec::with_capacity(DEFAULT_TOKENS.len() + 1); + let mut calls = Vec::with_capacity(listed.len() + 1); // [0] = native ETH balance (read through Multicall3 itself). calls.push(IMulticall3::Call3 { target: MULTICALL3, @@ -62,7 +68,7 @@ pub async fn fetch_portfolio( .into(), }); // [1..] = balanceOf per listed token, failure-tolerant. - for t in DEFAULT_TOKENS { + for t in listed { calls.push(IMulticall3::Call3 { target: t.address, allowFailure: true, @@ -91,13 +97,14 @@ pub async fn fetch_portfolio( let native_wei = IMulticall3::getEthBalanceCall::abi_decode_returns(&native.returnData)?; let mut tokens = Vec::new(); - for (t, r) in DEFAULT_TOKENS.iter().zip(results.iter().skip(1)) { + for (t, r) in listed.iter().zip(results.iter().skip(1)) { if !r.success { continue; } if let Ok(raw) = IERC20::balanceOfCall::abi_decode_returns(&r.returnData) { if !raw.is_zero() { tokens.push(TokenBalance { + address: t.address, symbol: t.symbol, name: t.name, decimals: t.decimals, @@ -154,6 +161,37 @@ fn group_thousands(int_part: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::tokens::SEPOLIA_TOKENS; + use alloy::sol_types::SolValue; + + /// An ABI-encoded `balanceOf` return decodes back to its `U256`, and a `TokenBalance` + /// built from a real Sepolia token carries its address + 18 decimals (the test-USDC quirk). + #[test] + fn decodes_balance_and_builds_token() { + // The Sepolia test USDC is an 18-decimals mock (see tokens.rs module note). + let usdc = SEPOLIA_TOKENS + .iter() + .find(|t| t.symbol == "USDC") + .expect("sepolia USDC present"); + assert_eq!(usdc.decimals, 18, "sepolia test USDC is 18 decimals, not 6"); + + // 1.5 USDC at 18 decimals, ABI-encoded the way an ERC-20 balanceOf return is. + let raw = U256::from(1_500_000_000_000_000_000u64); + let encoded = raw.abi_encode(); + let decoded = + IERC20::balanceOfCall::abi_decode_returns(&encoded).expect("decode balanceOf"); + assert_eq!(decoded, raw); + + let tb = TokenBalance { + address: usdc.address, + symbol: usdc.symbol, + name: usdc.name, + decimals: usdc.decimals, + raw: decoded, + }; + assert_eq!(tb.address, usdc.address); + assert_eq!(format_amount(tb.raw, tb.decimals, 4), "1.5"); + } #[test] fn formats_and_groups() { diff --git a/crates/deckard-core/src/cow_client.rs b/crates/deckard-core/src/cow_client.rs index f4bbe57..5e8fe8e 100644 --- a/crates/deckard-core/src/cow_client.rs +++ b/crates/deckard-core/src/cow_client.rs @@ -460,6 +460,65 @@ pub async fn get_account_orders( Ok(parse_account_orders(&body)?) } +// --------------------------------------------------------------------------- +// CowOrderbook — a thin handle that OWNS a `reqwest::Client` so the app can drive the +// orderbook WITHOUT naming `reqwest` itself. Each method takes high-level args plus the +// orderbook `base` URL and delegates to the free functions above (which the tests still use +// directly). Additive: the free functions are unchanged. +// --------------------------------------------------------------------------- + +/// A reusable CoW orderbook client. Builds one `reqwest::Client` (connection-pool reuse across +/// calls) and exposes the orderbook operations the app needs, taking only high-level arguments +/// and the orderbook `base` URL — so callers never depend on `reqwest` directly. +#[derive(Clone, Debug)] +pub struct CowOrderbook { + client: reqwest::Client, +} + +impl Default for CowOrderbook { + fn default() -> Self { + Self::new() + } +} + +impl CowOrderbook { + /// Build a new orderbook handle with a default `reqwest::Client`. + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } + + /// `POST {base}/api/v1/quote` → priced order parameters. + pub async fn quote(&self, base: &str, req: &QuoteRequest) -> anyhow::Result { + post_quote(&self.client, base, req).await + } + + /// `PUT {base}/api/v1/app_data` — register the full app-data doc (idempotent on the backend). + pub async fn put_app_data(&self, base: &str, doc: &str) -> anyhow::Result<()> { + put_app_data(&self.client, base, doc).await + } + + /// `POST {base}/api/v1/orders` → the created order's uid (0x-hex string). + pub async fn submit(&self, base: &str, order: &OrderCreation) -> anyhow::Result { + post_order(&self.client, base, order).await + } + + /// `GET {base}/api/v1/orders/{uid}/status` → the lifecycle status. + pub async fn status(&self, base: &str, uid: &str) -> anyhow::Result { + get_order_status(&self.client, base, uid).await + } + + /// `GET {base}/api/v1/account/{owner}/orders` → the account's recent orders. + pub async fn account_orders( + &self, + base: &str, + owner: Address, + ) -> anyhow::Result> { + get_account_orders(&self.client, base, owner).await + } +} + // --------------------------------------------------------------------------- // U256 decimal-string (de)serialization. TokenAmount on the CoW wire is a decimal string // (NOT 0x-hex), so alloy's default U256 serde (which is 0x-hex) is wrong here. @@ -750,6 +809,15 @@ mod tests { )); } + /// The owning `CowOrderbook` handle constructs via `new`/`Default` without a network call, + /// so the app can build one cheaply and hold it. (Round-trips are covered by the live + /// `#[ignore]`d test and Package D's helper tests.) + #[test] + fn cow_orderbook_constructs() { + let _ob = CowOrderbook::new(); + let _default = CowOrderbook::default(); + } + /// QuoteRequest serializes amounts as decimal strings (TokenAmount), not 0x-hex. #[test] fn quote_request_serializes_decimal_amount() { diff --git a/crates/deckard-core/src/eth.rs b/crates/deckard-core/src/eth.rs index cfe505b..11fdc92 100644 --- a/crates/deckard-core/src/eth.rs +++ b/crates/deckard-core/src/eth.rs @@ -31,11 +31,19 @@ use alloy::ens::ProviderEnsExt; use alloy::primitives::{Address, U256}; use alloy::providers::{DynProvider, Provider, ProviderBuilder}; +use alloy::sol; use deckard_contract::ReadStatus; use crate::balances::{fetch_portfolio, Portfolio}; +sol! { + #[sol(rpc)] + interface IERC20Allowance { + function allowance(address owner, address spender) external view returns (uint256); + } +} + /// A reliable public mainnet RPC, used as the execution-layer endpoint Helios proves /// against (or, with `verified-reads` off, read directly). Overridable via settings. pub const DEFAULT_RPC: &str = "https://ethereum-rpc.publicnode.com"; @@ -71,6 +79,12 @@ enum EthReq { addr: Address, reply: Reply>, }, + Allowance { + owner: Address, + spender: Address, + token: Address, + reply: Reply, + }, ResolveName { name: String, reply: Reply
, @@ -85,10 +99,11 @@ pub struct EthProvider { } impl EthProvider { - /// Spawn the network worker pointed at `rpc_url`. Never blocks; the runtime, the - /// embedded Helios client (when `verified-reads` is on), and the alloy provider are - /// all built on the worker thread. - pub fn spawn(rpc_url: impl Into) -> Self { + /// Spawn the network worker pointed at `rpc_url` for `chain_id`. The chain id selects the + /// curated token set the portfolio read uses (see [`crate::tokens::tokens_for`]). Never + /// blocks; the runtime, the embedded Helios client (when `verified-reads` is on), and the + /// alloy provider are all built on the worker thread. + pub fn spawn(rpc_url: impl Into, chain_id: u64) -> Self { let rpc_url = rpc_url.into(); let (tx, rx) = flume::unbounded::(); // Fatal-at-startup boundary: if the OS refuses to spawn the network thread the app cannot @@ -96,7 +111,7 @@ impl EthProvider { #[allow(clippy::expect_used)] std::thread::Builder::new() .name("deckard-eth".into()) - .spawn(move || run_worker(rpc_url, rx)) + .spawn(move || run_worker(rpc_url, chain_id, rx)) .expect("spawn deckard-eth worker thread"); Self { tx } } @@ -119,6 +134,24 @@ impl EthProvider { self.request(|reply| EthReq::Portfolio { addr, reply }) } + /// Read the ERC-20 `allowance(owner, spender)` of `token` — how much `spender` may move + /// of `owner`'s `token` balance. The swap path uses this to decide whether `owner` still + /// needs to approve the CoW vault relayer. Not value-bearing in the trust sense, so no + /// trust label; non-blocking, await on the UI executor. + pub fn allowance( + &self, + owner: Address, + spender: Address, + token: Address, + ) -> flume::Receiver> { + self.request(|reply| EthReq::Allowance { + owner, + spender, + token, + reply, + }) + } + /// Forward-resolve an ENS name (e.g. `vitalik.eth`) to an address. Not value-bearing, /// so no trust label — the resulting address is then read with one. pub fn resolve_name( @@ -146,7 +179,7 @@ impl EthProvider { /// The worker entry point: build the runtime + the read provider (verified or raw), /// then service requests until every `EthProvider` handle has dropped (closing `rx`). -fn run_worker(rpc_url: String, rx: flume::Receiver) { +fn run_worker(rpc_url: String, chain_id: u64, rx: flume::Receiver) { // Fatal-at-startup boundary: a current-thread runtime we cannot build leaves the worker unable // to do anything; panicking with a clear message beats silently servicing nothing. #[allow(clippy::expect_used)] @@ -156,7 +189,7 @@ fn run_worker(rpc_url: String, rx: flume::Receiver) { .expect("build tokio current-thread runtime"); rt.block_on(async move { - let read_path = ReadPath::build(&rpc_url).await; + let read_path = ReadPath::build(&rpc_url, chain_id).await; while let Ok(req) = rx.recv_async().await { match req { @@ -169,6 +202,14 @@ fn run_worker(rpc_url: String, rx: flume::Receiver) { EthReq::Portfolio { addr, reply } => { let _ = reply.send(read_path.portfolio(addr).await); } + EthReq::Allowance { + owner, + spender, + token, + reply, + } => { + let _ = reply.send(read_path.allowance(owner, spender, token).await); + } EthReq::ResolveName { name, reply } => { let _ = reply.send(read_path.resolve_name(&name).await); } @@ -181,6 +222,9 @@ fn run_worker(rpc_url: String, rx: flume::Receiver) { /// `verified-reads` is on, the embedded Helios client that owns the localhost server /// (kept alive for the worker's lifetime — its Drop tears the server down). struct ReadPath { + /// The chain this path reads against. Selects the curated token set for a portfolio read + /// (see [`crate::tokens::tokens_for`]). + chain_id: u64, /// `None` when the URL was unparseable / Helios failed to come up. Every read then /// answers with an error or an `Unsynced` status (fail-closed; the UI never hangs). provider: Option, @@ -196,7 +240,7 @@ struct ReadPath { impl ReadPath { /// Build the read path on the worker thread, inside the worker's tokio runtime. #[cfg(feature = "verified-reads")] - async fn build(rpc_url: &str) -> Self { + async fn build(rpc_url: &str, chain_id: u64) -> Self { // Demo / local-fork mode: when verified reads are disabled at runtime // (`DECKARD_VERIFIED_READS=0`), skip the Helios bootstrap entirely. Embedded Helios is // mainnet-only and would stall a Balance read against a Sepolia fork; instead read the @@ -207,6 +251,7 @@ impl ReadPath { .ok() .map(|url| ProviderBuilder::new().connect_http(url).erased()); return Self { + chain_id, provider, unverified_reason: Some("verification disabled (demo mode)".to_string()), _helios: None, @@ -231,6 +276,7 @@ impl ReadPath { // VerifiedReader is retained so the server task stays alive. let provider = reader.provider().clone(); Self { + chain_id, provider: Some(provider), unverified_reason: None, // verified path: label by head freshness _helios: Some(reader), @@ -247,6 +293,7 @@ impl ReadPath { .ok() .map(|url| ProviderBuilder::new().connect_http(url).erased()); Self { + chain_id, provider, unverified_reason: Some(reason), _helios: None, @@ -257,12 +304,13 @@ impl ReadPath { /// Feature-off build: the original raw-RPC path, always tagged Unsynced. #[cfg(not(feature = "verified-reads"))] - async fn build(rpc_url: &str) -> Self { + async fn build(rpc_url: &str, chain_id: u64) -> Self { let provider = rpc_url .parse() .ok() .map(|url| ProviderBuilder::new().connect_http(url).erased()); Self { + chain_id, provider, unverified_reason: Some("verification disabled".to_string()), } @@ -316,10 +364,27 @@ impl ReadPath { .provider .as_ref() .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?; - let value = fetch_portfolio(provider, addr).await?; + let value = fetch_portfolio(provider, addr, self.chain_id).await?; Ok(Read::new(value, self.status().await)) } + /// `eth_call` of ERC-20 `allowance(owner, spender)` on `token`. Not value-bearing in the + /// trust sense (it gates an approval, it isn't a balance the user reads), so no trust label. + async fn allowance( + &self, + owner: Address, + spender: Address, + token: Address, + ) -> anyhow::Result { + let provider = self + .provider + .as_ref() + .ok_or_else(|| anyhow::anyhow!("no RPC/Helios read path"))?; + let erc20 = IERC20Allowance::new(token, provider); + let allowed = erc20.allowance(owner, spender).call().await?; + Ok(allowed) + } + async fn resolve_name(&self, name: &str) -> anyhow::Result
{ let provider = self .provider @@ -357,6 +422,7 @@ mod tests { .connect_mocked_client(asserter) .erased(); ReadPath { + chain_id: 1, provider: Some(provider), unverified_reason: Some("test (no helios)".to_string()), #[cfg(feature = "verified-reads")] @@ -377,15 +443,40 @@ mod tests { assert!(!read.status.is_trustworthy()); } + /// The read path decodes an ERC-20 allowance off a mocked transport (no trust label). + /// An `eth_call` returns ABI-encoded bytes, so the mock must reply with the encoded + /// `allowance(...)` return (a 32-byte word), not a bare U256 RPC value. + #[tokio::test] + async fn allowance_reads_from_mocked_transport() { + use alloy::primitives::Bytes; + use alloy::sol_types::SolValue; + + let asserter = Asserter::new(); + let encoded = Bytes::from(U256::from(1_000_000u64).abi_encode()); + asserter.push_success(&encoded); + let path = mocked_path(asserter); + + let allowed = path + .allowance(Address::ZERO, Address::ZERO, Address::ZERO) + .await + .unwrap(); + assert_eq!(allowed, U256::from(1_000_000u64)); + } + /// A missing provider fails closed with an error rather than panicking or hanging. #[tokio::test] async fn no_provider_errors_cleanly() { let path = ReadPath { + chain_id: 1, provider: None, unverified_reason: Some("test (no provider)".to_string()), #[cfg(feature = "verified-reads")] _helios: None, }; assert!(path.balance(Address::ZERO).await.is_err()); + assert!(path + .allowance(Address::ZERO, Address::ZERO, Address::ZERO) + .await + .is_err()); } } diff --git a/crates/deckard-core/src/lib.rs b/crates/deckard-core/src/lib.rs index e1f28f6..ee4ecf2 100644 --- a/crates/deckard-core/src/lib.rs +++ b/crates/deckard-core/src/lib.rs @@ -77,8 +77,9 @@ pub use config::{config_dir, policy_path, vault_path}; pub use cow_client::{ get_account_orders, get_order_status, parse_account_orders, parse_error_body, parse_order_status, parse_order_uid, parse_quote_response, post_order, post_quote, - put_app_data, swap_order_from_quote, AccountOrder, AppDataDoc, CowError, OrderCreation, - OrderStatusResponse, QuoteOrderParameters, QuoteRequest, QuoteResponse, DEFAULT_SLIPPAGE_BPS, + put_app_data, swap_order_from_quote, AccountOrder, AppDataDoc, CowError, CowOrderbook, + OrderCreation, OrderStatusResponse, QuoteOrderParameters, QuoteRequest, QuoteResponse, + DEFAULT_SLIPPAGE_BPS, }; pub use env::{demo_fork_block, screen_capture_allowed, verified_reads_enabled}; pub use eth::{EthProvider, Read, DEFAULT_RPC}; diff --git a/docs/dev/railgun-local-testing.md b/docs/dev/railgun-local-testing.md new file mode 100644 index 0000000..72986f0 --- /dev/null +++ b/docs/dev/railgun-local-testing.md @@ -0,0 +1,77 @@ +# Railgun shield/swap — local testing (agentic + manual) + +Native-ETH **Send** works against any local anvil chain. **Railgun shield/swap do not** — they call +the *real deployed Railgun contracts* and read the on-chain Merkle-tree state, which only exist on a +real network. So to exercise shield/swap locally you fork **Sepolia at the pinned block `10822990`** +(where those contracts live) and point the app at the fork. + +## The only "blocker" is a Sepolia archive RPC — and it can be keyless + +`anvil`'s `--fork-url` needs a Sepolia **archive** endpoint that can serve block `10822990`. It is +**not** read by any Deckard binary (custody never touches it). The zero-setup option needs no key: + +```sh +export RPC_URL_SEPOLIA=https://sepolia.drpc.org # keyless public endpoint (may rate-limit) +cast chain-id --rpc-url "$RPC_URL_SEPOLIA" # -> 11155111 +cast block 10822990 --rpc-url "$RPC_URL_SEPOLIA" --field number # -> 10822990 (proves archive depth) +``` + +Verified 2026-06-14: the keyless public endpoint serves the fork block and a full shield syncs/mines. +For heavy/automated use, a free **keyed** endpoint (Alchemy/Infura/dRPC) is steadier. + +## Path A — the human demo: `just demo` + +```sh +export RPC_URL_SEPOLIA=https://sepolia.drpc.org +just demo # anvil fork of Sepolia + app + daemon (uses ~/.deckard/demo; Ctrl-C tears anvil down) +just demo-fund # terminal 2: anvil_setBalance 10 ETH onto the onboarded wallet +just demo-check # doctor: foundry, RPC, fork, signerd build, app unlocked on the right chain +``` + +## Path B — agentic / clicky QA: a fresh throwaway config against the fork + +`just demo` reuses `~/.deckard/demo` (which may hold a vault whose passphrase you don't know). For an +isolated, repeatable run, fork by hand into a fresh `DECKARD_CONFIG_DIR`: + +```sh +# 1. fork Sepolia at the Railgun block (chain id 11155111 is preserved — do NOT pass --chain-id) +anvil --fork-url https://sepolia.drpc.org --fork-block-number 10822990 --port 8545 --silent & + +# 2. launch the app against the fork in a throwaway config dir +CFG=$(mktemp -d /tmp/deckard-rail-XXXX) +cargo build -p deckard-signerd +DECKARD_CONFIG_DIR="$CFG" DECKARD_SOCKET_PATH="$CFG/signerd.sock" \ +DECKARD_CHAIN_ID=11155111 DECKARD_RPC_URL=http://127.0.0.1:8545 \ +DECKARD_VERIFIED_READS=0 DECKARD_DEMO_FORK_BLOCK=10822990 \ +DECKARD_SIGNERD_BIN="$PWD/target/debug/deckard-signerd" \ + cargo run + +# 3. in the app: onboard a throwaway wallet, then fund the onboarded address: +ADDR=$(DECKARD_CONFIG_DIR="$CFG" DECKARD_SOCKET_PATH="$CFG/signerd.sock" \ + DECKARD_CHAIN_ID=11155111 DECKARD_RPC_URL=http://127.0.0.1:8545 \ + cargo run -q -p deckard-mcp -- address | jq -r .address) +cast rpc anvil_setBalance "$ADDR" "$(cast to-hex "$(cast to-wei 10 ether)")" --rpc-url http://127.0.0.1:8545 +``` + +## Shield walkthrough (what "passing" looks like) + +1. Portfolio after funding: **Public 100% · Private 0%**, 10 ETH. +2. **Shield** → compose. The recipient is **pre-filled with the wallet's own `0zk` address** (this + alone exercises the Railgun SLIP-0010/babyjubjub key derivation). +3. Review card (clear-signing): amount, **Railgun fee 0.25%**, "you'll receive (private)" = amount × 0.9975. +4. **Hold-to-shield** (amber fill) → deposit broadcast → mined (fork block advances by 1); the status + strip flips to a green "Private. Spendable now." +5. **Refresh the Portfolio** — the split bar then flips **public → private** (e.g. 0.1 shielded → + Private 0.0997 / Public 9.8999). The balance does **not** update until Refresh: that resync-on-refresh + is the behavior PR #30 added (`refresh_portfolio` also calls the shielded handle's `resync`). + +## Gotchas + +- `DECKARD_VERIFIED_READS=0` for the demo/fork — Helios verified reads are mainnet-only. +- Restart anvil per run; the in-memory Railgun DB re-syncs from the fork each launch (~10 s). See #12 + for persisting the Railgun DB (cold sync ~11 min/launch otherwise). +- POI (proof-of-innocence) is `None` on a fork — expected; shielding still works. +- Driving the GPUI app for clicky QA: see [`headless-gui-screenshots.md`](headless-gui-screenshots.md) + (Linux) and the macOS recipe in the team memory; never let a subagent run `cargo` on the app (cold + builds stall watchdogs). + diff --git a/justfile b/justfile index 298ee6f..43cc38a 100644 --- a/justfile +++ b/justfile @@ -37,6 +37,29 @@ run-tray: cargo build -p deckard-signerd cargo run -p deckard-app --features tray +# ─── QA fast-unlock vault (DevEx) ──────────────────────────────────────────── +# Skip onboarding (Create -> passphrase -> seed reveal -> backup challenge) on every +# clicky QA run. `qa-vault` seals a THROWAWAY vault (anvil's dev mnemonic — account 0 +# is prefunded on any anvil/fork) under a fixed passphrase with FAST KDF into an +# isolated /tmp config dir; `qa` then launches the app there so it boots straight to +# Unlock — type the passphrase it prints (decrypts in ~tens of ms, not ~1 s). +# Throwaway only; never real funds. Production create/import keep KdfParams::PRODUCTION. + +# Seal the QA vault into /tmp/deckard-qa (re-run anytime to reset it). +qa-vault: + DECKARD_CONFIG_DIR="/tmp/deckard-qa" cargo run -q -p deckard-core --example qa-vault + +# Launch the app against the QA vault, pointed at a local anvil (start `anvil` first +# for live balances / send / shield). Run `just qa-vault` once before this. +qa: + cargo build -p deckard-signerd + DECKARD_CONFIG_DIR="/tmp/deckard-qa" \ + DECKARD_SOCKET_PATH="/tmp/deckard-qa/signerd.sock" \ + DECKARD_CHAIN_ID="31337" \ + DECKARD_RPC_URL="http://127.0.0.1:8545" \ + DECKARD_VERIFIED_READS="0" \ + cargo run + # Full walkthrough (onboard -> demo-fund -> shield): CONTRIBUTING "Demo / local-chain dev loop". # Wires an isolated ~/.deckard/demo config dir; verified reads are OFF; Ctrl-C tears anvil down. # Start the demo: a local anvil fork of Sepolia (pinned block) + the Deckard app & daemon. From 0cab12b214ed876556425618b78001ede703040a Mon Sep 17 00:00:00 2001 From: hellno Date: Mon, 15 Jun 2026 21:47:34 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(app):=20Swap=20v1=20GUI=20(#25)=20?= =?UTF-8?q?=E2=80=94=20CoW=20compose/quote/review=20on=20the=20CommitFlow?= =?UTF-8?q?=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built on the new commit_flow/commit_view foundation via a plan→build→review→fix workflow. Tree green: just check (default + tray) + cargo test --workspace (247 passed). - swap.rs: pure helpers (quote_request, order_from_quote, gross/min_receive, needs_approval, token lookups) + confirm_swap_blocking orchestrator: re-quote → propose_order (NeedsApproval) → allowance check → exact-gross shaped approve (proposed AFTER the order; spender=GPV2VaultRelayer, value=0) → re-read allowance guard → resolve+sign over the control channel → put_app_data → submit → uid. - swap_view.rs: Swap surface (sell-amount + sell/buy token pickers + Get quote, quote summary, order review clear-signing card) reusing commit_view's hold-to-confirm. - shell.rs: Surface::Swap + swap_* state + handlers; un-gated Swap button (welcome.rs); palette 'swap' command; v_flex render arm so the card centers (gpui-div-defaults-block). - 12 new swap unit tests; humanize_swap_deny swap-worded copy. Runtime fix: CoW HTTP (reqwest/hickory) needs a tokio reactor, which GPUI's executor lacks. Routed through deckard-core CowOrderbook::{quote,put_app_data,submit}_blocking (core owns tokio; the app never touches it — matches eth.rs/shielded.rs). Caught via live GUI drive (compose renders + token pickers verified on a Sepolia fork). KNOWN-PENDING: the post-fix LIVE quote→order-submit round-trip was not re-confirmed — the Mac auto-locked mid-verification. Compose/pickers verified; the fix compiles + the allowance/sequence logic is unit-tested, but a final live quote→accepted+open confirm is owed once the screen is unlocked. --- crates/deckard-app/src/errors.rs | 69 ++ crates/deckard-app/src/main.rs | 2 + crates/deckard-app/src/palette_commands.rs | 37 +- crates/deckard-app/src/shell.rs | 473 +++++++++++++- crates/deckard-app/src/shell_chrome.rs | 1 + crates/deckard-app/src/signer.rs | 12 +- crates/deckard-app/src/swap.rs | 562 +++++++++++++++++ crates/deckard-app/src/swap_view.rs | 696 +++++++++++++++++++++ crates/deckard-app/src/welcome.rs | 26 +- crates/deckard-core/src/cow_client.rs | 31 + 10 files changed, 1891 insertions(+), 18 deletions(-) create mode 100644 crates/deckard-app/src/swap.rs create mode 100644 crates/deckard-app/src/swap_view.rs diff --git a/crates/deckard-app/src/errors.rs b/crates/deckard-app/src/errors.rs index a57f3ad..14e8e8d 100644 --- a/crates/deckard-app/src/errors.rs +++ b/crates/deckard-app/src/errors.rs @@ -45,6 +45,41 @@ pub fn humanize_deny(reason: &str) -> String { } } +/// Map a daemon deny `reason` to calm, **swap-specific** copy (#25). [`humanize_deny`] is +/// deposit/shield-worded ("the deposit…"), which reads wrong on a swap, so the swap path routes +/// its denies here. The swap-only policy/admission tags get distinct copy; anything else falls +/// through to [`humanize_deny`] so the shared session/process tags (`locked`, `chain_mismatch`, +/// `broadcast_*`, …) keep their single source of truth. +pub fn humanize_swap_deny(reason: &str) -> String { + match reason { + // --- order admission (deckard-contract::evaluate_order) --- + "receiver_not_wallet" | "receiver_zero" => { + "a swap can only send the bought token back to your own wallet".into() + } + "off_swap_list" => "one of these tokens isn't on the agent's swap allow-list".into(), + "valid_to_too_far" => { + "the order's expiry is too far out — re-quote and try the swap again".into() + } + "zero_amount" => "enter an amount greater than zero to swap".into(), + // --- shaped-approve admission (the exact-gross relayer approve) --- + "approve_with_value" => "the token approval must not move any ETH".into(), + "approve_wrong_spender" => { + "the token approval targets the wrong contract — review the swap again".into() + } + "approve_no_matching_order" => { + "the approval didn't match a pending order — review the swap again".into() + } + // --- order sign / id guards --- + "already_signed" => { + "this order was already signed — it's on its way to the orderbook".into() + } + "not_an_order" => "the signer session was reset — review the swap again".into(), + "swap_unsupported_in_mock" => "swaps aren't available in this test build".into(), + // Everything else (session/process tags) keeps the shared humanizer's copy. + other => humanize_deny(other), + } +} + /// True for a daemon `reason` that means the unlock **session ended** — the key was zeroized /// by a STOP (an external `RevokeAll` from an MCP client, or the daemon is otherwise `Locked`). /// The app must return to the unlock gate, not just show an inline error: a propose against a @@ -148,6 +183,40 @@ mod tests { ); } + #[test] + fn humanize_swap_deny_uses_swap_worded_copy_and_falls_through() { + use super::humanize_swap_deny; + // Swap-only tags get swap-worded copy (never "deposit"). + for tag in [ + "receiver_not_wallet", + "off_swap_list", + "valid_to_too_far", + "approve_with_value", + "approve_wrong_spender", + "approve_no_matching_order", + "already_signed", + ] { + let line = humanize_swap_deny(tag); + assert!(!line.is_empty(), "{tag} must map to copy"); + assert!( + !line.to_lowercase().contains("deposit"), + "{tag} must not be deposit-worded: {line}" + ); + assert_ne!(line, tag, "{tag} must be humanized, not shown raw"); + } + // A shared session/process tag falls through to the shared humanizer (one source of truth). + assert_eq!(humanize_swap_deny("locked"), humanize_deny("locked")); + assert_eq!( + humanize_swap_deny("chain_mismatch"), + humanize_deny("chain_mismatch") + ); + // An unknown tag still falls through unchanged (never swallowed). + assert_eq!( + humanize_swap_deny("some_new_swap_reason"), + "some_new_swap_reason" + ); + } + #[test] fn humanize_deny_passes_unknown_tags_through() { // An unrecognised tag falls through unchanged (the `other => other.to_string()` arm) — diff --git a/crates/deckard-app/src/main.rs b/crates/deckard-app/src/main.rs index a936612..467c836 100644 --- a/crates/deckard-app/src/main.rs +++ b/crates/deckard-app/src/main.rs @@ -24,6 +24,8 @@ mod shell; mod shell_chrome; mod shield_view; mod signer; +mod swap; +mod swap_view; mod theme; #[cfg(feature = "tray")] mod tray; diff --git a/crates/deckard-app/src/palette_commands.rs b/crates/deckard-app/src/palette_commands.rs index d938a2d..4590b50 100644 --- a/crates/deckard-app/src/palette_commands.rs +++ b/crates/deckard-app/src/palette_commands.rs @@ -59,6 +59,13 @@ pub const COMMANDS: &[Command] = &[ shortcut: None, icon: None, // no shield glyph in the bundled subset }, + Command { + id: "swap", + title: "Swap", + aliases: &["trade", "exchange", "cow", "convert"], + shortcut: None, + icon: None, // no swap glyph in the bundled subset + }, Command { id: "settings", title: "Settings", @@ -297,13 +304,18 @@ mod tests { } #[test] - fn empty_query_returns_all_ten() { + fn empty_query_returns_all_commands() { let mut m = matcher(); let usage = empty_usage(); let results = rank("", COMMANDS, &usage, 0, &mut m); assert_eq!(results.len(), COMMANDS.len()); - assert_eq!(COMMANDS.len(), 10); + assert_eq!(COMMANDS.len(), 11); + // The swap command joined the registry (#25); membership is asserted below. + assert!( + COMMANDS.iter().any(|c| c.id == "swap"), + "the swap command must be in the registry" + ); for r in &results { assert!(r.positions.is_empty()); } @@ -336,6 +348,27 @@ mod tests { assert_eq!(id_at(&results, COMMANDS.len() - 1), "lock"); } + #[test] + fn swap_and_trade_rank_the_swap_command() { + let mut m = matcher(); + let usage = empty_usage(); + + // The literal title matches. + let by_title = rank("swap", COMMANDS, &usage, 0, &mut m); + assert!( + by_title.iter().any(|r| COMMANDS[r.cmd_index].id == "swap"), + "\"swap\" must match the swap command" + ); + + // The "trade" alias reaches it too (no title positions on an alias match). + let by_alias = rank("trade", COMMANDS, &usage, 0, &mut m); + let swap = by_alias + .iter() + .find(|r| COMMANDS[r.cmd_index].id == "swap") + .expect("\"trade\" must match the swap command via its \"trade\" alias"); + assert!(swap.positions.is_empty()); + } + #[test] fn junk_query_returns_empty() { let mut m = matcher(); diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index fadd521..dd0a9cb 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -21,14 +21,15 @@ use deckard_contract::{ Decision, ExecuteResult, Intent, Policy, ShieldStatus, SignerRequest, SignerResponse, }; use deckard_core::{ - Address, EthProvider, KdfParams, Portfolio, ReadStatus, ShieldedHandle, Vault, WordCount, U256, + tokens_for, Address, CowOrderbook, EthProvider, KdfParams, Portfolio, QuoteResponse, + ReadStatus, ShieldedHandle, Vault, WordCount, U256, }; use zeroize::Zeroizing; use deckard_signerd::SignerClient; use crate::commit_flow::CommitFlow; -use crate::errors::{humanize_deny, is_session_ended, short_err}; +use crate::errors::{humanize_deny, humanize_swap_deny, is_session_ended, short_err}; use crate::settings::{Settings, ThemeModePref}; use crate::signer::{self, AppSigner}; use crate::theme; @@ -437,6 +438,35 @@ impl Shell { // firing — they hold their own entity handles, independent of where the inputs now live. let send = CommitFlow::new(send_amount, send_recipient); + // Swap flow inputs (#25): the amount input doubles as the sell amount; the recipient input + // is a throwaway (a swap's receiver is always your own wallet) — `CommitFlow::new` just + // needs two entities. On a sell-amount edit, clear any stale quote (a quote priced for an + // old amount must never survive into a confirm) and re-render; Enter gets a quote if there + // isn't one yet, else reviews the priced order (keyboard-first). + let swap_amount = + cx.new(|cx| InputState::new(window, cx).placeholder("Amount to sell, e.g. 0.05")); + let swap_recipient = cx.new(|cx| InputState::new(window, cx)); + cx.subscribe( + &swap_amount, + |this, _, event: &InputEvent, cx| match event { + InputEvent::Change => { + // A stale quote must never outlive an amount edit (codex must-do #4). + this.invalidate_swap_quote(); + cx.notify(); + } + InputEvent::PressEnter { .. } => { + if this.swap_quote.is_some() { + this.review_swap(cx); + } else { + this.get_swap_quote(cx); + } + } + _ => {} + }, + ) + .detach(); + let swap = CommitFlow::new(swap_amount, swap_recipient); + // Submit-on-Enter for each auth field (keyboard-first). cx.subscribe(&create_pass2, |this, _, event: &InputEvent, cx| { if matches!(event, InputEvent::PressEnter { .. }) { @@ -541,6 +571,13 @@ impl Shell { allow_screen_capture, shield, send, + swap, + swap_sell_token: None, + swap_buy_token: None, + swap_quote: None, + swap_quoting: false, + swap_uid: None, + swap_quote_epoch: 0, shielded: None, railgun_address: None, recipient_autofilled: false, @@ -632,6 +669,15 @@ impl Shell { self.pending_shield_clear = true; self.shield.reset(); self.send.reset(); + // Clear the swap flow + its compose-only state (tokens / quote / uid) so a prior wallet's + // priced order can't linger into the next unlock. + self.swap.reset(); + self.swap_sell_token = None; + self.swap_buy_token = None; + self.swap_quote = None; + self.swap_quoting = false; + self.swap_uid = None; + self.swap_quote_epoch = self.swap_quote_epoch.wrapping_add(1); self.auth = AuthStep::Unlock; self.palette_open = false; cx.notify(); @@ -1045,6 +1091,12 @@ impl Shell { .unwrap_or_default() } + /// The chain the daemon signs for (resolved once at startup). The swap surface reads it to + /// pick the curated token list, the orderbook base, and the per-chain swatch. + pub fn chain_id(&self) -> u64 { + self.chain_id + } + /// Whether the app is pointed at a local development fork rather than a public network — /// drives the status-strip "DEMO FORK — not mainnet" caution (rendered in `shell_chrome`). pub(crate) fn fork_mode(&self) -> bool { @@ -1248,6 +1300,11 @@ impl Shell { if surface != Surface::Send && self.send.holding { self.send.cancel_hold(); } + // And the swap hold: leaving Swap cancels an in-progress hold so its timer can't fire a + // confirm after the screen is gone. + if surface != Surface::Swap && self.swap.holding { + self.swap.cancel_hold(); + } self.surface = surface; cx.notify(); } @@ -1682,6 +1739,401 @@ impl Shell { } } + // --- CoW swap flow (#25) --- + + /// Drop any priced quote (and the review proposal it backs) and fence a slow in-flight quote + /// reply via the epoch bump, so a stale price can never be signed against changed compose + /// inputs (codex must-do #4). Called on every sell-amount edit (the input subscription) and on + /// every sell/buy token change. Does NOT `notify` — the caller decides when to re-render. + fn invalidate_swap_quote(&mut self) { + self.swap_quote = None; + // A live review proposal was built from the now-cleared quote; drop it too so the user + // can't confirm a card whose figures no longer have a backing quote. + self.swap.proposal = None; + self.swap.error = None; + self.swap_quote_epoch = self.swap_quote_epoch.wrapping_add(1); + } + + /// Open the swap flow from the wallet home / palette. Refused while viewing a watched + /// read-only account (a swap signs from YOUR wallet) and refused on a chain with no curated + /// token list (a plain anvil fork, chain 31337 — `tokens_for` is empty there, so there'd be + /// nothing to pick); both surface a clear line rather than opening an unusable screen. Seeds + /// the sell/buy tokens to the first two distinct tokens so the pickers are never empty. + pub fn open_swap(&mut self, cx: &mut Context) { + if self.viewing_watch { + return; + } + let tokens = tokens_for(self.chain_id); + if tokens.is_empty() { + // Open the surface anyway so the refusal is visible (not a silently-inert button), but + // with no quote/pickers possible — an honest "wrong network" line. + self.swap.reset(); + self.swap_quote = None; + self.swap_uid = None; + self.swap.error = Some( + "Swap needs a supported network (Sepolia or mainnet) — switch chains first".into(), + ); + self.open(Surface::Swap, cx); + return; + } + // Fresh slate: clear the flow, the last quote, and any prior done-screen uid. + self.swap.reset(); + self.swap_quote = None; + self.swap_uid = None; + self.swap_quote_epoch = self.swap_quote_epoch.wrapping_add(1); + // Seed the pickers to the first two distinct tokens (only if not already chosen this + // session) so compose has a valid default pair on first paint. + if self.swap_sell_token.is_none() { + self.swap_sell_token = tokens.first().map(|t| t.address); + } + if self.swap_buy_token.is_none() { + self.swap_buy_token = tokens + .iter() + .map(|t| t.address) + .find(|&a| Some(a) != self.swap_sell_token); + } + self.open(Surface::Swap, cx); + } + + /// Choose the sell-side token. A different token invalidates the quote (it was priced for the + /// old pair) and never lets the sell == buy degenerate case stand (it clears the buy side if + /// they'd collide). + pub fn set_swap_sell_token(&mut self, token: Address, cx: &mut Context) { + if self.swap_sell_token == Some(token) { + return; + } + self.swap_sell_token = Some(token); + if self.swap_buy_token == Some(token) { + self.swap_buy_token = None; + } + self.invalidate_swap_quote(); + cx.notify(); + } + + /// Choose the buy-side token (same staleness + collision rules as the sell side). + pub fn set_swap_buy_token(&mut self, token: Address, cx: &mut Context) { + if self.swap_buy_token == Some(token) { + return; + } + self.swap_buy_token = Some(token); + if self.swap_sell_token == Some(token) { + self.swap_sell_token = None; + } + self.invalidate_swap_quote(); + cx.notify(); + } + + /// Fetch an indicative quote for the current compose inputs, off-thread. Epoch-fenced: a slow + /// reply for a since-edited compose (different amount or pair) lands as a no-op. The quote is + /// indicative only — `confirm_swap` re-quotes at confirm time for the binding figures. + pub fn get_swap_quote(&mut self, cx: &mut Context) { + if self.swap_quoting { + return; + } + let amount = self.swap.amount.read(cx).value().to_string(); + let sell_wei = match signer::parse_eth_to_wei(&amount) { + Ok(w) if w > U256::ZERO => w, + Ok(_) => { + self.swap.error = Some("Enter an amount greater than zero".into()); + cx.notify(); + return; + } + Err(e) => { + self.swap.error = Some(e); + cx.notify(); + return; + } + }; + let (Some(sell_token), Some(buy_token)) = (self.swap_sell_token, self.swap_buy_token) + else { + self.swap.error = Some("Pick a token to sell and a token to receive".into()); + cx.notify(); + return; + }; + if sell_token == buy_token { + self.swap.error = Some("Pick two different tokens".into()); + cx.notify(); + return; + } + let Some(base) = crate::swap::orderbook_base(self.chain_id) else { + self.swap.error = Some("Swap needs a supported network (Sepolia or mainnet)".into()); + cx.notify(); + return; + }; + let wallet = self.wallet_address.unwrap_or(Address::ZERO); + + self.swap.error = None; + self.swap_quoting = true; + // Fence this request: a reply for a since-changed compose is dropped on arrival. + self.swap_quote_epoch = self.swap_quote_epoch.wrapping_add(1); + let epoch = self.swap_quote_epoch; + cx.notify(); + + let req = crate::swap::quote_request(sell_token, buy_token, wallet, sell_wei); + let task = cx.background_spawn(async move { + // CoW HTTP (reqwest/hickory DNS) needs a tokio reactor, which GPUI's executor lacks — + // so the quote goes through deckard-core's blocking wrapper (it owns the runtime). The + // blocking call runs on this spawned background task, never the UI thread. + let ob = CowOrderbook::new(); + ob.quote_blocking(base, &req) + }); + cx.spawn(async move |this, cx| { + let res = task.await; + this.update(cx, |this, cx| { + // Drop a reply for a since-superseded quote request (a later edit / token change). + if this.swap_quote_epoch != epoch { + return; + } + this.swap_quoting = false; + match res { + Ok(quote) => { + this.swap_quote = Some(quote); + this.swap.error = None; + } + Err(e) => { + this.swap_quote = None; + this.swap.error = Some(crate::swap::humanize_quote_error(&e)); + } + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Build the bound order from the current quote, `propose_order` it off-thread, and on + /// `NeedsApproval` install the review proposal so the clear-signing card + hold-to-confirm + /// appear. A swap is ALWAYS `NeedsApproval` in v1 (the completed hold IS the approval); a + /// `Deny` surfaces a swap-worded line. Validates a fresh quote + distinct tokens up front. + pub fn review_swap(&mut self, cx: &mut Context) { + if self.swap.busy { + return; + } + let Some(quote) = self.swap_quote.clone() else { + self.swap.error = Some("Get a quote first, then review the order".into()); + cx.notify(); + return; + }; + let (Some(sell_token), Some(buy_token)) = (self.swap_sell_token, self.swap_buy_token) + else { + self.swap.error = Some("Pick a token to sell and a token to receive".into()); + cx.notify(); + return; + }; + if sell_token == buy_token { + self.swap.error = Some("Pick two different tokens".into()); + cx.notify(); + return; + } + let Some(wallet) = self.wallet_address else { + self.swap.error = Some("Unlock your wallet first".into()); + cx.notify(); + return; + }; + let chain_id = self.chain_id; + let order = crate::swap::order_from_quote("e, chain_id, wallet); + let bound = signer::bind_swap_order(&order, wallet); + let request_id = SignerClient::request_id_for_swap_order(&bound); + // A display-only "X SELL → at least Y BUY" summary the review card shows above the rows. + let summary = self.swap_summary_line("e, chain_id); + + self.swap.error = None; + self.swap.proposal = None; + // begin_review bumps the review epoch + sets busy; a stale reply checks it before installing. + let epoch = self.swap.begin_review(); + cx.notify(); + let client = self.signer.client(); + let order_for_task = order.clone(); + let task = + cx.background_spawn(async move { client.propose_order_blocking(&order_for_task) }); + cx.spawn(async move |this, cx| { + let res = task.await; + this.update(cx, |this, cx| { + // Guard FIRST: a stale review must not clear `busy` a newer review may own. + if !this.swap.review_is_current(epoch) { + return; + } + this.swap.busy = false; + match res { + // A valid swap is always NeedsApproval — the hold IS the approval. An Allow is + // unexpected (v1 swaps never auto-allow), but install it the same way (confirm + // re-derives + resolves regardless); the daemon stays the gate. + Ok(Decision::NeedsApproval { .. }) | Ok(Decision::Allow) => { + this.swap.proposal = Some(crate::commit_flow::Proposal { + // The swap path never reads `intent` (the orchestrator works off a + // fresh SwapInputs snapshot + re-quote); carry a synthetic placeholder + // so the shared `Proposal` shape is satisfied. + intent: signer::build_exact_approve_intent( + chain_id, + sell_token, + order.sell_amount, + ), + request_id, + recipient: summary, + needs_resolve: true, + }); + } + Ok(Decision::Deny { reason }) => { + if is_session_ended(&reason) { + this.handle_session_revoked(cx); + } else { + this.swap.error = + Some(format!("Can't swap: {}", humanize_swap_deny(&reason))); + } + } + Err(e) => this.swap.error = Some(short_err(e)), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// A display-only "0.05 WETH → at least 92.1 COW" summary for the review card header, scaled + /// by each side's curated decimals. Indicative (mirrors the quote summary); the binding figures + /// are the rows the card renders from the same quote. + fn swap_summary_line(&self, quote: &QuoteResponse, chain_id: u64) -> String { + let sell_tok = quote.quote.sell_token; + let buy_tok = quote.quote.buy_token; + let sell_sym = crate::swap::token_symbol(chain_id, sell_tok); + let buy_sym = crate::swap::token_symbol(chain_id, buy_tok); + let sell_dec = crate::swap::token_decimals(chain_id, sell_tok); + let buy_dec = crate::swap::token_decimals(chain_id, buy_tok); + let gross_sell = quote + .quote + .sell_amount + .saturating_add(quote.quote.fee_amount); + let min_recv = deckard_core::apply_slippage( + quote.quote.buy_amount, + deckard_core::DEFAULT_SLIPPAGE_BPS, + ); + format!( + "{} {} → at least {} {}", + deckard_core::format_amount(gross_sell, sell_dec, 6), + sell_sym, + deckard_core::format_amount(min_recv, buy_dec, 6), + buy_sym, + ) + } + + /// Confirm a reviewed swap (the hold-to-confirm completed): run the off-thread orchestrator + /// (re-quote → propose-order → exact-gross approve if short → resolve+sign over the control + /// channel → submit) and, on success, surface the order uid (the done screen). Mixes async I/O + /// with the daemon's `*_blocking` calls, so it runs inside `cx.background_spawn` (the blocking + /// calls block the spawned task, never the UI). Invalidates the proposal on every attempt — a + /// second hold must not re-submit (codex must-do, mirrors confirm_send/confirm_shield). + pub fn confirm_swap(&mut self, cx: &mut Context) { + if self.swap.proposal.is_none() || self.swap.busy { + return; + } + // Snapshot every value the orchestrator needs (codex must-do #5 — Shell isn't Send). + let (Some(quote), Some(sell_token), Some(buy_token), Some(wallet)) = ( + self.swap_quote.as_ref(), + self.swap_sell_token, + self.swap_buy_token, + self.wallet_address, + ) else { + self.swap.error = Some("Review the swap again — the order details are missing".into()); + cx.notify(); + return; + }; + let Some(base) = crate::swap::orderbook_base(self.chain_id) else { + self.swap.error = Some("Swap needs a supported network (Sepolia or mainnet)".into()); + cx.notify(); + return; + }; + let chain_id = self.chain_id; + // The gross sell amount the relayer must be allowed to pull. The orchestrator re-quotes at + // confirm time and re-derives its own gross, but THIS gross is the sell-in-atoms it + // re-quotes against (`sellAmountBeforeFee`), so it must be the compose quote's gross. + let sell_wei = quote + .quote + .sell_amount + .saturating_add(quote.quote.fee_amount); + + self.swap.busy = true; + self.swap.error = None; + // Invalidate the proposal on EVERY confirm attempt (a second hold can't re-submit). + self.swap.proposal = None; + cx.notify(); + + let client = self.signer.client(); + let control = self.signer.control(); + let eth = self.eth.clone(); + let inputs = crate::swap::SwapInputs { + chain_id, + wallet, + sell_token, + buy_token, + sell_wei, + }; + // The orchestrator is fully blocking: the CoW HTTP goes through deckard-core's `*_blocking` + // wrappers (which own a tokio runtime — the GPUI app never touches tokio), and the signer + // calls are already `*_blocking`. Run it on a background task so it blocks that task, never + // the UI thread. + let task = cx.background_spawn(async move { + let ob = CowOrderbook::new(); + crate::swap::confirm_swap_blocking(&ob, ð, &client, &control, base, inputs) + }); + cx.spawn(async move |this, cx| { + let outcome = task.await; + this.update(cx, |this, cx| { + this.swap.busy = false; + match outcome { + Ok(crate::swap::SwapConfirmOutcome::Submitted { uid }) => { + this.swap_uid = Some(uid); + // The order is on the orderbook (not yet on-chain) — no public balance + // change to refetch until a solver fills it. + } + Ok(crate::swap::SwapConfirmOutcome::Denied { reason }) => { + // A session-ended deny bounces to the unlock gate; the orchestrator returns + // the raw tag for those so we can detect it here. + if is_session_ended(&reason) { + this.handle_session_revoked(cx); + } else { + this.swap.error = + Some(format!("Can't swap: {}", humanize_swap_deny(&reason))); + } + } + Err(e) => this.swap.error = Some(short_err(e)), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Begin a confirm hold on the swap review: start the amber fill-sweep + a timer that fires + /// `confirm_swap` only if the hold survives [`SHIELD_HOLD`] and the user is still on Swap. + pub fn swap_hold_start(&mut self, cx: &mut Context) { + let Some(epoch) = self.swap.begin_hold() else { + return; + }; + cx.notify(); + cx.spawn(async move |this, cx| { + cx.background_executor().timer(SHIELD_HOLD).await; + this.update(cx, |this, cx| { + if this.surface == Surface::Swap && this.swap.hold_still_valid(epoch) { + this.swap.holding = false; + this.confirm_swap(cx); + } + }) + .ok(); + }) + .detach(); + } + + /// Release the swap confirm hold before it completed — reset the sweep; the epoch bump cancels + /// the pending timer. + pub fn swap_hold_cancel(&mut self, cx: &mut Context) { + if self.swap.cancel_hold() { + cx.notify(); + } + } + /// Re-install the theme from the current settings (mode). fn apply_theme(&self, cx: &mut Context) { theme::install(cx, self.settings.theme_mode.to_gpui()); @@ -1801,6 +2253,7 @@ impl Shell { "send" => self.open_send(cx), "receive" => self.open(Surface::Receive, cx), "shield" => self.open_shield(cx), + "swap" => self.open_swap(cx), "settings" => self.open(Surface::Settings, cx), "copy" => { cx.write_to_clipboard(gpui::ClipboardItem::new_string( @@ -1887,6 +2340,11 @@ impl Shell { self.send .recipient .update(cx, |i, cx| i.set_value("", window, cx)); + // The swap sell-amount field shares the lock-clear too (the quote/tokens were already + // cleared in `lock`; this clears the input text that a listener can't, needing a Window). + self.swap + .amount + .update(cx, |i, cx| i.set_value("", window, cx)); } if self.recipient_autofilled { return; @@ -1962,6 +2420,17 @@ impl Render for Shell { (_, Surface::Shield) => self .render_commit(&crate::shield_view::SHIELD_VIEW, cx) .into_any_element(), + // Swap is a bespoke render (token pickers + a quote summary the generic + // `render_commit` can't express); wrap it in its own scroll surface since the + // compose arm (pickers + summary) can run taller than the pane. + // v_flex (not a block div) so render_swap's commit_shell flex_1/justify_center + // actually centers the card — matching Send/Shield (see gpui-div-defaults-block). + (_, Surface::Swap) => v_flex() + .id("scroll-swap") + .size_full() + .overflow_y_scrollbar() + .child(self.render_swap(cx)) + .into_any_element(), (Selection::Wallet, Surface::Home) => div() .id("scroll-wallet") .size_full() diff --git a/crates/deckard-app/src/shell_chrome.rs b/crates/deckard-app/src/shell_chrome.rs index ea9ffc3..398d3d0 100644 --- a/crates/deckard-app/src/shell_chrome.rs +++ b/crates/deckard-app/src/shell_chrome.rs @@ -87,6 +87,7 @@ impl Shell { Surface::Receive => "Receive", Surface::Send => "Send", Surface::Shield => "Shield", + Surface::Swap => "Swap", Surface::Home => match self.selection { Selection::Project => "Personal", Selection::Wallet => "Wallet", diff --git a/crates/deckard-app/src/signer.rs b/crates/deckard-app/src/signer.rs index 20b0149..217a122 100644 --- a/crates/deckard-app/src/signer.rs +++ b/crates/deckard-app/src/signer.rs @@ -541,7 +541,10 @@ mod tests { let intent = build_exact_approve_intent(11155111, sell_token, gross); assert_eq!(intent.chain_id, 11155111); - assert_eq!(intent.to, sell_token, "approve targets the sell-token ERC-20"); + assert_eq!( + intent.to, sell_token, + "approve targets the sell-token ERC-20" + ); assert_eq!(intent.value, U256::ZERO, "approve must carry no ETH"); assert_eq!(intent.token, None); assert_eq!(intent.kind, IntentKind::ContractCall); @@ -590,7 +593,10 @@ mod tests { let intent = build_exact_approve_intent(11155111, order.sell_token, order.sell_amount); let (_, amount) = deckard_core::decode_approve(&intent.calldata).expect("valid approve"); - assert_eq!(amount, gross, "the approve covers the full gross sell amount"); + assert_eq!( + amount, gross, + "the approve covers the full gross sell amount" + ); } /// `bind_swap_order` pins BOTH owner and receiver to the wallet. The app builds the order with @@ -605,7 +611,7 @@ mod tests { let order = SwapOrder { chain_id: 11155111, owner: Address::repeat_byte(0xEE), // a placeholder owner the daemon rebinds to `wallet` - receiver: wallet, // app already binds receiver (else receiver_not_wallet) + receiver: wallet, // app already binds receiver (else receiver_not_wallet) sell_token: Address::repeat_byte(0x55), buy_token: Address::repeat_byte(0x66), sell_amount: U256::from(1_000u64), diff --git a/crates/deckard-app/src/swap.rs b/crates/deckard-app/src/swap.rs new file mode 100644 index 0000000..8e143ee --- /dev/null +++ b/crates/deckard-app/src/swap.rs @@ -0,0 +1,562 @@ +//! swap — the CoW Protocol swap path's pure helpers + the off-thread orchestrator that turns a +//! compose-screen snapshot into an ACCEPTED + OPEN order on the CoW orderbook (#25). All +//! snapshot-based: NOT a single function takes `&Shell` (codex must-do #5 — `Shell` isn't `Send` +//! across a spawn). The signing/approval helpers it calls live in [`crate::signer`] (key-less +//! wire-only); this module owns the *sequence* (propose-order → allowance → exact-gross approve → +//! resolve+sign → put-app-data → submit) and the quote↔order mapping. +//! +//! The orchestrator [`confirm_swap_blocking`] is fully synchronous: the orderbook HTTP goes through +//! deckard-core's `CowOrderbook::*_blocking` wrappers (which own a tokio runtime — the GPUI app +//! itself never touches tokio), the allowance read blocks on the `EthProvider` worker, and the +//! signer calls are already `*_blocking`. The caller (the shell) MUST run it inside +//! `cx.background_spawn` so it blocks the spawned background task, never the UI thread. + +use alloy_primitives::{Address, U256}; +use deckard_contract::{Decision, ExecuteResult, SwapOrder}; +use deckard_core::{ + cow_api_base, swap_order_from_quote, tokens_for, CowError, CowOrderbook, EthProvider, + OrderCreation, QuoteRequest, QuoteResponse, APP_DATA_DOC, DEFAULT_SLIPPAGE_BPS, + GPV2_VAULT_RELAYER, +}; +use deckard_signerd::{ControlChannel, SignerClient}; + +use crate::signer; + +/// How long a quote stays valid, in seconds (30 min). Sits comfortably inside the daemon's 24h +/// `valid_to` horizon (the `valid_to_too_far` policy gate) AND gives the user room to read the +/// review card before the order's `validTo` lapses. `confirm_swap_blocking` re-quotes at confirm time +/// regardless (codex must-do #4), so this is the budget for the WHOLE compose→confirm window. +pub const QUOTE_VALID_FOR: u32 = 1800; + +/// Build the orderbook quote request for a sell order: `sell_wei` of `sell` into `buy`, quoted for +/// the caller's `wallet`, valid for [`QUOTE_VALID_FOR`]. A thin, intention-revealing wrapper over +/// [`QuoteRequest::sell`] so the compose screen and the confirm-time re-quote build the SAME shape. +pub fn quote_request(sell: Address, buy: Address, wallet: Address, sell_wei: U256) -> QuoteRequest { + QuoteRequest::sell(sell, buy, wallet, sell_wei, QUOTE_VALID_FOR) +} + +/// Map a fetched quote into a signable [`SwapOrder`], binding BOTH owner and receiver to the +/// wallet (the receiver is always your own wallet in v1 — you never swap funds to a third party) +/// and applying the default 0.5% slippage floor. Delegates to [`swap_order_from_quote`], which +/// carries the GROSS sell amount (`quote.sellAmount + feeAmount`) and a `feeAmount` of 0 (CoW's +/// surplus-fee model). The daemon rebinds owner = wallet before hashing, so deriving the request +/// id requires re-binding via [`signer::bind_swap_order`] first. +pub fn order_from_quote(quote: &QuoteResponse, chain_id: u64, wallet: Address) -> SwapOrder { + swap_order_from_quote(quote, chain_id, wallet, wallet, DEFAULT_SLIPPAGE_BPS) +} + +/// The exact-gross sell amount the vault relayer must be allowed to pull — `order.sell_amount`, +/// which is the GROSS `quote.sellAmount + feeAmount` (already computed by core). This IS the exact +/// approve amount (codex must-do #1): the on-chain allowance must cover the full amount the relayer +/// moves, never the after-fee figure. +pub fn gross_sell_amount(order: &SwapOrder) -> U256 { + order.sell_amount +} + +/// True when the wallet must approve the vault relayer before this order can settle: the current +/// `allowance` is short of the order's GROSS sell amount. An exact-equal allowance is sufficient +/// (`allowance == gross → false`), so the exact-gross approve is never over-issued. +pub fn needs_approval(allowance: U256, gross: U256) -> bool { + allowance < gross +} + +/// The minimum the wallet receives if the order fills — `order.buy_amount_min`, the quoted +/// `buyAmount` with the slippage floor already applied. A worse price never settles; this is the +/// floor the review card shows and the order is signed against. (Kept + unit-tested as the symmetric +/// pair to `gross_sell_amount`; the live review derives the same value from the quote + slippage.) +#[allow(dead_code)] +pub fn min_receive(order: &SwapOrder) -> U256 { + order.buy_amount_min +} + +/// The ERC-20 decimals for a token on `chain_id`, from the curated [`tokens_for`] list. Defaults to +/// 18 when the address isn't listed, so a swap summary never mis-scales an amount (a wrong-decimals +/// display is worse than a generic 18-place one). NOTE: the Sepolia test-USDC is an 18-decimals +/// mock, NOT mainnet's 6 — `tokens_for` already pins that. +pub fn token_decimals(chain_id: u64, token: Address) -> u8 { + tokens_for(chain_id) + .iter() + .find(|t| t.address == token) + .map(|t| t.decimals) + .unwrap_or(18) +} + +/// The ticker for a token on `chain_id`, from the curated [`tokens_for`] list. Empty string when +/// the address isn't listed (the summary then shows the amount alone rather than a wrong symbol). +pub fn token_symbol(chain_id: u64, token: Address) -> &'static str { + tokens_for(chain_id) + .iter() + .find(|t| t.address == token) + .map(|t| t.symbol) + .unwrap_or("") +} + +/// The compose-screen snapshot the orchestrator confirms against — every value captured at confirm +/// time, NOT a borrow of `Shell` (which isn't `Send` across a spawn). The shell builds this from +/// its swap fields just before calling [`confirm_swap_blocking`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SwapInputs { + pub chain_id: u64, + pub wallet: Address, + pub sell_token: Address, + pub buy_token: Address, + /// Gross sell amount in the sell token's atoms (wei for an 18-decimals token). + pub sell_wei: U256, +} + +/// The terminal outcome of a confirm: a submitted uid (the order is now on the orderbook), or a +/// human-readable denial. Every error path resolves to one of these or an `Err` — the shell renders +/// `Denied { reason }` inline (never "swap failed"). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SwapConfirmOutcome { + /// The order was accepted by the orderbook; `uid` is its 0x-hex order uid. + Submitted { uid: String }, + /// A daemon deny, an orderbook rejection, or a re-quote failure — already turned into a calm, + /// swap-specific line. + Denied { reason: String }, +} + +/// Confirm a reviewed swap, off-thread: re-quote, propose the order, approve the vault relayer for +/// the exact gross IF the allowance is short, resolve+sign over the PRIVATE control channel, then +/// submit the signed order to the orderbook. Returns the order uid on success. +/// +/// EXACT sequence (codex must-dos #1, #2, #4) — the ordering is load-bearing: +/// 1. **Re-quote** at confirm time: `quote.validTo` and the daemon's approval TTL are independent +/// races, so a quote fetched on the compose screen may have lapsed. A signed order built off a +/// stale quote is rejected `EXPIRED`/`InvalidSignature`; re-quoting here avoids that. +/// 2. Map the fresh quote → a `SwapOrder` (owner = receiver = wallet), bind it the way the daemon +/// will, and derive the request id. +/// 3. **`propose_order` FIRST** — it must answer `NeedsApproval` (swaps never auto-allow in v1); a +/// `Deny` short-circuits to `Denied`. Proposing the order before the approve is mandatory: the +/// daemon admits the shaped approve ONLY when a matching pending order already exists +/// (`approve_no_matching_order` otherwise). +/// 4. Read the relayer **allowance**; if short of the gross, build the **exact-gross** approve, +/// propose it (now admitted, because step 3 left the order pending), then resolve+execute it +/// over the control/public split and wait for the broadcast. +/// 5. **Resolve+sign** the order over the control channel (the completed hold IS the approval) → +/// the 65-byte EIP-712 signature. +/// 6. Register the app-data doc, then **submit** the signed order → the uid. +/// +/// Typed orderbook errors (`EXPIRED` / `NoLiquidity` / `InsufficientBalance` / `InvalidSignature`) +/// and daemon denies surface as distinct `Denied { reason }` copy, never a bare "swap failed". A +/// session-ended deny (`locked`/`revoked`) is returned verbatim so the shell caller can detect it +/// via `errors::is_session_ended` and bounce to the unlock gate. +/// +/// Mixes async (the orderbook + the allowance read) with the daemon's `*_blocking` signer calls; +/// run it inside `cx.background_spawn` so the blocking calls block the spawned task, not the UI. +pub fn confirm_swap_blocking( + ob: &CowOrderbook, + eth: &EthProvider, + client: &SignerClient, + control: &ControlChannel, + base: &'static str, + inputs: SwapInputs, +) -> anyhow::Result { + let SwapInputs { + chain_id, + wallet, + sell_token, + buy_token, + sell_wei, + } = inputs; + + // (1) Re-quote at confirm time — the compose-screen quote may have lapsed. + let quote = match ob.quote_blocking( + base, + "e_request(sell_token, buy_token, wallet, sell_wei), + ) { + Ok(q) => q, + Err(e) => return Ok(deny_from_cow(&e, "couldn't re-price the swap")), + }; + + // (2) Map → order, bind it the way the daemon will, derive the matching request id. + let order = order_from_quote("e, chain_id, wallet); + let bound = signer::bind_swap_order(&order, wallet); + let id = SignerClient::request_id_for_swap_order(&bound); + let gross = gross_sell_amount(&order); + + // (3) Propose the order FIRST. A valid order is always NeedsApproval; a Deny is terminal. The + // pending order is also what admits the exact-gross approve in step 4. + match client.propose_order_blocking(&order)? { + Decision::NeedsApproval { .. } => {} + Decision::Allow => { + // v1 swaps never auto-allow; treat an unexpected Allow as a refusal rather than signing + // an order the daemon didn't gate behind the hold. + return Ok(SwapConfirmOutcome::Denied { + reason: "the signer didn't require approval for this swap — review again".into(), + }); + } + Decision::Deny { reason } => return Ok(SwapConfirmOutcome::Denied { reason }), + } + + // (4) Allowance check. The vault relayer must be allowed to pull the GROSS sell amount; if it + // can't, issue the exact-gross approve (admitted only because the order is now pending). + let allowance = eth + .allowance(wallet, GPV2_VAULT_RELAYER, sell_token) + .recv() + .map_err(|_| anyhow::anyhow!("network worker stopped"))??; + if needs_approval(allowance, gross) { + let approve = signer::build_exact_approve_intent(chain_id, sell_token, gross); + let approve_id = SignerClient::request_id_for_intent(&approve); + match client.propose_blocking(&approve)? { + // The approve is shaped to be NeedsApproval (the completed hold authorizes it). An + // Allow is fine too — both reach `approve_and_execute_blocking` below; only a Deny + // short-circuits. + Decision::NeedsApproval { .. } | Decision::Allow => {} + Decision::Deny { reason } => return Ok(SwapConfirmOutcome::Denied { reason }), + } + match signer::approve_and_execute_blocking(client, control, approve_id, true)? { + ExecuteResult::Broadcast { .. } => {} + ExecuteResult::Denied { reason } => return Ok(SwapConfirmOutcome::Denied { reason }), + } + // The approve tx must be ON-CHAIN before we submit the order, or the orderbook rejects it + // with InsufficientAllowance. Re-read the relayer allowance: on a local fork (auto-mine) the + // approve is already mined here, so this passes and the FIRST swap on a fresh wallet works. + // On a slow network it may not be mined yet — surface an honest "hold again" line instead of + // a confusing orderbook rejection (re-quote/re-hold once it confirms). + let confirmed = eth + .allowance(wallet, GPV2_VAULT_RELAYER, sell_token) + .recv() + .map_err(|_| anyhow::anyhow!("network worker stopped"))??; + if needs_approval(confirmed, gross) { + return Ok(SwapConfirmOutcome::Denied { + reason: + "approving the sell token on-chain — once that confirms, hold to swap again" + .into(), + }); + } + } + + // (5) Resolve over the control channel (the hold IS the approval), then sign the order. + let signature = match signer::sign_and_resolve_blocking(client, control, id) { + Ok(sig) => sig, + Err(e) => return Ok(SwapConfirmOutcome::Denied { reason: short(&e) }), + }; + + // (6) Register the app-data doc, then submit the signed order → its uid. + if let Err(e) = ob.put_app_data_blocking(base, APP_DATA_DOC) { + return Ok(deny_from_cow(&e, "couldn't register the order's app-data")); + } + let creation = OrderCreation::from_signed_order(&bound, signature, quote.id); + match ob.submit_blocking(base, &creation) { + Ok(uid) => Ok(SwapConfirmOutcome::Submitted { uid }), + Err(e) => Ok(deny_from_cow(&e, "the orderbook rejected the order")), + } +} + +/// The orderbook REST base for a chain, or `None` for an unsupported chain. Re-exported convenience +/// so the shell builds the `&'static str` base it passes to [`confirm_swap_blocking`] from one place. +pub fn orderbook_base(chain_id: u64) -> Option<&'static str> { + cow_api_base(chain_id) +} + +/// Turn a compose-time quote failure into a calm, swap-specific line for the compose screen — the +/// same honest CoW `errorType` mapping `confirm_swap_blocking` uses, never a generic "couldn't quote". +/// (Distinct from the confirm path: this is indicative pricing, so "no route" / "try again" reads +/// right here too.) +pub fn humanize_quote_error(e: &anyhow::Error) -> String { + match deny_from_cow(e, "couldn't price the swap") { + SwapConfirmOutcome::Denied { reason } => reason, + // `deny_from_cow` only ever returns `Denied`, but be total rather than panic. + SwapConfirmOutcome::Submitted { .. } => "couldn't price the swap".into(), + } +} + +/// Turn an `anyhow`-wrapped orderbook error into a distinct, swap-specific `Denied` line. Downcasts +/// to the typed [`CowError`] so the well-known `errorType`s read honestly (codex must-do #4); an +/// un-typed transport/decode error falls back to `context` plus the trimmed message. +fn deny_from_cow(e: &anyhow::Error, context: &str) -> SwapConfirmOutcome { + let reason = match e.downcast_ref::() { + Some(CowError::Api { error_type, .. }) => humanize_cow_api(error_type), + Some(CowError::Http { status, .. }) => { + format!("{context} — the orderbook returned HTTP {status}") + } + Some(CowError::Decode(_)) => { + format!("{context} — the orderbook sent an unexpected response") + } + Some(CowError::Transport(_)) => { + format!("{context} — check your network and try again") + } + None => format!("{context} — {}", short(e)), + }; + SwapConfirmOutcome::Denied { reason } +} + +/// Map a CoW orderbook `errorType` to a calm, swap-specific line. The well-known rejection types +/// each get distinct copy (never a generic "swap failed"); an unrecognised type falls through with +/// its raw tag so a new orderbook error isn't silently swallowed. +fn humanize_cow_api(error_type: &str) -> String { + match error_type { + // The quote/order lapsed between pricing and submit — the user can simply try again. + "OrderExpired" | "Expired" | "EXPIRED" => { + "the price quote expired before the order was placed — try the swap again".into() + } + // No solver route at any price for this pair/size. + "NoLiquidity" => { + "there's no route to swap these tokens right now — try a different pair or amount" + .into() + } + // The wallet doesn't hold enough of the sell token (or hasn't approved the relayer). + "InsufficientBalance" => { + "your wallet doesn't have enough of the sell token for this swap".into() + } + // The EIP-712 signature didn't validate against the submitted order (usually a stale quote). + "InvalidSignature" => { + "the order signature didn't validate — re-quote and try the swap again".into() + } + // Allowance shortfall the orderbook caught (we approve the exact gross, so this is rare). + "InsufficientAllowance" => { + "the vault relayer isn't approved to move enough of the sell token — try again".into() + } + // A duplicate of an already-placed order. + "DuplicatedOrder" => "this exact order is already on the orderbook".into(), + other => format!("the orderbook rejected the order ({other})"), + } +} + +/// One short line from an error (first line, trimmed, capped). Local copy mirroring +/// `errors::short_err` so the orchestrator doesn't depend on the shell's error module. +fn short(e: &anyhow::Error) -> String { + let line = e.to_string(); + let line = line.lines().next().unwrap_or("").trim(); + line.chars().take(140).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use deckard_core::{apply_slippage, QuoteOrderParameters}; + + /// A curated Sepolia token address by symbol, sourced from `tokens_for(11155111)` so the tests + /// stay in lock-step with the real list (and never depend on the `address!` macro / a hard-coded + /// checksum that could drift from `tokens.rs`). + fn sepolia_token(symbol: &str) -> Address { + tokens_for(11155111) + .iter() + .find(|t| t.symbol == symbol) + .map(|t| t.address) + .unwrap_or_else(|| panic!("Sepolia token {symbol} missing from tokens_for(11155111)")) + } + + /// A throwaway wallet address for the binding tests. + fn wallet_addr() -> Address { + Address::repeat_byte(0x11) + } + + /// The live-Sepolia fee vector reused across the swap tests: a 0.05 WETH gross broken into the + /// orderbook's after-fee + fee split. `after_fee + fee == 0.05e18` (the requested + /// `sellAmountBeforeFee`). Mirrors the vector in `signer.rs` + `cow_client.rs`. + fn sepolia_quote() -> QuoteResponse { + let after_fee = U256::from(37_989_365_556_267_132u64); + let fee = U256::from(12_010_634_443_732_868u64); + let buy = U256::from(1_953_742_300_219_817_002u64); + QuoteResponse { + quote: QuoteOrderParameters { + sell_token: sepolia_token("WETH"), + buy_token: sepolia_token("COW"), + receiver: None, + sell_amount: after_fee, + buy_amount: buy, + valid_to: 1_781_261_340, + fee_amount: fee, + }, + from: None, + expiration: None, + id: Some(1_506_978), + verified: Some(true), + } + } + + /// `needs_approval` boundary: an exact-equal allowance is sufficient (the exact-gross approve is + /// never over-issued), short → true, over → false. + #[test] + fn needs_approval_treats_exact_allowance_as_sufficient() { + let gross = U256::from(50_000_000_000_000_000u64); + // allowance == gross → no approve needed. + assert!(!needs_approval(gross, gross)); + // allowance < gross → approve needed. + assert!(needs_approval(gross - U256::from(1u64), gross)); + assert!(needs_approval(U256::ZERO, gross)); + // allowance > gross → no approve needed. + assert!(!needs_approval(gross + U256::from(1u64), gross)); + // gross == 0 (degenerate) → never needs approval. + assert!(!needs_approval(U256::ZERO, U256::ZERO)); + } + + /// `order_from_quote` pins owner == receiver == wallet and carries the GROSS sell amount + /// (`quote.sellAmount + feeAmount`, == the requested `sellAmountBeforeFee`), with the slippage + /// floor applied to the buy side. + #[test] + fn order_from_quote_binds_wallet_and_carries_gross() { + let wallet = wallet_addr(); + let quote = sepolia_quote(); + let order = order_from_quote("e, 11155111, wallet); + + assert_eq!(order.chain_id, 11155111); + assert_eq!(order.owner, wallet, "owner is bound to the wallet"); + assert_eq!(order.receiver, wallet, "receiver is always your own wallet"); + // GROSS = after-fee 37989365556267132 + fee 12010634443732868 == 0.05e18. + let gross = U256::from(50_000_000_000_000_000u64); + assert_eq!(order.sell_amount, gross, "the order sells the GROSS amount"); + assert_eq!(gross_sell_amount(&order), gross); + // The buy floor is the quoted buyAmount minus the default 0.5% slippage. + assert_eq!( + order.buy_amount_min, + apply_slippage(quote.quote.buy_amount, DEFAULT_SLIPPAGE_BPS) + ); + assert_eq!(min_receive(&order), order.buy_amount_min); + } + + /// `quote_request` requests the gross `sellAmountBeforeFee` as a decimal string and pins + /// `validFor == QUOTE_VALID_FOR` (1800s). Mirrors `cow_client`'s + /// `quote_request_serializes_decimal_amount`. + #[test] + fn quote_request_pins_valid_for_and_serializes_decimal() { + assert_eq!(QUOTE_VALID_FOR, 1800); + let req = quote_request( + sepolia_token("WETH"), + sepolia_token("COW"), + wallet_addr(), + U256::from(50_000_000_000_000_000u64), + ); + assert_eq!(req.valid_for, QUOTE_VALID_FOR); + let json = serde_json::to_string(&req).expect("serialize quote request"); + assert!( + json.contains("\"sellAmountBeforeFee\":\"50000000000000000\""), + "gross amount serializes as a decimal string: {json}" + ); + assert!(json.contains("\"validFor\":1800"), "got: {json}"); + assert!(json.contains("\"kind\":\"sell\"")); + } + + /// `token_decimals`/`token_symbol` over the Sepolia (11155111) curated list: the test-USDC is + /// 18 decimals (NOT mainnet's 6), GNO + WETH are 18, and an unknown address defaults to 18 / "" + /// rather than panicking. + #[test] + fn token_lookups_use_sepolia_curated_list() { + let usdc = sepolia_token("USDC"); + let gno = sepolia_token("GNO"); + let weth = sepolia_token("WETH"); + // Sepolia test-USDC is an 18-decimals mock — using 6 would misprice by 10^12. + assert_eq!(token_decimals(11155111, usdc), 18); + assert_eq!(token_symbol(11155111, usdc), "USDC"); + assert_eq!(token_decimals(11155111, gno), 18); + assert_eq!(token_symbol(11155111, gno), "GNO"); + assert_eq!(token_decimals(11155111, weth), 18); + assert_eq!(token_symbol(11155111, weth), "WETH"); + // An unlisted address: safe defaults, never a panic. + let unknown = Address::repeat_byte(0xAB); + assert_eq!(token_decimals(11155111, unknown), 18); + assert_eq!(token_symbol(11155111, unknown), ""); + // An unsupported chain has no list at all → defaults too. + assert_eq!(token_decimals(31337, weth), 18); + assert_eq!(token_symbol(31337, weth), ""); + } + + /// `gross_sell_amount` is the order's `sell_amount` and `min_receive` is its `buy_amount_min` — + /// the exact-approve figure and the displayed floor agree with the signed order byte-for-byte. + #[test] + fn gross_and_min_receive_track_the_order() { + let order = SwapOrder { + chain_id: 11155111, + owner: Address::repeat_byte(0x11), + sell_token: Address::repeat_byte(0x55), + buy_token: Address::repeat_byte(0x66), + sell_amount: U256::from(50_000_000_000_000_000u64), + buy_amount_min: U256::from(1_944_000_000_000_000_000u64), + receiver: Address::repeat_byte(0x11), + valid_to: 1_781_261_340, + app_data: deckard_core::APP_DATA_HASH, + }; + assert_eq!(gross_sell_amount(&order), order.sell_amount); + assert_eq!(min_receive(&order), order.buy_amount_min); + } + + /// `orderbook_base` resolves the supported chains and refuses the rest (so the shell can gate + /// the swap on a real orderbook base). + #[test] + fn orderbook_base_known_chains() { + assert_eq!(orderbook_base(1), Some("https://api.cow.fi/mainnet")); + assert_eq!(orderbook_base(11155111), Some("https://api.cow.fi/sepolia")); + assert_eq!(orderbook_base(31337), None); + } + + /// `deny_from_cow` maps each well-known orderbook `errorType` to DISTINCT copy and never emits a + /// bare "swap failed". The typed `CowError` is recovered through the `anyhow` wrapper. + #[test] + fn cow_errors_map_to_distinct_inline_copy() { + let expired = anyhow::Error::new(CowError::Api { + error_type: "OrderExpired".into(), + description: "expired".into(), + }); + let no_liq = anyhow::Error::new(CowError::Api { + error_type: "NoLiquidity".into(), + description: "no route".into(), + }); + let bad_sig = anyhow::Error::new(CowError::Api { + error_type: "InvalidSignature".into(), + description: "bad sig".into(), + }); + let low_bal = anyhow::Error::new(CowError::Api { + error_type: "InsufficientBalance".into(), + description: "broke".into(), + }); + + let lines: Vec = [&expired, &no_liq, &bad_sig, &low_bal] + .iter() + .map(|e| match deny_from_cow(e, "ctx") { + SwapConfirmOutcome::Denied { reason } => reason, + other => panic!("expected Denied, got {other:?}"), + }) + .collect(); + + // Each line is distinct and honest (no generic "swap failed"). + for line in &lines { + assert!(!line.is_empty()); + assert!( + !line.to_lowercase().contains("swap failed"), + "must not be a generic failure: {line}" + ); + } + assert!(lines[0].contains("expired"), "expired copy: {}", lines[0]); + assert!( + lines[1].contains("route"), + "no-liquidity copy: {}", + lines[1] + ); + assert!( + lines[2].contains("signature"), + "invalid-signature copy: {}", + lines[2] + ); + assert!( + lines[3].contains("enough"), + "insufficient-balance copy: {}", + lines[3] + ); + + // An unrecognised errorType falls through carrying its raw tag (not swallowed). + let novel = anyhow::Error::new(CowError::Api { + error_type: "BrandNewRejection".into(), + description: "x".into(), + }); + match deny_from_cow(&novel, "ctx") { + SwapConfirmOutcome::Denied { reason } => { + assert!(reason.contains("BrandNewRejection"), "got: {reason}") + } + other => panic!("expected Denied, got {other:?}"), + } + + // A transport error falls back to the context line, never a panic. + let transport = anyhow::Error::new(CowError::Transport("dns".into())); + match deny_from_cow(&transport, "couldn't re-price the swap") { + SwapConfirmOutcome::Denied { reason } => { + assert!( + reason.contains("couldn't re-price the swap"), + "got: {reason}" + ) + } + other => panic!("expected Denied, got {other:?}"), + } + } +} diff --git a/crates/deckard-app/src/swap_view.rs b/crates/deckard-app/src/swap_view.rs new file mode 100644 index 0000000..985590c --- /dev/null +++ b/crates/deckard-app/src/swap_view.rs @@ -0,0 +1,696 @@ +//! Swap — the CoW Protocol swap flow (#25)'s bespoke render + its [`CommitView`] descriptor. +//! +//! Unlike Send/Shield (which share the generic `render_commit` driver), Swap's compose screen has +//! two **token pickers** and a live **quote summary** the generic key/value money rows can't +//! express, and its review card shows buy / min-receive rows in *token* units (not ETH). So the +//! compose / quote-summary / review / done arms are hand-written here in [`Shell::render_swap`], +//! while the parts that ARE identical to every commit surface — the centered card frame +//! ([`Shell::commit_shell`]), the neutral glyph + H1 + subtitle ([`Shell::commit_heading`]), and +//! the amber hold-to-confirm sweep ([`Shell::hold_to_confirm`]) — are reused verbatim. +//! +//! The clear-signing contract is unchanged: plain language, exact mono figures, the honest "this +//! order is public" / "you receive at least the minimum" lines, and confirm is a **hold** (the +//! amber sweep), never a tap. The heading glyph is the neutral low-chroma `shield` tone — a swap +//! sits *off* the cyan/agent + amber/human actor axis; the human signal lives on the hold. +//! +//! The token swatch is a small rounded square in the cool `identity_square` neutral, never gold or +//! amber (DESIGN §Color: identity colors avoid the warm band so they never read as actor signal). +//! +//! [`SWAP_VIEW`] is the descriptor `commit_heading` / `hold_to_confirm` read for this surface's copy +//! and its hold handler routing; the compose/review descriptor text fields just carry swap copy so +//! the shared widgets have something to render. The actual swap state + handlers live on `Shell` +//! (`shell.rs`), and the snapshot-based orchestrator lives in `swap.rs`. + +use gpui::{ + div, prelude::FluentBuilder, px, ClipboardItem, Context, FontWeight, Hsla, IntoElement, + ParentElement, SharedString, Styled, +}; +use gpui_component::{ + button::{Button, ButtonVariants}, + h_flex, + input::Input, + v_flex, ActiveTheme, Disableable, Icon, IconName, +}; + +use deckard_core::{tokens_for, Address, U256}; + +use crate::commit_view::{CommitView, HonestyLine}; +use crate::money::money; +use crate::shell::{Shell, Surface}; +use crate::theme; + +/// The Swap surface descriptor. Unlike Send/Shield it does NOT feed `render_commit` (Swap's +/// compose + review are bespoke — see [`Shell::render_swap`]); it carries the copy, ids, glyph tone, +/// and the hold handler routing that the SHARED widgets ([`Shell::commit_heading`], +/// [`Shell::hold_to_confirm`]) read for this surface. The compose/review/done text fields hold the +/// swap copy so the shared heading widget renders the right strings; the `extra_rows` / +/// `compose_hint*` slots are unused by the bespoke render and left empty. +pub static SWAP_VIEW: CommitView = CommitView { + // The swap flow's live state + the neutral "shield / private" glyph tone (a swap is neither an + // agent nor a human signal — it sits off the actor axis like Shield). + flow: swap_flow, + glyph_tone: theme::shield, + + // --- compose (read by `commit_heading` on the compose arm) --- + compose_title: "Swap", + compose_subtitle: + "Trade one token for another via CoW Protocol. Your wallet receives the bought token; the order is public on the orderbook.", + // Unused by the bespoke compose (token pickers replace the single recipient input), but the + // descriptor field is non-optional; carry a sensible label rather than an empty string. + recipient_label: "Receiver", + review_button_id: "swap-review", + review_label: "Review order", + cancel_button_id: "swap-cancel", + // The bespoke compose draws its own hints inline; no generic hint hook. + compose_hint: None, + compose_hint_dynamic: None, + + // --- review (read by `commit_heading` on the review arm) --- + review_title: "Review swap", + review_subtitle: "Confirm what you sell, the minimum you receive, and where it goes. Hold to swap.", + // The bespoke review card builds its own token-denominated rows; the generic ETH money rows + // don't apply. + extra_rows: &[], + honesty: &[ + HonestyLine { + text: "This order is public on the CoW orderbook.", + emphasized: true, + }, + HonestyLine { + text: "You receive at least the minimum shown — a worse price never settles.", + emphasized: false, + }, + ], + hold_id: "swap-hold", + hold_fill_id: "swap-fill", + hold_label_idle: "Hold to swap", + hold_label_holding: "Keep holding…", + hold_label_busy: "Swapping…", + edit_button_id: "swap-edit", + + // --- done (read by `commit_heading` is N/A; the bespoke done draws its own copy) --- + done_title: "Order submitted", + done_body: + "Your order is open on the CoW orderbook. It settles when a solver fills it at or above your minimum.", + copy_button_id: "swap-copy-uid", + done_button_id: "swap-done", + + // --- handlers (route to the surface's existing `impl Shell` swap methods) --- + on_review: review_swap, + on_edit: open_swap, + on_cancel: open_home, + on_done: open_home, + on_hold_start: swap_hold_start, + on_hold_cancel: swap_hold_cancel, +}; + +/// Re-acquire the swap flow's state from the shell (the descriptor's `flow` selector). +fn swap_flow(shell: &Shell) -> &crate::commit_flow::CommitFlow { + &shell.swap +} + +// Thin free-function adapters so the descriptor's `fn(&mut Shell, &mut Context)` slots can +// name the surface's handlers (a `&'static` descriptor can't hold a closure, and the methods take +// `&mut self`). Each is a one-line forward to the existing handler in `shell.rs`. +fn review_swap(shell: &mut Shell, cx: &mut Context) { + shell.review_swap(cx); +} +fn open_swap(shell: &mut Shell, cx: &mut Context) { + shell.open_swap(cx); +} +fn open_home(shell: &mut Shell, cx: &mut Context) { + shell.open(Surface::Home, cx); +} +fn swap_hold_start(shell: &mut Shell, cx: &mut Context) { + shell.swap_hold_start(cx); +} +fn swap_hold_cancel(shell: &mut Shell, cx: &mut Context) { + shell.swap_hold_cancel(cx); +} + +/// Middle-truncate a long address (`0x…`) for a tight row. A local copy matching the per-view +/// practice in `send_view`/`shield_view`/`commit_view` (the shared one is module-private to +/// `commit_view`; we don't widen its visibility just for this). +fn short_mid(s: &str) -> String { + if s.len() >= 16 { + format!("{}…{}", &s[..10], &s[s.len() - 6..]) + } else { + s.to_string() + } +} + +impl Shell { + /// The Swap surface: a bespoke dispatch over the swap flow's state — done (submitted, has a + /// uid) → review (a proposal is installed) → compose. NOT `render_commit`: compose has token + /// pickers + a quote summary and review shows token-denominated buy/min-receive rows the + /// generic renderer can't express. Reuses `commit_shell` / `commit_heading` / `hold_to_confirm`. + pub fn render_swap(&self, cx: &mut Context) -> impl IntoElement { + if let Some(uid) = self.swap_uid.clone() { + return self.render_swap_done(uid, cx).into_any_element(); + } + if let Some(proposal) = self.swap.proposal.clone() { + return self.render_swap_review(proposal, cx).into_any_element(); + } + self.render_swap_compose(cx).into_any_element() + } + + /// Compose: a sell amount + a sell-token picker + a buy-token picker + Get quote, then the live + /// quote summary card once a quote is in hand. The pickers open a small inline token list from + /// `tokens_for(chain_id)`; `open_swap` seeds the first two distinct tokens so the picker is + /// never empty on first paint. + fn render_swap_compose(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let chain_id = self.chain_id(); + let busy = self.swap.busy; + let quoting = self.swap_quoting; + + let amount_raw = self.swap.amount.read(cx).value().to_string(); + let amount_ok = crate::signer::parse_eth_to_wei(&amount_raw) + .map(|w| w > U256::ZERO) + .unwrap_or(false); + let tokens_ok = match (self.swap_sell_token, self.swap_buy_token) { + (Some(s), Some(b)) => s != b, + _ => false, + }; + let can_quote = amount_ok && tokens_ok && !quoting && !busy; + + // The two token pickers. Each is a labeled row: a swatch + symbol button per token; the + // active one is primary, the rest ghost. Tapping sets the side (and clears any stale quote, + // handled in the shell handler). + let sell_picker = self.render_token_picker( + "You sell", + chain_id, + self.swap_sell_token, + self.swap_buy_token, + true, + cx, + ); + let buy_picker = self.render_token_picker( + "You receive", + chain_id, + self.swap_buy_token, + self.swap_sell_token, + false, + cx, + ); + + self.commit_shell( + &SWAP_VIEW, + v_flex() + .w_full() + .gap_5() + .child(self.commit_heading( + &SWAP_VIEW, + SWAP_VIEW.compose_title, + SWAP_VIEW.compose_subtitle, + cx, + )) + .child( + v_flex() + .w_full() + .gap_2() + .child(field_label("Amount to sell", muted)) + .child(Input::new(&self.swap.amount).w_full()), + ) + .child(sell_picker) + .child(buy_picker) + .children(self.swap.error.as_ref().map(|e| error_line(e, cx))) + .child( + h_flex() + .w_full() + .gap_2() + .child( + Button::new("swap-get-quote") + .primary() + .label(if quoting { "Getting quote…" } else { "Get quote" }) + .disabled(!can_quote) + .on_click(cx.listener(|this, _, _, cx| this.get_swap_quote(cx))), + ) + .child( + Button::new(SWAP_VIEW.cancel_button_id) + .ghost() + .label("Cancel") + .on_click(cx.listener(|this, _, _, cx| this.open(Surface::Home, cx))), + ), + ) + .children( + self.swap_quote + .as_ref() + .map(|q| self.render_quote_summary(q, chain_id, fg, muted, cx)), + ) + .child( + div().text_xs().text_color(muted).child( + "A quote is good for about 30 minutes; we re-check the price the moment you confirm.", + ), + ) + .into_any_element(), + ) + } + + /// A single token-side picker: a label + an inline row of token chips for the chain's curated + /// list. The active token's chip is `primary`, the rest `ghost`; the *other* side's current + /// token is disabled (you can't sell and buy the same token). Each chip carries the cool + /// `identity_square` swatch + the ticker. + fn render_token_picker( + &self, + label: &'static str, + chain_id: u64, + active: Option
, + other: Option
, + is_sell: bool, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let swatch = theme::identity_square(theme.is_dark()); + + let mut row = h_flex().w_full().gap_2().flex_wrap(); + for (i, tok) in tokens_for(chain_id).iter().enumerate() { + let addr = tok.address; + let is_active = active == Some(addr); + let is_other = other == Some(addr); + // Stable, side-scoped id so the two pickers never collide on a shared ticker. + let id = SharedString::from(format!( + "swap-tok-{}-{}", + if is_sell { "sell" } else { "buy" }, + i + )); + let chip = Button::new(id) + .when(is_active, |b| b.primary()) + .when(!is_active, |b| b.ghost()) + .disabled(is_other) + .child( + h_flex() + .gap_1p5() + .items_center() + .child(token_swatch(swatch)) + .child(div().text_sm().child(tok.symbol)), + ) + .on_click(cx.listener(move |this, _, _, cx| { + if is_sell { + this.set_swap_sell_token(addr, cx); + } else { + this.set_swap_buy_token(addr, cx); + } + })); + row = row.child(chip); + } + + v_flex() + .w_full() + .gap_2() + .child(field_label(label, muted)) + .child(row) + } + + /// The live quote summary card (shown once a quote is fetched): the indicative price, the + /// minimum you receive after slippage, and the network fee — all in mono token figures via + /// `swap.rs`'s decimals/symbol lookups. This is indicative only; the binding figures live on the + /// review card, built from the re-quote at confirm time. + fn render_quote_summary( + &self, + quote: &deckard_core::QuoteResponse, + chain_id: u64, + fg: Hsla, + muted: Hsla, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let border = theme.border; + let surface = theme.secondary; + let mono = theme.mono_font_family.clone(); + + let sell_tok = quote.quote.sell_token; + let buy_tok = quote.quote.buy_token; + let sell_dec = crate::swap::token_decimals(chain_id, sell_tok); + let buy_dec = crate::swap::token_decimals(chain_id, buy_tok); + let sell_sym = crate::swap::token_symbol(chain_id, sell_tok); + let buy_sym = crate::swap::token_symbol(chain_id, buy_tok); + + // The gross sell (after-fee + fee), the buy amount, and the post-slippage minimum receive. + let gross_sell = quote + .quote + .sell_amount + .saturating_add(quote.quote.fee_amount); + let buy = quote.quote.buy_amount; + let min_receive = deckard_core::apply_slippage(buy, deckard_core::DEFAULT_SLIPPAGE_BPS); + let fee = quote.quote.fee_amount; + + v_flex() + .w_full() + .p_4() + .gap_1() + .rounded_lg() + .border_1() + .border_color(border) + .bg(surface) + .child(token_money_row( + "You sell", + gross_sell, + sell_dec, + sell_sym, + mono.clone(), + fg, + muted, + )) + .child(token_money_row( + "You receive at least", + min_receive, + buy_dec, + buy_sym, + mono.clone(), + fg, + muted, + )) + .child(token_money_row( + "Network fee", + fee, + sell_dec, + sell_sym, + mono, + muted, + muted, + )) + } + + /// Review: the clear-signing card for the bound order — what you sell (gross), the minimum you + /// receive, the receiver (your own wallet), the max slippage, and the order's expiry — plus the + /// honesty lines and the amber hold-to-confirm. Built from the proposal SNAPSHOT (the bound + /// order's request_id rides the proposal; the display summary rides `proposal.recipient`). + fn render_swap_review( + &self, + proposal: crate::commit_flow::Proposal, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let border = theme.border; + let surface = theme.secondary; + let mono = theme.mono_font_family.clone(); + let chain_id = self.chain_id(); + + // Pull the bound figures off the last quote (the quote that produced this proposal). The + // proposal's `intent` carries the swap's value, but the token-denominated sell/buy figures + // come from the quote snapshot still held on the shell — cleared only on a compose edit, + // which `review_swap` blocks once a proposal is live. + let (sell_row, recv_row, valid_row) = match self.swap_quote.as_ref() { + Some(q) => { + let sell_tok = q.quote.sell_token; + let buy_tok = q.quote.buy_token; + let sell_dec = crate::swap::token_decimals(chain_id, sell_tok); + let buy_dec = crate::swap::token_decimals(chain_id, buy_tok); + let sell_sym = crate::swap::token_symbol(chain_id, sell_tok); + let buy_sym = crate::swap::token_symbol(chain_id, buy_tok); + let gross_sell = q.quote.sell_amount.saturating_add(q.quote.fee_amount); + let min_receive = deckard_core::apply_slippage( + q.quote.buy_amount, + deckard_core::DEFAULT_SLIPPAGE_BPS, + ); + ( + Some(token_money_row( + "You sell", + gross_sell, + sell_dec, + sell_sym, + mono.clone(), + fg, + muted, + )), + Some(token_money_row( + "You receive at least", + min_receive, + buy_dec, + buy_sym, + mono.clone(), + fg, + muted, + )), + Some(short_clock(q.quote.valid_to)), + ) + } + None => (None, None, None), + }; + + // The receiver is always your own wallet (a swap never sends elsewhere). + let receiver = self.wallet_address_string(); + + let mut card = v_flex() + .w_full() + .p_4() + .rounded_lg() + .border_1() + .border_color(border) + .bg(surface); + card = card.children(sell_row); + card = card.children(recv_row); + card = card + .child(kv_text_row( + "Receiver", + short_mid(receiver.trim()), + mono.clone(), + fg, + muted, + )) + .child(kv_text_row( + "Max slippage", + "0.5%".to_string(), + mono.clone(), + muted, + muted, + )); + if let Some(valid) = valid_row { + card = card.child(kv_text_row("Order valid until", valid, mono, muted, muted)); + } + + self.commit_shell( + &SWAP_VIEW, + v_flex() + .w_full() + .gap_4() + .child(self.commit_heading( + &SWAP_VIEW, + SWAP_VIEW.review_title, + SWAP_VIEW.review_subtitle, + cx, + )) + // A faint reminder of the human-readable summary the proposal snapshot carries + // (e.g. "0.05 WETH → at least 92.1 COW"), so the card matches what was reviewed. + .child( + div() + .text_sm() + .text_color(muted) + .child(proposal.recipient.clone()), + ) + .child(card) + .child(self.commit_honesty_swap(cx)) + .children(self.swap.error.as_ref().map(|e| error_line(e, cx))) + .child(self.hold_to_confirm(&SWAP_VIEW, cx)) + .child( + Button::new(SWAP_VIEW.edit_button_id) + .ghost() + .w_full() + .label("Edit") + .on_click(cx.listener(|this, _, _, cx| this.open_swap(cx))), + ) + .into_any_element(), + ) + } + + /// Done: the order is submitted — open on the orderbook. Shows a check, the success copy, the + /// CoW uid in a mono chip with Copy, and Done. The uid is a string (not a tx hash), so this is + /// bespoke rather than `render_commit_done`. + /// + /// TODO(swap-lifecycle): a Track-status / Cancel affordance belongs here once the open-order + /// poll loop + in-app cancel land (the daemon `cancel_order` / `pending_list` paths already + /// exist; wiring them is deferred for this increment). + fn render_swap_done(&self, uid: String, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let border = theme.border; + let surface = theme.secondary; + let success = theme.success; + let mono = theme.mono_font_family.clone(); + let uid_for_copy = uid.clone(); + + self.commit_shell( + &SWAP_VIEW, + v_flex() + .w_full() + .items_center() + .gap_4() + .child( + Icon::new(IconName::CircleCheck) + .text_color(success) + .flex_shrink_0(), + ) + .child( + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child(SWAP_VIEW.done_title), + ) + .child( + div() + .text_sm() + .text_color(muted) + .text_center() + .child(SWAP_VIEW.done_body), + ) + .child( + div() + .w_full() + .px_3() + .py_2() + .rounded_lg() + .border_1() + .border_color(border) + .bg(surface) + .font_family(mono) + .text_xs() + .text_color(muted) + .child(short_mid(&uid)), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new(SWAP_VIEW.copy_button_id) + .ghost() + .label("Copy uid") + .on_click(cx.listener(move |_, _, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + uid_for_copy.clone(), + )); + })), + ) + .child( + Button::new(SWAP_VIEW.done_button_id) + .primary() + .label("Done") + .on_click( + cx.listener(|this, _, _, cx| this.open(Surface::Home, cx)), + ), + ), + ) + .into_any_element(), + ) + } + + /// The swap honesty lines, in the same calm neutral surface as `commit_honesty`. A tiny local + /// copy reading [`SWAP_VIEW`]'s `honesty` slice (the shared `commit_honesty` is keyed by a + /// `&CommitView` too, but lives in `commit_view`; rather than route the bespoke review through + /// it we inline the identical treatment here for the two swap lines). + fn commit_honesty_swap(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let surface = theme.secondary; + + let mut col = v_flex() + .w_full() + .gap_1p5() + .px_3() + .py_2p5() + .rounded_lg() + .bg(surface); + for line in SWAP_VIEW.honesty { + let color = if line.emphasized { fg } else { muted }; + col = col.child(div().text_xs().text_color(color).child(line.text)); + } + col + } +} + +/// A small rounded square token swatch in a cool neutral (DESIGN: identity colors avoid the warm / +/// amber band, never gold). Sized to sit inline with a ticker in a chip. +fn token_swatch(tone: Hsla) -> impl IntoElement { + div() + .size(px(16.0)) + .rounded(px(4.0)) + .bg(tone) + .flex_shrink_0() +} + +/// A label/value money row in token units: label left (muted), the amount + ticker right (mono), +/// dimming the fraction + ticker by color only via [`money`]. The swap analogue of +/// `commit_view::kv_money_row`, but parameterized by token decimals + symbol instead of ETH. +fn token_money_row( + label: &'static str, + raw: U256, + decimals: u8, + symbol: &str, + mono: SharedString, + fg: Hsla, + muted: Hsla, +) -> impl IntoElement { + let unit = if symbol.is_empty() { + None + } else { + Some(symbol) + }; + h_flex() + .w_full() + .justify_between() + .items_center() + .py_1p5() + .child(div().text_sm().text_color(muted).child(label)) + .child( + div() + .text_sm() + // 6 fractional places, matching the ETH money rows; full precision lives on-chain. + .child(money(raw, decimals, 6, unit, false, mono, fg, muted)), + ) +} + +/// A label/value text row (mono value) — for the receiver, slippage, and validity rows that aren't +/// money figures. +fn kv_text_row( + label: &'static str, + value: String, + mono: SharedString, + fg: Hsla, + muted: Hsla, +) -> impl IntoElement { + h_flex() + .w_full() + .justify_between() + .items_center() + .py_1p5() + .child(div().text_sm().text_color(muted).child(label)) + .child( + div() + .font_family(mono) + .text_sm() + .text_color(fg) + .child(value), + ) +} + +/// A tiny field label (matches `commit_view::field_label`). +fn field_label(text: &'static str, muted: Hsla) -> impl IntoElement { + div().text_xs().text_color(muted).child(text) +} + +/// A one-line error, in `danger` (matches `commit_view::error_line`). +fn error_line(msg: &str, cx: &mut Context) -> impl IntoElement { + div() + .text_sm() + .text_color(cx.theme().danger) + .child(format!("⚠ {msg}")) +} + +/// Render a unix-seconds expiry as a short, human clock for the review card (e.g. `14:32 UTC`). +/// A `validTo` is always near-future (≈30 min out), so a time-of-day clock reads clearer than a +/// full timestamp; we keep it UTC to avoid a misleading local-time read of an on-chain field. +fn short_clock(valid_to: u32) -> String { + let secs = valid_to as u64; + let day_secs = secs % 86_400; + let hh = day_secs / 3_600; + let mm = (day_secs % 3_600) / 60; + format!("{hh:02}:{mm:02} UTC") +} diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index a2dc88f..3681fc7 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -13,7 +13,7 @@ use gpui_component::{ h_flex, v_flex, ActiveTheme, Disableable, IconName, }; -use deckard_core::U256; +use deckard_core::{tokens_for, U256}; use crate::money::money; use crate::shell::{Shell, Surface}; @@ -231,9 +231,10 @@ impl Shell { // allocation bar, and the composition lines (Wave 2 T10). .child(self.render_shielded_hero(native_wei, cx)) // Primary actions. Shield (the privacy hero) is the live, primary CTA; Send - // is now live too (native ETH). Both sign from YOUR wallet, so both are - // disabled while viewing a watched read-only account. Swap stays gated to - // the next release and shown disabled rather than inert-but-active. + // and Swap are now live too (native ETH / CoW). All sign from YOUR wallet, so + // all three are disabled while viewing a watched read-only account. Swap also + // needs a chain with a curated token list (mainnet/Sepolia) — a plain anvil + // fork (chain 31337) has none, so it's disabled there. .child( h_flex() .w_full() @@ -258,13 +259,16 @@ impl Shell { .disabled(self.viewing_watch) .on_click(cx.listener(|this, _, _, cx| this.open_send(cx))), ) - .child(Button::new("swap").ghost().label("Swap").disabled(true)), - ) - .child( - div() - .text_xs() - .text_color(muted) - .child("Swap arrives in the next release."), + .child( + Button::new("swap") + .ghost() + .label("Swap") + .disabled( + self.viewing_watch + || tokens_for(self.chain_id()).is_empty(), + ) + .on_click(cx.listener(|this, _, _, cx| this.open_swap(cx))), + ), ) // Holdings, or a state. .child(self.render_holdings(first_sync, has_tokens, holdings, cx)) diff --git a/crates/deckard-core/src/cow_client.rs b/crates/deckard-core/src/cow_client.rs index 5e8fe8e..15118b8 100644 --- a/crates/deckard-core/src/cow_client.rs +++ b/crates/deckard-core/src/cow_client.rs @@ -517,6 +517,37 @@ impl CowOrderbook { ) -> anyhow::Result> { get_account_orders(&self.client, base, owner).await } + + /// Blocking [`Self::quote`] for callers without a tokio reactor (the GPUI app runs on its own + /// executor; reqwest/hickory require a tokio runtime). Bridges through a dedicated current-thread + /// runtime — see [`block_on_orderbook`]. + pub fn quote_blocking(&self, base: &str, req: &QuoteRequest) -> anyhow::Result { + block_on_orderbook(self.quote(base, req)) + } + + /// Blocking [`Self::put_app_data`] — see [`Self::quote_blocking`]. + pub fn put_app_data_blocking(&self, base: &str, doc: &str) -> anyhow::Result<()> { + block_on_orderbook(self.put_app_data(base, doc)) + } + + /// Blocking [`Self::submit`] — see [`Self::quote_blocking`]. + pub fn submit_blocking(&self, base: &str, order: &OrderCreation) -> anyhow::Result { + block_on_orderbook(self.submit(base, order)) + } +} + +/// Drive a CoW orderbook future to completion on a dedicated current-thread tokio runtime. The CoW +/// HTTP client (reqwest/hickory DNS) requires a tokio reactor; callers on a non-tokio executor (the +/// GPUI app, whose worker model keeps the UI off tokio — see `eth.rs`) use the `*_blocking` methods, +/// which bridge through here. Returns an error (never panics) if the runtime can't be built. CoW +/// calls are user-initiated and infrequent, so a fresh current-thread runtime per call is fine. +fn block_on_orderbook>>( + fut: F, +) -> anyhow::Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(fut) } // --------------------------------------------------------------------------- From 14ba6235bd9bcbf286ec1674d37a4c9b3d932893 Mon Sep 17 00:00:00 2001 From: hellno Date: Mon, 15 Jun 2026 23:12:11 +0200 Subject: [PATCH 3/3] fix(swap): render the "Review order" button once a quote is in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live GUI drive caught a gap: the bespoke swap compose showed Get quote + the quote summary but never rendered the descriptor's review button, so a user could quote but not proceed to the order-review/hold-to-confirm. Add the primary "Review order" CTA (gated on swap_quote.is_some(), disabled while busy) below the quote summary, wired to the already-implemented Shell::review_swap. just check green (default + tray). Live-validated on a Sepolia fork: compose + token pickers render centered; a real CoW Sepolia quote returns (1 USDC -> 0.608 COW, no tokio panic — the prior runtime fix holds); Review order -> the clear-signing card (receiver=wallet, 0.5% slippage, valid-until, honesty box) renders correctly. --- crates/deckard-app/src/swap_view.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/deckard-app/src/swap_view.rs b/crates/deckard-app/src/swap_view.rs index 985590c..b6ca5c6 100644 --- a/crates/deckard-app/src/swap_view.rs +++ b/crates/deckard-app/src/swap_view.rs @@ -240,6 +240,16 @@ impl Shell { .as_ref() .map(|q| self.render_quote_summary(q, chain_id, fg, muted, cx)), ) + // Once a quote is in, the primary CTA to proceed to the clear-signing order review + + // hold-to-confirm. (Re-clicking "Get quote" above re-prices.) + .children(self.swap_quote.as_ref().map(|_| { + Button::new(SWAP_VIEW.review_button_id) + .primary() + .w_full() + .label(SWAP_VIEW.review_label) + .disabled(self.swap.busy) + .on_click(cx.listener(|this, _, _, cx| this.review_swap(cx))) + })) .child( div().text_xs().text_color(muted).child( "A quote is good for about 30 minutes; we re-check the price the moment you confirm.",