diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index 7475c77..586dd8c 100644 --- a/keep-desktop/src/app.rs +++ b/keep-desktop/src/app.rs @@ -39,11 +39,13 @@ use crate::screen::shares::{ShareEntry, ShareListScreen}; use crate::screen::signing_audit::{AuditDisplayEntry, ChainStatus, SigningAuditScreen}; use crate::screen::unlock::UnlockScreen; use crate::screen::wallet::{ - DescriptorProgress, SetupPhase, SetupState, TierConfig, WalletEntry, WalletScreen, + AnnounceState, DescriptorProgress, SetupPhase, SetupState, TierConfig, WalletEntry, + WalletScreen, }; use crate::screen::Screen; use crate::theme; use crate::tray::{TrayEvent, TrayState}; +use keep_frost_net::{MAX_XPUB_LABEL_LENGTH, MAX_XPUB_LENGTH}; static PENDING_NOSTRCONNECT: OnceLock>> = OnceLock::new(); @@ -157,6 +159,7 @@ pub struct App { pub(crate) pin_mismatch_confirm: bool, pub(crate) bunker_cert_pin_failed: bool, pub(crate) active_coordinations: HashMap<[u8; 32], ActiveCoordination>, + pub(crate) peer_xpubs: HashMap>, import_return_to_nsec: bool, cached_share_count: usize, cached_nsec_count: usize, @@ -567,6 +570,7 @@ impl App { tray_last_bunker: false, scanner_rx: None, active_coordinations: HashMap::new(), + peer_xpubs: HashMap::new(), import_return_to_nsec: false, cached_share_count: 0, cached_nsec_count: 0, @@ -709,7 +713,14 @@ impl App { | Message::WalletBeginCoordination | Message::WalletCancelSetup | Message::WalletSessionStarted(..) - | Message::WalletDescriptorProgress(..) => self.handle_wallet_message(message), + | Message::WalletDescriptorProgress(..) + | Message::WalletStartAnnounce + | Message::WalletAnnounceXpubChanged(..) + | Message::WalletAnnounceFingerprintChanged(..) + | Message::WalletAnnounceLabelChanged(..) + | Message::WalletCancelAnnounce + | Message::WalletSubmitAnnounce + | Message::WalletAnnounceResult(..) => self.handle_wallet_message(message), Message::RelayUrlChanged(..) | Message::ConnectPasswordChanged(..) @@ -951,7 +962,9 @@ impl App { Message::WalletsLoaded(result) => { match result { Ok(entries) => { - self.screen = Screen::Wallet(WalletScreen::new(entries)); + let mut ws = WalletScreen::new(entries); + ws.peer_xpubs = self.peer_xpubs.clone(); + self.screen = Screen::Wallet(ws); } Err(e) => { self.set_toast(e, ToastKind::Error); @@ -1420,6 +1433,95 @@ impl App { } Task::none() } + Message::WalletStartAnnounce => { + if let Screen::Wallet(s) = &mut self.screen { + s.announce = Some(AnnounceState { + xpub: String::new(), + fingerprint: String::new(), + label: String::new(), + error: None, + submitting: false, + }); + } + Task::none() + } + Message::WalletAnnounceXpubChanged(v) => { + if let Some(a) = self.announce_state_mut() { + a.xpub = v.chars().take(MAX_XPUB_LENGTH).collect(); + } + Task::none() + } + Message::WalletAnnounceFingerprintChanged(v) => { + if let Some(a) = self.announce_state_mut() { + a.fingerprint = v + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .take(8) + .collect(); + } + Task::none() + } + Message::WalletAnnounceLabelChanged(v) => { + if let Some(a) = self.announce_state_mut() { + a.label = v.chars().take(MAX_XPUB_LABEL_LENGTH).collect(); + } + Task::none() + } + Message::WalletCancelAnnounce => { + if let Screen::Wallet(s) = &mut self.screen { + s.announce = None; + } + Task::none() + } + Message::WalletSubmitAnnounce => { + let Some(a) = self.announce_state_mut() else { + return Task::none(); + }; + a.submitting = true; + a.error = None; + let xpub = a.xpub.trim().to_string(); + let fingerprint = a.fingerprint.trim().to_string(); + let label = a.label.trim().to_string(); + + let Some(node) = self.get_frost_node() else { + if let Some(a) = self.announce_state_mut() { + a.error = Some("Relay not connected".into()); + a.submitting = false; + } + return Task::none(); + }; + + let announced = keep_frost_net::AnnouncedXpub { + xpub, + fingerprint, + label: if label.is_empty() { None } else { Some(label) }, + }; + + Task::perform( + async move { + node.announce_xpubs(vec![announced]) + .await + .map_err(|e| format!("{e}")) + }, + Message::WalletAnnounceResult, + ) + } + Message::WalletAnnounceResult(result) => { + match result { + Ok(()) => { + if let Screen::Wallet(s) = &mut self.screen { + s.announce = None; + } + } + Err(e) => { + if let Some(a) = self.announce_state_mut() { + a.error = Some(e); + a.submitting = false; + } + } + } + Task::none() + } _ => Task::none(), } } @@ -1779,6 +1881,7 @@ impl App { let clear_clipboard = self.clipboard_clear_at.take().is_some(); self.active_share_hex = None; self.active_coordinations.clear(); + self.peer_xpubs.clear(); self.identities.clear(); self.cached_share_count = 0; self.cached_nsec_count = 0; @@ -3372,6 +3475,7 @@ impl App { tray_last_bunker: false, scanner_rx: None, active_coordinations: HashMap::new(), + peer_xpubs: HashMap::new(), import_return_to_nsec: false, cached_share_count: 0, cached_nsec_count: 0, diff --git a/keep-desktop/src/frost.rs b/keep-desktop/src/frost.rs index 855283a..9ccdb7b 100644 --- a/keep-desktop/src/frost.rs +++ b/keep-desktop/src/frost.rs @@ -662,6 +662,16 @@ impl App { self.frost_node.lock().ok()?.clone() } + pub(crate) fn announce_state_mut( + &mut self, + ) -> Option<&mut crate::screen::wallet::AnnounceState> { + if let Screen::Wallet(ws) = &mut self.screen { + ws.announce.as_mut() + } else { + None + } + } + pub(crate) fn update_wallet_setup( &mut self, session_id: &[u8; 32], @@ -819,6 +829,18 @@ impl App { count = recovery_xpubs.len(), "Received recovery xpub announcement" ); + let entry = self.peer_xpubs.entry(share_index).or_default(); + for xpub in recovery_xpubs { + if let Some(existing) = entry.iter_mut().find(|x| x.xpub == xpub.xpub) { + existing.fingerprint = xpub.fingerprint; + existing.label = xpub.label; + } else if entry.len() < keep_frost_net::MAX_RECOVERY_XPUBS { + entry.push(xpub); + } + } + if let Screen::Wallet(ws) = &mut self.screen { + ws.peer_xpubs = self.peer_xpubs.clone(); + } } FrostNodeMsg::HealthCheckComplete { responsive, diff --git a/keep-desktop/src/message.rs b/keep-desktop/src/message.rs index 478af9b..40c5951 100644 --- a/keep-desktop/src/message.rs +++ b/keep-desktop/src/message.rs @@ -202,6 +202,13 @@ pub enum Message { WalletCancelSetup, WalletSessionStarted(Result<([u8; 32], [u8; 32], String, usize), String>), WalletDescriptorProgress(DescriptorProgress, Option<[u8; 32]>), + WalletStartAnnounce, + WalletAnnounceXpubChanged(String), + WalletAnnounceFingerprintChanged(String), + WalletAnnounceLabelChanged(String), + WalletCancelAnnounce, + WalletSubmitAnnounce, + WalletAnnounceResult(Result<(), String>), // Relay / FROST RelayUrlChanged(String), ConnectPasswordChanged(Zeroizing), @@ -527,6 +534,20 @@ impl fmt::Debug for Message { .field(&r.as_ref().map(|_| "ok").map_err(|e| e.as_str())) .finish(), Self::WalletDescriptorProgress(..) => f.write_str("WalletDescriptorProgress"), + Self::WalletStartAnnounce => f.write_str("WalletStartAnnounce"), + Self::WalletAnnounceXpubChanged(_) => f.write_str("WalletAnnounceXpubChanged(***)"), + Self::WalletAnnounceFingerprintChanged(_) => { + f.write_str("WalletAnnounceFingerprintChanged()") + } + Self::WalletAnnounceLabelChanged(_) => { + f.write_str("WalletAnnounceLabelChanged()") + } + Self::WalletCancelAnnounce => f.write_str("WalletCancelAnnounce"), + Self::WalletSubmitAnnounce => f.write_str("WalletSubmitAnnounce"), + Self::WalletAnnounceResult(r) => f + .debug_tuple("WalletAnnounceResult") + .field(&r.as_ref().map(|_| "ok").map_err(|e| e.as_str())) + .finish(), Self::RelayUrlChanged(u) => f.debug_tuple("RelayUrlChanged").field(u).finish(), Self::ConnectPasswordChanged(_) => f.write_str("ConnectPasswordChanged(***)"), Self::AddRelay => f.write_str("AddRelay"), diff --git a/keep-desktop/src/screen/wallet.rs b/keep-desktop/src/screen/wallet.rs index ab82fb9..d82de18 100644 --- a/keep-desktop/src/screen/wallet.rs +++ b/keep-desktop/src/screen/wallet.rs @@ -1,9 +1,13 @@ // SPDX-FileCopyrightText: © 2026 PrivKey LLC // SPDX-License-Identifier: AGPL-3.0-or-later +use std::collections::HashMap; + use chrono::{DateTime, Utc}; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::{Alignment, Element, Length}; +use keep_frost_net::AnnouncedXpub; +use keep_frost_net::VALID_XPUB_PREFIXES; use crate::message::Message; use crate::screen::shares::ShareEntry; @@ -69,10 +73,20 @@ pub enum SetupPhase { Coordinating(DescriptorProgress), } +pub struct AnnounceState { + pub xpub: String, + pub fingerprint: String, + pub label: String, + pub error: Option, + pub submitting: bool, +} + pub struct WalletScreen { pub descriptors: Vec, pub expanded: Option, pub setup: Option, + pub announce: Option, + pub peer_xpubs: HashMap>, } pub struct SetupState { @@ -91,10 +105,16 @@ impl WalletScreen { descriptors, expanded: None, setup: None, + announce: None, + peer_xpubs: HashMap::new(), } } pub fn view_content(&self) -> Element<'_, Message> { + if let Some(announce) = &self.announce { + return self.view_announce(announce); + } + if let Some(setup) = &self.setup { return self.view_setup(setup); } @@ -104,40 +124,53 @@ impl WalletScreen { .padding([theme::space::SM, theme::space::LG]) .on_press(Message::WalletStartSetup); + let announce_btn = button(text("Announce Recovery Keys").size(theme::size::BODY)) + .style(theme::secondary_button) + .padding([theme::space::SM, theme::space::LG]) + .on_press(Message::WalletStartAnnounce); + let title_row = row![ theme::heading("Wallet Descriptors"), Space::new().width(Length::Fill), + announce_btn, setup_btn, ] + .spacing(theme::space::SM) .align_y(Alignment::Center); let mut content = column![title_row].spacing(theme::space::MD); - if self.descriptors.is_empty() { - let empty = column![ - text("No wallet descriptors yet") - .size(theme::size::BODY) - .color(theme::color::TEXT_MUTED), - text("Use Setup Wallet to coordinate a descriptor with your peers.") - .size(theme::size::SMALL) - .color(theme::color::TEXT_DIM), - ] - .align_x(Alignment::Center) - .spacing(theme::space::SM); + let mut list = column![].spacing(theme::space::SM); - content = content.push( - container(empty) - .center_x(Length::Fill) - .center_y(Length::Fill), + if self.descriptors.is_empty() { + list = list.push( + container( + column![ + text("No wallet descriptors yet") + .size(theme::size::BODY) + .color(theme::color::TEXT_MUTED), + text("Use Setup Wallet to coordinate a descriptor with your peers.") + .size(theme::size::SMALL) + .color(theme::color::TEXT_DIM), + ] + .align_x(Alignment::Center) + .spacing(theme::space::SM), + ) + .center_x(Length::Fill) + .center_y(Length::Fill), ); } else { - let mut list = column![].spacing(theme::space::SM); for (i, entry) in self.descriptors.iter().enumerate() { list = list.push(self.wallet_card(i, entry)); } - content = content.push(scrollable(list).height(Length::Fill)); } + if !self.peer_xpubs.is_empty() { + list = list.push(self.peer_xpubs_card()); + } + + content = content.push(scrollable(list).height(Length::Fill)); + container(content) .padding(theme::space::XL) .width(Length::Fill) @@ -477,4 +510,133 @@ impl WalletScreen { .width(Length::Fill) .into() } + + fn view_announce<'a>(&self, state: &'a AnnounceState) -> Element<'a, Message> { + let back_btn = button(text("< Back").size(theme::size::BODY)) + .on_press(Message::WalletCancelAnnounce) + .style(theme::text_button) + .padding([theme::space::XS, theme::space::SM]); + + let title_text = text("Announce Recovery Key") + .size(theme::size::HEADING) + .color(theme::color::TEXT); + + let header = row![back_btn, Space::new().width(theme::space::SM), title_text] + .align_y(Alignment::Center); + + let subtitle = text("Share a recovery xpub with your FROST group peers") + .size(theme::size::SMALL) + .color(theme::color::TEXT_MUTED); + + let xpub_input = text_input("tpub...", &state.xpub) + .on_input(Message::WalletAnnounceXpubChanged) + .padding(theme::space::SM); + + let fp_input = text_input("8 hex chars", &state.fingerprint) + .on_input(Message::WalletAnnounceFingerprintChanged) + .padding(theme::space::SM) + .width(120); + + let label_input = text_input("e.g. coldcard-backup", &state.label) + .on_input(Message::WalletAnnounceLabelChanged) + .padding(theme::space::SM); + + let xpub_valid = VALID_XPUB_PREFIXES + .iter() + .any(|p| state.xpub.starts_with(p)); + let fp_valid = state.fingerprint.len() == 8 + && state.fingerprint.chars().all(|c| c.is_ascii_hexdigit()); + let can_submit = xpub_valid && fp_valid && !state.submitting; + + let mut submit_btn = button(text("Announce").size(theme::size::BODY)) + .style(theme::primary_button) + .padding([theme::space::SM, theme::space::LG]); + if can_submit { + submit_btn = submit_btn.on_press(Message::WalletSubmitAnnounce); + } + + let mut content = column![ + header, + subtitle, + Space::new().height(theme::space::SM), + theme::label("Extended Public Key"), + xpub_input, + Space::new().height(theme::space::XS), + theme::label("Fingerprint"), + fp_input, + Space::new().height(theme::space::XS), + theme::label("Label (optional)"), + label_input, + Space::new().height(theme::space::MD), + submit_btn, + ] + .spacing(theme::space::XS); + + if let Some(err) = &state.error { + content = content.push(theme::error_text(err.as_str())); + } + + container(scrollable(content)) + .padding(theme::space::XL) + .width(Length::Fill) + .height(Length::Fill) + .into() + } + + fn peer_xpubs_card(&self) -> Element<'_, Message> { + let title = text("Announced Recovery Keys") + .size(theme::size::HEADING) + .color(theme::color::TEXT); + + let mut content = column![title].spacing(theme::space::SM); + + let mut indices: Vec<_> = self.peer_xpubs.keys().copied().collect(); + indices.sort(); + + for idx in indices { + let xpubs = &self.peer_xpubs[&idx]; + let share_label = text(format!("Share {idx}")) + .size(theme::size::BODY) + .color(theme::color::TEXT); + + let mut share_col = column![share_label].spacing(theme::space::XS); + + for xpub in xpubs { + let display = if xpub.xpub.chars().count() > 32 { + let prefix: String = xpub.xpub.chars().take(32).collect(); + format!("{prefix}...") + } else { + xpub.xpub.clone() + }; + + let mut xpub_row = row![ + text(display) + .size(theme::size::TINY) + .color(theme::color::TEXT_DIM), + text(&xpub.fingerprint) + .size(theme::size::TINY) + .color(theme::color::TEXT_MUTED), + ] + .spacing(theme::space::SM); + + if let Some(label) = &xpub.label { + xpub_row = xpub_row.push( + text(label) + .size(theme::size::TINY) + .color(theme::color::TEXT_MUTED), + ); + } + + share_col = share_col.push(xpub_row); + } + + content = content.push(share_col); + } + + container(content) + .style(theme::card_style) + .padding(theme::space::LG) + .width(Length::Fill) + .into() + } }