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}') +}