diff --git a/gui/src/installer/message.rs b/gui/src/installer/message.rs index 784df2c9..f403f469 100644 --- a/gui/src/installer/message.rs +++ b/gui/src/installer/message.rs @@ -16,6 +16,7 @@ pub enum Message { Exit(PathBuf), Clibpboard(String), Next, + Skip, Previous, Install, Close, @@ -29,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 86dfebd7..2465f1af 100644 --- a/gui/src/installer/mod.rs +++ b/gui/src/installer/mod.rs @@ -16,7 +16,7 @@ use crate::app::{config as gui_config, settings as gui_settings}; pub use message::Message; use step::{ BackupDescriptor, BackupMnemonic, DefineBitcoind, DefineDescriptor, Final, ImportDescriptor, - ParticipateXpub, RegisterDescriptor, Step, Welcome, + ParticipateXpub, RecoverMnemonic, RegisterDescriptor, Step, Welcome, }; pub struct Installer { @@ -116,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(), diff --git a/gui/src/installer/prompt.rs b/gui/src/installer/prompt.rs index f2055479..b9123666 100644 --- a/gui/src/installer/prompt.rs +++ b/gui/src/installer/prompt.rs @@ -8,3 +8,4 @@ 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 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/mnemonic.rs b/gui/src/installer/step/mnemonic.rs index de3cb7af..5f88bea9 100644 --- a/gui/src/installer/step/mnemonic.rs +++ b/gui/src/installer/step/mnemonic.rs @@ -1,6 +1,13 @@ -use crate::installer::{context::Context, message::Message, step::Step, view}; +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 { @@ -33,3 +40,122 @@ impl Step for BackupMnemonic { 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 91efff56..d1119ffa 100644 --- a/gui/src/installer/step/mod.rs +++ b/gui/src/installer/step/mod.rs @@ -5,7 +5,7 @@ pub use descriptor::{ BackupDescriptor, DefineDescriptor, ImportDescriptor, ParticipateXpub, RegisterDescriptor, }; -pub use mnemonic::BackupMnemonic; +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 6e8cede5..536fa877 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}; @@ -1435,6 +1436,111 @@ pub fn backup_mnemonic<'a>( ) } +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>,