From 908df117e6588eb45b2da96ffeeba573a7a767c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 23:04:40 -0400 Subject: [PATCH] Build 8: stability fixes, Cash App-style shell, settings, key rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stability (found via GUI testing): - tor: create arti runtime on a clean thread; lazy TOR_STATE init panicked inside tokio contexts and poisoned the whole Tor/nostr stack - store: open rkv SafeMode envs with capacity headroom; reopening at exactly DEFAULT_MAX_DBS crashed every wallet restart (DbsFull) - goblin ui: centered_column hands children a full-height rect; ScrollAreas inside clipped everything below the first widget - build: webtunnel client was silently embedded as 0 bytes without Go; warn at build, extract with create_dir_all + exec bit at runtime - price: CoinGecko requires a User-Agent (403 otherwise); add retry-once backoff and a parse-failure diagnostic - tor: retry http_request up to 3x on fresh isolated circuits UX overhaul (per owner direction + Cash App references): - floating icon-only 3-tab pill: Wallet / center accent ツ Pay puck / Activity (requests badge kept); Me opens via header avatar - Pay tab: amount-first surface (numpad on mobile, typed on desktop) with Request and Pay; Pay carries the amount straight to Review - Request flips Receive into "Requesting Nツ" state with a clear chip - full-bleed goblin surface: GRIM title panel and network column hidden while a wallet is open; node status card lives in the sidebar above the profile chip; Lock wallet row added (Settings -> Wallet) - goblin branding: titlebar, wallet-list logo + GOBLIN, new mark assets (white master, theme-tinted) in wordmark, QR center, wallet list - build-based versioning: Build N = commits since the GRIM fork base, emitted by build.rs; About leads with it, Third party credits GRIM, grin node, nostr-sdk, arti, egui and the implemented NIPs Accessibility & settings: - surface_text{,_dim,_mute} tokens: yellow theme has dark cards on a bright bg; all on-surface text now readable in every theme (incl. QR) - settings rows clickable across the whole row; profile card fills width; density option removed (comfy fixed) - editable Node connections (integrated/external, add/remove) and Relays (add/remove + live service restart); NIPs explainer page with goblin.st context; third-party rows link to upstream projects - standardized npub truncation (head 12 ... tail 6) shown in profile Identity (owner decision: drop NIP-06 seed binding): - random standalone nsec (Keys::generate); seed proves nothing about the identity and cannot resurrect it; legacy Derived identities still unlock - key rotation: double warning (pending payments disrupted), typed RESET + wallet password, fresh random key, username moved atomically via the name server transfer endpoint; aborts cleanly if the move fails - encrypted identity backup export (NIP-49 ncryptsec JSON, includes username + history) and import accepting nsec or backup JSON - nip05d: POST /api/v1/transfer (NIP-98 by current owner, atomic owner-guarded swap, one-name-per-pubkey enforced) + SQL invariant tests Co-Authored-By: Claude Fable 5 --- build.rs | 35 +- img/goblin-logo-256.png | Bin 0 -> 32431 bytes img/goblin-logo2-256.png | Bin 0 -> 10599 bytes src/gui/app.rs | 2 +- src/gui/theme.rs | 15 + src/gui/views/content.rs | 5 +- src/gui/views/goblin/data.rs | 5 +- src/gui/views/goblin/mod.rs | 1546 +++++++++++++++++++++++++++--- src/gui/views/goblin/send.rs | 152 +-- src/gui/views/goblin/widgets.rs | 201 +++- src/gui/views/views.rs | 17 +- src/gui/views/wallets/content.rs | 7 +- src/http/price.rs | 27 +- src/lib.rs | 4 + src/nostr/identity.rs | 30 +- src/nostr/nip05.rs | 42 + src/nostr/store.rs | 7 +- src/tor/tor.rs | 63 +- src/wallet/store.rs | 6 +- src/wallet/wallet.rs | 161 +++- 20 files changed, 2033 insertions(+), 292 deletions(-) create mode 100644 img/goblin-logo-256.png create mode 100644 img/goblin-logo2-256.png diff --git a/build.rs b/build.rs index 944728d5..3b43f3bb 100644 --- a/build.rs +++ b/build.rs @@ -2,9 +2,29 @@ use std::path::PathBuf; use std::process::Command; use std::{env, fs}; +/// The GRIM commit Goblin forked from; builds count commits on top of it. +const GOBLIN_FORK_BASE: &str = "b51a46b"; + fn main() { built::write_built_file().expect("Failed to acquire build-time information"); + // Goblin versioning is build-based: Build N = commits since the fork. + let build = Command::new("git") + .args([ + "rev-list", + "--count", + &format!("{}..HEAD", GOBLIN_FORK_BASE), + ]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "dev".to_string()); + println!("cargo:rustc-env=GOBLIN_BUILD={}", build); + println!("cargo:rerun-if-changed=.git/HEAD"); + // Setting up git hooks in the project: rustfmt and so on. let git_hooks = format!( "git config core.hooksPath {}", @@ -82,14 +102,14 @@ fn main() { Command::new("./scripts/webtunnel.bat") .arg(go_os) .arg(go_arch) - .arg(webtunnel_file) + .arg(&webtunnel_file) .output() } else { Command::new("bash") .arg("./scripts/webtunnel.sh") .arg(go_os) .arg(go_arch) - .arg(webtunnel_file) + .arg(&webtunnel_file) .output() }; if let Ok(out) = output { @@ -97,5 +117,16 @@ fn main() { panic!("webtunnel go build failed:\n{:?}", out); } } + // The build script exits 0 when Go is absent, leaving the placeholder + // empty — surface that loudly instead of shipping broken bridges. + let still_empty = fs::metadata(&webtunnel_file) + .map(|m| m.len() == 0) + .unwrap_or(true); + if still_empty { + println!( + "cargo:warning=webtunnel client was not built (is Go installed?) — \ + Tor webtunnel bridges will not work at runtime" + ); + } } } diff --git a/img/goblin-logo-256.png b/img/goblin-logo-256.png new file mode 100644 index 0000000000000000000000000000000000000000..06cc5508c89e3b20494dc05883e5725743bdb72a GIT binary patch literal 32431 zcmY&<18^l>v~`S$ZB0C}?TIG##I|iGlgY%kJuz?0iEZ1q_4oH))vNmJ)~)V3eb2e4 z_vyXQT5InPSCp4T`hx!j3=9lOTI#zp7#KKk3JwMb0~~amO3i=+sj-x@92l4v6&P4R z2pHH4&=hb42Ik5P26kcq2F9BT28QF1*{Z}3T!1l>k^Bz!`QInEqa*=nfp?J7bOr-M z!1(VA4wjyY3pB#INXv=Cp1^!T17{T5bdm;|C|$&Vx|kTcnDQDsnF0qeR%TXC24+qM z7G_muHeNPvURHKGW@cVy=KY`~_y4bht-Xn*ndkp?VCGR}W#eUK=H=l2-ww=*Q~5v# zn*aL;7fV}HXBR_ThyUvX{0R#eFEa}-JLmuQ`RhFs2L|TrCH-AQ)nny6%hiW)aCxw+ z-ksX4VQFlPklL}^&>`2*CUt{CE}hD<#&7^;wa_wyT~IFrpYfS#>Ue9DlN_2Jlj)v3#R3OL6}?U53CtP2}DAGEa?~K>0si!-12T1 zRY=$XwQhy(OJPlKbUZZA1R1K7q$>K4ra`4{oh>T^L{z^*?b^Q z_o2VrYZ;UXAY-jJ+$Q*mJutcPm`RSa?{Z-Ut_ai(1g|s1*nxFg`jYZ=5LWv
{fcsV3~U9KG% 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 literal 0 HcmV?d00001 diff --git a/src/gui/app.rs b/src/gui/app.rs index a8382b16..af5a9b4c 100755 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -291,7 +291,7 @@ impl App { } // Paint the title. - let title_text = format!("Grim {} ツ", crate::VERSION); + let title_text = format!("Goblin ツ · Build {}", crate::BUILD); painter.text( title_rect.center(), egui::Align2::CENTER_CENTER, diff --git a/src/gui/theme.rs b/src/gui/theme.rs index 178019d2..f4c99c00 100644 --- a/src/gui/theme.rs +++ b/src/gui/theme.rs @@ -54,6 +54,12 @@ pub struct ThemeTokens { pub text: Color32, pub text_dim: Color32, pub text_mute: Color32, + /// Text on surface/surface2 fills. Matches `text` in light/dark, but the + /// yellow theme has dark surfaces on a bright bg, so on-surface text must + /// be light there while `text` stays dark for the bg. + pub surface_text: Color32, + pub surface_text_dim: Color32, + pub surface_text_mute: Color32, pub line: Color32, pub accent: Color32, pub accent_dark: Color32, @@ -86,6 +92,9 @@ pub const LIGHT: ThemeTokens = ThemeTokens { text: Color32::from_rgb(0x0E, 0x0E, 0x0C), text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63), text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B), + surface_text: Color32::from_rgb(0x0E, 0x0E, 0x0C), + surface_text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63), + surface_text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B), // rgba(14,14,12,0.08) premultiplied. line: Color32::from_rgba_premultiplied(1, 1, 1, 20), accent: Color32::from_rgb(0xFF, 0xD6, 0x0A), @@ -106,6 +115,9 @@ pub const DARK: ThemeTokens = ThemeTokens { text: Color32::from_rgb(0xFA, 0xFA, 0xF7), text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), + surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7), + surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), + surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), // rgba(255,255,255,0.08) premultiplied. line: Color32::from_rgba_premultiplied(20, 20, 20, 20), accent: Color32::from_rgb(0xFF, 0xD6, 0x0A), @@ -126,6 +138,9 @@ pub const YELLOW: ThemeTokens = ThemeTokens { text: Color32::from_rgb(0x0E, 0x0E, 0x0C), text_dim: Color32::from_rgb(0x3A, 0x3A, 0x36), text_mute: Color32::from_rgb(0x6B, 0x6A, 0x63), + surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7), + surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), + surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), // rgba(14,14,12,0.18) premultiplied. line: Color32::from_rgba_premultiplied(2, 2, 2, 46), accent: Color32::from_rgb(0x0E, 0x0E, 0x0C), diff --git a/src/gui/views/content.rs b/src/gui/views/content.rs index 77b5c138..bd1f83ec 100644 --- a/src/gui/views/content.rs +++ b/src/gui/views/content.rs @@ -94,6 +94,9 @@ impl ContentContainer for Content { if self.network.showing_settings() { panel_width = ui.available_width(); } + // The open-wallet (Goblin) surface is full-bleed: node info lives in + // its sidebar, so the network column stays hidden while it shows. + let wallet_open = self.wallets.showing_wallet(); // Show network content. egui::SidePanel::left("network_panel") @@ -102,7 +105,7 @@ impl ContentContainer for Content { .frame(egui::Frame { ..Default::default() }) - .show_animated_inside(ui, is_panel_open, |ui| { + .show_animated_inside(ui, is_panel_open && !wallet_open, |ui| { self.network.ui(ui, cb); }); diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index 21235454..f268b6bc 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -68,8 +68,9 @@ pub fn short_npub(hex: &str) -> String { use nostr_sdk::{PublicKey, ToBech32}; if let Ok(pk) = PublicKey::from_hex(hex) { if let Ok(npub) = pk.to_bech32() { - if npub.len() > 16 { - return format!("{}…{}", &npub[..9], &npub[npub.len() - 4..]); + // Standard truncation: "npub1" + 7 head chars … 6 tail chars. + if npub.len() > 18 { + return format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]); } return npub; } diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 12a0d02b..e9ae4138 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -18,8 +18,8 @@ pub mod data; pub mod send; pub mod widgets; -use eframe::epaint::{FontId, Stroke}; -use egui::{Align, Layout, Margin, RichText, ScrollArea, Sense, Vec2}; +use eframe::epaint::{CornerRadius, FontId, Stroke}; +use egui::{Align, Color32, Layout, Margin, RichText, ScrollArea, Sense, Vec2}; use crate::gui::icons::{ARROW_DOWN, CLOCK, COPY, USER_CIRCLE, WALLET}; use crate::gui::platform::PlatformCallbacks; @@ -32,10 +32,13 @@ use self::data::{ActivityItem, activity_items, recent_peers}; use self::send::SendFlow; use self::widgets as w; -/// Goblin navigation tabs. +/// Goblin navigation tabs. The mobile bar shows Home / Pay / Activity; +/// Receive and Me stay reachable (Pay's Request action, header avatar, +/// desktop sidebar). #[derive(Clone, Copy, PartialEq, Eq)] pub enum Tab { Home, + Pay, Activity, Receive, Me, @@ -51,6 +54,31 @@ pub struct GoblinWalletView { wallet_id: Option, /// Inline username-claim state for the Me tab. claim: Option, + /// Inline key-rotation state for the Me tab. + rotate: Option, + /// Inline nsec-import state for the Me tab. + import_nsec: Option, + /// Amount being entered on the Pay tab. + pay_amount: String, + /// Amount being requested, shown on the Receive screen. + request_amount: Option, + /// Sub-page open inside the Settings tab. + settings_page: SettingsPage, + /// Inputs for adding an external node connection. + node_url_input: String, + node_secret_input: String, + /// Relay list being edited and the add-relay input. + relay_edit: Vec, + relay_input: String, +} + +/// Sub-pages of the Settings tab. +#[derive(Clone, Copy, PartialEq, Eq)] +enum SettingsPage { + Main, + Node, + Relays, + Nips, } impl Default for GoblinWalletView { @@ -61,6 +89,64 @@ impl Default for GoblinWalletView { approving: std::collections::HashSet::new(), wallet_id: None, claim: None, + rotate: None, + import_nsec: None, + pay_amount: String::new(), + request_amount: None, + settings_page: SettingsPage::Main, + node_url_input: String::new(), + node_secret_input: String::new(), + relay_edit: Vec::new(), + relay_input: String::new(), + } + } +} + +/// Inline key-rotation flow state (two warnings + typed confirmation). +struct RotateState { + /// 1 = warning, 2 = confirm (RESET + password), 3 = working, + /// 4 = done (new npub), 5 = error (message). + stage: u8, + reset_input: String, + password: String, + new_npub: String, + error: String, + result: std::sync::Arc>>>, +} + +impl Default for RotateState { + fn default() -> Self { + Self { + stage: 1, + reset_input: String::new(), + password: String::new(), + new_npub: String::new(), + error: String::new(), + result: std::sync::Arc::new(std::sync::Mutex::new(None)), + } + } +} + +/// Inline nsec-import flow state (restore path for the random-key model). +struct ImportState { + /// 1 = form, 3 = working, 4 = done, 5 = error. + stage: u8, + nsec: String, + password: String, + new_npub: String, + error: String, + result: std::sync::Arc>>>, +} + +impl Default for ImportState { + fn default() -> Self { + Self { + stage: 1, + nsec: String::new(), + password: String::new(), + new_npub: String::new(), + error: String::new(), + result: std::sync::Arc::new(std::sync::Mutex::new(None)), } } } @@ -104,6 +190,10 @@ impl GoblinWalletView { self.send = None; return false; } + if self.tab == Tab::Me && self.settings_page != SettingsPage::Main { + self.settings_page = SettingsPage::Main; + return false; + } if self.tab != Tab::Home { self.tab = Tab::Home; return false; @@ -123,7 +213,12 @@ impl GoblinWalletView { self.tab = Tab::Home; self.send = None; self.claim = None; + self.rotate = None; + self.import_nsec = None; self.approving.clear(); + self.pay_amount.clear(); + self.request_amount = None; + self.settings_page = SettingsPage::Main; } // Send flow takes the full surface when active. @@ -162,12 +257,11 @@ impl GoblinWalletView { .frame(egui::Frame { fill: t.bg, inner_margin: Margin { - left: 8, - right: 8, - top: 8, - bottom: (4.0 + bottom_inset) as i8, + left: 16, + right: 16, + top: 10, + bottom: (12.0 + bottom_inset) as i8, }, - stroke: Stroke::new(1.0, t.line), ..Default::default() }) .show_inside(ui, |ui| { @@ -189,7 +283,8 @@ impl GoblinWalletView { }) .show_inside(ui, |ui| { w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| match self.tab { - Tab::Home => self.home_ui(ui, wallet, cb), + Tab::Home => self.home_ui(ui, wallet, cb, wide_desktop), + Tab::Pay => self.pay_ui(ui, wallet), Tab::Activity => self.activity_ui(ui, wallet, cb), Tab::Receive => self.receive_ui(ui, wallet, cb), Tab::Me => self.me_ui(ui, wallet, cb), @@ -197,41 +292,91 @@ impl GoblinWalletView { }); } + /// Floating 3-item pill bar: Wallet · Pay (center ツ puck) · Activity. fn tab_bar_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { let t = theme::tokens(); let has_requests = wallet .nostr_service() .map(|s| !s.store.pending_requests().is_empty()) .unwrap_or(false); - let tabs = [ - (Tab::Home, WALLET, "Wallet", false), - (Tab::Activity, CLOCK, "Activity", has_requests), - (Tab::Receive, ARROW_DOWN, "Receive", false), - (Tab::Me, USER_CIRCLE, "Me", false), - ]; + + let bar_h = 64.0; + let bar_w = ui.available_width().min(340.0); + let margin = ((ui.available_width() - bar_w) / 2.0).max(0.0); ui.horizontal(|ui| { - let w = ui.available_width() / tabs.len() as f32; - for (tab, icon, label, badge) in tabs { - let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, 48.0), Sense::click()); + ui.add_space(margin); + let (bar_rect, _) = ui.allocate_exact_size(Vec2::new(bar_w, bar_h), Sense::hover()); + // Soft shadow + floating pill. + let shadow = bar_rect.translate(Vec2::new(0.0, 3.0)).expand(2.0); + ui.painter().rect_filled( + shadow, + CornerRadius::same(34), + Color32::from_black_alpha(70), + ); + ui.painter().rect( + bar_rect, + CornerRadius::same(32), + t.surface, + Stroke::new(1.0, t.line), + egui::StrokeKind::Inside, + ); + + let cell = bar_w / 3.0; + let tabs = [ + (Tab::Home, Some(WALLET), false), + (Tab::Pay, None, false), + (Tab::Activity, Some(CLOCK), has_requests), + ]; + for (i, (tab, icon, badge)) in tabs.into_iter().enumerate() { + let rect = egui::Rect::from_min_size( + bar_rect.min + Vec2::new(i as f32 * cell, 0.0), + Vec2::new(cell, bar_h), + ); + let resp = ui.interact(rect, ui.id().with(("goblin_tab", i)), Sense::click()); let active = self.tab == tab; - let color = if active { t.text } else { t.text_mute }; - ui.painter().text( - rect.center() - Vec2::new(0.0, 8.0), - egui::Align2::CENTER_CENTER, - icon, - FontId::new(22.0, fonts::regular()), - color, - ); - ui.painter().text( - rect.center() + Vec2::new(0.0, 14.0), - egui::Align2::CENTER_CENTER, - label, - FontId::new(11.0, fonts::semibold()), - color, - ); + match icon { + Some(icon) => { + // Icon-only; the active tab gets a circular highlight. + if active { + ui.painter().circle_filled(rect.center(), 22.0, t.surface2); + } + let color = if active { + t.surface_text + } else { + t.surface_text_mute + }; + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icon, + FontId::new(23.0, fonts::regular()), + color, + ); + } + None => { + // Center Pay action: accent ツ puck. + let grow = if active || resp.hovered() { 1.0 } else { 0.0 }; + ui.painter().circle_filled( + rect.center(), + 24.0 + grow, + if resp.hovered() { + t.accent_dark + } else { + t.accent + }, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + w::TSU, + FontId::new(24.0, fonts::bold()), + t.accent_ink, + ); + } + } if badge { ui.painter() - .circle_filled(rect.center() + Vec2::new(12.0, -10.0), 4.0, t.neg); + .circle_filled(rect.center() + Vec2::new(13.0, -13.0), 4.5, t.neg); } if resp.clicked() { self.tab = tab; @@ -259,16 +404,16 @@ impl GoblinWalletView { .nostr_service() .map(|s| !s.store.pending_requests().is_empty()) .unwrap_or(false); - // (tab, icon, label, is_send_action, badge) + // (tab, icon, label, badge) let items = [ - (Some(Tab::Home), WALLET, "Wallet", false), - (Some(Tab::Activity), CLOCK, "Activity", has_requests), - (None, crate::gui::icons::ARROW_UP, "Send", false), - (Some(Tab::Receive), ARROW_DOWN, "Receive", false), - (Some(Tab::Me), USER_CIRCLE, "Settings", false), + (Tab::Home, WALLET, "Wallet", false), + (Tab::Pay, crate::gui::icons::ARROW_UP, "Pay", false), + (Tab::Activity, CLOCK, "Activity", has_requests), + (Tab::Receive, ARROW_DOWN, "Receive", false), + (Tab::Me, USER_CIRCLE, "Settings", false), ]; for (tab, icon, label, badge) in items { - let active = tab.map(|x| x == self.tab).unwrap_or(false); + let active = tab == self.tab; let (rect, resp) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 44.0), Sense::click()); if active || resp.hovered() { @@ -305,59 +450,141 @@ impl GoblinWalletView { .circle_filled(rect.right_center() - Vec2::new(14.0, 0.0), 4.0, t.neg); } if resp.clicked() { - match tab { - Some(x) => self.tab = x, - None => self.send = Some(SendFlow::default()), - } + self.tab = tab; } ui.add_space(4.0); } - // Profile card pinned to the bottom. + // Node status + profile cards pinned to the bottom (node info lives + // here so the surface needs no separate network column). ui.with_layout(Layout::bottom_up(Align::Min), |ui| { - let (handle, connected) = wallet - .nostr_service() - .map(|s| { - let id = s.identity.read(); - let h = id - .nip05 - .clone() - .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) - .unwrap_or_else(|| data::short_npub(&hex_of(&id.npub))); - (h, s.is_connected()) - }) - .unwrap_or_else(|| ("Anonymous".to_string(), false)); - w::card(ui, |ui| { - ui.horizontal(|ui| { - w::avatar(ui, &handle, 36.0, 6); - ui.add_space(10.0); - ui.vertical(|ui| { - ui.label( - RichText::new(&handle) - .font(FontId::new(14.0, fonts::semibold())) - .color(t.text), - ); - ui.label( - RichText::new(if connected { - "synced · Tor" - } else { - "connecting…" - }) - .font(FontId::new(12.0, fonts::regular())) - .color(t.text_mute), - ); + let width = ui.available_width(); + let bottom = ui.allocate_ui_with_layout( + Vec2::new(width, 148.0), + Layout::top_down(Align::Min), + |ui| { + self.node_card_ui(ui, wallet); + ui.add_space(8.0); + let (handle, connected) = wallet + .nostr_service() + .map(|s| { + let id = s.identity.read(); + let h = id + .nip05 + .clone() + .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) + .unwrap_or_else(|| data::short_npub(&hex_of(&id.npub))); + (h, s.is_connected()) + }) + .unwrap_or_else(|| ("Anonymous".to_string(), false)); + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + w::avatar(ui, &handle, 36.0, 6); + ui.add_space(10.0); + ui.vertical(|ui| { + ui.label( + RichText::new(&handle) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.label( + RichText::new(if connected { + "synced · Tor" + } else { + "connecting…" + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + }); + }); }); + }, + ); + // Both bottom cards (node status + profile) open Settings. + if bottom.response.interact(Sense::click()).clicked() { + self.tab = Tab::Me; + } + }); + } + + /// Compact node status card: sync state dot, block height, connection. + fn node_card_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + let height = wallet + .get_data() + .map(|d| d.info.last_confirmed_height) + .unwrap_or(0); + let synced = height > 0 && !wallet.syncing(); + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + let (dot, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); + ui.painter().circle_filled( + dot.center(), + 4.0, + if synced { t.pos } else { t.accent }, + ); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.label( + RichText::new(if synced { "Node synced" } else { "Syncing…" }) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.label( + RichText::new(node_summary(wallet)) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_dim), + ); }); }); }); } - fn home_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + fn home_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + wide: bool, + ) { let data = wallet.get_data(); ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { - ui.add_space(48.0); + // Mobile header: wordmark left, avatar (opens settings) right. + if !wide { + ui.add_space(10.0); + ui.horizontal(|ui| { + widgets_logo(ui); + ui.add_space(8.0); + ui.label( + RichText::new("goblin") + .font(FontId::new(18.0, fonts::bold())) + .color(theme::tokens().text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let handle = wallet + .nostr_service() + .map(|s| { + let id = s.identity.read(); + id.nip05 + .clone() + .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) + .unwrap_or_else(|| "N".to_string()) + }) + .unwrap_or_else(|| "N".to_string()); + if w::avatar(ui, &handle, 36.0, 6).clicked() { + self.tab = Tab::Me; + } + }); + }); + ui.add_space(28.0); + } else { + ui.add_space(48.0); + } let spendable = data .as_ref() .map(|d| d.info.amount_currently_spendable) @@ -374,36 +601,7 @@ impl GoblinWalletView { ui.add_space(24.0); // Recent peers strip. - let peers = recent_peers(wallet, 8); - if !peers.is_empty() { - w::kicker(ui, "Recent"); - ui.add_space(12.0); - ScrollArea::horizontal() - .id_salt("goblin_peers") - .auto_shrink([false, true]) - .show(ui, |ui| { - ui.horizontal(|ui| { - for (name, hue, npub) in &peers { - ui.vertical(|ui| { - let resp = w::avatar(ui, name, 48.0, *hue); - ui.add_space(6.0); - let short: String = name.chars().take(6).collect(); - ui.label( - RichText::new(short) - .font(FontId::new(12.0, fonts::medium())), - ); - if resp.clicked() { - let mut f = SendFlow::default(); - f.prefill_contact(name.clone(), npub.clone()); - self.send = Some(f); - } - }); - ui.add_space(12.0); - } - }); - }); - ui.add_space(20.0); - } + self.peers_strip_ui(ui, wallet, "goblin_peers_home"); // Recent activity. w::kicker(ui, "Activity"); @@ -424,6 +622,133 @@ impl GoblinWalletView { }); } + /// Horizontal recent-contacts strip; tapping one starts a prefilled send. + fn peers_strip_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, salt: &str) { + let peers = recent_peers(wallet, 8); + if peers.is_empty() { + return; + } + w::kicker(ui, "Recent"); + ui.add_space(12.0); + ScrollArea::horizontal() + .id_salt(salt.to_string()) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal(|ui| { + for (name, hue, npub) in &peers { + ui.vertical(|ui| { + let resp = w::avatar(ui, name, 48.0, *hue); + ui.add_space(6.0); + let short: String = name.chars().take(6).collect(); + ui.label(RichText::new(short).font(FontId::new(12.0, fonts::medium()))); + if resp.clicked() { + let mut f = SendFlow::default(); + f.prefill_contact(name.clone(), npub.clone()); + self.send = Some(f); + } + }); + ui.add_space(12.0); + } + }); + }); + ui.add_space(20.0); + } + + /// Pay tab: amount-first combined pay/request surface. + fn pay_ui(&mut self, ui: &mut egui::Ui, _wallet: &Wallet) { + let t = theme::tokens(); + ui.add_space(8.0); + ui.label( + RichText::new("Pay") + .font(FontId::new(28.0, fonts::bold())) + .color(t.text), + ); + + // Big centered amount. + let display = if self.pay_amount.is_empty() { + "0".to_string() + } else { + self.pay_amount.clone() + }; + let tall = ui.available_height() > 560.0; + ui.add_space(if tall { 56.0 } else { 24.0 }); + w::amount_text_centered(ui, &display, 76.0); + if let Some(rate) = crate::http::grin_usd_rate() { + if let Ok(grin) = display.parse::() { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(format!("≈ ${:.2}", grin * rate)) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + } + } + ui.add_space(if tall { 32.0 } else { 16.0 }); + + // Numpad on mobile, typed input on desktop. + if !View::is_desktop() { + w::numpad(ui, &mut self.pay_amount); + } else { + w::amount_typed_input(ui, &mut self.pay_amount); + ui.vertical_centered(|ui| { + ui.label( + RichText::new("Type an amount") + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_mute), + ); + }); + } + ui.add_space(20.0); + + // Request | Pay actions, half width each. + let valid = grin_core::core::amount_from_hr_string(&self.pay_amount) + .map(|a| a > 0) + .unwrap_or(false); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 56.0), + )), + |ui| { + if w::big_action(ui, "Request", true).clicked() && valid { + self.request_amount = Some(self.pay_amount.clone()); + self.pay_amount.clear(); + self.tab = Tab::Receive; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 56.0), + )), + |ui| { + if w::big_action(ui, "Pay", false).clicked() && valid { + let mut f = SendFlow::default(); + f.prefill_amount(self.pay_amount.clone()); + self.pay_amount.clear(); + self.send = Some(f); + } + }, + ); + }); + if !valid { + ui.add_space(8.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new("Enter an amount to pay or request") + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + }); + } + } + fn activity_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { ui.add_space(8.0); ui.label( @@ -433,6 +758,9 @@ impl GoblinWalletView { ); ui.add_space(12.0); + // Recent contacts strip (Cash App-style row above the feed). + self.peers_strip_ui(ui, wallet, "goblin_peers_activity"); + // Pending payment requests pinned on top. if let Some(service) = wallet.nostr_service() { let requests = service.store.pending_requests(); @@ -505,19 +833,19 @@ impl GoblinWalletView { ui.label( RichText::new(format!("{} requests", name)) .font(FontId::new(15.0, fonts::semibold())) - .color(t.text), + .color(t.surface_text), ); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.label( RichText::new(w::amount_str(req.amount)) .font(FontId::new(15.0, fonts::mono_semibold())) - .color(t.text), + .color(t.surface_text), ); ui.label( RichText::new(w::TSU) .font(FontId::new(13.0, fonts::medium())) - .color(t.text_dim), + .color(t.surface_text_dim), ); }); }); @@ -599,19 +927,39 @@ impl GoblinWalletView { w::card(ui, |ui| { ui.vertical_centered(|ui| { // QR of the nostr handle (nostr: URI). + ui.add_space(12.0); let uri = format!("nostr:{}", npub); - crate::gui::views::QrCodeContent::new(uri, false).ui(ui, cb); - ui.add_space(8.0); + w::qr_code(ui, &uri, 220.0); + ui.add_space(14.0); ui.label( RichText::new(&handle) .font(FontId::new(18.0, fonts::bold())) - .color(t.text), - ); - ui.label( - RichText::new("Share your handle to get paid") - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), + .color(t.surface_text), ); + match &self.request_amount { + Some(amt) => { + ui.label( + RichText::new(format!( + "Requesting {}{} — share to get paid", + amt, + w::TSU + )) + .font(FontId::new(13.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + if w::chip(ui, "Clear request", false).clicked() { + self.request_amount = None; + } + } + None => { + ui.label( + RichText::new("Share your handle to get paid") + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + } + } }); }); @@ -663,6 +1011,12 @@ impl GoblinWalletView { fn me_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); + match self.settings_page { + SettingsPage::Node => return self.node_settings_ui(ui, wallet), + SettingsPage::Relays => return self.relays_ui(ui, wallet), + SettingsPage::Nips => return self.nips_ui(ui), + SettingsPage::Main => {} + } ui.add_space(8.0); ui.label( RichText::new("Settings") @@ -686,6 +1040,7 @@ impl GoblinWalletView { .unwrap_or_else(|| ("Anonymous".to_string(), String::new(), false)); w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); ui.horizontal(|ui| { w::avatar(ui, &handle, 56.0, 6); ui.add_space(14.0); @@ -693,8 +1048,16 @@ impl GoblinWalletView { ui.label( RichText::new(&handle) .font(FontId::new(17.0, fonts::bold())) - .color(t.text), + .color(t.surface_text), ); + if npub.len() > 18 { + // Standard truncation: head 12 chars … tail 6. + ui.label( + RichText::new(format!("{}…{}", &npub[..12], &npub[npub.len() - 6..])) + .font(FontId::new(12.0, fonts::mono())) + .color(t.surface_text_mute), + ); + } let status = if connected { "Connected over Tor" } else { @@ -703,7 +1066,7 @@ impl GoblinWalletView { ui.label( RichText::new(status) .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), + .color(t.surface_text_dim), ); }); }); @@ -756,12 +1119,49 @@ impl GoblinWalletView { cb.copy_string_to_buffer(nsec); } } + // Encrypted backup file: the identity JSON as stored + // (NIP-49 ncryptsec inside), incl. username + history. + if settings_row_btn( + ui, + "Export identity backup (encrypted)", + crate::gui::icons::DOWNLOAD_SIMPLE, + ) { + if let Some(s) = wallet.nostr_service() { + let json = serde_json::to_string_pretty(&*s.identity.read()) + .unwrap_or_default(); + cb.copy_string_to_buffer(json); + } + } + if settings_row_danger( + ui, + "Rotate nostr key", + crate::gui::icons::ARROWS_CLOCKWISE, + ) && self.rotate.is_none() + { + self.rotate = Some(RotateState::default()); + } + if settings_row_btn( + ui, + "Import identity (nsec / backup)", + crate::gui::icons::KEY, + ) && self.import_nsec.is_none() + { + self.import_nsec = Some(ImportState::default()); + } } }); if self.claim.is_some() { ui.add_space(8.0); self.claim_ui(ui, wallet); } + if self.rotate.is_some() { + ui.add_space(8.0); + self.rotate_ui(ui, wallet, cb); + } + if self.import_nsec.is_some() { + ui.add_space(8.0); + self.import_nsec_ui(ui, wallet); + } ui.add_space(16.0); w::kicker(ui, "Appearance"); @@ -775,21 +1175,36 @@ impl GoblinWalletView { if settings_row_btn(ui, "Theme", theme_label) { cycle_theme(ui.ctx()); } - let density_label = match crate::AppConfig::density() { - crate::gui::theme::DensityKind::Compact => "Compact", - crate::gui::theme::DensityKind::Regular => "Regular", - crate::gui::theme::DensityKind::Comfy => "Comfy", - }; - if settings_row_btn(ui, "Density", density_label) { - cycle_density(); - } }); ui.add_space(16.0); + let mut open_relays = false; + let mut open_node = false; settings_group(ui, "Wallet", |ui| { settings_row(ui, "Display unit", "ツ (grin)"); - settings_row(ui, "Relays", &relay_summary(wallet)); + if settings_row_nav(ui, "Relays", &relay_summary(wallet)) { + open_relays = true; + } + if settings_row_nav(ui, "Node", &node_summary(wallet)) { + open_node = true; + } + if settings_row_btn(ui, "Lock wallet", crate::gui::icons::LOCK) { + wallet.close(); + } }); + if open_relays { + self.relay_edit = wallet + .nostr_service() + .map(|s| s.config.read().relays()) + .unwrap_or_default(); + self.relay_input.clear(); + self.settings_page = SettingsPage::Relays; + } + if open_node { + self.node_url_input.clear(); + self.node_secret_input.clear(); + self.settings_page = SettingsPage::Node; + } ui.add_space(16.0); w::kicker(ui, "Archive"); @@ -810,13 +1225,753 @@ impl GoblinWalletView { ui.add_space(16.0); settings_group(ui, "About", |ui| { + settings_row(ui, "Goblin", &format!("Build {}", crate::BUILD)); settings_row(ui, "Network", "Mimblewimble · no address on chain"); - settings_row(ui, "Version", env!("CARGO_PKG_VERSION")); }); + + ui.add_space(16.0); + let mut open_nips = false; + settings_group(ui, "Third party", |ui| { + if settings_row_nav(ui, "GRIM (upstream wallet)", crate::VERSION) { + open_url(ui, "https://github.com/ardocrat/grim"); + } + if settings_row_nav(ui, "Grin node", "5.4.0") { + open_url(ui, "https://github.com/mimblewimble/grin"); + } + if settings_row_nav(ui, "nostr-sdk", "0.44") { + open_url(ui, "https://github.com/rust-nostr/nostr"); + } + if settings_row_nav(ui, "arti (Tor)", "0.42") { + open_url(ui, "https://gitlab.torproject.org/tpo/core/arti"); + } + if settings_row_nav(ui, "egui", "0.33") { + open_url(ui, "https://github.com/emilk/egui"); + } + if settings_row_nav(ui, "NIPs", "05 · 17 · 44 · 49 · 59 · 98") { + open_nips = true; + } + }); + if open_nips { + self.settings_page = SettingsPage::Nips; + } ui.add_space(16.0); }); } + /// Back header for Settings sub-pages; returns true when back is tapped. + fn sub_header(&mut self, ui: &mut egui::Ui, title: &str) -> bool { + let t = theme::tokens(); + let mut back = false; + ui.add_space(8.0); + ui.horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); + ui.painter().circle_filled(rect.center(), 18.0, t.surface2); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + crate::gui::icons::ARROW_LEFT, + FontId::new(16.0, fonts::regular()), + theme::ink_for(t.surface2), + ); + back = resp.clicked(); + ui.add_space(12.0); + ui.label( + RichText::new(title) + .font(FontId::new(24.0, fonts::bold())) + .color(t.text), + ); + }); + ui.add_space(16.0); + back + } + + /// Node connection editor: pick integrated/external, add or remove nodes. + fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + use crate::wallet::types::ConnectionMethod; + use crate::wallet::{ConnectionsConfig, ExternalConnection}; + let t = theme::tokens(); + if self.sub_header(ui, "Node") { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + let live = wallet.get_current_connection(); + let saved = wallet.get_config().connection(); + settings_group(ui, "Connection", |ui| { + let integrated = matches!(saved, ConnectionMethod::Integrated); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new("Integrated node") + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if integrated { + ui.label( + RichText::new(crate::gui::icons::CHECK) + .font(FontId::new(16.0, fonts::regular())) + .color(t.pos), + ); + } + }); + }); + ui.add_space(10.0); + if !integrated && row.response.interact(Sense::click()).clicked() { + wallet.update_connection(&ConnectionMethod::Integrated); + } + for conn in ConnectionsConfig::ext_conn_list() { + let active = + matches!(&saved, ConnectionMethod::External(id, _) if *id == conn.id); + let label = conn.url.replace("https://", "").replace("http://", ""); + let mut removed = false; + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(&label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if active { + ui.label( + RichText::new(crate::gui::icons::CHECK) + .font(FontId::new(16.0, fonts::regular())) + .color(t.pos), + ); + } else { + let x = ui.label( + RichText::new(crate::gui::icons::X) + .font(FontId::new(15.0, fonts::regular())) + .color(t.surface_text_mute), + ); + if x.interact(Sense::click()).clicked() { + ConnectionsConfig::remove_ext_conn(conn.id); + removed = true; + } + } + }); + }); + ui.add_space(10.0); + if !removed && !active && row.response.interact(Sense::click()).clicked() { + wallet.update_connection(&ConnectionMethod::External( + conn.id, + conn.url.clone(), + )); + } + } + }); + if saved != live { + ui.add_space(8.0); + ui.label( + RichText::new("Applies after the wallet is locked and unlocked again.") + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + } + + ui.add_space(16.0); + settings_group(ui, "Add external node", |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.node_url_input) + .hint_text("https://node.example.com:3413") + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + ui.add_space(8.0); + ui.add( + egui::TextEdit::singleline(&mut self.node_secret_input) + .hint_text("API secret (optional)") + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); + ui.add_space(10.0); + let url = self.node_url_input.trim().to_string(); + let valid = url.starts_with("http://") || url.starts_with("https://"); + if w::big_action(ui, "Add node", false).clicked() && valid { + let secret = { + let s = self.node_secret_input.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } + }; + let conn = ExternalConnection::new(url, None, secret); + wallet + .update_connection(&ConnectionMethod::External(conn.id, conn.url.clone())); + ConnectionsConfig::add_ext_conn(conn); + self.node_url_input.clear(); + self.node_secret_input.clear(); + } + ui.add_space(16.0); + }); + } + + /// Relay list editor; saving restarts the nostr service live. + fn relays_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + if self.sub_header(ui, "Relays") { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.label( + RichText::new( + "Payment messages are mirrored to every relay below; \ + one reachable relay is enough to receive.", + ) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(14.0); + settings_group(ui, "Your relays", |ui| { + let mut remove: Option = None; + let many = self.relay_edit.len() > 1; + for (i, relay) in self.relay_edit.iter().enumerate() { + ui.horizontal(|ui| { + ui.label( + RichText::new(relay) + .font(FontId::new(14.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if many { + let x = ui.label( + RichText::new(crate::gui::icons::X) + .font(FontId::new(15.0, fonts::regular())) + .color(t.surface_text_mute), + ); + if x.interact(Sense::click()).clicked() { + remove = Some(i); + } + } + }); + }); + ui.add_space(10.0); + } + if let Some(i) = remove { + self.relay_edit.remove(i); + } + }); + + ui.add_space(16.0); + settings_group(ui, "Add relay", |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.relay_input) + .hint_text("wss://relay.example.com") + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); + ui.add_space(10.0); + let relay = self.relay_input.trim().to_string(); + let valid = relay.starts_with("wss://") || relay.starts_with("ws://"); + if w::big_action_on_card(ui, "Add relay").clicked() + && valid && !self.relay_edit.contains(&relay) + { + self.relay_edit.push(relay); + self.relay_input.clear(); + } + ui.add_space(10.0); + if w::big_action(ui, "Save & reconnect", false).clicked() { + if let Some(s) = wallet.nostr_service() { + { + let mut c = s.config.write(); + c.set_relays(self.relay_edit.clone()); + c.save(); + } + s.restart(wallet.clone()); + } + self.settings_page = SettingsPage::Main; + } + ui.add_space(16.0); + }); + } + + /// What-is-nostr explainer and tappable NIP reference list. + fn nips_ui(&mut self, ui: &mut egui::Ui) { + let t = theme::tokens(); + if self.sub_header(ui, "nostr & NIPs") { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.label( + RichText::new( + "Goblin speaks nostr — an open protocol of signed messages \ + passed through simple relay servers. Your wallet carries \ + its own nostr identity: a standalone random key, kept \ + deliberately independent of your funds and seed. Every \ + payment travels as an end-to-end encrypted direct message \ + between identities, with the slatepack riding inside.", + ) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); + ui.label( + RichText::new( + "goblin.st is Goblin's name service: claiming a username \ + publishes a name → key mapping there (NIP-05), so people \ + can pay @you instead of a long npub. The username is \ + public; payment contents never are. NIPs are the \ + protocol's building blocks — tap one to read the spec.", + ) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(16.0); + let nips = [ + ( + "05", + "Names", + "Maps @username@goblin.st to your key, so handles work like addresses.", + ), + ( + "17", + "Private messages", + "The encrypted DM envelope every payment travels in.", + ), + ( + "44", + "Encryption", + "The authenticated cipher used inside those messages.", + ), + ( + "49", + "Key encryption", + "How the secret key is stored at rest, locked by your password.", + ), + ( + "59", + "Gift wrap", + "Wraps messages so relays can't see who is talking to whom.", + ), + ( + "98", + "HTTP auth", + "Signs the username registration request to goblin.st.", + ), + ]; + for (num, title, blurb) in nips { + let resp = ui.scope(|ui| { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(format!("NIP-{} · {}", num, title)) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(2.0); + ui.label( + RichText::new(blurb) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }); + if resp.response.interact(Sense::click()).clicked() { + open_url( + ui, + &format!( + "https://github.com/nostr-protocol/nips/blob/master/{}.md", + num + ), + ); + } + ui.add_space(8.0); + } + ui.add_space(16.0); + }); + } + + /// Inline key-rotation flow: warning → typed RESET + password → result. + fn rotate_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + let rotate = self.rotate.as_mut().unwrap(); + // Poll the worker result. + if rotate.stage == 3 { + if let Some(res) = rotate.result.lock().unwrap().take() { + match res { + Ok(npub) => { + rotate.new_npub = npub; + rotate.stage = 4; + } + Err(e) => { + rotate.error = e; + rotate.stage = 5; + } + } + } + } + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + match rotate.stage { + 1 => { + ui.label( + RichText::new("Rotate nostr key") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(6.0); + for line in [ + "• You get a brand-new RANDOM key; the old npub stops \ + receiving. There is no derivation chain between them.", + "• The new key is NOT recoverable from your seed — back \ + up the new nsec right after rotating.", + "• Your @username moves to the new key automatically.", + "• Payments still in flight to the old key WILL be \ + disrupted — wait for pending payments to finish first.", + "• Contacts who saved your npub directly must re-find \ + you via your @username.", + ] { + ui.label( + RichText::new(line) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(4.0); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, "Cancel").clicked() { + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action(ui, "Continue", false).clicked() { + rotate.stage = 2; + } + }, + ); + }); + } + 2 => { + ui.label( + RichText::new("Final confirmation") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(6.0); + ui.label( + RichText::new( + "This cannot be undone from the app. Type RESET and \ + enter your wallet password to rotate.", + ) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + ui.add( + egui::TextEdit::singleline(&mut rotate.reset_input) + .hint_text("Type RESET") + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + ui.add_space(8.0); + ui.add( + egui::TextEdit::singleline(&mut rotate.password) + .hint_text("Wallet password") + .password(true) + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + ui.add_space(10.0); + let armed = rotate.reset_input.trim() == "RESET" && !rotate.password.is_empty(); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, "Cancel").clicked() { + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + ui.add_enabled_ui(armed, |ui| { + if w::big_action(ui, "Rotate key", false).clicked() { + rotate.stage = 3; + let slot = rotate.result.clone(); + let password = std::mem::take(&mut rotate.password); + rotate.reset_input.clear(); + let wallet = wallet.clone(); + std::thread::spawn(move || { + let res = wallet.rotate_nostr_identity(password); + *slot.lock().unwrap() = Some(res); + }); + } + }); + }, + ); + }); + } + 3 => { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new("Rotating key…") + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } + 4 => { + ui.label( + RichText::new("Key rotated") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.pos), + ); + ui.add_space(4.0); + let npub = &rotate.new_npub; + let short = if npub.len() > 18 { + format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) + } else { + npub.clone() + }; + ui.label( + RichText::new(format!("New npub: {}", short)) + .font(FontId::new(13.0, fonts::mono())) + .color(t.surface_text_dim), + ); + ui.add_space(6.0); + ui.label( + RichText::new( + "Back up the NEW secret key now — your seed cannot \ + recover it.", + ) + .font(FontId::new(13.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, "Copy new nsec backup").clicked() { + if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) { + cb.copy_string_to_buffer(nsec); + } + } + ui.add_space(8.0); + if w::big_action(ui, "Done", false).clicked() { + close = true; + } + } + _ => { + ui.label( + RichText::new("Rotation failed") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(4.0); + ui.label( + RichText::new(&rotate.error) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, "Close").clicked() { + close = true; + } + } + } + }); + if close { + self.rotate = None; + } + } + + /// Inline nsec-import flow: replaces the identity with an imported key. + fn import_nsec_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + let import = self.import_nsec.as_mut().unwrap(); + if import.stage == 3 { + if let Some(res) = import.result.lock().unwrap().take() { + match res { + Ok(npub) => { + import.new_npub = npub; + import.stage = 4; + } + Err(e) => { + import.error = e; + import.stage = 5; + } + } + } + } + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + match import.stage { + 1 => { + ui.label( + RichText::new("Import identity") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + ui.label( + RichText::new( + "Replaces this wallet's nostr identity — paste a \ + bare nsec or an exported identity backup (the \ + backup also restores your username and history). \ + Back up the current key first if you still need it.", + ) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + ui.add( + egui::TextEdit::singleline(&mut import.nsec) + .hint_text("nsec1… or identity backup JSON") + .password(true) + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + ui.add_space(8.0); + ui.add( + egui::TextEdit::singleline(&mut import.password) + .hint_text("Wallet password") + .password(true) + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + ui.add_space(10.0); + let pasted = import.nsec.trim(); + let armed = (pasted.starts_with("nsec1") || pasted.starts_with('{')) + && !import.password.is_empty(); + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, "Cancel").clicked() { + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + ui.add_enabled_ui(armed, |ui| { + if w::big_action(ui, "Import", false).clicked() { + import.stage = 3; + let slot = import.result.clone(); + let nsec = std::mem::take(&mut import.nsec); + let password = std::mem::take(&mut import.password); + let wallet = wallet.clone(); + std::thread::spawn(move || { + let res = wallet.import_nostr_identity(nsec, password); + *slot.lock().unwrap() = Some(res); + }); + } + }); + }, + ); + }); + } + 3 => { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new("Importing…") + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } + 4 => { + ui.label( + RichText::new("Identity replaced") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.pos), + ); + ui.add_space(4.0); + let npub = &import.new_npub; + let short = if npub.len() > 18 { + format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) + } else { + npub.clone() + }; + ui.label( + RichText::new(format!("Now using: {}", short)) + .font(FontId::new(13.0, fonts::mono())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action(ui, "Done", false).clicked() { + close = true; + } + } + _ => { + ui.label( + RichText::new("Import failed") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(4.0); + ui.label( + RichText::new(&import.error) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, "Close").clicked() { + close = true; + } + } + } + }); + if close { + self.import_nsec = None; + } + } + /// Inline username-claim widget (availability check + register over Tor). fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { let t = theme::tokens(); @@ -852,14 +2007,19 @@ impl GoblinWalletView { ui.label( RichText::new("Pick a username") .font(FontId::new(15.0, fonts::semibold())) - .color(t.text), + .color(t.surface_text), ); ui.add_space(8.0); ui.horizontal(|ui| { - ui.label(RichText::new("@").font(FontId::new(16.0, fonts::semibold()))); + ui.label( + RichText::new("@") + .font(FontId::new(16.0, fonts::semibold())) + .color(t.surface_text), + ); let edit = egui::TextEdit::singleline(&mut claim.input) .hint_text("yourname") .desired_width(ui.available_width() - 20.0) + .text_color(t.surface_text) .frame(false); if ui.add(edit).changed() { claim.available = None; @@ -870,7 +2030,7 @@ impl GoblinWalletView { ui.label( RichText::new("Shown as @you. Public on goblin.st. Payments stay encrypted.") .font(FontId::new(12.0, fonts::regular())) - .color(t.text_mute), + .color(t.surface_text_mute), ); if let Some(msg) = &claim.message { ui.add_space(6.0); @@ -891,7 +2051,7 @@ impl GoblinWalletView { ui.horizontal(|ui| { View::small_loading_spinner(ui); ui.add_space(8.0); - ui.label(RichText::new("Working…").color(t.text_dim)); + ui.label(RichText::new("Working…").color(t.surface_text_dim)); }); ui.ctx().request_repaint(); } else { @@ -904,7 +2064,7 @@ impl GoblinWalletView { )), |ui| { ui.add_enabled_ui(valid, |ui| { - if w::big_action(ui, "Check", true).clicked() { + if w::big_action_on_card(ui, "Check").clicked() { start_claim_check(claim, &name, wallet); } }); @@ -996,7 +2156,7 @@ fn start_claim_register(claim: &mut ClaimState, name: &str, wallet: &Wallet) { pub fn widgets_logo(ui: &mut egui::Ui) { let size = 22.0; let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - let img = egui::Image::new(egui::include_image!("../../../../img/goblin-mask-64.png")) + let img = egui::Image::new(egui::include_image!("../../../../img/goblin-logo2-256.png")) .tint(theme::tokens().text) .fit_to_exact_size(Vec2::splat(size)); img.paint_at(ui, rect); @@ -1032,13 +2192,13 @@ fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) { ui.label( RichText::new(label) .font(FontId::new(15.0, fonts::medium())) - .color(t.text), + .color(t.surface_text), ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.label( RichText::new(value) .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), + .color(t.surface_text_dim), ); }); }); @@ -1048,17 +2208,17 @@ fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) { fn settings_row_btn(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { let t = theme::tokens(); let mut clicked = false; - ui.horizontal(|ui| { + let row = ui.horizontal(|ui| { ui.label( RichText::new(label) .font(FontId::new(15.0, fonts::medium())) - .color(t.text), + .color(t.surface_text), ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let resp = ui.label( RichText::new(icon) .font(FontId::new(18.0, fonts::regular())) - .color(t.text_dim), + .color(t.surface_text_dim), ); if resp.interact(Sense::click()).clicked() { clicked = true; @@ -1066,7 +2226,61 @@ fn settings_row_btn(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { }); }); ui.add_space(10.0); - clicked + // The whole row is tappable, not just the trailing value/icon. + clicked || row.response.interact(Sense::click()).clicked() +} + +/// A danger-styled settings row button (whole row taps). +fn settings_row_danger(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { + let t = theme::tokens(); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.neg), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(icon) + .font(FontId::new(18.0, fonts::regular())) + .color(t.neg), + ); + }); + }); + ui.add_space(10.0); + row.response.interact(Sense::click()).clicked() +} + +/// A settings row that navigates somewhere: value + chevron, whole row taps. +fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool { + let t = theme::tokens(); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(crate::gui::icons::CARET_RIGHT) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_mute), + ); + ui.add_space(4.0); + ui.label( + RichText::new(value) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }); + ui.add_space(10.0); + row.response.interact(Sense::click()).clicked() +} + +/// Open a URL in the system browser. +fn open_url(ui: &egui::Ui, url: &str) { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); } fn approve_button(ui: &mut egui::Ui) -> bool { @@ -1102,16 +2316,6 @@ fn cycle_theme(ctx: &egui::Context) { } /// Cycle the density Comfy → Regular → Compact → Comfy. -fn cycle_density() { - use crate::gui::theme::DensityKind; - let next = match crate::AppConfig::density() { - DensityKind::Comfy => DensityKind::Regular, - DensityKind::Regular => DensityKind::Compact, - DensityKind::Compact => DensityKind::Comfy, - }; - crate::AppConfig::set_density(next); -} - /// Cycle the incoming-payment accept policy Anyone → Contacts → Ask → Anyone. fn cycle_accept_policy(wallet: &Wallet) { use crate::nostr::config::AcceptPolicy; @@ -1140,6 +2344,40 @@ fn relay_summary(wallet: &Wallet) -> String { } /// Compute a fiat preview line for the balance, when a rate is available. +/// One-line node summary: "Block 1,847,221 · main.gri.mw · Tor". +fn node_summary(wallet: &Wallet) -> String { + let height = wallet + .get_data() + .map(|d| d.info.last_confirmed_height) + .unwrap_or(0); + let conn = match wallet.get_current_connection() { + crate::wallet::types::ConnectionMethod::Integrated => "integrated node".to_string(), + crate::wallet::types::ConnectionMethod::External(_, url) => url + .replace("https://", "") + .replace("http://", "") + .trim_end_matches('/') + .to_string(), + }; + if height == 0 { + format!("{} · syncing", conn) + } else { + format!("Block {} · {}", fmt_thousands(height), conn) + } +} + +/// Format a number with thousands separators. +fn fmt_thousands(n: u64) -> String { + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, c) in s.chars().enumerate() { + if i > 0 && (s.len() - i) % 3 == 0 { + out.push(','); + } + out.push(c); + } + out +} + fn fiat_line(data: &Option) -> Option { let _ = data; // Fiat rate provider is wired in P7; hide the line until a rate is available. diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index da1105b5..a6e0c4bc 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -90,6 +90,13 @@ impl SendFlow { self.stage = Stage::Amount; } + /// Pre-fill the amount (Pay tab, amount-first): the flow starts at the + /// recipient picker and jumps straight to review once one is resolved. + pub fn prefill_amount(&mut self, amount: String) { + self.amount = amount; + self.stage = Stage::Recipient; + } + /// Render the flow. Returns true when the flow is finished (close it). pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) -> bool { let t = theme::tokens(); @@ -162,28 +169,35 @@ impl SendFlow { return true; } - // Search field. + // Search field: filled rounded box per the design. let mut search = self.search.clone(); - ui.horizontal(|ui| { - ui.label( - RichText::new(MAGNIFYING_GLASS) - .font(FontId::new(18.0, fonts::regular())) - .color(t.text_mute), - ); - ui.add_space(8.0); - let edit = egui::TextEdit::singleline(&mut search) - .hint_text("@handle, npub, or name") - .desired_width(ui.available_width()) - .frame(false); - ui.add(edit); + egui::Frame { + fill: t.surface2, + corner_radius: eframe::epaint::CornerRadius::same(14), + inner_margin: egui::Margin::symmetric(14, 13), + ..Default::default() + } + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(MAGNIFYING_GLASS) + .font(FontId::new(18.0, fonts::regular())) + .color(t.surface_text_mute), + ); + ui.add_space(8.0); + let edit = egui::TextEdit::singleline(&mut search) + .hint_text("@handle, npub, or name") + .desired_width(ui.available_width()) + .text_color(t.surface_text) + .frame(false); + ui.add(edit); + }); }); if search != self.search { self.search = search; self.error = None; } - ui.add_space(6.0); - View::horizontal_line(ui, t.line); - ui.add_space(12.0); + ui.add_space(14.0); // Poll the async resolution result (set by a worker thread). self.poll_resolution(wallet); @@ -316,7 +330,12 @@ impl SendFlow { npub: hex.to_string(), hue, }); - self.stage = Stage::Amount; + // Amount-first (Pay tab): the amount is already set, go straight + // to review; otherwise continue to amount entry. + let preset = amount_from_hr_string(&self.amount) + .map(|a| a > 0) + .unwrap_or(false); + self.stage = if preset { Stage::Review } else { Stage::Amount }; } fn amount_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool { @@ -327,17 +346,23 @@ impl SendFlow { } let recipient = self.recipient.clone().unwrap(); - // Recipient chip. - ui.vertical_centered(|ui| { - ui.horizontal(|ui| { - w::avatar(ui, &recipient.name, 28.0, recipient.hue); - ui.add_space(8.0); - ui.label( - RichText::new(format!("To {}", recipient.name)) - .font(FontId::new(14.0, fonts::semibold())) - .color(t.text), - ); - }); + // Recipient chip, centered per the design. + let name_label = format!("To {}", recipient.name); + let name_galley = ui.painter().layout_no_wrap( + name_label.clone(), + FontId::new(14.0, fonts::semibold()), + t.text, + ); + let chip_w = 28.0 + 8.0 + name_galley.size().x; + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - chip_w) / 2.0).max(0.0)); + w::avatar(ui, &recipient.name, 28.0, recipient.hue); + ui.add_space(8.0); + ui.label( + RichText::new(name_label) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.text), + ); }); ui.add_space(20.0); @@ -347,26 +372,26 @@ impl SendFlow { } else { self.amount.clone() }; - ui.vertical_centered(|ui| { - w::amount_text(ui, &display, 64.0); - if let Some(rate) = crate::http::grin_usd_rate() { - if let Ok(grin) = display.parse::() { - ui.add_space(6.0); + w::amount_text_centered(ui, &display, 64.0); + if let Some(rate) = crate::http::grin_usd_rate() { + if let Ok(grin) = display.parse::() { + ui.add_space(6.0); + ui.vertical_centered(|ui| { ui.label( RichText::new(format!("≈ ${:.2}", grin * rate)) .font(FontId::new(14.0, fonts::regular())) .color(t.text_dim), ); - } + }); } - }); + } ui.add_space(16.0); // Quick chips. ui.horizontal(|ui| { ui.add_space((ui.available_width() - 220.0).max(0.0) / 2.0); for v in ["1", "10", "100", "Max"] { - if w::chip(ui, v, false).clicked() { + if w::chip_outline(ui, v).clicked() { if v == "Max" { let max = wallet .get_data() @@ -389,12 +414,13 @@ impl SendFlow { ui.label( RichText::new("Note") .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), + .color(t.surface_text_dim), ); ui.add_space(8.0); let edit = egui::TextEdit::singleline(&mut self.note) .hint_text("Add a note…") .desired_width(ui.available_width()) + .text_color(t.surface_text) .frame(false); note_focused = ui.add(edit).has_focus(); }); @@ -407,27 +433,7 @@ impl SendFlow { } else if !note_focused { // Only consume keystrokes for the amount when the note field is // not focused, so typing a note doesn't also edit the amount. - ui.input(|i| { - for ev in &i.events { - if let egui::Event::Text(txt) = ev { - for ch in txt.chars() { - if ch.is_ascii_digit() { - w::apply_key(&mut self.amount, &ch.to_string()); - } else if ch == '.' { - w::apply_key(&mut self.amount, "."); - } - } - } - if let egui::Event::Key { - key: egui::Key::Backspace, - pressed: true, - .. - } = ev - { - w::apply_key(&mut self.amount, "<"); - } - } - }); + w::amount_typed_input(ui, &mut self.amount); } ui.add_space(8.0); @@ -452,19 +458,27 @@ impl SendFlow { let amount = self.amount.clone(); w::card(ui, |ui| { - ui.vertical_centered(|ui| { - ui.horizontal(|ui| { - w::avatar(ui, &recipient.name, 36.0, recipient.hue); - ui.add_space(8.0); - ui.label( - RichText::new(format!("You're sending {}", recipient.name)) - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), - ); - }); + ui.add_space(8.0); + let label = format!("You're sending {}", recipient.name); + let galley = ui.painter().layout_no_wrap( + label.clone(), + FontId::new(14.0, fonts::regular()), + t.text_dim, + ); + let row_w = 36.0 + 8.0 + galley.size().x; + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - row_w) / 2.0).max(0.0)); + w::avatar(ui, &recipient.name, 36.0, recipient.hue); ui.add_space(8.0); - w::amount_text(ui, &amount, 48.0); + ui.label( + RichText::new(label) + .font(FontId::new(14.0, fonts::regular())) + .color(t.surface_text_dim), + ); }); + ui.add_space(8.0); + w::amount_text_centered_ink(ui, &amount, 48.0, t.surface_text, t.surface_text_dim); + ui.add_space(8.0); }); ui.add_space(16.0); diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index e9e1932b..2e35b485 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -47,14 +47,15 @@ pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response { resp } -/// Draw a balance/amount: big mono number + smaller ツ mark, tight. +/// Draw a balance/amount: big bold number + smaller ツ mark, tight. +/// Geist (sans) per the design; mono is reserved for kernel/block ids. pub fn amount_text(ui: &mut Ui, value: &str, size: f32) { let t = theme::tokens(); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.label( RichText::new(value) - .font(FontId::new(size, fonts::mono_semibold())) + .font(FontId::new(size, fonts::bold())) .color(t.text), ); ui.add_space(1.0); @@ -66,6 +67,46 @@ pub fn amount_text(ui: &mut Ui, value: &str, size: f32) { }); } +/// Like [`amount_text`] but centered in the available width. +pub fn amount_text_centered(ui: &mut Ui, value: &str, size: f32) { + let t = theme::tokens(); + amount_text_centered_ink(ui, value, size, t.text, t.text_dim); +} + +/// Centered amount with explicit inks, for drawing on card surfaces. +pub fn amount_text_centered_ink( + ui: &mut Ui, + value: &str, + size: f32, + num_ink: Color32, + mark_ink: Color32, +) { + let num = + ui.painter() + .layout_no_wrap(value.to_string(), FontId::new(size, fonts::bold()), num_ink); + let mark = ui.painter().layout_no_wrap( + TSU.to_string(), + FontId::new(size * 0.4, fonts::medium()), + mark_ink, + ); + let total = num.size().x + 1.0 + mark.size().x; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_space(((ui.available_width() - total) / 2.0).max(0.0)); + ui.label( + RichText::new(value) + .font(FontId::new(size, fonts::bold())) + .color(num_ink), + ); + ui.add_space(1.0); + ui.label( + RichText::new(TSU) + .font(FontId::new(size * 0.4, fonts::medium())) + .color(mark_ink), + ); + }); +} + /// An uppercase letterspaced kicker label. pub fn kicker(ui: &mut Ui, text: &str) { let t = theme::tokens(); @@ -108,13 +149,37 @@ pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response { resp } +/// Secondary big action drawn on a card surface: same shape as +/// [`big_action`], but the label uses on-surface text so it stays readable +/// on the yellow theme's dark cards. +pub fn big_action_on_card(ui: &mut Ui, label: &str) -> Response { + let t = theme::tokens(); + let desired = Vec2::new(ui.available_width(), 56.0); + let (rect, resp) = ui.allocate_exact_size(desired, Sense::click()); + ui.painter().rect( + rect, + CornerRadius::same(14), + Color32::TRANSPARENT, + Stroke::new(1.5, t.line), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::new(17.0, fonts::semibold()), + t.surface_text, + ); + resp +} + /// A pill/chip; returns the click response. `active` paints it inverted. pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response { let t = theme::tokens(); let galley = ui.painter().layout_no_wrap( label.to_string(), FontId::new(13.0, fonts::semibold()), - if active { t.bg } else { t.text }, + if active { t.bg } else { t.surface_text }, ); let pad = Vec2::new(14.0, 8.0); let size = galley.size() + pad * 2.0; @@ -130,11 +195,75 @@ pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response { ui.painter().galley( rect.center() - galley.size() / 2.0, galley, - if active { t.bg } else { t.text }, + if active { t.bg } else { t.surface_text }, ); resp } +/// An outline pill chip (transparent fill, line border) per the design's +/// amount quick-select row. +pub fn chip_outline(ui: &mut Ui, label: &str) -> Response { + let t = theme::tokens(); + let galley = ui.painter().layout_no_wrap( + label.to_string(), + FontId::new(13.0, fonts::semibold()), + t.text, + ); + let pad = Vec2::new(14.0, 8.0); + let size = galley.size() + pad * 2.0; + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + ui.painter().rect( + rect, + CornerRadius::same(255), + Color32::TRANSPARENT, + Stroke::new(1.0, t.line), + egui::StrokeKind::Inside, + ); + ui.painter() + .galley(rect.center() - galley.size() / 2.0, galley, t.text); + resp +} + +/// Paint a QR code for `text` in theme ink with the goblin mark centered, +/// per the design's receive card. Encoding a short URI is microseconds, so +/// this is done synchronously each frame; modules are plain painter rects. +pub fn qr_code(ui: &mut Ui, text: &str, size: f32) { + let t = theme::tokens(); + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + // High error correction tolerates the center mark covering modules. + let Ok(qr) = qrcodegen::QrCode::encode_text(text, qrcodegen::QrCodeEcc::High) else { + return; + }; + let n = qr.size(); + let cell = size / n as f32; + let radius = (cell * 0.3).max(1.0) as u8; + for y in 0..n { + for x in 0..n { + if qr.get_module(x, y) { + let min = rect.min + Vec2::new(x as f32 * cell, y as f32 * cell); + ui.painter().rect_filled( + egui::Rect::from_min_size(min, Vec2::splat(cell - 0.5)), + CornerRadius::same(radius), + t.surface_text, + ); + } + } + } + // Goblin mark on a surface backing square in the center. + let backing = size * 0.26; + let b_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing)); + ui.painter().rect_filled( + b_rect, + CornerRadius::same((backing * 0.18) as u8), + t.surface, + ); + let m_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing * 0.72)); + egui::Image::new(egui::include_image!("../../../../img/goblin-logo2-256.png")) + .tint(t.surface_text) + .fit_to_exact_size(m_rect.size()) + .paint_at(ui, m_rect); +} + /// A balance hero block: kicker, big number + ツ, optional fiat line. pub fn balance_hero(ui: &mut Ui, atomic: u64, fiat: Option<&str>, size: f32) { let t = theme::tokens(); @@ -295,14 +424,15 @@ pub fn send_receive(ui: &mut Ui) -> (bool, bool) { send = resp_s.clicked(); ui.add_space(10.0); let (rr, resp_r) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click()); + let r_fill = if resp_r.hovered() { + t.hover + } else { + t.surface2 + }; ui.painter().rect( rr, CornerRadius::same(14), - if resp_r.hovered() { - t.hover - } else { - t.surface2 - }, + r_fill, Stroke::NONE, egui::StrokeKind::Inside, ); @@ -311,7 +441,7 @@ pub fn send_receive(ui: &mut Ui) -> (bool, bool) { egui::Align2::CENTER_CENTER, format!("{} Receive", crate::gui::icons::ARROW_DOWN), FontId::new(16.0, fonts::semibold()), - t.text, + theme::ink_for(r_fill), ); receive = resp_r.clicked(); }); @@ -360,6 +490,32 @@ pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool { } /// Apply a numpad key to the amount string with validation. +/// Apply typed keyboard events (digits, '.', backspace) to an amount string, +/// for desktop where the on-screen numpad is hidden. +pub fn amount_typed_input(ui: &Ui, amount: &mut String) { + ui.input(|i| { + for ev in &i.events { + if let egui::Event::Text(txt) = ev { + for ch in txt.chars() { + if ch.is_ascii_digit() { + apply_key(amount, &ch.to_string()); + } else if ch == '.' { + apply_key(amount, "."); + } + } + } + if let egui::Event::Key { + key: egui::Key::Backspace, + pressed: true, + .. + } = ev + { + apply_key(amount, "<"); + } + } + }); +} + pub fn apply_key(amount: &mut String, key: &str) { match key { "<" => { @@ -396,19 +552,24 @@ pub fn fill_bg(ui: &Ui, color: Color32) { } /// Center a fixed-width column for narrow content on wide screens. +/// Hands the child the full remaining height: wrapping in `horizontal()` +/// would start the row a single line tall, so a `ScrollArea` inside would +/// clip everything below the first widget. pub fn centered_column(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { let avail = ui.available_width(); let w = width.min(avail); let margin = ((avail - w) / 2.0).max(0.0); - let mut result = None; - ui.horizontal(|ui| { - ui.add_space(margin); - ui.vertical(|ui| { - ui.set_width(w); - result = Some(add(ui)); - }); - }); - result.unwrap() + let mut rect = ui.available_rect_before_wrap(); + rect.min.x += margin; + rect.max.x = rect.min.x + w; + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect) + .layout(Layout::top_down(Align::Min)), + ); + let result = add(&mut child); + ui.allocate_rect(child.min_rect(), Sense::hover()); + result } /// Hold-to-send button: fills over `hold_secs`; returns true once on completion. @@ -468,7 +629,7 @@ impl HoldToSend { if self.progress > 0.5 { t.accent_ink } else { - t.text + theme::ink_for(t.surface2) }, ); if self.progress >= 1.0 { diff --git a/src/gui/views/views.rs b/src/gui/views/views.rs index a9b4e051..31b42174 100644 --- a/src/gui/views/views.rs +++ b/src/gui/views/views.rs @@ -26,7 +26,6 @@ use egui_extras::image::load_svg_bytes_with_size; use lazy_static::lazy_static; use std::sync::atomic::{AtomicI32, Ordering}; -use crate::AppConfig; use crate::gui::Colors; use crate::gui::icons::{CHECK_FAT, CHECK_SQUARE, SQUARE}; use crate::gui::views::types::LinePosition; @@ -694,27 +693,25 @@ impl View { /// Draw application logo image with name and version. pub fn app_logo_name_version(ui: &mut egui::Ui) { ui.add_space(-1.0); - let logo = if AppConfig::dark_theme().unwrap_or(false) { - egui::include_image!("../../../img/logo_light.png") - } else { - egui::include_image!("../../../img/logo.png") - }; + // Goblin mark (white master, tinted for the current theme). + let logo = egui::include_image!("../../../img/goblin-logo2-256.png"); // Show application logo and name. ui.scope(|ui| { ui.set_opacity(0.9); egui::Image::new(logo) - .fit_to_exact_size(egui::vec2(182.0, 182.0)) + .tint(Colors::white_or_black(true)) + .fit_to_exact_size(egui::vec2(150.0, 150.0)) .ui(ui); }); - ui.add_space(-11.0); + ui.add_space(8.0); ui.label( - RichText::new("GRIM") + RichText::new("GOBLIN") .size(24.0) .color(Colors::white_or_black(true)), ); ui.add_space(-2.0); ui.label( - RichText::new(crate::VERSION) + RichText::new(format!("Build {}", crate::BUILD)) .size(16.0) .color(Colors::title(false)), ); diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index 90d40d5d..90aab255 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -201,8 +201,11 @@ impl ContentContainer for WalletsContent { || self.wallets.list().is_empty() || (showing_wallet && (!dual_panel || !AppConfig::show_wallets_at_dual_panel())); - // Show title panel. - self.title_ui(ui, dual_panel, cb); + // Show title panel, except over the full-bleed Goblin wallet surface + // (it has its own header/navigation; lock + node info live there). + if !showing_wallet { + self.title_ui(ui, dual_panel, cb); + } egui::SidePanel::right("wallet_panel") .resizable(false) diff --git a/src/http/price.rs b/src/http/price.rs index 469b47ee..c26b9ab9 100644 --- a/src/http/price.rs +++ b/src/http/price.rs @@ -29,8 +29,13 @@ lazy_static! { static ref RATE: RwLock> = RwLock::new(None); static ref FETCHING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + static ref LAST_TRY: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); } +/// Minimum delay between fetch attempts, so a failing fetch (e.g. Tor still +/// bootstrapping) does not respawn a thread every frame. +const RETRY_SECS: i64 = 30; + fn now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -58,9 +63,14 @@ pub fn grin_usd_rate() -> Option { /// Spawn a background refresh over Tor (deduplicated). fn trigger_refresh() { use std::sync::atomic::Ordering; + let t = now(); + if t - LAST_TRY.load(Ordering::SeqCst) < RETRY_SECS { + return; + } if FETCHING.swap(true, Ordering::SeqCst) { return; } + LAST_TRY.store(t, Ordering::SeqCst); std::thread::spawn(|| { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -80,7 +90,18 @@ fn trigger_refresh() { async fn fetch_rate() -> Option { let url = "https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=usd".to_string(); - let body = Tor::http_request("GET", url, None, vec![]).await?; - let doc: serde_json::Value = serde_json::from_str(&body).ok()?; - doc.get("grin")?.get("usd")?.as_f64() + // CoinGecko rejects requests without a User-Agent (403). A static, + // non-identifying UA is fine over Tor. + let headers = vec![("User-Agent".to_string(), "goblin-wallet".to_string())]; + let body = Tor::http_request("GET", url, None, headers).await?; + let parsed: Option = serde_json::from_str::(&body) + .ok() + .and_then(|doc| doc.get("grin")?.get("usd")?.as_f64()); + if parsed.is_none() { + log::warn!( + "price: unexpected response from rate API (Tor exit blocked?): {}", + body.chars().take(120).collect::() + ); + } + parsed } diff --git a/src/lib.rs b/src/lib.rs index 423ad220..7f4835eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,8 +42,12 @@ mod settings; mod tor; mod wallet; +/// Upstream GRIM version the fork is based on (third-party credit). pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Goblin build number: commits on top of the GRIM base (see build.rs). +pub const BUILD: &str = env!("GOBLIN_BUILD"); + /// Android platform entry point. #[allow(dead_code)] #[cfg(target_os = "android")] diff --git a/src/nostr/identity.rs b/src/nostr/identity.rs index 33d861ab..9d6f5543 100644 --- a/src/nostr/identity.rs +++ b/src/nostr/identity.rs @@ -26,10 +26,14 @@ use std::path::PathBuf; /// Where the keys came from. #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] pub enum IdentitySource { - /// NIP-06 derivation from the wallet BIP-39 mnemonic. + /// NIP-06 derivation from the wallet BIP-39 mnemonic (legacy: binds the + /// identity to the seed forever; superseded by `Random`). Derived, /// Imported nsec. Imported, + /// Freshly generated random key, independent of the wallet seed: the + /// seed proves nothing about the identity and cannot resurrect it. + Random, } /// Identity file stored at `wallet_data/nostr/identity.json`. @@ -47,6 +51,9 @@ pub struct NostrIdentity { pub nip05: Option, /// User chose to stay anonymous (no NIP-05, no kind-0 metadata). pub anonymous: bool, + /// Previous npubs from key rotations (newest last), for reference. + #[serde(default)] + pub prev_npubs: Vec, } /// NIP-49 scrypt work factor (~64 MiB, interactive-grade). @@ -152,6 +159,13 @@ impl NostrIdentity { Ok((identity, keys)) } + /// Create a brand-new random identity, independent of the wallet seed. + pub fn create_random(password: &str) -> Result<(NostrIdentity, Keys), IdentityError> { + let keys = Keys::generate(); + let identity = Self::from_keys(&keys, password, IdentitySource::Random, 0)?; + Ok((identity, keys)) + } + /// Create an imported identity from an nsec string. pub fn create_imported( nsec: &str, @@ -192,6 +206,7 @@ impl NostrIdentity { npub, nip05: None, anonymous: true, + prev_npubs: Vec::new(), }) } @@ -222,6 +237,19 @@ impl NostrIdentity { mod tests { use super::*; + #[test] + fn random_identities_are_unlinked_and_unlock() { + let (a, ka) = NostrIdentity::create_random("pw-1").unwrap(); + let (b, _) = NostrIdentity::create_random("pw-1").unwrap(); + // Fresh entropy every time: no chain between identities. + assert_ne!(a.npub, b.npub); + assert_eq!(a.source, IdentitySource::Random); + // NIP-49 roundtrip with the right password; wrong one fails. + let unlocked = a.unlock("pw-1").unwrap(); + assert_eq!(unlocked.public_key(), ka.public_key()); + assert!(a.unlock("wrong").is_err()); + } + // NIP-06 test vector: this mnemonic must derive this npub (account 0). const NIP06_MNEMONIC: &str = "leader monkey parrot ring guide accident before fence cannon height naive bean"; diff --git a/src/nostr/nip05.rs b/src/nostr/nip05.rs index 868d20fb..1f53169b 100644 --- a/src/nostr/nip05.rs +++ b/src/nostr/nip05.rs @@ -187,6 +187,48 @@ pub async fn register(server: &str, name: &str, keys: &Keys) -> RegisterResult { } } +/// Atomically move an owned name to a new pubkey (key rotation). +/// Signed by the OLD key; the server enforces ownership and the +/// one-name-per-pubkey rule for the target key. +pub async fn transfer( + server: &str, + name: &str, + old_keys: &Keys, + new_pubkey_hex: &str, +) -> Result<(), String> { + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/transfer", server); + let body = serde_json::json!({ + "name": name.to_lowercase(), + "new_pubkey": new_pubkey_hex, + }) + .to_string(); + let Some(auth) = nip98_auth(old_keys, &url, "POST", Some(body.as_bytes())) else { + return Err("auth event build failed".to_string()); + }; + let headers = vec![ + ("Authorization".to_string(), auth), + ("Content-Type".to_string(), "application/json".to_string()), + ]; + let Some(resp) = Tor::http_request("POST", url, Some(body), headers).await else { + return Err("network unavailable".to_string()); + }; + if resp.contains("\"transferred\":true") { + return Ok(()); + } + let err = serde_json::from_str::(&resp) + .ok() + .and_then(|d| d.get("error").and_then(|e| e.as_str()).map(String::from)) + .unwrap_or_else(|| { + if resp.trim().is_empty() { + "name server does not support transfer yet".to_string() + } else { + format!("unexpected response: {}", resp) + } + }); + Err(err) +} + /// Release a registered name (NIP-98 authed by the owner). pub async fn unregister(server: &str, name: &str, keys: &Keys) -> bool { let server = server.trim_end_matches('/'); diff --git a/src/nostr/store.rs b/src/nostr/store.rs index 22e90f59..5e5100ac 100644 --- a/src/nostr/store.rs +++ b/src/nostr/store.rs @@ -49,8 +49,13 @@ impl NostrStore { pub fn new(dir: PathBuf) -> Self { let _ = fs::create_dir_all(&dir); let mut manager = Manager::::singleton().write().unwrap(); + // Open with headroom above the 5 stores below: rkv's SafeMode checks + // capacity before existence, so reopening an env that already holds + // `DEFAULT_MAX_DBS` (5) named dbs fails with DbsFull. let created_arc = manager - .get_or_create(dir.as_path(), Rkv::new::) + .get_or_create(dir.as_path(), |p: &std::path::Path| { + Rkv::with_capacity::(p, 16) + }) .unwrap(); let env = created_arc.clone(); let k = created_arc.read().unwrap(); diff --git a/src/tor/tor.rs b/src/tor/tor.rs index f96b6e10..fee340f9 100644 --- a/src/tor/tor.rs +++ b/src/tor/tor.rs @@ -90,15 +90,47 @@ pub struct Tor { impl Default for Tor { fn default() -> Self { - // Extract webtunnel bridge binary. - if !fs::exists(TorConfig::webtunnel_path()).unwrap_or(true) { + // Extract the bundled webtunnel bridge client when missing or empty + // (an empty file means the app was built without Go — see build.rs). + let wt_path = TorConfig::webtunnel_path(); + let wt_missing = fs::metadata(&wt_path).map(|m| m.len() == 0).unwrap_or(true); + if wt_missing { let webtunnel = include_bytes!(concat!(env!("OUT_DIR"), "/tor/webtunnel")); - if !webtunnel.is_empty() { - fs::write(TorConfig::webtunnel_path(), webtunnel).unwrap_or_default(); + if webtunnel.is_empty() { + error!( + "Tor: bundled webtunnel client is empty (built without Go?), \ + webtunnel bridges will not work" + ); + } else { + if let Some(parent) = std::path::Path::new(&wt_path).parent() { + let _ = fs::create_dir_all(parent); + } + match fs::write(&wt_path, webtunnel) { + Ok(_) => { + // The pluggable transport must be executable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = + fs::set_permissions(&wt_path, fs::Permissions::from_mode(0o755)); + } + } + Err(e) => error!("Tor: webtunnel client extraction failed: {}", e), + } } } + // Create the arti runtime on a clean thread: creation calls block_on, + // which panics (and poisons this lazy static) when the first TOR_STATE + // access happens on a thread already inside a tokio runtime context. + let runtime = std::thread::Builder::new() + .name("tor-runtime-init".to_string()) + .spawn(TokioNativeTlsRuntime::create) + .expect("failed to spawn tor runtime init thread") + .join() + .expect("tor runtime init thread panicked") + .unwrap(); Self { - runtime: TokioNativeTlsRuntime::create().unwrap(), + runtime, client_config: Arc::new(RwLock::new(None)), client_launching: Arc::new(AtomicBool::new(false)), run: Arc::new(RwLock::new(BTreeMap::new())), @@ -368,11 +400,32 @@ impl Tor { /// Perform an HTTP request over the embedded Tor client with optional /// headers, returning the response body. Used for NIP-05 and price APIs. + /// Retries with a fresh isolated circuit: single-circuit connect failures + /// right after bootstrap are common and transient. pub async fn http_request( method: &str, url: String, body: Option, headers: Vec<(String, String)>, + ) -> Option { + for attempt in 0..3 { + if attempt > 0 { + tokio::time::sleep(Duration::from_millis(1500)).await; + } + let resp = + Self::http_request_once(method, url.clone(), body.clone(), headers.clone()).await; + if resp.is_some() { + return resp; + } + } + None + } + + async fn http_request_once( + method: &str, + url: String, + body: Option, + headers: Vec<(String, String)>, ) -> Option { // Bind once to avoid a TOCTOU panic if Tor restarts mid-request. let Some((client_cfg, _)) = Self::client_config() else { diff --git a/src/wallet/store.rs b/src/wallet/store.rs index 6ab90887..2aec96cb 100644 --- a/src/wallet/store.rs +++ b/src/wallet/store.rs @@ -30,8 +30,12 @@ impl TxHeightStore { /// Create new transaction height storage from provided directory. pub fn new(dir: PathBuf) -> Self { let mut manager = Manager::::singleton().write().unwrap(); + // Headroom above the stores below; see NostrStore::new on rkv SafeMode + // rejecting reopens of an env already at capacity. let created_arc = manager - .get_or_create(dir.as_path(), Rkv::new::) + .get_or_create(dir.as_path(), |p: &std::path::Path| { + Rkv::with_capacity::(p, 16) + }) .unwrap(); let env = created_arc.clone(); let k = created_arc.read().unwrap(); diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 6a36338a..8c00b938 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -426,31 +426,25 @@ impl Wallet { return; } let nostr_dir = config.get_nostr_path(); - // Load existing identity or derive one from the wallet seed (NIP-06). + // Load the existing identity or generate a fresh RANDOM one. The key + // is deliberately independent of the wallet seed: the seed must never + // double as identity evidence, and a rotation must not be derivable + // from it. The nsec backup is therefore the identity backup. let identity = match NostrIdentity::load(&nostr_dir) { Some(identity) => identity, - None => { - let phrase = match self.get_recovery(password.to_string()) { - Ok(p) => p, - Err(e) => { - error!("nostr: recovery phrase unavailable: {:?}", e); - return; - } - }; - match NostrIdentity::create_derived(&phrase, password, 0) { - Ok((identity, _)) => { - if let Err(e) = identity.save(&nostr_dir) { - error!("nostr: identity save failed: {e}"); - return; - } - identity - } - Err(e) => { - error!("nostr: identity derivation failed: {e}"); + None => match NostrIdentity::create_random(password) { + Ok((identity, _)) => { + if let Err(e) = identity.save(&nostr_dir) { + error!("nostr: identity save failed: {e}"); return; } + identity } - } + Err(e) => { + error!("nostr: identity creation failed: {e}"); + return; + } + }, }; let keys = match identity.unlock(password) { Ok(keys) => keys, @@ -472,6 +466,133 @@ impl Wallet { r_nostr.clone() } + /// Rotate the nostr identity to a brand-new RANDOM key (no derivation + /// chain: a future seed compromise cannot reach it, and the old key + /// shares nothing with it), atomically moving the registered username + /// (if any) to the new key via the name server. Blocking (Tor HTTP): + /// call from a worker thread. Returns the new bech32 npub. + pub fn rotate_nostr_identity(&self, password: String) -> Result { + let svc = self + .nostr_service() + .ok_or_else(|| "nostr is not running".to_string())?; + // Snapshot the old identity and prove the password by unlocking it + // (NIP-49 decryption fails on a wrong password). + let old = svc.identity.read().clone(); + let old_keys = old + .unlock(&password) + .map_err(|_| "Wrong password".to_string())?; + + // Generate the replacement identity. + let (mut new_identity, new_keys) = NostrIdentity::create_random(&password) + .map_err(|e| format!("key generation failed: {e}"))?; + + // Move the username first; abort the rotation if that fails so the + // user never ends up keyless-but-named or named-but-keyless. + if let Some(nip05) = old.nip05.clone() { + let name = nip05.split('@').next().unwrap_or_default().to_string(); + let server = { svc.config.read().nip05_server() }; + let new_hex = new_keys.public_key().to_hex(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + rt.block_on(async { + crate::nostr::nip05::transfer(&server, &name, &old_keys, &new_hex).await + }) + .map_err(|e| format!("Couldn't move @{name}: {e}"))?; + new_identity.nip05 = Some(nip05); + new_identity.anonymous = false; + } + new_identity.prev_npubs = { + let mut v = old.prev_npubs.clone(); + v.push(old.npub.clone()); + v + }; + + // Persist, then swap the running service for one bound to the new key. + let config = self.get_config(); + let nostr_dir = config.get_nostr_path(); + new_identity + .save(&nostr_dir) + .map_err(|e| format!("identity save failed: {e}"))?; + svc.stop(); + for _ in 0..100 { + if !svc.is_running() { + break; + } + thread::sleep(Duration::from_millis(100)); + } + let wallet_dir = PathBuf::from(config.get_data_path()); + let nostr_config = NostrConfig::load(wallet_dir); + let store = NostrStore::new(config.get_nostr_db_path()); + let new_npub = new_identity.npub.clone(); + let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir); + { + let mut w_nostr = self.nostr.write(); + *w_nostr = Some(new_svc.clone()); + } + new_svc.start(self.clone()); + info!("nostr: identity rotated to {}", new_npub); + Ok(new_npub) + } + + /// Replace the nostr identity with an imported key — either a bare nsec + /// or an exported identity-backup JSON (which restores the username and + /// rotation history too). The current identity is overwritten; its npub + /// is kept in `prev_npubs` for reference. Blocking-safe (no network). + pub fn import_nostr_identity(&self, input: String, password: String) -> Result { + let svc = self + .nostr_service() + .ok_or_else(|| "nostr is not running".to_string())?; + // Prove the password against the current identity before replacing. + let old = svc.identity.read().clone(); + old.unlock(&password) + .map_err(|_| "Wrong password".to_string())?; + let input = input.trim(); + let (mut new_identity, new_keys) = if input.starts_with('{') { + // Identity backup JSON: the ncryptsec inside stays as exported; + // it must decrypt with the provided (= export-time) password. + let ident: NostrIdentity = + serde_json::from_str(input).map_err(|_| "Invalid identity backup".to_string())?; + let keys = ident.unlock(&password).map_err(|_| { + "Backup password mismatch (use the wallet password from when \ + it was exported)" + .to_string() + })?; + (ident, keys) + } else { + NostrIdentity::create_imported(input, &password) + .map_err(|_| "Invalid nsec".to_string())? + }; + if new_identity.npub != old.npub && !new_identity.prev_npubs.contains(&old.npub) { + new_identity.prev_npubs.push(old.npub.clone()); + } + let config = self.get_config(); + let nostr_dir = config.get_nostr_path(); + new_identity + .save(&nostr_dir) + .map_err(|e| format!("identity save failed: {e}"))?; + svc.stop(); + for _ in 0..100 { + if !svc.is_running() { + break; + } + thread::sleep(Duration::from_millis(100)); + } + let wallet_dir = PathBuf::from(config.get_data_path()); + let nostr_config = NostrConfig::load(wallet_dir); + let store = NostrStore::new(config.get_nostr_db_path()); + let new_npub = new_identity.npub.clone(); + let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir); + { + let mut w_nostr = self.nostr.write(); + *w_nostr = Some(new_svc.clone()); + } + new_svc.start(self.clone()); + info!("nostr: identity replaced by import: {}", new_npub); + Ok(new_npub) + } + /// Get keychain mask [`SecretKey`]. pub fn keychain_mask(&self) -> Option { let r_key = self.keychain_mask.read();