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-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/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 84f3564..695c9b9 100644 --- a/crates/deckard-app/src/main.rs +++ b/crates/deckard-app/src/main.rs @@ -7,12 +7,16 @@ //! 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; mod receive; mod settings; mod settings_view; mod shell; +mod shell_chrome; +mod shield_view; mod signer; mod theme; #[cfg(feature = "tray")] @@ -43,7 +47,8 @@ gpui::actions!( ToggleTheme, NewItem, GoBack, - TogglePalette + TogglePalette, + ToggleMask ] ); @@ -58,11 +63,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. @@ -73,6 +100,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, @@ -138,9 +166,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..b64f34b --- /dev/null +++ b/crates/deckard-app/src/money.rs @@ -0,0 +1,123 @@ +//! 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; + +/// 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`. 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) +} + +/// 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, 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) +} + +/// 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..0a8de45 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,29 @@ 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-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; - this.navigate(Route::Settings, cx); + this.open(Surface::Settings, cx); }, ))) .child(row("cmd-copy", "Copy address", "").on_click(cx.listener( @@ -97,6 +106,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/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..4c56c67 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. @@ -54,17 +51,26 @@ 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 { fn default() -> Self { Self { theme_mode: ThemeModePref::Dark, - accent: Accent::default(), display_name: String::new(), 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 793ac7b..4f87c5f 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) @@ -115,16 +93,41 @@ 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("Theme", "Light or dark interface", theme_control)) + .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("Accent", "Brand color across the app", accent_control)), + .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( diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index 2c10bf3..1540c5a 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -4,33 +4,50 @@ //! `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, }; 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}; +use alloy_primitives::B256; +use deckard_contract::{Decision, ExecuteResult, Intent, RequestId, ShieldStatus}; +use deckard_core::{ + Address, EthProvider, KdfParams, Portfolio, ReadStatus, ShieldedHandle, Vault, WordCount, U256, +}; use zeroize::Zeroizing; 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}; +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 /// 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(); @@ -38,6 +55,34 @@ 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 { + // The broadcast error carries a variable RPC suffix, so match it by prefix. + if reason.starts_with("broadcast_failed") { + return "the deposit couldn't be broadcast — check your network, then review again".into(); + } + match reason { + "locked" => "unlock your wallet first".into(), + "revoked" => "the signer is paused (STOP is active)".into(), + "chain_mismatch" => { + "the signer is on a different chain than this deposit — reconcile the chain first" + .into() + } + "over_cap" | "cap_exceeded" => "it exceeds the agent's spending cap".into(), + "off_allowlist" => "the recipient isn't on the allowlist".into(), + "undecodable" => "the deposit calldata didn't validate".into(), + "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(), + } +} + /// 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 @@ -52,13 +97,38 @@ 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 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 Route { - Welcome, +pub enum Surface { + Home, Receive, + /// The shield trigger flow (T5): compose a deposit → review card → hold-to-confirm. + Shield, 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)] @@ -81,13 +151,66 @@ 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, 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, + + // --- shield trigger flow (T5) --- + /// Deposit amount (ETH, free text) and the `0zk…` recipient. Free-text recipient is v1; + /// auto-filling the user's OWN railgun address is Wave 2. + pub shield_amount: Entity, + pub shield_recipient: Entity, + /// Set once `propose` returns `Allow`. `Some` means the review card + hold-to-confirm are + /// live; it carries a recipient snapshot so the card can't show a since-edited address. + pub shield_proposal: Option, + /// Bumped on each `review_shield` (and on reset) so a slow propose reply for a + /// since-cancelled/re-issued review can't install a stale proposal. + shield_review_epoch: u64, + /// True while a `propose`/`execute` round-trip runs on a background thread. + pub shield_busy: bool, + /// One-line, user-facing shield error (parse / build / deny / broadcast). + pub shield_error: Option, + /// Set on a successful `execute` broadcast — the demo's "deposit is moving private" state. + pub shield_tx: Option, + /// True while the confirm button is being held; drives the amber fill-sweep. + pub shield_holding: bool, + /// Bumped on each hold-start so a stale hold timer can't fire a later confirm. + shield_hold_epoch: u64, + + // --- 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, + /// 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, @@ -226,6 +349,29 @@ 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")); + // 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| { if matches!(event, InputEvent::PressEnter { .. }) { @@ -272,15 +418,37 @@ 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, - route: Route::Welcome, + selection: Selection::Wallet, + surface: Surface::Home, settings, name_input, rpc_input, watch_input, created: 0, palette_open: false, + mask, + agent_acting: false, + capture_applied: false, + shield_amount, + shield_recipient, + shield_proposal: None, + shield_review_epoch: 0, + shield_busy: false, + shield_error: None, + shield_tx: None, + shield_holding: false, + shield_hold_epoch: 0, + shielded: None, + railgun_address: None, + recipient_autofilled: false, + shield_status: None, + auth_epoch: 0, + pending_shield_clear: false, auth, auth_error: None, auth_busy: false, @@ -355,6 +523,15 @@ 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; + // 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(); @@ -643,8 +820,96 @@ 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.auth_epoch = self.auth_epoch.wrapping_add(1); 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) { + // 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(false, cx); + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// 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) + }); + match syncing { + Ok(true) => continue, + Ok(false) => { + timed_out = false; + break; + } + Err(_) => return, // the view is gone + } + } + if !drive_lifecycle || timed_out { + return; // an initial/refresh watch, or a hung sync — don't touch the lifecycle + } + this.update(cx, |this, cx| { + // 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(); + }) + .detach(); } /// The unlocked wallet's own address as an EIP-55 string (empty until unlocked). @@ -801,28 +1066,255 @@ 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); + } } - 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) { + // 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(); } - pub fn set_accent(&mut self, accent: Accent, cx: &mut Context) { - self.settings.accent = accent; + /// 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(); - 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(); } + /// 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(); + } + + // --- 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). 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 + + /// review epochs so any in-flight hold timer or propose reply lands as a no-op. + fn reset_shield(&mut self) { + self.shield_proposal = None; + self.shield_error = None; + self.shield_tx = None; + self.shield_busy = false; + self.shield_holding = false; + self.shield_hold_epoch = self.shield_hold_epoch.wrapping_add(1); + self.shield_review_epoch = self.shield_review_epoch.wrapping_add(1); + } + + /// Build + `propose` the shield off-thread. On `Allow`, stash the proposal so the review + /// card + hold-to-confirm appear; on `NeedsApproval`/`Deny`/parse error, surface a clear + /// line. Mirrors `do_unlock` (build off-thread, fold the result on the UI thread). + 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; + // 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 { + 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| { + // Guard FIRST: a stale review must not even clear `busy` (a newer review may + // own it now). + if this.shield_review_epoch != epoch { + return; + } + this.shield_busy = false; + match res { + Ok((intent, Decision::Allow)) => { + let request_id = SignerClient::request_id_for_intent(&intent); + this.shield_proposal = Some(ShieldProposal { + intent, + request_id, + recipient: recipient_snapshot, + }); + } + 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(ShieldProposal { 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; + // 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_tx = Some(tx_hash); + // Just broadcast — honestly `Sending` (we don't track confirmations). + // The re-sync surfaces the note; the watcher then settles to + // PrivateSpendable (or Failed), never a fabricated "spendable $0". + this.shield_status = Some(ShieldStatus::Sending); + if let Some(h) = &this.shielded { + h.resync(); + } + this.watch_shielded_sync(true, cx); + } + 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) 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); + } + }) + .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()); + } + pub fn set_mode(&mut self, mode: ThemeModePref, cx: &mut Context) { self.settings.theme_mode = mode; self.settings.save(); @@ -846,12 +1338,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 +1357,53 @@ impl Shell { cx.notify(); } + fn on_toggle_mask(&mut self, _: &ToggleMask, _: &mut Window, cx: &mut Context) { + self.toggle_mask(cx); + } + + /// 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; + } + 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. + 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 { 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), ) } } @@ -933,18 +1418,40 @@ 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: 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. + self.prepare_shield_inputs(window, cx); 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(), + (_, Surface::Shield) => self.render_shield(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 { @@ -971,6 +1478,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 new file mode 100644 index 0000000..1348aac --- /dev/null +++ b/crates/deckard-app/src/shell_chrome.rs @@ -0,0 +1,463 @@ +//! 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 std::time::Duration; + +use gpui::prelude::FluentBuilder; +use gpui::{ + 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; + +/// 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() + } +} + +/// 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 { + match self.surface { + Surface::Settings => "Settings", + Surface::Receive => "Receive", + Surface::Shield => "Shield", + 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| 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). + 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 — 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")) + // 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 { + fg + } else { + 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 + }; + // 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)) + .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( + // 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() + .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 + }; + + // 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 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(glyph) + .child(div().text_color(color).child(st.to_string())) + }); + + 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( + 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")) + } + + /// 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/shield_view.rs b/crates/deckard-app/src/shield_view.rs new file mode 100644 index 0000000..0440fbb --- /dev/null +++ b/crates/deckard-app/src/shield_view.rs @@ -0,0 +1,476 @@ +//! 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_core::U256; + +use crate::money::money; +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 +/// 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(proposal) = self.shield_proposal.clone() { + return self.render_shield_review(proposal, cx).into_any_element(); + } + self.render_shield_compose(cx).into_any_element() + } + + /// Compose: amount (ETH) + 0zk recipient, then Review. The shield glyph is a neutral, + /// low-chroma mark (DESIGN: private ≠ cyan/agent and ≠ amber/human — it stays off the + /// actor axis). + fn render_shield_compose(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let busy = self.shield_busy; + + // Validity drives the Review button's disabled state (DESIGN: disable a primary + // action on incomplete/invalid input). Re-evaluated live via the input subscriptions. + let amount_raw = self.shield_amount.read(cx).value().to_string(); + let recipient_raw = self.shield_recipient.read(cx).value().to_string(); + let can_review = crate::signer::parse_eth_to_wei(&amount_raw) + .map(|w| w > U256::ZERO) + .unwrap_or(false) + && !recipient_raw.trim().is_empty(); + + self.shield_shell( + v_flex() + .w_full() + .gap_5() + .child(self.shield_heading( + "Shield to private", + "Move public ETH into a Railgun private balance. The deposit itself is visible on Ethereum; the balance after is not.", + cx, + )) + .child( + v_flex() + .w_full() + .gap_2() + .child(field_label("Amount", muted)) + .child(Input::new(&self.shield_amount).w_full()), + ) + .child( + v_flex() + .w_full() + .gap_2() + .child(field_label("Recipient (your 0zk address)", muted)) + .child(Input::new(&self.shield_recipient).w_full()), + ) + .children(self.shield_error.as_ref().map(|e| error_line(e, cx))) + .child( + h_flex() + .w_full() + .gap_2() + .child( + Button::new("shield-review") + .primary() + .label(if busy { "Reviewing…" } else { "Review deposit" }) + .disabled(busy || !can_review) + .on_click(cx.listener(|this, _, _, cx| this.review_shield(cx))), + ) + .child( + Button::new("shield-cancel") + .ghost() + .label("Cancel") + .on_click(cx.listener(|this, _, _, cx| { + this.open(Surface::Home, cx) + })), + ), + ) + .child( + 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, + proposal: ShieldProposal, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let border = theme.border; + let surface = theme.secondary; + let mono = theme.mono_font_family.clone(); + + // Render from the proposal SNAPSHOT — the amount + recipient that are actually inside + // the signed intent — never the live input (which the user could have since edited). + let gross = proposal.intent.value; + let fee = shield_fee(gross); + let net = gross.saturating_sub(fee); + let recipient = proposal.recipient.clone(); + + // One key/value row: label left (muted), value right (mono-for-money or mono text). + let kv_money = |label: &'static str, wei: U256| { + h_flex() + .w_full() + .justify_between() + .items_center() + .py_1p5() + .child(div().text_sm().text_color(muted).child(label)) + .child(div().text_sm().child(money( + wei, + 18, + 6, + Some("ETH"), + false, + mono.clone(), + fg, + muted, + ))) + }; + + self.shield_shell( + v_flex() + .w_full() + .gap_4() + .child(self.shield_heading( + "Review deposit", + "Confirm what leaves, where it goes, and the fee. Hold to shield.", + cx, + )) + // The clear-signing card: one frame, no interior grid lines. + .child( + v_flex() + .w_full() + .p_4() + .rounded_lg() + .border_1() + .border_color(border) + .bg(surface) + .child(kv_money("Amount", gross)) + .child( + h_flex() + .w_full() + .justify_between() + .items_center() + .py_1p5() + .child(div().text_sm().text_color(muted).child("To")) + .child( + div() + .font_family(mono.clone()) + .text_sm() + .text_color(fg) + .child(short_mid(recipient.trim())), + ), + ) + .child(kv_money("Railgun fee · 0.25%", fee)) + .child(kv_money("You'll receive (private)", net)), + ) + .child(self.shield_honesty(cx)) + .children(self.shield_error.as_ref().map(|e| error_line(e, cx))) + .child(self.hold_to_confirm(cx)) + .child( + Button::new("shield-edit") + .ghost() + .w_full() + .label("Edit") + .on_click(cx.listener(|this, _, _, cx| this.open_shield(cx))), + ) + .into_any_element(), + ) + } + + /// Done: the deposit broadcast — on its way to a private note (reassurance copy mirrors + /// `ShieldStatus`). The full lifecycle drive lands in Wave 2. + fn render_shield_done(&self, tx: String, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let border = theme.border; + let surface = theme.secondary; + let success = theme.success; + let mono = theme.mono_font_family.clone(); + + self.shield_shell( + v_flex() + .w_full() + .items_center() + .gap_4() + .child( + Icon::new(IconName::CircleCheck) + .text_color(success) + .flex_shrink_0(), + ) + .child( + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .text_color(fg) + .child("Deposit broadcast"), + ) + .child( + div() + .text_sm() + .text_color(muted) + .text_center() + .child("Your deposit is on its way to a private balance. It becomes spendable after on-chain confirmation and a private sync."), + ) + .child( + div() + .w_full() + .px_3() + .py_2() + .rounded_lg() + .border_1() + .border_color(border) + .bg(surface) + .font_family(mono) + .text_xs() + .text_color(muted) + .child(short_mid(&tx)), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("shield-copy-tx") + .ghost() + .label("Copy tx hash") + .on_click(cx.listener(move |_, _, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(tx.clone())); + })), + ) + .child( + Button::new("shield-done") + .primary() + .label("Done") + .on_click(cx.listener(|this, _, _, cx| this.open(Surface::Home, cx))), + ), + ) + .into_any_element(), + ) + } + + /// The three honesty lines, in 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 ae48d4e..ab0b1f7 100644 --- a/crates/deckard-app/src/theme.rs +++ b/crates/deckard-app/src/theme.rs @@ -1,88 +1,83 @@ -//! 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 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) +} - /// 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) } -/// 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) { +/// 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" }) +} + +/// 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) { // 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 +88,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..b98103b 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -3,15 +3,33 @@ //! 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, relative, Context, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, + SharedString, StatefulInteractiveElement, 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::money::money; +use crate::shell::{Shell, Surface}; +use crate::shell_chrome::agent_squircle; +use crate::theme; -use crate::shell::{Route, Shell}; +/// 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 +47,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 +84,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,117 +119,91 @@ 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}") + // 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 { - "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 - } 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() - .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), - ) + .gap_3() + .child(div().size(px(28.0)).rounded(px(6.0)).bg(id_square)) .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)), + ), + ), ) - // Primary actions. Send + Swap are gated to the next release (Chunk 4, - // testnet-first), so they're shown disabled rather than inert-but-active. + // 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. .child( h_flex() .w_full() .gap_2() - .child(Button::new("send").primary().label("Send").disabled(true)) + // 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( - cx.listener(|this, _, _, cx| this.navigate(Route::Receive, cx)), + 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( @@ -227,17 +226,156 @@ 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, + ), + }; + + // 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() + .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% fee, and synced over raw RPC (not independently verified).", + )) + .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( &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 masked = self.mask; let mut col = v_flex().w_full().gap_2(); @@ -279,17 +417,21 @@ 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, + masked, + mono.clone(), )); } if !has_tokens { @@ -311,6 +453,323 @@ 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 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); + + 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( + 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, + fg, + ) + })), + ) + .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, + // breathing while Atlas acts) + agent name H1 (text.primary, NEVER cyan). + .child( + h_flex() + .items_center() + .gap_3() + .child(agent_squircle( + px(28.0), + px(6.0), + self.agent_acting, + agent, + agent_tint, + "agent-pulse-home", + )) + .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( + if self.agent_acting { + "Delegated agent · acting now" + } else { + "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")), + ) + // 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.", + ), + ), + ) + } +} + +/// 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); + // 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() + .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() +} + +/// `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. @@ -341,12 +800,16 @@ 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, + masked: bool, + mono: SharedString, ) -> impl IntoElement { h_flex() .w_full() @@ -354,10 +817,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 +828,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() @@ -391,11 +856,7 @@ fn render_row( .child(div().text_xs().text_color(muted).child(symbol)), ), ) - .child( - div() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(fg) - .child(amount), - ) + .child(div().text_sm().child(money( + raw, decimals, max_frac, None, masked, mono, fg, muted, + ))) } diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index 98ef372..73f41e6 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}; @@ -36,8 +37,10 @@ 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; #[cfg(test)] @@ -173,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] @@ -248,4 +274,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/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-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-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..0de1f0a 100644 --- a/crates/deckard-core/src/keystore.rs +++ b/crates/deckard-core/src/keystore.rs @@ -493,6 +493,35 @@ 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) + } + + /// 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 178e6d7..61915a8 100644 --- a/crates/deckard-core/src/lib.rs +++ b/crates/deckard-core/src/lib.rs @@ -34,11 +34,19 @@ 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). #[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}; @@ -51,6 +59,15 @@ 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, +}; +#[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 new file mode 100644 index 0000000..111d535 --- /dev/null +++ b/crates/deckard-core/src/railgun_keys.rs @@ -0,0 +1,210 @@ +//! 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 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. +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); + } +} 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 62cbc6e..569466d 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, RailgunViewGrant, RequestId, SignerRequest, + SignerResponse, UnlockOutcome, }; use crate::frame; @@ -112,6 +113,62 @@ 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: 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 })? { + 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 { 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 {