From dfc10eba61818b87ef12f057507a9b9660851d61 Mon Sep 17 00:00:00 2001 From: edouard Date: Tue, 17 Jan 2023 18:22:26 +0100 Subject: [PATCH 1/9] Add multisig wallet creation in installer --- gui/Cargo.lock | 2 +- gui/Cargo.toml | 2 +- gui/src/app/view/spend/detail.rs | 82 ++-- gui/src/installer/message.rs | 26 +- gui/src/installer/prompt.rs | 4 + gui/src/installer/step/descriptor.rs | 445 +++++++++++++++++----- gui/src/installer/view.rs | 539 +++++++++++++++++++++------ gui/src/ui/component/card.rs | 30 ++ gui/src/ui/component/mod.rs | 3 + gui/src/ui/component/tooltip.rs | 36 ++ gui/src/ui/icon.rs | 12 + 11 files changed, 939 insertions(+), 242 deletions(-) create mode 100644 gui/src/ui/component/tooltip.rs diff --git a/gui/Cargo.lock b/gui/Cargo.lock index d93a447a..637f78b1 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "liana" version = "0.1.0" -source = "git+https://github.com/revault/liana?branch=master#863cea55d7d84ea2262a68dcd006393a6ee239a4" +source = "git+https://github.com/wizardsardine/liana?branch=master#f433002e91b09ab700d06026e1d94c462aca9756" dependencies = [ "backtrace", "base64", diff --git a/gui/Cargo.toml b/gui/Cargo.toml index c738f62f..6f0f16c5 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" [dependencies] async-hwi = "0.0.2" -liana = { git = "https://github.com/revault/liana", branch = "master", default-features = false } +liana = { git = "https://github.com/wizardsardine/liana", branch = "master", default-features = false } backtrace = "0.3" base64 = "0.13" diff --git a/gui/src/app/view/spend/detail.rs b/gui/src/app/view/spend/detail.rs index 643392d2..d3f0c708 100644 --- a/gui/src/app/view/spend/detail.rs +++ b/gui/src/app/view/spend/detail.rs @@ -513,26 +513,26 @@ pub fn sign_action<'a>( chosen_hw: Option, signed: &[Fingerprint], ) -> Element<'a, Message> { - card::simple( - Column::new() - .push_maybe(warning.map(|w| warn(Some(w)))) - .push(if !hws.is_empty() { - Column::new() - .push( - Row::new() - .push( - text("Select hardware wallet to sign with:") - .bold() - .width(Length::Fill), - ) - .push(button::border(None, "Refresh").on_press(Message::Reload)) - .align_items(Alignment::Center), - ) - .spacing(10) - .push( - hws.iter() - .enumerate() - .fold(Column::new().spacing(10), |col, (i, hw)| { + Column::new() + .push_maybe(warning.map(|w| warn(Some(w)))) + .push(card::simple( + Column::new() + .push(if !hws.is_empty() { + Column::new() + .push( + Row::new() + .push( + text("Select hardware wallet to sign with:") + .bold() + .width(Length::Fill), + ) + .push(button::border(None, "Refresh").on_press(Message::Reload)) + .align_items(Alignment::Center), + ) + .spacing(10) + .push(hws.iter().enumerate().fold( + Column::new().spacing(10), + |col, (i, hw)| { col.push(hw_list_view( i, hw, @@ -540,27 +540,27 @@ pub fn sign_action<'a>( processing, signed.contains(&hw.fingerprint), )) - }), - ) - .width(Length::Fill) - } else { - Column::new() - .push( - Column::new() - .spacing(15) - .width(Length::Fill) - .push("Please connect a hardware wallet") - .push(button::border(None, "Refresh").on_press(Message::Reload)) - .align_items(Alignment::Center), - ) - .width(Length::Fill) - }) - .spacing(20) - .width(Length::Fill) - .align_items(Alignment::Center), - ) - .width(Length::Units(500)) - .into() + }, + )) + .width(Length::Fill) + } else { + Column::new() + .push( + Column::new() + .spacing(15) + .width(Length::Fill) + .push("Please connect a hardware wallet") + .push(button::border(None, "Refresh").on_press(Message::Reload)) + .align_items(Alignment::Center), + ) + .width(Length::Fill) + }) + .spacing(20) + .width(Length::Fill) + .align_items(Alignment::Center), + )) + .width(Length::Units(500)) + .into() } pub fn update_spend_view<'a>( diff --git a/gui/src/installer/message.rs b/gui/src/installer/message.rs index 880b632b..9c24030a 100644 --- a/gui/src/installer/message.rs +++ b/gui/src/installer/message.rs @@ -1,4 +1,7 @@ -use liana::miniscript::bitcoin::{util::bip32::Fingerprint, Network}; +use liana::miniscript::{ + bitcoin::{util::bip32::Fingerprint, Network}, + DescriptorPublicKey, +}; use std::path::PathBuf; use super::Error; @@ -34,10 +37,21 @@ pub enum DefineBitcoind { #[derive(Debug, Clone)] pub enum DefineDescriptor { ImportDescriptor(String), - ImportUserHWXpub, - ImportHeirHWXpub, - XpubImported(Result), - UserXpubEdited(String), - HeirXpubEdited(String), + /// AddKey(is_recovery) + AddKey(bool), + Key(bool, usize, DefineKey), + HWXpubImported(Result), + XPubEdited(String), SequenceEdited(String), + ThresholdEdited(bool, usize), + ConfirmXpub, +} + +#[derive(Debug, Clone)] +pub enum DefineKey { + Delete, + ImportFromHardware, + ImportFromClipboard, + Clipboard(String), + Imported(DescriptorPublicKey), } diff --git a/gui/src/installer/prompt.rs b/gui/src/installer/prompt.rs index c84211c9..c7dbf98f 100644 --- a/gui/src/installer/prompt.rs +++ b/gui/src/installer/prompt.rs @@ -1,2 +1,6 @@ pub const BACKUP_DESCRIPTOR_MESSAGE: &str = "The descriptor is necessary to recover your funds. The backup of your key (via mnemonics, sometimes called 'seed words') is not enough. Please make sure you have backed up both your private key and your descriptor."; pub const BACKUP_DESCRIPTOR_HELP: &str = "In Bitcoin, the coins are locked using a Script (related to the 'address'). In order to recover your funds you need both to know the Scripts you have participated in (your 'addresses'), and be able to sign a transaction that spends from those. For the ability to sign you backup your private key, this is your mnemonics ('seed words'). For finding the coins that belongs to you you backup a template of your Script ( / 'addresses'), this is your descriptor. Note however the descriptor needs not be as securely stored as the private key. A thief that steals your descriptor but not your private key will not be able to steal your funds."; +pub const DEFINE_DESCRIPTOR_PRIMATRY_PATH_TOOLTIP: &str = + "This is the keys that can spend received coins immediately,\n with no time restriction."; +pub const DEFINE_DESCRIPTOR_SEQUENCE_TOOLTIP: &str = + "Number of blocks after a coin is received \nfor which the recovery path is not available"; diff --git a/gui/src/installer/step/descriptor.rs b/gui/src/installer/step/descriptor.rs index 986de404..29ca0984 100644 --- a/gui/src/installer/step/descriptor.rs +++ b/gui/src/installer/step/descriptor.rs @@ -1,15 +1,16 @@ +use std::collections::HashSet; use std::path::PathBuf; use std::str::FromStr; use iced::{Command, Element}; use liana::{ - descriptors::MultipathDescriptor, + descriptors::{LianaDescKeys, MultipathDescriptor}, miniscript::{ bitcoin::{ util::bip32::{DerivationPath, ExtendedPubKey, Fingerprint}, Network, }, - descriptor::DescriptorPublicKey, + descriptor::{DerivPaths, DescriptorMultiXKey, DescriptorPublicKey, Wildcard}, }, }; @@ -20,20 +21,32 @@ use crate::{ step::{Context, Step}, view, Error, }, - ui::component::form, + ui::component::{form, modal::Modal}, }; const LIANA_STANDARD_PATH: &str = "m/48'/0'/0'/2'"; const LIANA_TESTNET_STANDARD_PATH: &str = "m/48'/1'/0'/2'"; +pub trait DescriptorKeyModal { + fn processing(&self) -> bool { + false + } + fn update(&mut self, _message: Message) -> Command { + Command::none() + } + fn view(&self) -> Element; +} + pub struct DefineDescriptor { network: Network, network_valid: bool, data_dir: Option, - user_xpub: form::Value, - heir_xpub: form::Value, + spending_keys: Vec, + spending_threshold: usize, + recovery_keys: Vec, + recovery_threshold: usize, sequence: form::Value, - modal: Option, + modal: Option>, error: Option, } @@ -44,19 +57,77 @@ impl DefineDescriptor { network: Network::Bitcoin, data_dir: None, network_valid: true, - user_xpub: form::Value::default(), - heir_xpub: form::Value::default(), + spending_keys: vec![DescriptorKey::new("Key 1".to_string())], + spending_threshold: 1, + recovery_keys: vec![DescriptorKey::new("Recovery key 1".to_string())], + recovery_threshold: 1, sequence: form::Value::default(), modal: None, error: None, } } + + fn valid(&self) -> bool { + !self.spending_keys.is_empty() + && !self.recovery_keys.is_empty() + && !self.sequence.value.is_empty() + && !self.spending_keys.iter().any(|k| k.key.is_none()) + && !self.spending_keys.iter().any(|k| k.key.is_none()) + } + + // TODO: Improve algo + fn check_for_duplicate(&mut self) { + let mut all_keys = HashSet::new(); + let mut duplicate_keys = HashSet::new(); + let mut all_names = HashSet::new(); + let mut duplicate_names = HashSet::new(); + for spending_key in &self.spending_keys { + if all_names.contains(&spending_key.name) { + duplicate_names.insert(spending_key.name.clone()); + } else { + all_names.insert(spending_key.name.clone()); + } + if let Some(key) = &spending_key.key { + if all_keys.contains(key) { + duplicate_keys.insert(key.clone()); + } else { + all_keys.insert(key.clone()); + } + } + } + for recovery_key in &self.recovery_keys { + if all_names.contains(&recovery_key.name) { + duplicate_names.insert(recovery_key.name.clone()); + } else { + all_names.insert(recovery_key.name.clone()); + } + if let Some(key) = &recovery_key.key { + if all_keys.contains(key) { + duplicate_keys.insert(key.clone()); + } else { + all_keys.insert(key.clone()); + } + } + } + for spending_key in self.spending_keys.iter_mut() { + spending_key.duplicate_name = duplicate_names.contains(&spending_key.name); + if let Some(key) = &spending_key.key { + spending_key.duplicate_key = duplicate_keys.contains(&key); + } + } + for recovery_key in self.recovery_keys.iter_mut() { + if let Some(key) = &recovery_key.key { + recovery_key.duplicate_key = duplicate_keys.contains(&key); + } + } + } } impl Step for DefineDescriptor { // form value is set as valid each time it is edited. // Verification of the values is happening when the user click on Next button. fn update(&mut self, message: Message) -> Command { + self.error = None; match message { Message::Close => { self.modal = None; @@ -66,18 +137,21 @@ impl Step for DefineDescriptor { let mut network_datadir = self.data_dir.clone().unwrap(); network_datadir.push(self.network.to_string()); self.network_valid = !network_datadir.exists(); + for key in self.spending_keys.iter_mut() { + key.check_network(self.network); + } + for key in self.recovery_keys.iter_mut() { + key.check_network(self.network); + } } Message::DefineDescriptor(msg) => { match msg { - message::DefineDescriptor::UserXpubEdited(xpub) => { - self.user_xpub.value = xpub; - self.user_xpub.valid = true; - self.modal = None; - } - message::DefineDescriptor::HeirXpubEdited(xpub) => { - self.heir_xpub.value = xpub; - self.heir_xpub.valid = true; - self.modal = None; + message::DefineDescriptor::ThresholdEdited(is_recovery, value) => { + if is_recovery { + self.recovery_threshold = value; + } else { + self.spending_threshold = value; + } } message::DefineDescriptor::SequenceEdited(seq) => { self.sequence.valid = true; @@ -85,18 +159,63 @@ impl Step for DefineDescriptor { self.sequence.value = seq; } } - message::DefineDescriptor::ImportUserHWXpub => { - let modal = GetHardwareWalletXpubModal::new(false, self.network); - let cmd = modal.load(); - self.modal = Some(modal); - return cmd; - } - message::DefineDescriptor::ImportHeirHWXpub => { - let modal = GetHardwareWalletXpubModal::new(true, self.network); - let cmd = modal.load(); - self.modal = Some(modal); - return cmd; + message::DefineDescriptor::AddKey(is_recovery) => { + if is_recovery { + self.recovery_keys.push(DescriptorKey::new(format!( + "Recovery key {}", + self.recovery_keys.len() + 1 + ))); + self.recovery_threshold += 1; + } else { + self.spending_keys.push(DescriptorKey::new(format!( + "Key {}", + self.spending_keys.len() + 1 + ))); + self.spending_threshold += 1; + } } + message::DefineDescriptor::Key(is_recovery, i, msg) => match msg { + message::DefineKey::Clipboard(key) => { + return Command::perform(async move { key }, Message::Clibpboard); + } + message::DefineKey::Imported(imported_key) => { + if is_recovery { + if let Some(recovery_key) = self.recovery_keys.get_mut(i) { + recovery_key.key = Some(imported_key); + recovery_key.check_network(self.network); + } + } else if let Some(spending_key) = self.spending_keys.get_mut(i) { + spending_key.key = Some(imported_key); + spending_key.check_network(self.network); + } + self.modal = None; + self.check_for_duplicate(); + } + message::DefineKey::ImportFromClipboard => { + let modal = ImportXpubModal::new(i, is_recovery, self.network); + self.modal = Some(Box::new(modal)); + } + message::DefineKey::ImportFromHardware => { + let modal = HardwareXpubModal::new(i, is_recovery, self.network); + let cmd = modal.load(); + self.modal = Some(Box::new(modal)); + return cmd; + } + message::DefineKey::Delete => { + if is_recovery { + self.recovery_keys.remove(i); + if self.recovery_threshold > self.recovery_keys.len() { + self.recovery_threshold -= 1; + } + } else { + self.spending_keys.remove(i); + if self.spending_threshold > self.spending_keys.len() { + self.spending_threshold -= 1; + } + } + self.check_for_duplicate(); + } + }, _ => { if let Some(modal) = &mut self.modal { return modal.update(Message::DefineDescriptor(msg)); @@ -123,57 +242,139 @@ impl Step for DefineDescriptor { fn apply(&mut self, ctx: &mut Context) -> bool { ctx.bitcoin_config.network = self.network; - // descriptor forms for import or creation cannot be both empty or filled. - let user_key = DescriptorPublicKey::from_str(&format!("{}/<0;1>/*", &self.user_xpub.value)); - self.user_xpub.valid = user_key.is_ok(); - if let Ok(key) = &user_key { - self.user_xpub.valid = check_key_network(key, self.network); - } + let spending_keys: Vec = self + .spending_keys + .iter() + .filter_map(|k| k.key.clone()) + .collect(); - let heir_key = DescriptorPublicKey::from_str(&format!("{}/<0;1>/*", &self.heir_xpub.value)); - self.heir_xpub.valid = heir_key.is_ok(); - if let Ok(key) = &heir_key { - self.heir_xpub.valid = check_key_network(key, self.network); - } + let recovery_keys: Vec = self + .recovery_keys + .iter() + .filter_map(|k| k.key.clone()) + .collect(); let sequence = self.sequence.value.parse::(); self.sequence.valid = sequence.is_ok(); if !self.network_valid - || !self.user_xpub.valid - || !self.heir_xpub.valid || !self.sequence.valid + || recovery_keys.is_empty() + || spending_keys.is_empty() { return false; } - let desc = - match MultipathDescriptor::new(user_key.unwrap(), heir_key.unwrap(), sequence.unwrap()) - { - Ok(desc) => desc, + let spending_keys = if spending_keys.len() == 1 { + LianaDescKeys::from_single(spending_keys[0].clone()) + } else { + match LianaDescKeys::from_multi(self.spending_threshold, spending_keys) { + Ok(keys) => keys, Err(e) => { self.error = Some(e.to_string()); return false; } - }; + } + }; + + let recovery_keys = if recovery_keys.len() == 1 { + LianaDescKeys::from_single(recovery_keys[0].clone()) + } else { + match LianaDescKeys::from_multi(self.recovery_threshold, recovery_keys) { + Ok(keys) => keys, + Err(e) => { + self.error = Some(e.to_string()); + return false; + } + } + }; + + let desc = match MultipathDescriptor::new(spending_keys, recovery_keys, sequence.unwrap()) { + Ok(desc) => desc, + Err(e) => { + self.error = Some(e.to_string()); + return false; + } + }; ctx.descriptor = Some(desc); true } fn view(&self, progress: (usize, usize)) -> Element { + let content = view::define_descriptor( + progress, + self.network, + self.network_valid, + self.spending_keys + .iter() + .enumerate() + .map(|(i, key)| { + key.view().map(move |msg| { + Message::DefineDescriptor(message::DefineDescriptor::Key(false, i, msg)) + }) + }) + .collect(), + self.recovery_keys + .iter() + .enumerate() + .map(|(i, key)| { + key.view().map(move |msg| { + Message::DefineDescriptor(message::DefineDescriptor::Key(true, i, msg)) + }) + }) + .collect(), + &self.sequence, + self.spending_threshold, + self.recovery_threshold, + self.valid(), + self.error.as_ref(), + ); if let Some(modal) = &self.modal { - modal.view() + Modal::new(content, modal.view()) + .on_blur(if modal.processing() { + None + } else { + Some(Message::Close) + }) + .into() } else { - view::define_descriptor( - progress, - self.network, - self.network_valid, - &self.user_xpub, - &self.heir_xpub, - &self.sequence, - self.error.as_ref(), - ) + content + } + } +} + +pub struct DescriptorKey { + pub name: String, + pub valid: bool, + pub key: Option, + pub duplicate_key: bool, + pub duplicate_name: bool, +} + +impl DescriptorKey { + pub fn new(name: String) -> Self { + Self { + name, + valid: true, + key: None, + duplicate_key: false, + duplicate_name: false, + } + } + + pub fn check_network(&mut self, network: Network) { + if let Some(key) = &self.key { + self.valid = check_key_network(key, network); + } + } + + pub fn view(&self) -> Element { + match &self.key { + None => view::undefined_descriptor_key(), + Some(key) => { + view::defined_descriptor_key(key.to_string(), self.valid, self.duplicate_key) + } } } } @@ -210,19 +411,22 @@ impl From for Box { } } -pub struct GetHardwareWalletXpubModal { - is_heir: bool, - chosen_hw: Option, - processing: bool, - hws: Vec, - error: Option, +pub struct HardwareXpubModal { + is_recovery: bool, + key_index: usize, network: Network, + error: Option, + processing: bool, + + chosen_hw: Option, + hws: Vec, } -impl GetHardwareWalletXpubModal { - fn new(is_heir: bool, network: Network) -> Self { +impl HardwareXpubModal { + fn new(key_index: usize, is_recovery: bool, network: Network) -> Self { Self { - is_heir, + is_recovery, + key_index, chosen_hw: None, processing: false, hws: Vec::new(), @@ -236,6 +440,13 @@ impl GetHardwareWalletXpubModal { Message::ConnectedHardwareWallets, ) } +} + +impl DescriptorKeyModal for HardwareXpubModal { + fn processing(&self) -> bool { + self.processing + } + fn update(&mut self, message: Message) -> Command { match message { Message::Select(i) => { @@ -246,8 +457,8 @@ impl GetHardwareWalletXpubModal { return Command::perform( get_extended_pubkey(device, hw.fingerprint, self.network), |res| { - Message::DefineDescriptor(message::DefineDescriptor::XpubImported( - res.map(|key| key.to_string()), + Message::DefineDescriptor(message::DefineDescriptor::HWXpubImported( + res, )) }, ); @@ -259,23 +470,23 @@ impl GetHardwareWalletXpubModal { Message::Reload => { return self.load(); } - Message::DefineDescriptor(message::DefineDescriptor::XpubImported(res)) => { + Message::DefineDescriptor(message::DefineDescriptor::HWXpubImported(res)) => { self.processing = false; match res { Ok(key) => { - if self.is_heir { - return Command::perform( - async move { key }, - message::DefineDescriptor::HeirXpubEdited, - ) - .map(Message::DefineDescriptor); - } else { - return Command::perform( - async move { key }, - message::DefineDescriptor::UserXpubEdited, - ) - .map(Message::DefineDescriptor); - } + let key_index = self.key_index; + let is_recovery = self.is_recovery; + return Command::perform( + async move { (is_recovery, key_index, key) }, + |(is_recovery, key_index, key)| { + message::DefineDescriptor::Key( + is_recovery, + key_index, + message::DefineKey::Imported(key), + ) + }, + ) + .map(Message::DefineDescriptor); } Err(e) => { self.error = Some(e); @@ -288,7 +499,7 @@ impl GetHardwareWalletXpubModal { } fn view(&self) -> Element { view::hardware_wallet_xpubs_modal( - self.is_heir, + self.is_recovery, &self.hws, self.error.as_ref(), self.processing, @@ -297,6 +508,60 @@ impl GetHardwareWalletXpubModal { } } +pub struct ImportXpubModal { + is_recovery: bool, + key_index: usize, + form_xpub: form::Value, + network: Network, +} + +impl ImportXpubModal { + fn new(key_index: usize, is_recovery: bool, network: Network) -> Self { + Self { + form_xpub: form::Value::default(), + is_recovery, + key_index, + network, + } + } +} + +impl DescriptorKeyModal for ImportXpubModal { + fn update(&mut self, message: Message) -> Command { + match message { + Message::DefineDescriptor(message::DefineDescriptor::XPubEdited(s)) => { + self.form_xpub.valid = + DescriptorPublicKey::from_str(&format!("{}/<0;1>/*", s)).is_ok(); + self.form_xpub.value = s; + } + Message::DefineDescriptor(message::DefineDescriptor::ConfirmXpub) => { + if let Ok(key) = + DescriptorPublicKey::from_str(&format!("{}/<0;1>/*", self.form_xpub.value)) + { + let key_index = self.key_index; + let is_recovery = self.is_recovery; + return Command::perform( + async move { (is_recovery, key_index, key) }, + |(is_recovery, key_index, key)| { + message::DefineDescriptor::Key( + is_recovery, + key_index, + message::DefineKey::Imported(key), + ) + }, + ) + .map(Message::DefineDescriptor); + } + } + _ => {} + }; + Command::none() + } + fn view(&self) -> Element { + view::clipboard_xpub_modal(&self.form_xpub, self.network) + } +} + pub struct XKey { origin: Option<(Fingerprint, DerivationPath)>, key: ExtendedPubKey, @@ -323,21 +588,27 @@ async fn get_extended_pubkey( hw: std::sync::Arc, fingerprint: Fingerprint, network: Network, -) -> Result { +) -> Result { let derivation_path = DerivationPath::from_str(if network == Network::Bitcoin { LIANA_STANDARD_PATH } else { LIANA_TESTNET_STANDARD_PATH }) .unwrap(); - let key = hw + let xkey = hw .get_extended_pubkey(&derivation_path, false) .await .map_err(Error::from)?; - Ok(XKey { + Ok(DescriptorPublicKey::MultiXPub(DescriptorMultiXKey { origin: Some((fingerprint, derivation_path)), - key, - }) + derivation_paths: DerivPaths::new(vec![ + DerivationPath::from_str("m/0").unwrap(), + DerivationPath::from_str("m/1").unwrap(), + ]) + .unwrap(), + wildcard: Wildcard::Unhardened, + xkey, + })) } pub struct ImportDescriptor { diff --git a/gui/src/installer/view.rs b/gui/src/installer/view.rs index a5ef142a..8af8a6b9 100644 --- a/gui/src/installer/view.rs +++ b/gui/src/installer/view.rs @@ -1,5 +1,7 @@ -use iced::widget::{Button, Checkbox, Column, Container, PickList, Row, Scrollable}; -use iced::{Alignment, Element, Length}; +use iced::widget::{ + scrollable::Properties, Button, Checkbox, Column, Container, PickList, Row, Scrollable, Space, +}; +use iced::{alignment, Alignment, Element, Length}; use liana::miniscript::bitcoin; @@ -13,8 +15,9 @@ use crate::{ ui::{ color, component::{ - button, card, collapse, container, form, + button, card, collapse, container, form, separation, text::{text, Text}, + tooltip, }, icon, util::Collection, @@ -117,13 +120,17 @@ pub fn welcome<'a>() -> Element<'a, Message> { .into() } +#[allow(clippy::too_many_arguments)] pub fn define_descriptor<'a>( progress: (usize, usize), network: bitcoin::Network, network_valid: bool, - user_xpub: &form::Value, - heir_xpub: &form::Value, + spending_keys: Vec>, + recovery_keys: Vec>, sequence: &form::Value, + spending_threshold: usize, + recovery_threshold: usize, + valid: bool, error: Option<&String>, ) -> Element<'a, Message> { let row_network = Row::new() @@ -144,71 +151,152 @@ pub fn define_descriptor<'a>( )) }); - let col_user_xpub = Column::new() - .push(text("Your public key:").bold()) + let col_spending_keys = Column::new() .push( Row::new() - .push(button::border(Some(icon::chip_icon()), "Import").on_press( - Message::DefineDescriptor(message::DefineDescriptor::ImportUserHWXpub), - )) - .push( - form::Form::new("Xpub", user_xpub, |msg| { - Message::DefineDescriptor(message::DefineDescriptor::UserXpubEdited(msg)) - }) - .warning(if network == bitcoin::Network::Bitcoin { - "Please enter correct xpub" - } else { - "Please enter correct tpub" - }) - .size(20) - .padding(12), - ) - .push(Container::new(text("/<0;1>/*"))) - .spacing(5) - .align_items(Alignment::Center), + .spacing(10) + .push(text("Primary path:").bold()) + .push(tooltip( + super::prompt::DEFINE_DESCRIPTOR_PRIMATRY_PATH_TOOLTIP, + )), ) - .spacing(10); - - let col_heir_xpub = Column::new() - .push(text("Public key of the recovery key:").bold()) - .push( - Row::new() - .push(button::border(Some(icon::chip_icon()), "Import").on_press( - Message::DefineDescriptor(message::DefineDescriptor::ImportHeirHWXpub), - )) - .push( - form::Form::new("Xpub", heir_xpub, |msg| { - Message::DefineDescriptor(message::DefineDescriptor::HeirXpubEdited(msg)) - }) - .warning(if network == bitcoin::Network::Bitcoin { - "Please enter correct xpub" - } else { - "Please enter correct tpub" - }) - .size(20) - .padding(12), - ) - .push(Container::new(text("/<0;1>/*"))) - .spacing(5) - .align_items(Alignment::Center), - ) - .spacing(10); - - let col_sequence = Column::new() - .push(text("Number of block before enabling recovery:").bold()) + .push(separation().width(Length::Fill)) .push( Container::new( - form::Form::new("Number of block", sequence, |msg| { - Message::DefineDescriptor(message::DefineDescriptor::SequenceEdited(msg)) - }) - .warning("Please enter correct block number") - .size(20) - .padding(10), + Row::new() + .align_items(Alignment::Center) + .push_maybe(if spending_keys.len() > 1 { + Some(threshsold_input::threshsold_input( + spending_threshold, + spending_keys.len(), + |value| { + Message::DefineDescriptor( + message::DefineDescriptor::ThresholdEdited(false, value), + ) + }, + )) + } else { + None + }) + .push( + Scrollable::new( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(Row::with_children(spending_keys).spacing(5)) + .push( + Button::new( + Container::new(icon::plus_icon().size(50)) + .width(Length::Units(250)) + .height(Length::Units(250)) + .align_y(alignment::Vertical::Center) + .align_x(alignment::Horizontal::Center), + ) + .width(Length::Units(250)) + .height(Length::Units(250)) + .style(button::Style::TransparentBorder.into()) + .on_press( + Message::DefineDescriptor( + message::DefineDescriptor::AddKey(false), + ), + ), + ) + .padding(5), + ) + .horizontal_scroll(Properties::new().width(3).scroller_width(3)), + ), ) - .width(Length::Units(150)), + .width(Length::Fill) + .align_x(alignment::Horizontal::Center), ) .spacing(10); + let col_recovery_keys = Column::new() + .push(text("Recovery path:").bold()) + .push(separation().width(Length::Fill)) + .push( + Container::new( + Row::new() + .align_items(Alignment::Center) + .push_maybe(if recovery_keys.len() > 1 { + Some(threshsold_input::threshsold_input( + recovery_threshold, + recovery_keys.len(), + |value| { + Message::DefineDescriptor( + message::DefineDescriptor::ThresholdEdited(true, value), + ) + }, + )) + } else { + None + }) + .push( + Scrollable::new( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(Row::with_children(recovery_keys).spacing(5)) + .push( + Button::new( + Container::new(icon::plus_icon().size(50)) + .width(Length::Units(250)) + .height(Length::Units(250)) + .align_y(alignment::Vertical::Center) + .align_x(alignment::Horizontal::Center), + ) + .width(Length::Units(250)) + .height(Length::Units(250)) + .style(button::Style::TransparentBorder.into()) + .on_press( + Message::DefineDescriptor( + message::DefineDescriptor::AddKey(true), + ), + ), + ) + .padding(5), + ) + .horizontal_scroll(Properties::new().width(3).scroller_width(3)), + ), + ) + .width(Length::Fill) + .align_x(alignment::Horizontal::Center), + ) + .spacing(10); + + let col_sequence = Container::new( + Row::new() + .spacing(50) + .align_items(Alignment::Center) + .push(Container::new(icon::arrow_down().size(50)).align_x(alignment::Horizontal::Right)) + .push( + Column::new() + .push( + Row::new() + .spacing(10) + .push(text("Blocks before recovery:").bold()) + .push(tooltip(super::prompt::DEFINE_DESCRIPTOR_SEQUENCE_TOOLTIP)), + ) + .push( + Container::new( + form::Form::new("Number of block", sequence, |msg| { + Message::DefineDescriptor( + message::DefineDescriptor::SequenceEdited(msg), + ) + }) + .warning("Please enter correct block number") + .size(20) + .padding(10), + ) + .width(Length::Units(150)), + ) + .spacing(10), + ) + .padding(20), + ) + .width(Length::Fill) + .align_x(alignment::Horizontal::Center); + layout( progress, Column::new() @@ -216,23 +304,18 @@ pub fn define_descriptor<'a>( .push( Column::new() .push(row_network) - .push(col_user_xpub) + .push(col_spending_keys) .push(col_sequence) - .push(col_heir_xpub) + .push(col_recovery_keys) .spacing(25), ) - .push( - if user_xpub.value.is_empty() - && heir_xpub.value.is_empty() - && sequence.value.is_empty() - { - button::primary(None, "Next").width(Length::Units(200)) - } else { - button::primary(None, "Next") - .width(Length::Units(200)) - .on_press(Message::Next) - }, - ) + .push(if !valid { + button::primary(None, "Next").width(Length::Units(200)) + } else { + button::primary(None, "Next") + .width(Length::Units(200)) + .on_press(Message::Next) + }) .push_maybe(error.map(|e| card::error("Failed to create descriptor", e.to_string()))) .width(Length::Fill) .height(Length::Fill) @@ -627,6 +710,119 @@ pub fn install<'a>( layout(progress, col) } +pub fn undefined_descriptor_key<'a>() -> Element<'a, message::DefineKey> { + card::simple( + Column::new() + .width(Length::Fill) + .align_items(Alignment::Center) + .push( + Row::new() + .align_items(Alignment::Center) + .push(icon::key_icon()) + .push(Space::with_width(Length::Fill)) + .push( + Button::new(icon::cross_icon()) + .style(button::Style::Transparent.into()) + .on_press(message::DefineKey::Delete), + ), + ) + .push( + Container::new( + Column::new() + .spacing(5) + .push( + button::border(Some(icon::import_icon()), "from text input") + .on_press(message::DefineKey::ImportFromClipboard), + ) + .push( + button::border(Some(icon::chip_icon()), "from hardware") + .on_press(message::DefineKey::ImportFromHardware), + ), + ) + .height(Length::Fill) + .align_y(alignment::Vertical::Center), + ), + ) + .padding(5) + .height(Length::Units(250)) + .width(Length::Units(250)) + .into() +} + +pub fn defined_descriptor_key<'a>( + key: String, + valid: bool, + duplicate: bool, +) -> Element<'a, message::DefineKey> { + let col = Column::new() + .spacing(40) + .width(Length::Fill) + .align_items(Alignment::Center) + .push( + Row::new() + .align_items(Alignment::Center) + .push(icon::key_icon()) + .push(Space::with_width(Length::Fill)) + .push( + Button::new(icon::cross_icon()) + .style(button::Style::Transparent.into()) + .on_press(message::DefineKey::Delete), + ), + ) + .push( + Column::new() + .align_items(Alignment::Center) + .spacing(5) + .push( + Container::new( + Scrollable::new(Container::new(text(key.clone()))) + .height(Length::Units(50)) + .horizontal_scroll(Properties::new().width(2).scroller_width(2)), + ) + .width(Length::Fill) + .height(Length::Fill), + ) + .push( + button::transparent_border(Some(icon::clipboard_icon()), "Copy") + .on_press(message::DefineKey::Clipboard(key)), + ), + ); + + if !valid { + Column::new() + .align_items(Alignment::Center) + .push( + card::invalid(col) + .padding(5) + .height(Length::Units(250)) + .width(Length::Units(250)), + ) + .push( + text("Key is for a different network") + .small() + .style(color::ALERT), + ) + .into() + } else if duplicate { + Column::new() + .align_items(Alignment::Center) + .push( + card::invalid(col) + .padding(5) + .height(Length::Units(250)) + .width(Length::Units(250)), + ) + .push(text("Key is a duplicate").small().style(color::ALERT)) + .into() + } else { + card::simple(col) + .padding(5) + .height(Length::Units(250)) + .width(Length::Units(250)) + .into() + } +} + pub fn hardware_wallet_xpubs_modal<'a>( is_heir: bool, hws: &[HardwareWallet], @@ -634,19 +830,20 @@ pub fn hardware_wallet_xpubs_modal<'a>( processing: bool, chosen_hw: Option, ) -> Element<'a, Message> { - modal( + card::simple( Column::new() + .spacing(20) .push( text(if is_heir { - "Import the recovery public key" + "Import the recovery public key:" } else { - "Import the user public key" + "Import the user public key:" }) - .bold() - .size(50), + .bold(), ) + .push(separation().width(Length::Fill)) .push_maybe(error.map(|e| card::error("Failed to import xpub", e.to_string()))) - .push( + .push(if !hws.is_empty() { Column::new() .push( Row::new() @@ -678,14 +875,62 @@ pub fn hardware_wallet_xpubs_modal<'a>( )) }), ) - .width(Length::Fill), - ) - .width(Length::Fill) - .height(Length::Fill) - .padding(100) - .spacing(50) - .align_items(Alignment::Center), + .width(Length::Fill) + } else { + Column::new() + .push( + Column::new() + .spacing(15) + .width(Length::Fill) + .push("Please connect a hardware wallet") + .push(button::border(None, "Refresh").on_press(Message::Reload)) + .align_items(Alignment::Center), + ) + .width(Length::Fill) + }) + .width(Length::Units(600)), ) + .into() +} +pub fn clipboard_xpub_modal<'a>( + form_xpub: &form::Value, + network: bitcoin::Network, +) -> Element<'a, Message> { + card::simple( + Column::new() + .spacing(10) + .push(text("Input extended public key:").bold()) + .push( + Row::new() + .push( + form::Form::new("Extended public key", form_xpub, |msg| { + Message::DefineDescriptor(message::DefineDescriptor::XPubEdited(msg)) + }) + .warning(if network == bitcoin::Network::Bitcoin { + "Please enter correct xpub" + } else { + "Please enter correct tpub" + }) + .size(20) + .padding(10), + ) + .spacing(10) + .push(Container::new(text("/<0;1>/*")).padding(5)), + ) + .push( + Row::new() + .push(Space::with_width(Length::Fill)) + .push(if form_xpub.valid { + button::primary(None, "Apply").on_press(Message::DefineDescriptor( + message::DefineDescriptor::ConfirmXpub, + )) + } else { + button::primary(None, "Apply") + }), + ), + ) + .width(Length::Units(600)) + .into() } fn hw_list_view<'a>( @@ -757,22 +1002,104 @@ fn layout<'a>( .into() } -fn modal<'a>(content: impl Into>) -> Element<'a, Message> { - Container::new(Scrollable::new( - Column::new() - .push( - Row::new().push(Column::new().width(Length::Fill)).push( - Container::new( - button::primary(Some(icon::cross_icon()), "Close").on_press(Message::Close), - ) - .padding(10), - ), - ) - .push(Container::new(content).width(Length::Fill).center_x()), - )) - .center_x() - .height(Length::Fill) - .width(Length::Fill) - .style(container::Style::Background) - .into() +mod threshsold_input { + use crate::ui::{ + component::{button, text::*}, + icon, + }; + use iced::alignment::{self, Alignment}; + use iced::widget::{Button, Column, Container}; + use iced::{Element, Length}; + use iced_lazy::{self, Component}; + + pub struct ThresholdInput { + value: usize, + max: usize, + on_change: Box Message>, + } + + pub fn threshsold_input( + value: usize, + max: usize, + on_change: impl Fn(usize) -> Message + 'static, + ) -> ThresholdInput { + ThresholdInput::new(value, max, on_change) + } + + #[derive(Debug, Clone)] + pub enum Event { + IncrementPressed, + DecrementPressed, + } + + impl ThresholdInput { + pub fn new( + value: usize, + max: usize, + on_change: impl Fn(usize) -> Message + 'static, + ) -> Self { + Self { + value, + max, + on_change: Box::new(on_change), + } + } + } + + impl Component for ThresholdInput { + type State = (); + type Event = Event; + + fn update(&mut self, _state: &mut Self::State, event: Event) -> Option { + match event { + Event::IncrementPressed => { + if self.value < self.max { + Some((self.on_change)(self.value.saturating_add(1))) + } else { + None + } + } + Event::DecrementPressed => { + if self.value > 1 { + Some((self.on_change)(self.value.saturating_sub(1))) + } else { + None + } + } + } + } + + fn view(&self, _state: &Self::State) -> Element { + let button = |label, on_press| { + Button::new(label) + .style(button::Style::Transparent.into()) + .width(Length::Units(50)) + .on_press(on_press) + }; + + Column::new() + .height(Length::Units(250)) + .width(Length::Units(200)) + .push(button(icon::up_icon().size(50), Event::IncrementPressed)) + .push(text("Threshold:").small().bold()) + .push( + Container::new(text(format!("{}/{}", self.value, self.max)).size(50)) + .height(Length::Fill) + .align_y(alignment::Vertical::Center), + ) + .push(button(icon::down_icon().size(50), Event::DecrementPressed)) + .align_items(Alignment::Center) + .spacing(10) + .into() + } + } + + impl<'a, Message> From> for Element<'a, Message> + where + Message: 'a, + { + fn from(numeric_input: ThresholdInput) -> Self { + iced_lazy::component(numeric_input) + } + } } diff --git a/gui/src/ui/component/card.rs b/gui/src/ui/component/card.rs index 52e8b92d..025b6768 100644 --- a/gui/src/ui/component/card.rs +++ b/gui/src/ui/component/card.rs @@ -35,6 +35,36 @@ impl From for iced::theme::Container { } } +pub fn invalid<'a, T: 'a, C: Into>>(content: C) -> widget::Container<'a, T> { + Container::new(content).padding(15).style(InvalidCardStyle) +} + +pub struct InvalidCardStyle; +impl widget::container::StyleSheet for InvalidCardStyle { + type Style = iced::Theme; + fn appearance(&self, _style: &Self::Style) -> widget::container::Appearance { + widget::container::Appearance { + border_radius: 10.0, + border_color: color::ALERT, + border_width: 1.0, + background: color::FOREGROUND.into(), + ..widget::container::Appearance::default() + } + } +} + +impl From for Box> { + fn from(s: InvalidCardStyle) -> Box> { + Box::new(s) + } +} + +impl From for iced::theme::Container { + fn from(i: InvalidCardStyle) -> iced::theme::Container { + iced::theme::Container::Custom(i.into()) + } +} + /// display an error card with the message and the error in a tooltip. pub fn warning<'a, T: 'a>(message: String) -> widget::Container<'a, T> { Container::new( diff --git a/gui/src/ui/component/mod.rs b/gui/src/ui/component/mod.rs index 3f97ac8d..e9346029 100644 --- a/gui/src/ui/component/mod.rs +++ b/gui/src/ui/component/mod.rs @@ -7,6 +7,9 @@ pub mod form; pub mod modal; pub mod notification; pub mod text; +pub mod tooltip; + +pub use tooltip::tooltip; use iced::widget::{Column, Container, Text}; use iced::Length; diff --git a/gui/src/ui/component/tooltip.rs b/gui/src/ui/component/tooltip.rs new file mode 100644 index 00000000..bb254e9d --- /dev/null +++ b/gui/src/ui/component/tooltip.rs @@ -0,0 +1,36 @@ +use crate::ui::{color, icon}; +use iced::widget::{self, Tooltip}; + +pub fn tooltip<'a, T: 'a>(help: &'static str) -> Tooltip<'a, T> { + Tooltip::new( + icon::tooltip_icon().style(color::DARK_GREY), + help, + widget::tooltip::Position::Right, + ) + .style(TooltipStyle) +} +pub struct TooltipStyle; +impl widget::container::StyleSheet for TooltipStyle { + type Style = iced::Theme; + fn appearance(&self, _style: &Self::Style) -> widget::container::Appearance { + widget::container::Appearance { + border_radius: 10.0, + border_color: color::DARK_GREY, + border_width: 1.5, + background: color::FOREGROUND.into(), + ..widget::container::Appearance::default() + } + } +} + +impl From for Box> { + fn from(s: TooltipStyle) -> Box> { + Box::new(s) + } +} + +impl From for iced::theme::Container { + fn from(i: TooltipStyle) -> iced::theme::Container { + iced::theme::Container::Custom(i.into()) + } +} diff --git a/gui/src/ui/icon.rs b/gui/src/ui/icon.rs index fc7d7c5f..46d1a8a7 100644 --- a/gui/src/ui/icon.rs +++ b/gui/src/ui/icon.rs @@ -13,6 +13,10 @@ fn icon(unicode: char) -> Text<'static> { .size(20) } +pub fn arrow_down() -> Text<'static> { + icon('\u{F128}') +} + pub fn recovery_icon() -> Text<'static> { icon('\u{F467}') } @@ -206,3 +210,11 @@ pub fn collapse_icon() -> Text<'static> { pub fn collapsed_icon() -> Text<'static> { icon('\u{F282}') } + +pub fn down_icon() -> Text<'static> { + icon('\u{F279}') +} + +pub fn up_icon() -> Text<'static> { + icon('\u{F27C}') +} From a2ac34e6b03ef92025ac0f9c9cb709719ea6375a Mon Sep 17 00:00:00 2001 From: edouard Date: Thu, 19 Jan 2023 18:00:45 +0100 Subject: [PATCH 2/9] installer: Participate in a new wallet section --- gui/src/installer/message.rs | 4 +- gui/src/installer/mod.rs | 16 ++- gui/src/installer/step/descriptor.rs | 150 +++++++++++++++++++++++-- gui/src/installer/step/mod.rs | 4 +- gui/src/installer/view.rs | 159 +++++++++++++++++++++++++-- gui/src/ui/icon.rs | 4 + 6 files changed, 315 insertions(+), 22 deletions(-) diff --git a/gui/src/installer/message.rs b/gui/src/installer/message.rs index 9c24030a..6090308a 100644 --- a/gui/src/installer/message.rs +++ b/gui/src/installer/message.rs @@ -10,8 +10,9 @@ use crate::hw::HardwareWallet; #[derive(Debug, Clone)] pub enum Message { CreateWallet, + ParticipateWallet, ImportWallet, - BackupDone(bool), + UserActionDone(bool), Exit(PathBuf), Clibpboard(String), Next, @@ -24,6 +25,7 @@ pub enum Message { Network(Network), DefineBitcoind(DefineBitcoind), DefineDescriptor(DefineDescriptor), + ImportXpub(Result), ConnectedHardwareWallets(Vec), WalletRegistered(Result<(Fingerprint, Option<[u8; 32]>), Error>), } diff --git a/gui/src/installer/mod.rs b/gui/src/installer/mod.rs index 76630970..0c0df440 100644 --- a/gui/src/installer/mod.rs +++ b/gui/src/installer/mod.rs @@ -18,7 +18,7 @@ use crate::{ pub use message::Message; use step::{ BackupDescriptor, Context, DefineBitcoind, DefineDescriptor, Final, ImportDescriptor, - RegisterDescriptor, Step, Welcome, + ParticipateXpub, RegisterDescriptor, Step, Welcome, }; pub struct Installer { @@ -100,10 +100,22 @@ impl Installer { ]; self.next() } + Message::ParticipateWallet => { + self.steps = vec![ + Welcome::default().into(), + ParticipateXpub::new().into(), + ImportDescriptor::new(false).into(), + BackupDescriptor::default().into(), + RegisterDescriptor::default().into(), + DefineBitcoind::new().into(), + Final::new().into(), + ]; + self.next() + } Message::ImportWallet => { self.steps = vec![ Welcome::default().into(), - ImportDescriptor::new().into(), + ImportDescriptor::new(true).into(), RegisterDescriptor::default().into(), DefineBitcoind::new().into(), Final::new().into(), diff --git a/gui/src/installer/step/descriptor.rs b/gui/src/installer/step/descriptor.rs index 29ca0984..e32816b6 100644 --- a/gui/src/installer/step/descriptor.rs +++ b/gui/src/installer/step/descriptor.rs @@ -611,17 +611,156 @@ async fn get_extended_pubkey( })) } +pub struct ParticipateXpub { + network: Network, + network_valid: bool, + data_dir: Option, + + xpub: Option, + shared: bool, + + processing: bool, + chosen_hw: Option, + hws: Vec<(HardwareWallet, bool)>, + error: Option, +} + +impl ParticipateXpub { + pub fn new() -> Self { + Self { + network: Network::Bitcoin, + network_valid: true, + data_dir: None, + processing: false, + xpub: None, + chosen_hw: None, + hws: Vec::new(), + shared: false, + error: None, + } + } +} + +impl Step for ParticipateXpub { + // form value is set as valid each time it is edited. + // Verification of the values is happening when the user click on Next button. + fn update(&mut self, message: Message) -> Command { + match message { + Message::Network(network) => { + self.network = network; + let mut network_datadir = self.data_dir.clone().unwrap(); + network_datadir.push(self.network.to_string()); + self.network_valid = !network_datadir.exists(); + } + Message::UserActionDone(shared) => self.shared = shared, + Message::ImportXpub(res) => { + self.processing = false; + match res { + Err(e) => { + self.error = e.into(); + self.chosen_hw = None; + } + Ok(xpub) => { + self.error = None; + self.xpub = Some(xpub.to_string().trim_end_matches("/<0;1>/*").to_string()); + for (i, (_, imported)) in self.hws.iter_mut().enumerate() { + *imported = Some(i) == self.chosen_hw; + } + self.chosen_hw = None; + } + } + } + Message::Select(i) => { + if let Some((hw, _)) = self.hws.get(i) { + let device = hw.device.clone(); + self.chosen_hw = Some(i); + self.processing = true; + self.error = None; + return Command::perform( + get_extended_pubkey(device, hw.fingerprint, self.network), + Message::ImportXpub, + ); + } + } + Message::ConnectedHardwareWallets(hws) => { + for hw in hws { + if !self + .hws + .iter() + .any(|(h, _)| h.fingerprint == hw.fingerprint) + { + self.hws.push((hw, false)); + } + } + } + Message::Reload => { + return self.load(); + } + _ => {} + }; + Command::none() + } + + fn load_context(&mut self, ctx: &Context) { + self.network = ctx.bitcoin_config.network; + self.data_dir = Some(ctx.data_dir.clone()); + let mut network_datadir = ctx.data_dir.clone(); + network_datadir.push(self.network.to_string()); + self.network_valid = !network_datadir.exists(); + } + + fn load(&self) -> Command { + Command::perform( + list_hardware_wallets(&[], None), + Message::ConnectedHardwareWallets, + ) + } + + fn apply(&mut self, ctx: &mut Context) -> bool { + ctx.bitcoin_config.network = self.network; + true + } + + fn view(&self, progress: (usize, usize)) -> Element { + view::participate_xpub( + progress, + self.network, + self.network_valid, + &self.hws, + self.processing, + self.chosen_hw, + self.xpub.as_ref(), + self.shared, + self.error.as_ref(), + ) + } +} + +impl Default for ParticipateXpub { + fn default() -> Self { + Self::new() + } +} + +impl From for Box { + fn from(s: ParticipateXpub) -> Box { + Box::new(s) + } +} + pub struct ImportDescriptor { network: Network, network_valid: bool, + change_network: bool, data_dir: Option, imported_descriptor: form::Value, error: Option, } impl ImportDescriptor { - pub fn new() -> Self { + pub fn new(change_network: bool) -> Self { Self { + change_network, network: Network::Bitcoin, network_valid: true, data_dir: None, @@ -679,6 +818,7 @@ impl Step for ImportDescriptor { fn view(&self, progress: (usize, usize)) -> Element { view::import_descriptor( progress, + self.change_network, self.network, self.network_valid, &self.imported_descriptor, @@ -687,12 +827,6 @@ impl Step for ImportDescriptor { } } -impl Default for ImportDescriptor { - fn default() -> Self { - Self::new() - } -} - impl From for Box { fn from(s: ImportDescriptor) -> Box { Box::new(s) @@ -817,7 +951,7 @@ pub struct BackupDescriptor { impl Step for BackupDescriptor { fn update(&mut self, message: Message) -> Command { - if let Message::BackupDone(done) = message { + if let Message::UserActionDone(done) = message { self.done = done; } Command::none() diff --git a/gui/src/installer/step/mod.rs b/gui/src/installer/step/mod.rs index b3a20ff1..f425d907 100644 --- a/gui/src/installer/step/mod.rs +++ b/gui/src/installer/step/mod.rs @@ -1,5 +1,7 @@ mod descriptor; -pub use descriptor::{BackupDescriptor, DefineDescriptor, ImportDescriptor, RegisterDescriptor}; +pub use descriptor::{ + BackupDescriptor, DefineDescriptor, ImportDescriptor, ParticipateXpub, RegisterDescriptor, +}; use std::path::PathBuf; use std::str::FromStr; diff --git a/gui/src/installer/view.rs b/gui/src/installer/view.rs index 8af8a6b9..e77d6087 100644 --- a/gui/src/installer/view.rs +++ b/gui/src/installer/view.rs @@ -82,12 +82,12 @@ pub fn welcome<'a>() -> Element<'a, Message> { Button::new( Container::new( Column::new() - .width(Length::Units(200)) + .width(Length::Units(250)) .push(icon::wallet_icon().size(50).width(Length::Units(100))) - .push(text("Create new wallet")) + .push(text("Create a new wallet")) .align_items(Alignment::Center), ) - .padding(50), + .padding(20), ) .style(button::Style::Border.into()) .on_press(Message::CreateWallet), @@ -96,12 +96,26 @@ pub fn welcome<'a>() -> Element<'a, Message> { Button::new( Container::new( Column::new() - .width(Length::Units(200)) - .push(icon::import_icon().size(50).width(Length::Units(100))) - .push(text("Import wallet")) + .width(Length::Units(250)) + .push(icon::people_icon().size(50).width(Length::Units(100))) + .push(text("Participate in a new wallet")) .align_items(Alignment::Center), ) - .padding(50), + .padding(20), + ) + .style(button::Style::Border.into()) + .on_press(Message::ParticipateWallet), + ) + .push( + Button::new( + Container::new( + Column::new() + .width(Length::Units(250)) + .push(icon::import_icon().size(50).width(Length::Units(100))) + .push(text("Import a wallet backup")) + .align_items(Alignment::Center), + ) + .padding(20), ) .style(button::Style::Border.into()) .on_press(Message::ImportWallet), @@ -109,7 +123,6 @@ pub fn welcome<'a>() -> Element<'a, Message> { ) .width(Length::Fill) .height(Length::Fill) - .padding(100) .spacing(50) .align_items(Alignment::Center), )) @@ -327,6 +340,7 @@ pub fn define_descriptor<'a>( pub fn import_descriptor<'a>( progress: (usize, usize), + change_network: bool, network: bitcoin::Network, network_valid: bool, imported_descriptor: &form::Value, @@ -367,7 +381,11 @@ pub fn import_descriptor<'a>( .push( Column::new() .spacing(20) - .push(row_network) + .push_maybe(if change_network { + Some(row_network) + } else { + None + }) .push(col_descriptor), ) .push(if imported_descriptor.value.is_empty() { @@ -386,6 +404,127 @@ pub fn import_descriptor<'a>( ) } +#[allow(clippy::too_many_arguments)] +pub fn participate_xpub<'a>( + progress: (usize, usize), + network: bitcoin::Network, + network_valid: bool, + hws: &[(HardwareWallet, bool)], + processing: bool, + chosen_hw: Option, + xpub: Option<&'a String>, + shared: bool, + error: Option<&Error>, +) -> Element<'a, Message> { + let row_network = Row::new() + .spacing(10) + .align_items(Alignment::Center) + .push(text("Network:").bold()) + .push(Container::new( + PickList::new(&NETWORKS[..], Some(Network::from(network)), |net| { + Message::Network(net.into()) + }) + .padding(10), + )) + .push_maybe(if network_valid { + None + } else { + Some(card::warning( + "A data directory already exists for this network".to_string(), + )) + }); + + layout( + progress, + Column::new() + .push(text("Share your public key").bold().size(50)) + .push( + Column::new() + .spacing(20) + .width(Length::Fill) + .push(row_network), + ) + .push( + Column::new() + .push( + Row::new() + .spacing(10) + .align_items(Alignment::Center) + .push( + Container::new( + text(format!("Select your hardware wallet:")).bold(), + ) + .width(Length::Fill), + ) + .push( + button::border(Some(icon::reload_icon()), "Refresh") + .on_press(Message::Reload), + ), + ) + .spacing(10) + .push( + hws.iter() + .enumerate() + .fold(Column::new().spacing(10), |col, (i, hw)| { + col.push(hw_list_view( + i, + &hw.0, + Some(i) == chosen_hw, + processing, + hw.1, + )) + }), + ) + .width(Length::Fill), + ) + .push_maybe(xpub.map(|xpub| { + Column::new() + .spacing(5) + .push(text("Your extended pubkey:").bold()) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push( + Container::new( + Scrollable::new(Container::new(text(xpub)).padding(10)) + .horizontal_scroll( + Properties::new().width(2).scroller_width(2), + ), + ) + .width(Length::Fill), + ) + .push( + Container::new( + button::border(Some(icon::clipboard_icon()), "Copy") + .on_press(Message::Clibpboard(xpub.clone())) + .width(Length::Shrink), + ) + .padding(10), + ), + ) + })) + .push(Checkbox::new( + "I have shared my xpub", + shared, + Message::UserActionDone, + )) + .push(if shared { + button::primary(None, "Next") + .width(Length::Units(200)) + .on_press(Message::Next) + } else { + button::primary(None, "Next").width(Length::Units(200)) + }) + .push_maybe(error.map(|e| card::error("Hardware error", e.to_string()))) + .width(Length::Fill) + .height(Length::Fill) + .padding(100) + .spacing(50) + .align_items(Alignment::Center), + ) +} + pub fn register_descriptor<'a>( progress: (usize, usize), descriptor: String, @@ -518,7 +657,7 @@ pub fn backup_descriptor<'a>( .push(Checkbox::new( "I have backed up my descriptor", done, - Message::BackupDone, + Message::UserActionDone, )) .push(if done { button::primary(None, "Next") diff --git a/gui/src/ui/icon.rs b/gui/src/ui/icon.rs index 46d1a8a7..92141dc4 100644 --- a/gui/src/ui/icon.rs +++ b/gui/src/ui/icon.rs @@ -218,3 +218,7 @@ pub fn down_icon() -> Text<'static> { pub fn up_icon() -> Text<'static> { icon('\u{F27C}') } + +pub fn people_icon() -> Text<'static> { + icon('\u{F4CF}') +} From 9a1cda2f5ed80a66324b272b7cf4c3bd7202e953 Mon Sep 17 00:00:00 2001 From: edouard Date: Fri, 20 Jan 2023 12:41:23 +0100 Subject: [PATCH 3/9] Use spend state in recovery panel --- gui/src/app/message.rs | 1 + gui/src/app/state/recovery.rs | 149 +++++++++++--------------- gui/src/app/state/spend/mod.rs | 2 +- gui/src/app/view/recovery.rs | 188 ++------------------------------- 4 files changed, 68 insertions(+), 272 deletions(-) diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index c06e4b84..bffe8e7c 100644 --- a/gui/src/app/message.rs +++ b/gui/src/app/message.rs @@ -23,6 +23,7 @@ pub enum Message { Coins(Result, Error>), SpendTxs(Result, Error>), Psbt(Result), + Recovery(Result), Signed(Result<(Psbt, Fingerprint), Error>), Updated(Result<(), Error>), Saved(Result<(), Error>), diff --git a/gui/src/app/state/recovery.rs b/gui/src/app/state/recovery.rs index cdfadef9..3dcc2c5f 100644 --- a/gui/src/app/state/recovery.rs +++ b/gui/src/app/state/recovery.rs @@ -10,19 +10,19 @@ use crate::{ error::Error, menu::Menu, message::Message, + state::spend::detail, state::{redirect, State}, view, wallet::Wallet, }, daemon::{ - model::{remaining_sequence, Coin}, + model::{remaining_sequence, Coin, SpendTx}, Daemon, }, - hw::{list_hardware_wallets, HardwareWallet}, ui::component::form, }; -use liana::miniscript::bitcoin::{util::psbt::Psbt, Address, Amount, Network}; +use liana::miniscript::bitcoin::{Address, Amount, Network}; pub struct RecoveryPanel { wallet: Wallet, @@ -32,10 +32,7 @@ pub struct RecoveryPanel { warning: Option, feerate: form::Value, recipient: form::Value, - generated: Option, - hws: Vec, - selected_hw: Option, - signed: bool, + generated: Option, /// timelock value to pass for the heir to consume a coin. timelock: u32, } @@ -72,30 +69,27 @@ impl RecoveryPanel { recipient: form::Value::default(), generated: None, timelock, - hws: Vec::new(), - selected_hw: None, - signed: false, } } } impl State for RecoveryPanel { - fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { - view::modal( - false, - self.warning.as_ref(), - view::recovery::recovery( - &self.locked_coins, - &self.recoverable_coins, - &self.feerate, - &self.recipient, - self.generated.as_ref(), - &self.hws, - self.selected_hw, - self.signed, - ), - None::>, - ) + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + if let Some(generated) = &self.generated { + generated.view(cache) + } else { + view::modal( + false, + self.warning.as_ref(), + view::recovery::recovery( + &self.locked_coins, + &self.recoverable_coins, + &self.feerate, + &self.recipient, + ), + None::>, + ) + } } fn update( @@ -127,27 +121,18 @@ impl State for RecoveryPanel { } } }, - // We add the new hws without dropping the reference of the previous ones. - Message::ConnectedHardwareWallets(hws) => { - for h in hws { - if !self.hws.iter().any(|hw| hw.fingerprint == h.fingerprint) { - self.hws.push(h); - } + Message::Recovery(res) => match res { + Ok(tx) => { + self.generated = Some(detail::SpendTxState::new( + self.wallet.clone(), + self.config.clone(), + tx, + false, + )) } - } - Message::Psbt(res) => match res { - Ok(psbt) => self.generated = Some(psbt), Err(e) => self.warning = Some(e), }, - Message::Updated(res) => match res { - Err(e) => self.warning = Some(e), - Ok(()) => { - self.warning = None; - self.signed = true; - } - }, Message::View(msg) => match msg { - view::Message::Reload => return self.load(daemon), view::Message::Close => return redirect(Menu::Settings), view::Message::Previous => self.generated = None, view::Message::CreateSpend(view::CreateSpendMessage::RecipientEdited( @@ -177,68 +162,52 @@ impl State for RecoveryPanel { self.warning = None; return Command::perform( async move { - daemon - .create_recovery(address, feerate_vb) - .map_err(|e| e.into()) + let psbt = daemon.create_recovery(address, feerate_vb)?; + let coins = daemon.list_coins().map(|res| res.coins)?; + let coins = coins + .iter() + .filter(|coin| { + psbt.unsigned_tx + .input + .iter() + .any(|input| input.previous_output == coin.outpoint) + }) + .copied() + .collect(); + Ok(SpendTx::new(psbt, coins)) }, - Message::Psbt, + Message::Recovery, ); } - view::Message::Spend(view::SpendTxMessage::SelectHardwareWallet(i)) => { - if let Some(hw) = self.hws.get(i) { - let device = hw.device.clone(); - self.selected_hw = Some(i); - let psbt = self.generated.clone().unwrap(); - return Command::perform( - send_funds(daemon, device, psbt), - Message::Updated, - ); + _ => { + if let Some(generated) = &mut self.generated { + return generated.update(daemon, cache, Message::View(msg)); } } - _ => {} }, - _ => {} + _ => { + if let Some(generated) = &mut self.generated { + return generated.update(daemon, cache, message); + } + } }; Command::none() } fn load(&self, daemon: Arc) -> Command { - let config = self.config.clone(); - let desc = self.wallet.main_descriptor.to_string(); let daemon = daemon.clone(); - Command::batch(vec![ - Command::perform( - async move { - daemon - .list_coins() - .map(|res| res.coins) - .map_err(|e| e.into()) - }, - Message::Coins, - ), - Command::perform( - list_hws(config, self.wallet.name.clone(), desc), - Message::ConnectedHardwareWallets, - ), - ]) + Command::perform( + async move { + daemon + .list_coins() + .map(|res| res.coins) + .map_err(|e| e.into()) + }, + Message::Coins, + ) } } -async fn list_hws(config: Config, wallet_name: String, descriptor: String) -> Vec { - list_hardware_wallets(&config.hardware_wallets, Some((&wallet_name, &descriptor))).await -} - -async fn send_funds( - daemon: Arc, - hw: std::sync::Arc, - mut psbt: Psbt, -) -> Result<(), Error> { - hw.sign_tx(&mut psbt).await.map_err(Error::from)?; - daemon.update_spend_tx(&psbt)?; - daemon.broadcast_spend_tx(&psbt.unsigned_tx.txid())?; - Ok(()) -} - impl From for Box { fn from(s: RecoveryPanel) -> Box { Box::new(s) diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index ddc4368e..84dad9d0 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -1,4 +1,4 @@ -mod detail; +pub mod detail; mod step; use std::sync::Arc; diff --git a/gui/src/app/view/recovery.rs b/gui/src/app/view/recovery.rs index 15b14c73..0fd90b27 100644 --- a/gui/src/app/view/recovery.rs +++ b/gui/src/app/view/recovery.rs @@ -1,18 +1,14 @@ use iced::{ - widget::{Button, Column, Container, Row, Space}, + widget::{Column, Container, Row, Space}, Alignment, Element, Length, }; -use liana::miniscript::bitcoin::{util::psbt::Psbt, Amount}; +use liana::miniscript::bitcoin::Amount; use crate::{ - app::view::{ - hw::hw_list_view, - message::{CreateSpendMessage, Message}, - }, - hw::HardwareWallet, + app::view::message::{CreateSpendMessage, Message}, ui::{ - component::{button, card, form, text::*}, + component::{button, form, text::*}, icon, util::Collection, }, @@ -24,10 +20,6 @@ pub fn recovery<'a>( recoverable_coins: &(usize, Amount), feerate: &form::Value, address: &'a form::Value, - generated: Option<&Psbt>, - hws: &[HardwareWallet], - chosen_hw: Option, - done: bool, ) -> Element<'a, Message> { Column::new() .push(Space::with_height(Length::Units(100))) @@ -59,173 +51,7 @@ pub fn recovery<'a>( None }) .push(Space::with_height(Length::Units(20))) - .push(if let Some(psbt) = generated { - if done { - Column::new() - .spacing(20) - .align_items(Alignment::Center) - .push(text("Funds were sweeped")) - .push(card::simple( - Column::new() - .push( - Row::new() - .spacing(5) - .align_items(Alignment::Center) - .push( - text(format!( - "{}", - Amount::from_sat(psbt.unsigned_tx.output[0].value) - )) - .small() - .bold(), - ) - .push(text(" to ").small()) - .push(text(&address.value).small().bold()), - ) - .push( - Row::new() - .spacing(5) - .align_items(Alignment::Center) - .push( - text(format!("Txid: {}", psbt.unsigned_tx.txid())).small(), - ) - .push( - Button::new(icon::clipboard_icon().small()) - .on_press(Message::Clipboard( - psbt.unsigned_tx.txid().to_string(), - )) - .style(button::Style::Border.into()), - ), - ) - .push_maybe( - if recoverable_coins.1.to_sat() > psbt.unsigned_tx.output[0].value { - Some( - Row::new().push( - text(format!( - "Fees: {}", - recoverable_coins.1 - - Amount::from_sat( - psbt.unsigned_tx.output[0].value - ) - )) - .small(), - ), - ) - } else { - None - }, - ), - )) - } else { - Column::new() - .spacing(20) - .align_items(Alignment::Center) - .push_maybe(if chosen_hw.is_none() { - Some(button::border(None, "< Previous").on_press(Message::Previous)) - } else { - None - }) - .push(text("Sign the transaction to sweep the funds").bold()) - .push(card::simple( - Column::new() - .push( - Row::new() - .spacing(5) - .align_items(Alignment::Center) - .push( - text(format!( - "{}", - Amount::from_sat(psbt.unsigned_tx.output[0].value) - )) - .small() - .bold(), - ) - .push(text(" to ").small()) - .push(text(&address.value).small().bold()), - ) - .push( - Row::new() - .spacing(5) - .align_items(Alignment::Center) - .push( - text(format!("Txid: {}", psbt.unsigned_tx.txid())).small(), - ) - .push( - Button::new(icon::clipboard_icon().small()) - .on_press(Message::Clipboard( - psbt.unsigned_tx.txid().to_string(), - )) - .style(button::Style::Border.into()), - ), - ) - .push_maybe( - if recoverable_coins.1.to_sat() > psbt.unsigned_tx.output[0].value { - Some( - Row::new().push( - text(format!( - "Fees: {}", - recoverable_coins.1 - - Amount::from_sat( - psbt.unsigned_tx.output[0].value - ) - )) - .small(), - ), - ) - } else { - None - }, - ), - )) - .push(if !hws.is_empty() { - Column::new() - .push( - Row::new() - .align_items(Alignment::Center) - .push( - text("Select hardware wallet to sign with:") - .bold() - .width(Length::Fill), - ) - .push_maybe(if chosen_hw.is_none() { - Some( - button::border(None, "Refresh") - .on_press(Message::Reload), - ) - } else { - None - }), - ) - .spacing(10) - .push(hws.iter().enumerate().fold( - Column::new().spacing(10), - |col, (i, hw)| { - col.push(hw_list_view( - i, - hw, - Some(i) == chosen_hw, - chosen_hw.is_some(), - false, - )) - }, - )) - .max_width(500) - } else { - Column::new() - .push( - Column::new() - .spacing(20) - .width(Length::Fill) - .push("Please connect a hardware wallet") - .push( - button::primary(None, "Refresh").on_press(Message::Reload), - ) - .align_items(Alignment::Center), - ) - .width(Length::Fill) - }) - } - } else { + .push( Column::new() .push(text("Enter destination address and feerate:").bold()) .push( @@ -267,8 +93,8 @@ pub fn recovery<'a>( }, ) .spacing(20) - .align_items(Alignment::Center) - }) + .align_items(Alignment::Center), + ) .align_items(Alignment::Center) .spacing(20) .into() From 1f3399bd0f78593a689f0ec50934b1cf4f34f692 Mon Sep 17 00:00:00 2001 From: edouard Date: Sat, 21 Jan 2023 16:56:11 +0100 Subject: [PATCH 4/9] Add PartialSpendInfo to SpendTx model --- gui/src/app/state/recovery.rs | 4 +++- gui/src/app/state/spend/step.rs | 8 ++++++- gui/src/daemon/mod.rs | 40 ++++++++++++++++++--------------- gui/src/daemon/model.rs | 5 ++++- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/gui/src/app/state/recovery.rs b/gui/src/app/state/recovery.rs index 3dcc2c5f..4933878d 100644 --- a/gui/src/app/state/recovery.rs +++ b/gui/src/app/state/recovery.rs @@ -160,6 +160,7 @@ impl State for RecoveryPanel { let address = Address::from_str(&self.recipient.value).expect("Checked before"); let feerate_vb = self.feerate.value.parse::().expect("Checked before"); self.warning = None; + let desc = self.wallet.main_descriptor.clone(); return Command::perform( async move { let psbt = daemon.create_recovery(address, feerate_vb)?; @@ -174,7 +175,8 @@ impl State for RecoveryPanel { }) .copied() .collect(); - Ok(SpendTx::new(psbt, coins)) + let sigs = desc.partial_spend_info(&psbt).unwrap(); + Ok(SpendTx::new(psbt, coins, sigs)) }, Message::Recovery, ); diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index eae21155..77b2529d 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -447,10 +447,16 @@ impl SaveSpend { impl Step for SaveSpend { fn load(&mut self, draft: &TransactionDraft) { + let psbt = draft.generated.clone().unwrap(); + let sigs = self + .wallet + .main_descriptor + .partial_spend_info(&psbt) + .unwrap(); self.spend = Some(detail::SpendTxState::new( self.wallet.clone(), self.config.clone(), - SpendTx::new(draft.generated.clone().unwrap(), draft.inputs.clone()), + SpendTx::new(psbt, draft.inputs.clone(), sigs), false, )); } diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index 4ff20477..d81716eb 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -72,25 +72,29 @@ pub trait Daemon: Debug { fn list_txs(&self, txid: &[Txid]) -> Result; fn list_spend_transactions(&self) -> Result, DaemonError> { + let info = self.get_info()?; let coins = self.list_coins()?.coins; - let spend_txs = self.list_spend_txs()?.spend_txs; - Ok(spend_txs - .into_iter() - .map(|tx| { - let coins = coins - .iter() - .filter(|coin| { - tx.psbt - .unsigned_tx - .input - .iter() - .any(|input| input.previous_output == coin.outpoint) - }) - .copied() - .collect(); - model::SpendTx::new(tx.psbt, coins) - }) - .collect()) + let mut spend_txs = Vec::new(); + for tx in self.list_spend_txs()?.spend_txs { + let coins = coins + .iter() + .filter(|coin| { + tx.psbt + .unsigned_tx + .input + .iter() + .any(|input| input.previous_output == coin.outpoint) + }) + .copied() + .collect(); + let sigs = info + .descriptors + .main + .partial_spend_info(&tx.psbt) + .map_err(|e| DaemonError::Unexpected(e.to_string()))?; + spend_txs.push(model::SpendTx::new(tx.psbt, coins, sigs)) + } + Ok(spend_txs) } fn list_history_txs( diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index ec14e83d..10958940 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -3,6 +3,7 @@ pub use liana::{ CreateSpendResult, GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult, ListSpendEntry, ListSpendResult, ListTransactionsResult, TransactionInfo, }, + descriptors::PartialSpendInfo, miniscript::bitcoin::{util::psbt::Psbt, Amount, Transaction}, }; @@ -28,6 +29,7 @@ pub struct SpendTx { pub spend_amount: Amount, pub fee_amount: Amount, pub status: SpendStatus, + pub sigs: PartialSpendInfo, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -38,7 +40,7 @@ pub enum SpendStatus { } impl SpendTx { - pub fn new(psbt: Psbt, coins: Vec) -> Self { + pub fn new(psbt: Psbt, coins: Vec, sigs: PartialSpendInfo) -> Self { let mut change_indexes = Vec::new(); let (change_amount, spend_amount) = psbt.unsigned_tx.output.iter().enumerate().fold( (Amount::from_sat(0), Amount::from_sat(0)), @@ -72,6 +74,7 @@ impl SpendTx { spend_amount, fee_amount: inputs_amount - spend_amount - change_amount, status, + sigs, } } From bcd223f3fb8cf7224eede75b5cfeb97ae8e93ef9 Mon Sep 17 00:00:00 2001 From: edouard Date: Mon, 23 Jan 2023 12:18:55 +0100 Subject: [PATCH 5/9] Add sigs number and threshold to spend list view --- gui/src/app/view/spend/mod.rs | 64 ++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/gui/src/app/view/spend/mod.rs b/gui/src/app/view/spend/mod.rs index 1c9c9e6e..4a98a927 100644 --- a/gui/src/app/view/spend/mod.rs +++ b/gui/src/app/view/spend/mod.rs @@ -111,23 +111,63 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { .push( Row::new() .push(badge::spend()) - .push_maybe(match tx.status { - SpendStatus::Deprecated => Some( - Container::new(text(" Deprecated ").small()) - .padding(3) - .style(badge::PillStyle::Simple), - ), - SpendStatus::Broadcast => Some( - Container::new(text(" Broadcast ").small()) - .padding(3) - .style(badge::PillStyle::Success), - ), - _ => None, + .push(if let Some(sigs) = tx.sigs.recovery_path() { + Row::new() + .spacing(10) + .align_items(Alignment::Center) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(text(format!( + "{}/{}", + if sigs.signed_pubkeys.len() <= sigs.threshold { + sigs.signed_pubkeys.len() + } else { + sigs.threshold + }, + sigs.threshold + ))) + .push(icon::key_icon()), + ) + .push( + Container::new(text(" Recovery ").small()) + .padding(3) + .style(badge::PillStyle::Simple), + ) + } else { + let sigs = tx.sigs.primary_path(); + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(text(format!( + "{}/{}", + if sigs.signed_pubkeys.len() <= sigs.threshold { + sigs.signed_pubkeys.len() + } else { + sigs.threshold + }, + sigs.threshold + ))) + .push(icon::key_icon()) }) .spacing(10) .align_items(Alignment::Center) .width(Length::Fill), ) + .push_maybe(match tx.status { + SpendStatus::Deprecated => Some( + Container::new(text(" Deprecated ").small()) + .padding(3) + .style(badge::PillStyle::Simple), + ), + SpendStatus::Broadcast => Some( + Container::new(text(" Broadcast ").small()) + .padding(3) + .style(badge::PillStyle::Success), + ), + _ => None, + }) .push( Column::new() .push(amount(&tx.spend_amount)) From 689f19a4f22009a9043a7308e00c96bf1de97a45 Mon Sep 17 00:00:00 2001 From: edouard Date: Mon, 23 Jan 2023 17:40:00 +0100 Subject: [PATCH 6/9] Edit key name in installer --- gui/src/installer/message.rs | 6 +- gui/src/installer/step/descriptor.rs | 188 +++++++-------- gui/src/installer/view.rs | 343 +++++++++++++++------------ 3 files changed, 286 insertions(+), 251 deletions(-) diff --git a/gui/src/installer/message.rs b/gui/src/installer/message.rs index 6090308a..2517ea5c 100644 --- a/gui/src/installer/message.rs +++ b/gui/src/installer/message.rs @@ -44,6 +44,7 @@ pub enum DefineDescriptor { Key(bool, usize, DefineKey), HWXpubImported(Result), XPubEdited(String), + NameEdited(String), SequenceEdited(String), ThresholdEdited(bool, usize), ConfirmXpub, @@ -52,8 +53,7 @@ pub enum DefineDescriptor { #[derive(Debug, Clone)] pub enum DefineKey { Delete, - ImportFromHardware, - ImportFromClipboard, + Edit, Clipboard(String), - Imported(DescriptorPublicKey), + Edited(String, DescriptorPublicKey), } diff --git a/gui/src/installer/step/descriptor.rs b/gui/src/installer/step/descriptor.rs index e32816b6..5a4595e8 100644 --- a/gui/src/installer/step/descriptor.rs +++ b/gui/src/installer/step/descriptor.rs @@ -7,7 +7,7 @@ use liana::{ descriptors::{LianaDescKeys, MultipathDescriptor}, miniscript::{ bitcoin::{ - util::bip32::{DerivationPath, ExtendedPubKey, Fingerprint}, + util::bip32::{DerivationPath, Fingerprint}, Network, }, descriptor::{DerivPaths, DescriptorMultiXKey, DescriptorPublicKey, Wildcard}, @@ -48,6 +48,8 @@ pub struct DefineDescriptor { sequence: form::Value, modal: Option>, + name_indexes: (usize, usize), + error: Option, } @@ -61,6 +63,7 @@ impl DefineDescriptor { spending_threshold: 1, recovery_keys: vec![DescriptorKey::new("Recovery key 1".to_string())], recovery_threshold: 1, + name_indexes: (1, 1), sequence: form::Value::default(), modal: None, error: None, @@ -112,12 +115,12 @@ impl DefineDescriptor { for spending_key in self.spending_keys.iter_mut() { spending_key.duplicate_name = duplicate_names.contains(&spending_key.name); if let Some(key) = &spending_key.key { - spending_key.duplicate_key = duplicate_keys.contains(&key); + spending_key.duplicate_key = duplicate_keys.contains(key); } } for recovery_key in self.recovery_keys.iter_mut() { if let Some(key) = &recovery_key.key { - recovery_key.duplicate_key = duplicate_keys.contains(&key); + recovery_key.duplicate_key = duplicate_keys.contains(key); } } } @@ -161,16 +164,16 @@ impl Step for DefineDescriptor { } message::DefineDescriptor::AddKey(is_recovery) => { if is_recovery { + self.name_indexes.0 += 1; self.recovery_keys.push(DescriptorKey::new(format!( "Recovery key {}", - self.recovery_keys.len() + 1 + self.name_indexes.0, ))); self.recovery_threshold += 1; } else { - self.spending_keys.push(DescriptorKey::new(format!( - "Key {}", - self.spending_keys.len() + 1 - ))); + self.name_indexes.1 += 1; + self.spending_keys + .push(DescriptorKey::new(format!("Key {}", self.name_indexes.1,))); self.spending_threshold += 1; } } @@ -178,28 +181,51 @@ impl Step for DefineDescriptor { message::DefineKey::Clipboard(key) => { return Command::perform(async move { key }, Message::Clibpboard); } - message::DefineKey::Imported(imported_key) => { + message::DefineKey::Edited(name, imported_key) => { if is_recovery { if let Some(recovery_key) = self.recovery_keys.get_mut(i) { + recovery_key.name = name; recovery_key.key = Some(imported_key); recovery_key.check_network(self.network); } } else if let Some(spending_key) = self.spending_keys.get_mut(i) { + spending_key.name = name; spending_key.key = Some(imported_key); spending_key.check_network(self.network); } self.modal = None; self.check_for_duplicate(); } - message::DefineKey::ImportFromClipboard => { - let modal = ImportXpubModal::new(i, is_recovery, self.network); - self.modal = Some(Box::new(modal)); - } - message::DefineKey::ImportFromHardware => { - let modal = HardwareXpubModal::new(i, is_recovery, self.network); - let cmd = modal.load(); - self.modal = Some(Box::new(modal)); - return cmd; + message::DefineKey::Edit => { + if is_recovery { + if let Some(recovery_key) = self.recovery_keys.get(i) { + let name = recovery_key.name.clone(); + let key = recovery_key + .key + .as_ref() + .map(|k| { + k.to_string().trim_end_matches("/<0;1>/*").to_string() + }) + .unwrap_or_else(|| "".to_string()); + let modal = + EditXpubModal::new(name, key, i, is_recovery, self.network); + let cmd = modal.load(); + self.modal = Some(Box::new(modal)); + return cmd; + } + } else if let Some(spending_key) = self.spending_keys.get(i) { + let name = spending_key.name.clone(); + let key = spending_key + .key + .as_ref() + .map(|k| k.to_string().trim_end_matches("/<0;1>/*").to_string()) + .unwrap_or_else(|| "".to_string()); + let modal = + EditXpubModal::new(name, key, i, is_recovery, self.network); + let cmd = modal.load(); + self.modal = Some(Box::new(modal)); + return cmd; + } } message::DefineKey::Delete => { if is_recovery { @@ -371,10 +397,13 @@ impl DescriptorKey { pub fn view(&self) -> Element { match &self.key { - None => view::undefined_descriptor_key(), - Some(key) => { - view::defined_descriptor_key(key.to_string(), self.valid, self.duplicate_key) - } + None => view::undefined_descriptor_key(&self.name), + Some(_) => view::defined_descriptor_key( + &self.name, + self.valid, + self.duplicate_key, + self.duplicate_name, + ), } } } @@ -411,20 +440,37 @@ impl From for Box { } } -pub struct HardwareXpubModal { +pub struct EditXpubModal { is_recovery: bool, key_index: usize, network: Network, error: Option, processing: bool, + form_name: form::Value, + form_xpub: form::Value, + chosen_hw: Option, hws: Vec, } -impl HardwareXpubModal { - fn new(key_index: usize, is_recovery: bool, network: Network) -> Self { +impl EditXpubModal { + fn new( + name: String, + key: String, + key_index: usize, + is_recovery: bool, + network: Network, + ) -> Self { Self { + form_name: form::Value { + valid: true, + value: name, + }, + form_xpub: form::Value { + valid: true, + value: key, + }, is_recovery, key_index, chosen_hw: None, @@ -442,7 +488,7 @@ impl HardwareXpubModal { } } -impl DescriptorKeyModal for HardwareXpubModal { +impl DescriptorKeyModal for EditXpubModal { fn processing(&self) -> bool { self.processing } @@ -474,61 +520,18 @@ impl DescriptorKeyModal for HardwareXpubModal { self.processing = false; match res { Ok(key) => { - let key_index = self.key_index; - let is_recovery = self.is_recovery; - return Command::perform( - async move { (is_recovery, key_index, key) }, - |(is_recovery, key_index, key)| { - message::DefineDescriptor::Key( - is_recovery, - key_index, - message::DefineKey::Imported(key), - ) - }, - ) - .map(Message::DefineDescriptor); + self.form_xpub.value = + key.to_string().trim_end_matches("/<0;1>/*").to_string(); } Err(e) => { self.error = Some(e); } } } - _ => {} - }; - Command::none() - } - fn view(&self) -> Element { - view::hardware_wallet_xpubs_modal( - self.is_recovery, - &self.hws, - self.error.as_ref(), - self.processing, - self.chosen_hw, - ) - } -} - -pub struct ImportXpubModal { - is_recovery: bool, - key_index: usize, - form_xpub: form::Value, - network: Network, -} - -impl ImportXpubModal { - fn new(key_index: usize, is_recovery: bool, network: Network) -> Self { - Self { - form_xpub: form::Value::default(), - is_recovery, - key_index, - network, - } - } -} - -impl DescriptorKeyModal for ImportXpubModal { - fn update(&mut self, message: Message) -> Command { - match message { + Message::DefineDescriptor(message::DefineDescriptor::NameEdited(name)) => { + self.form_name.valid = true; + self.form_name.value = name; + } Message::DefineDescriptor(message::DefineDescriptor::XPubEdited(s)) => { self.form_xpub.valid = DescriptorPublicKey::from_str(&format!("{}/<0;1>/*", s)).is_ok(); @@ -540,13 +543,14 @@ impl DescriptorKeyModal for ImportXpubModal { { let key_index = self.key_index; let is_recovery = self.is_recovery; + let name = self.form_name.value.clone(); return Command::perform( async move { (is_recovery, key_index, key) }, |(is_recovery, key_index, key)| { message::DefineDescriptor::Key( is_recovery, key_index, - message::DefineKey::Imported(key), + message::DefineKey::Edited(name, key), ) }, ) @@ -558,29 +562,15 @@ impl DescriptorKeyModal for ImportXpubModal { Command::none() } fn view(&self) -> Element { - view::clipboard_xpub_modal(&self.form_xpub, self.network) - } -} - -pub struct XKey { - origin: Option<(Fingerprint, DerivationPath)>, - key: ExtendedPubKey, -} - -impl std::fmt::Display for XKey { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - if let Some((ref master_id, ref master_deriv)) = self.origin { - std::fmt::Formatter::write_str(f, "[")?; - for byte in master_id.into_bytes().iter() { - write!(f, "{:02x}", byte)?; - } - for child in master_deriv { - write!(f, "/{}", child)?; - } - std::fmt::Formatter::write_str(f, "]")?; - } - self.key.fmt(f)?; - Ok(()) + view::edit_key_modal( + self.network, + &self.hws, + self.error.as_ref(), + self.processing, + self.chosen_hw, + &self.form_xpub, + &self.form_name, + ) } } diff --git a/gui/src/installer/view.rs b/gui/src/installer/view.rs index e77d6087..a22ab1da 100644 --- a/gui/src/installer/view.rs +++ b/gui/src/installer/view.rs @@ -162,12 +162,14 @@ pub fn define_descriptor<'a>( Some(card::warning( "A data directory already exists for this network".to_string(), )) - }); + }) + .padding(50); let col_spending_keys = Column::new() .push( Row::new() .spacing(10) + .push(Space::with_width(Length::Units(40))) .push(text("Primary path:").bold()) .push(tooltip( super::prompt::DEFINE_DESCRIPTOR_PRIMATRY_PATH_TOOLTIP, @@ -200,13 +202,13 @@ pub fn define_descriptor<'a>( .push( Button::new( Container::new(icon::plus_icon().size(50)) - .width(Length::Units(250)) - .height(Length::Units(250)) + .width(Length::Units(200)) + .height(Length::Units(200)) .align_y(alignment::Vertical::Center) .align_x(alignment::Horizontal::Center), ) - .width(Length::Units(250)) - .height(Length::Units(250)) + .width(Length::Units(200)) + .height(Length::Units(200)) .style(button::Style::TransparentBorder.into()) .on_press( Message::DefineDescriptor( @@ -225,7 +227,11 @@ pub fn define_descriptor<'a>( .spacing(10); let col_recovery_keys = Column::new() - .push(text("Recovery path:").bold()) + .push( + Row::new() + .push(Space::with_width(Length::Units(50))) + .push(text("Recovery path:").bold()), + ) .push(separation().width(Length::Fill)) .push( Container::new( @@ -253,13 +259,13 @@ pub fn define_descriptor<'a>( .push( Button::new( Container::new(icon::plus_icon().size(50)) - .width(Length::Units(250)) - .height(Length::Units(250)) + .width(Length::Units(200)) + .height(Length::Units(200)) .align_y(alignment::Vertical::Center) .align_x(alignment::Horizontal::Center), ) - .width(Length::Units(250)) - .height(Length::Units(250)) + .width(Length::Units(200)) + .height(Length::Units(200)) .style(button::Style::TransparentBorder.into()) .on_press( Message::DefineDescriptor( @@ -292,7 +298,7 @@ pub fn define_descriptor<'a>( ) .push( Container::new( - form::Form::new("Number of block", sequence, |msg| { + form::Form::new("Number of blocks", sequence, |msg| { Message::DefineDescriptor( message::DefineDescriptor::SequenceEdited(msg), ) @@ -313,6 +319,7 @@ pub fn define_descriptor<'a>( layout( progress, Column::new() + .push(Space::with_height(Length::Units(50))) .push(text("Create the wallet").bold().size(50)) .push( Column::new() @@ -330,9 +337,9 @@ pub fn define_descriptor<'a>( .on_press(Message::Next) }) .push_maybe(error.map(|e| card::error("Failed to create descriptor", e.to_string()))) + .push(Space::with_height(Length::Units(20))) .width(Length::Fill) .height(Length::Fill) - .padding(100) .spacing(50) .align_items(Alignment::Center), ) @@ -451,10 +458,8 @@ pub fn participate_xpub<'a>( .spacing(10) .align_items(Alignment::Center) .push( - Container::new( - text(format!("Select your hardware wallet:")).bold(), - ) - .width(Length::Fill), + Container::new(text("Select your hardware wallet:").bold()) + .width(Length::Fill), ) .push( button::border(Some(icon::reload_icon()), "Refresh") @@ -849,7 +854,7 @@ pub fn install<'a>( layout(progress, col) } -pub fn undefined_descriptor_key<'a>() -> Element<'a, message::DefineKey> { +pub fn undefined_descriptor_key(name: &str) -> Element { card::simple( Column::new() .width(Length::Fill) @@ -857,7 +862,6 @@ pub fn undefined_descriptor_key<'a>() -> Element<'a, message::DefineKey> { .push( Row::new() .align_items(Alignment::Center) - .push(icon::key_icon()) .push(Space::with_width(Length::Fill)) .push( Button::new(icon::cross_icon()) @@ -868,39 +872,41 @@ pub fn undefined_descriptor_key<'a>() -> Element<'a, message::DefineKey> { .push( Container::new( Column::new() - .spacing(5) + .spacing(15) + .align_items(Alignment::Center) .push( - button::border(Some(icon::import_icon()), "from text input") - .on_press(message::DefineKey::ImportFromClipboard), + Scrollable::new(text(name).bold()) + .horizontal_scroll(Properties::new().width(2).scroller_width(2)), ) - .push( - button::border(Some(icon::chip_icon()), "from hardware") - .on_press(message::DefineKey::ImportFromHardware), - ), + .push(icon::circle_check_icon().style(color::FOREGROUND).size(50)), ) .height(Length::Fill) .align_y(alignment::Vertical::Center), - ), + ) + .push( + button::border(Some(icon::pencil_icon()), "Edit") + .on_press(message::DefineKey::Edit), + ) + .push(Space::with_height(Length::Units(5))), ) .padding(5) - .height(Length::Units(250)) - .width(Length::Units(250)) + .height(Length::Units(200)) + .width(Length::Units(200)) .into() } -pub fn defined_descriptor_key<'a>( - key: String, +pub fn defined_descriptor_key( + name: &str, valid: bool, - duplicate: bool, -) -> Element<'a, message::DefineKey> { + duplicate_key: bool, + duplicate_name: bool, +) -> Element { let col = Column::new() - .spacing(40) .width(Length::Fill) .align_items(Alignment::Center) .push( Row::new() .align_items(Alignment::Center) - .push(icon::key_icon()) .push(Space::with_width(Length::Fill)) .push( Button::new(icon::cross_icon()) @@ -914,18 +920,28 @@ pub fn defined_descriptor_key<'a>( .spacing(5) .push( Container::new( - Scrollable::new(Container::new(text(key.clone()))) - .height(Length::Units(50)) - .horizontal_scroll(Properties::new().width(2).scroller_width(2)), + Column::new() + .spacing(15) + .align_items(Alignment::Center) + .push( + Scrollable::new(text(name).bold()).horizontal_scroll( + Properties::new().width(2).scroller_width(2), + ), + ) + .push( + icon::circle_check_icon() + .style(color::SUCCESS) + .size(40) + .width(Length::Units(50)), + ), ) - .width(Length::Fill) - .height(Length::Fill), + .height(Length::Fill) + .align_y(alignment::Vertical::Center), ) - .push( - button::transparent_border(Some(icon::clipboard_icon()), "Copy") - .on_press(message::DefineKey::Clipboard(key)), - ), - ); + .height(Length::Fill), + ) + .push(button::border(Some(icon::pencil_icon()), "Edit").on_press(message::DefineKey::Edit)) + .push(Space::with_height(Length::Units(5))); if !valid { Column::new() @@ -933,8 +949,8 @@ pub fn defined_descriptor_key<'a>( .push( card::invalid(col) .padding(5) - .height(Length::Units(250)) - .width(Length::Units(250)), + .height(Length::Units(200)) + .width(Length::Units(200)), ) .push( text("Key is for a different network") @@ -942,134 +958,164 @@ pub fn defined_descriptor_key<'a>( .style(color::ALERT), ) .into() - } else if duplicate { + } else if duplicate_key { Column::new() .align_items(Alignment::Center) .push( card::invalid(col) .padding(5) - .height(Length::Units(250)) - .width(Length::Units(250)), + .height(Length::Units(200)) + .width(Length::Units(200)), ) .push(text("Key is a duplicate").small().style(color::ALERT)) .into() + } else if duplicate_name { + Column::new() + .align_items(Alignment::Center) + .push( + card::invalid(col) + .padding(5) + .height(Length::Units(200)) + .width(Length::Units(200)), + ) + .push(text("Name is a duplicate").small().style(color::ALERT)) + .into() } else { card::simple(col) .padding(5) - .height(Length::Units(250)) - .width(Length::Units(250)) + .height(Length::Units(200)) + .width(Length::Units(200)) .into() } } -pub fn hardware_wallet_xpubs_modal<'a>( - is_heir: bool, +pub fn edit_key_modal<'a>( + network: bitcoin::Network, hws: &[HardwareWallet], error: Option<&Error>, processing: bool, chosen_hw: Option, + form_xpub: &form::Value, + form_name: &form::Value, ) -> Element<'a, Message> { - card::simple( - Column::new() - .spacing(20) - .push( - text(if is_heir { - "Import the recovery public key:" - } else { - "Import the user public key:" - }) - .bold(), - ) - .push(separation().width(Length::Fill)) - .push_maybe(error.map(|e| card::error("Failed to import xpub", e.to_string()))) - .push(if !hws.is_empty() { - Column::new() - .push( + Column::new() + .push_maybe(error.map(|e| card::error("Failed to import xpub", e.to_string()))) + .push(card::simple( + Column::new() + .spacing(25) + .push( + Container::new( Row::new() - .spacing(10) - .align_items(Alignment::Center) - .push( - Container::new( - text(format!("{} hardware wallets connected", hws.len())) - .bold(), - ) - .width(Length::Fill), - ) - .push( - button::border(Some(icon::reload_icon()), "Refresh") - .on_press(Message::Reload), - ), + .spacing(5) + .push(icon::pencil_icon()) + .push(text("Edit")), ) - .spacing(10) - .push( - hws.iter() - .enumerate() - .fold(Column::new().spacing(10), |col, (i, hw)| { + .width(Length::Fill) + .align_x(alignment::Horizontal::Center), + ) + .push( + Column::new() + .spacing(5) + .push(text("Edit name:").bold()) + .push( + form::Form::new("Name", form_name, |msg| { + Message::DefineDescriptor(message::DefineDescriptor::NameEdited( + msg, + )) + }) + .warning("Please enter correct name") + .size(20) + .padding(10), + ), + ) + .push( + Column::new() + .spacing(5) + .push(text("Enter an extended public key:").bold()) + .push( + Row::new() + .push( + form::Form::new("Extended public key", form_xpub, |msg| { + Message::DefineDescriptor( + message::DefineDescriptor::XPubEdited(msg), + ) + }) + .warning(if network == bitcoin::Network::Bitcoin { + "Please enter correct xpub" + } else { + "Please enter correct tpub" + }) + .size(20) + .padding(10), + ) + .spacing(10) + .push(Container::new(text("/<0;1>/*")).padding(5)), + ), + ) + .push(if !hws.is_empty() { + Column::new() + .push( + Row::new() + .spacing(10) + .align_items(Alignment::Center) + .push( + Container::new(text("Or select a hardware wallet:").bold()) + .width(Length::Fill), + ) + .push( + button::border(Some(icon::reload_icon()), "Refresh") + .on_press(Message::Reload), + ), + ) + .spacing(10) + .push(hws.iter().enumerate().fold( + Column::new().spacing(10), + |col, (i, hw)| { col.push(hw_list_view( i, hw, Some(i) == chosen_hw, processing, - false, + !processing + && Some(i) == chosen_hw + && form_xpub.valid + && !form_xpub.value.is_empty(), )) - }), - ) - .width(Length::Fill) - } else { - Column::new() - .push( - Column::new() - .spacing(15) - .width(Length::Fill) - .push("Please connect a hardware wallet") - .push(button::border(None, "Refresh").on_press(Message::Reload)) - .align_items(Alignment::Center), - ) - .width(Length::Fill) - }) - .width(Length::Units(600)), - ) - .into() -} -pub fn clipboard_xpub_modal<'a>( - form_xpub: &form::Value, - network: bitcoin::Network, -) -> Element<'a, Message> { - card::simple( - Column::new() - .spacing(10) - .push(text("Input extended public key:").bold()) - .push( - Row::new() - .push( - form::Form::new("Extended public key", form_xpub, |msg| { - Message::DefineDescriptor(message::DefineDescriptor::XPubEdited(msg)) - }) - .warning(if network == bitcoin::Network::Bitcoin { - "Please enter correct xpub" - } else { - "Please enter correct tpub" - }) - .size(20) - .padding(10), - ) - .spacing(10) - .push(Container::new(text("/<0;1>/*")).padding(5)), - ) - .push( - Row::new() - .push(Space::with_width(Length::Fill)) - .push(if form_xpub.valid { - button::primary(None, "Apply").on_press(Message::DefineDescriptor( - message::DefineDescriptor::ConfirmXpub, + }, )) - } else { + .width(Length::Fill) + } else { + Column::new() + .push( + Row::new() + .spacing(15) + .width(Length::Fill) + .push( + text("Or connect a hardware wallet") + .bold() + .width(Length::Fill), + ) + .push(button::border(None, "Refresh").on_press(Message::Reload)) + .align_items(Alignment::Center), + ) + .width(Length::Fill) + }) + .push( + if form_xpub.valid && !form_xpub.value.is_empty() && !form_name.value.is_empty() + { button::primary(None, "Apply") - }), - ), - ) - .width(Length::Units(600)) - .into() + .on_press(Message::DefineDescriptor( + message::DefineDescriptor::ConfirmXpub, + )) + .width(Length::Units(200)) + } else { + button::primary(None, "Apply").width(Length::Units(100)) + }, + ) + .align_items(Alignment::Center), + )) + .width(Length::Units(600)) + .into() } fn hw_list_view<'a>( @@ -1217,18 +1263,17 @@ mod threshsold_input { }; Column::new() - .height(Length::Units(250)) - .width(Length::Units(200)) - .push(button(icon::up_icon().size(50), Event::IncrementPressed)) + .height(Length::Units(200)) + .width(Length::Units(100)) + .push(button(icon::up_icon().size(40), Event::IncrementPressed)) .push(text("Threshold:").small().bold()) .push( Container::new(text(format!("{}/{}", self.value, self.max)).size(50)) .height(Length::Fill) .align_y(alignment::Vertical::Center), ) - .push(button(icon::down_icon().size(50), Event::DecrementPressed)) + .push(button(icon::down_icon().size(40), Event::DecrementPressed)) .align_items(Alignment::Center) - .spacing(10) .into() } } From bf1e9e4b808a7c275766975467e637b883524236 Mon Sep 17 00:00:00 2001 From: edouard Date: Wed, 25 Jan 2023 16:07:59 +0100 Subject: [PATCH 7/9] gui: new module settings --- gui/src/app/cache.rs | 1 + gui/src/app/config.rs | 16 +- gui/src/app/mod.rs | 14 +- gui/src/app/settings.rs | 75 ++++++++ gui/src/app/state/mod.rs | 4 +- gui/src/app/state/recovery.rs | 20 +-- gui/src/app/state/spend/detail.rs | 32 ++-- gui/src/app/state/spend/mod.rs | 22 +-- gui/src/app/state/spend/step.rs | 10 +- gui/src/app/wallet.rs | 38 +++- gui/src/installer/config.rs | 2 +- gui/src/installer/context.rs | 87 +++++++++ gui/src/installer/message.rs | 1 + gui/src/installer/mod.rs | 89 +++++---- gui/src/installer/prompt.rs | 2 + gui/src/installer/step/descriptor.rs | 260 +++++++++++++++++++++------ gui/src/installer/step/mod.rs | 37 +--- gui/src/installer/view.rs | 158 +++++++++------- gui/src/loader.rs | 87 +++++++-- gui/src/main.rs | 14 +- 20 files changed, 650 insertions(+), 319 deletions(-) create mode 100644 gui/src/app/settings.rs create mode 100644 gui/src/installer/context.rs diff --git a/gui/src/app/cache.rs b/gui/src/app/cache.rs index e75429fc..4c13ed10 100644 --- a/gui/src/app/cache.rs +++ b/gui/src/app/cache.rs @@ -1,6 +1,7 @@ use crate::daemon::model::{Coin, SpendTx}; use liana::miniscript::bitcoin::Network; +#[derive(Debug)] pub struct Cache { pub network: Network, pub blockheight: i32, diff --git a/gui/src/app/config.rs b/gui/src/app/config.rs index 8cf30fc1..459de2d7 100644 --- a/gui/src/app/config.rs +++ b/gui/src/app/config.rs @@ -11,19 +11,19 @@ pub struct Config { /// Use iced debug feature if true. pub debug: Option, /// hardware wallets config. - #[serde(default)] - pub hardware_wallets: Vec, + /// LEGACY: Use Settings module instead. + pub hardware_wallets: Option>, } pub const DEFAULT_FILE_NAME: &str = "gui.toml"; impl Config { - pub fn new(daemon_config_path: PathBuf, hardware_wallets: Vec) -> Self { + pub fn new(daemon_config_path: PathBuf) -> Self { Self { daemon_config_path, log_level: None, debug: None, - hardware_wallets, + hardware_wallets: None, } } @@ -40,14 +40,6 @@ impl Config { })?; Ok(config) } - - pub fn default_path() -> Result { - let mut datadir = default_datadir().map_err(|_| { - ConfigError::Unexpected("Could not locate the default datadir directory.".to_owned()) - })?; - datadir.push(DEFAULT_FILE_NAME); - Ok(datadir) - } } #[derive(PartialEq, Eq, Debug, Clone)] diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 17b08751..acab0d48 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -2,6 +2,7 @@ pub mod cache; pub mod config; pub mod menu; pub mod message; +pub mod settings; pub mod state; pub mod view; pub mod wallet; @@ -31,14 +32,14 @@ pub struct App { state: Box, cache: Cache, config: Config, - wallet: Wallet, + wallet: Arc, daemon: Arc, } impl App { pub fn new( cache: Cache, - wallet: Wallet, + wallet: Arc, config: Config, daemon: Arc, ) -> (App, Command) { @@ -72,22 +73,15 @@ impl App { .into(), menu::Menu::Recovery => RecoveryPanel::new( self.wallet.clone(), - self.config.clone(), &self.cache.coins, self.wallet.main_descriptor.timelock_value(), self.cache.blockheight as u32, ) .into(), menu::Menu::Receive => ReceivePanel::default().into(), - menu::Menu::Spend => SpendPanel::new( - self.wallet.clone(), - self.config.clone(), - &self.cache.spend_txs, - ) - .into(), + menu::Menu::Spend => SpendPanel::new(self.wallet.clone(), &self.cache.spend_txs).into(), menu::Menu::CreateSpendTx => CreateSpendPanel::new( self.wallet.clone(), - self.config.clone(), &self.cache.coins, self.cache.blockheight as u32, ) diff --git a/gui/src/app/settings.rs b/gui/src/app/settings.rs new file mode 100644 index 00000000..3f917c92 --- /dev/null +++ b/gui/src/app/settings.rs @@ -0,0 +1,75 @@ +use std::collections::HashMap; +use std::path::Path; + +use liana::miniscript::bitcoin::util::bip32::Fingerprint; +use serde::{Deserialize, Serialize}; + +use crate::hw::HardwareWalletConfig; + +///! Settings is the module to handle the GUI settings file. +///! The settings file is used by the GUI to store useful information. +pub const DEFAULT_FILE_NAME: &str = "settings.json"; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Settings { + pub wallets: Vec, +} + +impl Settings { + pub fn from_file(path: &Path) -> Result { + let config = std::fs::read(path) + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => SettingsError::NotFound, + _ => SettingsError::ReadingFile(format!("Reading settings file: {}", e)), + }) + .and_then(|file_content| { + serde_json::from_slice::(&file_content).map_err(|e| { + SettingsError::ReadingFile(format!("Parsing settings file: {}", e)) + }) + })?; + Ok(config) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WalletSetting { + pub name: String, + pub descriptor_checksum: String, + #[serde(default)] + pub keys: Vec, + #[serde(default)] + pub hardware_wallets: Vec, +} + +impl WalletSetting { + pub fn keys_aliases(&self) -> HashMap { + let mut map = HashMap::new(); + for key in self.keys.clone() { + map.insert(key.master_fingerprint, key.name); + } + map + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct KeySetting { + pub name: String, + pub master_fingerprint: Fingerprint, +} + +#[derive(PartialEq, Eq, Debug, Clone)] +pub enum SettingsError { + NotFound, + ReadingFile(String), + Unexpected(String), +} + +impl std::fmt::Display for SettingsError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::NotFound => write!(f, "Settings file not found"), + Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e), + Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), + } + } +} diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 1c3e648f..a6fc78dc 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -39,7 +39,7 @@ pub trait State { } pub struct Home { - wallet: Wallet, + wallet: Arc, balance: Amount, recovery_warning: Option<(Amount, usize)>, recovery_alert: Option<(Amount, usize)>, @@ -50,7 +50,7 @@ pub struct Home { } impl Home { - pub fn new(wallet: Wallet, coins: &[Coin]) -> Self { + pub fn new(wallet: Arc, coins: &[Coin]) -> Self { Self { wallet, balance: Amount::from_sat( diff --git a/gui/src/app/state/recovery.rs b/gui/src/app/state/recovery.rs index 4933878d..ecf35c26 100644 --- a/gui/src/app/state/recovery.rs +++ b/gui/src/app/state/recovery.rs @@ -6,7 +6,6 @@ use iced::{Command, Element}; use crate::{ app::{ cache::Cache, - config::Config, error::Error, menu::Menu, message::Message, @@ -25,8 +24,7 @@ use crate::{ use liana::miniscript::bitcoin::{Address, Amount, Network}; pub struct RecoveryPanel { - wallet: Wallet, - config: Config, + wallet: Arc, locked_coins: (usize, Amount), recoverable_coins: (usize, Amount), warning: Option, @@ -38,13 +36,7 @@ pub struct RecoveryPanel { } impl RecoveryPanel { - pub fn new( - wallet: Wallet, - config: Config, - coins: &[Coin], - timelock: u32, - blockheight: u32, - ) -> Self { + pub fn new(wallet: Arc, coins: &[Coin], timelock: u32, blockheight: u32) -> Self { let mut locked_coins = (0, Amount::from_sat(0)); let mut recoverable_coins = (0, Amount::from_sat(0)); for coin in coins { @@ -61,7 +53,6 @@ impl RecoveryPanel { } Self { wallet, - config, locked_coins, recoverable_coins, warning: None, @@ -123,12 +114,7 @@ impl State for RecoveryPanel { }, Message::Recovery(res) => match res { Ok(tx) => { - self.generated = Some(detail::SpendTxState::new( - self.wallet.clone(), - self.config.clone(), - tx, - false, - )) + self.generated = Some(detail::SpendTxState::new(self.wallet.clone(), tx, false)) } Err(e) => self.warning = Some(e), }, diff --git a/gui/src/app/state/spend/detail.rs b/gui/src/app/state/spend/detail.rs index c6a34d64..6dd2f21c 100644 --- a/gui/src/app/state/spend/detail.rs +++ b/gui/src/app/state/spend/detail.rs @@ -8,8 +8,7 @@ use liana::miniscript::bitcoin::{ use crate::{ app::{ - cache::Cache, config::Config, error::Error, message::Message, view, view::spend::detail, - wallet::Wallet, + cache::Cache, error::Error, message::Message, view, view::spend::detail, wallet::Wallet, }, daemon::{ model::{SpendStatus, SpendTx}, @@ -39,19 +38,17 @@ trait Action { } pub struct SpendTxState { - wallet: Wallet, - config: Config, + wallet: Arc, tx: SpendTx, saved: bool, action: Option>, } impl SpendTxState { - pub fn new(wallet: Wallet, config: Config, tx: SpendTx, saved: bool) -> Self { + pub fn new(wallet: Arc, tx: SpendTx, saved: bool) -> Self { Self { wallet, action: None, - config, tx, saved, } @@ -80,7 +77,7 @@ impl SpendTxState { self.action = Some(Box::new(DeleteAction::default())); } view::SpendTxMessage::Sign => { - let action = SignAction::new(self.config.clone()); + let action = SignAction::new(); let cmd = action.load(&self.wallet, daemon); self.action = Some(Box::new(action)); return cmd; @@ -252,7 +249,6 @@ impl Action for DeleteAction { } pub struct SignAction { - config: Config, chosen_hw: Option, processing: bool, hws: Vec, @@ -261,9 +257,8 @@ pub struct SignAction { } impl SignAction { - pub fn new(config: Config) -> Self { + pub fn new() -> Self { Self { - config, chosen_hw: None, processing: false, hws: Vec::new(), @@ -279,13 +274,8 @@ impl Action for SignAction { } fn load(&self, wallet: &Wallet, _daemon: Arc) -> Command { - let config = self.config.clone(); - let desc = wallet.main_descriptor.to_string(); - let name = wallet.name.clone(); - Command::perform( - list_hws(config, name, desc), - Message::ConnectedHardwareWallets, - ) + let wallet = wallet.clone(); + Command::perform(list_hws(wallet), Message::ConnectedHardwareWallets) } fn update( &mut self, @@ -350,8 +340,12 @@ impl Action for SignAction { } } -async fn list_hws(config: Config, wallet_name: String, descriptor: String) -> Vec { - list_hardware_wallets(&config.hardware_wallets, Some((&wallet_name, &descriptor))).await +async fn list_hws(wallet: Wallet) -> Vec { + list_hardware_wallets( + &wallet.hardware_wallets, + Some((&wallet.name, &wallet.main_descriptor.to_string())), + ) + .await } async fn sign_psbt( diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index 84dad9d0..230167f5 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -8,10 +8,7 @@ use liana::miniscript::bitcoin::{consensus, util::psbt::Psbt}; use super::{redirect, State}; use crate::{ - app::{ - cache::Cache, config::Config, error::Error, menu::Menu, message::Message, view, - wallet::Wallet, - }, + app::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet}, daemon::{ model::{Coin, SpendTx}, Daemon, @@ -20,8 +17,7 @@ use crate::{ }; pub struct SpendPanel { - wallet: Wallet, - config: Config, + wallet: Arc, selected_tx: Option, spend_txs: Vec, warning: Option, @@ -29,10 +25,9 @@ pub struct SpendPanel { } impl SpendPanel { - pub fn new(wallet: Wallet, config: Config, spend_txs: &[SpendTx]) -> Self { + pub fn new(wallet: Arc, spend_txs: &[SpendTx]) -> Self { Self { wallet, - config, spend_txs: spend_txs.to_vec(), warning: None, selected_tx: None, @@ -97,12 +92,7 @@ impl State for SpendPanel { } Message::View(view::Message::Select(i)) => { if let Some(tx) = self.spend_txs.get(i) { - let tx = detail::SpendTxState::new( - self.wallet.clone(), - self.config.clone(), - tx.clone(), - true, - ); + let tx = detail::SpendTxState::new(self.wallet.clone(), tx.clone(), true); let cmd = tx.load(daemon); self.selected_tx = Some(tx); return cmd; @@ -143,7 +133,7 @@ pub struct CreateSpendPanel { } impl CreateSpendPanel { - pub fn new(wallet: Wallet, config: Config, coins: &[Coin], blockheight: u32) -> Self { + pub fn new(wallet: Arc, coins: &[Coin], blockheight: u32) -> Self { let descriptor = wallet.main_descriptor.clone(); let timelock = descriptor.timelock_value(); Self { @@ -157,7 +147,7 @@ impl CreateSpendPanel { timelock, blockheight, )), - Box::new(step::SaveSpend::new(wallet, config)), + Box::new(step::SaveSpend::new(wallet)), ], } } diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index 77b2529d..0280b990 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -12,8 +12,7 @@ use liana::{ use crate::{ app::{ - cache::Cache, config::Config, error::Error, message::Message, state::spend::detail, view, - wallet::Wallet, + cache::Cache, error::Error, message::Message, state::spend::detail, view, wallet::Wallet, }, daemon::{ model::{remaining_sequence, Coin, SpendTx}, @@ -430,16 +429,14 @@ impl Step for ChooseCoins { } pub struct SaveSpend { - wallet: Wallet, - config: Config, + wallet: Arc, spend: Option, } impl SaveSpend { - pub fn new(wallet: Wallet, config: Config) -> Self { + pub fn new(wallet: Arc) -> Self { Self { wallet, - config, spend: None, } } @@ -455,7 +452,6 @@ impl Step for SaveSpend { .unwrap(); self.spend = Some(detail::SpendTxState::new( self.wallet.clone(), - self.config.clone(), SpendTx::new(psbt, draft.inputs.clone(), sigs), false, )); diff --git a/gui/src/app/wallet.rs b/gui/src/app/wallet.rs index 7fd91089..6e8bd4a2 100644 --- a/gui/src/app/wallet.rs +++ b/gui/src/app/wallet.rs @@ -1,16 +1,46 @@ -use liana::descriptors::MultipathDescriptor; +use std::collections::HashMap; -#[derive(Clone)] +use crate::hw::HardwareWalletConfig; + +use liana::descriptors::MultipathDescriptor; +use liana::miniscript::bitcoin::util::bip32::Fingerprint; + +pub const DEFAULT_WALLET_NAME: &str = "Liana"; + +#[derive(Debug, Clone)] pub struct Wallet { pub name: String, pub main_descriptor: MultipathDescriptor, + pub keys_aliases: HashMap, + pub hardware_wallets: Vec, } impl Wallet { - pub fn new(main_descriptor: MultipathDescriptor) -> Self { + pub fn new(name: String, main_descriptor: MultipathDescriptor) -> Self { Self { - name: "Liana".to_string(), + name, main_descriptor, + keys_aliases: HashMap::new(), + hardware_wallets: Vec::new(), } } + + pub fn legacy(main_descriptor: MultipathDescriptor) -> Self { + Self { + name: DEFAULT_WALLET_NAME.to_string(), + main_descriptor, + keys_aliases: HashMap::new(), + hardware_wallets: Vec::new(), + } + } + + pub fn with_key_aliases(mut self, aliases: HashMap) -> Self { + self.keys_aliases = aliases; + self + } + + pub fn with_harware_wallets(mut self, hardware_wallets: Vec) -> Self { + self.hardware_wallets = hardware_wallets; + self + } } diff --git a/gui/src/installer/config.rs b/gui/src/installer/config.rs index 696ab269..a4dd53af 100644 --- a/gui/src/installer/config.rs +++ b/gui/src/installer/config.rs @@ -2,7 +2,7 @@ use std::convert::TryFrom; use liana::config::Config as LianaConfig; -use super::step::Context; +use super::Context; pub const DEFAULT_FILE_NAME: &str = "daemon.toml"; diff --git a/gui/src/installer/context.rs b/gui/src/installer/context.rs new file mode 100644 index 00000000..91b0d687 --- /dev/null +++ b/gui/src/installer/context.rs @@ -0,0 +1,87 @@ +use std::path::PathBuf; +use std::time::Duration; + +use crate::{ + app::{ + settings::{KeySetting, Settings, WalletSetting}, + wallet::DEFAULT_WALLET_NAME, + }, + hw::HardwareWalletConfig, +}; +use async_hwi::DeviceKind; +use liana::{ + config::Config, + config::{BitcoinConfig, BitcoindConfig}, + descriptors::MultipathDescriptor, + miniscript::bitcoin, +}; + +#[derive(Clone)] +pub struct Context { + pub bitcoin_config: BitcoinConfig, + pub bitcoind_config: Option, + pub descriptor: Option, + pub keys: Vec, + pub hws: Vec<( + DeviceKind, + bitcoin::util::bip32::Fingerprint, + Option<[u8; 32]>, + )>, + pub data_dir: PathBuf, +} + +impl Context { + pub fn new(network: bitcoin::Network, data_dir: PathBuf) -> Self { + Self { + bitcoin_config: BitcoinConfig { + network, + poll_interval_secs: Duration::from_secs(30), + }, + hws: Vec::new(), + keys: Vec::new(), + bitcoind_config: None, + descriptor: None, + data_dir, + } + } + + pub fn extract_gui_settings(&self) -> Settings { + let hardware_wallets = self + .hws + .iter() + .filter_map(|(kind, fingerprint, token)| { + token + .as_ref() + .map(|token| HardwareWalletConfig::new(kind, fingerprint, token)) + }) + .collect(); + Settings { + wallets: vec![WalletSetting { + name: DEFAULT_WALLET_NAME.to_string(), + descriptor_checksum: self + .descriptor + .as_ref() + .unwrap() + .to_string() + .split_once('#') + .map(|(_, checksum)| checksum) + .unwrap() + .to_string(), + keys: self.keys.clone(), + hardware_wallets, + }], + } + } + + pub fn extract_daemon_config(&self) -> Config { + Config { + #[cfg(unix)] + daemon: false, + log_level: log::LevelFilter::Info, + main_descriptor: self.descriptor.clone().unwrap(), + data_dir: Some(self.data_dir.clone()), + bitcoin_config: self.bitcoin_config.clone(), + bitcoind_config: self.bitcoind_config.clone(), + } + } +} diff --git a/gui/src/installer/message.rs b/gui/src/installer/message.rs index 2517ea5c..390c4a5e 100644 --- a/gui/src/installer/message.rs +++ b/gui/src/installer/message.rs @@ -44,6 +44,7 @@ pub enum DefineDescriptor { Key(bool, usize, DefineKey), HWXpubImported(Result), XPubEdited(String), + EditName, NameEdited(String), SequenceEdited(String), ThresholdEdited(bool, usize), diff --git a/gui/src/installer/mod.rs b/gui/src/installer/mod.rs index 0c0df440..dc87d294 100644 --- a/gui/src/installer/mod.rs +++ b/gui/src/installer/mod.rs @@ -1,4 +1,4 @@ -mod config; +mod context; mod message; mod prompt; mod step; @@ -7,18 +7,16 @@ mod view; use iced::{clipboard, Command, Element, Subscription}; use liana::miniscript::bitcoin; -use std::convert::TryInto; +use context::Context; use std::io::Write; use std::path::PathBuf; -use crate::{ - app::config as gui_config, hw::HardwareWalletConfig, installer::config::DEFAULT_FILE_NAME, -}; +use crate::app::{config as gui_config, settings as gui_settings}; pub use message::Message; use step::{ - BackupDescriptor, Context, DefineBitcoind, DefineDescriptor, Final, ImportDescriptor, - ParticipateXpub, RegisterDescriptor, Step, Welcome, + BackupDescriptor, DefineBitcoind, DefineDescriptor, Final, ImportDescriptor, ParticipateXpub, + RegisterDescriptor, Step, Welcome, }; pub struct Installer { @@ -164,19 +162,7 @@ impl Installer { } pub async fn install(ctx: Context) -> Result { - let hardware_wallets = ctx - .hws - .iter() - .filter_map(|(kind, fingerprint, token)| { - token - .as_ref() - .map(|token| HardwareWalletConfig::new(kind, fingerprint, token)) - }) - .collect(); - - let mut cfg: liana::config::Config = ctx - .try_into() - .expect("Everything should be checked at this point"); + let mut cfg: liana::config::Config = ctx.extract_daemon_config(); // Start Daemon to check correctness of installation let daemon = liana::DaemonHandle::start_default(cfg.clone()).map_err(|e| { Error::Unexpected(format!("Failed to start daemon with entered config: {}", e)) @@ -191,42 +177,55 @@ pub async fn install(ctx: Context) -> Result { let mut datadir_path = cfg.data_dir.clone().unwrap(); datadir_path.push(cfg.bitcoin_config.network.to_string()); - // create lianad configuration file - let mut daemon_config_path = datadir_path.clone(); - daemon_config_path.push(DEFAULT_FILE_NAME); - let mut daemon_config_file = std::fs::File::create(&daemon_config_path) - .map_err(|e| Error::CannotCreateFile(e.to_string()))?; - // Step needed because of ValueAfterTable error in the toml serialize implementation. let daemon_config = toml::Value::try_from(&cfg).expect("daemon::Config has a proper Serialize implementation"); - daemon_config_file - .write_all(daemon_config.to_string().as_bytes()) - .map_err(|e| Error::CannotWriteToFile(e.to_string()))?; + // create lianad configuration file + let daemon_config_path = create_and_write_file( + datadir_path.clone(), + "daemon.toml", + daemon_config.to_string().as_bytes(), + )?; // create liana GUI configuration file - let mut gui_config_path = datadir_path; - gui_config_path.push(gui_config::DEFAULT_FILE_NAME); - let mut gui_config_file = std::fs::File::create(&gui_config_path) - .map_err(|e| Error::CannotCreateFile(e.to_string()))?; + let gui_config_path = create_and_write_file( + datadir_path.clone(), + gui_config::DEFAULT_FILE_NAME, + toml::to_string(&gui_config::Config::new( + daemon_config_path.canonicalize().map_err(|e| { + Error::Unexpected(format!("Failed to canonicalize daemon config path: {}", e)) + })?, + )) + .unwrap() + .as_bytes(), + )?; - gui_config_file - .write_all( - toml::to_string(&gui_config::Config::new( - daemon_config_path.canonicalize().map_err(|e| { - Error::Unexpected(format!("Failed to canonicalize daemon config path: {}", e)) - })?, - hardware_wallets, - )) - .unwrap() - .as_bytes(), - ) - .map_err(|e| Error::CannotWriteToFile(e.to_string()))?; + // create liana GUI settings file + let settings: gui_settings::Settings = ctx.extract_gui_settings(); + create_and_write_file( + datadir_path, + gui_settings::DEFAULT_FILE_NAME, + serde_json::to_string_pretty(&settings).unwrap().as_bytes(), + )?; Ok(gui_config_path) } +pub fn create_and_write_file( + mut network_datadir: PathBuf, + file_name: &str, + data: &[u8], +) -> Result { + network_datadir.push(file_name); + let path = network_datadir; + let mut file = + std::fs::File::create(&path).map_err(|e| Error::CannotCreateFile(e.to_string()))?; + file.write_all(data) + .map_err(|e| Error::CannotWriteToFile(e.to_string()))?; + Ok(path) +} + #[derive(Debug, Clone)] pub enum Error { CannotCreateDatadir(String), diff --git a/gui/src/installer/prompt.rs b/gui/src/installer/prompt.rs index c7dbf98f..7d91ea48 100644 --- a/gui/src/installer/prompt.rs +++ b/gui/src/installer/prompt.rs @@ -4,3 +4,5 @@ pub const DEFINE_DESCRIPTOR_PRIMATRY_PATH_TOOLTIP: &str = "This is the keys that can spend received coins immediately,\n with no time restriction."; pub const DEFINE_DESCRIPTOR_SEQUENCE_TOOLTIP: &str = "Number of blocks after a coin is received \nfor which the recovery path is not available"; +pub const DEFINE_DESCRIPTOR_FINGERPRINT_TOOLTIP: &str = + "The alias is applied on all the keys derived from the same seed"; diff --git a/gui/src/installer/step/descriptor.rs b/gui/src/installer/step/descriptor.rs index 5a4595e8..44777cb4 100644 --- a/gui/src/installer/step/descriptor.rs +++ b/gui/src/installer/step/descriptor.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::str::FromStr; @@ -7,7 +7,7 @@ use liana::{ descriptors::{LianaDescKeys, MultipathDescriptor}, miniscript::{ bitcoin::{ - util::bip32::{DerivationPath, Fingerprint}, + util::bip32::{ChildNumber, DerivationPath, Fingerprint}, Network, }, descriptor::{DerivPaths, DescriptorMultiXKey, DescriptorPublicKey, Wildcard}, @@ -15,6 +15,7 @@ use liana::{ }; use crate::{ + app::settings::KeySetting, hw::{list_hardware_wallets, HardwareWallet}, installer::{ message::{self, Message}, @@ -24,9 +25,6 @@ use crate::{ ui::component::{form, modal::Modal}, }; -const LIANA_STANDARD_PATH: &str = "m/48'/0'/0'/2'"; -const LIANA_TESTNET_STANDARD_PATH: &str = "m/48'/1'/0'/2'"; - pub trait DescriptorKeyModal { fn processing(&self) -> bool { false @@ -48,8 +46,6 @@ pub struct DefineDescriptor { sequence: form::Value, modal: Option>, - name_indexes: (usize, usize), - error: Option, } @@ -59,11 +55,10 @@ impl DefineDescriptor { network: Network::Bitcoin, data_dir: None, network_valid: true, - spending_keys: vec![DescriptorKey::new("Key 1".to_string())], + spending_keys: vec![DescriptorKey::default()], spending_threshold: 1, - recovery_keys: vec![DescriptorKey::new("Recovery key 1".to_string())], + recovery_keys: vec![DescriptorKey::default()], recovery_threshold: 1, - name_indexes: (1, 1), sequence: form::Value::default(), modal: None, error: None, @@ -79,18 +74,22 @@ impl DefineDescriptor { } // TODO: Improve algo + // Mark as duplicate every defined key that have the same name but not the same fingerprint. + // And every undefined_key that have a same name than an other key. fn check_for_duplicate(&mut self) { let mut all_keys = HashSet::new(); let mut duplicate_keys = HashSet::new(); - let mut all_names = HashSet::new(); + let mut all_names: HashMap = HashMap::new(); let mut duplicate_names = HashSet::new(); for spending_key in &self.spending_keys { - if all_names.contains(&spending_key.name) { - duplicate_names.insert(spending_key.name.clone()); - } else { - all_names.insert(spending_key.name.clone()); - } if let Some(key) = &spending_key.key { + if let Some(fg) = all_names.get(&spending_key.name) { + if fg != &key.master_fingerprint() { + duplicate_names.insert(spending_key.name.clone()); + } + } else { + all_names.insert(spending_key.name.clone(), key.master_fingerprint()); + } if all_keys.contains(key) { duplicate_keys.insert(key.clone()); } else { @@ -99,12 +98,14 @@ impl DefineDescriptor { } } for recovery_key in &self.recovery_keys { - if all_names.contains(&recovery_key.name) { - duplicate_names.insert(recovery_key.name.clone()); - } else { - all_names.insert(recovery_key.name.clone()); - } if let Some(key) = &recovery_key.key { + if let Some(fg) = all_names.get(&recovery_key.name) { + if fg != &key.master_fingerprint() { + duplicate_names.insert(recovery_key.name.clone()); + } + } else { + all_names.insert(recovery_key.name.clone(), key.master_fingerprint()); + } if all_keys.contains(key) { duplicate_keys.insert(key.clone()); } else { @@ -124,6 +125,69 @@ impl DefineDescriptor { } } } + + fn edit_alias_for_key_with_same_fingerprint(&mut self, name: String, fingerprint: Fingerprint) { + for spending_key in &mut self.spending_keys { + if spending_key.key.as_ref().map(|k| k.master_fingerprint()) == Some(fingerprint) { + spending_key.name = name.clone(); + } + } + for recovery_key in &mut self.recovery_keys { + if recovery_key.key.as_ref().map(|k| k.master_fingerprint()) == Some(fingerprint) { + recovery_key.name = name.clone(); + } + } + } + + /// Returns the maximum account index per key fingerprint + fn fingerprint_account_index_mappping(&self) -> HashMap { + let mut mapping = HashMap::new(); + let update_mapping = + |keys: &[DescriptorKey], mapping: &mut HashMap| { + for key in keys { + if let Some(DescriptorPublicKey::MultiXPub(key)) = key.key.as_ref() { + if let Some((fingerprint, derivation_path)) = key.origin.as_ref() { + let index = if derivation_path.len() >= 4 { + if derivation_path[0].to_string() == "48'" { + Some(derivation_path[2]) + } else { + None + } + } else { + None + }; + if let Some(index) = index { + if let Some(previous_index) = mapping.get(fingerprint) { + if index > *previous_index { + mapping.insert(*fingerprint, index); + } + } else { + mapping.insert(*fingerprint, index); + } + } + } + } + } + }; + update_mapping(&self.spending_keys, &mut mapping); + update_mapping(&self.recovery_keys, &mut mapping); + mapping + } + + fn keys_aliases(&self) -> HashMap { + let mut map = HashMap::new(); + for spending_key in &self.spending_keys { + if let Some(key) = spending_key.key.as_ref() { + map.insert(key.master_fingerprint(), spending_key.name.clone()); + } + } + for recovery_key in &self.recovery_keys { + if let Some(key) = recovery_key.key.as_ref() { + map.insert(key.master_fingerprint(), recovery_key.name.clone()); + } + } + map + } } impl Step for DefineDescriptor { @@ -164,16 +228,10 @@ impl Step for DefineDescriptor { } message::DefineDescriptor::AddKey(is_recovery) => { if is_recovery { - self.name_indexes.0 += 1; - self.recovery_keys.push(DescriptorKey::new(format!( - "Recovery key {}", - self.name_indexes.0, - ))); + self.recovery_keys.push(DescriptorKey::default()); self.recovery_threshold += 1; } else { - self.name_indexes.1 += 1; - self.spending_keys - .push(DescriptorKey::new(format!("Key {}", self.name_indexes.1,))); + self.spending_keys.push(DescriptorKey::default()); self.spending_threshold += 1; } } @@ -182,6 +240,10 @@ impl Step for DefineDescriptor { return Command::perform(async move { key }, Message::Clibpboard); } message::DefineKey::Edited(name, imported_key) => { + self.edit_alias_for_key_with_same_fingerprint( + name.clone(), + imported_key.master_fingerprint(), + ); if is_recovery { if let Some(recovery_key) = self.recovery_keys.get_mut(i) { recovery_key.name = name; @@ -207,8 +269,15 @@ impl Step for DefineDescriptor { k.to_string().trim_end_matches("/<0;1>/*").to_string() }) .unwrap_or_else(|| "".to_string()); - let modal = - EditXpubModal::new(name, key, i, is_recovery, self.network); + let modal = EditXpubModal::new( + name, + key, + i, + is_recovery, + self.network, + self.fingerprint_account_index_mappping(), + self.keys_aliases(), + ); let cmd = modal.load(); self.modal = Some(Box::new(modal)); return cmd; @@ -220,8 +289,15 @@ impl Step for DefineDescriptor { .as_ref() .map(|k| k.to_string().trim_end_matches("/<0;1>/*").to_string()) .unwrap_or_else(|| "".to_string()); - let modal = - EditXpubModal::new(name, key, i, is_recovery, self.network); + let modal = EditXpubModal::new( + name, + key, + i, + is_recovery, + self.network, + self.fingerprint_account_index_mappping(), + self.keys_aliases(), + ); let cmd = modal.load(); self.modal = Some(Box::new(modal)); return cmd; @@ -268,17 +344,36 @@ impl Step for DefineDescriptor { fn apply(&mut self, ctx: &mut Context) -> bool { ctx.bitcoin_config.network = self.network; - let spending_keys: Vec = self - .spending_keys - .iter() - .filter_map(|k| k.key.clone()) - .collect(); + ctx.keys = Vec::new(); + let mut spending_keys: Vec = Vec::new(); + for spending_key in self.spending_keys.iter().clone() { + if let Some(key) = spending_key.key.as_ref() { + if let DescriptorPublicKey::MultiXPub(xpub) = key { + if let Some((master_fingerprint, _)) = xpub.origin { + ctx.keys.push(KeySetting { + master_fingerprint, + name: spending_key.name.clone(), + }); + } + } + spending_keys.push(key.clone()); + } + } - let recovery_keys: Vec = self - .recovery_keys - .iter() - .filter_map(|k| k.key.clone()) - .collect(); + let mut recovery_keys: Vec = Vec::new(); + for recovery_key in self.recovery_keys.iter().clone() { + if let Some(key) = recovery_key.key.as_ref() { + if let DescriptorPublicKey::MultiXPub(xpub) = key { + if let Some((master_fingerprint, _)) = xpub.origin { + ctx.keys.push(KeySetting { + master_fingerprint, + name: recovery_key.name.clone(), + }); + } + } + recovery_keys.push(key.clone()); + } + } let sequence = self.sequence.value.parse::(); self.sequence.valid = sequence.is_ok(); @@ -378,17 +473,19 @@ pub struct DescriptorKey { pub duplicate_name: bool, } -impl DescriptorKey { - pub fn new(name: String) -> Self { +impl Default for DescriptorKey { + fn default() -> Self { Self { - name, + name: "".to_string(), valid: true, key: None, duplicate_key: false, duplicate_name: false, } } +} +impl DescriptorKey { pub fn check_network(&mut self, network: Network) { if let Some(key) = &self.key { self.valid = check_key_network(key, network); @@ -397,7 +494,7 @@ impl DescriptorKey { pub fn view(&self) -> Element { match &self.key { - None => view::undefined_descriptor_key(&self.name), + None => view::undefined_descriptor_key(), Some(_) => view::defined_descriptor_key( &self.name, self.valid, @@ -447,8 +544,12 @@ pub struct EditXpubModal { error: Option, processing: bool, + keys_aliases: HashMap, + account_indexes: HashMap, + form_name: form::Value, form_xpub: form::Value, + edit_name: bool, chosen_hw: Option, hws: Vec, @@ -461,6 +562,8 @@ impl EditXpubModal { key_index: usize, is_recovery: bool, network: Network, + account_indexes: HashMap, + keys_aliases: HashMap, ) -> Self { Self { form_name: form::Value { @@ -471,6 +574,8 @@ impl EditXpubModal { valid: true, value: key, }, + keys_aliases, + account_indexes, is_recovery, key_index, chosen_hw: None, @@ -478,6 +583,7 @@ impl EditXpubModal { hws: Vec::new(), error: None, network, + edit_name: false, } } fn load(&self) -> Command { @@ -500,8 +606,14 @@ impl DescriptorKeyModal for EditXpubModal { let device = hw.device.clone(); self.chosen_hw = Some(i); self.processing = true; + // If another account n exists, the key is retrieved for the account n+1 + let account_index = self + .account_indexes + .get(&hw.fingerprint) + .map(|account_index| account_index.increment().unwrap()) + .unwrap_or_else(|| ChildNumber::from_hardened_idx(0).unwrap()); return Command::perform( - get_extended_pubkey(device, hw.fingerprint, self.network), + get_extended_pubkey(device, hw.fingerprint, self.network, account_index), |res| { Message::DefineDescriptor(message::DefineDescriptor::HWXpubImported( res, @@ -520,6 +632,14 @@ impl DescriptorKeyModal for EditXpubModal { self.processing = false; match res { Ok(key) => { + if let Some(alias) = self.keys_aliases.get(&key.master_fingerprint()) { + self.form_name.valid = true; + self.form_name.value = alias.clone(); + self.edit_name = false; + } else { + self.edit_name = true; + } + self.form_xpub.valid = true; self.form_xpub.value = key.to_string().trim_end_matches("/<0;1>/*").to_string(); } @@ -528,13 +648,32 @@ impl DescriptorKeyModal for EditXpubModal { } } } + Message::DefineDescriptor(message::DefineDescriptor::EditName) => { + self.edit_name = true; + } Message::DefineDescriptor(message::DefineDescriptor::NameEdited(name)) => { self.form_name.valid = true; self.form_name.value = name; } Message::DefineDescriptor(message::DefineDescriptor::XPubEdited(s)) => { - self.form_xpub.valid = - DescriptorPublicKey::from_str(&format!("{}/<0;1>/*", s)).is_ok(); + if let Ok(DescriptorPublicKey::MultiXPub(key)) = + DescriptorPublicKey::from_str(&format!("{}/<0;1>/*", s)) + { + if let Some((fingerprint, _)) = key.origin { + self.form_xpub.valid = true; + if let Some(alias) = self.keys_aliases.get(&fingerprint) { + self.form_name.valid = true; + self.form_name.value = alias.clone(); + self.edit_name = false; + } else { + self.edit_name = true; + } + } else { + self.form_xpub.valid = false; + } + } else { + self.form_xpub.valid = false; + } self.form_xpub.value = s; } Message::DefineDescriptor(message::DefineDescriptor::ConfirmXpub) => { @@ -570,19 +709,25 @@ impl DescriptorKeyModal for EditXpubModal { self.chosen_hw, &self.form_xpub, &self.form_name, + self.edit_name, ) } } +/// LIANA_STANDARD_PATH: m/48'/0'/0'/2'; +/// LIANA_TESTNET_STANDARD_PATH: m/48'/1'/0'/2'; async fn get_extended_pubkey( hw: std::sync::Arc, fingerprint: Fingerprint, network: Network, + account_index: ChildNumber, ) -> Result { - let derivation_path = DerivationPath::from_str(if network == Network::Bitcoin { - LIANA_STANDARD_PATH - } else { - LIANA_TESTNET_STANDARD_PATH + let derivation_path = DerivationPath::from_str(&{ + if network == Network::Bitcoin { + format!("m/48'/0'/{}/2'", account_index) + } else { + format!("m/48'/1'/{}/2'", account_index) + } }) .unwrap(); let xkey = hw @@ -667,7 +812,12 @@ impl Step for ParticipateXpub { self.processing = true; self.error = None; return Command::perform( - get_extended_pubkey(device, hw.fingerprint, self.network), + get_extended_pubkey( + device, + hw.fingerprint, + self.network, + ChildNumber::from_hardened_idx(0).unwrap(), + ), Message::ImportXpub, ); } diff --git a/gui/src/installer/step/mod.rs b/gui/src/installer/step/mod.rs index f425d907..198cce20 100644 --- a/gui/src/installer/step/mod.rs +++ b/gui/src/installer/step/mod.rs @@ -5,19 +5,14 @@ pub use descriptor::{ use std::path::PathBuf; use std::str::FromStr; -use std::time::Duration; -use async_hwi::DeviceKind; use iced::{Command, Element}; -use liana::{ - config::{BitcoinConfig, BitcoindConfig}, - descriptors::MultipathDescriptor, - miniscript::bitcoin, -}; +use liana::{config::BitcoindConfig, miniscript::bitcoin}; use crate::ui::component::form; use crate::installer::{ + context::Context, message::{self, Message}, view, }; @@ -39,34 +34,6 @@ pub trait Step { } } -#[derive(Clone)] -pub struct Context { - pub bitcoin_config: BitcoinConfig, - pub bitcoind_config: Option, - pub descriptor: Option, - pub hws: Vec<( - DeviceKind, - bitcoin::util::bip32::Fingerprint, - Option<[u8; 32]>, - )>, - pub data_dir: PathBuf, -} - -impl Context { - pub fn new(network: bitcoin::Network, data_dir: PathBuf) -> Self { - Self { - bitcoin_config: BitcoinConfig { - network, - poll_interval_secs: Duration::from_secs(30), - }, - hws: Vec::new(), - bitcoind_config: None, - descriptor: None, - data_dir, - } - } -} - #[derive(Default)] pub struct Welcome {} diff --git a/gui/src/installer/view.rs b/gui/src/installer/view.rs index a22ab1da..18341093 100644 --- a/gui/src/installer/view.rs +++ b/gui/src/installer/view.rs @@ -8,9 +8,9 @@ use liana::miniscript::bitcoin; use crate::{ hw::HardwareWallet, installer::{ + context::Context, message::{self, Message}, - step::Context, - Error, + prompt, Error, }, ui::{ color, @@ -171,9 +171,7 @@ pub fn define_descriptor<'a>( .spacing(10) .push(Space::with_width(Length::Units(40))) .push(text("Primary path:").bold()) - .push(tooltip( - super::prompt::DEFINE_DESCRIPTOR_PRIMATRY_PATH_TOOLTIP, - )), + .push(tooltip(prompt::DEFINE_DESCRIPTOR_PRIMATRY_PATH_TOOLTIP)), ) .push(separation().width(Length::Fill)) .push( @@ -294,7 +292,7 @@ pub fn define_descriptor<'a>( Row::new() .spacing(10) .push(text("Blocks before recovery:").bold()) - .push(tooltip(super::prompt::DEFINE_DESCRIPTOR_SEQUENCE_TOOLTIP)), + .push(tooltip(prompt::DEFINE_DESCRIPTOR_SEQUENCE_TOOLTIP)), ) .push( Container::new( @@ -319,7 +317,7 @@ pub fn define_descriptor<'a>( layout( progress, Column::new() - .push(Space::with_height(Length::Units(50))) + .push(Space::with_height(Length::Units(30))) .push(text("Create the wallet").bold().size(50)) .push( Column::new() @@ -620,7 +618,7 @@ pub fn backup_descriptor<'a>( ) .push( Column::new() - .push(text(super::prompt::BACKUP_DESCRIPTOR_MESSAGE)) + .push(text(prompt::BACKUP_DESCRIPTOR_MESSAGE)) .push(collapse::Collapse::new( || { Button::new( @@ -680,7 +678,7 @@ pub fn backup_descriptor<'a>( } pub fn help_backup<'a>() -> Element<'a, Message> { - text(super::prompt::BACKUP_DESCRIPTOR_HELP).small().into() + text(prompt::BACKUP_DESCRIPTOR_HELP).small().into() } pub fn define_bitcoin<'a>( @@ -854,7 +852,7 @@ pub fn install<'a>( layout(progress, col) } -pub fn undefined_descriptor_key(name: &str) -> Element { +pub fn undefined_descriptor_key<'a>() -> Element<'a, message::DefineKey> { card::simple( Column::new() .width(Length::Fill) @@ -875,8 +873,13 @@ pub fn undefined_descriptor_key(name: &str) -> Element { .spacing(15) .align_items(Alignment::Center) .push( - Scrollable::new(text(name).bold()) - .horizontal_scroll(Properties::new().width(2).scroller_width(2)), + Scrollable::new( + icon::key_icon() + .style(color::DARK_GREY) + .size(50) + .width(Length::Units(50)), + ) + .horizontal_scroll(Properties::new().width(2).scroller_width(2)), ) .push(icon::circle_check_icon().style(color::FOREGROUND).size(50)), ) @@ -884,8 +887,7 @@ pub fn undefined_descriptor_key(name: &str) -> Element { .align_y(alignment::Vertical::Center), ) .push( - button::border(Some(icon::pencil_icon()), "Edit") - .on_press(message::DefineKey::Edit), + button::border(Some(icon::pencil_icon()), "Set").on_press(message::DefineKey::Edit), ) .push(Space::with_height(Length::Units(5))), ) @@ -967,7 +969,7 @@ pub fn defined_descriptor_key( .height(Length::Units(200)) .width(Length::Units(200)), ) - .push(text("Key is a duplicate").small().style(color::ALERT)) + .push(text("Duplicate key").small().style(color::ALERT)) .into() } else if duplicate_name { Column::new() @@ -978,7 +980,7 @@ pub fn defined_descriptor_key( .height(Length::Units(200)) .width(Length::Units(200)), ) - .push(text("Name is a duplicate").small().style(color::ALERT)) + .push(text("Duplicate name").small().style(color::ALERT)) .into() } else { card::simple(col) @@ -989,6 +991,7 @@ pub fn defined_descriptor_key( } } +#[allow(clippy::too_many_arguments)] pub fn edit_key_modal<'a>( network: bitcoin::Network, hws: &[HardwareWallet], @@ -996,62 +999,14 @@ pub fn edit_key_modal<'a>( processing: bool, chosen_hw: Option, form_xpub: &form::Value, - form_name: &form::Value, + form_name: &'a form::Value, + edit_name: bool, ) -> Element<'a, Message> { Column::new() .push_maybe(error.map(|e| card::error("Failed to import xpub", e.to_string()))) .push(card::simple( Column::new() .spacing(25) - .push( - Container::new( - Row::new() - .spacing(5) - .push(icon::pencil_icon()) - .push(text("Edit")), - ) - .width(Length::Fill) - .align_x(alignment::Horizontal::Center), - ) - .push( - Column::new() - .spacing(5) - .push(text("Edit name:").bold()) - .push( - form::Form::new("Name", form_name, |msg| { - Message::DefineDescriptor(message::DefineDescriptor::NameEdited( - msg, - )) - }) - .warning("Please enter correct name") - .size(20) - .padding(10), - ), - ) - .push( - Column::new() - .spacing(5) - .push(text("Enter an extended public key:").bold()) - .push( - Row::new() - .push( - form::Form::new("Extended public key", form_xpub, |msg| { - Message::DefineDescriptor( - message::DefineDescriptor::XPubEdited(msg), - ) - }) - .warning(if network == bitcoin::Network::Bitcoin { - "Please enter correct xpub" - } else { - "Please enter correct tpub" - }) - .size(20) - .padding(10), - ) - .spacing(10) - .push(Container::new(text("/<0;1>/*")).padding(5)), - ), - ) .push(if !hws.is_empty() { Column::new() .push( @@ -1059,7 +1014,7 @@ pub fn edit_key_modal<'a>( .spacing(10) .align_items(Alignment::Center) .push( - Container::new(text("Or select a hardware wallet:").bold()) + Container::new(text("Select a hardware wallet:").bold()) .width(Length::Fill), ) .push( @@ -1100,6 +1055,75 @@ pub fn edit_key_modal<'a>( ) .width(Length::Fill) }) + .push( + Column::new() + .spacing(5) + .push(text("Or enter an extended public key:").bold()) + .push( + Row::new() + .push( + form::Form::new("Extended public key", form_xpub, |msg| { + Message::DefineDescriptor( + message::DefineDescriptor::XPubEdited(msg), + ) + }) + .warning(if network == bitcoin::Network::Bitcoin { + "Please enter correct xpub with origin" + } else { + "Please enter correct tpub with origin" + }) + .size(20) + .padding(10), + ) + .spacing(10) + .push(Container::new(text("/<0;1>/*")).padding(5)), + ), + ) + .push( + if !edit_name && !form_xpub.value.is_empty() && form_xpub.valid { + Column::new().push( + Row::new() + .push( + Column::new() + .spacing(5) + .width(Length::Fill) + .push( + Row::new() + .spacing(5) + .push(text("Fingerprint alias:").bold()) + .push(tooltip( + prompt::DEFINE_DESCRIPTOR_FINGERPRINT_TOOLTIP, + )), + ) + .push(text(&form_name.value)), + ) + .push(button::border(Some(icon::pencil_icon()), "Edit").on_press( + Message::DefineDescriptor(message::DefineDescriptor::EditName), + )), + ) + } else if !form_xpub.value.is_empty() && form_xpub.valid { + Column::new() + .spacing(5) + .push( + Row::new() + .spacing(5) + .push(text("Fingerprint alias:").bold()) + .push(tooltip(prompt::DEFINE_DESCRIPTOR_FINGERPRINT_TOOLTIP)), + ) + .push( + form::Form::new("Alias", form_name, |msg| { + Message::DefineDescriptor( + message::DefineDescriptor::NameEdited(msg), + ) + }) + .warning("Please enter correct alias") + .size(20) + .padding(10), + ) + } else { + Column::new() + }, + ) .push( if form_xpub.valid && !form_xpub.value.is_empty() && !form_name.value.is_empty() { diff --git a/gui/src/loader.rs b/gui/src/loader.rs index 558d647a..477bf8e7 100644 --- a/gui/src/loader.rs +++ b/gui/src/loader.rs @@ -17,7 +17,12 @@ use liana::{ }; use crate::{ - app::config::{default_datadir, Config as GUIConfig}, + app::{ + cache::Cache, + config::{default_datadir, Config as GUIConfig}, + settings::{self, Settings}, + wallet::Wallet, + }, daemon::{client, embedded::EmbeddedDaemon, model::*, Daemon, DaemonError}, ui::{ component::{button, notification, text::*}, @@ -30,6 +35,7 @@ type Lianad = client::Lianad; pub struct Loader { pub datadir_path: Option, + pub network: bitcoin::Network, pub gui_config: GUIConfig, pub daemon_started: bool, @@ -47,16 +53,12 @@ pub enum Step { Error(Box), } +#[allow(clippy::type_complexity)] #[derive(Debug)] pub enum Message { View(ViewMessage), Syncing(Result), - Synced( - GetInfoResult, - Vec, - Vec, - Arc, - ), + Synced(Result<(Arc, Cache, Arc), Error>), Started(Result, Error>), Loaded(Result, Error>), Failure(DaemonError), @@ -75,6 +77,7 @@ impl Loader { .unwrap(); ( Loader { + network: daemon_config.bitcoin_config.network, datadir_path, daemon_config: daemon_config.clone(), gui_config, @@ -141,18 +144,47 @@ impl Loader { Ok(info) => { if (info.sync - 1.0_f64).abs() < f64::EPSILON { let daemon = daemon.clone(); + let settings_path = + settings_path(&self.datadir_path, self.network).unwrap(); + let gui_config_hws = self + .gui_config + .hardware_wallets + .as_ref() + .cloned() + .unwrap_or_default(); return Command::perform( async move { - let coins = daemon - .list_coins() - .map(|res| res.coins) - .unwrap_or_else(|_| Vec::new()); - let spend_txs = daemon - .list_spend_transactions() - .unwrap_or_else(|_| Vec::new()); - (info, coins, spend_txs, daemon) + let coins = daemon.list_coins().map(|res| res.coins)?; + let spend_txs = daemon.list_spend_transactions()?; + let cache = Cache { + network: info.network, + blockheight: info.block_height, + coins, + spend_txs, + ..Default::default() + }; + let wallet = match Settings::from_file(&settings_path) { + Ok(settings) => { + if let Some(wallet_setting) = settings.wallets.first() { + Wallet::legacy(info.descriptors.main) + .with_harware_wallets( + wallet_setting.hardware_wallets.clone(), + ) + .with_key_aliases(wallet_setting.keys_aliases()) + } else { + Wallet::legacy(info.descriptors.main) + .with_harware_wallets(gui_config_hws) + } + } + Err(settings::SettingsError::NotFound) => { + Wallet::legacy(info.descriptors.main) + .with_harware_wallets(gui_config_hws) + } + Err(e) => return Err(e.into()), + }; + Ok((Arc::new(wallet), cache, daemon)) }, - |res| Message::Synced(res.0, res.1, res.2, res.3), + Message::Synced, ); } else { *progress = info.sync @@ -333,6 +365,7 @@ async fn sync( #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub enum Error { + Settings(settings::SettingsError), Config(ConfigError), Daemon(DaemonError), } @@ -340,12 +373,19 @@ pub enum Error { impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { + Self::Settings(e) => write!(f, "Settings error: {}", e), Self::Config(e) => write!(f, "Config error: {}", e), Self::Daemon(e) => write!(f, "Liana daemon error: {}", e), } } } +impl From for Error { + fn from(error: settings::SettingsError) -> Self { + Error::Settings(error) + } +} + impl From for Error { fn from(error: ConfigError) -> Self { Error::Config(error) @@ -372,3 +412,18 @@ fn socket_path( path.push("lianad_rpc"); Ok(path) } + +/// default liana settings path is .liana/bitcoin/settings.json +fn settings_path( + datadir: &Option, + network: bitcoin::Network, +) -> Result { + let mut path = if let Some(ref datadir) = datadir { + datadir.clone() + } else { + default_datadir().map_err(|_| ConfigError::DatadirNotFound)? + }; + path.push(network.to_string()); + path.push(settings::DEFAULT_FILE_NAME); + Ok(path) +} diff --git a/gui/src/main.rs b/gui/src/main.rs index 917aaf1b..397891b3 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -11,9 +11,7 @@ use liana::{config::Config as DaemonConfig, miniscript::bitcoin}; use liana_gui::{ app::{ self, - cache::Cache, config::{default_datadir, ConfigError}, - wallet::Wallet, App, }, installer::{self, Installer}, @@ -205,17 +203,7 @@ impl Application for GUI { ))); Command::none() } - loader::Message::Synced(info, coins, spend_txs, daemon) => { - let cache = Cache { - network: info.network, - blockheight: info.block_height, - coins, - spend_txs, - ..Default::default() - }; - - let wallet = Wallet::new(info.descriptors.main); - + loader::Message::Synced(Ok((wallet, cache, daemon))) => { let (app, command) = App::new(cache, wallet, loader.gui_config.clone(), daemon); self.state = State::App(app); command.map(|msg| Message::Run(Box::new(msg))) From 445ad733fbb6604f2b1acfc93db454eb4794baf7 Mon Sep 17 00:00:00 2001 From: edouard Date: Mon, 30 Jan 2023 16:40:36 +0100 Subject: [PATCH 8/9] Add signatures information to spend --- gui/src/app/state/spend/detail.rs | 27 ++- gui/src/app/view/spend/detail.rs | 290 +++++++++++++++++++++++------- gui/src/daemon/model.rs | 13 +- gui/src/ui/icon.rs | 4 + 4 files changed, 263 insertions(+), 71 deletions(-) diff --git a/gui/src/app/state/spend/detail.rs b/gui/src/app/state/spend/detail.rs index 6dd2f21c..81479859 100644 --- a/gui/src/app/state/spend/detail.rs +++ b/gui/src/app/state/spend/detail.rs @@ -1,9 +1,12 @@ use std::sync::Arc; use iced::{Command, Element}; -use liana::miniscript::bitcoin::{ - consensus, - util::{bip32::Fingerprint, psbt::Psbt}, +use liana::{ + descriptors::LianaDescInfo, + miniscript::bitcoin::{ + consensus, + util::{bip32::Fingerprint, psbt::Psbt}, + }, }; use crate::{ @@ -39,6 +42,7 @@ trait Action { pub struct SpendTxState { wallet: Arc, + desc_info: LianaDescInfo, tx: SpendTx, saved: bool, action: Option>, @@ -47,6 +51,7 @@ pub struct SpendTxState { impl SpendTxState { pub fn new(wallet: Arc, tx: SpendTx, saved: bool) -> Self { Self { + desc_info: wallet.main_descriptor.info(), wallet, action: None, tx, @@ -116,7 +121,13 @@ impl SpendTxState { } pub fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - let content = detail::spend_view(&self.tx, self.saved, cache.network); + let content = detail::spend_view( + &self.tx, + self.saved, + &self.desc_info, + &self.wallet.keys_aliases, + cache.network, + ); if let Some(action) = &self.action { modal::Modal::new(content, action.view()) .on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel))) @@ -311,7 +322,10 @@ impl Action for SignAction { } }, Message::Updated(res) => match res { - Ok(()) => self.processing = false, + Ok(()) => { + self.processing = false; + tx.sigs = wallet.main_descriptor.partial_spend_info(&tx.psbt).unwrap(); + } Err(e) => self.error = Some(e), }, // We add the new hws without dropping the reference of the previous ones. @@ -393,7 +407,7 @@ impl Action for UpdateAction { fn update( &mut self, - _wallet: &Wallet, + wallet: &Wallet, daemon: Arc, message: Message, tx: &mut SpendTx, @@ -430,6 +444,7 @@ impl Action for UpdateAction { .extend(updated_input.partial_sigs.clone().into_iter()); } } + tx.sigs = wallet.main_descriptor.partial_spend_info(&tx.psbt).unwrap(); } Err(e) => self.error = e.into(), } diff --git a/gui/src/app/view/spend/detail.rs b/gui/src/app/view/spend/detail.rs index d3f0c708..a1a8267c 100644 --- a/gui/src/app/view/spend/detail.rs +++ b/gui/src/app/view/spend/detail.rs @@ -1,9 +1,14 @@ +use std::collections::HashMap; + use iced::{ - widget::{Button, Column, Container, Row, Scrollable, Space}, + widget::{scrollable, tooltip, Button, Column, Container, Row, Scrollable, Space}, Alignment, Element, Length, }; -use liana::miniscript::bitcoin::{util::bip32::Fingerprint, Address, Amount, Network, Transaction}; +use liana::{ + descriptors::{LianaDescInfo, PathInfo, PathSpendInfo}, + miniscript::bitcoin::{util::bip32::Fingerprint, Address, Amount, Network, Transaction}, +}; use crate::{ app::{ @@ -25,7 +30,13 @@ use crate::{ }, }; -pub fn spend_view(tx: &SpendTx, saved: bool, network: Network) -> Element { +pub fn spend_view<'a>( + tx: &'a SpendTx, + saved: bool, + desc_info: &'a LianaDescInfo, + key_aliases: &'a HashMap, + network: Network, +) -> Element<'a, Message> { spend_modal( saved, None, @@ -33,7 +44,7 @@ pub fn spend_view(tx: &SpendTx, saved: bool, network: Network) -> Element(tx: &SpendTx) -> Element<'a, Message> { .push( Row::new() .push(badge::Badge::new(icon::send_icon()).style(badge::Style::Standard)) - .push(text("Spend").bold()) + .push(if tx.sigs.recovery_path().is_some() { + text("Recovery").bold() + } else { + text("Spend").bold() + }) .spacing(5) .align_items(Alignment::Center), ) @@ -217,67 +232,17 @@ fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { .into() } -fn spend_overview_view<'a>(tx: &SpendTx) -> Element<'a, Message> { - card::simple( +fn spend_overview_view<'a>( + tx: &'a SpendTx, + desc_info: &'a LianaDescInfo, + key_aliases: &'a HashMap, +) -> Element<'a, Message> { + Container::new( Column::new() - .push(Container::new( - Row::new() - .push( - Container::new( - Row::new() - .push(Container::new( - icon::key_icon().size(30).width(Length::Fill), - )) - .push( - Column::new() - .push(text("Number of signatures:").bold()) - .push(text(format!( - "{}", - tx.psbt.inputs[0].partial_sigs.len(), - ))) - .width(Length::Fill), - ) - .push_maybe(if tx.status == SpendStatus::Pending { - if !tx.is_signed() { - Some( - button::primary(None, "Sign") - .on_press(Message::Spend(SpendTxMessage::Sign)), - ) - } else { - Some( - button::primary(None, "Broadcast").on_press( - Message::Spend(SpendTxMessage::Broadcast), - ), - ) - } - } else { - None - }) - .align_items(Alignment::Center) - .spacing(20), - ) - .width(Length::FillPortion(1)), - ) - .align_items(Alignment::Center) - .spacing(20), - )) - .push(separation().width(Length::Fill)) .push( Column::new() + .padding(15) .spacing(10) - .push( - Row::new() - .push(text("Tx ID:").bold().width(Length::Fill)) - .push(text(tx.psbt.unsigned_tx.txid().to_string()).small()) - .push( - Button::new(icon::clipboard_icon()) - .on_press(Message::Clipboard( - tx.psbt.unsigned_tx.txid().to_string(), - )) - .style(button::Style::TransparentBorder.into()), - ) - .align_items(Alignment::Center), - ) .push( Row::new() .align_items(Alignment::Center) @@ -295,10 +260,209 @@ fn spend_overview_view<'a>(tx: &SpendTx) -> Element<'a, Message> { ), ) .align_items(Alignment::Center), + ) + .push( + Row::new() + .push(text("Tx ID:").bold().width(Length::Fill)) + .push(text(tx.psbt.unsigned_tx.txid().to_string()).small()) + .push( + Button::new(icon::clipboard_icon()) + .on_press(Message::Clipboard( + tx.psbt.unsigned_tx.txid().to_string(), + )) + .style(button::Style::TransparentBorder.into()), + ) + .align_items(Alignment::Center), ), ) - .spacing(20), + .push(signatures(tx, desc_info, key_aliases)), ) + .style(card::SimpleCardStyle) + .into() +} + +pub fn signatures<'a>( + tx: &'a SpendTx, + desc_info: &'a LianaDescInfo, + keys_aliases: &'a HashMap, +) -> Element<'a, Message> { + Column::new() + .push(Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push(if tx.is_ready() { + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(icon::circle_check_icon().style(color::SUCCESS)) + .push(text("Ready").bold().style(color::SUCCESS)) + .width(Length::Fill) + } else { + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(icon::circle_cross_icon()) + .push(text("Not ready").bold()) + .width(Length::Fill) + }) + .push(icon::collapse_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(button::Style::TransparentBorder.into()) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push(if tx.is_ready() { + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(icon::circle_check_icon().style(color::SUCCESS)) + .push(text("Ready").bold().style(color::SUCCESS)) + .width(Length::Fill) + } else { + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(icon::circle_cross_icon()) + .push(text("Not ready").bold()) + .width(Length::Fill) + }) + .push(icon::collapsed_icon()), + ) + .padding(15) + .width(Length::Fill) + .style(button::Style::TransparentBorder.into()) + }, + move || { + Into::>::into( + Column::new().push(separation().width(Length::Fill)).push( + Column::new() + .padding(15) + .spacing(10) + .push(path_view( + desc_info.primary_path(), + tx.sigs.primary_path(), + keys_aliases, + )) + .push_maybe(tx.sigs.recovery_path().as_ref().map(|path| { + let (_, keys) = desc_info.recovery_path(); + path_view(keys, path, keys_aliases) + })), + ), + ) + }, + )) + .push_maybe(if tx.status == SpendStatus::Pending { + Some( + Column::new().push(separation().width(Length::Fill)).push( + Container::new( + Row::new() + .push(Space::with_width(Length::Fill)) + .push_maybe(if !tx.is_ready() { + Some( + button::primary(None, "Sign") + .on_press(Message::Spend(SpendTxMessage::Sign)) + .width(Length::Units(150)), + ) + } else { + Some( + button::primary(None, "Broadcast") + .on_press(Message::Spend(SpendTxMessage::Broadcast)) + .width(Length::Units(150)), + ) + }) + .align_items(Alignment::Center) + .spacing(20), + ) + .padding(15), + ), + ) + } else { + None + }) + .into() +} + +pub fn path_view<'a>( + path: &'a PathInfo, + sigs: &'a PathSpendInfo, + key_aliases: &'a HashMap, +) -> Element<'a, Message> { + let mut keys: Vec = path.thresh_fingerprints().1.into_iter().collect(); + keys.sort(); + Scrollable::new( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(if sigs.signed_pubkeys.len() >= sigs.threshold { + icon::circle_check_icon().style(color::SUCCESS) + } else { + icon::circle_cross_icon() + }) + .push( + Container::new(text(format!(" {} ", sigs.threshold))).style( + if sigs.signed_pubkeys.len() >= sigs.threshold { + badge::PillStyle::Success + } else { + badge::PillStyle::Simple + }, + ), + ) + .push(text(format!( + "signature{} out of", + if sigs.threshold > 1 { "s" } else { "" } + ))) + .push( + sigs.signed_pubkeys + .keys() + .fold(Row::new().spacing(5), |row, value| { + row.push(if let Some(alias) = key_aliases.get(value) { + Container::new( + tooltip::Tooltip::new( + Container::new(text(alias)) + .padding(3) + .style(badge::PillStyle::Success), + value.to_string(), + tooltip::Position::Bottom, + ) + .style(card::SimpleCardStyle), + ) + } else { + Container::new(text(value.to_string())) + .padding(3) + .style(badge::PillStyle::Success) + }) + }), + ) + .push(keys.iter().fold(Row::new().spacing(5), |row, &value| { + row.push_maybe(if !sigs.signed_pubkeys.contains_key(&value) { + Some(if let Some(alias) = key_aliases.get(&value) { + Container::new( + tooltip::Tooltip::new( + Container::new(text(alias)) + .padding(3) + .style(badge::PillStyle::Simple), + value.to_string(), + tooltip::Position::Bottom, + ) + .style(card::SimpleCardStyle), + ) + } else { + Container::new(text(value.to_string())) + .padding(3) + .style(badge::PillStyle::Simple) + }) + } else { + None + }) + })), + ) + .horizontal_scroll(scrollable::Properties::new().width(2).scroller_width(2)) .into() } diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index 10958940..9f38582f 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -78,8 +78,17 @@ impl SpendTx { } } - pub fn is_signed(&self) -> bool { - !self.psbt.inputs.first().unwrap().partial_sigs.is_empty() + pub fn is_ready(&self) -> bool { + let path = self.sigs.primary_path(); + if path.signed_pubkeys.len() >= path.threshold { + return true; + } + if let Some(path) = self.sigs.recovery_path() { + if path.signed_pubkeys.len() >= path.threshold { + return true; + } + } + false } } diff --git a/gui/src/ui/icon.rs b/gui/src/ui/icon.rs index 92141dc4..ec082572 100644 --- a/gui/src/ui/icon.rs +++ b/gui/src/ui/icon.rs @@ -121,6 +121,10 @@ pub fn circle_check_icon() -> Text<'static> { icon('\u{F26B}') } +pub fn circle_cross_icon() -> Text<'static> { + icon('\u{F623}') +} + pub fn network_icon() -> Text<'static> { icon('\u{F40D}') } From ca39b15edd366d23d4bcf7433273663807fa2aab Mon Sep 17 00:00:00 2001 From: edouard Date: Tue, 31 Jan 2023 10:44:10 +0100 Subject: [PATCH 9/9] installer: generate multiple xpubs in participate step --- gui/src/installer/message.rs | 2 +- gui/src/installer/step/descriptor.rs | 130 ++++++++++++++--------- gui/src/installer/view.rs | 149 +++++++++++++++++---------- 3 files changed, 179 insertions(+), 102 deletions(-) diff --git a/gui/src/installer/message.rs b/gui/src/installer/message.rs index 390c4a5e..ea2388a4 100644 --- a/gui/src/installer/message.rs +++ b/gui/src/installer/message.rs @@ -25,7 +25,7 @@ pub enum Message { Network(Network), DefineBitcoind(DefineBitcoind), DefineDescriptor(DefineDescriptor), - ImportXpub(Result), + ImportXpub(usize, Result), ConnectedHardwareWallets(Vec), WalletRegistered(Result<(Fingerprint, Option<[u8; 32]>), Error>), } diff --git a/gui/src/installer/step/descriptor.rs b/gui/src/installer/step/descriptor.rs index 44777cb4..bf4048e4 100644 --- a/gui/src/installer/step/descriptor.rs +++ b/gui/src/installer/step/descriptor.rs @@ -746,18 +746,76 @@ async fn get_extended_pubkey( })) } +pub struct HardwareWalletXpubs { + hw: HardwareWallet, + xpubs: Vec, + processing: bool, + error: Option, + next_account: ChildNumber, +} + +impl HardwareWalletXpubs { + fn new(hw: HardwareWallet) -> Self { + Self { + hw, + xpubs: Vec::new(), + processing: false, + error: None, + next_account: ChildNumber::from_hardened_idx(0).unwrap(), + } + } + + fn update(&mut self, res: Result) { + self.processing = false; + match res { + Err(e) => { + self.error = e.into(); + } + Ok(xpub) => { + self.error = None; + self.next_account = self.next_account.increment().unwrap(); + self.xpubs + .push(xpub.to_string().trim_end_matches("/<0;1>/*").to_string()); + } + } + } + + fn select(&mut self, i: usize, network: Network) -> Command { + let device = self.hw.device.clone(); + self.processing = true; + self.error = None; + let fingerprint = self.hw.fingerprint; + let next_account = self.next_account; + Command::perform( + async move { + ( + i, + get_extended_pubkey(device, fingerprint, network, next_account).await, + ) + }, + |(i, res)| Message::ImportXpub(i, res), + ) + } + + pub fn view(&self, i: usize) -> Element { + view::hardware_wallet_xpubs( + i, + &self.xpubs, + &self.hw, + self.processing, + self.error.as_ref(), + ) + } +} + pub struct ParticipateXpub { network: Network, network_valid: bool, data_dir: Option, - xpub: Option, shared: bool, - processing: bool, - chosen_hw: Option, - hws: Vec<(HardwareWallet, bool)>, - error: Option, + xpubs_hw: Vec, } impl ParticipateXpub { @@ -766,12 +824,8 @@ impl ParticipateXpub { network: Network::Bitcoin, network_valid: true, data_dir: None, - processing: false, - xpub: None, - chosen_hw: None, - hws: Vec::new(), + xpubs_hw: Vec::new(), shared: false, - error: None, } } } @@ -788,48 +842,26 @@ impl Step for ParticipateXpub { self.network_valid = !network_datadir.exists(); } Message::UserActionDone(shared) => self.shared = shared, - Message::ImportXpub(res) => { - self.processing = false; - match res { - Err(e) => { - self.error = e.into(); - self.chosen_hw = None; - } - Ok(xpub) => { - self.error = None; - self.xpub = Some(xpub.to_string().trim_end_matches("/<0;1>/*").to_string()); - for (i, (_, imported)) in self.hws.iter_mut().enumerate() { - *imported = Some(i) == self.chosen_hw; - } - self.chosen_hw = None; - } + Message::ImportXpub(i, res) => { + if let Some(hw) = self.xpubs_hw.get_mut(i) { + hw.update(res); } } Message::Select(i) => { - if let Some((hw, _)) = self.hws.get(i) { - let device = hw.device.clone(); - self.chosen_hw = Some(i); - self.processing = true; - self.error = None; - return Command::perform( - get_extended_pubkey( - device, - hw.fingerprint, - self.network, - ChildNumber::from_hardened_idx(0).unwrap(), - ), - Message::ImportXpub, - ); + if let Some(hw) = self.xpubs_hw.get_mut(i) { + return hw.select(i, self.network); } } Message::ConnectedHardwareWallets(hws) => { for hw in hws { - if !self - .hws - .iter() - .any(|(h, _)| h.fingerprint == hw.fingerprint) + if let Some(xpub_hw) = self + .xpubs_hw + .iter_mut() + .find(|h| h.hw.fingerprint == hw.fingerprint) { - self.hws.push((hw, false)); + xpub_hw.hw = hw; + } else { + self.xpubs_hw.push(HardwareWalletXpubs::new(hw)); } } } @@ -866,12 +898,12 @@ impl Step for ParticipateXpub { progress, self.network, self.network_valid, - &self.hws, - self.processing, - self.chosen_hw, - self.xpub.as_ref(), + self.xpubs_hw + .iter() + .enumerate() + .map(|(i, hw)| hw.view(i)) + .collect(), self.shared, - self.error.as_ref(), ) } } diff --git a/gui/src/installer/view.rs b/gui/src/installer/view.rs index 18341093..31857563 100644 --- a/gui/src/installer/view.rs +++ b/gui/src/installer/view.rs @@ -409,18 +409,103 @@ pub fn import_descriptor<'a>( ) } -#[allow(clippy::too_many_arguments)] -pub fn participate_xpub<'a>( +pub fn hardware_wallet_xpubs<'a>( + i: usize, + xpubs: &'a Vec, + hw: &HardwareWallet, + processing: bool, + error: Option<&Error>, +) -> Element<'a, Message> { + let mut bttn = Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + Column::new() + .push(text(format!("{}", hw.kind)).bold()) + .push(text(format!("fingerprint: {}", hw.fingerprint)).small()) + .spacing(5) + .width(Length::Fill), + ) + .push_maybe(error.map(|e| { + iced::widget::tooltip( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(icon::warning_icon().style(color::ALERT)) + .push(text("An error occured").style(color::ALERT)), + e, + iced::widget::tooltip::Position::Bottom, + ) + .style(card::ErrorCardStyle) + })), + ) + .padding(10) + .style(button::Style::TransparentBorder.into()) + .width(Length::Fill); + if !processing { + bttn = bttn.on_press(Message::Select(i)); + } + Container::new( + Column::new() + .push(bttn) + .push_maybe(if xpubs.is_empty() { + None + } else { + Some(separation().width(Length::Fill)) + }) + .push_maybe(if xpubs.is_empty() { + None + } else { + Some(xpubs.iter().fold(Column::new().padding(15), |col, xpub| { + col.push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push( + Container::new( + Scrollable::new(Container::new(text(xpub).small()).padding(10)) + .horizontal_scroll( + Properties::new().width(2).scroller_width(2), + ), + ) + .width(Length::Fill), + ) + .push( + Container::new( + button::border(Some(icon::clipboard_icon()), "Copy") + .on_press(Message::Clibpboard(xpub.clone())) + .width(Length::Shrink), + ) + .padding(10), + ), + ) + })) + }) + .push_maybe(if !xpubs.is_empty() { + Some( + Container::new(if !processing { + button::border(Some(icon::plus_icon()), "New public key") + .on_press(Message::Select(i)) + } else { + button::border(Some(icon::plus_icon()), "New public key") + }) + .padding(10), + ) + } else { + None + }), + ) + .style(card::SimpleCardStyle) + .into() +} + +pub fn participate_xpub( progress: (usize, usize), network: bitcoin::Network, network_valid: bool, - hws: &[(HardwareWallet, bool)], - processing: bool, - chosen_hw: Option, - xpub: Option<&'a String>, + hws: Vec>, shared: bool, - error: Option<&Error>, -) -> Element<'a, Message> { +) -> Element { let row_network = Row::new() .spacing(10) .align_items(Alignment::Center) @@ -442,7 +527,7 @@ pub fn participate_xpub<'a>( layout( progress, Column::new() - .push(text("Share your public key").bold().size(50)) + .push(text("Share your public keys").bold().size(50)) .push( Column::new() .spacing(20) @@ -456,7 +541,7 @@ pub fn participate_xpub<'a>( .spacing(10) .align_items(Alignment::Center) .push( - Container::new(text("Select your hardware wallet:").bold()) + Container::new(text("Generate an extended public key by selecting a signing device:").bold()) .width(Length::Fill), ) .push( @@ -465,50 +550,11 @@ pub fn participate_xpub<'a>( ), ) .spacing(10) - .push( - hws.iter() - .enumerate() - .fold(Column::new().spacing(10), |col, (i, hw)| { - col.push(hw_list_view( - i, - &hw.0, - Some(i) == chosen_hw, - processing, - hw.1, - )) - }), - ) + .push(Column::with_children(hws).spacing(10)) .width(Length::Fill), ) - .push_maybe(xpub.map(|xpub| { - Column::new() - .spacing(5) - .push(text("Your extended pubkey:").bold()) - .push( - Row::new() - .spacing(5) - .align_items(Alignment::Center) - .push( - Container::new( - Scrollable::new(Container::new(text(xpub)).padding(10)) - .horizontal_scroll( - Properties::new().width(2).scroller_width(2), - ), - ) - .width(Length::Fill), - ) - .push( - Container::new( - button::border(Some(icon::clipboard_icon()), "Copy") - .on_press(Message::Clibpboard(xpub.clone())) - .width(Length::Shrink), - ) - .padding(10), - ), - ) - })) .push(Checkbox::new( - "I have shared my xpub", + "I have shared my public keys", shared, Message::UserActionDone, )) @@ -519,7 +565,6 @@ pub fn participate_xpub<'a>( } else { button::primary(None, "Next").width(Length::Units(200)) }) - .push_maybe(error.map(|e| card::error("Hardware error", e.to_string()))) .width(Length::Fill) .height(Length::Fill) .padding(100)