diff --git a/gui/Cargo.lock b/gui/Cargo.lock index 0b91ef6f..eecac727 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -216,6 +216,18 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bip39" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e89470017230c38e52b82b3ee3f530db1856ba1d434e3a67a3456a8a8dec5f" +dependencies = [ + "bitcoin_hashes 0.9.7", + "rand_core 0.4.2", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -239,13 +251,19 @@ checksum = "9cb36de3b18ad25f396f9168302e36fb7e1e8923298ab3127da252d288d5af9d" dependencies = [ "base64", "bech32", - "bitcoin_hashes", + "bitcoin_hashes 0.11.0", "core2", "hashbrown 0.8.2", "secp256k1", "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce18265ec2324ad075345d5814fbeed4f41f0a660055dc78840b74d19b874b1" + [[package]] name = "bitcoin_hashes" version = "0.11.0" @@ -1640,16 +1658,19 @@ dependencies = [ [[package]] name = "liana" version = "0.2.0" -source = "git+https://github.com/wizardsardine/liana?branch=master#88e5977166373437169ea00e54f365085afd03c0" +source = "git+https://github.com/wizardsardine/liana?branch=master#200d3777e0fe89d3b73d296eb31c83b6272dc490" dependencies = [ "backtrace", "base64", + "bip39", "dirs", "fern", + "getrandom", "jsonrpc", "libc", "log", "miniscript", + "rdrand", "rusqlite", "serde", "serde_json", @@ -1840,6 +1861,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" +[[package]] +name = "maybe-uninit" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" + [[package]] name = "memchr" version = "2.5.0" @@ -2291,7 +2318,7 @@ dependencies = [ "instant", "libc", "redox_syscall", - "smallvec", + "smallvec 1.9.0", "winapi", ] @@ -2304,7 +2331,7 @@ dependencies = [ "cfg-if", "libc", "redox_syscall", - "smallvec", + "smallvec 1.9.0", "windows-sys 0.42.0", ] @@ -2473,7 +2500,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.3", ] [[package]] @@ -2483,9 +2510,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.3", ] +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + [[package]] name = "rand_core" version = "0.6.3" @@ -2559,6 +2592,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ae028b272a6e99d9f8260ceefa3caa09300a8d6c8d2b2001316474bc52122e9" +[[package]] +name = "rdrand" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e233b642160555c1aa1ff7a78443c6139342f411b6fa6602af2ebbfee9e166bb" +dependencies = [ + "rand_core 0.6.3", +] + [[package]] name = "redox_syscall" version = "0.2.15" @@ -2648,7 +2690,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "memchr", - "smallvec", + "smallvec 1.9.0", ] [[package]] @@ -2671,7 +2713,7 @@ checksum = "44561062e583c4873162861261f16fd1d85fe927c4904d71329a4fe43dc355ef" dependencies = [ "bitflags", "bytemuck", - "smallvec", + "smallvec 1.9.0", "ttf-parser 0.12.3", "unicode-bidi-mirroring", "unicode-ccc", @@ -2724,7 +2766,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7649a0b3ffb32636e60c7ce0d70511eda9c52c658cd0634e194d5a19943aeff" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.11.0", "secp256k1-sys", "serde", ] @@ -2859,6 +2901,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "smallvec" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0" +dependencies = [ + "maybe-uninit", +] + [[package]] name = "smallvec" version = "1.9.0" @@ -3180,6 +3231,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15c61ba63f9235225a22310255a29b806b907c9b8c964bcbd0a2c70f3f2deea7" +[[package]] +name = "unicode-normalization" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c8070a9942f5e7cfccd93f490fdebd230ee3c3c9f107cb25bad5351ef671cf" +dependencies = [ + "smallvec 0.6.14", +] + [[package]] name = "unicode-script" version = "0.5.4" @@ -3372,7 +3432,7 @@ checksum = "94f6e5e340d7c13490eca867898c4cec5af56c27a5ffe5c80c6fc4708e22d33e" dependencies = [ "nix 0.22.3", "once_cell", - "smallvec", + "smallvec 1.9.0", "wayland-sys", ] @@ -3453,7 +3513,7 @@ dependencies = [ "naga", "parking_lot 0.12.1", "raw-window-handle 0.5.0", - "smallvec", + "smallvec 1.9.0", "static_assertions", "wasm-bindgen", "wasm-bindgen-futures", @@ -3480,7 +3540,7 @@ dependencies = [ "parking_lot 0.12.1", "profiling", "raw-window-handle 0.5.0", - "smallvec", + "smallvec 1.9.0", "thiserror", "web-sys", "wgpu-hal", @@ -3518,7 +3578,7 @@ dependencies = [ "range-alloc", "raw-window-handle 0.5.0", "renderdoc-sys", - "smallvec", + "smallvec 1.9.0", "thiserror", "wasm-bindgen", "web-sys", diff --git a/gui/src/app/error.rs b/gui/src/app/error.rs index f0e767a6..ff6d6457 100644 --- a/gui/src/app/error.rs +++ b/gui/src/app/error.rs @@ -9,6 +9,7 @@ pub enum Error { Daemon(DaemonError), Unexpected(String), HardwareWallet(async_hwi::Error), + HotSigner(String), } impl std::fmt::Display for Error { @@ -40,6 +41,7 @@ impl std::fmt::Display for Error { }, Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), Self::HardwareWallet(e) => write!(f, "{}", e), + Self::HotSigner(e) => write!(f, "{}", e), } } } diff --git a/gui/src/app/state/spend/detail.rs b/gui/src/app/state/spend/detail.rs index 4c6f45aa..b568c6e6 100644 --- a/gui/src/app/state/spend/detail.rs +++ b/gui/src/app/state/spend/detail.rs @@ -25,12 +25,11 @@ trait Action { fn warning(&self) -> Option<&Error> { None } - fn load(&self, _wallet: &Wallet, _daemon: Arc) -> Command { + fn load(&self, _daemon: Arc) -> Command { Command::none() } fn update( &mut self, - _wallet: &Wallet, _daemon: Arc, _message: Message, _tx: &mut SpendTx, @@ -61,7 +60,7 @@ impl SpendTxState { pub fn load(&self, daemon: Arc) -> Command { if let Some(action) = &self.action { - action.load(&self.wallet, daemon) + action.load(daemon) } else { Command::none() } @@ -82,14 +81,14 @@ impl SpendTxState { self.action = Some(Box::new(DeleteAction::default())); } view::SpendTxMessage::Sign => { - let action = SignAction::new(); - let cmd = action.load(&self.wallet, daemon); + let action = SignAction::new(self.wallet.clone()); + let cmd = action.load(daemon); self.action = Some(Box::new(action)); return cmd; } view::SpendTxMessage::EditPsbt => { - let action = UpdateAction::new(self.tx.psbt.to_string()); - let cmd = action.load(&self.wallet, daemon); + let action = UpdateAction::new(self.wallet.clone(), self.tx.psbt.to_string()); + let cmd = action.load(daemon); self.action = Some(Box::new(action)); return cmd; } @@ -101,19 +100,19 @@ impl SpendTxState { } _ => { if let Some(action) = self.action.as_mut() { - return action.update(&self.wallet, daemon.clone(), message, &mut self.tx); + return action.update(daemon.clone(), message, &mut self.tx); } } }, Message::Updated(Ok(_)) => { self.saved = true; if let Some(action) = self.action.as_mut() { - return action.update(&self.wallet, daemon.clone(), message, &mut self.tx); + return action.update(daemon.clone(), message, &mut self.tx); } } _ => { if let Some(action) = self.action.as_mut() { - return action.update(&self.wallet, daemon.clone(), message, &mut self.tx); + return action.update(daemon.clone(), message, &mut self.tx); } } }; @@ -147,7 +146,6 @@ pub struct SaveAction { impl Action for SaveAction { fn update( &mut self, - _wallet: &Wallet, daemon: Arc, message: Message, tx: &mut SpendTx, @@ -183,7 +181,6 @@ pub struct BroadcastAction { impl Action for BroadcastAction { fn update( &mut self, - _wallet: &Wallet, daemon: Arc, message: Message, tx: &mut SpendTx, @@ -227,7 +224,6 @@ pub struct DeleteAction { impl Action for DeleteAction { fn update( &mut self, - _wallet: &Wallet, daemon: Arc, message: Message, tx: &mut SpendTx, @@ -260,6 +256,7 @@ impl Action for DeleteAction { } pub struct SignAction { + wallet: Arc, chosen_hw: Option, processing: bool, hws: Vec, @@ -268,8 +265,9 @@ pub struct SignAction { } impl SignAction { - pub fn new() -> Self { + pub fn new(wallet: Arc) -> Self { Self { + wallet, chosen_hw: None, processing: false, hws: Vec::new(), @@ -284,13 +282,14 @@ impl Action for SignAction { self.error.as_ref() } - fn load(&self, wallet: &Wallet, _daemon: Arc) -> Command { - let wallet = wallet.clone(); - Command::perform(list_hws(wallet), Message::ConnectedHardwareWallets) + fn load(&self, _daemon: Arc) -> Command { + Command::perform( + list_hws(self.wallet.clone()), + Message::ConnectedHardwareWallets, + ) } fn update( &mut self, - wallet: &Wallet, daemon: Arc, message: Message, tx: &mut SpendTx, @@ -312,6 +311,13 @@ impl Action for SignAction { ); } } + Message::View(view::Message::Spend(view::SpendTxMessage::SelectHotSigner)) => { + self.processing = true; + return Command::perform( + sign_psbt_with_hot_signer(self.wallet.clone(), tx.psbt.clone()), + Message::Signed, + ); + } Message::Signed(res) => match res { Err(e) => self.error = Some(e), Ok((psbt, fingerprint)) => { @@ -328,7 +334,11 @@ impl Action for SignAction { Message::Updated(res) => match res { Ok(()) => { self.processing = false; - tx.sigs = wallet.main_descriptor.partial_spend_info(&tx.psbt).unwrap(); + tx.sigs = self + .wallet + .main_descriptor + .partial_spend_info(&tx.psbt) + .unwrap(); } Err(e) => self.error = Some(e), }, @@ -348,7 +358,7 @@ impl Action for SignAction { self.hws = Vec::new(); self.chosen_hw = None; self.error = None; - return self.load(wallet, daemon); + return self.load(daemon); } _ => {} }; @@ -358,6 +368,7 @@ impl Action for SignAction { view::spend::detail::sign_action( self.error.as_ref(), &self.hws, + self.wallet.signer.as_ref().map(|s| s.fingerprint()), self.processing, self.chosen_hw, &self.signed, @@ -365,7 +376,7 @@ impl Action for SignAction { } } -async fn list_hws(wallet: Wallet) -> Vec { +async fn list_hws(wallet: Arc) -> Vec { list_hardware_wallets( &wallet.hardware_wallets, Some((&wallet.name, &wallet.main_descriptor.to_string())), @@ -373,6 +384,20 @@ async fn list_hws(wallet: Wallet) -> Vec { .await } +async fn sign_psbt_with_hot_signer( + wallet: Arc, + psbt: Psbt, +) -> Result<(Psbt, Fingerprint), Error> { + if let Some(signer) = &wallet.signer { + let psbt = signer + .sign_psbt(psbt) + .map_err(|e| Error::HotSigner(format!("Hot signer failed to sign psbt: {}", e)))?; + Ok((psbt, signer.fingerprint())) + } else { + Err(Error::HotSigner("Hot signer not loaded".to_string())) + } +} + async fn sign_psbt( hw: std::sync::Arc, fingerprint: Fingerprint, @@ -383,6 +408,7 @@ async fn sign_psbt( } pub struct UpdateAction { + wallet: Arc, psbt: String, updated: form::Value, processing: bool, @@ -391,8 +417,9 @@ pub struct UpdateAction { } impl UpdateAction { - pub fn new(psbt: String) -> Self { + pub fn new(wallet: Arc, psbt: String) -> Self { Self { + wallet, psbt, updated: form::Value::default(), processing: false, @@ -418,7 +445,6 @@ impl Action for UpdateAction { fn update( &mut self, - wallet: &Wallet, daemon: Arc, message: Message, tx: &mut SpendTx, @@ -455,7 +481,11 @@ impl Action for UpdateAction { .extend(updated_input.partial_sigs.clone().into_iter()); } } - tx.sigs = wallet.main_descriptor.partial_spend_info(&tx.psbt).unwrap(); + tx.sigs = self + .wallet + .main_descriptor + .partial_spend_info(&tx.psbt) + .unwrap(); } Err(e) => self.error = e.into(), } diff --git a/gui/src/app/view/hw.rs b/gui/src/app/view/hw.rs index c32b8357..1410b1a1 100644 --- a/gui/src/app/view/hw.rs +++ b/gui/src/app/view/hw.rs @@ -74,12 +74,11 @@ pub fn hw_list_view( }) .push_maybe(if signed { Some( - Column::new().push( - Row::new() - .spacing(5) - .push(icon::circle_check_icon().style(color::SUCCESS)) - .push(text("Signed").style(color::SUCCESS)), - ), + Row::new() + .align_items(Alignment::Center) + .spacing(5) + .push(icon::circle_check_icon().style(color::SUCCESS)) + .push(text("Signed").style(color::SUCCESS)), ) } else { None diff --git a/gui/src/app/view/message.rs b/gui/src/app/view/message.rs index ceb85482..95afe49b 100644 --- a/gui/src/app/view/message.rs +++ b/gui/src/app/view/message.rs @@ -40,6 +40,7 @@ pub enum SpendTxMessage { Save, Confirm, Cancel, + SelectHotSigner, SelectHardwareWallet(usize), EditPsbt, PsbtEdited(String), diff --git a/gui/src/app/view/spend/detail.rs b/gui/src/app/view/spend/detail.rs index ff674133..0aaa617a 100644 --- a/gui/src/app/view/spend/detail.rs +++ b/gui/src/app/view/spend/detail.rs @@ -707,6 +707,7 @@ pub fn inputs_and_outputs_view<'a>( pub fn sign_action<'a>( warning: Option<&Error>, hws: &'a [HardwareWallet], + signer: Option, processing: bool, chosen_hw: Option, signed: &[Fingerprint], @@ -715,12 +716,12 @@ pub fn sign_action<'a>( .push_maybe(warning.map(|w| warn(Some(w)))) .push(card::simple( Column::new() - .push(if !hws.is_empty() { + .push( Column::new() .push( Row::new() .push( - text("Select hardware wallet to sign with:") + text("Select signing device to sign with:") .bold() .width(Length::Fill), ) @@ -742,19 +743,42 @@ pub fn sign_action<'a>( )) }, )) - .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) - }) + .push_maybe(signer.map(|fingerprint| { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + Column::new() + .width(Length::Fill) + .push(text("This computer").bold()) + .push( + text(format!("fingerprint: {}", fingerprint)) + .small(), + ) + .spacing(5) + .width(Length::Fill), + ) + .push_maybe(if signed.contains(&fingerprint) { + Some( + Row::new() + .align_items(Alignment::Center) + .spacing(5) + .push( + icon::circle_check_icon().style(color::SUCCESS), + ) + .push(text("Signed").style(color::SUCCESS)), + ) + } else { + None + }), + ) + .on_press(Message::Spend(SpendTxMessage::SelectHotSigner)) + .padding(10) + .style(button::Style::Border.into()) + .width(Length::Fill) + })) + .width(Length::Fill), + ) .spacing(20) .width(Length::Fill) .align_items(Alignment::Center), diff --git a/gui/src/app/view/warning.rs b/gui/src/app/view/warning.rs index b121ec9e..4a2845e0 100644 --- a/gui/src/app/view/warning.rs +++ b/gui/src/app/view/warning.rs @@ -37,6 +37,7 @@ impl From<&Error> for WarningMessage { }, Error::Unexpected(_) => WarningMessage("Unknown error".to_string()), Error::HardwareWallet(_) => WarningMessage("Hardware wallet error".to_string()), + Error::HotSigner(_) => WarningMessage("Hot signer error".to_string()), } } } diff --git a/gui/src/app/wallet.rs b/gui/src/app/wallet.rs index 6e8bd4a2..29742695 100644 --- a/gui/src/app/wallet.rs +++ b/gui/src/app/wallet.rs @@ -1,18 +1,19 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; -use crate::hw::HardwareWalletConfig; +use crate::{hw::HardwareWalletConfig, signer::Signer}; use liana::descriptors::MultipathDescriptor; use liana::miniscript::bitcoin::util::bip32::Fingerprint; pub const DEFAULT_WALLET_NAME: &str = "Liana"; -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct Wallet { pub name: String, pub main_descriptor: MultipathDescriptor, pub keys_aliases: HashMap, pub hardware_wallets: Vec, + pub signer: Option, } impl Wallet { @@ -22,6 +23,7 @@ impl Wallet { main_descriptor, keys_aliases: HashMap::new(), hardware_wallets: Vec::new(), + signer: None, } } @@ -31,6 +33,7 @@ impl Wallet { main_descriptor, keys_aliases: HashMap::new(), hardware_wallets: Vec::new(), + signer: None, } } @@ -39,8 +42,25 @@ impl Wallet { self } - pub fn with_harware_wallets(mut self, hardware_wallets: Vec) -> Self { + pub fn with_hardware_wallets(mut self, hardware_wallets: Vec) -> Self { self.hardware_wallets = hardware_wallets; self } + + pub fn with_signer(mut self, signer: Signer) -> Self { + self.signer = Some(signer); + self + } + + pub fn descriptor_keys(&self) -> HashSet { + let info = self.main_descriptor.info(); + let mut descriptor_keys = HashSet::new(); + for (fingerprint, _) in info.primary_path().thresh_origins().1.iter() { + descriptor_keys.insert(*fingerprint); + } + for (fingerprint, _) in info.recovery_path().1.thresh_origins().1.iter() { + descriptor_keys.insert(*fingerprint); + } + descriptor_keys + } } diff --git a/gui/src/installer/context.rs b/gui/src/installer/context.rs index 91b0d687..e822684a 100644 --- a/gui/src/installer/context.rs +++ b/gui/src/installer/context.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use crate::{ @@ -7,6 +8,7 @@ use crate::{ wallet::DEFAULT_WALLET_NAME, }, hw::HardwareWalletConfig, + signer::Signer, }; use async_hwi::DeviceKind; use liana::{ @@ -28,6 +30,7 @@ pub struct Context { Option<[u8; 32]>, )>, pub data_dir: PathBuf, + pub signer: Option>, } impl Context { @@ -42,6 +45,7 @@ impl Context { bitcoind_config: None, descriptor: None, data_dir, + signer: None, } } diff --git a/gui/src/installer/message.rs b/gui/src/installer/message.rs index ea2388a4..f403f469 100644 --- a/gui/src/installer/message.rs +++ b/gui/src/installer/message.rs @@ -16,11 +16,13 @@ pub enum Message { Exit(PathBuf), Clibpboard(String), Next, + Skip, Previous, Install, Close, Reload, Select(usize), + UseHotSigner, Installed(Result), Network(Network), DefineBitcoind(DefineBitcoind), @@ -28,6 +30,8 @@ pub enum Message { ImportXpub(usize, Result), ConnectedHardwareWallets(Vec), WalletRegistered(Result<(Fingerprint, Option<[u8; 32]>), Error>), + MnemonicWord(usize, String), + ImportMnemonic(bool), } #[derive(Debug, Clone)] diff --git a/gui/src/installer/mod.rs b/gui/src/installer/mod.rs index 316f8844..f63ce5fd 100644 --- a/gui/src/installer/mod.rs +++ b/gui/src/installer/mod.rs @@ -15,8 +15,8 @@ use crate::app::{config as gui_config, settings as gui_settings}; pub use message::Message; use step::{ - BackupDescriptor, DefineBitcoind, DefineDescriptor, Final, ImportDescriptor, ParticipateXpub, - RegisterDescriptor, Step, Welcome, + BackupDescriptor, BackupMnemonic, DefineBitcoind, DefineDescriptor, Final, ImportDescriptor, + ParticipateXpub, RecoverMnemonic, RegisterDescriptor, Step, Welcome, }; pub struct Installer { @@ -91,6 +91,7 @@ impl Installer { self.steps = vec![ Welcome::default().into(), DefineDescriptor::new().into(), + BackupMnemonic::default().into(), BackupDescriptor::default().into(), RegisterDescriptor::default().into(), DefineBitcoind::new().into(), @@ -103,6 +104,7 @@ impl Installer { Welcome::default().into(), ParticipateXpub::new().into(), ImportDescriptor::new(false).into(), + BackupMnemonic::default().into(), BackupDescriptor::default().into(), RegisterDescriptor::default().into(), DefineBitcoind::new().into(), @@ -114,6 +116,7 @@ impl Installer { self.steps = vec![ Welcome::default().into(), ImportDescriptor::new(true).into(), + RecoverMnemonic::default().into(), RegisterDescriptor::default().into(), DefineBitcoind::new().into(), Final::new().into(), @@ -165,11 +168,28 @@ impl Installer { } } + /// Some steps are skipped because of contextual choice of the user, this + /// code is giving a correct progress summary to the user. + fn progress(&self) -> (usize, usize) { + let mut current = self.current; + let mut total = 0; + for (i, step) in self.steps.iter().enumerate() { + if step.skip(&self.context) { + if i < self.current { + current -= 1; + } + } else { + total += 1 + } + } + (current, total - 1) + } + pub fn view(&self) -> Element { self.steps .get(self.current) .expect("There is always a step") - .view((self.current, self.steps.len() - 1)) + .view(self.progress()) } } @@ -212,7 +232,18 @@ pub async fn install(ctx: Context) -> Result { daemon_config.to_string().as_bytes(), )?; - log::info!("Daemon config file created"); + log::info!("Daemon configuration file created"); + + if let Some(signer) = &ctx.signer { + signer + .store( + &cfg.data_dir().expect("Already checked"), + cfg.bitcoin_config.network, + ) + .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; + + log::info!("Hot signer mnemonic stored"); + } // create liana GUI configuration file let gui_config_path = create_and_write_file( @@ -227,7 +258,7 @@ pub async fn install(ctx: Context) -> Result { .as_bytes(), )?; - log::info!("Gui config file created"); + log::info!("Gui configuration file created"); // create liana GUI settings file let settings: gui_settings::Settings = ctx.extract_gui_settings(); diff --git a/gui/src/installer/prompt.rs b/gui/src/installer/prompt.rs index c08e0069..4bbd862e 100644 --- a/gui/src/installer/prompt.rs +++ b/gui/src/installer/prompt.rs @@ -6,4 +6,6 @@ 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"; -pub const REGISTER_DESCRIPTOR_HELP: &str = "To be used with the wallet, a device needs the descriptor. Registration on a device is not a substitute for backing up the descriptor."; +pub const REGISTER_DESCRIPTOR_HELP: &str = "To be used with the wallet, a device needs the descriptor. If the descriptor contains one or more keys imported from an external signing device, the descriptor must be registered on it. Registration on a device is not a substitute for backing up the descriptor."; +pub const MNEMONIC_HELP: &str = "A hot key generated on this computer was used for creating this wallet. It needs to be backed up. \n Keep it in a safe place. Never share it with anyone."; +pub const RECOVER_MNEMONIC_HELP: &str = "If you were using a hot key (a key stored on the computer) in your wallet, you will need to recover it from mnemonics to be able to sign transactions again. Otherwise you can directly go the next step."; diff --git a/gui/src/installer/step/descriptor.rs b/gui/src/installer/step/descriptor.rs index d9bdd17e..073d930e 100644 --- a/gui/src/installer/step/descriptor.rs +++ b/gui/src/installer/step/descriptor.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::str::FromStr; +use std::sync::Arc; use iced::{Command, Element}; use liana::{ @@ -24,6 +25,7 @@ use crate::{ step::{Context, Step}, view, Error, }, + signer::Signer, ui::component::{form, modal::Modal}, }; @@ -47,6 +49,7 @@ pub struct DefineDescriptor { recovery_threshold: usize, sequence: form::Value, modal: Option>, + signer: Arc, error: Option, } @@ -63,6 +66,7 @@ impl DefineDescriptor { recovery_threshold: 1, sequence: form::Value::default(), modal: None, + signer: Arc::new(Signer::generate(Network::Bitcoin).unwrap()), error: None, } } @@ -75,6 +79,23 @@ impl DefineDescriptor { && !self.spending_keys.iter().any(|k| k.key.is_none()) } + fn set_network(&mut self, network: Network) { + self.network = network; + if let Some(signer) = Arc::get_mut(&mut self.signer) { + signer.set_network(network); + } + if let Some(mut network_datadir) = self.data_dir.clone() { + 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); + } + } + // 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. @@ -201,18 +222,7 @@ impl Step for DefineDescriptor { Message::Close => { self.modal = None; } - 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(); - 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::Network(network) => self.set_network(network), Message::DefineDescriptor(msg) => { match msg { message::DefineDescriptor::ThresholdEdited(is_recovery, value) => { @@ -263,42 +273,30 @@ impl Step for DefineDescriptor { 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, + recovery_key.name.clone(), + recovery_key.key.as_ref(), i, is_recovery, self.network, self.fingerprint_account_index_mappping(), self.keys_aliases(), + self.signer.clone(), ); 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, + spending_key.name.clone(), + spending_key.key.as_ref(), i, is_recovery, self.network, self.fingerprint_account_index_mappping(), self.keys_aliases(), + self.signer.clone(), ); let cmd = modal.load(); self.modal = Some(Box::new(modal)); @@ -337,16 +335,14 @@ impl Step for DefineDescriptor { } 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(); + self.set_network(ctx.bitcoin_config.network) } fn apply(&mut self, ctx: &mut Context) -> bool { ctx.bitcoin_config.network = self.network; ctx.keys = Vec::new(); + let mut signer_is_used = false; 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() { @@ -371,6 +367,9 @@ impl Step for DefineDescriptor { master_fingerprint, name: recovery_key.name.clone(), }); + if master_fingerprint == self.signer.fingerprint() { + signer_is_used = true; + } } } recovery_keys.push(key.clone()); @@ -421,6 +420,9 @@ impl Step for DefineDescriptor { }; ctx.descriptor = Some(desc); + if signer_is_used { + ctx.signer = Some(self.signer.clone()); + } true } @@ -555,17 +557,22 @@ pub struct EditXpubModal { chosen_hw: Option, hws: Vec, + + signer: Arc, + chosen_signer: bool, } impl EditXpubModal { + #[allow(clippy::too_many_arguments)] fn new( name: String, - key: String, + key: Option<&DescriptorPublicKey>, key_index: usize, is_recovery: bool, network: Network, account_indexes: HashMap, keys_aliases: HashMap, + signer: Arc, ) -> Self { Self { form_name: form::Value { @@ -574,7 +581,9 @@ impl EditXpubModal { }, form_xpub: form::Value { valid: true, - value: key, + value: key + .map(|k| k.to_string().trim_end_matches("/<0;1>/*").to_string()) + .unwrap_or_else(|| "".to_string()), }, keys_aliases, account_indexes, @@ -586,6 +595,8 @@ impl EditXpubModal { error: None, network, edit_name: false, + chosen_signer: Some(signer.fingerprint()) == key.map(|k| k.master_fingerprint()), + signer, } } fn load(&self) -> Command { @@ -622,8 +633,7 @@ impl DescriptorKeyModal for EditXpubModal { get_extended_pubkey( device.clone(), *fingerprint, - self.network, - account_index, + generate_derivation_path(self.network, account_index), ), |res| { Message::DefineDescriptor(message::DefineDescriptor::HWXpubImported( @@ -635,11 +645,45 @@ impl DescriptorKeyModal for EditXpubModal { } Message::ConnectedHardwareWallets(hws) => { self.hws = hws; + if let Ok(key) = + DescriptorPublicKey::from_str(&format!("{}/<0;1>/*", self.form_xpub.value)) + { + self.chosen_hw = self + .hws + .iter() + .position(|hw| hw.fingerprint() == Some(key.master_fingerprint())); + } } Message::Reload => { self.hws = Vec::new(); return self.load(); } + Message::UseHotSigner => { + self.chosen_hw = None; + self.chosen_signer = true; + self.form_xpub.valid = true; + let fingerprint = self.signer.fingerprint(); + 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; + self.form_name.value = String::new(); + } + let account_index = self + .account_indexes + .get(&fingerprint) + .map(|account_index| account_index.increment().unwrap()) + .unwrap_or_else(|| ChildNumber::from_hardened_idx(0).unwrap()); + let derivation_path = generate_derivation_path(self.network, account_index); + self.form_xpub.value = format!( + "[{}{}]{}", + fingerprint, + derivation_path.to_string().trim_start_matches('m'), + self.signer.get_extended_pubkey(&derivation_path) + ); + } Message::DefineDescriptor(message::DefineDescriptor::HWXpubImported(res)) => { self.processing = false; match res { @@ -651,11 +695,13 @@ impl DescriptorKeyModal for EditXpubModal { } else { self.edit_name = true; } + self.chosen_signer = false; self.form_xpub.valid = true; self.form_xpub.value = key.to_string().trim_end_matches("/<0;1>/*").to_string(); } Err(e) => { + self.chosen_hw = None; self.error = Some(e); } } @@ -719,6 +765,7 @@ impl DescriptorKeyModal for EditXpubModal { self.error.as_ref(), self.processing, self.chosen_hw, + self.chosen_signer, &self.form_xpub, &self.form_name, self.edit_name, @@ -726,22 +773,24 @@ impl DescriptorKeyModal for EditXpubModal { } } -/// LIANA_STANDARD_PATH: m/48'/0'/0'/2'; -/// LIANA_TESTNET_STANDARD_PATH: m/48'/1'/0'/2'; -async fn get_extended_pubkey( - hw: std::sync::Arc, - fingerprint: Fingerprint, - network: Network, - account_index: ChildNumber, -) -> Result { - let derivation_path = DerivationPath::from_str(&{ +fn generate_derivation_path(network: Network, account_index: ChildNumber) -> DerivationPath { + DerivationPath::from_str(&{ if network == Network::Bitcoin { format!("m/48'/0'/{}/2'", account_index) } else { format!("m/48'/1'/{}/2'", account_index) } }) - .unwrap(); + .unwrap() +} + +/// 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, + derivation_path: DerivationPath, +) -> Result { let xkey = hw .get_extended_pubkey(&derivation_path, false) .await @@ -792,6 +841,12 @@ impl HardwareWalletXpubs { } } + fn reset(&mut self) { + self.error = None; + self.next_account = ChildNumber::from_hardened_idx(0).unwrap(); + self.xpubs = Vec::new(); + } + fn select(&mut self, i: usize, network: Network) -> Command { if let HardwareWallet::Supported { device, @@ -803,12 +858,12 @@ impl HardwareWalletXpubs { let fingerprint = *fingerprint; self.processing = true; self.error = None; - let next_account = self.next_account; + let derivation_path = generate_derivation_path(network, self.next_account); Command::perform( async move { ( i, - get_extended_pubkey(device, fingerprint, network, next_account).await, + get_extended_pubkey(device, fingerprint, derivation_path).await, ) }, |(i, res)| Message::ImportXpub(i, res), @@ -829,6 +884,42 @@ impl HardwareWalletXpubs { } } +pub struct SignerXpubs { + signer: Arc, + xpubs: Vec, + next_account: ChildNumber, +} + +impl SignerXpubs { + fn new(signer: Arc) -> Self { + Self { + signer, + xpubs: Vec::new(), + next_account: ChildNumber::from_hardened_idx(0).unwrap(), + } + } + + fn reset(&mut self) { + self.xpubs = Vec::new(); + self.next_account = ChildNumber::from_hardened_idx(0).unwrap(); + } + + fn select(&mut self, network: Network) { + let derivation_path = generate_derivation_path(network, self.next_account); + self.next_account = self.next_account.increment().unwrap(); + self.xpubs.push(format!( + "[{}{}]{}/<0;1>/*", + self.signer.fingerprint(), + derivation_path.to_string().trim_start_matches('m'), + self.signer.get_extended_pubkey(&derivation_path) + )); + } + + pub fn view(&self) -> Element { + view::signer_xpubs(&self.xpubs) + } +} + pub struct ParticipateXpub { network: Network, network_valid: bool, @@ -837,6 +928,7 @@ pub struct ParticipateXpub { shared: bool, xpubs_hw: Vec, + xpubs_signer: SignerXpubs, } impl ParticipateXpub { @@ -847,8 +939,30 @@ impl ParticipateXpub { data_dir: None, xpubs_hw: Vec::new(), shared: false, + xpubs_signer: SignerXpubs::new(Arc::new(Signer::generate(Network::Bitcoin).unwrap())), } } + + fn set_network(&mut self, network: Network) { + if network != self.network { + self.xpubs_hw.iter_mut().for_each(|hw| hw.reset()); + self.xpubs_signer.reset(); + } + self.network = network; + if let Some(signer) = Arc::get_mut(&mut self.xpubs_signer.signer) { + signer.set_network(network); + } + if let Some(mut network_datadir) = self.data_dir.clone() { + network_datadir.push(self.network.to_string()); + self.network_valid = !network_datadir.exists(); + } + } +} + +impl Default for ParticipateXpub { + fn default() -> Self { + Self::new() + } } impl Step for ParticipateXpub { @@ -857,10 +971,7 @@ impl Step for ParticipateXpub { 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(); + self.set_network(network); } Message::UserActionDone(shared) => self.shared = shared, Message::ImportXpub(i, res) => { @@ -868,6 +979,9 @@ impl Step for ParticipateXpub { hw.update(res); } } + Message::UseHotSigner => { + self.xpubs_signer.select(self.network); + } Message::Select(i) => { if let Some(hw) = self.xpubs_hw.get_mut(i) { return hw.select(i, self.network); @@ -894,11 +1008,8 @@ impl Step for ParticipateXpub { } 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(); + self.set_network(ctx.bitcoin_config.network); } fn load(&self) -> Command { @@ -912,6 +1023,11 @@ impl Step for ParticipateXpub { ctx.bitcoin_config.network = self.network; // Drop connections to hardware wallets. self.xpubs_hw = Vec::new(); + if !self.xpubs_signer.xpubs.is_empty() { + ctx.signer = Some(self.xpubs_signer.signer.clone()); + } else { + ctx.signer = None; + } true } @@ -925,17 +1041,12 @@ impl Step for ParticipateXpub { .enumerate() .map(|(i, hw)| hw.view(i)) .collect(), + self.xpubs_signer.view(), self.shared, ) } } -impl Default for ParticipateXpub { - fn default() -> Self { - Self::new() - } -} - impl From for Box { fn from(s: ParticipateXpub) -> Box { Box::new(s) diff --git a/gui/src/installer/step/mnemonic.rs b/gui/src/installer/step/mnemonic.rs new file mode 100644 index 00000000..5f88bea9 --- /dev/null +++ b/gui/src/installer/step/mnemonic.rs @@ -0,0 +1,161 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use iced::{Command, Element}; +use liana::{bip39, signer::HotSigner}; + +use crate::{ + installer::{context::Context, message::Message, step::Step, view}, + signer::Signer, +}; + +#[derive(Default)] +pub struct BackupMnemonic { + words: [&'static str; 12], + done: bool, +} + +impl From for Box { + fn from(s: BackupMnemonic) -> Box { + Box::new(s) + } +} + +impl Step for BackupMnemonic { + fn load_context(&mut self, ctx: &Context) { + if let Some(signer) = &ctx.signer { + self.words = signer.mnemonic(); + } + } + fn update(&mut self, message: Message) -> Command { + if let Message::UserActionDone(done) = message { + self.done = done; + } + Command::none() + } + fn skip(&self, ctx: &Context) -> bool { + ctx.signer.is_none() + } + fn view(&self, progress: (usize, usize)) -> Element { + view::backup_mnemonic(progress, &self.words, self.done) + } +} + +pub struct RecoverMnemonic { + language: bip39::Language, + words: [(String, bool); 12], + current: usize, + suggestions: Vec, + error: Option, + skip: bool, + recover: bool, +} + +impl Default for RecoverMnemonic { + fn default() -> Self { + Self { + language: bip39::Language::English, + words: Default::default(), + current: 0, + suggestions: Vec::new(), + error: None, + skip: false, + recover: false, + } + } +} + +impl From for Box { + fn from(s: RecoverMnemonic) -> Box { + Box::new(s) + } +} + +impl Step for RecoverMnemonic { + fn update(&mut self, message: Message) -> Command { + match message { + Message::MnemonicWord(index, value) => { + if let Some((word, valid)) = self.words.get_mut(index) { + if value.len() >= 3 { + let suggestions = self.language.words_by_prefix(&value); + if suggestions.contains(&value.as_ref()) { + *valid = true; + self.suggestions = Vec::new(); + } else { + self.suggestions = suggestions.iter().map(|s| s.to_string()).collect(); + *valid = false; + } + } else { + self.suggestions = Vec::new(); + *valid = false; + } + self.current = index; + *word = value; + } + } + Message::ImportMnemonic(recover) => self.recover = recover, + Message::Skip => { + self.skip = true; + return Command::perform(async {}, |_| Message::Next); + } + _ => {} + } + Command::none() + } + + fn apply(&mut self, ctx: &mut Context) -> bool { + if self.skip { + // If the user click previous, we dont want the skip to be set to true. + self.skip = false; + ctx.signer = None; + return true; + } + + let words: Vec = self + .words + .iter() + .filter_map(|(s, valid)| if *valid { Some(s.clone()) } else { None }) + .collect(); + + let seed = match HotSigner::from_str(ctx.bitcoin_config.network, &words.join(" ")) { + Ok(seed) => seed, + Err(e) => { + self.error = Some(e.to_string()); + return false; + } + }; + + let signer = Signer::new(seed); + let fingerprint = signer.fingerprint(); + + if let Some(descriptor) = &ctx.descriptor { + let info = descriptor.info(); + let mut descriptor_keys = HashSet::new(); + for (fingerprint, _) in info.primary_path().thresh_origins().1.iter() { + descriptor_keys.insert(*fingerprint); + } + for (fingerprint, _) in info.recovery_path().1.thresh_origins().1.iter() { + descriptor_keys.insert(*fingerprint); + } + if !descriptor_keys.contains(&fingerprint) { + self.error = + Some("The descriptor does not use a key derived from this seed".to_string()); + return false; + } + } + + ctx.signer = Some(Arc::new(signer)); + + true + } + fn view(&self, progress: (usize, usize)) -> Element { + view::recover_mnemonic( + progress, + &self.words, + self.current, + &self.suggestions, + self.recover, + self.error.as_ref(), + ) + } +} diff --git a/gui/src/installer/step/mod.rs b/gui/src/installer/step/mod.rs index 198cce20..d1119ffa 100644 --- a/gui/src/installer/step/mod.rs +++ b/gui/src/installer/step/mod.rs @@ -1,8 +1,12 @@ mod descriptor; +mod mnemonic; + pub use descriptor::{ BackupDescriptor, DefineDescriptor, ImportDescriptor, ParticipateXpub, RegisterDescriptor, }; +pub use mnemonic::{BackupMnemonic, RecoverMnemonic}; + use std::path::PathBuf; use std::str::FromStr; diff --git a/gui/src/installer/view.rs b/gui/src/installer/view.rs index 406fda19..a19c9411 100644 --- a/gui/src/installer/view.rs +++ b/gui/src/installer/view.rs @@ -1,5 +1,6 @@ use iced::widget::{ scrollable::Properties, Button, Checkbox, Column, Container, PickList, Row, Scrollable, Space, + TextInput, }; use iced::{alignment, Alignment, Element, Length}; @@ -411,6 +412,76 @@ pub fn import_descriptor<'a>( ) } +pub fn signer_xpubs(xpubs: &Vec) -> Element { + Container::new( + Column::new() + .push( + Button::new( + Row::new().align_items(Alignment::Center).push( + Column::new() + .push(text("This computer").bold()) + .push( + text("Derive a key from a mnemonic stored on this computer") + .small(), + ) + .spacing(5) + .width(Length::Fill), + ), + ) + .on_press(Message::UseHotSigner) + .padding(10) + .style(button::Style::TransparentBorder.into()) + .width(Length::Fill), + ) + .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( + button::border(Some(icon::plus_icon()), "New public key") + .on_press(Message::UseHotSigner), + ) + .padding(10), + ) + } else { + None + }), + ) + .style(card::SimpleCardStyle) + .into() +} + pub fn hardware_wallet_xpubs<'a>( i: usize, xpubs: &'a Vec, @@ -531,13 +602,14 @@ pub fn hardware_wallet_xpubs<'a>( .into() } -pub fn participate_xpub( +pub fn participate_xpub<'a>( progress: (usize, usize), network: bitcoin::Network, network_valid: bool, - hws: Vec>, + hws: Vec>, + signer: Element<'a, Message>, shared: bool, -) -> Element { +) -> Element<'a, Message> { let row_network = Row::new() .spacing(10) .align_items(Alignment::Center) @@ -583,6 +655,7 @@ pub fn participate_xpub( ) .spacing(10) .push(Column::with_children(hws).spacing(10)) + .push(signer) .width(Length::Fill), ) .push(Checkbox::new( @@ -829,115 +902,142 @@ pub fn install<'a>( config_path: Option<&std::path::PathBuf>, warning: Option<&'a String>, ) -> Element<'a, Message> { - let mut col = Column::new() - .push( - Container::new( - Column::new() - .spacing(10) - .push( - card::simple( - Column::new() - .spacing(5) - .push(text("Descriptor:").small().bold()) - .push(text(descriptor).small()), + layout( + progress, + Column::new() + .push(Space::with_height(Length::Units(50))) + .push(text("Final step").bold().size(50)) + .push(Space::with_height(Length::Units(50))) + .push(text( + "Check your information before finalizing the install process:", + )) + .push( + Container::new( + Column::new() + .spacing(10) + .push( + card::simple( + Column::new() + .spacing(5) + .push(text("Descriptor:").small().bold()) + .push(text(descriptor).small()), + ) + .width(Length::Fill), ) - .width(Length::Fill), - ) - .push( - card::simple( - Column::new() - .spacing(5) - .push(text("Hardware devices:").small().bold()) - .push(context.hws.iter().fold(Column::new(), |acc, hw| { - acc.push( + .push_maybe(if context.hws.is_empty() && context.signer.is_none() { + None + } else { + Some( + card::simple( + Column::new() + .spacing(5) + .push(text("Registered signing devices:").small().bold()) + .push_maybe(if context.hws.is_empty() { + None + } else { + Some(context.hws.iter().fold( + Column::new(), + |acc, hw| { + acc.push( + Row::new() + .spacing(5) + .push(text(hw.0.to_string()).small()) + .push( + text(format!( + "(fingerprint: {})", + hw.1 + )) + .small(), + ), + ) + }, + )) + }) + .push_maybe(context.signer.as_ref().map(|signer| { + Row::new().push(text("This computer").small()).push( + text(format!( + "(fingerprint: {})", + signer.fingerprint() + )) + .small(), + ) + })), + ) + .width(Length::Fill), + ) + }) + .push( + card::simple( + Column::new() + .push(text("Bitcoind:").small().bold()) + .push( Row::new() .spacing(5) - .push(text(hw.0.to_string()).small()) - .push(text(format!("(fingerprint: {})", hw.1)).small()), + .align_items(Alignment::Center) + .push(text("Cookie path:").small()) + .push( + text(format!( + "{}", + context + .bitcoind_config + .as_ref() + .unwrap() + .cookie_path + .to_string_lossy() + )) + .small(), + ), ) - })), - ) - .width(Length::Fill), - ) - .push( - card::simple( - Column::new() - .push(text("Bitcoind:").small().bold()) - .push( - Row::new() - .spacing(5) - .align_items(Alignment::Center) - .push(text("Cookie path:").small()) - .push( - text(format!( - "{}", - context - .bitcoind_config - .as_ref() - .unwrap() - .cookie_path - .to_string_lossy() - )) - .small(), - ), - ) - .push( - Row::new() - .spacing(5) - .align_items(Alignment::Center) - .push(text("Address:").small()) - .push( - text(format!( - "{}", - context.bitcoind_config.as_ref().unwrap().addr - )) - .small(), - ), - ), - ) - .width(Length::Fill), - ), + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push(text("Address:").small()) + .push( + text(format!( + "{}", + context.bitcoind_config.as_ref().unwrap().addr + )) + .small(), + ), + ), + ) + .width(Length::Fill), + ), + ) + .max_width(1000), ) - .padding(50) - .max_width(1000), - ) - .spacing(50) - .width(Length::Fill) - .height(Length::Fill) - .align_items(Alignment::Center); - - if let Some(error) = warning { - col = col.push(text(error)); - } - - if generating { - col = col.push(button::primary(None, "Installing ...").width(Length::Units(200))) - } else if let Some(path) = config_path { - col = col.push( - Container::new( - Column::new() - .push(Container::new(text("Installed !"))) - .push(Container::new( - button::primary(None, "Start") - .on_press(Message::Exit(path.clone())) - .width(Length::Units(200)), - )) - .align_items(Alignment::Center) - .spacing(20), - ) - .padding(50) + .push(Space::with_height(Length::Units(50))) + .push_maybe(warning.map(|e| card::invalid(text(e)))) + .push(if generating { + Container::new(button::primary(None, "Installing ...").width(Length::Units(200))) + } else if let Some(path) = config_path { + Container::new( + Column::new() + .push(Container::new(text("Installed !"))) + .push(Container::new( + button::primary(None, "Start") + .on_press(Message::Exit(path.clone())) + .width(Length::Units(200)), + )) + .align_items(Alignment::Center) + .spacing(20), + ) + .padding(50) + .width(Length::Fill) + .center_x() + } else { + Container::new( + button::primary(None, "Finalize installation") + .on_press(Message::Install) + .width(Length::Units(200)), + ) + }) + .spacing(10) .width(Length::Fill) - .center_x(), - ); - } else { - col = col.push( - button::primary(None, "Finalize installation") - .on_press(Message::Install) - .width(Length::Units(200)), - ); - } - - layout(progress, col) + .height(Length::Fill) + .align_items(Alignment::Center), + ) } pub fn undefined_descriptor_key<'a>() -> Element<'a, message::DefineKey> { @@ -1086,6 +1186,7 @@ pub fn edit_key_modal<'a>( error: Option<&Error>, processing: bool, chosen_hw: Option, + chosen_signer: bool, form_xpub: &form::Value, form_name: &'a form::Value, edit_name: bool, @@ -1095,14 +1196,14 @@ pub fn edit_key_modal<'a>( .push(card::simple( Column::new() .spacing(25) - .push(if !hws.is_empty() { + .push( Column::new() .push( Row::new() .spacing(10) .align_items(Alignment::Center) .push( - Container::new(text("Select a hardware wallet:").bold()) + Container::new(text("Select a signing device:").bold()) .width(Length::Fill), ) .push( @@ -1126,19 +1227,34 @@ pub fn edit_key_modal<'a>( )) }, )) - .width(Length::Fill) - } else { - Column::new() .push( - Row::new() - .spacing(15) - .width(Length::Fill) - .push(text("Connect a hardware wallet").bold().width(Length::Fill)) - .push(button::border(None, "Refresh").on_press(Message::Reload)) - .align_items(Alignment::Center), + Button::new( + Row::new() + .padding(5) + .width(Length::Fill) + .align_items(Alignment::Center) + .push( + Column::new() + .spacing(5) + .push(text("This computer").bold()) + .push( + text("Derive a key from a mnemonic stored on this computer").small(), + ) + .width(Length::Fill), + ) + .push_maybe(if chosen_signer { + Some(icon::circle_check_icon().style(color::SUCCESS)) + } else { + None + }) + .spacing(10), + ) + .width(Length::Fill) + .on_press(Message::UseHotSigner) + .style(button::Style::Border.into()), ) - .width(Length::Fill) - }) + .width(Length::Fill), + ) .push( Column::new() .spacing(5) @@ -1301,6 +1417,157 @@ fn hw_list_view( .into() } +pub fn backup_mnemonic<'a>( + progress: (usize, usize), + words: &'a [&'static str; 12], + done: bool, +) -> Element<'a, Message> { + layout( + progress, + Column::new() + .push(text("Backup your mnemonic").bold().size(50)) + .push(text(prompt::MNEMONIC_HELP)) + .push( + words + .iter() + .enumerate() + .fold(Column::new().spacing(5), |acc, (i, w)| { + acc.push( + Row::new() + .align_items(Alignment::End) + .push( + Container::new(text(format!("#{}", i + 1)).small()) + .width(Length::Units(50)), + ) + .push(text(*w).bold()), + ) + }), + ) + .push(Checkbox::new( + "I have backed up my mnemonic", + done, + Message::UserActionDone, + )) + .push(if done { + button::primary(None, "Next") + .on_press(Message::Next) + .width(Length::Units(200)) + } 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 recover_mnemonic<'a>( + progress: (usize, usize), + words: &'a [(String, bool); 12], + current: usize, + suggestions: &'a Vec, + recover: bool, + error: Option<&'a String>, +) -> Element<'a, Message> { + layout( + progress, + Column::new() + .push(text("Mnemonics import").bold().size(50)) + .push(text(prompt::RECOVER_MNEMONIC_HELP)) + .push_maybe(if recover { + Some( + Column::new() + .align_items(Alignment::Center) + .push( + Container::new(if !suggestions.is_empty() { + suggestions.iter().fold(Row::new().spacing(5), |row, sugg| { + row.push( + Button::new(text(sugg)) + .style(button::Style::Border.into()) + .on_press(Message::MnemonicWord( + current, + sugg.to_string(), + )), + ) + }) + } else { + Row::new() + }) + // Fixed height in order to not move words list + .height(Length::Units(50)), + ) + .push(words.iter().enumerate().fold( + Column::new().spacing(5), + |acc, (i, (word, valid))| { + acc.push( + Row::new() + .spacing(10) + .align_items(Alignment::Center) + .push( + Container::new(text(format!("#{}", i + 1)).small()) + .width(Length::Units(50)), + ) + .push( + Container::new(TextInput::new("", word, move |msg| { + Message::MnemonicWord(i, msg) + })) + .width(Length::Units(100)), + ) + .push_maybe(if *valid { + Some(icon::circle_check_icon().style(color::SUCCESS)) + } else { + None + }), + ) + }, + )) + .push(Space::with_height(Length::Units(50))) + .push_maybe(error.map(|e| card::invalid(text(e).style(color::ALERT)))), + ) + } else { + None + }) + .push(if !recover { + Row::new() + .spacing(10) + .push( + button::border(None, "Import mnemonic") + .on_press(Message::ImportMnemonic(true)) + .width(Length::Units(200)), + ) + .push( + button::primary(None, "Skip") + .on_press(Message::Skip) + .width(Length::Units(200)), + ) + } else { + Row::new() + .spacing(10) + .push( + button::border(None, "Cancel") + .on_press(Message::ImportMnemonic(false)) + .width(Length::Units(200)), + ) + .push( + if words.iter().any(|(_, valid)| !valid) || error.is_some() { + button::primary(None, "Next").width(Length::Units(200)) + } else { + button::primary(None, "Next") + .on_press(Message::Next) + .width(Length::Units(200)) + }, + ) + }) + .width(Length::Fill) + .height(Length::Fill) + .padding(100) + .spacing(50) + .align_items(Alignment::Center), + ) +} + fn layout<'a>( progress: (usize, usize), content: impl Into>, diff --git a/gui/src/lib.rs b/gui/src/lib.rs index 082e6287..8a377d14 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -4,6 +4,7 @@ pub mod hw; pub mod installer; pub mod launcher; pub mod loader; +pub mod signer; pub mod ui; pub mod utils; diff --git a/gui/src/loader.rs b/gui/src/loader.rs index 477bf8e7..67b30a08 100644 --- a/gui/src/loader.rs +++ b/gui/src/loader.rs @@ -13,6 +13,7 @@ use log::{debug, info}; use liana::{ config::{Config, ConfigError}, miniscript::bitcoin, + signer::HotSigner, StartupError, }; @@ -24,6 +25,7 @@ use crate::{ wallet::Wallet, }, daemon::{client, embedded::EmbeddedDaemon, model::*, Daemon, DaemonError}, + signer::Signer, ui::{ component::{button, notification, text::*}, icon, @@ -143,47 +145,14 @@ impl Loader { match res { 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)?; - 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)) - }, + load_application( + daemon.clone(), + info, + self.gui_config.clone(), + self.datadir_path.clone(), + self.network, + ), Message::Synced, ); } else { @@ -229,6 +198,10 @@ impl Loader { Message::Started(res) => self.on_start(res), Message::Loaded(res) => self.on_load(res), Message::Syncing(res) => self.on_sync(res), + Message::Synced(Err(e)) => { + self.step = Step::Error(Box::new(e)); + Command::none() + } Message::Failure(_) => { self.daemon_started = false; Command::none() @@ -246,6 +219,71 @@ impl Loader { } } +pub async fn load_application( + daemon: Arc, + info: GetInfoResult, + gui_config: GUIConfig, + datadir_path: Option, + network: bitcoin::Network, +) -> Result<(Arc, Cache, Arc), Error> { + 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 settings_path = settings_path(&datadir_path, network).unwrap(); + let gui_config_hws = gui_config + .hardware_wallets + .as_ref() + .cloned() + .unwrap_or_default(); + + let mut wallet = match Settings::from_file(&settings_path) { + Ok(settings) => { + if let Some(wallet_setting) = settings.wallets.first() { + Wallet::new(wallet_setting.name.clone(), info.descriptors.main) + .with_hardware_wallets(wallet_setting.hardware_wallets.clone()) + .with_key_aliases(wallet_setting.keys_aliases()) + } else { + Wallet::legacy(info.descriptors.main).with_hardware_wallets(gui_config_hws) + } + } + Err(settings::SettingsError::NotFound) => { + Wallet::legacy(info.descriptors.main).with_hardware_wallets(gui_config_hws) + } + Err(e) => return Err(e.into()), + }; + + let hot_signers = match HotSigner::from_datadir(&get_datadir_path(&datadir_path)?, network) { + Ok(signers) => signers, + Err(e) => match e { + liana::signer::SignerError::MnemonicStorage(e) => { + if e.kind() == std::io::ErrorKind::NotFound { + Vec::new() + } else { + return Err(Error::HotSigner(e.to_string())); + } + } + _ => return Err(Error::HotSigner(e.to_string())), + }, + }; + + let curve = bitcoin::secp256k1::Secp256k1::signing_only(); + let keys = wallet.descriptor_keys(); + if let Some(hot_signer) = hot_signers + .into_iter() + .find(|s| keys.contains(&s.fingerprint(&curve))) + { + wallet = wallet.with_signer(Signer::new(hot_signer)); + } + + Ok((Arc::new(wallet), cache, daemon)) +} + #[derive(Clone, Debug)] pub enum ViewMessage { Retry, @@ -368,6 +406,7 @@ pub enum Error { Settings(settings::SettingsError), Config(ConfigError), Daemon(DaemonError), + HotSigner(String), } impl std::fmt::Display for Error { @@ -376,6 +415,7 @@ impl std::fmt::Display for Error { 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), + Self::HotSigner(e) => write!(f, "Failed to load hot signer: {}", e), } } } @@ -398,16 +438,20 @@ impl From for Error { } } +fn get_datadir_path(datadir_path: &Option) -> Result { + if let Some(ref datadir) = datadir_path { + Ok(datadir.clone()) + } else { + default_datadir().map_err(|_| ConfigError::DatadirNotFound) + } +} + /// default lianad socket path is .liana/bitcoin/lianad_rpc fn socket_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)? - }; + let mut path = get_datadir_path(datadir)?; path.push(network.to_string()); path.push("lianad_rpc"); Ok(path) @@ -418,11 +462,7 @@ 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)? - }; + let mut path = get_datadir_path(datadir)?; path.push(network.to_string()); path.push(settings::DEFAULT_FILE_NAME); Ok(path) diff --git a/gui/src/signer.rs b/gui/src/signer.rs new file mode 100644 index 00000000..cad4ffc5 --- /dev/null +++ b/gui/src/signer.rs @@ -0,0 +1,69 @@ +pub use liana::signer::SignerError; + +use liana::{ + miniscript::bitcoin::{ + secp256k1, + util::{ + bip32::{DerivationPath, ExtendedPubKey, Fingerprint}, + psbt::Psbt, + }, + Network, + }, + signer::HotSigner, +}; + +pub struct Signer { + curve: secp256k1::Secp256k1, + key: HotSigner, + fingerprint: Fingerprint, +} + +impl std::fmt::Debug for Signer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Signer").finish() + } +} + +impl Signer { + pub fn new(key: HotSigner) -> Self { + let curve = secp256k1::Secp256k1::signing_only(); + let fingerprint = key.fingerprint(&curve); + Self { + key, + curve, + fingerprint, + } + } + + pub fn set_network(&mut self, network: Network) { + self.key.set_network(network) + } + + pub fn mnemonic(&self) -> [&'static str; 12] { + self.key.words() + } + + pub fn generate(network: Network) -> Result { + Ok(Self::new(HotSigner::generate(network)?)) + } + + pub fn fingerprint(&self) -> Fingerprint { + self.fingerprint + } + + pub fn get_extended_pubkey(&self, path: &DerivationPath) -> ExtendedPubKey { + self.key.xpub_at(path, &self.curve) + } + + pub fn sign_psbt(&self, psbt: Psbt) -> Result { + self.key.sign_psbt(psbt, &self.curve) + } + + pub fn store( + &self, + datadir_root: &std::path::Path, + network: Network, + ) -> Result<(), SignerError> { + self.key.store(datadir_root, network, &self.curve) + } +}