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/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/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/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 cdfadef9..ecf35c26 100644 --- a/gui/src/app/state/recovery.rs +++ b/gui/src/app/state/recovery.rs @@ -6,48 +6,37 @@ use iced::{Command, Element}; use crate::{ app::{ cache::Cache, - config::Config, 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, - config: Config, + wallet: Arc, locked_coins: (usize, Amount), recoverable_coins: (usize, Amount), 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, } 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 { @@ -64,7 +53,6 @@ impl RecoveryPanel { } Self { wallet, - config, locked_coins, recoverable_coins, warning: None, @@ -72,30 +60,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 +112,13 @@ 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(), 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( @@ -175,70 +146,56 @@ 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 { - 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(); + let sigs = desc.partial_spend_info(&psbt).unwrap(); + Ok(SpendTx::new(psbt, coins, sigs)) }, - 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/detail.rs b/gui/src/app/state/spend/detail.rs index c6a34d64..81479859 100644 --- a/gui/src/app/state/spend/detail.rs +++ b/gui/src/app/state/spend/detail.rs @@ -1,15 +1,17 @@ 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::{ 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 +41,19 @@ trait Action { } pub struct SpendTxState { - wallet: Wallet, - config: Config, + wallet: Arc, + desc_info: LianaDescInfo, 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 { + desc_info: wallet.main_descriptor.info(), wallet, action: None, - config, tx, saved, } @@ -80,7 +82,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; @@ -119,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))) @@ -252,7 +260,6 @@ impl Action for DeleteAction { } pub struct SignAction { - config: Config, chosen_hw: Option, processing: bool, hws: Vec, @@ -261,9 +268,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 +285,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, @@ -321,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. @@ -350,8 +354,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( @@ -399,7 +407,7 @@ impl Action for UpdateAction { fn update( &mut self, - _wallet: &Wallet, + wallet: &Wallet, daemon: Arc, message: Message, tx: &mut SpendTx, @@ -436,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/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index ddc4368e..230167f5 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; @@ -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 eae21155..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, } } @@ -447,10 +444,15 @@ 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/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() diff --git a/gui/src/app/view/spend/detail.rs b/gui/src/app/view/spend/detail.rs index 643392d2..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() } @@ -513,26 +677,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 +704,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/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)) 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/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..9f38582f 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,11 +74,21 @@ impl SpendTx { spend_amount, fee_amount: inputs_amount - spend_amount - change_amount, status, + sigs, } } - 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/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 880b632b..ea2388a4 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; @@ -7,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, @@ -21,6 +25,7 @@ pub enum Message { Network(Network), DefineBitcoind(DefineBitcoind), DefineDescriptor(DefineDescriptor), + ImportXpub(usize, Result), ConnectedHardwareWallets(Vec), WalletRegistered(Result<(Fingerprint, Option<[u8; 32]>), Error>), } @@ -34,10 +39,22 @@ 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), + EditName, + NameEdited(String), SequenceEdited(String), + ThresholdEdited(bool, usize), + ConfirmXpub, +} + +#[derive(Debug, Clone)] +pub enum DefineKey { + Delete, + Edit, + Clipboard(String), + Edited(String, DescriptorPublicKey), } diff --git a/gui/src/installer/mod.rs b/gui/src/installer/mod.rs index 76630970..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,17 +7,15 @@ 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, + BackupDescriptor, DefineBitcoind, DefineDescriptor, Final, ImportDescriptor, ParticipateXpub, RegisterDescriptor, Step, Welcome, }; @@ -100,10 +98,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(), @@ -152,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)) @@ -179,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 c84211c9..7d91ea48 100644 --- a/gui/src/installer/prompt.rs +++ b/gui/src/installer/prompt.rs @@ -1,2 +1,8 @@ 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"; +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 986de404..bf4048e4 100644 --- a/gui/src/installer/step/descriptor.rs +++ b/gui/src/installer/step/descriptor.rs @@ -1,39 +1,50 @@ +use std::collections::{HashMap, 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}, + util::bip32::{ChildNumber, DerivationPath, Fingerprint}, Network, }, - descriptor::DescriptorPublicKey, + descriptor::{DerivPaths, DescriptorMultiXKey, DescriptorPublicKey, Wildcard}, }, }; use crate::{ + app::settings::KeySetting, hw::{list_hardware_wallets, HardwareWallet}, installer::{ message::{self, Message}, 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 +55,146 @@ 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::default()], + spending_threshold: 1, + recovery_keys: vec![DescriptorKey::default()], + 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 + // 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: HashMap = HashMap::new(); + let mut duplicate_names = HashSet::new(); + for spending_key in &self.spending_keys { + 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 { + all_keys.insert(key.clone()); + } + } + } + for recovery_key in &self.recovery_keys { + 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 { + 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); + } + } + } + + 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 { // 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 +204,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 +226,98 @@ 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::default()); + self.recovery_threshold += 1; + } else { + self.spending_keys.push(DescriptorKey::default()); + 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::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; + 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::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, + self.fingerprint_account_index_mappping(), + self.keys_aliases(), + ); + 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, + self.fingerprint_account_index_mappping(), + self.keys_aliases(), + ); + 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 +344,163 @@ 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); + 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 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 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(); 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 Default for DescriptorKey { + fn default() -> Self { + Self { + 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); + } + } + + pub fn view(&self) -> Element { + match &self.key { + None => view::undefined_descriptor_key(), + Some(_) => view::defined_descriptor_key( + &self.name, + self.valid, + self.duplicate_key, + self.duplicate_name, + ), } } } @@ -210,24 +537,53 @@ impl From for Box { } } -pub struct GetHardwareWalletXpubModal { - is_heir: bool, - chosen_hw: Option, - processing: bool, - hws: Vec, - error: Option, +pub struct EditXpubModal { + is_recovery: bool, + key_index: usize, network: Network, + 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, } -impl GetHardwareWalletXpubModal { - fn new(is_heir: bool, network: Network) -> Self { +impl EditXpubModal { + fn new( + name: String, + key: String, + key_index: usize, + is_recovery: bool, + network: Network, + account_indexes: HashMap, + keys_aliases: HashMap, + ) -> Self { Self { - is_heir, + form_name: form::Value { + valid: true, + value: name, + }, + form_xpub: form::Value { + valid: true, + value: key, + }, + keys_aliases, + account_indexes, + is_recovery, + key_index, chosen_hw: None, processing: false, hws: Vec::new(), error: None, network, + edit_name: false, } } fn load(&self) -> Command { @@ -236,6 +592,13 @@ impl GetHardwareWalletXpubModal { Message::ConnectedHardwareWallets, ) } +} + +impl DescriptorKeyModal for EditXpubModal { + fn processing(&self) -> bool { + self.processing + } + fn update(&mut self, message: Message) -> Command { match message { Message::Select(i) => { @@ -243,11 +606,17 @@ impl GetHardwareWalletXpubModal { 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::XpubImported( - res.map(|key| key.to_string()), + Message::DefineDescriptor(message::DefineDescriptor::HWXpubImported( + res, )) }, ); @@ -259,98 +628,311 @@ 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); + 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 { - return Command::perform( - async move { key }, - message::DefineDescriptor::UserXpubEdited, - ) - .map(Message::DefineDescriptor); + self.edit_name = true; } + self.form_xpub.valid = true; + self.form_xpub.value = + key.to_string().trim_end_matches("/<0;1>/*").to_string(); } Err(e) => { self.error = Some(e); } } } + 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)) => { + 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) => { + 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; + 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::Edited(name, key), + ) + }, + ) + .map(Message::DefineDescriptor); + } + } _ => {} }; Command::none() } fn view(&self) -> Element { - view::hardware_wallet_xpubs_modal( - self.is_heir, + view::edit_key_modal( + self.network, &self.hws, self.error.as_ref(), self.processing, self.chosen_hw, + &self.form_xpub, + &self.form_name, + self.edit_name, ) } } -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(()) - } -} - +/// 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, -) -> Result { - let derivation_path = DerivationPath::from_str(if network == Network::Bitcoin { - LIANA_STANDARD_PATH - } else { - LIANA_TESTNET_STANDARD_PATH + account_index: ChildNumber, +) -> Result { + 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 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 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, + + shared: bool, + + xpubs_hw: Vec, +} + +impl ParticipateXpub { + pub fn new() -> Self { + Self { + network: Network::Bitcoin, + network_valid: true, + data_dir: None, + xpubs_hw: Vec::new(), + shared: false, + } + } +} + +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(i, res) => { + if let Some(hw) = self.xpubs_hw.get_mut(i) { + hw.update(res); + } + } + Message::Select(i) => { + if let Some(hw) = self.xpubs_hw.get_mut(i) { + return hw.select(i, self.network); + } + } + Message::ConnectedHardwareWallets(hws) => { + for hw in hws { + if let Some(xpub_hw) = self + .xpubs_hw + .iter_mut() + .find(|h| h.hw.fingerprint == hw.fingerprint) + { + xpub_hw.hw = hw; + } else { + self.xpubs_hw.push(HardwareWalletXpubs::new(hw)); + } + } + } + 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.xpubs_hw + .iter() + .enumerate() + .map(|(i, hw)| hw.view(i)) + .collect(), + self.shared, + ) + } +} + +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, @@ -408,6 +990,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, @@ -416,12 +999,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) @@ -546,7 +1123,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..198cce20 100644 --- a/gui/src/installer/step/mod.rs +++ b/gui/src/installer/step/mod.rs @@ -1,21 +1,18 @@ 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; -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, }; @@ -37,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 a5ef142a..31857563 100644 --- a/gui/src/installer/view.rs +++ b/gui/src/installer/view.rs @@ -1,20 +1,23 @@ -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; use crate::{ hw::HardwareWallet, installer::{ + context::Context, message::{self, Message}, - step::Context, - Error, + prompt, Error, }, ui::{ color, component::{ - button, card, collapse, container, form, + button, card, collapse, container, form, separation, text::{text, Text}, + tooltip, }, icon, util::Collection, @@ -79,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), @@ -93,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), @@ -106,7 +123,6 @@ pub fn welcome<'a>() -> Element<'a, Message> { ) .width(Length::Fill) .height(Length::Fill) - .padding(100) .spacing(50) .align_items(Alignment::Center), )) @@ -117,13 +133,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() @@ -142,101 +162,182 @@ pub fn define_descriptor<'a>( Some(card::warning( "A data directory already exists for this network".to_string(), )) - }); + }) + .padding(50); - 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(Space::with_width(Length::Units(40))) + .push(text("Primary path:").bold()) + .push(tooltip(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(200)) + .height(Length::Units(200)) + .align_y(alignment::Vertical::Center) + .align_x(alignment::Horizontal::Center), + ) + .width(Length::Units(200)) + .height(Length::Units(200)) + .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( + Row::new() + .push(Space::with_width(Length::Units(50))) + .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(200)) + .height(Length::Units(200)) + .align_y(alignment::Vertical::Center) + .align_x(alignment::Horizontal::Center), + ) + .width(Length::Units(200)) + .height(Length::Units(200)) + .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(prompt::DEFINE_DESCRIPTOR_SEQUENCE_TOOLTIP)), + ) + .push( + Container::new( + form::Form::new("Number of blocks", 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() + .push(Space::with_height(Length::Units(30))) .push(text("Create the wallet").bold().size(50)) .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()))) + .push(Space::with_height(Length::Units(20))) .width(Length::Fill) .height(Length::Fill) - .padding(100) .spacing(50) .align_items(Alignment::Center), ) @@ -244,6 +345,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, @@ -284,7 +386,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() { @@ -303,6 +409,170 @@ pub fn import_descriptor<'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: Vec>, + shared: bool, +) -> Element { + 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 keys").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("Generate an extended public key by selecting a signing device:").bold()) + .width(Length::Fill), + ) + .push( + button::border(Some(icon::reload_icon()), "Refresh") + .on_press(Message::Reload), + ), + ) + .spacing(10) + .push(Column::with_children(hws).spacing(10)) + .width(Length::Fill), + ) + .push(Checkbox::new( + "I have shared my public keys", + 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)) + }) + .width(Length::Fill) + .height(Length::Fill) + .padding(100) + .spacing(50) + .align_items(Alignment::Center), + ) +} + pub fn register_descriptor<'a>( progress: (usize, usize), descriptor: String, @@ -393,7 +663,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( @@ -435,7 +705,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") @@ -453,7 +723,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>( @@ -627,65 +897,294 @@ pub fn install<'a>( layout(progress, col) } -pub fn hardware_wallet_xpubs_modal<'a>( - is_heir: bool, +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(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(15) + .align_items(Alignment::Center) + .push( + 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)), + ) + .height(Length::Fill) + .align_y(alignment::Vertical::Center), + ) + .push( + button::border(Some(icon::pencil_icon()), "Set").on_press(message::DefineKey::Edit), + ) + .push(Space::with_height(Length::Units(5))), + ) + .padding(5) + .height(Length::Units(200)) + .width(Length::Units(200)) + .into() +} + +pub fn defined_descriptor_key( + name: &str, + valid: bool, + duplicate_key: bool, + duplicate_name: bool, +) -> Element { + let col = Column::new() + .width(Length::Fill) + .align_items(Alignment::Center) + .push( + Row::new() + .align_items(Alignment::Center) + .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( + 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)), + ), + ) + .height(Length::Fill) + .align_y(alignment::Vertical::Center), + ) + .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() + .align_items(Alignment::Center) + .push( + card::invalid(col) + .padding(5) + .height(Length::Units(200)) + .width(Length::Units(200)), + ) + .push( + text("Key is for a different network") + .small() + .style(color::ALERT), + ) + .into() + } else if duplicate_key { + Column::new() + .align_items(Alignment::Center) + .push( + card::invalid(col) + .padding(5) + .height(Length::Units(200)) + .width(Length::Units(200)), + ) + .push(text("Duplicate key").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("Duplicate name").small().style(color::ALERT)) + .into() + } else { + card::simple(col) + .padding(5) + .height(Length::Units(200)) + .width(Length::Units(200)) + .into() + } +} + +#[allow(clippy::too_many_arguments)] +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: &'a form::Value, + edit_name: bool, ) -> Element<'a, Message> { - modal( - Column::new() - .push( - text(if is_heir { - "Import the recovery public key" - } else { - "Import the user public key" - }) - .bold() - .size(50), - ) - .push_maybe(error.map(|e| card::error("Failed to import xpub", e.to_string()))) - .push( - Column::new() - .push( - Row::new() - .spacing(10) - .align_items(Alignment::Center) - .push( - Container::new( - text(format!("{} hardware wallets connected", hws.len())) - .bold(), + Column::new() + .push_maybe(error.map(|e| card::error("Failed to import xpub", e.to_string()))) + .push(card::simple( + Column::new() + .spacing(25) + .push(if !hws.is_empty() { + Column::new() + .push( + Row::new() + .spacing(10) + .align_items(Alignment::Center) + .push( + Container::new(text("Select a hardware wallet:").bold()) + .width(Length::Fill), ) - .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)| { + .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), - ) - .width(Length::Fill) - .height(Length::Fill) - .padding(100) - .spacing(50) - .align_items(Alignment::Center), - ) + }, + )) + .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( + 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() + { + button::primary(None, "Apply") + .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>( @@ -757,22 +1256,103 @@ 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(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(40), Event::DecrementPressed)) + .align_items(Alignment::Center) + .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/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))) 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..ec082572 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}') } @@ -117,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}') } @@ -206,3 +214,15 @@ 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}') +} + +pub fn people_icon() -> Text<'static> { + icon('\u{F4CF}') +}