From 52755ed8129041433e3d9695a3c5f93b094c80ed Mon Sep 17 00:00:00 2001 From: edouardparis Date: Mon, 26 Aug 2024 17:47:32 +0200 Subject: [PATCH 1/2] Display details of wallet policy in installer --- gui/src/installer/step/descriptor.rs | 16 +- gui/src/installer/view.rs | 227 +++++++++++++++++++++++---- 2 files changed, 214 insertions(+), 29 deletions(-) diff --git a/gui/src/installer/step/descriptor.rs b/gui/src/installer/step/descriptor.rs index 0411725f..89d6265b 100644 --- a/gui/src/installer/step/descriptor.rs +++ b/gui/src/installer/step/descriptor.rs @@ -1363,6 +1363,7 @@ impl From for Box { pub struct BackupDescriptor { done: bool, descriptor: Option, + key_aliases: HashMap, } impl Step for BackupDescriptor { @@ -1377,6 +1378,12 @@ impl Step for BackupDescriptor { self.descriptor.clone_from(&ctx.descriptor); self.done = false; } + self.key_aliases = ctx + .keys + .iter() + .cloned() + .map(|k| (k.master_fingerprint, k.name)) + .collect() } fn view<'a>( &'a self, @@ -1384,8 +1391,13 @@ impl Step for BackupDescriptor { progress: (usize, usize), email: Option<&'a str>, ) -> Element { - let desc = self.descriptor.as_ref().unwrap(); - view::backup_descriptor(progress, email, desc.to_string(), self.done) + view::backup_descriptor( + progress, + email, + self.descriptor.as_ref().expect("Must be a descriptor"), + &self.key_aliases, + self.done, + ) } } diff --git a/gui/src/installer/view.rs b/gui/src/installer/view.rs index 7fa53d40..79b2fbbb 100644 --- a/gui/src/installer/view.rs +++ b/gui/src/installer/view.rs @@ -3,15 +3,23 @@ use iced::widget::{ checkbox, container, pick_list, radio, scrollable, scrollable::Properties, slider, Button, Space, TextInput, }; -use iced::{alignment, widget::progress_bar, Alignment, Length}; +use iced::{ + alignment, + widget::{progress_bar, tooltip as iced_tooltip}, + Alignment, Length, +}; use async_hwi::DeviceKind; use liana_ui::component::text; +use std::collections::HashMap; use std::net::{Ipv4Addr, Ipv6Addr}; use std::path::PathBuf; use std::{collections::HashSet, str::FromStr}; -use liana::miniscript::bitcoin::{self, bip32::Fingerprint}; +use liana::{ + descriptors::{LianaDescriptor, LianaPolicy}, + miniscript::bitcoin::{self, bip32::Fingerprint}, +}; use liana_ui::{ color, component::{ @@ -1007,12 +1015,13 @@ pub fn register_descriptor<'a>( ) } -pub fn backup_descriptor( +pub fn backup_descriptor<'a>( progress: (usize, usize), - email: Option<&str>, - descriptor: String, + email: Option<&'a str>, + descriptor: &'a LianaDescriptor, + keys_aliases: &'a HashMap, done: bool, -) -> Element<'_, Message> { +) -> Element<'a, Message> { layout( progress, email, @@ -1046,28 +1055,37 @@ pub fn backup_descriptor( )) .max_width(1000), ) - .push(card::simple( - Column::new() - .push(text("The descriptor:").small().bold()) - .push( - scrollable( - Column::new() - .push(text(descriptor.to_owned()).small()) - .push(Space::with_height(Length::Fixed(5.0))), + .push( + card::simple( + Column::new() + .push(text("The descriptor:").small().bold()) + .push( + scrollable( + Column::new() + .push(text(descriptor.to_string()).small()) + .push(Space::with_height(Length::Fixed(5.0))), + ) + .direction( + scrollable::Direction::Horizontal( + scrollable::Properties::new().width(5).scroller_width(5), + ), + ), ) - .direction(scrollable::Direction::Horizontal( - scrollable::Properties::new().width(5).scroller_width(5), - )), - ) - .push( - Row::new().push(Column::new().width(Length::Fill)).push( - button::secondary(Some(icon::clipboard_icon()), "Copy") - .on_press(Message::Clibpboard(descriptor)), - ), - ) - .spacing(10) - .max_width(1000), - )) + .push( + Row::new().push(Column::new().width(Length::Fill)).push( + button::secondary(Some(icon::clipboard_icon()), "Copy") + .on_press(Message::Clibpboard(descriptor.to_string())), + ), + ) + .spacing(10), + ) + .max_width(1500), + ) + .push( + card::simple(display_policy(descriptor.policy(), keys_aliases)) + .width(Length::Fill) + .max_width(1500), + ) .push( checkbox("I have backed up my descriptor", done).on_toggle(Message::UserActionDone), ) @@ -1084,6 +1102,161 @@ pub fn backup_descriptor( ) } +fn display_policy( + policy: LianaPolicy, + keys_aliases: &HashMap, +) -> Element<'_, Message> { + let (primary_threshold, primary_keys) = policy.primary_path().thresh_origins(); + let recovery_paths = policy.recovery_paths(); + let mut col = Column::new().push( + Row::new() + .spacing(5) + .push( + text(format!( + "{} signature{}", + primary_threshold, + if primary_threshold > 1 { "s" } else { "" } + )) + .bold(), + ) + .push(if primary_keys.len() > 1 { + text(format!("out of {} by", primary_keys.len())) + } else { + text("by") + }) + .push( + primary_keys + .keys() + .enumerate() + .fold(Row::new().spacing(5), |row, (i, k)| { + let content = if let Some(alias) = keys_aliases.get(k) { + Container::new( + iced_tooltip::Tooltip::new( + text(alias).bold(), + text(k.to_string()), + iced_tooltip::Position::Bottom, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + Container::new(text(k.to_string())) + .padding(10) + .style(theme::Container::Pill(theme::Pill::Simple)) + }; + if primary_keys.len() == 1 || i == primary_keys.len() - 1 { + row.push(content) + } else if i <= primary_keys.len() - 2 { + row.push(content).push(text("and")) + } else { + row.push(content).push(text(",")) + } + }), + ) + .push(text("can always spend this wallet's funds (Primary path)")), + ); + for (i, (sequence, recovery_path)) in recovery_paths.iter().enumerate() { + let (threshold, recovery_keys) = recovery_path.thresh_origins(); + col = col.push( + Row::new() + .spacing(5) + .push( + text(format!( + "{} signature{}", + threshold, + if threshold > 1 { "s" } else { "" } + )) + .bold(), + ) + .push(if recovery_keys.len() > 1 { + text(format!("out of {} by", recovery_keys.len())) + } else { + text("by") + }) + .push(recovery_keys.keys().enumerate().fold( + Row::new().spacing(5), + |row, (i, k)| { + let content = if let Some(alias) = keys_aliases.get(k) { + Container::new( + iced_tooltip::Tooltip::new( + text(alias).bold(), + text(k.to_string()), + iced_tooltip::Position::Bottom, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + Container::new(text(k.to_string())) + .padding(10) + .style(theme::Container::Pill(theme::Pill::Simple)) + }; + if recovery_keys.len() == 1 || i == recovery_keys.len() - 1 { + row.push(content) + } else if i <= recovery_keys.len() - 2 { + row.push(content).push(text("and")) + } else { + row.push(content).push(text(",")) + } + }, + )) + .push(text("can spend coins inactive for")) + .push( + text(format!( + "{} blocks (~{})", + sequence, + expire_message_units(*sequence as u32).join(",") + )) + .bold(), + ) + .push(text(format!("(Recovery path #{})", i + 1))), + ); + } + Column::new() + .spacing(10) + .push(text("The wallet policy:").bold()) + .push(scrollable(col).direction(scrollable::Direction::Horizontal( + scrollable::Properties::new().width(5).scroller_width(5), + ))) + .into() +} + +/// returns y,m,d +fn expire_message_units(sequence: u32) -> Vec { + let mut n_minutes = sequence * 10; + let n_years = n_minutes / 525960; + n_minutes -= n_years * 525960; + let n_months = n_minutes / 43830; + n_minutes -= n_months * 43830; + let n_days = n_minutes / 1440; + + #[allow(clippy::nonminimal_bool)] + if n_years != 0 || n_months != 0 || n_days != 0 { + [(n_years, "y"), (n_months, "m"), (n_days, "d")] + .iter() + .filter_map(|(n, u)| { + if *n != 0 { + Some(format!("{}{}", n, u)) + } else { + None + } + }) + .collect() + } else { + n_minutes -= n_days * 1440; + let n_hours = n_minutes / 60; + n_minutes -= n_hours * 60; + [(n_hours, "h"), (n_minutes, "m")] + .iter() + .filter_map(|(n, u)| { + if *n != 0 { + Some(format!("{}{}", n, u)) + } else { + None + } + }) + .collect() + } +} + pub fn help_backup<'a>() -> Element<'a, Message> { text(prompt::BACKUP_DESCRIPTOR_HELP).small().into() } From 59e7131aeef4478933c6923155ed59535e218c96 Mon Sep 17 00:00:00 2001 From: edouardparis Date: Tue, 27 Aug 2024 14:43:46 +0200 Subject: [PATCH 2/2] Display wallet policy in wallet settings --- gui/src/app/state/settings/wallet.rs | 11 +- gui/src/app/view/settings.rs | 347 +++++++++++++++++++++------ 2 files changed, 275 insertions(+), 83 deletions(-) diff --git a/gui/src/app/state/settings/wallet.rs b/gui/src/app/state/settings/wallet.rs index 34690539..e560ac8a 100644 --- a/gui/src/app/state/settings/wallet.rs +++ b/gui/src/app/state/settings/wallet.rs @@ -5,7 +5,10 @@ use std::sync::Arc; use iced::{Command, Subscription}; -use liana::miniscript::bitcoin::{bip32::Fingerprint, Network}; +use liana::{ + descriptors::LianaDescriptor, + miniscript::bitcoin::{bip32::Fingerprint, Network}, +}; use liana_ui::{ component::{form, modal}, @@ -23,7 +26,7 @@ use crate::{ pub struct WalletSettingsState { data_dir: PathBuf, warning: Option, - descriptor: String, + descriptor: LianaDescriptor, keys_aliases: Vec<(Fingerprint, form::Value)>, wallet: Arc, modal: Option, @@ -35,7 +38,7 @@ impl WalletSettingsState { pub fn new(data_dir: PathBuf, wallet: Arc) -> Self { WalletSettingsState { data_dir, - descriptor: wallet.main_descriptor.to_string(), + descriptor: wallet.main_descriptor.clone(), keys_aliases: Self::keys_aliases(&wallet), wallet, warning: None, @@ -177,7 +180,7 @@ impl State for WalletSettingsState { daemon: Arc, wallet: Arc, ) -> Command { - self.descriptor = wallet.main_descriptor.to_string(); + self.descriptor = wallet.main_descriptor.clone(); self.keys_aliases = Self::keys_aliases(&wallet); self.wallet = wallet; Command::perform( diff --git a/gui/src/app/view/settings.rs b/gui/src/app/view/settings.rs index e85838f6..a81bbd29 100644 --- a/gui/src/app/view/settings.rs +++ b/gui/src/app/view/settings.rs @@ -3,12 +3,13 @@ use std::str::FromStr; use iced::{ alignment, - widget::{radio, scrollable, Space}, + widget::{radio, scrollable, tooltip as iced_tooltip, Space}, Alignment, Length, }; use liana::{ config::BitcoindRpcAuth, + descriptors::{LianaDescriptor, LianaPolicy}, miniscript::bitcoin::{bip32::Fingerprint, Network}, }; @@ -685,8 +686,8 @@ fn is_ok_and(res: &Result, f: impl FnOnce(&T) -> bool) -> bool { pub fn wallet_settings<'a>( cache: &'a Cache, warning: Option<&Error>, - descriptor: &'a str, - keys_aliases: &[(Fingerprint, form::Value)], + descriptor: &'a LianaDescriptor, + keys_aliases: &'a [(Fingerprint, form::Value)], processing: bool, updated: bool, ) -> Element<'a, Message> { @@ -712,88 +713,276 @@ pub fn wallet_settings<'a>( .on_press(Message::Settings(SettingsMessage::EditWalletSettings)), ), ) - .push(card::simple( - Column::new() - .push(text("Wallet descriptor:").bold()) - .push( - scrollable( - Column::new() - .push(text(descriptor.to_owned()).small()) - .push(Space::with_height(Length::Fixed(5.0))), - ) - .direction(scrollable::Direction::Horizontal( - scrollable::Properties::new().width(5).scroller_width(5), - )), - ) - .push( - Row::new() - .spacing(10) - .push(Column::new().width(Length::Fill)) - .push( - button::secondary(Some(icon::clipboard_icon()), "Copy") - .on_press(Message::Clipboard(descriptor.to_owned())), + .push( + card::simple( + Column::new() + .push(text("Wallet descriptor:").bold()) + .push( + scrollable( + Column::new() + .push(text(descriptor.to_string()).small()) + .push(Space::with_height(Length::Fixed(5.0))), ) - .push( - button::primary( - Some(icon::chip_icon()), - "Register on hardware device", - ) - .on_press(Message::Settings(SettingsMessage::RegisterWallet)), + .direction( + scrollable::Direction::Horizontal( + scrollable::Properties::new().width(5).scroller_width(5), + ), ), - ) - .spacing(10), - )) - .push(card::simple( - Column::new() - .push(text("Fingerprint aliases:").bold()) - .push(keys_aliases.iter().fold( - Column::new().spacing(10), - |col, (fingerprint, name)| { - let fg = *fingerprint; - col.push( - Row::new() - .spacing(10) - .align_items(Alignment::Center) - .push(text(fg.to_string()).bold().width(Length::Fixed(100.0))) - .push( - form::Form::new("Alias", name, move |msg| { - Message::Settings( - SettingsMessage::FingerprintAliasEdited(fg, msg), - ) - }) - .warning("Please enter correct alias") - .size(P1_SIZE) - .padding(10), - ), - ) - }, - )) - .push( - Row::new() - .align_items(Alignment::Center) - .push(Space::with_width(Length::Fill)) - .push_maybe(if updated { - Some( - Row::new() - .align_items(Alignment::Center) - .push(icon::circle_check_icon().style(color::GREEN)) - .push(text("Updated").style(color::GREEN)), + ) + .push( + Row::new() + .spacing(10) + .push(Column::new().width(Length::Fill)) + .push( + button::secondary(Some(icon::clipboard_icon()), "Copy") + .on_press(Message::Clipboard(descriptor.to_string())), ) - } else { - None - }) - .push(if !processing { - button::primary(None, "Update") - .on_press(Message::Settings(SettingsMessage::Save)) - } else { - button::primary(None, "Updating") - }), - ) - .spacing(10), - )), + .push( + button::primary( + Some(icon::chip_icon()), + "Register on hardware device", + ) + .on_press(Message::Settings(SettingsMessage::RegisterWallet)), + ), + ) + .spacing(10), + ) + .width(Length::Fill), + ) + .push( + card::simple(display_policy(descriptor.policy(), keys_aliases)).width(Length::Fill), + ) + .push( + card::simple( + Column::new() + .push(text("Fingerprint aliases:").bold()) + .push(keys_aliases.iter().fold( + Column::new().spacing(10), + |col, (fingerprint, name)| { + let fg = *fingerprint; + col.push( + Row::new() + .spacing(10) + .align_items(Alignment::Center) + .push( + text(fg.to_string()).bold().width(Length::Fixed(100.0)), + ) + .push( + form::Form::new("Alias", name, move |msg| { + Message::Settings( + SettingsMessage::FingerprintAliasEdited( + fg, msg, + ), + ) + }) + .warning("Please enter correct alias") + .size(P1_SIZE) + .padding(10), + ), + ) + }, + )) + .push( + Row::new() + .align_items(Alignment::Center) + .push(Space::with_width(Length::Fill)) + .push_maybe(if updated { + Some( + Row::new() + .align_items(Alignment::Center) + .push(icon::circle_check_icon().style(color::GREEN)) + .push(text("Updated").style(color::GREEN)), + ) + } else { + None + }) + .push(if !processing { + button::primary(None, "Update") + .on_press(Message::Settings(SettingsMessage::Save)) + } else { + button::primary(None, "Updating") + }), + ) + .spacing(10), + ) + .width(Length::Fill), + ), ) } +fn display_policy( + policy: LianaPolicy, + keys_aliases: &[(Fingerprint, form::Value)], +) -> Element<'_, Message> { + let (primary_threshold, primary_keys) = policy.primary_path().thresh_origins(); + let recovery_paths = policy.recovery_paths(); + + // The iteration over an HashMap keys can have a different order at each refresh + let mut primary_keys: Vec = primary_keys.into_keys().collect(); + primary_keys.sort(); + + let mut col = Column::new().push( + Row::new() + .spacing(5) + .push( + text(format!( + "{} signature{}", + primary_threshold, + if primary_threshold > 1 { "s" } else { "" } + )) + .bold(), + ) + .push(if primary_keys.len() > 1 { + text(format!("out of {} by", primary_keys.len())) + } else { + text("by") + }) + .push( + primary_keys + .iter() + .enumerate() + .fold(Row::new().spacing(5), |row, (i, k)| { + let content = if let Some(alias) = keys_aliases + .iter() + .find(|(fg, _)| fg == k) + .map(|(_, f)| &f.value) + { + Container::new( + iced_tooltip::Tooltip::new( + text(alias).bold(), + text(k.to_string()), + iced_tooltip::Position::Bottom, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + Container::new(text(k.to_string())) + .padding(10) + .style(theme::Container::Pill(theme::Pill::Simple)) + }; + if primary_keys.len() == 1 || i == primary_keys.len() - 1 { + row.push(content) + } else if i <= primary_keys.len() - 2 { + row.push(content).push(text("and")) + } else { + row.push(content).push(text(",")) + } + }), + ) + .push(text("can always spend this wallet's funds (Primary path)")), + ); + for (i, (sequence, recovery_path)) in recovery_paths.iter().enumerate() { + let (threshold, recovery_keys) = recovery_path.thresh_origins(); + + // The iteration over an HashMap keys can have a different order at each refresh + let mut recovery_keys: Vec = recovery_keys.into_keys().collect(); + recovery_keys.sort(); + + col = col.push( + Row::new() + .spacing(5) + .push( + text(format!( + "{} signature{}", + threshold, + if threshold > 1 { "s" } else { "" } + )) + .bold(), + ) + .push(if recovery_keys.len() > 1 { + text(format!("out of {} by", recovery_keys.len())) + } else { + text("by") + }) + .push(recovery_keys.iter().enumerate().fold( + Row::new().spacing(5), + |row, (i, k)| { + let content = if let Some(alias) = keys_aliases + .iter() + .find(|(fg, _)| fg == k) + .map(|(_, f)| &f.value) + { + Container::new( + iced_tooltip::Tooltip::new( + text(alias).bold(), + text(k.to_string()), + iced_tooltip::Position::Bottom, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + Container::new(text(k.to_string())) + .padding(10) + .style(theme::Container::Pill(theme::Pill::Simple)) + }; + if recovery_keys.len() == 1 || i == recovery_keys.len() - 1 { + row.push(content) + } else if i <= recovery_keys.len() - 2 { + row.push(content).push(text("and")) + } else { + row.push(content).push(text(",")) + } + }, + )) + .push(text("can spend coins inactive for")) + .push( + text(format!( + "{} blocks (~{})", + sequence, + expire_message_units(*sequence as u32).join(",") + )) + .bold(), + ) + .push(text(format!("(Recovery path #{})", i + 1))), + ); + } + Column::new() + .spacing(10) + .push(text("The wallet policy:").bold()) + .push(scrollable(col).direction(scrollable::Direction::Horizontal( + scrollable::Properties::new().width(5).scroller_width(5), + ))) + .into() +} + +/// returns y,m,d +fn expire_message_units(sequence: u32) -> Vec { + let mut n_minutes = sequence * 10; + let n_years = n_minutes / 525960; + n_minutes -= n_years * 525960; + let n_months = n_minutes / 43830; + n_minutes -= n_months * 43830; + let n_days = n_minutes / 1440; + + #[allow(clippy::nonminimal_bool)] + if n_years != 0 || n_months != 0 || n_days != 0 { + [(n_years, "y"), (n_months, "m"), (n_days, "d")] + .iter() + .filter_map(|(n, u)| { + if *n != 0 { + Some(format!("{}{}", n, u)) + } else { + None + } + }) + .collect() + } else { + n_minutes -= n_days * 1440; + let n_hours = n_minutes / 60; + n_minutes -= n_hours * 60; + [(n_hours, "h"), (n_minutes, "m")] + .iter() + .filter_map(|(n, u)| { + if *n != 0 { + Some(format!("{}{}", n, u)) + } else { + None + } + }) + .collect() + } +} + pub fn register_wallet_modal<'a>( warning: Option<&Error>, hws: &'a [HardwareWallet],