diff --git a/crates/deckard-app/src/main.rs b/crates/deckard-app/src/main.rs index 60cc3b1..88cce70 100644 --- a/crates/deckard-app/src/main.rs +++ b/crates/deckard-app/src/main.rs @@ -14,6 +14,7 @@ mod palette; mod palette_commands; mod palette_usage; mod receive; +mod send_view; mod settings; mod settings_view; mod shell; diff --git a/crates/deckard-app/src/palette_commands.rs b/crates/deckard-app/src/palette_commands.rs index 78f45bc..d938a2d 100644 --- a/crates/deckard-app/src/palette_commands.rs +++ b/crates/deckard-app/src/palette_commands.rs @@ -38,6 +38,13 @@ pub const COMMANDS: &[Command] = &[ shortcut: None, icon: None, }, + Command { + id: "send", + title: "Send", + aliases: &["transfer", "pay", "withdraw", "send eth"], + shortcut: None, + icon: None, // no send glyph in the bundled subset + }, Command { id: "receive", title: "Receive", @@ -290,13 +297,13 @@ mod tests { } #[test] - fn empty_query_returns_all_nine() { + fn empty_query_returns_all_ten() { 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(), 9); + assert_eq!(COMMANDS.len(), 10); for r in &results { assert!(r.positions.is_empty()); } diff --git a/crates/deckard-app/src/send_view.rs b/crates/deckard-app/src/send_view.rs new file mode 100644 index 0000000..b981dc4 --- /dev/null +++ b/crates/deckard-app/src/send_view.rs @@ -0,0 +1,451 @@ +//! 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). +//! +//! 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. + +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 deckard_core::U256; + +use crate::money::money; +use crate::shell::{SendProposal, Shell, Surface, SHIELD_HOLD}; +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()); + + 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()), + ), + ) + } +} + +/// 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) +} + +/// 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}")) +} diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index 7cb432c..ff14949 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -123,6 +123,9 @@ pub enum Selection { pub enum Surface { Home, Receive, + /// The native-ETH send flow: compose (amount + 0x/ENS recipient) → review card → + /// hold-to-confirm. Mirrors `Shield`; the daemon decides + signs, the app holds no key. + Send, /// The shield trigger flow (T5): compose a deposit → review card → hold-to-confirm. Shield, Settings, @@ -143,6 +146,20 @@ pub struct ShieldProposal { pub needs_resolve: bool, } +/// 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, +} + /// 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. #[derive(Clone, Copy, PartialEq, Eq)] @@ -240,6 +257,28 @@ pub struct Shell { /// Bumped on each hold-start so a stale hold timer can't fire a later confirm. shield_hold_epoch: u64, + // --- 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, + // --- shielded balance (Wave 2: T9 sync + T10 lifecycle) --- /// The read-only Railgun sync actor (None until the view grant is fetched post-unlock, /// and only if the derivation gate passes). Holds the viewing key, never the spending key. @@ -422,6 +461,28 @@ impl Shell { ) .detach(); + // Send flow inputs: amount in ETH + a `0x…`/ENS recipient. Same live-validity + + // Enter-to-review wiring as the shield fields above. + let send_amount = + cx.new(|cx| InputState::new(window, cx).placeholder("Amount in ETH, e.g. 0.05")); + let send_recipient = cx + .new(|cx| InputState::new(window, cx).placeholder("0x… address or name.eth recipient")); + cx.subscribe(&send_amount, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }) + .detach(); + cx.subscribe( + &send_recipient, + |this, _, event: &InputEvent, cx| match event { + InputEvent::Change => cx.notify(), + InputEvent::PressEnter { .. } => this.review_send(cx), + _ => {} + }, + ) + .detach(); + // Submit-on-Enter for each auth field (keyboard-first). cx.subscribe(&create_pass2, |this, _, event: &InputEvent, cx| { if matches!(event, InputEvent::PressEnter { .. }) { @@ -533,6 +594,15 @@ impl Shell { 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, shielded: None, railgun_address: None, recipient_autofilled: false, @@ -623,6 +693,7 @@ impl Shell { self.auth_epoch = self.auth_epoch.wrapping_add(1); self.pending_shield_clear = true; self.reset_shield(); + self.reset_send(); self.auth = AuthStep::Unlock; self.palette_open = false; cx.notify(); @@ -1235,6 +1306,12 @@ impl Shell { self.shield_holding = false; self.shield_hold_epoch = self.shield_hold_epoch.wrapping_add(1); } + // 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); + } self.surface = surface; cx.notify(); } @@ -1483,6 +1560,231 @@ impl Shell { } } + /// Open the native-send flow from the wallet home / palette. Refused while viewing a + /// watched read-only account — a send signs from YOUR wallet, so it has no meaning there + /// (the same guard `open_shield` uses). + pub fn open_send(&mut self, cx: &mut Context) { + if self.viewing_watch { + return; + } + self.reset_send(); + 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); + } + + /// Resolve the recipient, then build + `propose` the send off-thread. A `0x…` recipient is + /// parsed directly (works offline); anything else is treated as an ENS name and + /// forward-resolved over the same path the watch-address field uses + /// (`EthProvider::resolve_name`). On `Allow`/`NeedsApproval`, stash the proposal so the + /// 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 { + return; + } + 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()); + cx.notify(); + return; + } + Err(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()); + 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; + cx.notify(); + let client = self.signer.client(); + let chain_id = self.chain_id; + let eth = self.eth.clone(); + // A literal 0x address skips ENS entirely; anything else is treated as a name. + let parsed = recipient.parse::
().ok(); + let recipient_for_task = recipient.clone(); + let task = cx.background_spawn(async move { + let to = match parsed { + Some(addr) => addr, + None => eth + .resolve_name(recipient_for_task.clone()) + .recv_async() + .await + .map_err(|_| anyhow::anyhow!("network worker stopped"))? + .map_err(|e| anyhow::anyhow!("couldn't resolve name — {}", short_err(e)))?, + }; + 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)) + }); + 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(); + }) + .ok(); + }) + .detach(); + } + + /// Sign + broadcast the reviewed send 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 transfer is broadcast and the public + /// balance is re-fetched. Mirrors `confirm_shield`. + pub fn confirm_send(&mut self, cx: &mut Context) { + let Some(SendProposal { + request_id, + needs_resolve, + .. + }) = self.send_proposal.clone() + else { + return; + }; + if self.send_busy { + return; + } + self.send_busy = true; + self.send_error = None; + cx.notify(); + let client = self.signer.client(); + let task = cx.background_spawn(async move { + signer::approve_and_execute_blocking(&client, request_id, needs_resolve) + }); + cx.spawn(async move |this, cx| { + let res = task.await; + this.update(cx, |this, cx| { + 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; + match res { + Ok(ExecuteResult::Broadcast { 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); + } + Ok(ExecuteResult::Denied { 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!("Send denied: {}", humanize_deny(&reason))); + } + } + Err(e) => this.send_error = Some(short_err(e)), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Begin a confirm hold: start the amber fill-sweep and a timer that fires `confirm_send` + /// 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() { + 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 + // 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; + this.confirm_send(cx); + } + }) + .ok(); + }) + .detach(); + } + + /// 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); + 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()); @@ -1599,6 +1901,7 @@ impl Shell { self.select(Selection::Wallet, cx); self.open(Surface::Home, cx); } + "send" => self.open_send(cx), "receive" => self.open(Surface::Receive, cx), "shield" => self.open_shield(cx), "settings" => self.open(Surface::Settings, cx), @@ -1677,6 +1980,12 @@ impl Shell { .update(cx, |i, cx| i.set_value("", window, cx)); 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 + .update(cx, |i, cx| i.set_value("", window, cx)); + self.send_recipient + .update(cx, |i, cx| i.set_value("", window, cx)); } if self.recipient_autofilled { return; @@ -1746,6 +2055,7 @@ 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(), (Selection::Wallet, Surface::Home) => div() .id("scroll-wallet") diff --git a/crates/deckard-app/src/shell_chrome.rs b/crates/deckard-app/src/shell_chrome.rs index 470634e..ea9ffc3 100644 --- a/crates/deckard-app/src/shell_chrome.rs +++ b/crates/deckard-app/src/shell_chrome.rs @@ -85,6 +85,7 @@ impl Shell { match self.surface { Surface::Settings => "Settings", Surface::Receive => "Receive", + Surface::Send => "Send", Surface::Shield => "Shield", Surface::Home => match self.selection { Selection::Project => "Personal", diff --git a/crates/deckard-app/src/signer.rs b/crates/deckard-app/src/signer.rs index 6231a40..acd8a21 100644 --- a/crates/deckard-app/src/signer.rs +++ b/crates/deckard-app/src/signer.rs @@ -10,8 +10,8 @@ use std::ffi::OsString; use std::path::PathBuf; -use alloy_primitives::{Address, B256, U256}; -use deckard_contract::{Decision, ExecuteResult, Intent, RequestId, UnlockOutcome}; +use alloy_primitives::{Address, Bytes, B256, U256}; +use deckard_contract::{Decision, ExecuteResult, Intent, IntentKind, RequestId, UnlockOutcome}; use deckard_signerd::{DaemonSupervisor, SignerClient}; /// Result of the app's send path (propose, then execute on `Allow`). The path is implemented @@ -145,6 +145,23 @@ pub fn build_shield_intent( deckard_core::build_shield_native_intent(chain_id, recipient, value_wei) } +/// Build a key-less native-ETH **send** intent: a plain transfer of `value_wei` to `to`, on +/// `chain_id`. Native only (`token: None`) with empty calldata — the empty payload IS the +/// native/contract-call discriminator the daemon switches on, and the policy gate requires a +/// `Send` to carry no calldata (`deckard-contract::policy::calldata_ok`). Infallible: the +/// recipient is already a resolved [`Address`] (the caller turns `0x…`/ENS into one), and the +/// amount is pre-parsed wei, so there is nothing left to fail. The daemon decides + signs. +pub fn build_native_send_intent(chain_id: u64, to: Address, value_wei: U256) -> Intent { + Intent { + chain_id, + to, + token: None, + value: value_wei, + calldata: Bytes::new(), + kind: IntentKind::Send, + } +} + /// 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. @@ -347,6 +364,21 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The Send builder produces a *native* transfer: no token, empty calldata (so the + /// daemon broadcasts it as a plain ETH send and the policy gate's `calldata_ok` admits + /// it), `kind == Send`, with `to`/`value` carried verbatim. + #[test] + fn build_native_send_intent_is_a_native_send() { + let to = Address::repeat_byte(0x44); + let intent = build_native_send_intent(31337, to, U256::from(1_234u64)); + assert_eq!(intent.chain_id, 31337); + assert_eq!(intent.to, to); + assert_eq!(intent.token, None); + assert_eq!(intent.value, U256::from(1_234u64)); + assert!(intent.calldata.is_empty()); + assert_eq!(intent.kind, IntentKind::Send); + } + #[test] fn parse_eth_to_wei_handles_decimals_and_rejects_junk() { // Whole + fractional ETH parse to exact wei. diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index 9218e0c..a2dc88f 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -230,9 +230,10 @@ impl Shell { // Balance hero: the merged Total (public + private), a Private/Public // 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 one live, primary - // CTA; Send + Swap are gated to the next release (Chunk 4, testnet-first) - // and shown disabled rather than inert-but-active. + // 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. .child( h_flex() .w_full() @@ -250,14 +251,20 @@ impl Shell { .child(Button::new("receive").ghost().label("Receive").on_click( cx.listener(|this, _, _, cx| this.open(Surface::Receive, cx)), )) - .child(Button::new("send").ghost().label("Send").disabled(true)) + .child( + Button::new("send") + .ghost() + .label("Send") + .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("Send & Swap arrive in the next release."), + .child("Swap arrives in the next release."), ) // Holdings, or a state. .child(self.render_holdings(first_sync, has_tokens, holdings, cx))