From b15edc007d130dd431b604444bdbfcdd1aa6ad6a Mon Sep 17 00:00:00 2001 From: hellno Date: Tue, 9 Jun 2026 11:52:19 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(app):=20DESIGN.md=20foundation=20+=20t?= =?UTF-8?q?wo-pane=20shell=20(Wave=201:=20T1=E2=80=93T3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit theme: locked DESIGN palette + agent/cyan + amber Hsla tokens; drop the 6-accent picker; neutral primary buttons + amber focus ring; font-family wiring (licensed font files deferred to a human drop-in + README) money: mono-for-money helper (dim decimals/ticker by color only) contract: ShieldStatus lifecycle enum (UI-free, serde, glyph hook) signerd: blocking propose/execute/resolve/status client wrappers shell: Route -> Selection/Surface routing + hand-built two-pane chrome (248px sidebar tree / 44px breadcrumb / 25px status strip), wallet/ project/agent home, receive amber-keyline network warning, palette repoint codex-review fixes: neutral caution surface (not a filled tint), weights <=600, token squares (not rounded-full), hairline holdings rows, stale comment removed DoD green: cargo fmt + clippy -D warnings (default + --features tray) + cargo test --workspace; no new dependencies. Static agent squircle landed; breathing pulse + full privacy mask + allocation bar (T4) + shield UI (T5) + the railgun hero (Wave 2) are follow-ups. --- crates/deckard-app/assets/fonts/README.md | 24 ++ crates/deckard-app/src/main.rs | 34 +- crates/deckard-app/src/money.rs | 83 ++++ crates/deckard-app/src/onboarding.rs | 2 +- crates/deckard-app/src/palette.rs | 9 +- crates/deckard-app/src/receive.rs | 36 +- crates/deckard-app/src/settings.rs | 4 - crates/deckard-app/src/settings_view.rs | 31 +- crates/deckard-app/src/shell.rs | 151 +++---- crates/deckard-app/src/shell_chrome.rs | 369 ++++++++++++++++ crates/deckard-app/src/theme.rs | 224 +++++----- crates/deckard-app/src/tray.rs | 31 +- crates/deckard-app/src/welcome.rs | 421 +++++++++++++------ crates/deckard-contract/src/lib.rs | 59 +++ crates/deckard-contract/src/shield_status.rs | 103 +++++ crates/deckard-signerd/src/client.rs | 42 +- 16 files changed, 1248 insertions(+), 375 deletions(-) create mode 100644 crates/deckard-app/assets/fonts/README.md create mode 100644 crates/deckard-app/src/money.rs create mode 100644 crates/deckard-app/src/shell_chrome.rs create mode 100644 crates/deckard-contract/src/shield_status.rs diff --git a/crates/deckard-app/assets/fonts/README.md b/crates/deckard-app/assets/fonts/README.md new file mode 100644 index 0000000..4b32180 --- /dev/null +++ b/crates/deckard-app/assets/fonts/README.md @@ -0,0 +1,24 @@ +# Bundled fonts (offline-first — no web-font CDN) + +DESIGN.md mandates two bundled families. They are **not committed** here because +they are licensed binaries a human must place; the build stays green without them +(the theme sets the family names and GPUI silently falls back to the system font +until the files exist). + +Drop these files into this directory, then **uncomment the `add_fonts(...)` block +in `crates/deckard-app/src/main.rs`** (search `TODO(fonts)`): + +| File | Family / weight | Source | License | +|------|-----------------|--------|---------| +| `GeneralSans-Regular.otf` | General Sans 400 | https://www.fontshare.com/fonts/general-sans | Fontshare (free) | +| `GeneralSans-Medium.otf` | General Sans 500 | same | same | +| `GeneralSans-Semibold.otf` | General Sans 600 | same | same | +| `JetBrainsMono-Regular.ttf`| JetBrains Mono 400 | https://www.jetbrains.com/lega/font / GitHub `JetBrains/JetBrainsMono` | OFL 1.1 | +| `JetBrainsMono-Medium.ttf` | JetBrains Mono 500 | same | OFL 1.1 | + +Notes: +- DESIGN caps weight at **600** — do not bundle Bold (700+). +- The family-name strings in `theme.rs` (`"General Sans"` / `"JetBrains Mono"`) + must match the font files' internal name table, or GPUI silently falls back. + Verify by launching the app and confirming money renders in mono after dropping + the files in. diff --git a/crates/deckard-app/src/main.rs b/crates/deckard-app/src/main.rs index 84f3564..d95e68c 100644 --- a/crates/deckard-app/src/main.rs +++ b/crates/deckard-app/src/main.rs @@ -7,12 +7,14 @@ //! Fork checklist: rename the crate in `Cargo.toml`, change `APP_NAME` and the //! bundle identifier, swap `assets/icon.png`, then start editing the views. +mod money; mod onboarding; mod palette; mod receive; mod settings; mod settings_view; mod shell; +mod shell_chrome; mod signer; mod theme; #[cfg(feature = "tray")] @@ -58,11 +60,33 @@ fn main() { // 1. Bring up gpui-component (themes, fonts, icon assets, input system). gpui_component::init(cx); + // 1b. Register the bundled offline fonts (no web-font CDN). The theme + // sets the family names ("General Sans" / "JetBrains Mono"); GPUI + // silently falls back to the system font until the files exist, so + // the family-name config alone is safe to ship now. + // + // TODO(fonts): a human must drop the licensed font files into + // crates/deckard-app/assets/fonts/ (see that dir's README.md), then + // uncomment the block below to embed + register them. Do NOT + // uncomment before the files exist — `include_bytes!` of a missing + // path is a compile error. + // + // use std::borrow::Cow; + // cx.text_system() + // .add_fonts(vec![ + // Cow::Borrowed(include_bytes!("../assets/fonts/GeneralSans-Regular.otf").as_slice()), + // Cow::Borrowed(include_bytes!("../assets/fonts/GeneralSans-Medium.otf").as_slice()), + // Cow::Borrowed(include_bytes!("../assets/fonts/GeneralSans-Semibold.otf").as_slice()), + // Cow::Borrowed(include_bytes!("../assets/fonts/JetBrainsMono-Regular.ttf").as_slice()), + // Cow::Borrowed(include_bytes!("../assets/fonts/JetBrainsMono-Medium.ttf").as_slice()), + // ]) + // // reason: bundled fonts are a build-time invariant; a failure here + // // is a packaging bug we want to surface loudly at startup. + // .expect("bundled fonts failed to register"); + // 2. Load persisted preferences and install the refined theme from them. let settings = Settings::load(); - #[cfg(feature = "tray")] - let accent = settings.accent; - theme::install(cx, settings.accent, settings.theme_mode.to_gpui()); + theme::install(cx, settings.theme_mode.to_gpui()); // 3. Keyboard shortcuts. `secondary` = ⌘ on macOS, Ctrl on Linux / // Windows — so these are portable. Context `None` = global. @@ -138,9 +162,9 @@ fn main() { .expect("failed to open window"); // Optional: native menu-bar tray icon + dock hiding (`--features tray`). - // The tray icon uses the saved accent and restyles live when changed. + // The tray icon uses a fixed brand color. #[cfg(feature = "tray")] - tray::install(cx, accent); + tray::install(cx); cx.activate(true); }); diff --git a/crates/deckard-app/src/money.rs b/crates/deckard-app/src/money.rs new file mode 100644 index 0000000..12d5740 --- /dev/null +++ b/crates/deckard-app/src/money.rs @@ -0,0 +1,83 @@ +//! Money — mono-for-money rendering (DESIGN.md §Typography). +//! +//! Every figure that is money / a balance renders in **JetBrains Mono**, tabular, +//! full precision. The fractional part **and** the ticker are dimmed *by color +//! only* (`text.muted`) — never by a size step, which would produce the +//! superscript look DESIGN rejects. The integer carries `text.primary`. +//! +//! Two entry points wrap the single canonical formatter +//! `deckard_core::format_amount(raw, decimals, max_frac)`: +//! - [`money`] — an asset amount with an optional trailing ticker (`1,934.5 ETH`). +//! - [`usd`] — a USD figure carrying the `$` prefix; zero renders `$0`, never +//! `$0.0` (the `$` discipline + the zero rule). + +use gpui::{div, prelude::FluentBuilder, Hsla, IntoElement, ParentElement, SharedString, Styled}; +use gpui_component::h_flex; + +use deckard_core::U256; + +/// Render an asset amount as mono spans: integer in `primary`, decimals + ticker +/// dimmed to `dim` by color only. `unit` is the trailing ticker (e.g. `"ETH"`), +/// or `None` for a bare number. `mono` is `cx.theme().mono_font_family`. +pub fn money( + raw: U256, + decimals: u8, + max_frac: usize, + unit: Option<&str>, + mono: SharedString, + primary: Hsla, + dim: Hsla, +) -> impl IntoElement { + let s = deckard_core::format_amount(raw, decimals, max_frac); + let (int_part, frac) = split_amount(&s); + spans(int_part, frac, unit, mono, primary, dim) +} + +/// Render a USD figure with the `$` prefix. Integer (incl. `$`) in `primary`, +/// decimals dimmed to `dim`. Zero renders `"$0"` (no `.00`, no `$0.0k`). +// reason: consumed by the Wave-2 shielded-balance / fiat view (Total + Private/ +// Public lines carry `$`); kept now as the companion to `money`. +#[allow(dead_code)] +pub fn usd( + raw: U256, + decimals: u8, + max_frac: usize, + mono: SharedString, + primary: Hsla, + dim: Hsla, +) -> impl IntoElement { + let s = deckard_core::format_amount(raw, decimals, max_frac); + let (int_part, frac) = split_amount(&s); + spans(format!("${int_part}"), frac, None, mono, primary, dim) +} + +/// Split a formatted amount (`"1,934.5"`) into its integer and (possibly empty) +/// fractional parts. `format_amount` already strips trailing zeros, so the frac +/// is absent for whole numbers (zero renders `"0"` → `("0", "")`). +fn split_amount(s: &str) -> (String, String) { + match s.split_once('.') { + Some((int_part, frac)) => (int_part.to_string(), frac.to_string()), + None => (s.to_string(), String::new()), + } +} + +/// The shared row: a baseline-aligned mono flex with up to three colored spans +/// (integer · `.decimals` · ` ticker`). Dimming is color-only, size held flat. +fn spans( + int_part: impl Into, + frac: String, + unit: Option<&str>, + mono: SharedString, + primary: Hsla, + dim: Hsla, +) -> impl IntoElement { + let unit = unit.map(|u| format!(" {u}")); + h_flex() + .items_baseline() + .font_family(mono) + .child(div().text_color(primary).child(int_part.into())) + .when(!frac.is_empty(), |e| { + e.child(div().text_color(dim).child(format!(".{frac}"))) + }) + .when_some(unit, |e, u| e.child(div().text_color(dim).child(u))) +} diff --git a/crates/deckard-app/src/onboarding.rs b/crates/deckard-app/src/onboarding.rs index 5ab13d1..835b7b0 100644 --- a/crates/deckard-app/src/onboarding.rs +++ b/crates/deckard-app/src/onboarding.rs @@ -373,7 +373,7 @@ impl Shell { .child( div() .text_2xl() - .font_weight(FontWeight::BOLD) + .font_weight(FontWeight::SEMIBOLD) .text_color(theme.foreground) .child(title.to_string()), ) diff --git a/crates/deckard-app/src/palette.rs b/crates/deckard-app/src/palette.rs index bd26d57..43ddf98 100644 --- a/crates/deckard-app/src/palette.rs +++ b/crates/deckard-app/src/palette.rs @@ -8,7 +8,7 @@ use gpui::{ }; use gpui_component::{v_flex, ActiveTheme}; -use crate::shell::{Route, Shell}; +use crate::shell::{Selection, Shell, Surface}; impl Shell { pub fn render_palette(&self, cx: &mut Context) -> impl IntoElement { @@ -64,20 +64,21 @@ impl Shell { row("cmd-portfolio", "Go to Portfolio", "").on_click(cx.listener( |this, _, _, cx| { this.palette_open = false; - this.navigate(Route::Welcome, cx); + this.select(Selection::Wallet, cx); + this.open(Surface::Home, cx); }, )), ) .child(row("cmd-receive", "Receive", "").on_click(cx.listener( |this, _, _, cx| { this.palette_open = false; - this.navigate(Route::Receive, cx); + this.open(Surface::Receive, cx); }, ))) .child(row("cmd-settings", "Settings", "⌘,").on_click(cx.listener( |this, _, _, cx| { this.palette_open = false; - this.navigate(Route::Settings, cx); + this.open(Surface::Settings, cx); }, ))) .child(row("cmd-copy", "Copy address", "").on_click(cx.listener( diff --git a/crates/deckard-app/src/receive.rs b/crates/deckard-app/src/receive.rs index 3f936e3..4074aba 100644 --- a/crates/deckard-app/src/receive.rs +++ b/crates/deckard-app/src/receive.rs @@ -5,11 +5,12 @@ use gpui::{div, px, rgb, ClipboardItem, Context, FontWeight, IntoElement, ParentElement, Styled}; use gpui_component::{ button::{Button, ButtonVariants}, - h_flex, v_flex, ActiveTheme, + h_flex, v_flex, ActiveTheme, Icon, IconName, }; use qrcode::{Color, QrCode}; -use crate::shell::{Route, Shell}; +use crate::shell::{Shell, Surface}; +use crate::theme; impl Shell { pub fn render_receive(&self, cx: &mut Context) -> impl IntoElement { @@ -19,6 +20,9 @@ impl Shell { let border = theme.border; let surface = theme.secondary; + let is_dark = theme.is_dark(); + let amber = theme::amber(is_dark); + let address = self.wallet_address_string(); // Real QR on a white card (QR must be dark-on-light to scan). @@ -81,6 +85,32 @@ impl Shell { .text_color(fg) .child(address), ) + // Network warning — the one caution moment (DESIGN §236): a + // neutral surface with a 2px amber LEFT keyline + amber icon/text. + // Not a filled warm block; the risk word carries the emphasis. + .child( + h_flex() + .w_full() + .items_start() + .gap_2() + .px_3() + .py_2p5() + .rounded_lg() + .bg(surface) + .border_l_2() + .border_color(amber) + .child( + Icon::new(IconName::TriangleAlert) + .text_color(amber) + .flex_shrink_0(), + ) + .child( + div() + .text_xs() + .text_color(fg) + .child("Only send Ethereum-network assets to this address. Funds sent on the wrong network may be lost."), + ), + ) .child( h_flex() .gap_2() @@ -95,7 +125,7 @@ impl Shell { })), ) .child(Button::new("receive-back").ghost().label("Back").on_click( - cx.listener(|this, _, _, cx| this.navigate(Route::Welcome, cx)), + cx.listener(|this, _, _, cx| this.open(Surface::Home, cx)), )), ), ) diff --git a/crates/deckard-app/src/settings.rs b/crates/deckard-app/src/settings.rs index a78c372..c078f02 100644 --- a/crates/deckard-app/src/settings.rs +++ b/crates/deckard-app/src/settings.rs @@ -12,8 +12,6 @@ use directories::ProjectDirs; use gpui_component::ThemeMode; use serde::{Deserialize, Serialize}; -use crate::theme::Accent; - // Reverse-DNS used for the config dir. Matches the bundle identifier // (`com.deckard.app`) and the wallet keystore dir, so settings + keystore share one // location. (qualifier, organization, application) @@ -45,7 +43,6 @@ impl ThemeModePref { #[serde(default)] pub struct Settings { pub theme_mode: ThemeModePref, - pub accent: Accent, pub display_name: String, pub launch_minimized: bool, /// Custom Ethereum RPC URL (bring-your-own-RPC). Empty = the bundled default. @@ -60,7 +57,6 @@ impl Default for Settings { fn default() -> Self { Self { theme_mode: ThemeModePref::Dark, - accent: Accent::default(), display_name: String::new(), launch_minimized: false, rpc_url: String::new(), diff --git a/crates/deckard-app/src/settings_view.rs b/crates/deckard-app/src/settings_view.rs index 793ac7b..71ac4d6 100644 --- a/crates/deckard-app/src/settings_view.rs +++ b/crates/deckard-app/src/settings_view.rs @@ -2,10 +2,7 @@ //! Every control writes straight back into `self.settings` and calls `.save()`, //! and theme changes apply live. This is the template for your own settings. -use gpui::{ - div, px, rgb, AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, - StatefulInteractiveElement, Styled, Window, -}; +use gpui::{div, px, AnyElement, Context, FontWeight, IntoElement, ParentElement, Styled, Window}; use gpui_component::{ button::{Button, ButtonVariants}, h_flex, @@ -16,7 +13,6 @@ use gpui_component::{ use crate::settings::{Settings, ThemeModePref}; use crate::shell::Shell; -use crate::theme::Accent; impl Shell { pub fn render_settings( @@ -29,10 +25,8 @@ impl Shell { let muted = theme.muted_foreground; let border = theme.border; let surface = theme.secondary; - let ring = theme.ring; let mode = self.settings.theme_mode; - let accent = self.settings.accent; // One settings row: title + description on the left, a control on the right. let row = move |title: &str, desc: &str, control: AnyElement| { @@ -84,22 +78,6 @@ impl Shell { .child(mode_button("mode-light", "Light", ThemeModePref::Light)) .into_any_element(); - // Accent: a row of clickable swatches; the active one gets a ring. - let accent_control = h_flex() - .gap_2() - .children(Accent::ALL.iter().map(|&a| { - let selected = a == accent; - div() - .id(a.label()) - .size(px(24.0)) - .rounded_full() - .bg(rgb(a.rgb())) - .border_2() - .border_color(if selected { ring } else { ring.opacity(0.0) }) - .on_click(cx.listener(move |this, _, _, cx| this.set_accent(a, cx))) - })) - .into_any_element(); - let name_control = Input::new(&self.name_input).w(px(220.0)).into_any_element(); let rpc_control = Input::new(&self.rpc_input).w(px(260.0)).into_any_element(); let watch_control = Input::new(&self.watch_input) @@ -120,12 +98,7 @@ impl Shell { .w(px(540.0)) .gap_6() .child(section_label("Appearance", muted)) - .child( - card() - .child(row("Theme", "Light or dark interface", theme_control)) - .child(divider(border)) - .child(row("Accent", "Brand color across the app", accent_control)), - ) + .child(card().child(row("Theme", "Light or dark interface", theme_control))) .child(section_label("Network", muted)) .child( card() diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index 2c10bf3..e477d97 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -9,10 +9,9 @@ use gpui::{ IntoElement, ParentElement, Render, Styled, Window, }; use gpui_component::{ - button::{Button, ButtonVariants}, h_flex, input::{InputEvent, InputState}, - v_flex, ActiveTheme, IconName, TitleBar, + v_flex, ActiveTheme, TitleBar, }; use deckard_core::{Address, EthProvider, KdfParams, Portfolio, ReadStatus, Vault, WordCount}; @@ -22,7 +21,7 @@ use deckard_signerd::SignerClient; use crate::settings::{Settings, ThemeModePref}; use crate::signer::{self, AppSigner}; -use crate::theme::{self, Accent}; +use crate::theme; use crate::wallet; use crate::{GoBack, NewItem, OpenSettings, TogglePalette, ToggleTheme, APP_NAME}; @@ -52,9 +51,22 @@ fn write_then_unlock( signer::address_or_error(outcome) } +/// What the sidebar tree selects — the contextual-view driver. The home surface +/// renders differently per selection (wallet / project / agent). Demo scope is a +/// single project, wallet, and agent (see deckard-demo-ux-locked.md). #[derive(Clone, Copy, PartialEq, Eq)] -pub enum Route { - Welcome, +pub enum Selection { + Project, + Wallet, + Agent, +} + +/// Transient full-pane surfaces opened FROM a selection. `Home` = the contextual +/// view for the current `Selection`; `Receive`/`Settings` are actions, not nav +/// destinations (DESIGN §Information architecture). +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Surface { + Home, Receive, Settings, } @@ -81,7 +93,10 @@ pub enum AuthStep { pub struct Shell { pub focus_handle: FocusHandle, - pub route: Route, + /// Which sidebar entity is selected (drives the Home contextual view). + pub selection: Selection, + /// The active full-pane surface (Home = the selection's contextual view). + pub surface: Surface, pub settings: Settings, pub name_input: Entity, pub rpc_input: Entity, @@ -274,7 +289,8 @@ impl Shell { Self { focus_handle, - route: Route::Welcome, + selection: Selection::Wallet, + surface: Surface::Home, settings, name_input, rpc_input, @@ -643,7 +659,8 @@ impl Shell { self.wallet_address = Some(address); self.auth = AuthStep::Ready; self.auth_error = None; - self.route = Route::Welcome; + self.selection = Selection::Wallet; + self.surface = Surface::Home; self.retarget(cx); } @@ -803,24 +820,22 @@ impl Shell { self.retarget(cx); } - pub fn navigate(&mut self, route: Route, cx: &mut Context) { - self.route = route; + /// Select a sidebar entity: switch the selection and reset to its Home view. + pub fn select(&mut self, sel: Selection, cx: &mut Context) { + self.selection = sel; + self.surface = Surface::Home; cx.notify(); } - /// Re-install the theme from the current settings (accent + mode). - fn apply_theme(&self, cx: &mut Context) { - theme::install(cx, self.settings.accent, self.settings.theme_mode.to_gpui()); + /// Open a full-pane surface (Home / Receive / Settings) over the current selection. + pub fn open(&mut self, surface: Surface, cx: &mut Context) { + self.surface = surface; + cx.notify(); } - pub fn set_accent(&mut self, accent: Accent, cx: &mut Context) { - self.settings.accent = accent; - self.settings.save(); - self.apply_theme(cx); - // Keep the menu-bar tray icon (if running) in sync with the accent. - #[cfg(feature = "tray")] - crate::tray::set_accent(cx, accent); - 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()); } pub fn set_mode(&mut self, mode: ThemeModePref, cx: &mut Context) { @@ -846,12 +861,13 @@ impl Shell { } fn on_open_settings(&mut self, _: &OpenSettings, _: &mut Window, cx: &mut Context) { - self.navigate(Route::Settings, cx); + self.open(Surface::Settings, cx); } fn on_go_back(&mut self, _: &GoBack, _: &mut Window, cx: &mut Context) { - if self.route != Route::Welcome { - self.navigate(Route::Welcome, cx); + // Back = leave any action surface and return to the selection's Home view. + if self.surface != Surface::Home { + self.open(Surface::Home, cx); } } @@ -864,61 +880,16 @@ impl Shell { cx.notify(); } + /// A bare macOS title bar: just the traffic-light inset + the app name. Its old + /// settings/theme controls now live in the breadcrumb (`shell_chrome.rs`). fn render_title_bar(&self, cx: &mut Context) -> impl IntoElement { let muted = cx.theme().muted_foreground; - let is_settings = self.route == Route::Settings; - let theme_icon = if self.settings.theme_mode == ThemeModePref::Dark { - IconName::Sun - } else { - IconName::Moon - }; - TitleBar::new().child( - h_flex() - .w_full() - .items_center() - .justify_between() - .child( - h_flex() - .items_center() - .gap_2() - .children(is_settings.then(|| { - Button::new("back") - .ghost() - .icon(IconName::ChevronLeft) - .on_click( - cx.listener(|this, _, _, cx| this.navigate(Route::Welcome, cx)), - ) - })) - .child( - div() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(muted) - .child(if is_settings { "Settings" } else { APP_NAME }), - ), - ) - .child( - h_flex() - .items_center() - .gap_1() - .children((!is_settings).then(|| { - Button::new("open-settings") - .ghost() - .icon(IconName::Settings) - .on_click( - cx.listener(|this, _, _, cx| { - this.navigate(Route::Settings, cx) - }), - ) - })) - .child( - Button::new("toggle-theme") - .ghost() - .icon(theme_icon) - .on_click(cx.listener(|this, _, _, cx| this.toggle_mode(cx))), - ), - ), + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .child(APP_NAME), ) } } @@ -934,17 +905,33 @@ impl Render for Shell { let background = cx.theme().background; let body = if self.auth == AuthStep::Ready { - // The unlocked app: full title bar + routes + command palette. + // The unlocked app: macOS title bar above the two-pane shell grid + // (sidebar | [breadcrumb / content / status strip]) + command palette. let title_bar = self.render_title_bar(cx); - let content = match self.route { - Route::Welcome => self.render_welcome(cx).into_any_element(), - Route::Receive => self.render_receive(cx).into_any_element(), - Route::Settings => self.render_settings(window, cx).into_any_element(), + let content = match (self.selection, self.surface) { + (_, Surface::Settings) => self.render_settings(window, cx).into_any_element(), + (_, Surface::Receive) => self.render_receive(cx).into_any_element(), + (Selection::Wallet, Surface::Home) => { + self.render_wallet_home(cx).into_any_element() + } + (Selection::Project, Surface::Home) => { + self.render_project_home(cx).into_any_element() + } + (Selection::Agent, Surface::Home) => self.render_agent_home(cx).into_any_element(), }; v_flex() .size_full() .child(title_bar) - .child(content) + .child( + h_flex().size_full().child(self.render_sidebar(cx)).child( + v_flex() + .flex_1() + .min_w_0() + .child(self.render_breadcrumb(cx)) + .child(div().flex_1().min_h_0().child(content)) + .child(self.render_status_strip(cx)), + ), + ) .children(self.palette_open.then(|| self.render_palette(cx))) .into_any_element() } else { diff --git a/crates/deckard-app/src/shell_chrome.rs b/crates/deckard-app/src/shell_chrome.rs new file mode 100644 index 0000000..cbde72f --- /dev/null +++ b/crates/deckard-app/src/shell_chrome.rs @@ -0,0 +1,369 @@ +//! Shell chrome — the two-pane shell's hand-built furniture (DESIGN §Information +//! architecture): the 248px sidebar tree, the 44px breadcrumb top bar, and the +//! 25px bottom status strip, plus the shared neutral network pill. +//! +//! These are deliberately NOT gpui-component's heavyweight `Sidebar`/`Breadcrumb` +//! components — the demo scope is a single project / wallet / agent, so the tree +//! is a plain `v_flex` of rows. Color law (DESIGN §Color): ~95% grayscale; the +//! selected row is a brightness lift (`secondary`), NEVER a colored keyline; amber +//! is reserved for Receive's keyline + focus rings; cyan appears ONLY on the agent +//! squircle glyph. + +use gpui::prelude::FluentBuilder; +use gpui::{ + div, px, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, + StatefulInteractiveElement, Styled, +}; +use gpui_component::{ + button::{Button, ButtonVariants}, + h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, +}; + +use crate::settings::ThemeModePref; +use crate::shell::{Selection, Shell, Surface}; +use crate::theme; + +/// Middle-truncate an address for a tight row, e.g. `0xA1b2…9F3c`. +fn short_addr(a: &str) -> String { + if a.len() >= 12 { + format!("{}…{}", &a[..6], &a[a.len() - 4..]) + } else { + a.to_string() + } +} + +impl Shell { + /// The current view's human label, for the breadcrumb's trailing segment. + fn view_label(&self) -> &'static str { + match self.surface { + Surface::Settings => "Settings", + Surface::Receive => "Receive", + Surface::Home => match self.selection { + Selection::Project => "Personal", + Selection::Wallet => "Wallet", + Selection::Agent => "Atlas", + }, + } + } + + /// The hand-built sidebar tree: a PROJECTS label, one project row, a Wallets + /// group + one wallet row, an Agents group + one agent row, a flex spacer, and + /// a footer gear that opens Settings. Neutral throughout; cyan only on the + /// agent squircle. + pub fn render_sidebar(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let border = theme.border; + let lift = theme.secondary; // selected/active = brightness lift (not a keyline) + let mono = theme.mono_font_family.clone(); + let is_dark = theme.is_dark(); + let id_square = theme::identity_square(is_dark); + let agent = theme::agent(is_dark); + let agent_tint = theme::agent_tint(is_dark); + + let project_selected = + self.surface == Surface::Home && self.selection == Selection::Project; + let wallet_selected = self.surface == Surface::Home && self.selection == Selection::Wallet; + let agent_selected = self.surface == Surface::Home && self.selection == Selection::Agent; + + let addr = short_addr(&self.wallet_address_string()); + let balance = self + .portfolio + .as_ref() + .map(|p| deckard_core::format_amount(p.native_wei, 18, 4)) + .unwrap_or_else(|| "—".to_string()); + + // A tiny uppercase section label (10px, +letterspacing per DESIGN typography). + let group_label = |text: &'static str| { + div() + .px_3() + .pt_3() + .pb_1() + .text_xs() + .text_color(muted) + .child(text) + }; + + v_flex() + .w(px(248.0)) + .flex_shrink_0() + .h_full() + .bg(theme.sidebar) + .border_r_1() + .border_color(border) + // PROJECTS header. + .child(group_label("PROJECTS")) + // Project row. + .child( + div() + .id("nav-project") + .mx_2() + .px_2() + .py_1p5() + .rounded_md() + .when(project_selected, |e| e.bg(lift)) + .cursor_pointer() + .child( + h_flex() + .items_center() + .gap_2() + .child(div().size(px(16.0)).rounded(px(4.0)).bg(id_square)) + .child(div().text_sm().text_color(fg).child("Personal")), + ) + .on_click(cx.listener(|this, _, _, cx| this.select(Selection::Project, cx))), + ) + // Wallets group. + .child(group_label("Wallets")) + .child( + div() + .id("nav-wallet") + .mx_2() + .px_2() + .py_1p5() + .rounded_md() + .when(wallet_selected, |e| e.bg(lift)) + .cursor_pointer() + .child( + h_flex() + .items_center() + .justify_between() + .gap_2() + .child( + h_flex() + .items_center() + .gap_2() + .min_w_0() + .child(div().size(px(16.0)).rounded(px(4.0)).bg(id_square)) + .child( + div() + .font_family(mono.clone()) + .text_xs() + .text_color(fg) + .child(addr), + ), + ) + .child( + div() + .font_family(mono.clone()) + .text_xs() + .text_color(muted) + .child(balance), + ), + ) + .on_click(cx.listener(|this, _, _, cx| this.select(Selection::Wallet, cx))), + ) + // Agents group. + .child(group_label("Agents")) + .child( + div() + .id("nav-agent") + .mx_2() + .px_2() + .py_1p5() + .rounded_md() + .when(agent_selected, |e| e.bg(lift)) + .cursor_pointer() + .child( + h_flex() + .w_full() + .items_center() + .gap_2() + // The cyan squircle monogram — the ONE cyan surface, a + // rounded square (~6px), NEVER rounded_full. + .child( + div() + .size(px(16.0)) + .rounded(px(5.0)) + .bg(agent_tint) + .border_1() + .border_color(agent) + .flex() + .items_center() + .justify_center() + .child( + div() + .text_color(agent) + .child("A") + .text_xs() + .font_weight(FontWeight::SEMIBOLD), + ), + ) + .child(div().flex_1().text_sm().text_color(fg).child("Atlas")) + // Idle status dot (neutral; the agent's "currently acting" + // pulse is a fast-follow). + .child(div().size(px(6.0)).rounded_full().bg(muted)), + ) + .on_click(cx.listener(|this, _, _, cx| this.select(Selection::Agent, cx))), + ) + // Spacer pushes the footer gear to the bottom. + .child(div().flex_1()) + // Footer gear → Settings. + .child( + div() + .id("nav-settings") + .mx_2() + .mb_2() + .px_2() + .py_1p5() + .rounded_md() + .when(self.surface == Surface::Settings, |e| e.bg(lift)) + .cursor_pointer() + .child( + h_flex() + .items_center() + .gap_2() + .child(Icon::new(IconName::Settings).text_color(muted)) + .child(div().text_sm().text_color(muted).child("Settings")), + ) + .on_click(cx.listener(|this, _, _, cx| this.open(Surface::Settings, cx))), + ) + } + + /// The 44px breadcrumb bar: `[identity square] Personal › ` on the left, + /// and the neutral network pill + ⌘K affordance + theme toggle on the right + /// (the controls lifted out of the old title bar). + pub fn render_breadcrumb(&self, 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 id_square = theme::identity_square(theme.is_dark()); + let theme_icon = if self.settings.theme_mode == ThemeModePref::Dark { + IconName::Sun + } else { + IconName::Moon + }; + + h_flex() + .h(px(44.0)) + .flex_shrink_0() + .w_full() + .px_3() + .items_center() + .justify_between() + .border_b_1() + .border_color(border) + .child( + h_flex() + .items_center() + .gap_2() + .child(div().size(px(16.0)).rounded(px(4.0)).bg(id_square)) + .child(div().text_sm().text_color(fg).child("Personal")) + .child(div().text_sm().text_color(muted).child("›")) + .child(div().text_sm().text_color(fg).child(self.view_label())), + ) + .child( + h_flex() + .items_center() + .gap_2() + .child(self.network_pill(cx)) + .child( + // ⌘K affordance — opens the command palette. + div() + .id("breadcrumb-cmdk") + .px_2() + .py_0p5() + .rounded_md() + .border_1() + .border_color(border) + .bg(surface) + .text_xs() + .text_color(muted) + .cursor_pointer() + .child("⌘K") + .on_click(cx.listener(|this, _, _, cx| { + this.palette_open = !this.palette_open; + cx.notify(); + })), + ) + .child( + Button::new("toggle-theme") + .ghost() + .icon(theme_icon) + .on_click(cx.listener(|this, _, _, cx| this.toggle_mode(cx))), + ), + ) + } + + /// The 25px bottom status strip: the synced-block + trust label on the left + /// (migrated out of welcome.rs), the network name on the right. A balance is + /// never shown as quietly trusted — a non-Verified read surfaces here. + pub fn render_status_strip(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let border = theme.border; + + let first_sync = self.portfolio_loading && self.portfolio.is_none(); + + // Trust label suffix (DESIGN: never silently "trusted"). + let trust_tag = match &self.read_status { + Some(deckard_core::ReadStatus::Verified) => " · verified", + Some(deckard_core::ReadStatus::Degraded { .. }) => " · degraded", + Some(deckard_core::ReadStatus::Unsynced { .. }) => " · NOT VERIFIED", + None => "", + }; + let status_line = if let Some(err) = &self.portfolio_error { + format!("⚠ {err}") + } else if first_sync { + "Syncing over Ethereum…".to_string() + } else if let Some(block) = self.synced_block { + let watching = if self.viewing_watch { + "watching · " + } else { + "" + }; + format!("{watching}synced · block {block}{trust_tag}") + } else { + "Ethereum mainnet".to_string() + }; + + // An unverified read is a soft warning (not trustless), not a hard error. + let unverified = matches!( + self.read_status, + Some(deckard_core::ReadStatus::Unsynced { .. }) + ); + let status_color = if self.portfolio_error.is_some() { + theme.danger + } else if unverified { + theme.warning + } else { + muted + }; + + h_flex() + .h(px(25.0)) + .flex_shrink_0() + .w_full() + .px_3() + .items_center() + .justify_between() + .border_t_1() + .border_color(border) + .text_xs() + .child(div().text_color(status_color).child(status_line)) + .child(div().text_color(muted).child("Ethereum")) + } + + /// The shared neutral network chip ("Ethereum"). Bordered, NOT amber (DESIGN + /// §Color rule 4) and NOT a fully-rounded pill (rounded_md, ~6px). + pub fn network_pill(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let border = theme.border; + let surface = theme.secondary; + + h_flex() + .items_center() + .gap_1p5() + .px_2() + .py_0p5() + .rounded_md() + .border_1() + .border_color(border) + .bg(surface) + .child(Icon::new(IconName::Globe).text_color(fg).small()) + .child(div().text_xs().text_color(fg).child("Ethereum")) + } +} diff --git a/crates/deckard-app/src/theme.rs b/crates/deckard-app/src/theme.rs index ae48d4e..b021303 100644 --- a/crates/deckard-app/src/theme.rs +++ b/crates/deckard-app/src/theme.rs @@ -1,88 +1,77 @@ -//! Theme — a refined dark/light palette with a selectable brand accent. +//! Theme — the locked Deckard dark/light palette (DESIGN.md §Color). //! //! gpui-component's stock dark theme is near pure-black-on-white, which reads -//! harsh. The common pattern (Linear, GitHub, Zed) is: a *soft* near-black with -//! slightly-elevated surfaces, muted secondary text, and a single saturated -//! **accent** that carries the brand. We build that by cloning the built-in -//! `ThemeConfig` and overriding ~20 color tokens, so it survives light/dark -//! toggles (gpui-component re-applies the config on every `Theme::change`). +//! harsh. Deckard's language is ~95% grayscale: a *soft* near-black with +//! slightly-elevated surfaces, muted secondary text, **neutral** primary buttons, +//! and two signal colors spent sparingly — **amber = the human / where-you-are / +//! caution / focus ring**, **cyan = the agent class**. We build the surface +//! palette by cloning the built-in `ThemeConfig` and overriding the color tokens, +//! so it survives light/dark toggles (gpui-component re-applies the config on +//! every `Theme::change`). +//! +//! The two signal colors are *not* gpui-component theme slots — the cyan slot is +//! private and the live `cyan` is the kit's own base color. So `amber()` / +//! `agent()` live here as app-level helpers returning `gpui::Hsla`, consumed at +//! render sites with `.text_color(..)` / `.bg(..)`. use std::rc::Rc; -use gpui::{App, SharedString}; +use gpui::{App, Hsla, Rgba, SharedString}; use gpui_component::{Theme, ThemeConfig, ThemeMode, ThemeRegistry}; -use serde::{Deserialize, Serialize}; - -/// The brand accent. This is the knob that makes the app feel like *yours* — the -/// settings page lets the user pick one and it re-themes the whole app live. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Accent { - Indigo, - Blue, - Violet, - Emerald, - #[default] - Amber, - Rose, + +/// Parse a `#RRGGBB` / `#RRGGBBAA` hex string into an `Hsla`. Falls back to a +/// transparent default on a malformed literal (the inputs here are all const). +pub fn hex(s: &str) -> Hsla { + Rgba::try_from(s).map(Into::into).unwrap_or_default() } -impl Accent { - pub const ALL: [Accent; 6] = [ - Accent::Indigo, - Accent::Blue, - Accent::Violet, - Accent::Emerald, - Accent::Amber, - Accent::Rose, - ]; - - pub fn label(self) -> &'static str { - match self { - Accent::Indigo => "Indigo", - Accent::Blue => "Blue", - Accent::Violet => "Violet", - Accent::Emerald => "Emerald", - Accent::Amber => "Amber", - Accent::Rose => "Rose", - } - } +/// **amber** — the human / "where you are" / caution / the sanctioned focus ring. +/// <1% of pixels. Never a primary-button fill, never a chart segment. +pub fn amber(dark: bool) -> Hsla { + hex(if dark { "#F2A43B" } else { "#A8650C" }) +} - /// `(base, hover, active)` hex for the accent. `base` is also the swatch color. - fn ramp(self) -> (&'static str, &'static str, &'static str) { - match self { - Accent::Indigo => ("#6E78F0", "#828AF2", "#5A64E0"), - Accent::Blue => ("#3B82F6", "#5A95F7", "#2F6FE0"), - Accent::Violet => ("#8B5CF6", "#9D74F7", "#7A47E6"), - Accent::Emerald => ("#10B981", "#2DC894", "#0E9E6F"), - Accent::Amber => ("#F2A43B", "#FFB454", "#DB8C06"), - Accent::Rose => ("#F43F5E", "#F65A75", "#E02A4A"), - } - } +/// Amber at low alpha — the hold-to-confirm sweep wash + future amber washes. +/// `rgba(242,164,59,.14)` dark; the deepened light amber at the same alpha. +// reason: consumed by the T5 shield hold-to-confirm amber fill-sweep; the +// caution banner correctly uses a NEUTRAL surface + keyline (DESIGN rule 7), not +// a tint fill, so this helper has no consumer until the sweep lands. +#[allow(dead_code)] +pub fn amber_tint(dark: bool) -> Hsla { + amber(dark).opacity(0.14) +} - /// The swatch / mark color as an `0xRRGGBB` value, for `gpui::rgb(..)` in the UI. - pub fn rgb(self) -> u32 { - match self { - Accent::Indigo => 0x6E78F0, - Accent::Blue => 0x3B82F6, - Accent::Violet => 0x8B5CF6, - Accent::Emerald => 0x10B981, - Accent::Amber => 0xF2A43B, - Accent::Rose => 0xF43F5E, - } - } +/// **cyan** — the agent class only (the squircle glyph + agent status). Low-chroma. +/// **Never** a page title, body, or link color. +pub fn agent(dark: bool) -> Hsla { + hex(if dark { "#3CC9BC" } else { "#0C7E75" }) +} + +/// Cyan at low alpha — the agent-identity chip / "currently acting" wash. +/// `rgba(60,201,188,.12)` dark; the deepened light teal at the same alpha. +pub fn agent_tint(dark: bool) -> Hsla { + agent(dark).opacity(0.12) +} + +/// A project/wallet **identity square** fill — a desaturated, tinted-neutral chip +/// (DESIGN §Color rule 4: identity colors avoid the warm/amber band entirely and +/// sit off the semantic `success` hue, so they never read as actor signal or +/// status). A cool slate-neutral, distinct from both amber and cyan. +pub fn identity_square(dark: bool) -> Hsla { + hex(if dark { "#3A4250" } else { "#A7AEBA" }) } -/// Install (or re-install) the refined theme for `accent`, then apply `mode`. -/// Call once at startup and again whenever the user changes accent or mode. -pub fn install(cx: &mut App, accent: Accent, mode: ThemeMode) { +/// Install (or re-install) the refined theme, then apply `mode`. Call once at +/// startup and again whenever the user toggles light/dark. +pub fn install(cx: &mut App, mode: ThemeMode) { // Ensure the Theme global exists (first call seeds it from the registry). Theme::change(mode, None, cx); let registry = ThemeRegistry::global(cx); let mut dark = (**registry.default_dark_theme()).clone(); let mut light = (**registry.default_light_theme()).clone(); - refine(&mut dark, accent, true); - refine(&mut light, accent, false); + refine(&mut dark, true); + refine(&mut light, false); let theme = Theme::global_mut(cx); theme.dark_theme = Rc::new(dark); @@ -93,50 +82,73 @@ pub fn install(cx: &mut App, accent: Accent, mode: ThemeMode) { cx.refresh_windows(); } -fn refine(config: &mut ThemeConfig, accent: Accent, dark: bool) { - let (primary, primary_hover, primary_active) = accent.ramp(); +fn refine(config: &mut ThemeConfig, dark: bool) { + // Bundled offline fonts (registered in `main.rs`). The string is the OS / + // registered family name, not a path; GPUI silently falls back to the system + // font if the family isn't installed. `Root` applies `font_family` app-wide; + // mono is per-element via `cx.theme().mono_font_family`. + config.font_family = Some("General Sans".into()); + config.mono_font_family = Some("JetBrains Mono".into()); + // NB: deliberately do NOT set `font.size` — the views rely on the relative + // `.text_*` utilities off gpui's 16px base; lowering it would rescale every + // screen (DESIGN body=13 is honored per-element, not via the base rem). + let c = &mut config.colors; let set = |slot: &mut Option, hex: &str| *slot = Some(hex.to_string().into()); - // Brand accent (shared across modes). `primary` is the brand color; the - // `accent` token is a *subtle surface* (ghost-button / menu hover), not the brand. - set(&mut c.primary, primary); - set(&mut c.primary_hover, primary_hover); - set(&mut c.primary_active, primary_active); - set(&mut c.primary_foreground, "#FFFFFF"); - set(&mut c.ring, primary); + // PRIMARY buttons are NEUTRAL (DESIGN: amber is never a primary fill — it + // appears only as the hold-sweep on irreversible confirms). The focus ring is + // the one sanctioned amber surface. + if dark { + set(&mut c.primary, "#161922"); // bg.raise2 + set(&mut c.primary_hover, "#1B1E25"); + set(&mut c.primary_active, "#121419"); // bg.raise + set(&mut c.primary_foreground, "#E7E9EC"); // text.primary (never pure white) + set(&mut c.ring, "#F2A43B"); // amber focus ring + } else { + set(&mut c.primary, "#FFFFFF"); // raise + set(&mut c.primary_hover, "#ECEBE4"); // hover + set(&mut c.primary_active, "#F6F5F1"); // base + set(&mut c.primary_foreground, "#17191E"); // text.primary + set(&mut c.ring, "#A8650C"); // deepened amber for AA + } + + // SEMANTIC tokens — set explicitly so they match DESIGN (warning = amber). + set(&mut c.success, if dark { "#4FB463" } else { "#2F8F47" }); + set(&mut c.danger, if dark { "#E5565B" } else { "#C23B40" }); + set(&mut c.warning, if dark { "#F2A43B" } else { "#A8650C" }); if dark { - set(&mut c.background, "#0A0B0D"); // Deckard near-black, faint cool cast - set(&mut c.foreground, "#E6E8EB"); - set(&mut c.secondary, "#14161A"); // cards / surfaces - set(&mut c.secondary_foreground, "#E6E8EB"); - set(&mut c.muted, "#1B1E24"); - set(&mut c.muted_foreground, "#878E97"); - set(&mut c.border, "#23272E"); - set(&mut c.input, "#1B1E24"); - set(&mut c.popover, "#14161A"); - set(&mut c.popover_foreground, "#E6E8EB"); - set(&mut c.title_bar, "#0A0B0D"); - set(&mut c.title_bar_border, "#23272E"); - set(&mut c.sidebar, "#0E0F12"); - set(&mut c.accent, "#1B1E24"); // subtle hover surface - set(&mut c.accent_foreground, "#E6E8EB"); + set(&mut c.background, "#0A0B0D"); // bg.base — near-black, faint cool cast + set(&mut c.foreground, "#E7E9EC"); // text.primary + set(&mut c.secondary, "#121419"); // bg.raise — cards / surfaces + set(&mut c.secondary_foreground, "#E7E9EC"); + set(&mut c.muted, "#161922"); // bg.raise2 + set(&mut c.muted_foreground, "#9298A2"); // text.secondary (the kit's "muted text") + set(&mut c.border, "#1B1E25"); // border.hairline + set(&mut c.input, "#262A33"); // border.strong + set(&mut c.popover, "#161922"); // bg.raise2 + set(&mut c.popover_foreground, "#E7E9EC"); + set(&mut c.title_bar, "#0B0C0F"); // bg.rail + set(&mut c.title_bar_border, "#1B1E25"); // hairline + set(&mut c.sidebar, "#0B0C0F"); // bg.rail + set(&mut c.accent, "#14161B"); // bg.hover — subtle hover surface + set(&mut c.accent_foreground, "#E7E9EC"); } else { - set(&mut c.background, "#FBFBFC"); - set(&mut c.foreground, "#16171D"); - set(&mut c.secondary, "#F1F2F4"); - set(&mut c.secondary_foreground, "#16171D"); - set(&mut c.muted, "#F1F2F4"); - set(&mut c.muted_foreground, "#6B6D78"); - set(&mut c.border, "#E4E5EA"); - set(&mut c.input, "#FFFFFF"); - set(&mut c.popover, "#FFFFFF"); - set(&mut c.popover_foreground, "#16171D"); - set(&mut c.title_bar, "#FBFBFC"); - set(&mut c.title_bar_border, "#E4E5EA"); - set(&mut c.sidebar, "#F6F7F9"); - set(&mut c.accent, "#F1F2F4"); - set(&mut c.accent_foreground, "#16171D"); + set(&mut c.background, "#F6F5F1"); // bg.base + set(&mut c.foreground, "#17191E"); // text.primary + set(&mut c.secondary, "#FFFFFF"); // raise + set(&mut c.secondary_foreground, "#17191E"); + set(&mut c.muted, "#ECEBE4"); // hover + set(&mut c.muted_foreground, "#6B7280"); // text.muted (>=4.5:1 on base) + set(&mut c.border, "#DDDBD2"); // border.hairline + set(&mut c.input, "#CFCCC2"); // border.strong + set(&mut c.popover, "#FFFFFF"); // raise + set(&mut c.popover_foreground, "#17191E"); + set(&mut c.title_bar, "#EEEDE6"); // bg.rail + set(&mut c.title_bar_border, "#DDDBD2"); + set(&mut c.sidebar, "#EEEDE6"); // bg.rail + set(&mut c.accent, "#ECEBE4"); // hover + set(&mut c.accent_foreground, "#17191E"); } } diff --git a/crates/deckard-app/src/tray.rs b/crates/deckard-app/src/tray.rs index 46c2efb..45f61fd 100644 --- a/crates/deckard-app/src/tray.rs +++ b/crates/deckard-app/src/tray.rs @@ -22,11 +22,14 @@ use tray_icon::{ Icon, TrayIcon, TrayIconBuilder, }; -use crate::theme::Accent; +/// The fixed brand color for the tray icon (locked amber, `#F2A43B`). The 6-accent +/// picker is gone — there is one brand color. +const BRAND: u32 = 0x00F2_A43B; -/// Holds the live tray icon so it (a) stays alive for the process and (b) can be -/// restyled when the user changes accent in settings. Stored as a GPUI global. +/// Holds the live tray icon so it stays alive for the whole process. Stored as a +/// GPUI global. struct TrayState { + #[allow(dead_code)] tray: TrayIcon, } @@ -34,7 +37,7 @@ impl Global for TrayState {} /// Build the tray icon, hide the dock, and bridge tray-menu clicks into GPUI. /// Call once from `app.run` (it must run on the main thread, after launch). -pub fn install(cx: &mut App, accent: Accent) { +pub fn install(cx: &mut App) { hide_dock_icon(); let menu = Menu::new(); @@ -45,13 +48,12 @@ pub fn install(cx: &mut App, accent: Accent) { let tray = TrayIconBuilder::new() .with_tooltip(crate::APP_NAME) - .with_icon(brand_icon(accent)) + .with_icon(brand_icon()) .with_menu(Box::new(menu)) .build() .expect("failed to build tray icon"); - // Keep the status item alive for the whole process, and reachable so accent - // changes can restyle it (see `set_accent`). + // Keep the status item alive for the whole process. cx.set_global(TrayState { tray }); let show_id = show.id().clone(); @@ -78,15 +80,6 @@ pub fn install(cx: &mut App, accent: Accent) { .detach(); } -/// Restyle the tray icon to match a new accent. No-op if the tray isn't running -/// (i.e. the feature is on but `install` wasn't called). Wired from `Shell`. -pub fn set_accent(cx: &mut App, accent: Accent) { - if cx.has_global::() { - let icon = brand_icon(accent); - let _ = cx.global::().tray.set_icon(Some(icon)); - } -} - /// Make the app a menu-bar "accessory": no dock icon, no ⌘-Tab entry. /// macOS-only — GPUI hardcodes `Regular` at launch, so we override it here at /// runtime via objc2. On Linux/Windows there's no dock; whether a window shows @@ -106,12 +99,12 @@ fn hide_dock_icon() { #[cfg(not(target_os = "macos"))] fn hide_dock_icon() {} -/// A simple 32×32 rounded-square icon in the current accent color. Replace with +/// A simple 32×32 rounded-square icon in the fixed brand color. Replace with /// your own — e.g. `Icon::from_path("…")`, or a black template image so macOS /// tints it to match the menu bar automatically. -fn brand_icon(accent: Accent) -> Icon { +fn brand_icon() -> Icon { const SIZE: u32 = 32; - let hex = accent.rgb(); + let hex = BRAND; let (r, g, b) = ((hex >> 16) as u8, (hex >> 8) as u8, hex as u8); let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize]; for y in 0..SIZE { diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index a740e65..10f3b52 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -3,15 +3,29 @@ //! live over Multicall3 (`deckard-core`). Renders instantly from the last cached //! snapshot; the only loading state is the very first sync. -use gpui::{div, px, Context, FontWeight, IntoElement, ParentElement, Styled}; +use gpui::prelude::FluentBuilder; +use gpui::{div, px, Context, FontWeight, IntoElement, ParentElement, SharedString, Styled}; use gpui_component::{ button::{Button, ButtonVariants}, h_flex, v_flex, ActiveTheme, Disableable, IconName, }; -use deckard_core::format_amount; +use deckard_core::U256; -use crate::shell::{Route, Shell}; +use crate::money::money; +use crate::shell::{Shell, Surface}; +use crate::theme; + +/// One row in the holdings table. Carries the raw balance (not a pre-formatted +/// string) so the amount column can render mono-for-money with dimmed decimals. +struct Holding { + mark: String, + name: String, + symbol: String, + raw: U256, + decimals: u8, + max_frac: usize, +} /// The primary modifier label, per platform (⌘ on macOS, "Ctrl " elsewhere). #[cfg(target_os = "macos")] @@ -29,13 +43,17 @@ fn short_addr(a: &str) -> String { } impl Shell { - pub fn render_welcome(&self, cx: &mut Context) -> impl IntoElement { + /// The selected wallet's home — a left-anchored, scrollable main pane: + /// wallet-name H1 + address subtitle, the balance hero, Send/Receive/Swap, + /// then live holdings. The synced/trust status line lives in the bottom + /// status strip (`shell_chrome.rs`), not here. + pub fn render_wallet_home(&self, 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 accent = theme.primary; + let mono: SharedString = theme.mono_font_family.clone(); // A small bordered key-hint chip, e.g. ⌘K. let chip = move |keys: String, label: String| { @@ -62,30 +80,33 @@ impl Shell { let account_pill = short_addr(&addr_str); let first_sync = self.portfolio_loading && self.portfolio.is_none(); - let native_str = self - .portfolio - .as_ref() - .map(|p| format_amount(p.native_wei, 18, 4)) - .unwrap_or_else(|| "—".to_string()); + // The native ETH balance for the hero — `None` until the first sync lands. + let native_wei = self.portfolio.as_ref().map(|p| p.native_wei); - // Holdings rows: ETH first, then each non-zero listed token. - let mut holdings: Vec<(String, String, String, String)> = Vec::new(); + // Holdings rows: ETH first, then each listed token. Each row carries the + // raw value + decimals + frac so the amount column renders mono-for-money + // (dimmed decimals) rather than a single-color string. + let mut holdings: Vec = Vec::new(); if let Some(p) = &self.portfolio { - holdings.push(( - "Ξ".into(), - "Ethereum".into(), - "ETH".into(), - format_amount(p.native_wei, 18, 4), - )); + holdings.push(Holding { + mark: "Ξ".into(), + name: "Ethereum".into(), + symbol: "ETH".into(), + raw: p.native_wei, + decimals: 18, + max_frac: 4, + }); for t in &p.tokens { let frac = if t.decimals <= 6 { 2 } else { 4 }; let mark = t.symbol.chars().next().unwrap_or('•').to_string(); - holdings.push(( + holdings.push(Holding { mark, - t.name.to_string(), - t.symbol.to_string(), - format_amount(t.raw, t.decimals, frac), - )); + name: t.name.to_string(), + symbol: t.symbol.to_string(), + raw: t.raw, + decimals: t.decimals, + max_frac: frac, + }); } } let has_tokens = self @@ -94,107 +115,85 @@ impl Shell { .map(|p| !p.tokens.is_empty()) .unwrap_or(false); - // Status sub-line: synced block, watching tag, or an error. When a read carries a - // non-Verified trust label, surface it: a balance is never shown as quietly trusted. - let trust_tag = match &self.read_status { - Some(deckard_core::ReadStatus::Verified) => " · verified", - Some(deckard_core::ReadStatus::Degraded { .. }) => " · degraded", - Some(deckard_core::ReadStatus::Unsynced { .. }) => " · NOT VERIFIED", - None => "", - }; - let status_line = if let Some(err) = &self.portfolio_error { - format!("⚠ {err}") - } else if first_sync { - "Syncing over Ethereum…".to_string() - } else if let Some(block) = self.synced_block { - let net = if self.viewing_watch { - "watching · " - } else { - "" - }; - format!("{net}synced · block {block}{trust_tag}") - } else { - "Ethereum mainnet".to_string() - }; - // An unverified read is a soft warning (the value may not be trustless), not a hard error. - let unverified = matches!( - self.read_status, - Some(deckard_core::ReadStatus::Unsynced { .. }) - ); - let status_color = if self.portfolio_error.is_some() { - theme.danger - } else if unverified { - theme.warning + // Wallet identity for the header: a desaturated, tinted-neutral square + // (DESIGN rule 4 — identity squares avoid the warm/amber band). + let id_square = theme::identity_square(theme.is_dark()); + let wallet_name = if self.viewing_watch { + "Watched account".to_string() } else { - muted + "Personal".to_string() }; div() - .flex_1() - .flex() - .flex_col() - .items_center() - .justify_center() + .size_full() + .p_8() + // TODO(scroll): restore a scrollable main pane via a Stateful + // `div().id(..).overflow_y_scroll()` (the agent draft mis-ordered + // gpui-component's `overflow_y_scrollbar`). Content is short for now. .child( v_flex() - .w(px(460.0)) + .items_start() + .max_w(px(680.0)) .gap_6() - // Header: section label + account pill + refresh. + // Page header (DESIGN §Page header): identity square + wallet-name + // H1 (text.primary, weight 600 — NEVER cyan) + a muted mono, + // middle-truncated address subtitle. .child( h_flex() .w_full() .items_center() .justify_between() - .child(div().text_sm().text_color(muted).child("Portfolio")) .child( h_flex() .items_center() - .gap_2() + .gap_3() + .child(div().size(px(28.0)).rounded(px(6.0)).bg(id_square)) .child( - div() - .px_2p5() - .py_1() - .rounded_full() - .border_1() - .border_color(if self.viewing_watch { - accent - } else { - border - }) - .bg(surface) - .text_xs() - .text_color(fg) - .child(account_pill), - ) - .child( - Button::new("refresh") - .ghost() - .icon(IconName::Replace) - .on_click(cx.listener(|this, _, _, cx| { - this.refresh_portfolio(cx) - })), + v_flex() + .gap_0p5() + .child( + div() + .text_xl() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child(wallet_name), + ) + .child( + div() + .font_family(mono.clone()) + .text_xs() + .text_color(muted) + .child(account_pill), + ), ), - ), - ) - // Total: native ETH balance + status sub-line. - .child( - v_flex() - .gap_1() - .child( - h_flex() - .items_baseline() - .gap_2() - .child( - div() - .text_3xl() - .font_weight(FontWeight::BOLD) - .text_color(fg) - .child(native_str), - ) - .child(div().text_lg().text_color(muted).child("ETH")), ) - .child(div().text_sm().text_color(status_color).child(status_line)), + .child( + Button::new("refresh") + .ghost() + .icon(IconName::Replace) + .on_click( + cx.listener(|this, _, _, cx| this.refresh_portfolio(cx)), + ), + ), ) + // Balance hero: native ETH balance (mono-for-money, dimmed + // decimals + ticker, weight 600 not 700). + .child(v_flex().gap_1().child( + div().text_3xl().font_weight(FontWeight::SEMIBOLD).map( + |el| match native_wei { + Some(wei) => el.child(money( + wei, + 18, + 4, + Some("ETH"), + mono.clone(), + fg, + muted, + )), + None => el.font_family(mono.clone()).text_color(muted).child("—"), + }, + ), + )) // Primary actions. Send + Swap are gated to the next release (Chunk 4, // testnet-first), so they're shown disabled rather than inert-but-active. .child( @@ -203,7 +202,7 @@ impl Shell { .gap_2() .child(Button::new("send").primary().label("Send").disabled(true)) .child(Button::new("receive").ghost().label("Receive").on_click( - cx.listener(|this, _, _, cx| this.navigate(Route::Receive, cx)), + cx.listener(|this, _, _, cx| this.open(Surface::Receive, cx)), )) .child(Button::new("swap").ghost().label("Swap").disabled(true)), ) @@ -233,11 +232,12 @@ impl Shell { &self, first_sync: bool, has_tokens: bool, - holdings: Vec<(String, String, String, String)>, + holdings: Vec, cx: &mut Context, ) -> impl IntoElement { let theme = cx.theme(); let muted = theme.muted_foreground; + let mono: SharedString = theme.mono_font_family.clone(); let mut col = v_flex().w_full().gap_2(); @@ -279,17 +279,20 @@ impl Shell { .into_any_element(); } - for (mark, name, symbol, amount) in holdings { + for h in holdings { col = col.child(render_row( theme.foreground, theme.muted_foreground, theme.border, theme.secondary, theme.muted, - mark, - name, - symbol, - amount, + h.mark, + h.name, + h.symbol, + h.raw, + h.decimals, + h.max_frac, + mono.clone(), )); } if !has_tokens { @@ -311,6 +314,179 @@ impl Shell { } col.into_any_element() } + + /// Project home — the aggregate-of-one for the demo's single project: the + /// wallet's balance plus a one-line composition (1 wallet · 1 agent). Real + /// multi-wallet aggregation is fast-follow. + pub fn render_project_home(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let mono: SharedString = theme.mono_font_family.clone(); + let id_square = theme::identity_square(theme.is_dark()); + + let native_wei = self.portfolio.as_ref().map(|p| p.native_wei); + + div() + .size_full() + .p_8() + // TODO(scroll): restore a scrollable main pane via a Stateful + // `div().id(..).overflow_y_scroll()` (the agent draft mis-ordered + // gpui-component's `overflow_y_scrollbar`). Content is short for now. + .child( + v_flex() + .items_start() + .max_w(px(680.0)) + .gap_6() + .child( + h_flex() + .items_center() + .gap_3() + .child(div().size(px(28.0)).rounded(px(6.0)).bg(id_square)) + .child( + div() + .text_xl() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child("Personal"), + ), + ) + .child( + div().text_3xl().font_weight(FontWeight::SEMIBOLD).map( + |el| match native_wei { + Some(wei) => el.child(money( + wei, + 18, + 4, + Some("ETH"), + mono.clone(), + fg, + muted, + )), + None => el.font_family(mono.clone()).text_color(muted).child("—"), + }, + ), + ) + .child( + div() + .text_sm() + .text_color(muted) + .child("1 wallet · 1 agent"), + ), + ) + } + + /// Agent home — a static, demo-scoped policy-card placeholder (DESIGN §Policy + /// card): 2-column label/value pairs grouped by whitespace in one faint frame, + /// no interior grid lines. Agent "Atlas" is the openly-narrated manual stand-in + /// for v1 (real Claude-Desktop-via-MCP is fast-follow), so the values are + /// static. The agent identity (cyan) lives only on the squircle glyph. + pub fn render_agent_home(&self, 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: SharedString = theme.mono_font_family.clone(); + let is_dark = theme.is_dark(); + let agent = theme::agent(is_dark); + let agent_tint = theme::agent_tint(is_dark); + + // One policy row: label left (muted), value right (mono, primary). No + // per-row hairline — grouping is whitespace. + let mono_for_row = mono.clone(); + let policy_row = move |label: &'static str, value: &'static str| { + h_flex() + .w_full() + .justify_between() + .items_center() + .py_1p5() + .child(div().text_sm().text_color(muted).child(label)) + .child( + div() + .font_family(mono_for_row.clone()) + .text_sm() + .text_color(fg) + .child(value), + ) + }; + + div() + .size_full() + .p_8() + // TODO(scroll): restore a scrollable main pane via a Stateful + // `div().id(..).overflow_y_scroll()` (the agent draft mis-ordered + // gpui-component's `overflow_y_scrollbar`). Content is short for now. + .child( + v_flex() + .items_start() + .max_w(px(680.0)) + .gap_6() + // Header: cyan squircle monogram (the ONLY cyan on the surface) + + // agent name H1 (text.primary, NEVER cyan). + .child( + h_flex() + .items_center() + .gap_3() + .child( + div() + .size(px(28.0)) + .rounded(px(6.0)) + .bg(agent_tint) + .border_1() + .border_color(agent) + .flex() + .items_center() + .justify_center() + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(agent) + .child("A"), + ), + ) + .child( + v_flex() + .gap_0p5() + .child( + div() + .text_xl() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child("Atlas"), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child("Delegated agent · idle"), + ), + ), + ) + // Policy card: one faint frame, no interior grid lines. + .child( + v_flex() + .w_full() + .gap_0() + .p_4() + .rounded_lg() + .border_1() + .border_color(border) + .bg(surface) + .child(policy_row("Per-transaction cap", "0.10 ETH")) + .child(policy_row("Period budget", "1.00 ETH / week")) + .child(policy_row("Allowed assets", "ETH")) + .child(policy_row("Session key", "expires in 6d")) + .child(policy_row("Autonomy", "act < $50 · ask above")), + ) + .child( + div().text_xs().text_color(muted).child( + "Atlas is a manual stand-in for the demo. Controls land with MCP.", + ), + ), + ) + } } /// A shimmer-free skeleton placeholder row for the first-sync state. @@ -341,12 +517,15 @@ fn render_row( fg: gpui::Hsla, muted: gpui::Hsla, border: gpui::Hsla, - surface: gpui::Hsla, + _surface: gpui::Hsla, mark_bg: gpui::Hsla, mark: String, name: String, symbol: String, - amount: String, + raw: U256, + decimals: u8, + max_frac: usize, + mono: SharedString, ) -> impl IntoElement { h_flex() .w_full() @@ -354,10 +533,10 @@ fn render_row( .justify_between() .px_4() .py_3() - .rounded_lg() - .border_1() + // DESIGN §Holdings table: tight rows, hairline row separators only — + // no per-row card frame, no fill. + .border_b_1() .border_color(border) - .bg(surface) .child( h_flex() .items_center() @@ -365,7 +544,9 @@ fn render_row( .child( div() .size(px(34.0)) - .rounded_full() + // DESIGN §Radii: a desaturated token SQUARE (6px), not a + // round identicon (round is reserved for the human principal). + .rounded(px(6.0)) .bg(mark_bg) .flex() .items_center() @@ -394,8 +575,6 @@ fn render_row( .child( div() .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(fg) - .child(amount), + .child(money(raw, decimals, max_frac, None, mono, fg, muted)), ) } diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index 98ef372..05bf2f5 100644 --- a/crates/deckard-contract/src/lib.rs +++ b/crates/deckard-contract/src/lib.rs @@ -28,6 +28,7 @@ pub mod mock; pub mod policy; pub mod read_status; pub mod rpc; +pub mod shield_status; pub mod signer; pub use decision::{Decision, RequestId}; @@ -38,6 +39,7 @@ pub use read_status::ReadStatus; pub use rpc::{ ApprovalStatus, BalanceReport, ExecuteResult, SignerRequest, SignerResponse, UnlockOutcome, }; +pub use shield_status::ShieldStatus; pub use signer::Signer; #[cfg(test)] @@ -248,4 +250,61 @@ mod roundtrip_tests { reason: "verification disabled".into(), }); } + + #[test] + fn shield_status_roundtrip() { + // Every variant of the shield lifecycle must survive both wire encodings, + // including the owned-String failure reason and the U256 spendable amount. + roundtrip(&ShieldStatus::Sending); + roundtrip(&ShieldStatus::ConfirmingOnChain { + tx_hash: B256::repeat_byte(0xCD), + confirmed: 2, + target: 6, + }); + roundtrip(&ShieldStatus::SyncingPrivate { + tx_hash: B256::repeat_byte(0xEF), + }); + roundtrip(&ShieldStatus::PrivateSpendable { + shielded_wei: U256::from(997_500_u64), + }); + roundtrip(&ShieldStatus::Failed { + reason: "reverted".into(), + }); + } + + #[test] + fn shield_status_glyph_and_terminality() { + // Glyph + lifecycle predicates: in-flight states share the pending glyph and + // are non-terminal; the two terminal states report themselves as such. + assert_eq!(ShieldStatus::Sending.glyph(), "clock-ring"); + assert_eq!( + ShieldStatus::PrivateSpendable { + shielded_wei: U256::from(1_u64), + } + .glyph(), + "check-filled" + ); + assert_eq!( + ShieldStatus::Failed { reason: "x".into() }.glyph(), + "x-ring" + ); + + assert!(!ShieldStatus::Sending.is_terminal()); + assert!(!ShieldStatus::SyncingPrivate { + tx_hash: B256::ZERO, + } + .is_terminal()); + + let spendable = ShieldStatus::PrivateSpendable { + shielded_wei: U256::from(5_u64), + }; + assert!(spendable.is_spendable()); + assert!(spendable.is_terminal()); + + let failed = ShieldStatus::Failed { + reason: "sync_failed".into(), + }; + assert!(!failed.is_spendable()); + assert!(failed.is_terminal()); + } } diff --git a/crates/deckard-contract/src/shield_status.rs b/crates/deckard-contract/src/shield_status.rs new file mode 100644 index 0000000..b1d4e8b --- /dev/null +++ b/crates/deckard-contract/src/shield_status.rs @@ -0,0 +1,103 @@ +//! `ShieldStatus` — the lifecycle of a shield (public → private) deposit. +//! +//! A shield moves funds from a public Ethereum balance into a Railgun private note. +//! That is not a single instant: the tx must be broadcast, included + confirmed on +//! chain, and then the Railgun UTXO sync must catch up before the private note is +//! visible and spendable. This enum is the **spec-complete map of those steps**, plus +//! the per-state reassurance copy ("where's my money?") and a status-glyph hook the UI +//! renders. Wave 2 drives the transitions; v1 builds the minimal path +//! (`Sending` → `ConfirmingOnChain` → `PrivateSpendable`) and leaves the rest as +//! specced-but-stubbed variants. +//! +//! ## Portability +//! +//! Like every other wire type in this crate, `ShieldStatus` carries the standard +//! `serde` derives so it round-trips byte-stably across JSON (the MCP surface) and +//! CBOR (the daemon UDS), and it leans only on `core::fmt` + `alloc`-available types +//! (`String`) so a future `#![no_std]` flip would be mechanical. The glyph hook +//! returns a plain `&'static str` *semantic* token (never a GPUI `IconName`) so the +//! key-less contract crate stays free of any UI dependency; the app maps the token to +//! the circular status glyph defined in `DESIGN.md`. + +use core::fmt; + +use alloy_primitives::{B256, U256}; +use serde::{Deserialize, Serialize}; + +/// The lifecycle of a shield deposit, from broadcast to spendable private balance. +/// +/// The happy path is `Sending` → `ConfirmingOnChain` → `SyncingPrivate` → +/// `PrivateSpendable`; any step may instead terminate in `Failed`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShieldStatus { + /// Tx signed and broadcast; awaiting first inclusion in a block. + Sending, + /// Included on chain; waiting for `confirmed`/`target` confirmations before the + /// note is sync-visible. + ConfirmingOnChain { + tx_hash: B256, + confirmed: u32, + target: u32, + }, + /// Confirmed; the Railgun UTXO sync is catching up so the private note appears. + SyncingPrivate { tx_hash: B256 }, + /// The note is synced and spendable. `shielded_wei` is the private balance now + /// available (net of the on-chain Railgun fee). + PrivateSpendable { shielded_wei: U256 }, + /// Terminal failure at any step. `reason` is a short, non-secret tag + /// (e.g. `broadcast_failed`, `reverted`, `sync_failed`). + Failed { reason: String }, +} + +impl ShieldStatus { + /// A short, stable semantic token for the circular status glyph the UI renders + /// (see `DESIGN.md`: filled check = confirmed/done, amber clock ring = pending, + /// error x-ring = failed). The app maps this token to its icon kit; the contract + /// crate stays UI-free. The strings are stable wire-adjacent identifiers. + pub fn glyph(&self) -> &'static str { + match self { + // In-flight: the amber clock-ring "pending" glyph. + ShieldStatus::Sending + | ShieldStatus::ConfirmingOnChain { .. } + | ShieldStatus::SyncingPrivate { .. } => "clock-ring", + // Done: the filled-check "confirmed" glyph. + ShieldStatus::PrivateSpendable { .. } => "check-filled", + // Terminal failure: the error x-ring glyph. + ShieldStatus::Failed { .. } => "x-ring", + } + } + + /// True once the shielded note is synced and spendable — the only terminal-success + /// state. + pub fn is_spendable(&self) -> bool { + matches!(self, ShieldStatus::PrivateSpendable { .. }) + } + + /// True for any terminal state (spendable or failed) — nothing more will transition. + pub fn is_terminal(&self) -> bool { + matches!( + self, + ShieldStatus::PrivateSpendable { .. } | ShieldStatus::Failed { .. } + ) + } +} + +impl fmt::Display for ShieldStatus { + /// The "where's my money?" reassurance line shown in the status strip. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ShieldStatus::Sending => write!(f, "Broadcasting your deposit…"), + ShieldStatus::ConfirmingOnChain { + confirmed, target, .. + } => write!( + f, + "On-chain. Waiting for {confirmed}/{target} confirmations — your funds are safe." + ), + ShieldStatus::SyncingPrivate { .. } => { + write!(f, "Confirmed. Syncing your private balance…") + } + ShieldStatus::PrivateSpendable { .. } => write!(f, "Private. Spendable now."), + ShieldStatus::Failed { reason } => write!(f, "Shield failed ({reason})."), + } + } +} diff --git a/crates/deckard-signerd/src/client.rs b/crates/deckard-signerd/src/client.rs index 62cbc6e..4c270ff 100644 --- a/crates/deckard-signerd/src/client.rs +++ b/crates/deckard-signerd/src/client.rs @@ -11,7 +11,8 @@ use std::time::{Duration, Instant}; use tokio::net::UnixStream; use deckard_contract::{ - Decision, ExecuteResult, Intent, RequestId, SignerRequest, SignerResponse, UnlockOutcome, + ApprovalStatus, Decision, ExecuteResult, Intent, RequestId, SignerRequest, SignerResponse, + UnlockOutcome, }; use crate::frame; @@ -112,6 +113,45 @@ impl SignerClient { } } + /// Blocking [`propose`](Self::propose) — policy check, no signing, for callers + /// without a tokio runtime (the app's GUI background thread). + pub fn propose_blocking(&self, intent: &Intent) -> anyhow::Result { + match self.request_blocking(&SignerRequest::Propose { + intent: intent.clone(), + })? { + SignerResponse::Decision(d) => Ok(d), + other => Err(unexpected("Propose", other)), + } + } + + /// Blocking [`execute`](Self::execute) — sign + broadcast (or denial). + pub fn execute_blocking(&self, request_id: RequestId) -> anyhow::Result { + match self.request_blocking(&SignerRequest::Execute { request_id })? { + SignerResponse::Execute(r) => Ok(r), + other => Err(unexpected("Execute", other)), + } + } + + /// Blocking resolve: close a `NeedsApproval` loop by flipping its `Pending` record + /// to `Allowed` (`approved: true`) or `Denied` (`approved: false`). + pub fn resolve_blocking(&self, request_id: RequestId, approved: bool) -> anyhow::Result<()> { + match self.request_blocking(&SignerRequest::Resolve { + request_id, + approved, + })? { + SignerResponse::Ack => Ok(()), + other => Err(unexpected("Resolve", other)), + } + } + + /// Blocking poll of an approval loop → its current [`ApprovalStatus`]. + pub fn status_blocking(&self, request_id: RequestId) -> anyhow::Result { + match self.request_blocking(&SignerRequest::Status { request_id })? { + SignerResponse::Status(s) => Ok(s), + other => Err(unexpected("Status", other)), + } + } + /// Propose an intent → a `Decision`. Note: the returned `request_id` for an `Allow` is /// derivable locally via [`request_id_for_intent`](Self::request_id_for_intent). pub async fn propose(&self, intent: &Intent) -> anyhow::Result { From 039b7b6677a4eab0fbf345f2107e6dfb7ef5a0ab Mon Sep 17 00:00:00 2001 From: hellno Date: Tue, 9 Jun 2026 13:00:05 +0200 Subject: [PATCH 2/7] =?UTF-8?q?feat(app):=20T4=20=E2=80=94=20agent=20breat?= =?UTF-8?q?hing=20pulse=20+=20full=20privacy=20mask?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent visuals: - shared agent_squircle() with the one sanctioned ambient motion: a 1.2s pulsating_between(0.35,1.0) breathing of the cyan keyline, only while acting - Shell.agent_acting demo toggle (palette row + agent-home button); sidebar status dot goes cyan while acting Full privacy mask (persisted-once-on, inverse of seed reveal): - money.rs: MASK_BULLETS (fixed 6, magnitude-safe) + mask_money(); masked param threaded through money()/usd() and every money surface (wallet/project hero, holdings, sidebar balance) - Splits-style allocation_bar() (tonal, >=3px segments, legend) that flattens to one neutral bar when masked; single Public segment in v1 - triggers: click-the-Total, eye/eye-off breadcrumb glyph, palette row, ToggleMask action + secondary-shift-m, Settings Privacy toggle - Settings.mask_balances + capture_block (serde default false) macOS capture-block (opt-in, default OFF): - capture.rs apply_capture_block() via NSApplication.windows + setSharingType, gated #[cfg(all(target_os="macos", feature="tray"))] to reuse the tray objc2 dep (no manifest churn); inert no-op twin elsewhere; re-applied from render keyed off capture_block && mask DoD green: fmt --check, clippy (default + --features tray), cargo test --workspace all pass; no new deps. --- crates/deckard-app/src/capture.rs | 48 +++++ crates/deckard-app/src/main.rs | 5 +- crates/deckard-app/src/money.rs | 44 +++- crates/deckard-app/src/palette.rs | 30 +++ crates/deckard-app/src/settings.rs | 10 + crates/deckard-app/src/settings_view.rs | 30 +++ crates/deckard-app/src/shell.rs | 63 +++++- crates/deckard-app/src/shell_chrome.rs | 105 +++++++--- crates/deckard-app/src/welcome.rs | 261 ++++++++++++++++++------ 9 files changed, 504 insertions(+), 92 deletions(-) create mode 100644 crates/deckard-app/src/capture.rs diff --git a/crates/deckard-app/src/capture.rs b/crates/deckard-app/src/capture.rs new file mode 100644 index 0000000..a35cfc6 --- /dev/null +++ b/crates/deckard-app/src/capture.rs @@ -0,0 +1,48 @@ +//! macOS screen-capture block — `NSWindow.sharingType = .none` (DESIGN §Trust, +//! deckard-demo-ux-locked). Opt-in and **default OFF**: tied to the privacy mask, +//! it removes the app's windows from screen recordings / screenshots so a masked +//! balance can't be captured anyway. For a demo *recording* you leave it off (or the +//! recording itself goes blank), which is exactly why the default is OFF. +//! +//! ## Why this reuses the tray feature's objc2 dep (no new dependency) +//! +//! The native call lives behind `#[cfg(all(target_os = "macos", feature = "tray"))]` +//! so it compiles against the SAME `objc2` / `objc2-app-kit` crates the tray icon +//! already pulls — no manifest churn, no `raw-window-handle`. We reach the window +//! through `NSApplication.windows` (the app owns exactly one window) rather than +//! bridging GPUI's `Window` to a raw handle, mirroring `tray.rs`'s +//! `NSApplication::sharedApplication` activation-policy call. Every other build +//! (no `tray`, or non-macOS) gets the inert no-op twin below. + +/// Apply (or clear) the capture block to all of the app's native windows. +/// +/// `on == true` → `NSWindowSharingType::None` (content cannot be captured by other +/// processes); `on == false` → `NSWindowSharingType::ReadOnly` (the system default, +/// capturable). Must run on the main thread — the caller invokes it from `render`, +/// which is already on GPUI's main UI thread. +#[cfg(all(target_os = "macos", feature = "tray"))] +pub fn apply_capture_block(on: bool) { + use objc2::MainThreadMarker; + use objc2_app_kit::{NSApplication, NSWindowSharingType}; + + // Off the main thread we have no AppKit access; bail rather than risk UB. (render + // always runs on the main thread, so this is just a guard, never hit in practice.) + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let app = NSApplication::sharedApplication(mtm); + let sharing = if on { + NSWindowSharingType::None + } else { + NSWindowSharingType::ReadOnly + }; + for window in app.windows().iter() { + window.setSharingType(sharing); + } +} + +/// No-op on every build without the macOS `tray` feature (Linux/Windows have no +/// `NSWindow`; a non-`tray` macOS build doesn't link AppKit). The setting still +/// persists and the toggle still renders — it just has no OS effect here. +#[cfg(not(all(target_os = "macos", feature = "tray")))] +pub fn apply_capture_block(_on: bool) {} diff --git a/crates/deckard-app/src/main.rs b/crates/deckard-app/src/main.rs index d95e68c..318835b 100644 --- a/crates/deckard-app/src/main.rs +++ b/crates/deckard-app/src/main.rs @@ -7,6 +7,7 @@ //! Fork checklist: rename the crate in `Cargo.toml`, change `APP_NAME` and the //! bundle identifier, swap `assets/icon.png`, then start editing the views. +mod capture; mod money; mod onboarding; mod palette; @@ -45,7 +46,8 @@ gpui::actions!( ToggleTheme, NewItem, GoBack, - TogglePalette + TogglePalette, + ToggleMask ] ); @@ -97,6 +99,7 @@ fn main() { KeyBinding::new("secondary-shift-d", ToggleTheme, None), KeyBinding::new("secondary-[", GoBack, None), KeyBinding::new("secondary-k", TogglePalette, None), + KeyBinding::new("secondary-shift-m", ToggleMask, None), ]); // 4. Global action handlers. View-local actions (NewItem, OpenSettings, diff --git a/crates/deckard-app/src/money.rs b/crates/deckard-app/src/money.rs index 12d5740..b64f34b 100644 --- a/crates/deckard-app/src/money.rs +++ b/crates/deckard-app/src/money.rs @@ -16,18 +16,47 @@ use gpui_component::h_flex; use deckard_core::U256; +/// The fixed-length privacy mask: **always six bullets**, never `real.len()`, so a +/// masked figure leaks neither its value nor its digit count (the magnitude-safe rule, +/// per MetaMask's `SensitiveText`). One glyph for every money surface. +pub const MASK_BULLETS: &str = "••••••"; + +/// String-level mask for callers that render a plain balance string rather than the +/// `money()` spans (e.g. the sidebar wallet balance). Fixed six bullets when masked. +pub fn mask_money(masked: bool, real: &str) -> String { + if masked { + MASK_BULLETS.to_string() + } else { + real.to_string() + } +} + /// Render an asset amount as mono spans: integer in `primary`, decimals + ticker /// dimmed to `dim` by color only. `unit` is the trailing ticker (e.g. `"ETH"`), -/// or `None` for a bare number. `mono` is `cx.theme().mono_font_family`. +/// or `None` for a bare number. `mono` is `cx.theme().mono_font_family`. When +/// `masked`, renders the fixed [`MASK_BULLETS`] in `dim` instead (no value, no unit). +#[allow(clippy::too_many_arguments)] pub fn money( raw: U256, decimals: u8, max_frac: usize, unit: Option<&str>, + masked: bool, mono: SharedString, primary: Hsla, dim: Hsla, ) -> impl IntoElement { + if masked { + // Magnitude-safe: a single dimmed bullet span, no decimals, no ticker. + return spans( + MASK_BULLETS.to_string(), + String::new(), + None, + mono, + dim, + dim, + ); + } let s = deckard_core::format_amount(raw, decimals, max_frac); let (int_part, frac) = split_amount(&s); spans(int_part, frac, unit, mono, primary, dim) @@ -37,15 +66,26 @@ pub fn money( /// decimals dimmed to `dim`. Zero renders `"$0"` (no `.00`, no `$0.0k`). // reason: consumed by the Wave-2 shielded-balance / fiat view (Total + Private/ // Public lines carry `$`); kept now as the companion to `money`. -#[allow(dead_code)] +#[allow(dead_code, clippy::too_many_arguments)] pub fn usd( raw: U256, decimals: u8, max_frac: usize, + masked: bool, mono: SharedString, primary: Hsla, dim: Hsla, ) -> impl IntoElement { + if masked { + return spans( + MASK_BULLETS.to_string(), + String::new(), + None, + mono, + dim, + dim, + ); + } let s = deckard_core::format_amount(raw, decimals, max_frac); let (int_part, frac) = split_amount(&s); spans(format!("${int_part}"), frac, None, mono, primary, dim) diff --git a/crates/deckard-app/src/palette.rs b/crates/deckard-app/src/palette.rs index 43ddf98..6b16297 100644 --- a/crates/deckard-app/src/palette.rs +++ b/crates/deckard-app/src/palette.rs @@ -98,6 +98,36 @@ impl Shell { }, )), ) + .child( + row( + "cmd-mask", + if self.mask { + "Show balances" + } else { + "Mask balances" + }, + "⌘⇧M", + ) + .on_click(cx.listener(|this, _, _, cx| { + this.palette_open = false; + this.toggle_mask(cx); + })), + ) + .child( + row( + "cmd-agent-acting", + if self.agent_acting { + "Stop agent activity (demo)" + } else { + "Simulate agent activity (demo)" + }, + "", + ) + .on_click(cx.listener(|this, _, _, cx| { + this.palette_open = false; + this.toggle_agent_acting(cx); + })), + ) .child(row("cmd-lock", "Lock wallet", "").on_click(cx.listener( |this, _, _, cx| { this.palette_open = false; diff --git a/crates/deckard-app/src/settings.rs b/crates/deckard-app/src/settings.rs index c078f02..4c56c67 100644 --- a/crates/deckard-app/src/settings.rs +++ b/crates/deckard-app/src/settings.rs @@ -51,6 +51,14 @@ pub struct Settings { /// A read-only address or ENS name to view instead of the active wallet. Empty = /// show the wallet. Lets an operator watch any address (e.g. `vitalik.eth`). pub watch_address: String, + /// Privacy mask: replace every money figure with fixed-length bullets. Unlike the + /// seed reveal (momentary, default-hidden), the mask is **persisted-once-on** — a + /// stated preference that survives relaunch. Default OFF. + pub mask_balances: bool, + /// macOS screen-capture block (NSWindow sharingType = none) tied to the mask. Opt-in, + /// **default OFF** — for a demo recording it stays off, or the capture itself is + /// blocked. Only takes effect in a `--features tray` macOS build (reuses that dep). + pub capture_block: bool, } impl Default for Settings { @@ -61,6 +69,8 @@ impl Default for Settings { launch_minimized: false, rpc_url: String::new(), watch_address: String::new(), + mask_balances: false, + capture_block: false, } } } diff --git a/crates/deckard-app/src/settings_view.rs b/crates/deckard-app/src/settings_view.rs index 71ac4d6..4f87c5f 100644 --- a/crates/deckard-app/src/settings_view.rs +++ b/crates/deckard-app/src/settings_view.rs @@ -93,12 +93,42 @@ impl Shell { })) .into_any_element(); + // Privacy: the mask is security-relevant, so its switch is allowed to read amber + // (DESIGN §Toggle). It mirrors `self.mask` (the live, persisted state). + let mask_control = Switch::new("mask-balances") + .checked(self.mask) + .on_click(cx.listener(|this, checked: &bool, _, cx| this.set_mask(*checked, cx))) + .into_any_element(); + let capture_control = Switch::new("capture-block") + .checked(self.settings.capture_block) + .on_click(cx.listener(|this, checked: &bool, _, cx| { + this.settings.capture_block = *checked; + this.settings.save(); + cx.notify(); + })) + .into_any_element(); + v_flex().flex_1().items_center().p_8().child( v_flex() .w(px(540.0)) .gap_6() .child(section_label("Appearance", muted)) .child(card().child(row("Theme", "Light or dark interface", theme_control))) + .child(section_label("Privacy", muted)) + .child( + card() + .child(row( + "Mask balances", + "Hide every balance behind fixed bullets — persists until you turn it off (⌘⇧M, or click the Total)", + mask_control, + )) + .child(divider(border)) + .child(row( + "Block screen capture", + "While masked, remove Deckard's windows from screen recordings (macOS, tray build) — off for demos", + capture_control, + )), + ) .child(section_label("Network", muted)) .child( card() diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index e477d97..9519828 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -23,7 +23,7 @@ use crate::settings::{Settings, ThemeModePref}; use crate::signer::{self, AppSigner}; use crate::theme; use crate::wallet; -use crate::{GoBack, NewItem, OpenSettings, TogglePalette, ToggleTheme, APP_NAME}; +use crate::{GoBack, NewItem, OpenSettings, ToggleMask, TogglePalette, ToggleTheme, APP_NAME}; /// The chain the supervised daemon signs for. v1 is mainnet-first (the default RPC is /// mainnet); multi-chain app config that re-points both the reader and the daemon is a @@ -103,6 +103,17 @@ pub struct Shell { pub watch_input: Entity, pub created: usize, pub palette_open: bool, + /// Privacy mask: when true, every money surface renders fixed bullets instead of a + /// figure (DESIGN §Trust). Initialised from `Settings.mask_balances` and persisted on + /// every toggle — the inverse of the seed reveal's momentary, default-hidden model. + pub mask: bool, + /// Demo stand-in for "Atlas is currently acting": drives the one sanctioned ambient + /// motion (the ~1.2s breathing pulse on the agent squircle). Not persisted — it's a + /// narrated demo toggle, since the real MCP agent is a fast-follow. + pub agent_acting: bool, + /// The capture-block state last pushed to the OS, so `render` only re-issues the + /// native `setSharingType` call when `capture_block && mask` actually changes. + capture_applied: bool, // --- auth / keystore (Chunk 3) --- pub auth: AuthStep, @@ -287,6 +298,9 @@ impl Shell { // same RPC the app reads from. let signer = AppSigner::launch(current_rpc.clone(), DAEMON_CHAIN_ID); + // The mask is a persisted preference (default off); seed it from settings. + let mask = settings.mask_balances; + Self { focus_handle, selection: Selection::Wallet, @@ -297,6 +311,9 @@ impl Shell { watch_input, created: 0, palette_open: false, + mask, + agent_acting: false, + capture_applied: false, auth, auth_error: None, auth_busy: false, @@ -833,6 +850,30 @@ impl Shell { cx.notify(); } + /// Set the privacy mask to an explicit value (the Settings switch), persisting it. + pub fn set_mask(&mut self, masked: bool, cx: &mut Context) { + if self.mask == masked { + return; + } + self.mask = masked; + self.settings.mask_balances = masked; + self.settings.save(); + cx.notify(); + } + + /// Toggle the privacy mask (the ⌘⇧M action, the eye glyph, the click-the-Total + /// gesture, and the palette row all route here). Persists the new state. + pub fn toggle_mask(&mut self, cx: &mut Context) { + self.set_mask(!self.mask, cx); + } + + /// Flip the demo "agent currently acting" state (the breathing-pulse driver). Not + /// persisted — Atlas is an openly-narrated manual stand-in for v1. + pub fn toggle_agent_acting(&mut self, cx: &mut Context) { + self.agent_acting = !self.agent_acting; + 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()); @@ -880,6 +921,21 @@ impl Shell { cx.notify(); } + fn on_toggle_mask(&mut self, _: &ToggleMask, _: &mut Window, cx: &mut Context) { + self.toggle_mask(cx); + } + + /// Push the capture-block state to the OS when `capture_block && mask` changes. + /// Called once per `render`; the change-guard makes it a no-op on most frames. On a + /// non-macOS or non-`tray` build `apply_capture_block` is itself an inert no-op. + fn sync_capture_block(&mut self) { + let desired = self.settings.capture_block && self.mask; + if desired != self.capture_applied { + crate::capture::apply_capture_block(desired); + self.capture_applied = desired; + } + } + /// A bare macOS title bar: just the traffic-light inset + the app name. Its old /// settings/theme controls now live in the breadcrumb (`shell_chrome.rs`). fn render_title_bar(&self, cx: &mut Context) -> impl IntoElement { @@ -904,6 +960,10 @@ impl Render for Shell { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let background = cx.theme().background; + // Keep the OS capture-block in sync with `capture_block && mask` (no-op unless it + // changed, and a no-op entirely off a macOS `--features tray` build). + self.sync_capture_block(); + let body = if self.auth == AuthStep::Ready { // The unlocked app: macOS title bar above the two-pane shell grid // (sidebar | [breadcrumb / content / status strip]) + command palette. @@ -958,6 +1018,7 @@ impl Render for Shell { .on_action(cx.listener(Self::on_go_back)) .on_action(cx.listener(Self::on_toggle_theme)) .on_action(cx.listener(Self::on_toggle_palette)) + .on_action(cx.listener(Self::on_toggle_mask)) .child(body) } } diff --git a/crates/deckard-app/src/shell_chrome.rs b/crates/deckard-app/src/shell_chrome.rs index cbde72f..b0c81b7 100644 --- a/crates/deckard-app/src/shell_chrome.rs +++ b/crates/deckard-app/src/shell_chrome.rs @@ -9,16 +9,19 @@ //! is reserved for Receive's keyline + focus rings; cyan appears ONLY on the agent //! squircle glyph. +use std::time::Duration; + use gpui::prelude::FluentBuilder; use gpui::{ - div, px, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, - StatefulInteractiveElement, Styled, + div, pulsating_between, px, Animation, AnimationExt, AnyElement, Context, FontWeight, Hsla, + InteractiveElement, IntoElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, }; use gpui_component::{ button::{Button, ButtonVariants}, h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, }; +use crate::money::mask_money; use crate::settings::ThemeModePref; use crate::shell::{Selection, Shell, Surface}; use crate::theme; @@ -32,6 +35,50 @@ fn short_addr(a: &str) -> String { } } +/// The agent's cyan squircle monogram — the ONE cyan surface (DESIGN §Actor model): a +/// rounded square (NEVER `rounded_full`) with the "A" monogram. When `acting`, its cyan +/// keyline breathes on the sanctioned ~1.2s pulse — the single ambient motion in the +/// whole app, shown only while the agent is mid-action (everywhere else renders +/// instantly). Shared by the sidebar row and the agent-home header. `id` must be unique +/// per live instance so GPUI keys the animation state correctly. +pub(crate) fn agent_squircle( + size: Pixels, + radius: Pixels, + acting: bool, + agent: Hsla, + agent_tint: Hsla, + id: &'static str, +) -> AnyElement { + let base = div() + .size(size) + .rounded(radius) + .bg(agent_tint) + .border_1() + .border_color(agent) + .flex() + .items_center() + .justify_center() + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(agent) + .child("A"), + ); + if acting { + base.with_animation( + id, + Animation::new(Duration::from_millis(1200)) + .repeat() + .with_easing(pulsating_between(0.35, 1.0)), + move |el, delta| el.border_color(agent.alpha(delta)), + ) + .into_any_element() + } else { + base.into_any_element() + } +} + impl Shell { /// The current view's human label, for the breadcrumb's trailing segment. fn view_label(&self) -> &'static str { @@ -71,7 +118,7 @@ impl Shell { let balance = self .portfolio .as_ref() - .map(|p| deckard_core::format_amount(p.native_wei, 18, 4)) + .map(|p| mask_money(self.mask, &deckard_core::format_amount(p.native_wei, 18, 4))) .unwrap_or_else(|| "—".to_string()); // A tiny uppercase section label (10px, +letterspacing per DESIGN typography). @@ -169,30 +216,22 @@ impl Shell { .w_full() .items_center() .gap_2() - // The cyan squircle monogram — the ONE cyan surface, a - // rounded square (~6px), NEVER rounded_full. - .child( - div() - .size(px(16.0)) - .rounded(px(5.0)) - .bg(agent_tint) - .border_1() - .border_color(agent) - .flex() - .items_center() - .justify_center() - .child( - div() - .text_color(agent) - .child("A") - .text_xs() - .font_weight(FontWeight::SEMIBOLD), - ), - ) + // The cyan squircle monogram — breathes when Atlas is acting. + .child(agent_squircle( + px(16.0), + px(5.0), + self.agent_acting, + agent, + agent_tint, + "agent-pulse-sidebar", + )) .child(div().flex_1().text_sm().text_color(fg).child("Atlas")) - // Idle status dot (neutral; the agent's "currently acting" - // pulse is a fast-follow). - .child(div().size(px(6.0)).rounded_full().bg(muted)), + // Status dot: cyan while acting, neutral when idle. + .child(div().size(px(6.0)).rounded_full().bg(if self.agent_acting { + agent + } else { + muted + })), ) .on_click(cx.listener(|this, _, _, cx| this.select(Selection::Agent, cx))), ) @@ -235,6 +274,12 @@ impl Shell { } else { IconName::Moon }; + // The eye glyph reflects current state: slashed eye = balances hidden. + let mask_icon = if self.mask { + IconName::EyeOff + } else { + IconName::Eye + }; h_flex() .h(px(44.0)) @@ -278,6 +323,14 @@ impl Shell { cx.notify(); })), ) + .child( + // Eye glyph → toggle the privacy mask (⌘⇧M / click-the-Total / + // palette all route to the same `toggle_mask`). + Button::new("toggle-mask") + .ghost() + .icon(mask_icon) + .on_click(cx.listener(|this, _, _, cx| this.toggle_mask(cx))), + ) .child( Button::new("toggle-theme") .ghost() diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index 10f3b52..5b5dd9f 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -4,7 +4,10 @@ //! snapshot; the only loading state is the very first sync. use gpui::prelude::FluentBuilder; -use gpui::{div, px, Context, FontWeight, IntoElement, ParentElement, SharedString, Styled}; +use gpui::{ + div, px, relative, Context, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, + SharedString, StatefulInteractiveElement, Styled, +}; use gpui_component::{ button::{Button, ButtonVariants}, h_flex, v_flex, ActiveTheme, Disableable, IconName, @@ -14,6 +17,7 @@ use deckard_core::U256; use crate::money::money; use crate::shell::{Shell, Surface}; +use crate::shell_chrome::agent_squircle; use crate::theme; /// One row in the holdings table. Carries the raw balance (not a pre-formatted @@ -54,6 +58,7 @@ impl Shell { let border = theme.border; let surface = theme.secondary; let mono: SharedString = theme.mono_font_family.clone(); + let masked = self.mask; // A small bordered key-hint chip, e.g. ⌘K. let chip = move |keys: String, label: String| { @@ -176,24 +181,53 @@ impl Shell { ), ), ) - // Balance hero: native ETH balance (mono-for-money, dimmed - // decimals + ticker, weight 600 not 700). - .child(v_flex().gap_1().child( - div().text_3xl().font_weight(FontWeight::SEMIBOLD).map( - |el| match native_wei { - Some(wei) => el.child(money( - wei, - 18, - 4, - Some("ETH"), - mono.clone(), - fg, + // Balance hero: the click-to-hide Total (mono-for-money, dimmed + // decimals, weight 600) over a thin Splits-style allocation bar. + // Clicking the Total toggles the privacy mask (one of its triggers). + .child( + v_flex() + .w_full() + .gap_3() + .child( + div() + .id("balance-hero") + .cursor_pointer() + .text_3xl() + .font_weight(FontWeight::SEMIBOLD) + .map(|el| match native_wei { + Some(wei) => el.child(money( + wei, + 18, + 4, + Some("ETH"), + masked, + mono.clone(), + fg, + muted, + )), + None => el + .font_family(mono.clone()) + .text_color(muted) + .child("—"), + }) + .on_click(cx.listener(|this, _, _, cx| this.toggle_mask(cx))), + ) + .children(native_wei.map(|_| { + // v1: the whole balance is public; the bar is one segment + // (Wave 2 splits it into Private/Public). Flattens when masked. + allocation_bar( + vec![AllocSegment { + label: "Public".into(), + fraction: 1.0, + tone: id_square, + }], + masked, + border, muted, - )), - None => el.font_family(mono.clone()).text_color(muted).child("—"), - }, - ), - )) + fg, + ) + })), + ) // Primary actions. Send + Swap are gated to the next release (Chunk 4, // testnet-first), so they're shown disabled rather than inert-but-active. .child( @@ -238,6 +272,7 @@ impl Shell { let theme = cx.theme(); let muted = theme.muted_foreground; let mono: SharedString = theme.mono_font_family.clone(); + let masked = self.mask; let mut col = v_flex().w_full().gap_2(); @@ -292,6 +327,7 @@ impl Shell { h.raw, h.decimals, h.max_frac, + masked, mono.clone(), )); } @@ -322,8 +358,10 @@ impl Shell { let theme = cx.theme(); let fg = theme.foreground; let muted = theme.muted_foreground; + let border = theme.border; let mono: SharedString = theme.mono_font_family.clone(); let id_square = theme::identity_square(theme.is_dark()); + let masked = self.mask; let native_wei = self.portfolio.as_ref().map(|p| p.native_wei); @@ -352,20 +390,46 @@ impl Shell { ), ) .child( - div().text_3xl().font_weight(FontWeight::SEMIBOLD).map( - |el| match native_wei { - Some(wei) => el.child(money( - wei, - 18, - 4, - Some("ETH"), - mono.clone(), - fg, + v_flex() + .w_full() + .gap_3() + .child( + div() + .id("project-balance-hero") + .cursor_pointer() + .text_3xl() + .font_weight(FontWeight::SEMIBOLD) + .map(|el| match native_wei { + Some(wei) => el.child(money( + wei, + 18, + 4, + Some("ETH"), + masked, + mono.clone(), + fg, + muted, + )), + None => el + .font_family(mono.clone()) + .text_color(muted) + .child("—"), + }) + .on_click(cx.listener(|this, _, _, cx| this.toggle_mask(cx))), + ) + .children(native_wei.map(|_| { + allocation_bar( + vec![AllocSegment { + label: "Public".into(), + fraction: 1.0, + tone: id_square, + }], + masked, + border, muted, - )), - None => el.font_family(mono.clone()).text_color(muted).child("—"), - }, - ), + fg, + ) + })), ) .child( div() @@ -422,30 +486,20 @@ impl Shell { .items_start() .max_w(px(680.0)) .gap_6() - // Header: cyan squircle monogram (the ONLY cyan on the surface) + - // agent name H1 (text.primary, NEVER cyan). + // Header: cyan squircle monogram (the ONLY cyan on the surface, + // breathing while Atlas acts) + agent name H1 (text.primary, NEVER cyan). .child( h_flex() .items_center() .gap_3() - .child( - div() - .size(px(28.0)) - .rounded(px(6.0)) - .bg(agent_tint) - .border_1() - .border_color(agent) - .flex() - .items_center() - .justify_center() - .child( - div() - .text_xs() - .font_weight(FontWeight::SEMIBOLD) - .text_color(agent) - .child("A"), - ), - ) + .child(agent_squircle( + px(28.0), + px(6.0), + self.agent_acting, + agent, + agent_tint, + "agent-pulse-home", + )) .child( v_flex() .gap_0p5() @@ -456,12 +510,13 @@ impl Shell { .text_color(fg) .child("Atlas"), ) - .child( - div() - .text_xs() - .text_color(muted) - .child("Delegated agent · idle"), - ), + .child(div().text_xs().text_color(muted).child( + if self.agent_acting { + "Delegated agent · acting now" + } else { + "Delegated agent · idle" + }, + )), ), ) // Policy card: one faint frame, no interior grid lines. @@ -480,6 +535,18 @@ impl Shell { .child(policy_row("Session key", "expires in 6d")) .child(policy_row("Autonomy", "act < $50 · ask above")), ) + // Demo control: narrate Atlas "acting" to show the one ambient motion + // (the breathing squircle). Real activity arrives with the MCP agent. + .child( + Button::new("toggle-agent-acting") + .ghost() + .label(if self.agent_acting { + "Stop activity (demo)" + } else { + "Simulate activity (demo)" + }) + .on_click(cx.listener(|this, _, _, cx| this.toggle_agent_acting(cx))), + ) .child( div().text_xs().text_color(muted).child( "Atlas is a manual stand-in for the demo. Controls land with MCP.", @@ -489,6 +556,77 @@ impl Shell { } } +/// One segment of the [`allocation_bar`]: a label, its share of the whole (0..=1), +/// and a low-chroma tone. Wave 2 feeds it Private/Public; v1 passes a single Public. +struct AllocSegment { + label: SharedString, + fraction: f32, + tone: Hsla, +} + +/// A thin Splits-style allocation bar (DESIGN §Balance hero): low-chroma tonal +/// segments (never amber, rule 5) over a neutral track, each non-zero segment kept +/// ≥3px wide so it stays visible, with a small legend below. When `masked`, the +/// composition is itself private — the bar collapses to one flat neutral track with no +/// legend (part of what the privacy mask hides). +fn allocation_bar( + segments: Vec, + masked: bool, + track: Hsla, + muted: Hsla, + fg: Hsla, +) -> impl IntoElement { + // Masked → a single flat neutral bar, no segments, no legend. + if masked { + return div() + .w_full() + .h(px(8.0)) + .rounded(px(3.0)) + .bg(track) + .into_any_element(); + } + + // The tonal bar: each segment a fraction of the width, ≥3px, clipped to the rounding. + let mut bar = h_flex() + .w_full() + .h(px(8.0)) + .rounded(px(3.0)) + .bg(track) + .overflow_hidden(); + for seg in &segments { + let frac = seg.fraction.clamp(0.0, 1.0); + bar = bar.child( + div() + .h_full() + .flex_shrink_0() + .min_w(px(3.0)) + .w(relative(frac)) + .bg(seg.tone), + ); + } + + // The legend: a tone chip + label + percentage per segment. + let mut legend = h_flex().gap_4(); + for seg in &segments { + let pct = (seg.fraction.clamp(0.0, 1.0) * 100.0).round() as u32; + legend = legend.child( + h_flex() + .items_center() + .gap_1p5() + .child(div().size(px(8.0)).rounded(px(2.0)).bg(seg.tone)) + .child(div().text_xs().text_color(fg).child(seg.label.clone())) + .child(div().text_xs().text_color(muted).child(format!("{pct}%"))), + ); + } + + v_flex() + .w_full() + .gap_2() + .child(bar) + .child(legend) + .into_any_element() +} + /// A shimmer-free skeleton placeholder row for the first-sync state. fn skeleton_row(surface: gpui::Hsla, border: gpui::Hsla) -> impl IntoElement { h_flex() @@ -525,6 +663,7 @@ fn render_row( raw: U256, decimals: u8, max_frac: usize, + masked: bool, mono: SharedString, ) -> impl IntoElement { h_flex() @@ -572,9 +711,7 @@ fn render_row( .child(div().text_xs().text_color(muted).child(symbol)), ), ) - .child( - div() - .text_sm() - .child(money(raw, decimals, max_frac, None, mono, fg, muted)), - ) + .child(div().text_sm().child(money( + raw, decimals, max_frac, None, masked, mono, fg, muted, + ))) } From a1198152a1f8ac9eeca503955e46d4c6d0a8f408 Mon Sep 17 00:00:00 2001 From: hellno Date: Tue, 9 Jun 2026 13:13:15 +0200 Subject: [PATCH 3/7] =?UTF-8?q?feat(app):=20T5=20=E2=80=94=20shield=20trig?= =?UTF-8?q?ger=20UI=20(compose=20=E2=86=92=20review=20=E2=86=92=20hold-to-?= =?UTF-8?q?confirm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Surface::Shield flow, wired to the daemon's existing propose/execute: - signer.rs: build_shield_intent(chain, 0zk recipient, value_wei) wrapping deckard_core::build_shield_native_intent (parses RailgunAddress, clear error on a bad 0zk); parse_eth_to_wei() — total, magnitude-safe ETH→wei (+ tests) - shell.rs: shield state + review_shield()/confirm_shield() mirroring do_unlock (build+propose / execute off-thread, fold on the UI thread); epoch-guarded hold timer; humanize_deny() for friendly deny reasons; SHIELD_CHAIN_ID config seam (D1, defaults to DAEMON_CHAIN_ID) + SHIELD_HOLD - shield_view.rs: compose (amount + free-text 0zk) → clear-signing review card (amount/recipient/0.25% fee/net + the 3 honesty lines in a neutral+amber-keyline caution frame) → hand-built hold-to-confirm with an amber theme::amber_tint fill-sweep over SHIELD_HOLD → broadcast-done card - theme::shield() neutral low-chroma shield tone (off the actor axis, not cyan/amber) - entry points: primary Shield CTA on wallet-home + a ⌘K "Shield to private" row Daemon/core/contract unchanged. DoD green: fmt, clippy (default + tray), tests; no new deps. --- crates/deckard-app/src/main.rs | 1 + crates/deckard-app/src/palette.rs | 8 + crates/deckard-app/src/shell.rs | 238 ++++++++++++- crates/deckard-app/src/shell_chrome.rs | 1 + crates/deckard-app/src/shield_view.rs | 462 +++++++++++++++++++++++++ crates/deckard-app/src/signer.rs | 86 ++++- crates/deckard-app/src/theme.rs | 18 +- crates/deckard-app/src/welcome.rs | 13 +- 8 files changed, 816 insertions(+), 11 deletions(-) create mode 100644 crates/deckard-app/src/shield_view.rs diff --git a/crates/deckard-app/src/main.rs b/crates/deckard-app/src/main.rs index 318835b..695c9b9 100644 --- a/crates/deckard-app/src/main.rs +++ b/crates/deckard-app/src/main.rs @@ -16,6 +16,7 @@ mod settings; mod settings_view; mod shell; mod shell_chrome; +mod shield_view; mod signer; mod theme; #[cfg(feature = "tray")] diff --git a/crates/deckard-app/src/palette.rs b/crates/deckard-app/src/palette.rs index 6b16297..0a8de45 100644 --- a/crates/deckard-app/src/palette.rs +++ b/crates/deckard-app/src/palette.rs @@ -75,6 +75,14 @@ impl Shell { this.open(Surface::Receive, cx); }, ))) + .child( + row("cmd-shield", "Shield to private", "").on_click(cx.listener( + |this, _, _, cx| { + this.palette_open = false; + this.open_shield(cx); + }, + )), + ) .child(row("cmd-settings", "Settings", "⌘,").on_click(cx.listener( |this, _, _, cx| { this.palette_open = false; diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index 9519828..cab65d4 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -4,6 +4,8 @@ //! `settings_view.rs` as `impl Shell` methods (Rust lets you split an inherent //! impl across modules), so this file stays focused on state + navigation. +use std::time::Duration; + use gpui::{ div, App, AppContext, Context, Entity, FocusHandle, Focusable, FontWeight, InteractiveElement, IntoElement, ParentElement, Render, Styled, Window, @@ -14,7 +16,11 @@ use gpui_component::{ v_flex, ActiveTheme, TitleBar, }; -use deckard_core::{Address, EthProvider, KdfParams, Portfolio, ReadStatus, Vault, WordCount}; +use alloy_primitives::B256; +use deckard_contract::{Decision, ExecuteResult, Intent, RequestId}; +use deckard_core::{ + Address, EthProvider, KdfParams, Portfolio, ReadStatus, Vault, WordCount, U256, +}; use zeroize::Zeroizing; use deckard_signerd::SignerClient; @@ -30,6 +36,18 @@ use crate::{GoBack, NewItem, OpenSettings, ToggleMask, TogglePalette, ToggleThem /// fast-follow. const DAEMON_CHAIN_ID: u64 = 1; +/// The chain a **shield** deposit targets (T5 config seam, decision D1). It MUST equal the +/// daemon's chain or `propose` denies `chain_mismatch` — so it defaults to +/// [`DAEMON_CHAIN_ID`]. Railgun supports mainnet (1) and Sepolia (11155111); to record a +/// shield on Sepolia, relaunch the daemon + reader on Sepolia and set this to `11155111` +/// (a single switch point, kept out of the render path). +const SHIELD_CHAIN_ID: u64 = DAEMON_CHAIN_ID; + +/// How long the user must hold the shield confirm before it signs — the deliberate-gesture +/// duration (DESIGN: confirm is a hold, never a tap). The amber fill-sweep (`shield_view`) +/// 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(); @@ -37,6 +55,25 @@ fn short_err(e: impl std::fmt::Display) -> String { 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 { + 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" => "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(), + "erc20_unsupported_v1" => "only native-ETH shields are supported in v1".into(), + "unsupported_v1" => "that action isn't supported in v1".into(), + other => other.to_string(), + } +} + /// 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 @@ -68,6 +105,8 @@ pub enum Selection { pub enum Surface { Home, Receive, + /// The shield trigger flow (T5): compose a deposit → review card → hold-to-confirm. + Shield, Settings, } @@ -115,6 +154,25 @@ pub struct Shell { /// native `setSharingType` call when `capture_block && mask` actually changes. capture_applied: 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`: the built intent + its (locally-derived) request + /// id. `Some` means the review card + hold-to-confirm are live. + pub shield_proposal: Option<(Intent, RequestId)>, + /// 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, + // --- auth / keystore (Chunk 3) --- pub auth: AuthStep, pub auth_error: Option, @@ -252,6 +310,19 @@ 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). + let shield_amount = + cx.new(|cx| InputState::new(window, cx).placeholder("Amount in ETH, e.g. 0.05")); + let shield_recipient = + cx.new(|cx| InputState::new(window, cx).placeholder("0zk… recipient address")); + // Enter on the recipient field reviews the deposit (keyboard-first). + cx.subscribe(&shield_recipient, |this, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::PressEnter { .. }) { + this.review_shield(cx); + } + }) + .detach(); + // Submit-on-Enter for each auth field (keyboard-first). cx.subscribe(&create_pass2, |this, _, event: &InputEvent, cx| { if matches!(event, InputEvent::PressEnter { .. }) { @@ -314,6 +385,14 @@ impl Shell { mask, agent_acting: false, capture_applied: false, + shield_amount, + shield_recipient, + shield_proposal: None, + shield_busy: false, + shield_error: None, + shield_tx: None, + shield_holding: false, + shield_hold_epoch: 0, auth, auth_error: None, auth_busy: false, @@ -874,6 +953,162 @@ impl Shell { cx.notify(); } + // --- shield trigger flow (T5) --- + + /// Open the shield flow with a clean slate (clears any prior proposal/error/result; the + /// typed amount/recipient are left intact). + pub fn open_shield(&mut self, cx: &mut Context) { + self.reset_shield(); + self.open(Surface::Shield, cx); + } + + /// Clear all transient shield state (proposal, error, broadcast, hold). Bumps the hold + /// epoch so any in-flight hold timer is cancelled. + 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); + } + + /// 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). + pub fn review_shield(&mut self, cx: &mut Context) { + 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 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()); + cx.notify(); + return; + } + Err(e) => { + self.shield_error = Some(e); + cx.notify(); + return; + } + }; + if recipient.trim().is_empty() { + 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; + cx.notify(); + let client = self.signer.client(); + let task = cx.background_spawn(async move { + let intent = signer::build_shield_intent(SHIELD_CHAIN_ID, &recipient, value_wei)?; + let decision = client.propose_blocking(&intent)?; + Ok::<(Intent, Decision), anyhow::Error>((intent, decision)) + }); + cx.spawn(async move |this, cx| { + let res = task.await; + this.update(cx, |this, cx| { + this.shield_busy = false; + match res { + Ok((intent, Decision::Allow)) => { + let request_id = SignerClient::request_id_for_intent(&intent); + this.shield_proposal = Some((intent, request_id)); + } + Ok((_, Decision::NeedsApproval { .. })) => { + this.shield_error = Some( + "This is over the agent cap — a human approval card is required (lands with the approvals flow)." + .into(), + ); + } + Ok((_, Decision::Deny { reason })) => { + this.shield_error = + Some(format!("Can't shield: {}", humanize_deny(&reason))); + } + Err(e) => this.shield_error = Some(short_err(e)), + } + cx.notify(); + }) + .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. + pub fn confirm_shield(&mut self, cx: &mut Context) { + let Some((_, request_id)) = self.shield_proposal.clone() else { + return; + }; + if self.shield_busy { + return; + } + self.shield_busy = true; + self.shield_error = None; + cx.notify(); + let client = self.signer.client(); + let task = cx.background_spawn(async move { client.execute_blocking(request_id) }); + cx.spawn(async move |this, cx| { + let res = task.await; + this.update(cx, |this, cx| { + this.shield_busy = false; + match res { + Ok(ExecuteResult::Broadcast { tx_hash }) => { + this.shield_proposal = None; + this.shield_tx = Some(tx_hash); + } + Ok(ExecuteResult::Denied { reason }) => { + this.shield_error = + Some(format!("Shield denied: {}", humanize_deny(&reason))); + } + Err(e) => this.shield_error = Some(short_err(e)), + } + cx.notify(); + }) + .ok(); + }) + .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. + pub fn shield_hold_start(&mut self, cx: &mut Context) { + if self.shield_holding || self.shield_busy || self.shield_proposal.is_none() { + 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). + if this.shield_holding && this.shield_hold_epoch == epoch { + this.shield_holding = false; + this.confirm_shield(cx); + } + }) + .ok(); + }) + .detach(); + } + + /// 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); + 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()); @@ -971,6 +1206,7 @@ impl Render for Shell { let content = match (self.selection, self.surface) { (_, Surface::Settings) => self.render_settings(window, cx).into_any_element(), (_, Surface::Receive) => self.render_receive(cx).into_any_element(), + (_, Surface::Shield) => self.render_shield(cx).into_any_element(), (Selection::Wallet, Surface::Home) => { self.render_wallet_home(cx).into_any_element() } diff --git a/crates/deckard-app/src/shell_chrome.rs b/crates/deckard-app/src/shell_chrome.rs index b0c81b7..1e46c29 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::Shield => "Shield", Surface::Home => match self.selection { Selection::Project => "Personal", Selection::Wallet => "Wallet", diff --git a/crates/deckard-app/src/shield_view.rs b/crates/deckard-app/src/shield_view.rs new file mode 100644 index 0000000..5d3dbbd --- /dev/null +++ b/crates/deckard-app/src/shield_view.rs @@ -0,0 +1,462 @@ +//! 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). +//! +//! 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. + +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_contract::Intent; +use deckard_core::U256; + +use crate::money::money; +use crate::shell::{Shell, Surface, SHIELD_HOLD}; +use crate::theme; + +/// The Railgun shield fee, 25 bps (0.25%) — matches `deckard_core::shield`'s on-chain +/// deduction (`value - value*25/10000`). Shown so the review card never hides the haircut. +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() + } +} + +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((intent, _)) = self.shield_proposal.clone() { + return self.render_shield_review(intent, 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; + + 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) + .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( + div() + .text_xs() + .text_color(muted) + .child("Your own 0zk address auto-fills in a later release."), + ) + .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, intent: Intent, 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 gross = intent.value; + let fee = shield_fee(gross); + let net = gross.saturating_sub(fee); + let recipient = self.shield_recipient.read(cx).value().to_string(); + + // 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 DESIGN's caution frame (neutral surface + a 2px amber + /// left keyline). Calm, not a filled warm block. + 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; + let amber = theme::amber(theme.is_dark()); + + v_flex() + .w_full() + .gap_1p5() + .px_3() + .py_2p5() + .rounded_lg() + .bg(surface) + .border_l_2() + .border_color(amber) + .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 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()); + + 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() + .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 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 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}")) +} diff --git a/crates/deckard-app/src/signer.rs b/crates/deckard-app/src/signer.rs index a236711..b4214f8 100644 --- a/crates/deckard-app/src/signer.rs +++ b/crates/deckard-app/src/signer.rs @@ -7,7 +7,7 @@ //! The keystore is only ever touched in-process by *onboarding* (to write `vault.bin`), never //! to sign. -use alloy_primitives::{Address, B256}; +use alloy_primitives::{Address, B256, U256}; use deckard_contract::{Decision, ExecuteResult, Intent, RequestId, UnlockOutcome}; use deckard_signerd::{DaemonSupervisor, SignerClient}; @@ -85,6 +85,57 @@ pub fn send_blocking(client: &SignerClient, intent: &Intent) -> anyhow::Result anyhow::Result { + let recipient: deckard_core::RailgunAddress = recipient_0zk + .trim() + .parse() + .map_err(|e| anyhow::anyhow!("not a valid 0zk address: {e}"))?; + deckard_core::build_shield_native_intent(chain_id, recipient, value_wei) +} + +/// 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. +pub fn parse_eth_to_wei(input: &str) -> Result { + let s = input.trim(); + if s.is_empty() { + return Err("Enter an amount".into()); + } + let (int_part, frac_part) = match s.split_once('.') { + Some((i, f)) => (i, f), + None => (s, ""), + }; + if int_part.is_empty() && frac_part.is_empty() { + return Err("Enter a valid amount like 0.05".into()); + } + let all_digits = |p: &str| p.bytes().all(|b| b.is_ascii_digit()); + if !all_digits(int_part) || !all_digits(frac_part) { + return Err("Amount must be a number like 0.05".into()); + } + if frac_part.len() > 18 { + return Err("Too many decimal places (max 18 for ETH)".into()); + } + // Concatenate the integer part with the fractional part right-padded to 18 digits → wei. + let mut digits = String::with_capacity(int_part.len() + 18); + digits.push_str(if int_part.is_empty() { "0" } else { int_part }); + digits.push_str(frac_part); + for _ in frac_part.len()..18 { + digits.push('0'); + } + U256::from_str_radix(&digits, 10).map_err(|_| "Amount is too large".into()) +} + /// Interpret an [`UnlockOutcome`] into either the wallet address or a user-facing error. pub fn address_or_error(outcome: UnlockOutcome) -> Result { match outcome { @@ -185,6 +236,39 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn parse_eth_to_wei_handles_decimals_and_rejects_junk() { + // Whole + fractional ETH parse to exact wei. + assert_eq!(parse_eth_to_wei("1").unwrap(), U256::from(10u128.pow(18))); + assert_eq!( + parse_eth_to_wei("0.05").unwrap(), + U256::from(50_000_000_000_000_000u128) + ); + assert_eq!( + parse_eth_to_wei(" 1.234 ").unwrap(), + U256::from(1_234_000_000_000_000_000u128) + ); + assert_eq!(parse_eth_to_wei("0").unwrap(), U256::ZERO); + // Full 18-place precision survives. + assert_eq!( + parse_eth_to_wei("0.000000000000000001").unwrap(), + U256::from(1u64) + ); + // Junk is rejected, never silently coerced to a wrong magnitude. + for bad in [ + "", + " ", + ".", + "abc", + "-1", + "1.2.3", + "1,5", + "0.1234567890123456789", + ] { + assert!(parse_eth_to_wei(bad).is_err(), "{bad:?} must be rejected"); + } + } + #[test] fn unlock_outcomes_map_to_address_or_message() { let addr = Address::repeat_byte(0x11); diff --git a/crates/deckard-app/src/theme.rs b/crates/deckard-app/src/theme.rs index b021303..ab0b1f7 100644 --- a/crates/deckard-app/src/theme.rs +++ b/crates/deckard-app/src/theme.rs @@ -31,12 +31,9 @@ pub fn amber(dark: bool) -> Hsla { hex(if dark { "#F2A43B" } else { "#A8650C" }) } -/// Amber at low alpha — the hold-to-confirm sweep wash + future amber washes. -/// `rgba(242,164,59,.14)` dark; the deepened light amber at the same alpha. -// reason: consumed by the T5 shield hold-to-confirm amber fill-sweep; the -// caution banner correctly uses a NEUTRAL surface + keyline (DESIGN rule 7), not -// a tint fill, so this helper has no consumer until the sweep lands. -#[allow(dead_code)] +/// Amber at low alpha — the T5 shield hold-to-confirm fill-sweep wash. +/// `rgba(242,164,59,.14)` dark; the deepened light amber at the same alpha. (The caution +/// banner correctly uses a NEUTRAL surface + keyline per DESIGN rule 7, not a tint fill.) pub fn amber_tint(dark: bool) -> Hsla { amber(dark).opacity(0.14) } @@ -61,6 +58,15 @@ pub fn identity_square(dark: bool) -> Hsla { hex(if dark { "#3A4250" } else { "#A7AEBA" }) } +/// The **shield / private** tone — a neutral, LOW-chroma cool slate for the private +/// (shielded) balance segment + the shield glyph. Deliberately NOT cyan and NOT amber: +/// privacy sits *off* the actor axis (cyan = agent, amber = human), so the shield mark +/// never reads as an actor signal (deckard-demo-ux-locked + DESIGN §Color). A touch +/// cooler/dimmer than `identity_square` so the two neutrals stay distinguishable. +pub fn shield(dark: bool) -> Hsla { + hex(if dark { "#33424C" } else { "#94A2AC" }) +} + /// Install (or re-install) the refined theme, then apply `mode`. Call once at /// startup and again whenever the user toggles light/dark. pub fn install(cx: &mut App, mode: ThemeMode) { diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index 5b5dd9f..518bf43 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -228,16 +228,23 @@ impl Shell { ) })), ) - // Primary actions. Send + Swap are gated to the next release (Chunk 4, - // testnet-first), so they're shown disabled rather than inert-but-active. + // 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. .child( h_flex() .w_full() .gap_2() - .child(Button::new("send").primary().label("Send").disabled(true)) + .child( + Button::new("shield") + .primary() + .label("Shield") + .on_click(cx.listener(|this, _, _, cx| this.open_shield(cx))), + ) .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("swap").ghost().label("Swap").disabled(true)), ) .child( From dbc9d551d579288073801ed5a22fb6d787040bbd Mon Sep 17 00:00:00 2001 From: hellno Date: Tue, 9 Jun 2026 13:29:59 +0200 Subject: [PATCH 4/7] fix(app): address codex adversarial-review P1s on T5 shield flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model (codex GPT-5 xhigh) read-only review vs DESIGN.md + the handoff: - P1 clear-signing integrity: the review card rendered the LIVE recipient input, which the user could edit during the propose round-trip → could show recipient B while signing intent A. Now ShieldProposal carries a recipient snapshot and the card renders that; a shield_review_epoch drops a stale propose reply (guard-first so it can't clobber a newer review's busy flag). - P1 hold-after-leave: a held confirm could fire after ⌘[/palette navigation (mouse-up never landed). Timer now requires surface==Shield && proposal.is_some, and open() cancels any in-progress hold when leaving Shield. - P1 double-broadcast on timeout: confirm_shield now invalidates the proposal on EVERY execute attempt (not just success) so a second hold can't re-broadcast an ambiguous timeout; humanize_deny covers broadcast_timeout/_failed/already_executed. - P1 shield on watched account: Shield is disabled while viewing_watch (it signs from your own wallet); open_shield no-ops there too. - P2 single-cyan-surface: the acting status dot is now a neutral brightness lift (the breathing squircle stays the sole cyan). - P2 disabled-invalid state: "Review deposit" is disabled until amount>0 and recipient are present (live via input subscriptions). DoD green: fmt, clippy (default + tray), tests; no new deps. --- crates/deckard-app/src/shell.rs | 100 +++++++++++++++++++++---- crates/deckard-app/src/shell_chrome.rs | 5 +- crates/deckard-app/src/shield_view.rs | 30 ++++++-- crates/deckard-app/src/welcome.rs | 4 + 4 files changed, 113 insertions(+), 26 deletions(-) diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index cab65d4..fb225ae 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -58,6 +58,10 @@ fn short_err(e: impl std::fmt::Display) -> String { /// 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(), @@ -65,11 +69,16 @@ fn humanize_deny(reason: &str) -> String { "the signer is on a different chain than this deposit — reconcile the chain first" .into() } - "over_cap" => "it exceeds the agent's spending cap".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(), "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(), } } @@ -110,6 +119,16 @@ pub enum Surface { 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, +} + /// 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)] @@ -159,9 +178,12 @@ pub struct Shell { /// auto-filling the user's OWN railgun address is Wave 2. pub shield_amount: Entity, pub shield_recipient: Entity, - /// Set once `propose` returns `Allow`: the built intent + its (locally-derived) request - /// id. `Some` means the review card + hold-to-confirm are live. - pub shield_proposal: Option<(Intent, RequestId)>, + /// 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). @@ -315,13 +337,23 @@ impl Shell { cx.new(|cx| InputState::new(window, cx).placeholder("Amount in ETH, e.g. 0.05")); let shield_recipient = cx.new(|cx| InputState::new(window, cx).placeholder("0zk… recipient address")); - // Enter on the recipient field reviews the deposit (keyboard-first). - cx.subscribe(&shield_recipient, |this, _, event: &InputEvent, cx| { - if matches!(event, InputEvent::PressEnter { .. }) { - this.review_shield(cx); + // Re-render on edits so the Review button's disabled state tracks validity live; + // Enter on the recipient reviews the deposit (keyboard-first). + cx.subscribe(&shield_amount, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); } }) .detach(); + cx.subscribe( + &shield_recipient, + |this, _, event: &InputEvent, cx| match event { + InputEvent::Change => cx.notify(), + InputEvent::PressEnter { .. } => this.review_shield(cx), + _ => {} + }, + ) + .detach(); // Submit-on-Enter for each auth field (keyboard-first). cx.subscribe(&create_pass2, |this, _, event: &InputEvent, cx| { @@ -388,6 +420,7 @@ impl Shell { shield_amount, shield_recipient, shield_proposal: None, + shield_review_epoch: 0, shield_busy: false, shield_error: None, shield_tx: None, @@ -925,6 +958,12 @@ impl Shell { /// Open a full-pane surface (Home / Receive / Settings) over the current selection. 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); + } self.surface = surface; cx.notify(); } @@ -956,14 +995,19 @@ impl Shell { // --- shield trigger flow (T5) --- /// Open the shield flow with a clean slate (clears any prior proposal/error/result; the - /// typed amount/recipient are left intact). + /// typed amount/recipient are left intact). No-op while viewing a watched read-only + /// account — a shield signs from YOUR wallet, so it must not be initiated from a + /// someone-else's-address context. pub fn open_shield(&mut self, cx: &mut Context) { + if self.viewing_watch { + return; + } self.reset_shield(); self.open(Surface::Shield, cx); } - /// Clear all transient shield state (proposal, error, broadcast, hold). Bumps the hold - /// epoch so any in-flight hold timer is cancelled. + /// 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; @@ -971,6 +1015,7 @@ impl Shell { 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 @@ -1003,6 +1048,11 @@ impl Shell { 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; + let recipient_snapshot = recipient.clone(); cx.notify(); let client = self.signer.client(); let task = cx.background_spawn(async move { @@ -1013,11 +1063,20 @@ impl Shell { 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((intent, request_id)); + this.shield_proposal = Some(ShieldProposal { + intent, + request_id, + recipient: recipient_snapshot, + }); } Ok((_, Decision::NeedsApproval { .. })) => { this.shield_error = Some( @@ -1041,7 +1100,7 @@ impl Shell { /// 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. pub fn confirm_shield(&mut self, cx: &mut Context) { - let Some((_, request_id)) = self.shield_proposal.clone() else { + let Some(ShieldProposal { request_id, .. }) = self.shield_proposal.clone() else { return; }; if self.shield_busy { @@ -1056,9 +1115,12 @@ impl Shell { let res = task.await; this.update(cx, |this, cx| { 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; match res { Ok(ExecuteResult::Broadcast { tx_hash }) => { - this.shield_proposal = None; this.shield_tx = Some(tx_hash); } Ok(ExecuteResult::Denied { reason }) => { @@ -1088,8 +1150,14 @@ impl Shell { 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). - if this.shield_holding && this.shield_hold_epoch == epoch { + // 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; this.confirm_shield(cx); } diff --git a/crates/deckard-app/src/shell_chrome.rs b/crates/deckard-app/src/shell_chrome.rs index 1e46c29..fdf033d 100644 --- a/crates/deckard-app/src/shell_chrome.rs +++ b/crates/deckard-app/src/shell_chrome.rs @@ -227,9 +227,10 @@ impl Shell { "agent-pulse-sidebar", )) .child(div().flex_1().text_sm().text_color(fg).child("Atlas")) - // Status dot: cyan while acting, neutral when idle. + // Status dot: a neutral brightness lift while acting (the cyan + // breathing squircle is the sole cyan surface — DESIGN actor model). .child(div().size(px(6.0)).rounded_full().bg(if self.agent_acting { - agent + fg } else { muted })), diff --git a/crates/deckard-app/src/shield_view.rs b/crates/deckard-app/src/shield_view.rs index 5d3dbbd..0440fbb 100644 --- a/crates/deckard-app/src/shield_view.rs +++ b/crates/deckard-app/src/shield_view.rs @@ -20,11 +20,10 @@ use gpui_component::{ v_flex, ActiveTheme, Disableable, Icon, IconName, }; -use deckard_contract::Intent; use deckard_core::U256; use crate::money::money; -use crate::shell::{Shell, Surface, SHIELD_HOLD}; +use crate::shell::{Shell, ShieldProposal, Surface, SHIELD_HOLD}; use crate::theme; /// The Railgun shield fee, 25 bps (0.25%) — matches `deckard_core::shield`'s on-chain @@ -50,8 +49,8 @@ impl Shell { .render_shield_done(tx.to_string(), cx) .into_any_element(); } - if let Some((intent, _)) = self.shield_proposal.clone() { - return self.render_shield_review(intent, 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() } @@ -64,6 +63,15 @@ impl Shell { 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() @@ -96,7 +104,7 @@ impl Shell { Button::new("shield-review") .primary() .label(if busy { "Reviewing…" } else { "Review deposit" }) - .disabled(busy) + .disabled(busy || !can_review) .on_click(cx.listener(|this, _, _, cx| this.review_shield(cx))), ) .child( @@ -120,7 +128,11 @@ impl Shell { /// 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, intent: Intent, cx: &mut Context) -> impl IntoElement { + 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; @@ -128,10 +140,12 @@ impl Shell { let surface = theme.secondary; let mono = theme.mono_font_family.clone(); - let gross = intent.value; + // 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 = self.shield_recipient.read(cx).value().to_string(); + 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| { diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index 518bf43..a2b2a69 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -235,10 +235,14 @@ impl Shell { h_flex() .w_full() .gap_2() + // Shield signs from YOUR wallet, so it's disabled while viewing a + // watched read-only account (don't show a funds-moving action in a + // someone-else's-address context). .child( Button::new("shield") .primary() .label("Shield") + .disabled(self.viewing_watch) .on_click(cx.listener(|this, _, _, cx| this.open_shield(cx))), ) .child(Button::new("receive").ghost().label("Receive").on_click( From 046011a7debf0734247afd0e324184682d9441de Mon Sep 17 00:00:00 2001 From: hellno Date: Tue, 9 Jun 2026 13:52:51 +0200 Subject: [PATCH 5/7] =?UTF-8?q?feat(core):=20T8=20=E2=80=94=20KAT-gated=20?= =?UTF-8?q?Railgun=20seed=E2=86=920zk=20key=20derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consensus-critical hero foundation: derive the Railgun spending (babyjubjub) + viewing (ed25519) keys from the wallet's BIP-39 entropy, GATED on a known-answer test against Railgun's OWN engine vectors. A wrong derivation shows a silent $0 shielded balance forever — the worst bug for a trust wallet — so nothing downstream may render a shielded balance unless known_answer_ok() passes. - deckard-core/src/railgun_keys.rs (new, behind `shield`): SLIP-0010-style hardened HMAC-SHA512 derivation along m/44'/1984'/0'/0'/i' (spend) + m/420'/1984'/0'/0'/i' (view). The one non-textbook detail Railgun uses — and the bug the KAT caught — is the master HMAC key "babyjubjub seed", NOT "ed25519 seed". chainKey bytes feed HexKey::from_hex (ByteKey::from_bytes is crate-private). Empty-passphrase seed via to_seed_normalized. No unwrap/expect/indexing (split_at + try_into), propagated. - KAT: mnemonic "test…junk" → getAddress({EVM,id:1/2}) asserted against the engine's published literals (two chains pin both the keys AND the chain-id encoding); known_answer_ok() is the same check at runtime. - UnlockedVault::railgun_address(chain_id, index) (keystore.rs) mirrors account_signer; the seed/entropy never leaves core. - Cargo: hmac + sha2 added to the `shield` feature (approved — already transitively in Cargo.lock, so +2 edges, no new crates). DoD: fmt, clippy (default + tray), KAT + workspace tests green (anvil_e2e is a parallel-load flake — passes reliably single-threaded). T9 (in-app sync actor) paused pending the ZK-tree feature-widening decision. --- Cargo.lock | 2 + crates/deckard-core/Cargo.toml | 8 +- crates/deckard-core/src/keystore.rs | 14 ++ crates/deckard-core/src/lib.rs | 12 ++ crates/deckard-core/src/railgun_keys.rs | 194 ++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 crates/deckard-core/src/railgun_keys.rs diff --git a/Cargo.lock b/Cargo.lock index 8811d46..e1beee4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3933,9 +3933,11 @@ dependencies = [ "directories", "flume", "helios-ethereum", + "hmac", "railgun", "rand 0.8.6", "rand 0.9.4", + "sha2 0.10.9", "tokio", "zeroize", ] diff --git a/crates/deckard-core/Cargo.toml b/crates/deckard-core/Cargo.toml index 7da2c44..be21910 100644 --- a/crates/deckard-core/Cargo.toml +++ b/crates/deckard-core/Cargo.toml @@ -17,7 +17,7 @@ verified-reads = ["dep:helios-ethereum"] # can be skipped. When OFF, `build_shield_native_intent` returns "shield unavailable # (feature off)" — never a fake success. Pulls `rand_09` because railgun pins rand 0.9 # while core's own `rand` stays 0.8. -shield = ["dep:railgun", "dep:rand_09"] +shield = ["dep:railgun", "dep:rand_09", "dep:hmac", "dep:sha2"] [dependencies] # The frozen wire contract — only for the shared `ReadStatus` trust label attached @@ -51,6 +51,12 @@ railgun = { git = "https://github.com/ethereum/kohaku", package = "railgun", rev # (below) is 0.8 (RustCrypto-aligned), so the 0.9 crate is aliased `rand_09` and pulled only # with the `shield` feature. rand_09 = { package = "rand", version = "0.9", optional = true } +# SLIP-0010 ed25519 derivation for the Railgun 0zk keys (railgun_keys.rs): HMAC-SHA512 only. +# Audited RustCrypto primitives, the same family as argon2/chacha20poly1305/bip39 above, and +# already present transitively (bip39's pbkdf2 pulls them) — these add only the direct edges. +# Gated with `shield` since the derivation feeds the railgun key types. +hmac = { version = "0.12", optional = true } +sha2 = { version = "0.10", optional = true } # A single background tokio runtime owns all network; the GUI thread never makes # a network call. `rt` (current-thread) only — no multi-thread worker pool needed. diff --git a/crates/deckard-core/src/keystore.rs b/crates/deckard-core/src/keystore.rs index f043d04..1a96b27 100644 --- a/crates/deckard-core/src/keystore.rs +++ b/crates/deckard-core/src/keystore.rs @@ -493,6 +493,20 @@ impl UnlockedVault { ); entropy_to_phrase(&self.secret) } + + /// The wallet's own Railgun **0zk address** for account `index` on `chain_id` — the shield + /// auto-fill recipient and the source of the viewing key. Derived from the same BIP-39 + /// entropy as `account_signer` (the seed never leaves core). Errors for a raw-key import + /// (it has no mnemonic). Gated with `shield` since it leans on the railgun key types; the + /// derivation itself is KAT-verified in [`crate::railgun_keys`]. + #[cfg(feature = "shield")] + pub fn railgun_address(&self, chain_id: u64, index: u32) -> anyhow::Result { + anyhow::ensure!( + self.kind.has_phrase(), + "imported raw key has no Railgun 0zk address" + ); + crate::railgun_keys::railgun_address_from_entropy(&self.secret, chain_id, index) + } } // --- crypto helpers --- diff --git a/crates/deckard-core/src/lib.rs b/crates/deckard-core/src/lib.rs index 178e6d7..b091d38 100644 --- a/crates/deckard-core/src/lib.rs +++ b/crates/deckard-core/src/lib.rs @@ -34,6 +34,11 @@ pub mod eth; #[cfg(feature = "verified-reads")] pub mod helios; pub mod keystore; +/// Railgun seed→0zk-key derivation (SLIP-0010 ed25519), gated behind `shield`. The +/// consensus-critical path: KAT-verified against Railgun's own engine vector so a wrong +/// derivation can't silently show a $0 shielded balance. +#[cfg(feature = "shield")] +pub mod railgun_keys; /// Key-less Railgun shield-calldata builder. Gated behind the default-on `shield` feature /// so the heavy ZK `railgun` crate is toggleable. When the feature is off, the /// `build_shield_native_intent` stub below returns a clear error (never a fake success). @@ -51,6 +56,13 @@ pub use keystore::{random_word_positions, KdfParams, SecretKind, UnlockedVault, // and its tests can name them through core without a direct `railgun` dependency. #[cfg(feature = "shield")] pub use shield::{build_shield_native_intent, RailgunAddress}; +// Railgun key derivation + the runtime known-answer gate (`known_answer_ok`), re-exported so +// the app can derive the user's own 0zk address and refuse to show shielded balances until the +// gate passes. +#[cfg(feature = "shield")] +pub use railgun_keys::{ + known_answer_ok, railgun_address_from_entropy, railgun_keys_from_entropy, RailgunKeys, +}; pub use tokens::{TokenInfo, DEFAULT_TOKENS}; /// Feature-off stub: when `shield` is compiled out, the symbol still exists so the daemon diff --git a/crates/deckard-core/src/railgun_keys.rs b/crates/deckard-core/src/railgun_keys.rs new file mode 100644 index 0000000..d9d648a --- /dev/null +++ b/crates/deckard-core/src/railgun_keys.rs @@ -0,0 +1,194 @@ +//! Railgun key derivation — the consensus-critical seed → 0zk-key path. +//! +//! Railgun derives its spending (babyjubjub) and viewing (ed25519) private keys via a +//! SLIP-0010-style hardened HMAC-SHA512 chain (NOT secp256k1 BIP-32) along +//! - spending `m/44'/1984'/0'/0'/index'` +//! - viewing `m/420'/1984'/0'/0'/index'` +//! +//! and feeds each node's 32-byte `chainKey` straight into the babyjubjub / ed25519 public-key +//! functions (Railgun engine `bip32.ts` + `wallet-node.ts` @ `e2913b39`). The one place +//! Railgun diverges from textbook SLIP-0010: the master HMAC key is the literal +//! **`"babyjubjub seed"`**, NOT `"ed25519 seed"`. Get ANY step wrong — that seed constant, the +//! hardened-only paths, the byte order, the pubkey curve — and the synced private balance +//! reads $0 forever. That is silent, and it is the worst failure mode a trust wallet can have. +//! +//! So this module is **gated by a known-answer test**. [`known_answer_ok`] re-derives a fixed +//! mnemonic and compares the resulting 0zk address to Railgun's OWN engine test vector (an +//! independent source). The app must not display any shielded balance unless it returns true, +//! and the same comparison is a `#[test]` so a derivation drift also turns CI red. +//! +//! Construction note: `railgun`'s `ByteKey::from_bytes` is crate-private, so the only public +//! way to build a `SpendingKey`/`ViewingKey` from our derived bytes is `HexKey::from_hex` — +//! hence the small local hex encoder (kept dependency-free). + +use hmac::{Hmac, Mac}; +use sha2::Sha512; + +use railgun::account::address::RailgunAddress; +use railgun::account::chain::ChainId; +use railgun::crypto::keys::{HexKey, SpendingKey, ViewingKey}; + +type HmacSha512 = Hmac; + +/// The hardened-key offset (SLIP-0010 / BIP-32). Every Railgun path segment is hardened — +/// which is also why ed25519 derivation is even possible (it supports hardened only). +const HARDENED: u32 = 0x8000_0000; +/// The fixed prefix of the spending-key path `m/44'/1984'/0'/0'/index'`. +const SPENDING_PREFIX: [u32; 4] = [44, 1984, 0, 0]; +/// The fixed prefix of the viewing-key path `m/420'/1984'/0'/0'/index'`. +const VIEWING_PREFIX: [u32; 4] = [420, 1984, 0, 0]; + +/// Railgun's own known-answer vectors — engine `src/test/config.test.ts` (the mnemonic) + +/// `src/wallet/__tests__/railgun-wallet.test.ts` (account-0 `getAddress({ type: EVM, id })`). +/// Two chains so the test pins BOTH the key derivation AND the chain-id encoding. (The +/// engine's no-arg `getAddress()` default is the distinct ALL-chains address, NOT id=1.) +const KAT_MNEMONIC: &str = "test test test test test test test test test test test junk"; +/// `getAddress({ type: ChainType.EVM, id: 1 })` — used by [`known_answer_ok`] at runtime. +/// (The chain-2 vector lives in the test, which is its only consumer.) +const KAT_ADDRESS_CHAIN1: &str = "0zk1qyk9nn28x0u3rwn5pknglda68wrn7gw6anjw8gg94mcj6eq5u48t7unpd9kxwatwq9ma02nutwtcqc979wnce0qwly4y7w4rls5cq040g7z8eagshxrw56ltkfa"; + +/// A derived Railgun keypair — the private spending + viewing keys. The public master key +/// and the 0zk address are computed from these by `railgun`. +pub struct RailgunKeys { + pub spending: SpendingKey, + pub viewing: ViewingKey, +} + +/// HMAC-SHA512 keyed by `key` over the concatenation of `parts`, split into the +/// `(left[0..32], right[32..64])` halves SLIP-0010 uses for `(key, chainCode)`. +fn hmac_split(key: &[u8], parts: &[&[u8]]) -> anyhow::Result<([u8; 32], [u8; 32])> { + // HMAC accepts a key of any length, so `new_from_slice` never errors here; propagate + // rather than unwrap (deckard-core forbids unwrap/expect in non-test code). + let mut mac = HmacSha512::new_from_slice(key).map_err(|e| anyhow::anyhow!("hmac key: {e}"))?; + for p in parts { + mac.update(p); + } + let out = mac.finalize().into_bytes(); + // `split_at` + `try_into` instead of `out[..32]` to satisfy the no-raw-indexing lint. + let (left, right) = out.split_at(32); + let l: [u8; 32] = left + .try_into() + .map_err(|_| anyhow::anyhow!("hmac left split"))?; + let r: [u8; 32] = right + .try_into() + .map_err(|_| anyhow::anyhow!("hmac right split"))?; + Ok((l, r)) +} + +/// Railgun's SLIP-0010-style derivation: master from `seed` (keyed by the literal +/// `"babyjubjub seed"`, Railgun's custom constant — its one divergence from textbook +/// SLIP-0010), then a hardened child-key derivation per segment; returns the final node's +/// 32-byte private key (Railgun's `chainKey`). +fn derive_chain_key(seed: &[u8], path: &[u32]) -> anyhow::Result<[u8; 32]> { + // Master: I = HMAC-SHA512("babyjubjub seed", seed); key = I_L, chainCode = I_R. + let (mut key, mut chain_code) = hmac_split(b"babyjubjub seed", &[seed])?; + // Child (hardened only): I = HMAC-SHA512(chainCode, 0x00 || key || ser32(idx | HARDENED)). + for &segment in path { + let index = (segment | HARDENED).to_be_bytes(); + let (k, cc) = hmac_split(&chain_code, &[&[0u8], &key, &index])?; + key = k; + chain_code = cc; + } + Ok(key) +} + +/// Lowercase hex (no `0x`) of a 32-byte key — the input to `railgun`'s public `HexKey::from_hex` +/// (its `ByteKey::from_bytes` is crate-private). Dependency-free, no raw indexing. +fn to_hex(bytes: &[u8; 32]) -> String { + use std::fmt::Write; + let mut s = String::with_capacity(64); + for &b in bytes { + // Writing to a String is infallible; discard the formatter Result explicitly. + let _ = write!(s, "{b:02x}"); + } + s +} + +/// Derive the Railgun spending + viewing keys for account `index` from BIP-39 entropy. +/// +/// Mirrors [`crate::UnlockedVault::account_signer`] (entropy stays in core, derived per call). +/// The BIP-39 seed uses an EMPTY passphrase, matching Railgun engine's +/// `Mnemonic.toSeed(mnemonic)`. +pub fn railgun_keys_from_entropy(entropy: &[u8], index: u32) -> anyhow::Result { + let mnemonic = bip39::Mnemonic::from_entropy(entropy) + .map_err(|e| anyhow::anyhow!("bip39 from_entropy: {e}"))?; + // `to_seed_normalized` (vs `to_seed`) avoids needing the `unicode-normalization` feature; + // an empty passphrase is already normalized. + let seed = mnemonic.to_seed_normalized(""); + + let mut spending_path = SPENDING_PREFIX.to_vec(); + spending_path.push(index); + let mut viewing_path = VIEWING_PREFIX.to_vec(); + viewing_path.push(index); + + let spend_bytes = derive_chain_key(&seed, &spending_path)?; + let view_bytes = derive_chain_key(&seed, &viewing_path)?; + + let spending = SpendingKey::from_hex(&to_hex(&spend_bytes)) + .map_err(|e| anyhow::anyhow!("railgun spending key: {e}"))?; + let viewing = ViewingKey::from_hex(&to_hex(&view_bytes)) + .map_err(|e| anyhow::anyhow!("railgun viewing key: {e}"))?; + Ok(RailgunKeys { spending, viewing }) +} + +/// The 0zk address string for account `index` on `chain_id`, derived from BIP-39 entropy. +pub fn railgun_address_from_entropy( + entropy: &[u8], + chain_id: u64, + index: u32, +) -> anyhow::Result { + let keys = railgun_keys_from_entropy(entropy, index)?; + let address = + RailgunAddress::from_private_keys(keys.spending, keys.viewing, ChainId::evm(chain_id)); + Ok(address.to_string()) +} + +/// The runtime gate: re-derive Railgun's own known mnemonic and compare the 0zk address to the +/// engine's published vector. **The app must not display a shielded balance unless this is +/// true** — a wrong derivation would otherwise show a silent, wrong (typically $0) balance. +pub fn known_answer_ok() -> bool { + let Ok(mnemonic) = bip39::Mnemonic::parse(KAT_MNEMONIC) else { + return false; + }; + let entropy = mnemonic.to_entropy(); + match railgun_address_from_entropy(&entropy, 1, 0) { + Ok(addr) => addr == KAT_ADDRESS_CHAIN1, + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The gate. The expected address is Railgun's OWN engine test vector, so a pass means our + /// SLIP-0010 + babyjubjub/ed25519 + bech32m path matches the canonical implementation; a + /// fail means shielded balances would read wrong, so it must block the build. + #[test] + fn known_answer_matches_railgun_engine_vector() { + let mnemonic = bip39::Mnemonic::parse(KAT_MNEMONIC).unwrap(); + let entropy = mnemonic.to_entropy(); + assert_eq!( + railgun_address_from_entropy(&entropy, 1, 0).unwrap(), + KAT_ADDRESS_CHAIN1, + "Railgun derivation drifted from the engine vector — shielded balances would be wrong" + ); + // A second chain pins the chain-id encoding, not just the keys. + const KAT_ADDRESS_CHAIN2: &str = "0zk1qyk9nn28x0u3rwn5pknglda68wrn7gw6anjw8gg94mcj6eq5u48t7unpd9kxwatwqfma02nutwtcqc979wnce0qwly4y7w4rls5cq040g7z8eagshxrw5aha7vd"; + assert_eq!( + railgun_address_from_entropy(&entropy, 2, 0).unwrap(), + KAT_ADDRESS_CHAIN2 + ); + assert!(known_answer_ok(), "runtime gate must agree with the KAT"); + } + + /// Different account indices must yield different addresses (the path's last segment). + #[test] + fn distinct_indices_distinct_addresses() { + let mnemonic = bip39::Mnemonic::parse(KAT_MNEMONIC).unwrap(); + let entropy = mnemonic.to_entropy(); + let a0 = railgun_address_from_entropy(&entropy, 1, 0).unwrap(); + let a1 = railgun_address_from_entropy(&entropy, 1, 1).unwrap(); + assert_ne!(a0, a1); + } +} From c6bfcf5321859068b3b31bf2e9865e10a8d23a51 Mon Sep 17 00:00:00 2001 From: hellno Date: Tue, 9 Jun 2026 14:28:53 +0200 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20T9/T10=20=E2=80=94=20shielded-balan?= =?UTF-8?q?ce=20sync=20+=20split-balance=20render=20(the=20hero)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the KAT-verified derivation (T8) into a visible private balance, with codex's honesty corrections folded in. Boundary (decision D2, tightened): - deckard-contract: RailgunViewGrant { address, viewing_key } wire type with a REDACTED Debug (the viewing key reveals private history). SignerRequest:: RailgunViewGrant + SignerResponse::RailgunView. - daemon derives + exports ONLY the view grant (viewing key + 0zk address, never the spending key), gated on known_answer_ok() — no grant from an unverified derivation. railgun is already in the daemon's build graph (its shield feature). - core: railgun_view_grant_from_entropy + UnlockedVault::railgun_view_grant. Sync actor (deckard-core/src/shielded.rs, behind shield): - ShieldedHandle mirrors EthProvider: a background thread owns the RailgunProvider (raw RPC + Subsquid, NOT Helios) and updates a cached snapshot the GUI reads instantly — balance() never blocks on sync. Private balance is UNKNOWN while syncing (never a fake 0) and always Unsynced (never Verified) in v1. - ViewOnlySigner (module-private): real viewing key + precomputed address + an inert dummy spending key; address() overridden, provider never exposed. Render + lifecycle (app): - welcome.rs render_shielded_hero: merged Total (public + private, summed only when both known), Private/Public allocation bar (Private first, neutral shield tone), composition lines, "WETH-equivalent net of fee" footnote. Integer (bps) ratios. - shell.rs: fetch the grant post-unlock → spawn the actor → watch the sync (capped poll); after a shield broadcast → ShieldStatus::SyncingPrivate + resync → PrivateSpendable; auto-fill the recipient with the user's own 0zk; tear down on lock. - shell_chrome.rs: ShieldStatus reassurance line + glyph in the status strip. DoD green: fmt, clippy (default + tray), cargo test --workspace; no new deps. --- crates/deckard-app/src/shell.rs | 108 ++++++++++- crates/deckard-app/src/shell_chrome.rs | 23 ++- crates/deckard-app/src/welcome.rs | 219 +++++++++++++++++----- crates/deckard-contract/src/lib.rs | 3 +- crates/deckard-contract/src/rpc.rs | 25 +++ crates/deckard-core/src/keystore.rs | 15 ++ crates/deckard-core/src/lib.rs | 5 + crates/deckard-core/src/railgun_keys.rs | 16 ++ crates/deckard-core/src/shielded.rs | 232 ++++++++++++++++++++++++ crates/deckard-signerd/src/client.rs | 21 ++- crates/deckard-signerd/src/daemon.rs | 43 ++++- 11 files changed, 655 insertions(+), 55 deletions(-) create mode 100644 crates/deckard-core/src/shielded.rs diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index fb225ae..c6ab87d 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -17,9 +17,9 @@ use gpui_component::{ }; use alloy_primitives::B256; -use deckard_contract::{Decision, ExecuteResult, Intent, RequestId}; +use deckard_contract::{Decision, ExecuteResult, Intent, RequestId, ShieldStatus}; use deckard_core::{ - Address, EthProvider, KdfParams, Portfolio, ReadStatus, Vault, WordCount, U256, + Address, EthProvider, KdfParams, Portfolio, ReadStatus, ShieldedHandle, Vault, WordCount, U256, }; use zeroize::Zeroizing; @@ -195,6 +195,17 @@ pub struct Shell { /// Bumped on each hold-start so a stale hold timer can't fire a later confirm. shield_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. + pub shielded: Option, + /// The user's own 0zk address — the shield recipient auto-fill (None until granted). + pub railgun_address: Option, + /// True once the shield recipient input has been auto-filled with `railgun_address`. + recipient_autofilled: bool, + /// The active shield's lifecycle, surfaced in the status strip (None when idle). + pub shield_status: Option, + // --- auth / keystore (Chunk 3) --- pub auth: AuthStep, pub auth_error: Option, @@ -426,6 +437,10 @@ impl Shell { shield_tx: None, shield_holding: false, shield_hold_epoch: 0, + shielded: None, + railgun_address: None, + recipient_autofilled: false, + shield_status: None, auth, auth_error: None, auth_busy: false, @@ -500,6 +515,11 @@ impl Shell { .detach(); self.wallet_address = None; self.portfolio = None; + // Dropping the handle closes its channel → the sync worker thread exits. + self.shielded = None; + self.railgun_address = None; + self.recipient_autofilled = false; + self.shield_status = None; self.auth = AuthStep::Unlock; self.palette_open = false; cx.notify(); @@ -791,6 +811,68 @@ impl Shell { self.selection = Selection::Wallet; self.surface = Surface::Home; self.retarget(cx); + self.kick_railgun_grant(cx); + } + + /// After unlock, ask the daemon for the read-only Railgun view grant (it gates this on the + /// derivation known-answer test), then spawn the shielded-balance sync over the app's RPC + /// and start watching it. If the daemon refuses (locked / gate failed), there's simply no + /// shielded UI — honest, never a fabricated private balance. + fn kick_railgun_grant(&mut self, cx: &mut Context) { + let client = self.signer.client(); + let chain_id = SHIELD_CHAIN_ID; + let rpc = self.settings.effective_rpc(); + let task = + cx.background_spawn(async move { client.railgun_view_grant_blocking(chain_id, 0) }); + cx.spawn(async move |this, cx| { + let grant = task.await; + this.update(cx, |this, cx| { + if let Ok(grant) = grant { + this.railgun_address = Some(grant.address.clone()); + this.recipient_autofilled = false; + this.shielded = Some(ShieldedHandle::spawn(rpc, chain_id, grant)); + this.watch_shielded_sync(cx); + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Poll the shielded snapshot while a sync runs so the UI reflects progress + completion + /// (the actor's cached state isn't a GPUI entity, so we tick it). Caps the poll so a hung + /// sync can't loop forever. When a post-shield sync settles, advance `ShieldStatus`. + fn watch_shielded_sync(&self, cx: &mut Context) { + cx.spawn(async move |this, cx| { + for _ in 0..90 { + cx.background_executor().timer(Duration::from_secs(2)).await; + let syncing = this.update(cx, |this, cx| { + cx.notify(); + this.shielded.as_ref().is_some_and(|h| h.snapshot().syncing) + }); + if !matches!(syncing, Ok(true)) { + break; + } + } + // Sync settled: if this was the post-shield sync, the note is now visible. + this.update(cx, |this, cx| { + if matches!( + this.shield_status, + Some(ShieldStatus::SyncingPrivate { .. }) + ) { + let wei = this + .shielded + .as_ref() + .and_then(|h| h.snapshot().shielded_wei) + .unwrap_or(U256::ZERO); + this.shield_status = Some(ShieldStatus::PrivateSpendable { shielded_wei: wei }); + cx.notify(); + } + }) + .ok(); + }) + .detach(); } /// The unlocked wallet's own address as an EIP-55 string (empty until unlocked). @@ -1122,6 +1204,13 @@ impl Shell { match res { Ok(ExecuteResult::Broadcast { tx_hash }) => { this.shield_tx = Some(tx_hash); + // The deposit is on its way to a private note; re-sync to surface it + // and drive the lifecycle (SyncingPrivate → PrivateSpendable). + this.shield_status = Some(ShieldStatus::SyncingPrivate { tx_hash }); + if let Some(h) = &this.shielded { + h.resync(); + } + this.watch_shielded_sync(cx); } Ok(ExecuteResult::Denied { reason }) => { this.shield_error = @@ -1228,6 +1317,20 @@ impl Shell { self.toggle_mask(cx); } + /// Pre-fill the shield recipient with the user's own 0zk address once the grant arrives + /// (the user can still edit it). Runs from `render`, the only place with a `Window`. + fn autofill_shield_recipient(&mut self, window: &mut Window, cx: &mut Context) { + if self.recipient_autofilled { + return; + } + if let Some(addr) = self.railgun_address.clone() { + self.shield_recipient.update(cx, |input, cx| { + input.set_value(addr.as_str(), window, cx); + }); + self.recipient_autofilled = true; + } + } + /// Push the capture-block state to the OS when `capture_block && mask` changes. /// Called once per `render`; the change-guard makes it a no-op on most frames. On a /// non-macOS or non-`tray` build `apply_capture_block` is itself an inert no-op. @@ -1270,6 +1373,7 @@ impl Render for Shell { let body = if self.auth == AuthStep::Ready { // The unlocked app: macOS title bar above the two-pane shell grid // (sidebar | [breadcrumb / content / status strip]) + command palette. + self.autofill_shield_recipient(window, cx); let title_bar = self.render_title_bar(cx); let content = match (self.selection, self.surface) { (_, Surface::Settings) => self.render_settings(window, cx).into_any_element(), diff --git a/crates/deckard-app/src/shell_chrome.rs b/crates/deckard-app/src/shell_chrome.rs index fdf033d..dd545cd 100644 --- a/crates/deckard-app/src/shell_chrome.rs +++ b/crates/deckard-app/src/shell_chrome.rs @@ -387,6 +387,21 @@ impl Shell { muted }; + // An active shield's lifecycle (the "where's my money?" reassurance line). The glyph + // token maps to a small colored dot: amber in-flight, success done, danger failed. + let shield_chip = self.shield_status.as_ref().map(|st| { + let color = match st.glyph() { + "check-filled" => theme.success, + "x-ring" => theme.danger, + _ => theme::amber(theme.is_dark()), + }; + h_flex() + .items_center() + .gap_1p5() + .child(div().size(px(6.0)).rounded_full().bg(color)) + .child(div().text_color(color).child(st.to_string())) + }); + h_flex() .h(px(25.0)) .flex_shrink_0() @@ -397,7 +412,13 @@ impl Shell { .border_t_1() .border_color(border) .text_xs() - .child(div().text_color(status_color).child(status_line)) + .child( + h_flex() + .items_center() + .gap_3() + .children(shield_chip) + .child(div().text_color(status_color).child(status_line)), + ) .child(div().text_color(muted).child("Ethereum")) } diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index a2b2a69..67d4bf0 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -58,7 +58,6 @@ impl Shell { let border = theme.border; let surface = theme.secondary; let mono: SharedString = theme.mono_font_family.clone(); - let masked = self.mask; // A small bordered key-hint chip, e.g. ⌘K. let chip = move |keys: String, label: String| { @@ -181,53 +180,9 @@ impl Shell { ), ), ) - // Balance hero: the click-to-hide Total (mono-for-money, dimmed - // decimals, weight 600) over a thin Splits-style allocation bar. - // Clicking the Total toggles the privacy mask (one of its triggers). - .child( - v_flex() - .w_full() - .gap_3() - .child( - div() - .id("balance-hero") - .cursor_pointer() - .text_3xl() - .font_weight(FontWeight::SEMIBOLD) - .map(|el| match native_wei { - Some(wei) => el.child(money( - wei, - 18, - 4, - Some("ETH"), - masked, - mono.clone(), - fg, - muted, - )), - None => el - .font_family(mono.clone()) - .text_color(muted) - .child("—"), - }) - .on_click(cx.listener(|this, _, _, cx| this.toggle_mask(cx))), - ) - .children(native_wei.map(|_| { - // v1: the whole balance is public; the bar is one segment - // (Wave 2 splits it into Private/Public). Flattens when masked. - allocation_bar( - vec![AllocSegment { - label: "Public".into(), - fraction: 1.0, - tone: id_square, - }], - masked, - border, - muted, - fg, - ) - })), - ) + // 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. @@ -271,6 +226,137 @@ impl Shell { ) } + /// The merged Total hero (Wave 2 T10): `Total = public + private` when both are known, a + /// Private/Public allocation bar (Private first, neutral shield tone — off the actor axis), + /// and the composition lines. While the private sync runs the total stays the known public + /// (never `public + 0`) and the private line reads "syncing…". Clicking the Total masks it. + fn render_shielded_hero( + &self, + native_wei: Option, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let border = theme.border; + let mono: SharedString = theme.mono_font_family.clone(); + let masked = self.mask; + let is_dark = theme.is_dark(); + let public_tone = theme::identity_square(is_dark); + let shield_tone = theme::shield(is_dark); + + let snap = self.shielded.as_ref().map(|h| h.snapshot()); + let private_wei = snap.as_ref().and_then(|s| s.shielded_wei); + let syncing = snap.as_ref().map(|s| s.syncing).unwrap_or(false); + let public = native_wei; + + // Total: sum only when the private side is known; never `public + 0` while syncing. + let total = match (public, private_wei) { + (Some(p), Some(s)) => Some(p.saturating_add(s)), + (Some(p), None) => Some(p), + _ => None, + }; + + let hero = div() + .id("balance-hero") + .cursor_pointer() + .text_3xl() + .font_weight(FontWeight::SEMIBOLD) + .map(|el| match total { + Some(wei) => el.child(money( + wei, + 18, + 4, + Some("ETH"), + masked, + mono.clone(), + fg, + muted, + )), + None => el.font_family(mono.clone()).text_color(muted).child("—"), + }) + .on_click(cx.listener(|this, _, _, cx| this.toggle_mask(cx))); + + // No balance yet (first sync): just the placeholder hero. + let Some(pub_wei) = public else { + return v_flex().w_full().gap_3().child(hero).into_any_element(); + }; + + // A real Private/Public split once the private side is known, else a single Public bar. + let bar = match private_wei { + Some(priv_wei) => { + let total_wei = pub_wei.saturating_add(priv_wei); + allocation_bar( + vec![ + AllocSegment { + label: "Private".into(), + fraction: fraction(priv_wei, total_wei), + tone: shield_tone, + }, + AllocSegment { + label: "Public".into(), + fraction: fraction(pub_wei, total_wei), + tone: public_tone, + }, + ], + masked, + border, + muted, + fg, + ) + } + None => allocation_bar( + vec![AllocSegment { + label: "Public".into(), + fraction: 1.0, + tone: public_tone, + }], + masked, + border, + muted, + fg, + ), + }; + + v_flex() + .w_full() + .gap_3() + .child(hero) + .child(bar) + .child( + v_flex() + .w_full() + .gap_1() + .child(composition_line( + "Private", + shield_tone, + private_wei, + syncing, + masked, + mono.clone(), + fg, + muted, + )) + .child(composition_line( + "Public", + public_tone, + Some(pub_wei), + false, + masked, + mono.clone(), + fg, + muted, + )), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child("Private is WETH-equivalent, net of the 0.25% shield fee."), + ) + .into_any_element() + } + /// The holdings region: skeleton on first sync, empty-state when nothing held, /// otherwise the live rows plus the listed-tokens-only caveat. fn render_holdings( @@ -638,6 +724,43 @@ fn allocation_bar( .into_any_element() } +/// `part / total` as a 0..=1 fraction, via integer (bps) math — f32 only at the edge so a +/// huge `U256` can't lose precision in the ratio. Zero `total` → 0. +fn fraction(part: U256, total: U256) -> f32 { + if total.is_zero() { + return 0.0; + } + let bps = (part.saturating_mul(U256::from(10_000u64)) / total).min(U256::from(10_000u64)); + let bps: u64 = bps.try_into().unwrap_or(0); + bps as f32 / 10_000.0 +} + +/// One composition line: a tone chip + label + the (maskable) amount, or "syncing…" while the +/// private side hasn't landed (never a fake zero). +#[allow(clippy::too_many_arguments)] +fn composition_line( + label: &'static str, + tone: Hsla, + wei: Option, + syncing: bool, + masked: bool, + mono: SharedString, + fg: Hsla, + muted: Hsla, +) -> impl IntoElement { + h_flex() + .w_full() + .items_center() + .gap_2() + .child(div().size(px(8.0)).rounded(px(2.0)).bg(tone)) + .child(div().flex_1().text_xs().text_color(muted).child(label)) + .child(div().text_xs().map(|el| match wei { + Some(w) => el.child(money(w, 18, 4, Some("ETH"), masked, mono, fg, muted)), + None if syncing => el.text_color(muted).child("syncing…"), + None => el.text_color(muted).child("—"), + })) +} + /// A shimmer-free skeleton placeholder row for the first-sync state. fn skeleton_row(surface: gpui::Hsla, border: gpui::Hsla) -> impl IntoElement { h_flex() diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index 05bf2f5..4cf1f25 100644 --- a/crates/deckard-contract/src/lib.rs +++ b/crates/deckard-contract/src/lib.rs @@ -37,7 +37,8 @@ pub use mock::MockSigner; pub use policy::{evaluate, ApprovalMode, Policy}; pub use read_status::ReadStatus; pub use rpc::{ - ApprovalStatus, BalanceReport, ExecuteResult, SignerRequest, SignerResponse, UnlockOutcome, + ApprovalStatus, BalanceReport, ExecuteResult, RailgunViewGrant, SignerRequest, SignerResponse, + UnlockOutcome, }; pub use shield_status::ShieldStatus; pub use signer::Signer; diff --git a/crates/deckard-contract/src/rpc.rs b/crates/deckard-contract/src/rpc.rs index 110de44..d881ef5 100644 --- a/crates/deckard-contract/src/rpc.rs +++ b/crates/deckard-contract/src/rpc.rs @@ -43,6 +43,10 @@ pub enum SignerRequest { Address, /// → [`BalanceReport`]. Balance { shielded: bool }, + /// Export the read-only Railgun view grant (0zk address + viewing key) for shielded-balance + /// sync → [`SignerResponse::RailgunView`]. The daemon refuses unless it's unlocked AND the + /// derivation known-answer test passes (no grant from an unverified derivation). + RailgunViewGrant { chain_id: u64, index: u32 }, } /// `deckard-signerd` → `deckard-mcp`. One variant per request shape. @@ -58,6 +62,27 @@ pub enum SignerResponse { Policy(Policy), Address(Address), Balance(BalanceReport), + /// Reply to `RailgunViewGrant`, or a `Decision::Deny` when locked / the gate fails. + RailgunView(RailgunViewGrant), +} + +/// A read-only Railgun grant: the 0zk `address` + the `viewing_key` (hex). NOT the spending +/// key — the app can SEE private balances but cannot spend them (spending stays in the +/// daemon). The viewing key reveals private note history, so it's a secret: `Debug` is +/// redacted and callers must treat it accordingly. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct RailgunViewGrant { + pub address: String, + pub viewing_key: String, +} + +impl core::fmt::Debug for RailgunViewGrant { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("RailgunViewGrant") + .field("address", &self.address) + .field("viewing_key", &"") + .finish() + } } /// Outcome of `Unlock`. Carries the wallet address on success — never any key material, diff --git a/crates/deckard-core/src/keystore.rs b/crates/deckard-core/src/keystore.rs index 1a96b27..0de1f0a 100644 --- a/crates/deckard-core/src/keystore.rs +++ b/crates/deckard-core/src/keystore.rs @@ -507,6 +507,21 @@ impl UnlockedVault { ); crate::railgun_keys::railgun_address_from_entropy(&self.secret, chain_id, index) } + + /// The read-only view grant `(0zk address, viewing-key hex)` for shielded-balance sync. + /// The spending key is never exported. Errors for a raw-key import. + #[cfg(feature = "shield")] + pub fn railgun_view_grant( + &self, + chain_id: u64, + index: u32, + ) -> anyhow::Result<(String, String)> { + anyhow::ensure!( + self.kind.has_phrase(), + "imported raw key has no Railgun keys" + ); + crate::railgun_keys::railgun_view_grant_from_entropy(&self.secret, chain_id, index) + } } // --- crypto helpers --- diff --git a/crates/deckard-core/src/lib.rs b/crates/deckard-core/src/lib.rs index b091d38..61915a8 100644 --- a/crates/deckard-core/src/lib.rs +++ b/crates/deckard-core/src/lib.rs @@ -44,6 +44,9 @@ pub mod railgun_keys; /// `build_shield_native_intent` stub below returns a clear error (never a fake success). #[cfg(feature = "shield")] pub mod shield; +/// Read-only Railgun shielded-balance sync actor (Wave-2 T9). Gated behind `shield`. +#[cfg(feature = "shield")] +pub mod shielded; pub mod tokens; pub use balances::{fetch_portfolio, format_amount, Portfolio, TokenBalance}; @@ -63,6 +66,8 @@ pub use shield::{build_shield_native_intent, RailgunAddress}; pub use railgun_keys::{ known_answer_ok, railgun_address_from_entropy, railgun_keys_from_entropy, RailgunKeys, }; +#[cfg(feature = "shield")] +pub use shielded::{ShieldedHandle, ShieldedSnapshot}; pub use tokens::{TokenInfo, DEFAULT_TOKENS}; /// Feature-off stub: when `shield` is compiled out, the symbol still exists so the daemon diff --git a/crates/deckard-core/src/railgun_keys.rs b/crates/deckard-core/src/railgun_keys.rs index d9d648a..111d535 100644 --- a/crates/deckard-core/src/railgun_keys.rs +++ b/crates/deckard-core/src/railgun_keys.rs @@ -143,6 +143,22 @@ pub fn railgun_address_from_entropy( Ok(address.to_string()) } +/// The read-only view grant for account `index` on `chain_id`: the 0zk address + the viewing +/// key as hex. The spending key is intentionally NOT returned — this is the smaller grant the +/// daemon hands the app for balance sync (it can see private notes, not spend them). +pub fn railgun_view_grant_from_entropy( + entropy: &[u8], + chain_id: u64, + index: u32, +) -> anyhow::Result<(String, String)> { + let keys = railgun_keys_from_entropy(entropy, index)?; + let viewing_key = keys.viewing.to_hex(); + let address = + RailgunAddress::from_private_keys(keys.spending, keys.viewing, ChainId::evm(chain_id)) + .to_string(); + Ok((address, viewing_key)) +} + /// The runtime gate: re-derive Railgun's own known mnemonic and compare the 0zk address to the /// engine's published vector. **The app must not display a shielded balance unless this is /// true** — a wrong derivation would otherwise show a silent, wrong (typically $0) balance. diff --git a/crates/deckard-core/src/shielded.rs b/crates/deckard-core/src/shielded.rs new file mode 100644 index 0000000..099bb41 --- /dev/null +++ b/crates/deckard-core/src/shielded.rs @@ -0,0 +1,232 @@ +//! Shielded-balance sync — the read-only Railgun account actor (Wave-2 T9). +//! +//! Mirrors [`EthProvider`](crate::eth::EthProvider): one OS thread with a current-thread tokio +//! runtime owns the Railgun provider and syncs in the background, updating a cached snapshot the +//! GUI reads instantly (so a read never blocks behind a full sync). The app holds only a +//! read-only VIEW grant — the viewing key + 0zk address, never the spending key — so it can SEE +//! private balances, not spend them. Sync rides the raw RPC + Subsquid (Railgun's `getLogs` +//! path is NOT Helios-verified), so a synced private balance is honestly `Unsynced`, never +//! `Verified`; and while the first sync runs the balance is UNKNOWN, never silently zero. +//! +//! The `ViewOnlySigner` and the underlying `RailgunProvider` are private to this module: a real +//! spend must go back through the daemon, never "reuse" this read-only signer. + +use std::sync::{Arc, Mutex}; + +use alloy::providers::{Provider, ProviderBuilder}; +use deckard_contract::{RailgunViewGrant, ReadStatus}; +use railgun::{ + account::{ + address::RailgunAddress, + chain::ChainId, + signer::{RailgunSigner, RailgunSignerError}, + }, + builder::RailgunBuilder, + caip::AssetId, + chain_config::ChainConfig, + crypto::keys::{HexKey, SpendingKey, SpendingSignature, ViewingKey}, + indexer::syncer::{ChainedSyncer, RpcSyncer, SubsquidSyncer}, +}; + +use crate::U256; + +/// The cached shielded-balance state the GUI renders. `shielded_wei` is `None` until the first +/// successful sync — UNKNOWN, never silently zero. `syncing` marks an in-flight sync; `error` +/// holds the last failure reason. +#[derive(Clone, Default)] +pub struct ShieldedSnapshot { + pub shielded_wei: Option, + pub syncing: bool, + pub error: Option, +} + +impl ShieldedSnapshot { + /// The honest trust label: a private balance is NEVER `Verified` in v1 — the sync rides the + /// raw RPC / Subsquid, not the Helios-verified read path. + pub fn read_status(&self) -> ReadStatus { + ReadStatus::unsynced("private sync uses unverified RPC/subsquid") + } +} + +/// A handle to the background Railgun sync. The GUI reads [`snapshot`](Self::snapshot) and +/// triggers [`resync`](Self::resync) (e.g. after a shield). Dropping it stops the worker. +pub struct ShieldedHandle { + cached: Arc>, + resync: flume::Sender<()>, +} + +impl ShieldedHandle { + /// Spawn the sync worker for `chain_id` using the read-only `grant`. The worker builds its + /// own raw provider; any build/parse failure surfaces as the snapshot's `error`, never a + /// panic (fail-closed — the UI never hangs and never shows a fabricated balance). + pub fn spawn(rpc_url: String, chain_id: u64, grant: RailgunViewGrant) -> Self { + let cached = Arc::new(Mutex::new(ShieldedSnapshot { + syncing: true, + ..Default::default() + })); + let (resync, resync_rx) = flume::bounded(1); + let worker_cached = cached.clone(); + std::thread::spawn(move || run_worker(rpc_url, chain_id, grant, worker_cached, resync_rx)); + Self { cached, resync } + } + + /// The current cached snapshot — instant, never blocks on a sync. Returns the default + /// (unknown, not syncing) if the lock was poisoned. + pub fn snapshot(&self) -> ShieldedSnapshot { + self.cached.lock().map(|s| s.clone()).unwrap_or_default() + } + + /// Request a re-sync (e.g. after a shield broadcast). Coalesces: a pending request is kept, + /// extra requests are dropped, so syncs never overlap. + pub fn resync(&self) { + let _ = self.resync.try_send(()); + } +} + +/// The worker: build the provider + Railgun account once, then sync on spawn and on every +/// `resync` signal, folding each result into the shared snapshot. +fn run_worker( + rpc_url: String, + chain_id: u64, + grant: RailgunViewGrant, + cached: Arc>, + resync_rx: flume::Receiver<()>, +) { + // Startup-fatal boundary (mirrors `eth::run_worker`): a runtime we can't build leaves the + // worker unable to do anything; a clear panic beats silently servicing nothing. + #[allow(clippy::expect_used)] + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio current-thread runtime"); + + rt.block_on(async move { + let Some(chain) = ChainConfig::from_chain_id(chain_id) else { + set_error(&cached, format!("unsupported chain {chain_id}")); + return; + }; + let signer = match ViewOnlySigner::from_grant(&grant, chain_id) { + Ok(s) => Arc::new(s), + Err(e) => return set_error(&cached, e), + }; + let address = signer.address(); + let weth = AssetId::Erc20(chain.wrapped_base_token); + + let Ok(url) = rpc_url.parse() else { + set_error(&cached, format!("bad rpc url: {rpc_url}")); + return; + }; + let provider = ProviderBuilder::new().connect_http(url).erased(); + let syncer = Arc::new( + ChainedSyncer::new() + .then(SubsquidSyncer::new(&chain.subsquid_endpoint)) + .then(RpcSyncer::new(chain.clone(), provider.clone()).with_batch_size(1000)), + ); + let mut railgun = match RailgunBuilder::new(chain, provider) + .with_utxo_syncer(syncer) + .build() + .await + { + Ok(r) => r, + Err(e) => return set_error(&cached, format!("railgun build: {e}")), + }; + if let Err(e) = railgun.register(signer).await { + return set_error(&cached, format!("register: {e}")); + } + + // Initial sync, then re-sync on each trigger. Single-threaded → no overlap. + loop { + mark_syncing(&cached); + match railgun.sync().await { + Ok(()) => { + let wei = railgun + .balance(address) + .await + .get(&weth) + .copied() + .unwrap_or(0); + set_synced(&cached, U256::from(wei)); + } + Err(e) => set_error(&cached, format!("sync: {e}")), + } + // Wait for the next resync; the channel closing means the handle was dropped. + if resync_rx.recv_async().await.is_err() { + break; + } + } + }); +} + +fn mark_syncing(cached: &Mutex) { + if let Ok(mut s) = cached.lock() { + s.syncing = true; + s.error = None; + } +} + +fn set_synced(cached: &Mutex, wei: U256) { + if let Ok(mut s) = cached.lock() { + s.shielded_wei = Some(wei); + s.syncing = false; + s.error = None; + } +} + +fn set_error(cached: &Mutex, reason: impl Into) { + if let Ok(mut s) = cached.lock() { + s.syncing = false; + s.error = Some(reason.into()); + } +} + +/// A read-only Railgun signer: the real viewing key + a precomputed address, with a +/// NON-functional dummy spending key. The sync/balance path uses only `address()` + +/// `viewing_key()`, so the dummy is never exercised; this type is module-private and the +/// `RailgunProvider` is never exposed, so it can never be wired to a real spend. +struct ViewOnlySigner { + address: RailgunAddress, + viewing: ViewingKey, + dummy_spending: SpendingKey, + chain: ChainId, +} + +impl ViewOnlySigner { + fn from_grant(grant: &RailgunViewGrant, chain_id: u64) -> Result { + let address = grant + .address + .parse::() + .map_err(|e| format!("bad 0zk address: {e}"))?; + let viewing = ViewingKey::from_hex(&grant.viewing_key) + .map_err(|e| format!("bad viewing key: {e}"))?; + // A throwaway spending key — never used (address() is overridden, sign() unreachable). + let dummy_spending = + SpendingKey::from_hex(&"0".repeat(64)).map_err(|e| format!("dummy key: {e}"))?; + Ok(Self { + address, + viewing, + dummy_spending, + chain: ChainId::evm(chain_id), + }) + } +} + +impl RailgunSigner for ViewOnlySigner { + fn chain_id(&self) -> ChainId { + self.chain + } + fn viewing_key(&self) -> ViewingKey { + self.viewing + } + fn spending_key(&self) -> SpendingKey { + self.dummy_spending + } + // Override so the address is the granted one, never re-derived from the dummy spending key. + fn address(&self) -> RailgunAddress { + self.address + } + fn sign(&self, inputs: U256) -> Result { + // Unreachable on the read-only balance path; signs with the inert dummy key. This signer + // is module-private and never wired to a spend — real spends go through the daemon. + Ok(self.dummy_spending.sign(inputs)) + } +} diff --git a/crates/deckard-signerd/src/client.rs b/crates/deckard-signerd/src/client.rs index 4c270ff..569466d 100644 --- a/crates/deckard-signerd/src/client.rs +++ b/crates/deckard-signerd/src/client.rs @@ -11,8 +11,8 @@ use std::time::{Duration, Instant}; use tokio::net::UnixStream; use deckard_contract::{ - ApprovalStatus, Decision, ExecuteResult, Intent, RequestId, SignerRequest, SignerResponse, - UnlockOutcome, + ApprovalStatus, Decision, ExecuteResult, Intent, RailgunViewGrant, RequestId, SignerRequest, + SignerResponse, UnlockOutcome, }; use crate::frame; @@ -144,6 +144,23 @@ impl SignerClient { } } + /// Blocking: fetch the read-only Railgun view grant (0zk address + viewing key) for + /// shielded-balance sync. A locked daemon or a failed derivation gate comes back as a + /// `Decision::Deny`, surfaced here as an error. + pub fn railgun_view_grant_blocking( + &self, + chain_id: u64, + index: u32, + ) -> anyhow::Result { + match self.request_blocking(&SignerRequest::RailgunViewGrant { chain_id, index })? { + SignerResponse::RailgunView(grant) => Ok(grant), + SignerResponse::Decision(Decision::Deny { reason }) => { + anyhow::bail!("railgun view grant denied: {reason}") + } + other => Err(unexpected("RailgunViewGrant", other)), + } + } + /// Blocking poll of an approval loop → its current [`ApprovalStatus`]. pub fn status_blocking(&self, request_id: RequestId) -> anyhow::Result { match self.request_blocking(&SignerRequest::Status { request_id })? { diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index 161df31..10eaf1d 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -21,7 +21,7 @@ use zeroize::Zeroizing; use deckard_contract::{ evaluate, ApprovalStatus, BalanceReport, Decision, ExecuteResult, Intent, IntentKind, Policy, - ReadStatus, RequestId, SignerRequest, SignerResponse, UnlockOutcome, + RailgunViewGrant, ReadStatus, RequestId, SignerRequest, SignerResponse, UnlockOutcome, }; use deckard_core::{UnlockedVault, Vault}; @@ -217,9 +217,50 @@ impl Daemon { SignerRequest::Balance { shielded } => { SignerResponse::Balance(self.balance(shielded).await) } + SignerRequest::RailgunViewGrant { chain_id, index } => { + self.railgun_view_grant(chain_id, index) + } + } + } + + /// Export the read-only Railgun view grant (0zk address + viewing key) for the unlocked + /// vault. Refuses if locked, and — crucially — if the derivation known-answer test fails: + /// a grant from an unverified derivation would let the app show a wrong/silent-$0 private + /// balance. The spending key never leaves the daemon. + #[cfg(feature = "shield")] + fn railgun_view_grant(&self, chain_id: u64, index: u32) -> SignerResponse { + let vault = match &self.state { + VaultState::Unlocked { vault, .. } => vault, + VaultState::Locked => { + return SignerResponse::Decision(Decision::Deny { + reason: "locked".into(), + }) + } + }; + if !deckard_core::known_answer_ok() { + return SignerResponse::Decision(Decision::Deny { + reason: "derivation_unverified".into(), + }); + } + match vault.railgun_view_grant(chain_id, index) { + Ok((address, viewing_key)) => SignerResponse::RailgunView(RailgunViewGrant { + address, + viewing_key, + }), + Err(e) => SignerResponse::Decision(Decision::Deny { + reason: format!("railgun_keys: {}", one_line(&e)), + }), } } + /// Without the `shield` feature there is no Railgun derivation to grant. + #[cfg(not(feature = "shield"))] + fn railgun_view_grant(&self, _chain_id: u64, _index: u32) -> SignerResponse { + SignerResponse::Decision(Decision::Deny { + reason: "shield_unavailable".into(), + }) + } + /// Read the keystore, decrypt under `passphrase`, and hold the key. The raw passphrase is /// moved into `Zeroizing` immediately and never echoed or logged. async fn unlock(&mut self, passphrase: String) -> UnlockOutcome { From 3995026f11dc02fd1c0062b5f7576334c9360685 Mon Sep 17 00:00:00 2001 From: hellno Date: Tue, 9 Jun 2026 14:46:00 +0200 Subject: [PATCH 7/7] fix: address Wave-2 codex review P1/P2s (honesty + lifecycle hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final adversarial codex pass on T8/T9/T10: - [P1] app now independently gates shielded UI on known_answer_ok() before spawning the sync actor (defense-in-depth alongside the daemon gate). - [P1] grant fetch carries a session/auth epoch (bumped on lock+unlock); a slow reply from a prior session can't re-install a stale handle/address after lock. - [P1] shield lifecycle is honest: broadcast sets ShieldStatus::Sending (not the "Confirmed" SyncingPrivate), and the watcher settles to PrivateSpendable ONLY on a real synced value, Failed on error, and stays in-flight on timeout — never "Spendable now · $0" off a failed/hung sync. - [P1] hero is labelled "Total" only when private is known; otherwise "Public · private balance still syncing" (never reads public as public+0). - [P2] private balance footnote now states it's synced over raw RPC and not independently verified. - [P2] RPC change re-points the shielded worker too (drops stale private state). - [P2] allocation bar omits a zero-value segment (no phantom 3px Private sliver). - [P2] lock clears the shield inputs on the next render (no prior wallet's 0zk lingering); reset_shield on lock. - [P3] status strip uses circular CircleCheck/CircleX glyphs; contract gains a RailgunViewGrant roundtrip + Debug-redaction test. Acknowledged/deferred (honest behavior already holds): the ViewOnlySigner dummy spending key (kohaku has no view-only signer + RailgunSignerError has no public ctor; module-private + verified-unreachable), and a sync timeout/backoff (needs a tokio `time` feature widening) — noted as follow-ups. DoD green: fmt, clippy (default + tray), cargo test --workspace; no new deps. --- crates/deckard-app/src/shell.rs | 106 ++++++++++++++++++------- crates/deckard-app/src/shell_chrome.rs | 22 ++++- crates/deckard-app/src/welcome.rs | 23 ++++-- crates/deckard-contract/src/lib.rs | 23 ++++++ 4 files changed, 139 insertions(+), 35 deletions(-) diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index c6ab87d..1540c5a 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -205,6 +205,12 @@ pub struct Shell { recipient_autofilled: bool, /// The active shield's lifecycle, surfaced in the status strip (None when idle). pub shield_status: Option, + /// Bumped on every unlock/lock so a slow grant fetch from a prior session can't install a + /// stale handle/address after the wallet locked or a different wallet unlocked. + auth_epoch: u64, + /// Set on lock so the next Ready render clears the shield inputs (which a listener can't — + /// `set_value` needs a `Window`), preventing a prior wallet's 0zk recipient from lingering. + pending_shield_clear: bool, // --- auth / keystore (Chunk 3) --- pub auth: AuthStep, @@ -441,6 +447,8 @@ impl Shell { railgun_address: None, recipient_autofilled: false, shield_status: None, + auth_epoch: 0, + pending_shield_clear: false, auth, auth_error: None, auth_busy: false, @@ -520,6 +528,10 @@ impl Shell { self.railgun_address = None; self.recipient_autofilled = false; self.shield_status = None; + // 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.auth = AuthStep::Unlock; self.palette_open = false; cx.notify(); @@ -810,6 +822,7 @@ impl Shell { self.auth_error = None; self.selection = Selection::Wallet; self.surface = Surface::Home; + self.auth_epoch = self.auth_epoch.wrapping_add(1); self.retarget(cx); self.kick_railgun_grant(cx); } @@ -819,19 +832,29 @@ impl Shell { /// and start watching it. If the daemon refuses (locked / gate failed), there's simply no /// shielded UI — honest, never a fabricated private balance. fn kick_railgun_grant(&mut self, cx: &mut Context) { + // Belt-and-suspenders: the daemon already gates the grant on the derivation KAT, but + // never show a shielded balance unless the app independently re-verifies it too. + if !deckard_core::known_answer_ok() { + return; + } let client = self.signer.client(); let chain_id = SHIELD_CHAIN_ID; let rpc = self.settings.effective_rpc(); + let epoch = self.auth_epoch; let task = cx.background_spawn(async move { client.railgun_view_grant_blocking(chain_id, 0) }); cx.spawn(async move |this, cx| { let grant = task.await; this.update(cx, |this, cx| { + // Drop a reply for a session that has since locked / re-unlocked. + if this.auth_epoch != epoch || this.auth != AuthStep::Ready { + return; + } if let Ok(grant) = grant { this.railgun_address = Some(grant.address.clone()); this.recipient_autofilled = false; this.shielded = Some(ShieldedHandle::spawn(rpc, chain_id, grant)); - this.watch_shielded_sync(cx); + this.watch_shielded_sync(false, cx); } cx.notify(); }) @@ -840,35 +863,49 @@ impl Shell { .detach(); } - /// Poll the shielded snapshot while a sync runs so the UI reflects progress + completion - /// (the actor's cached state isn't a GPUI entity, so we tick it). Caps the poll so a hung - /// sync can't loop forever. When a post-shield sync settles, advance `ShieldStatus`. - fn watch_shielded_sync(&self, cx: &mut Context) { + /// Poll the shielded snapshot while a sync runs so the UI reflects progress (the actor's + /// cached state isn't a GPUI entity, so we tick it). Capped so a hung sync can't loop + /// forever. With `drive_lifecycle`, once the sync SETTLES it advances `ShieldStatus` + /// honestly: a clean synced balance → `PrivateSpendable(wei)`, a sync error → `Failed`, a + /// timeout → stays in-flight (never claims "spendable" with a fabricated zero). + fn watch_shielded_sync(&self, drive_lifecycle: bool, cx: &mut Context) { cx.spawn(async move |this, cx| { + let mut timed_out = true; for _ in 0..90 { cx.background_executor().timer(Duration::from_secs(2)).await; let syncing = this.update(cx, |this, cx| { cx.notify(); this.shielded.as_ref().is_some_and(|h| h.snapshot().syncing) }); - if !matches!(syncing, Ok(true)) { - break; + match syncing { + Ok(true) => continue, + Ok(false) => { + timed_out = false; + break; + } + Err(_) => return, // the view is gone } } - // Sync settled: if this was the post-shield sync, the note is now visible. + if !drive_lifecycle || timed_out { + return; // an initial/refresh watch, or a hung sync — don't touch the lifecycle + } this.update(cx, |this, cx| { - if matches!( - this.shield_status, - Some(ShieldStatus::SyncingPrivate { .. }) - ) { - let wei = this - .shielded - .as_ref() - .and_then(|h| h.snapshot().shielded_wei) - .unwrap_or(U256::ZERO); - this.shield_status = Some(ShieldStatus::PrivateSpendable { shielded_wei: wei }); - cx.notify(); + // Only advance an in-flight shield, and only on a real settled result. + if !matches!(this.shield_status, Some(ShieldStatus::Sending)) { + return; } + let snap = this.shielded.as_ref().map(|h| h.snapshot()); + this.shield_status = match snap { + Some(s) if s.error.is_some() => Some(ShieldStatus::Failed { + reason: s.error.unwrap_or_default(), + }), + Some(s) => match s.shielded_wei { + Some(wei) => Some(ShieldStatus::PrivateSpendable { shielded_wei: wei }), + None => return, // settled without a value — leave it in-flight + }, + None => return, + }; + cx.notify(); }) .ok(); }) @@ -1029,6 +1066,12 @@ impl Shell { self.current_rpc = url.clone(); self.eth = EthProvider::spawn(url); 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. + if self.auth == AuthStep::Ready { + self.shielded = None; + self.kick_railgun_grant(cx); + } } /// Select a sidebar entity: switch the selection and reset to its Home view. @@ -1204,13 +1247,14 @@ impl Shell { match res { Ok(ExecuteResult::Broadcast { tx_hash }) => { this.shield_tx = Some(tx_hash); - // The deposit is on its way to a private note; re-sync to surface it - // and drive the lifecycle (SyncingPrivate → PrivateSpendable). - this.shield_status = Some(ShieldStatus::SyncingPrivate { 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". + this.shield_status = Some(ShieldStatus::Sending); if let Some(h) = &this.shielded { h.resync(); } - this.watch_shielded_sync(cx); + this.watch_shielded_sync(true, cx); } Ok(ExecuteResult::Denied { reason }) => { this.shield_error = @@ -1317,9 +1361,17 @@ impl Shell { self.toggle_mask(cx); } - /// Pre-fill the shield recipient with the user's own 0zk address once the grant arrives - /// (the user can still edit it). Runs from `render`, the only place with a `Window`. - fn autofill_shield_recipient(&mut self, window: &mut Window, cx: &mut Context) { + /// Clear a prior session's shield inputs once after lock, then pre-fill the recipient with + /// the user's own 0zk address once the grant arrives (still editable). Runs from `render`, + /// the only place with a `Window` for `set_value`. + 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 + .update(cx, |i, cx| i.set_value("", window, cx)); + self.shield_recipient + .update(cx, |i, cx| i.set_value("", window, cx)); + } if self.recipient_autofilled { return; } @@ -1373,7 +1425,7 @@ impl Render for Shell { let body = if self.auth == AuthStep::Ready { // The unlocked app: macOS title bar above the two-pane shell grid // (sidebar | [breadcrumb / content / status strip]) + command palette. - self.autofill_shield_recipient(window, cx); + self.prepare_shield_inputs(window, cx); let title_bar = self.render_title_bar(cx); let content = match (self.selection, self.surface) { (_, Surface::Settings) => self.render_settings(window, cx).into_any_element(), diff --git a/crates/deckard-app/src/shell_chrome.rs b/crates/deckard-app/src/shell_chrome.rs index dd545cd..1348aac 100644 --- a/crates/deckard-app/src/shell_chrome.rs +++ b/crates/deckard-app/src/shell_chrome.rs @@ -390,15 +390,33 @@ impl Shell { // An active shield's lifecycle (the "where's my money?" reassurance line). The glyph // token maps to a small colored dot: amber in-flight, success done, danger failed. let shield_chip = self.shield_status.as_ref().map(|st| { - let color = match st.glyph() { + let token = st.glyph(); + let color = match token { "check-filled" => theme.success, "x-ring" => theme.danger, _ => theme::amber(theme.is_dark()), }; + // DESIGN status-as-glyph: filled check / x-ring from the icon kit; pending has no + // clock icon in the kit, so it's a small amber dot. + let glyph = match token { + "check-filled" => Icon::new(IconName::CircleCheck) + .text_color(color) + .small() + .into_any_element(), + "x-ring" => Icon::new(IconName::CircleX) + .text_color(color) + .small() + .into_any_element(), + _ => div() + .size(px(6.0)) + .rounded_full() + .bg(color) + .into_any_element(), + }; h_flex() .items_center() .gap_1p5() - .child(div().size(px(6.0)).rounded_full().bg(color)) + .child(glyph) .child(div().text_color(color).child(st.to_string())) }); diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index 67d4bf0..b98103b 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -318,10 +318,19 @@ impl Shell { ), }; + // Label the hero honestly: a real Total once private is known, otherwise public-only + // (so the big figure is never read as "public + 0" while the private side syncs). + let caption = match (private_wei, snap.is_some()) { + (Some(_), _) => "Total", + (None, true) => "Public · private balance still syncing", + (None, false) => "", + }; + v_flex() .w_full() .gap_3() .child(hero) + .children((!caption.is_empty()).then(|| div().text_xs().text_color(muted).child(caption))) .child(bar) .child( v_flex() @@ -348,12 +357,9 @@ impl Shell { muted, )), ) - .child( - div() - .text_xs() - .text_color(muted) - .child("Private is WETH-equivalent, net of the 0.25% shield fee."), - ) + .child(div().text_xs().text_color(muted).child( + "Private is WETH-equivalent, net of the 0.25% fee, and synced over raw RPC (not independently verified).", + )) .into_any_element() } @@ -692,6 +698,11 @@ fn allocation_bar( .overflow_hidden(); for seg in &segments { let frac = seg.fraction.clamp(0.0, 1.0); + // A zero-value segment is omitted — the ≥3px minimum is only for a NON-zero share + // (DESIGN §Balance hero), so an empty Private slice never shows a phantom sliver. + if frac <= 0.0 { + continue; + } bar = bar.child( div() .h_full() diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index 4cf1f25..73f41e6 100644 --- a/crates/deckard-contract/src/lib.rs +++ b/crates/deckard-contract/src/lib.rs @@ -176,6 +176,29 @@ mod roundtrip_tests { roundtrip(&SignerRequest::Address); roundtrip(&SignerRequest::Balance { shielded: true }); roundtrip(&SignerRequest::Balance { shielded: false }); + roundtrip(&SignerRequest::RailgunViewGrant { + chain_id: 1, + index: 0, + }); + } + + #[test] + fn railgun_view_grant_roundtrips_and_redacts_debug() { + let grant = RailgunViewGrant { + address: "0zk1example".into(), + viewing_key: "deadbeefdeadbeef".into(), + }; + roundtrip(&SignerResponse::RailgunView(grant.clone())); + // The viewing key is a secret: it must never appear in Debug output. + let dbg = format!("{grant:?}"); + assert!( + dbg.contains(""), + "viewing key not redacted: {dbg}" + ); + assert!( + !dbg.contains("deadbeef"), + "viewing key leaked in Debug: {dbg}" + ); } #[test]