From 8ddcece8204d6e0690cc5346a2d94c8f89d052c0 Mon Sep 17 00:00:00 2001 From: edouardparis Date: Wed, 7 May 2025 15:23:36 +0200 Subject: [PATCH 1/8] Add wallet id for settings filtering --- liana-gui/src/app/settings.rs | 42 +++-- liana-gui/src/app/state/settings/wallet.rs | 8 +- liana-gui/src/app/wallet.rs | 48 ++++-- liana-gui/src/backup.rs | 7 +- liana-gui/src/installer/mod.rs | 2 + liana-gui/src/launcher.rs | 179 ++++++++++----------- liana-gui/src/loader.rs | 20 ++- liana-gui/src/main.rs | 36 ++--- 8 files changed, 176 insertions(+), 166 deletions(-) diff --git a/liana-gui/src/app/settings.rs b/liana-gui/src/app/settings.rs index eca160d8..0fc72217 100644 --- a/liana-gui/src/app/settings.rs +++ b/liana-gui/src/app/settings.rs @@ -26,6 +26,24 @@ pub struct Settings { pub wallets: Vec, } +impl Settings { + pub fn from_file(network_dir: &NetworkDirectory) -> Result { + let mut path = network_dir.path().to_path_buf(); + path.push(DEFAULT_FILE_NAME); + + 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)) + }) + }) + } +} + pub async fn update_settings_file( network_dir: &NetworkDirectory, updater: F, @@ -107,6 +125,7 @@ impl AuthConfig { pub struct WalletSettings { pub name: String, pub descriptor_checksum: String, + pub pinned_at: Option, // if wallet is using remote backend, then this information is stored on the remote backend // wallet metadata #[serde(default)] @@ -129,20 +148,7 @@ impl WalletSettings { where F: FnMut(&WalletSettings) -> bool, { - let mut path = network_dir.path().to_path_buf(); - path.push(DEFAULT_FILE_NAME); - - 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)) - }) - }) - .map(|cache| cache.wallets.into_iter().find(selecter)) + Settings::from_file(network_dir).map(|cache| cache.wallets.into_iter().find(selecter)) } pub fn keys_aliases(&self) -> HashMap { @@ -183,6 +189,14 @@ impl WalletSettings { .collect(); } } + + pub fn wallet_id(&self) -> String { + if let Some(t) = self.pinned_at { + format!("{}-{}", self.descriptor_checksum, t) + } else { + self.descriptor_checksum.clone() + } + } } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] diff --git a/liana-gui/src/app/state/settings/wallet.rs b/liana-gui/src/app/state/settings/wallet.rs index 92f0f230..8d24ac6b 100644 --- a/liana-gui/src/app/state/settings/wallet.rs +++ b/liana-gui/src/app/state/settings/wallet.rs @@ -428,12 +428,12 @@ async fn register_wallet( if daemon.backend() != DaemonBackend::RemoteBackend { let network_dir = data_dir.network_directory(network); - let checksum = wallet.descriptor_checksum(); + let wallet_id = wallet.id(); update_settings_file(&network_dir, |mut settings| { if let Some(wallet_setting) = settings .wallets .iter_mut() - .find(|w| w.descriptor_checksum == checksum) + .find(|w| w.wallet_id() == wallet_id) { if let Some(hw_config) = wallet_setting .hardware_wallets @@ -479,12 +479,12 @@ pub async fn update_keys_aliases( ) -> Result, Error> { if daemon.backend() != DaemonBackend::RemoteBackend { let network_dir = data_dir.network_directory(network); - let checksum = wallet.descriptor_checksum(); + let wallet_id = wallet.id(); update_settings_file(&network_dir, |mut settings| { if let Some(wallet_setting) = settings .wallets .iter_mut() - .find(|w| w.descriptor_checksum == checksum) + .find(|w| w.wallet_id() == wallet_id) { wallet_setting.keys = keys_aliases .iter() diff --git a/liana-gui/src/app/wallet.rs b/liana-gui/src/app/wallet.rs index 1cfdcf10..6aae66f9 100644 --- a/liana-gui/src/app/wallet.rs +++ b/liana-gui/src/app/wallet.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use crate::dir::{LianaDirectory, NetworkDirectory}; +use crate::dir::LianaDirectory; use crate::{ app::settings, daemon::DaemonBackend, hw::HardwareWalletConfig, node::NodeType, signer::Signer, }; @@ -11,6 +11,8 @@ use liana::{miniscript::bitcoin, signer::HotSigner}; use liana::descriptors::LianaDescriptor; use liana::miniscript::bitcoin::bip32::Fingerprint; +use super::settings::WalletSettings; + const DEFAULT_WALLET_NAME: &str = "Liana"; pub fn wallet_name(main_descriptor: &LianaDescriptor) -> String { @@ -31,6 +33,8 @@ pub fn wallet_name(main_descriptor: &LianaDescriptor) -> String { pub struct Wallet { pub name: String, pub main_descriptor: LianaDescriptor, + pub descriptor_checksum: String, + pub pinned_at: Option, // TODO: We could replace these two fields with `keys: HashMap`. pub keys_aliases: HashMap, pub provider_keys: HashMap, @@ -42,6 +46,13 @@ impl Wallet { pub fn new(main_descriptor: LianaDescriptor) -> Self { Self { name: wallet_name(&main_descriptor), + descriptor_checksum: main_descriptor + .to_string() + .split_once('#') + .map(|(_, checksum)| checksum) + .unwrap() + .to_string(), + pinned_at: None, main_descriptor, keys_aliases: HashMap::new(), provider_keys: HashMap::new(), @@ -55,6 +66,20 @@ impl Wallet { self } + pub fn with_pin_date(mut self, pinned_at: Option) -> Self { + self.pinned_at = pinned_at; + self + } + + // To match with WalletSettings.wallet_id + pub fn id(&self) -> String { + if let Some(t) = self.pinned_at { + format!("{}-{}", self.descriptor_checksum, t) + } else { + self.descriptor_checksum.clone() + } + } + pub fn with_key_aliases(mut self, aliases: HashMap) -> Self { self.keys_aliases = aliases; self @@ -92,26 +117,15 @@ impl Wallet { descriptor_keys } - pub fn descriptor_checksum(&self) -> String { - self.main_descriptor - .to_string() - .split_once('#') - .map(|(_, checksum)| checksum) - .unwrap() - .to_string() - } - - pub fn load_from_settings(self, dir: &NetworkDirectory) -> Result { - if let Some(wallet_settings) = settings::WalletSettings::from_file(dir, |w| { - w.descriptor_checksum == self.descriptor_checksum() - })? { + pub fn load_from_settings(self, wallet_settings: WalletSettings) -> Result { + if wallet_settings.descriptor_checksum != self.descriptor_checksum { + Err(WalletError::WrongWalletLoaded) + } else { Ok(self .with_key_aliases(wallet_settings.keys_aliases()) .with_provider_keys(wallet_settings.provider_keys()) .with_name(wallet_settings.name) .with_hardware_wallets(wallet_settings.hardware_wallets)) - } else { - Ok(self) } } @@ -172,6 +186,7 @@ impl Wallet { #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub enum WalletError { + WrongWalletLoaded, Settings(settings::SettingsError), HotSigner(String), } @@ -179,6 +194,7 @@ pub enum WalletError { impl std::fmt::Display for WalletError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { + Self::WrongWalletLoaded => write!(f, "Wrong wallet was loaded"), Self::Settings(e) => write!(f, "Failed to load settings: {}", e), Self::HotSigner(e) => write!(f, "Failed to load hot signer: {}", e), } diff --git a/liana-gui/src/backup.rs b/liana-gui/src/backup.rs index 11a9aa61..5ff66f63 100644 --- a/liana-gui/src/backup.rs +++ b/liana-gui/src/backup.rs @@ -199,10 +199,9 @@ impl Backup { let keys = wallet.keys(); let network_dir = datadir.network_directory(network); - if let Some(settings) = WalletSettings::from_file(&network_dir, |settings| { - wallet.descriptor_checksum() == settings.descriptor_checksum - }) - .map_err(|_| Error::SettingsFromFile)? + if let Some(settings) = + WalletSettings::from_file(&network_dir, |settings| wallet.id() == settings.wallet_id()) + .map_err(|_| Error::SettingsFromFile)? { if let Ok(settings) = serde_json::to_value(settings) { proprietary.insert(SETTINGS_KEY.to_string(), settings); diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index 2ebce40b..1ca0d102 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -668,6 +668,7 @@ pub async fn extract_remote_gui_settings(ctx: &Context, backend: &BackendWalletC wallets: vec![WalletSettings { name: wallet_name(descriptor), descriptor_checksum, + pinned_at: Some(chrono::Utc::now().timestamp()), keys: Vec::new(), hardware_wallets: Vec::new(), remote_backend_auth: Some(AuthConfig::new( @@ -704,6 +705,7 @@ pub fn extract_local_gui_settings(ctx: &Context) -> Settings { Settings { wallets: vec![WalletSettings { name: wallet_name(descriptor), + pinned_at: Some(chrono::Utc::now().timestamp()), descriptor_checksum, keys: ctx.keys.values().cloned().collect(), hardware_wallets, diff --git a/liana-gui/src/launcher.rs b/liana-gui/src/launcher.rs index 0fa95e16..3c120a7a 100644 --- a/liana-gui/src/launcher.rs +++ b/liana-gui/src/launcher.rs @@ -13,7 +13,7 @@ use liana_ui::{ use lianad::config::ConfigError; use crate::{ - app, + app::{self, settings::WalletSettings}, dir::{LianaDirectory, NetworkDirectory}, installer::UserFlow, }; @@ -28,11 +28,7 @@ const NETWORKS: [Network; 4] = [ #[derive(Debug, Clone)] pub enum State { Unchecked, - Wallet { - name: Option, - email: Option, - checksum: Option, - }, + Wallets(Vec), NoWallet, } @@ -133,20 +129,26 @@ impl Launcher { Task::none() } }, - Message::View(ViewMessage::Run) => { - if matches!(self.state, State::Wallet { .. }) { - let datadir_path = self.datadir_path.clone(); - let mut path = self - .datadir_path - .network_directory(self.network) - .path() - .to_path_buf(); - path.push(app::config::DEFAULT_FILE_NAME); - let cfg = app::Config::from_file(&path).expect("Already checked"); - let network = self.network; - Task::perform(async move { (datadir_path.clone(), cfg, network) }, |m| { - Message::Run(m.0, m.1, m.2) - }) + Message::View(ViewMessage::Run(index)) => { + if let State::Wallets(wallets) = &self.state { + if let Some(settings) = wallets.get(index) { + let datadir_path = self.datadir_path.clone(); + let mut path = self + .datadir_path + .network_directory(self.network) + .path() + .to_path_buf(); + path.push(app::config::DEFAULT_FILE_NAME); + let cfg = app::Config::from_file(&path).expect("Already checked"); + let network = self.network; + let settings = settings.clone(); + Task::perform( + async move { (datadir_path.clone(), cfg, network, settings) }, + |m| Message::Run(m.0, m.1, m.2, m.3), + ) + } else { + Task::none() + } } else { Task::none() } @@ -191,7 +193,7 @@ impl Launcher { Column::new() .align_x(Alignment::Center) .spacing(30) - .push(if matches!(self.state, State::Wallet { .. }) { + .push(if matches!(self.state, State::Wallets { .. }) { text("Welcome back").size(50).bold() } else { text("Welcome").size(50).bold() @@ -199,63 +201,14 @@ impl Launcher { .push_maybe(self.error.as_ref().map(|e| card::simple(text(e)))) .push(match &self.state { State::Unchecked => Column::new(), - State::Wallet { - email, checksum, .. - } => Column::new().push( - Row::new() - .align_y(Alignment::Center) - .spacing(20) - .push( - Container::new( - Button::new( - Column::new() - .push(p1_bold(format!( - "My Liana {} wallet", - match self.network { - Network::Bitcoin => "Bitcoin", - Network::Signet => "Signet", - Network::Testnet => "Testnet", - Network::Regtest => "Regtest", - _ => "", - } - ))) - .push_maybe(checksum.as_ref().map( - |checksum| { - p1_regular(format!( - "Liana-{}", - checksum - )) - .style(theme::text::secondary) - }, - )) - .push_maybe(email.as_ref().map(|email| { - Row::new() - .push(Space::with_width( - Length::Fill, - )) - .push( - p1_regular(email).style( - theme::text::secondary, - ), - ) - })), - ) - .on_press(ViewMessage::Run) - .padding(15) - .style(theme::button::container_border) - .width(Length::Fill), - ) - .style(theme::card::simple), - ) - .push( - Button::new(icon::trash_icon()) - .style(theme::button::secondary) - .padding(10) - .on_press(ViewMessage::DeleteWallet( - DeleteWalletMessage::ShowModal, - )), - ), - ), + State::Wallets(wallets) => { + Column::new().push(wallets.iter().enumerate().fold( + Column::new().spacing(20), + |col, (i, settings)| { + col.push(wallets_list_item(self.network, settings, i)) + }, + )) + } State::NoWallet => Column::new() .push( Row::new() @@ -338,12 +291,63 @@ impl Launcher { } } +fn wallets_list_item( + network: Network, + settings: &WalletSettings, + i: usize, +) -> Element { + Container::new( + Row::new() + .align_y(Alignment::Center) + .spacing(20) + .push( + Container::new( + Button::new( + Column::new() + .push(p1_bold(format!( + "My Liana {} wallet", + match network { + Network::Bitcoin => "Bitcoin", + Network::Signet => "Signet", + Network::Testnet => "Testnet", + Network::Regtest => "Regtest", + _ => "", + } + ))) + .push( + p1_regular(format!("Liana-{}", settings.descriptor_checksum)) + .style(theme::text::secondary), + ) + .push_maybe(settings.remote_backend_auth.as_ref().map(|auth| { + Row::new() + .push(Space::with_width(Length::Fill)) + .push(p1_regular(&auth.email).style(theme::text::secondary)) + })), + ) + .on_press(ViewMessage::Run(i)) + .padding(15) + .style(theme::button::container_border) + .width(Length::Fill), + ) + .style(theme::card::simple), + ) + .push( + Button::new(icon::trash_icon()) + .style(theme::button::secondary) + .padding(10) + .on_press(ViewMessage::DeleteWallet(DeleteWalletMessage::ShowModal)), + ), + ) + .into() +} + +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone)] pub enum Message { View(ViewMessage), Install(LianaDirectory, Network, UserFlow), Checked(Result), - Run(LianaDirectory, app::config::Config, Network), + Run(LianaDirectory, app::config::Config, Network, WalletSettings), } #[derive(Debug, Clone)] @@ -354,7 +358,7 @@ pub enum ViewMessage { SelectNetwork(Network), StartInstall(Network), Check, - Run, + Run(usize), DeleteWallet(DeleteWalletMessage), } @@ -513,16 +517,7 @@ async fn check_network_datadir(path: NetworkDirectory) -> Result })?; } - if let Ok(Some(wallet)) = app::settings::WalletSettings::from_file(&path, |_w| true) { - return Ok(State::Wallet { - name: Some(wallet.name), - checksum: Some(wallet.descriptor_checksum), - email: wallet.remote_backend_auth.map(|auth| auth.email), - }); - } - Ok(State::Wallet { - name: None, - checksum: None, - email: None, - }) + app::settings::Settings::from_file(&path) + .map(|s| State::Wallets(s.wallets)) + .map_err(|e| e.to_string()) } diff --git a/liana-gui/src/loader.rs b/liana-gui/src/loader.rs index c34c5dbe..b595f955 100644 --- a/liana-gui/src/loader.rs +++ b/liana-gui/src/loader.rs @@ -59,7 +59,7 @@ pub struct Loader { pub internal_bitcoind: Option, pub waiting_daemon_bitcoind: bool, pub backup: Option, - pub wallet_setting: Option, + pub wallet_settings: WalletSettings, step: Step, } @@ -119,7 +119,7 @@ impl Loader { network: bitcoin::Network, internal_bitcoind: Option, backup: Option, - wallet_setting: Option, + wallet_settings: WalletSettings, ) -> (Self, Task) { let path = socket_path(&datadir_path, network); ( @@ -131,7 +131,7 @@ impl Loader { daemon_started: false, internal_bitcoind, waiting_daemon_bitcoind: false, - wallet_setting, + wallet_settings, backup, }, Task::perform(connect(path), Message::Loaded), @@ -141,11 +141,7 @@ impl Loader { fn start_bitcoind(&self) -> bool { if self.internal_bitcoind.is_some() { false - } else if let Some(start) = self - .wallet_setting - .as_ref() - .and_then(|setting| setting.start_internal_bitcoind) - { + } else if let Some(start) = self.wallet_settings.start_internal_bitcoind { start } else { self.gui_config.start_internal_bitcoind @@ -162,6 +158,7 @@ impl Loader { if info.block_height > 0 { return Task::perform( load_application( + self.wallet_settings.clone(), daemon, info, self.datadir_path.clone(), @@ -256,6 +253,7 @@ impl Loader { if (info.sync - 1.0_f64).abs() < f64::EPSILON { return Task::perform( load_application( + self.wallet_settings.clone(), daemon.clone(), info, self.datadir_path.clone(), @@ -309,7 +307,7 @@ impl Loader { self.network, self.internal_bitcoind.clone(), self.backup.clone(), - self.wallet_setting.clone(), + self.wallet_settings.clone(), ); *self = loader; cmd @@ -406,6 +404,7 @@ fn get_bitcoind_log(log_path: PathBuf) -> impl Stream> { } pub async fn load_application( + wallet_settings: WalletSettings, daemon: Arc, info: GetInfoResult, datadir_path: LianaDirectory, @@ -422,9 +421,8 @@ pub async fn load_application( ), Error, > { - let network_dir = datadir_path.network_directory(network); let wallet = Wallet::new(info.descriptors.main) - .load_from_settings(&network_dir)? + .load_from_settings(wallet_settings)? .load_hotsigners(&datadir_path, network)?; let coins = coins_to_cache(daemon.clone()).await.map(|res| res.coins)?; diff --git a/liana-gui/src/main.rs b/liana-gui/src/main.rs index 42dc9cc8..e1b9445d 100644 --- a/liana-gui/src/main.rs +++ b/liana-gui/src/main.rs @@ -200,34 +200,21 @@ impl GUI { self.state = State::Installer(Box::new(install)); command.map(|msg| Message::Install(Box::new(msg))) } - launcher::Message::Run(datadir_path, cfg, network) => { + launcher::Message::Run(datadir_path, cfg, network, settings) => { self.logger.set_running_mode( datadir_path.clone(), network, self.log_level .unwrap_or_else(|| cfg.log_level().unwrap_or(LevelFilter::INFO)), ); - let network_dir = datadir_path.network_directory(network); - if let Ok(settings) = - app::settings::WalletSettings::from_file(&network_dir, |_w| true) - { - if let Some(setting) = settings - .as_ref() - .and_then(|w| w.remote_backend_auth.clone()) - { - let (login, command) = - login::LianaLiteLogin::new(datadir_path, network, setting); - self.state = State::Login(Box::new(login)); - command.map(|msg| Message::Login(Box::new(msg))) - } else { - let (loader, command) = - Loader::new(datadir_path, cfg, network, None, None, settings); - self.state = State::Loader(Box::new(loader)); - command.map(|msg| Message::Load(Box::new(msg))) - } + if let Some(setting) = settings.remote_backend_auth { + let (login, command) = + login::LianaLiteLogin::new(datadir_path, network, setting); + self.state = State::Login(Box::new(login)); + command.map(|msg| Message::Login(Box::new(msg))) } else { let (loader, command) = - Loader::new(datadir_path, cfg, network, None, None, None); + Loader::new(datadir_path, cfg, network, None, None, settings); self.state = State::Loader(Box::new(loader)); command.map(|msg| Message::Load(Box::new(msg))) } @@ -281,12 +268,11 @@ impl GUI { (State::Installer(i), Message::Install(msg)) => { if let installer::Message::Exit(path, internal_bitcoind, remove_log) = *msg { let network_dir = i.datadir.network_directory(i.network); + // We get the first one created. let settings = app::settings::WalletSettings::from_file(&network_dir, |_| true) - .expect("A settings file was created"); - if let Some(setting) = settings - .as_ref() - .and_then(|w| w.remote_backend_auth.clone()) - { + .expect("A settings file was created") + .expect("A wallet was created"); + if let Some(setting) = settings.remote_backend_auth { let (login, command) = login::LianaLiteLogin::new(i.datadir.clone(), i.network, setting); self.state = State::Login(Box::new(login)); From e85f89dc7f1f83c8e74bf69a564671dfc4275848 Mon Sep 17 00:00:00 2001 From: edouardparis Date: Wed, 7 May 2025 19:49:38 +0200 Subject: [PATCH 2/8] Add wallet list to launcher --- liana-gui/src/launcher.rs | 170 +++++++++++++++++++++++--------------- 1 file changed, 102 insertions(+), 68 deletions(-) diff --git a/liana-gui/src/launcher.rs b/liana-gui/src/launcher.rs index 3c120a7a..105de7b6 100644 --- a/liana-gui/src/launcher.rs +++ b/liana-gui/src/launcher.rs @@ -28,7 +28,10 @@ const NETWORKS: [Network; 4] = [ #[derive(Debug, Clone)] pub enum State { Unchecked, - Wallets(Vec), + Wallets { + wallets: Vec, + add_wallet: bool, + }, NoWallet, } @@ -115,6 +118,7 @@ impl Launcher { self.state = State::NoWallet; Task::none() } + Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::CloseModal)) => { self.delete_wallet_modal = None; Task::none() @@ -129,8 +133,14 @@ impl Launcher { Task::none() } }, + Message::View(ViewMessage::AddWalletToList(add)) => { + if let State::Wallets { add_wallet, .. } = &mut self.state { + *add_wallet = add; + } + Task::none() + } Message::View(ViewMessage::Run(index)) => { - if let State::Wallets(wallets) = &self.state { + if let State::Wallets { wallets, .. } = &self.state { if let Some(settings) = wallets.get(index) { let datadir_path = self.datadir_path.clone(); let mut path = self @@ -172,6 +182,21 @@ impl Launcher { Container::new(image::liana_brand_grey().width(Length::Fixed(200.0))) .width(Length::Fill), ) + .push_maybe(if let State::Wallets { add_wallet, .. } = &self.state { + if *add_wallet { + Some( + button::secondary( + Some(icon::previous_icon()), + "Back to wallet list", + ) + .on_press(ViewMessage::AddWalletToList(false)), + ) + } else { + None + } + } else { + None + }) .push( button::secondary(None, "Share Xpubs") .on_press(ViewMessage::ShareXpubs), @@ -201,73 +226,39 @@ impl Launcher { .push_maybe(self.error.as_ref().map(|e| card::simple(text(e)))) .push(match &self.state { State::Unchecked => Column::new(), - State::Wallets(wallets) => { - Column::new().push(wallets.iter().enumerate().fold( - Column::new().spacing(20), - |col, (i, settings)| { - col.push(wallets_list_item(self.network, settings, i)) - }, - )) - } - State::NoWallet => Column::new() - .push( - Row::new() - .align_y(Alignment::End) - .spacing(20) - .push( - Container::new( - Column::new() - .spacing(20) - .align_x(Alignment::Center) - .push( - image::create_new_wallet_icon() - .width(Length::Fixed(100.0)), - ) - .push( - p1_regular("Create a new Liana wallet") - .style(theme::text::secondary), - ) - .push( - button::secondary(None, "Select") - .width(Length::Fixed(200.0)) - .on_press( - ViewMessage::CreateWallet, - ), - ) - .align_x(Alignment::Center), + State::Wallets { + wallets, + add_wallet, + } => { + if *add_wallet { + Column::new().push(add_wallet_menu()) + } else { + let col = wallets.iter().enumerate().fold( + Column::new().spacing(20), + |col, (i, settings)| { + col.push(wallets_list_item( + self.network, + settings, + i, + )) + }, + ); + col.push( + Column::new().push( + button::secondary( + Some(icon::plus_icon()), + "Add wallet", ) - .padding(20), - ) - .push( - Container::new( - Column::new() - .spacing(20) - .align_x(Alignment::Center) - .push( - image::restore_wallet_icon() - .width(Length::Fixed(100.0)), - ) - .push( - p1_regular( - "Add an existing Liana wallet", - ) - .style(theme::text::secondary), - ) - .push( - button::secondary(None, "Select") - .width(Length::Fixed(200.0)) - .on_press( - ViewMessage::ImportWallet, - ), - ) - .align_x(Alignment::Center), - ) - .padding(20), + .on_press(ViewMessage::AddWalletToList(true)) + .padding(10) + .width(Length::Fixed(500.0)), ), - ) - .align_x(Alignment::Center), + ) + } + } + State::NoWallet => Column::new().push(add_wallet_menu()), }) - .max_width(500), + .align_x(Alignment::Center), ) .center_x(Length::Fill), ) @@ -291,6 +282,45 @@ impl Launcher { } } +fn add_wallet_menu<'a>() -> Element<'a, ViewMessage> { + Row::new() + .align_y(Alignment::End) + .spacing(20) + .push( + Container::new( + Column::new() + .spacing(20) + .align_x(Alignment::Center) + .push(image::create_new_wallet_icon().width(Length::Fixed(100.0))) + .push(p1_regular("Create a new Liana wallet").style(theme::text::secondary)) + .push( + button::secondary(None, "Select") + .width(Length::Fixed(200.0)) + .on_press(ViewMessage::CreateWallet), + ) + .align_x(Alignment::Center), + ) + .padding(20), + ) + .push( + Container::new( + Column::new() + .spacing(20) + .align_x(Alignment::Center) + .push(image::restore_wallet_icon().width(Length::Fixed(100.0))) + .push(p1_regular("Add an existing Liana wallet").style(theme::text::secondary)) + .push( + button::secondary(None, "Select") + .width(Length::Fixed(200.0)) + .on_press(ViewMessage::ImportWallet), + ) + .align_x(Alignment::Center), + ) + .padding(20), + ) + .into() +} + fn wallets_list_item( network: Network, settings: &WalletSettings, @@ -327,7 +357,7 @@ fn wallets_list_item( .on_press(ViewMessage::Run(i)) .padding(15) .style(theme::button::container_border) - .width(Length::Fill), + .width(Length::Fixed(500.0)), ) .style(theme::card::simple), ) @@ -354,6 +384,7 @@ pub enum Message { pub enum ViewMessage { ImportWallet, CreateWallet, + AddWalletToList(bool), ShareXpubs, SelectNetwork(Network), StartInstall(Network), @@ -518,6 +549,9 @@ async fn check_network_datadir(path: NetworkDirectory) -> Result } app::settings::Settings::from_file(&path) - .map(|s| State::Wallets(s.wallets)) + .map(|s| State::Wallets { + wallets: s.wallets, + add_wallet: false, + }) .map_err(|e| e.to_string()) } From 58bccef4cdcae5bdbd5389dcea87e44e0cbdf267 Mon Sep 17 00:00:00 2001 From: edouardparis Date: Thu, 8 May 2025 17:01:28 +0200 Subject: [PATCH 3/8] Append wallet settings to file during creation --- liana-gui/src/backup.rs | 5 -- liana-gui/src/installer/message.rs | 10 ++- liana-gui/src/installer/mod.rs | 114 ++++++++++++++-------------- liana-gui/src/installer/step/mod.rs | 28 +++---- liana-gui/src/installer/view/mod.rs | 4 +- liana-gui/src/main.rs | 21 ++--- 6 files changed, 89 insertions(+), 93 deletions(-) diff --git a/liana-gui/src/backup.rs b/liana-gui/src/backup.rs index 5ff66f63..cb6664d5 100644 --- a/liana-gui/src/backup.rs +++ b/liana-gui/src/backup.rs @@ -148,11 +148,6 @@ impl Backup { }; let name = if let Some(settings) = settings { - assert_eq!(settings.wallets.len(), 1); - if settings.wallets.len() != 1 { - return Err(Error::NotSingleWallet); - } - let settings = settings.wallets.first().expect("only one wallet"); let name = settings.name.clone(); if let Ok(settings) = serde_json::to_value(settings) { proprietary.insert(SETTINGS_KEY.to_string(), settings); diff --git a/liana-gui/src/installer/message.rs b/liana-gui/src/installer/message.rs index c1ee32ca..804d8616 100644 --- a/liana-gui/src/installer/message.rs +++ b/liana-gui/src/installer/message.rs @@ -2,7 +2,7 @@ use liana::miniscript::{ bitcoin::{bip32::Fingerprint, Network}, DescriptorPublicKey, }; -use std::{collections::HashMap, path::PathBuf}; +use std::collections::HashMap; use super::{context, Error}; use crate::{ @@ -28,7 +28,11 @@ use crate::{ #[derive(Debug, Clone)] pub enum Message { UserActionDone(bool), - Exit(PathBuf, Option, /* remove log */ bool), + Exit( + Box, + Option, + /* remove log */ bool, + ), Clibpboard(String), Next, Skip, @@ -39,7 +43,7 @@ pub enum Message { Reload, Select(usize), UseHotSigner, - Installed(Result), + Installed(Result), CreateTaprootDescriptor(bool), SelectDescriptorTemplate(context::DescriptorTemplate), SelectBackend(SelectBackend), diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index 1ca0d102..959791ab 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -23,7 +23,7 @@ use std::sync::{Arc, Mutex}; use crate::{ app::{ config as gui_config, - settings::{self as gui_settings, AuthConfig, Settings, SettingsError, WalletSettings}, + settings::{update_settings_file, AuthConfig, SettingsError, WalletSettings}, wallet::wallet_name, }, backup, @@ -362,7 +362,7 @@ pub fn daemon_check(cfg: lianad::config::Config) -> Result<(), Error> { pub async fn install_local_wallet( ctx: Context, signer: Arc>, -) -> Result { +) -> Result { let network_datadir = ctx .liana_directory .network_directory(ctx.bitcoin_config.network); @@ -412,7 +412,7 @@ pub async fn install_local_wallet( } // create liana GUI configuration file - let gui_config_path = create_and_write_file( + let _gui_config_path = create_and_write_file( &network_datadir, gui_config::DEFAULT_FILE_NAME, toml::to_string(&gui_config::Config::new( @@ -426,25 +426,24 @@ pub async fn install_local_wallet( info!("Gui configuration file created"); // create liana GUI settings file - let settings: gui_settings::Settings = extract_local_gui_settings(&ctx); - create_and_write_file( - &network_datadir, - gui_settings::DEFAULT_FILE_NAME, - serde_json::to_string_pretty(&settings) - .map_err(|e| Error::Unexpected(format!("Failed to serialize settings: {}", e)))? - .as_bytes(), - )?; + let wallet_settings = extract_local_gui_settings(&ctx); + update_settings_file(&network_datadir, |mut settings| { + settings.wallets.push(wallet_settings.clone()); + settings + }) + .await + .map_err(|e| Error::Unexpected(e.to_string()))?; info!("Settings file created"); - Ok(gui_config_path) + Ok(wallet_settings) } pub async fn create_remote_wallet( ctx: Context, signer: Arc>, remote_backend: BackendClient, -) -> Result { +) -> Result { let network_datadir = ctx.liana_directory.network_directory(ctx.network); network_datadir .init() @@ -477,7 +476,7 @@ pub async fn create_remote_wallet( } // create liana GUI configuration file - let gui_config_path = create_and_write_file( + let _gui_config_path = create_and_write_file( &network_datadir, gui_config::DEFAULT_FILE_NAME, toml::to_string(&gui_config::Config { @@ -540,14 +539,13 @@ pub async fn create_remote_wallet( let remote_backend = remote_backend.connect_wallet(wallet).0; // create liana GUI settings file - let settings: gui_settings::Settings = extract_remote_gui_settings(&ctx, &remote_backend).await; - create_and_write_file( - &network_datadir, - gui_settings::DEFAULT_FILE_NAME, - serde_json::to_string_pretty(&settings) - .map_err(|e| Error::Unexpected(format!("Failed to serialize settings: {}", e)))? - .as_bytes(), - )?; + let wallet_settings = extract_remote_gui_settings(&ctx, &remote_backend).await; + update_settings_file(&network_datadir, |mut settings| { + settings.wallets.push(wallet_settings.clone()); + settings + }) + .await + .map_err(|e| Error::Unexpected(e.to_string()))?; info!("Settings file created"); @@ -567,13 +565,13 @@ pub async fn create_remote_wallet( info!("Liana-Connect cache updated"); }; - Ok(gui_config_path) + Ok(wallet_settings) } pub async fn import_remote_wallet( ctx: Context, backend: BackendWalletClient, -) -> Result { +) -> Result { tracing::info!("Importing wallet from remote backend"); if let Some(signer) = &ctx.recovered_signer { @@ -590,19 +588,18 @@ pub async fn import_remote_wallet( .map_err(|e| Error::Unexpected(format!("Failed to create datadir path: {}", e)))?; // create liana GUI settings file - let settings: gui_settings::Settings = extract_remote_gui_settings(&ctx, &backend).await; - create_and_write_file( - &network_datadir, - gui_settings::DEFAULT_FILE_NAME, - serde_json::to_string_pretty(&settings) - .map_err(|e| Error::Unexpected(format!("Failed to serialize settings: {}", e)))? - .as_bytes(), - )?; + let wallet_settings = extract_remote_gui_settings(&ctx, &backend).await; + update_settings_file(&network_datadir, |mut settings| { + settings.wallets.push(wallet_settings.clone()); + settings + }) + .await + .map_err(|e| Error::Unexpected(e.to_string()))?; info!("Settings file created"); // create liana GUI configuration file - let gui_config_path = create_and_write_file( + let _gui_config_path = create_and_write_file( &network_datadir, gui_config::DEFAULT_FILE_NAME, toml::to_string(&gui_config::Config { @@ -632,7 +629,7 @@ pub async fn import_remote_wallet( info!("Liana-Connect cache updated"); }; - Ok(gui_config_path) + Ok(wallet_settings) } pub fn create_and_write_file( @@ -651,7 +648,10 @@ pub fn create_and_write_file( // if the wallet is using the remote backend, then the hardware wallet settings and // keys will be store on the remote backend side and not in the settings file. -pub async fn extract_remote_gui_settings(ctx: &Context, backend: &BackendWalletClient) -> Settings { +pub async fn extract_remote_gui_settings( + ctx: &Context, + backend: &BackendWalletClient, +) -> WalletSettings { let descriptor = ctx .descriptor .as_ref() @@ -664,23 +664,21 @@ pub async fn extract_remote_gui_settings(ctx: &Context, backend: &BackendWalletC .expect("LianaDescriptor.to_string() always include the checksum") .to_string(); - Settings { - wallets: vec![WalletSettings { - name: wallet_name(descriptor), - descriptor_checksum, - pinned_at: Some(chrono::Utc::now().timestamp()), - keys: Vec::new(), - hardware_wallets: Vec::new(), - remote_backend_auth: Some(AuthConfig::new( - backend.user_email().to_string(), - backend.wallet_id(), - )), - start_internal_bitcoind: None, - }], + WalletSettings { + name: wallet_name(descriptor), + descriptor_checksum, + pinned_at: Some(chrono::Utc::now().timestamp()), + keys: Vec::new(), + hardware_wallets: Vec::new(), + remote_backend_auth: Some(AuthConfig::new( + backend.user_email().to_string(), + backend.wallet_id(), + )), + start_internal_bitcoind: None, } } -pub fn extract_local_gui_settings(ctx: &Context) -> Settings { +pub fn extract_local_gui_settings(ctx: &Context) -> WalletSettings { let descriptor = ctx .descriptor .as_ref() @@ -702,16 +700,14 @@ pub fn extract_local_gui_settings(ctx: &Context) -> Settings { .map(|token| HardwareWalletConfig::new(kind, *fingerprint, token)) }) .collect(); - Settings { - wallets: vec![WalletSettings { - name: wallet_name(descriptor), - pinned_at: Some(chrono::Utc::now().timestamp()), - descriptor_checksum, - keys: ctx.keys.values().cloned().collect(), - hardware_wallets, - remote_backend_auth: None, - start_internal_bitcoind: Some(ctx.internal_bitcoind.is_some()), - }], + WalletSettings { + name: wallet_name(descriptor), + pinned_at: Some(chrono::Utc::now().timestamp()), + descriptor_checksum, + keys: ctx.keys.values().cloned().collect(), + hardware_wallets, + remote_backend_auth: None, + start_internal_bitcoind: Some(ctx.internal_bitcoind.is_some()), } } diff --git a/liana-gui/src/installer/step/mod.rs b/liana-gui/src/installer/step/mod.rs index 98fc4568..616869c3 100644 --- a/liana-gui/src/installer/step/mod.rs +++ b/liana-gui/src/installer/step/mod.rs @@ -21,14 +21,14 @@ pub use mnemonic::{BackupMnemonic, RecoverMnemonic}; pub use share_xpubs::ShareXpubs; use tracing::warn; -use std::{collections::HashMap, path::PathBuf}; +use std::collections::HashMap; use iced::{Subscription, Task}; use liana_ui::widget::*; use crate::{ - app::settings::ProviderKey, + app::settings::{ProviderKey, WalletSettings}, hw::HardwareWallets, installer::{context::Context, message::Message, view}, node::bitcoind::Bitcoind, @@ -67,7 +67,7 @@ pub struct Final { generating: bool, internal_bitcoind: Option, warning: Option, - config_path: Option, + wallet_settings: Option, key_redemptions: HashMap>>, } @@ -77,7 +77,7 @@ impl Final { internal_bitcoind: None, generating: false, warning: None, - config_path: None, + wallet_settings: None, key_redemptions: HashMap::new(), } } @@ -99,7 +99,7 @@ impl Step for Final { .collect(); } fn load(&self) -> Task { - if !self.generating && self.config_path.is_none() { + if !self.generating && self.wallet_settings.is_none() { Task::perform(async {}, |_| Message::Install) } else { Task::none() @@ -142,30 +142,30 @@ impl Step for Final { } // Now exit the installer whether or not any redemption errors occurred. let internal_bitcoind = self.internal_bitcoind.clone(); - let path = self.config_path.clone().expect("config path already set"); + let settings = self.wallet_settings.clone().expect("Install is done"); // If there were any errors, don't remove the installer log. return Task::perform( - async move { (path, internal_bitcoind, has_error) }, - |(path, internal_bitcoind, has_error)| { - Message::Exit(path, internal_bitcoind, !has_error) + async move { (settings, internal_bitcoind, has_error) }, + |(settings, internal_bitcoind, has_error)| { + Message::Exit(Box::new(settings), internal_bitcoind, !has_error) }, ); } Message::Installed(res) => match res { Err(e) => { self.generating = false; - self.config_path = None; + self.wallet_settings = None; self.warning = Some(e.to_string()); } - Ok(path) => { - self.config_path = Some(path.clone()); + Ok(wallet_settings) => { + self.wallet_settings = Some(wallet_settings); // Now redeem any provider keys. return Task::perform(async move {}, |_| Message::RedeemNextKey); } }, Message::Install => { self.generating = true; - self.config_path = None; + self.wallet_settings = None; self.warning = None; } _ => {} @@ -183,7 +183,7 @@ impl Step for Final { progress, email, self.generating, - self.config_path.as_ref(), + self.wallet_settings.is_some(), self.warning.as_ref(), ) } diff --git a/liana-gui/src/installer/view/mod.rs b/liana-gui/src/installer/view/mod.rs index 2b8a6b2e..19fdfb4b 100644 --- a/liana-gui/src/installer/view/mod.rs +++ b/liana-gui/src/installer/view/mod.rs @@ -1485,7 +1485,7 @@ pub fn install<'a>( progress: (usize, usize), email: Option<&'a str>, generating: bool, - config_path: Option<&std::path::PathBuf>, + installed: bool, warning: Option<&'a String>, ) -> Element<'a, Message> { let prev_msg = if !generating && warning.is_some() { @@ -1501,7 +1501,7 @@ pub fn install<'a>( .push_maybe(warning.map(|e| card::invalid(text(e)))) .push(if generating { Container::new(text("Installing...")) - } else if config_path.is_some() { + } else if installed { Container::new( Row::new() .spacing(10) diff --git a/liana-gui/src/main.rs b/liana-gui/src/main.rs index e1b9445d..fd942403 100644 --- a/liana-gui/src/main.rs +++ b/liana-gui/src/main.rs @@ -266,19 +266,20 @@ impl GUI { _ => l.update(*msg).map(|msg| Message::Login(Box::new(msg))), }, (State::Installer(i), Message::Install(msg)) => { - if let installer::Message::Exit(path, internal_bitcoind, remove_log) = *msg { - let network_dir = i.datadir.network_directory(i.network); - // We get the first one created. - let settings = app::settings::WalletSettings::from_file(&network_dir, |_| true) - .expect("A settings file was created") - .expect("A wallet was created"); - if let Some(setting) = settings.remote_backend_auth { + if let installer::Message::Exit(settings, internal_bitcoind, remove_log) = *msg { + if let Some(auth) = settings.remote_backend_auth { let (login, command) = - login::LianaLiteLogin::new(i.datadir.clone(), i.network, setting); + login::LianaLiteLogin::new(i.datadir.clone(), i.network, auth); self.state = State::Login(Box::new(login)); command.map(|msg| Message::Login(Box::new(msg))) } else { - let cfg = app::Config::from_file(&path).expect("A config file was created"); + let cfg = app::Config::from_file( + &i.datadir + .network_directory(i.network) + .path() + .join(app::config::DEFAULT_FILE_NAME), + ) + .expect("A gui configuration file must be present"); self.logger.set_running_mode( i.datadir.clone(), @@ -296,7 +297,7 @@ impl GUI { i.network, internal_bitcoind, i.context.backup.take(), - settings, + *settings, ); self.state = State::Loader(Box::new(loader)); command.map(|msg| Message::Load(Box::new(msg))) From 9a6218b3f1e5b6698d922eec91d51f7dff980790 Mon Sep 17 00:00:00 2001 From: edouardparis Date: Thu, 8 May 2025 18:10:04 +0200 Subject: [PATCH 4/8] Change lianad directory location --- liana-gui/src/app/wallet.rs | 6 ++ liana-gui/src/backup.rs | 25 ++++--- liana-gui/src/dir.rs | 13 +++- liana-gui/src/installer/mod.rs | 116 +++++++++++++++++---------------- liana-gui/src/loader.rs | 17 +++-- 5 files changed, 98 insertions(+), 79 deletions(-) diff --git a/liana-gui/src/app/wallet.rs b/liana-gui/src/app/wallet.rs index 6aae66f9..a87a8360 100644 --- a/liana-gui/src/app/wallet.rs +++ b/liana-gui/src/app/wallet.rs @@ -80,6 +80,11 @@ impl Wallet { } } + pub fn with_pinned_at(mut self, pinned_at: Option) -> Self { + self.pinned_at = pinned_at; + self + } + pub fn with_key_aliases(mut self, aliases: HashMap) -> Self { self.keys_aliases = aliases; self @@ -125,6 +130,7 @@ impl Wallet { .with_key_aliases(wallet_settings.keys_aliases()) .with_provider_keys(wallet_settings.provider_keys()) .with_name(wallet_settings.name) + .with_pinned_at(wallet_settings.pinned_at) .with_hardware_wallets(wallet_settings.hardware_wallets)) } } diff --git a/liana-gui/src/backup.rs b/liana-gui/src/backup.rs index cb6664d5..ba721f2a 100644 --- a/liana-gui/src/backup.rs +++ b/liana-gui/src/backup.rs @@ -132,29 +132,26 @@ impl Backup { let mut proprietary = serde_json::Map::new(); proprietary.insert(LIANA_VERSION_KEY.to_string(), liana_version().into()); - let config = extract_daemon_config(&ctx).map_err(|e| Error::Daemon(e.to_string()))?; + let settings = match &ctx.remote_backend { + // This append while user is importing a wallet already created on Liana-Connect. + RemoteBackend::WithWallet(backend) => extract_remote_gui_settings(&ctx, backend).await, + // Other cases are about wallet creation, the ctx contains all the keys aliases and + // descriptor registration hmacs. + _ => extract_local_gui_settings(&ctx), + }; + + let config = + extract_daemon_config(&ctx, &settings).map_err(|e| Error::Daemon(e.to_string()))?; if let Ok(config) = serde_json::to_value(config) { proprietary.insert(CONFIG_KEY.to_string(), config); } - let settings = if ctx.bitcoin_backend.is_some() { - Some(extract_local_gui_settings(&ctx)) - } else { - match &ctx.remote_backend { - RemoteBackend::WithWallet(backend) => { - Some(extract_remote_gui_settings(&ctx, backend).await) - } - _ => None, - } - }; - let name = if let Some(settings) = settings { + let name = { let name = settings.name.clone(); if let Ok(settings) = serde_json::to_value(settings) { proprietary.insert(SETTINGS_KEY.to_string(), settings); } Some(name) - } else { - None }; ctx.keys.iter().for_each(|(k, s)| { diff --git a/liana-gui/src/dir.rs b/liana-gui/src/dir.rs index 5b498704..9ced7df7 100644 --- a/liana-gui/src/dir.rs +++ b/liana-gui/src/dir.rs @@ -1,4 +1,6 @@ +use crate::app::settings::WalletSettings; use liana::miniscript::bitcoin::Network; +use lianad::datadir::DataDirectory; use std::path::{Path, PathBuf}; #[derive(Clone, Debug, PartialEq)] @@ -78,11 +80,20 @@ impl NetworkDirectory { self.0.as_path().exists() } pub fn init(&self) -> Result<(), Box> { - create_directory(self.0.as_path()) + create_directory(self.0.as_path())?; + create_directory(&self.0.as_path().join("data")) } pub fn path(&self) -> &Path { self.0.as_path() } + pub fn lianad_data_directory(&self, settings: &WalletSettings) -> DataDirectory { + let mut path = self.0.clone(); + if let Some(t) = settings.pinned_at { + path.push("data"); + path.push(format!("{}-{}", settings.descriptor_checksum, t)) + } + DataDirectory::new(path) + } } #[derive(Clone, Debug)] diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index 959791ab..b67d6cca 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -17,7 +17,7 @@ use std::ops::Deref; use tracing::{error, info, warn}; use std::io::Write; -use std::path::PathBuf; +use std::path::Path; use std::sync::{Arc, Mutex}; use crate::{ @@ -28,7 +28,7 @@ use crate::{ }, backup, daemon::DaemonError, - dir::{LianaDirectory, NetworkDirectory}, + dir::LianaDirectory, hw::{HardwareWalletConfig, HardwareWallets}, services::{ self, @@ -370,7 +370,8 @@ pub async fn install_local_wallet( .init() .map_err(|e| Error::Unexpected(format!("Failed to create datadir path: {}", e)))?; - let cfg: lianad::config::Config = extract_daemon_config(&ctx)?; + let wallet_settings = extract_local_gui_settings(&ctx); + let cfg: lianad::config::Config = extract_daemon_config(&ctx, &wallet_settings)?; daemon_check(cfg.clone())?; @@ -381,9 +382,11 @@ pub async fn install_local_wallet( .map_err(|e| Error::Unexpected(format!("Failed to serialize daemon config: {}", e)))?; // create lianad configuration file - let _daemon_config_path = create_and_write_file( - &network_datadir, - "daemon.toml", + create_and_write_file( + &network_datadir + .lianad_data_directory(&wallet_settings) + .path() + .join("daemon.toml"), daemon_config.to_string().as_bytes(), )?; @@ -412,21 +415,24 @@ pub async fn install_local_wallet( } // create liana GUI configuration file - let _gui_config_path = create_and_write_file( - &network_datadir, - gui_config::DEFAULT_FILE_NAME, - toml::to_string(&gui_config::Config::new( - // Installer started a bitcoind, it is expected that gui will start it on startup - ctx.internal_bitcoind.is_some(), - )) - .map_err(|e| Error::Unexpected(format!("Failed to serialize gui config: {}", e)))? - .as_bytes(), - )?; - - info!("Gui configuration file created"); + let gui_config_path = network_datadir + .path() + .join(gui_config::DEFAULT_FILE_NAME) + .to_path_buf(); + if !gui_config_path.exists() { + create_and_write_file( + &gui_config_path, + toml::to_string(&gui_config::Config::new( + // Installer started a bitcoind, it is expected that gui will start it on startup + ctx.internal_bitcoind.is_some(), + )) + .map_err(|e| Error::Unexpected(format!("Failed to serialize gui config: {}", e)))? + .as_bytes(), + )?; + info!("Gui configuration file created"); + } // create liana GUI settings file - let wallet_settings = extract_local_gui_settings(&ctx); update_settings_file(&network_datadir, |mut settings| { settings.wallets.push(wallet_settings.clone()); settings @@ -476,19 +482,19 @@ pub async fn create_remote_wallet( } // create liana GUI configuration file - let _gui_config_path = create_and_write_file( - &network_datadir, - gui_config::DEFAULT_FILE_NAME, - toml::to_string(&gui_config::Config { - log_level: Some("info".to_string()), - debug: Some(false), - start_internal_bitcoind: false, - }) - .map_err(|e| Error::Unexpected(format!("Failed to serialize gui config: {}", e)))? - .as_bytes(), - )?; - - info!("Gui configuration file created"); + let gui_config_path = network_datadir + .path() + .join(gui_config::DEFAULT_FILE_NAME) + .to_path_buf(); + if !gui_config_path.exists() { + create_and_write_file( + &gui_config_path, + toml::to_string(&gui_config::Config::new(false)) + .map_err(|e| Error::Unexpected(format!("Failed to serialize gui config: {}", e)))? + .as_bytes(), + )?; + info!("Gui configuration file created"); + } let pks: Vec<_> = ctx .keys @@ -599,19 +605,19 @@ pub async fn import_remote_wallet( info!("Settings file created"); // create liana GUI configuration file - let _gui_config_path = create_and_write_file( - &network_datadir, - gui_config::DEFAULT_FILE_NAME, - toml::to_string(&gui_config::Config { - log_level: Some("info".to_string()), - debug: Some(false), - start_internal_bitcoind: false, - }) - .map_err(|e| Error::Unexpected(format!("Failed to serialize gui config: {}", e)))? - .as_bytes(), - )?; - - info!("Gui configuration file created"); + let gui_config_path = network_datadir + .path() + .join(gui_config::DEFAULT_FILE_NAME) + .to_path_buf(); + if !gui_config_path.exists() { + create_and_write_file( + &gui_config_path, + toml::to_string(&gui_config::Config::new(false)) + .map_err(|e| Error::Unexpected(format!("Failed to serialize gui config: {}", e)))? + .as_bytes(), + )?; + info!("Gui configuration file created"); + } let backend = backend.inner_client(); if let Err(e) = update_connect_cache( @@ -632,18 +638,12 @@ pub async fn import_remote_wallet( Ok(wallet_settings) } -pub fn create_and_write_file( - network_datadir: &NetworkDirectory, - file_name: &str, - data: &[u8], -) -> Result { - let mut path = network_datadir.path().to_path_buf(); - path.push(file_name); +pub fn create_and_write_file(path: &Path, data: &[u8]) -> Result<(), Error> { let mut file = - std::fs::File::create(&path).map_err(|e| Error::CannotCreateFile(e.to_string()))?; + 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) + Ok(()) } // if the wallet is using the remote backend, then the hardware wallet settings and @@ -711,10 +711,16 @@ pub fn extract_local_gui_settings(ctx: &Context) -> WalletSettings { } } -pub fn extract_daemon_config(ctx: &Context) -> Result { +pub fn extract_daemon_config(ctx: &Context, settings: &WalletSettings) -> Result { let data_directory = ctx .liana_directory .network_directory(ctx.bitcoin_config.network) + .lianad_data_directory(settings); + data_directory + .init() + .map_err(|e| Error::CannotCreateDatadir(e.to_string()))?; + + let data_directory = data_directory .path() .to_path_buf() .canonicalize() diff --git a/liana-gui/src/loader.rs b/liana-gui/src/loader.rs index b595f955..604d2b2a 100644 --- a/liana-gui/src/loader.rs +++ b/liana-gui/src/loader.rs @@ -121,7 +121,10 @@ impl Loader { backup: Option, wallet_settings: WalletSettings, ) -> (Self, Task) { - let path = socket_path(&datadir_path, network); + let socket_path = datadir_path + .network_directory(network) + .lianad_data_directory(&wallet_settings) + .lianad_rpc_socket_path(); ( Loader { network, @@ -134,7 +137,7 @@ impl Loader { wallet_settings, backup, }, - Task::perform(connect(path), Message::Loaded), + Task::perform(connect(socket_path), Message::Loaded), ) } @@ -204,6 +207,7 @@ impl Loader { self.datadir_path.clone(), self.start_bitcoind(), self.network, + self.wallet_settings.clone(), ), Message::Started, ); @@ -551,9 +555,11 @@ pub async fn start_bitcoind_and_daemon( liana_datadir_path: LianaDirectory, start_internal_bitcoind: bool, network: bitcoin::Network, + settings: WalletSettings, ) -> StartedResult { let mut config_path = liana_datadir_path .network_directory(network) + .lianad_data_directory(&settings) .path() .to_path_buf(); config_path.push("daemon.toml"); @@ -629,10 +635,3 @@ impl From for Error { Error::Daemon(error) } } - -/// default lianad socket path is .liana/bitcoin/lianad_rpc -fn socket_path(datadir: &LianaDirectory, network: bitcoin::Network) -> PathBuf { - let mut path = datadir.network_directory(network).path().to_path_buf(); - path.push("lianad_rpc"); - path -} From 30228c6490c45b2b775ac6974b40998dac565208 Mon Sep 17 00:00:00 2001 From: edouardparis Date: Mon, 12 May 2025 17:51:41 +0200 Subject: [PATCH 5/8] refac delete wallet modal and desc backup --- liana-gui/src/app/settings.rs | 47 ++++- liana-gui/src/app/wallet.rs | 10 +- liana-gui/src/backup.rs | 56 +----- liana-gui/src/delete.rs | 82 ++++++++ liana-gui/src/dir.rs | 8 +- liana-gui/src/installer/message.rs | 2 +- liana-gui/src/installer/mod.rs | 188 ++++++++++-------- .../src/installer/step/descriptor/mod.rs | 2 +- liana-gui/src/installer/step/mod.rs | 2 +- liana-gui/src/launcher.rs | 70 ++++--- liana-gui/src/lib.rs | 1 + liana-gui/src/loader.rs | 4 +- .../src/services/connect/client/cache.rs | 63 ++++++ 13 files changed, 355 insertions(+), 180 deletions(-) create mode 100644 liana-gui/src/delete.rs diff --git a/liana-gui/src/app/settings.rs b/liana-gui/src/app/settings.rs index 0fc72217..518c5c29 100644 --- a/liana-gui/src/app/settings.rs +++ b/liana-gui/src/app/settings.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use async_fd_lock::LockWrite; +use liana::descriptors::LianaDescriptor; use std::io::SeekFrom; use tokio::fs::OpenOptions; use tokio::io::AsyncSeekExt; @@ -190,11 +191,49 @@ impl WalletSettings { } } - pub fn wallet_id(&self) -> String { - if let Some(t) = self.pinned_at { - format!("{}-{}", self.descriptor_checksum, t) + pub fn wallet_id(&self) -> WalletId { + WalletId { + timestamp: self.pinned_at, + descriptor_checksum: self.descriptor_checksum.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WalletId { + pub timestamp: Option, + pub descriptor_checksum: String, +} + +impl WalletId { + pub fn new(descriptor_checksum: String, timestamp: Option) -> Self { + WalletId { + timestamp, + descriptor_checksum, + } + } + pub fn generate(descriptor: &LianaDescriptor) -> Self { + WalletId { + timestamp: Some(chrono::Utc::now().timestamp()), + descriptor_checksum: descriptor + .to_string() + .split_once('#') + .map(|(_, checksum)| checksum) + .expect("LianaDescriptor.to_string() always include the checksum") + .to_string(), + } + } + pub fn is_legacy(&self) -> bool { + self.timestamp.is_none() + } +} + +impl std::fmt::Display for WalletId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(t) = self.timestamp { + write!(f, "{}-{}", self.descriptor_checksum, t) } else { - self.descriptor_checksum.clone() + write!(f, "{}", self.descriptor_checksum) } } } diff --git a/liana-gui/src/app/wallet.rs b/liana-gui/src/app/wallet.rs index a87a8360..fad98487 100644 --- a/liana-gui/src/app/wallet.rs +++ b/liana-gui/src/app/wallet.rs @@ -11,7 +11,7 @@ use liana::{miniscript::bitcoin, signer::HotSigner}; use liana::descriptors::LianaDescriptor; use liana::miniscript::bitcoin::bip32::Fingerprint; -use super::settings::WalletSettings; +use super::settings::{WalletId, WalletSettings}; const DEFAULT_WALLET_NAME: &str = "Liana"; @@ -72,12 +72,8 @@ impl Wallet { } // To match with WalletSettings.wallet_id - pub fn id(&self) -> String { - if let Some(t) = self.pinned_at { - format!("{}-{}", self.descriptor_checksum, t) - } else { - self.descriptor_checksum.clone() - } + pub fn id(&self) -> WalletId { + WalletId::new(self.descriptor_checksum.clone(), self.pinned_at) } pub fn with_pinned_at(mut self, pinned_at: Option) -> Self { diff --git a/liana-gui/src/backup.rs b/liana-gui/src/backup.rs index ba721f2a..fd436668 100644 --- a/liana-gui/src/backup.rs +++ b/liana-gui/src/backup.rs @@ -20,16 +20,13 @@ use tokio::sync::mpsc::UnboundedSender; use crate::{ app::{ settings::{Settings, WalletSettings}, - wallet::Wallet, + wallet::{wallet_name, Wallet}, Config, }, daemon::{model::HistoryTransaction, Daemon, DaemonBackend, DaemonError}, dir::LianaDirectory, export::Progress, - installer::{ - extract_daemon_config, extract_local_gui_settings, extract_remote_gui_settings, Context, - RemoteBackend, - }, + installer::Context, services::connect::client::backend::api::DEFAULT_LIMIT, VERSION, }; @@ -116,54 +113,23 @@ impl Backup { /// /// # Arguments /// * `ctx` - the installer context - /// * `timestamp` - whether to record the current timestamp as wallet creation time - /// (we should want to set timestamp = false for a wallet import for instance) - pub async fn from_installer(ctx: Context, timestamp: bool) -> Result { - let descriptor = ctx - .descriptor - .clone() - .ok_or(Error::DescriptorMissing)? - .to_string(); + pub async fn from_installer_descriptor_step(ctx: Context) -> Result { + let descriptor = ctx.descriptor.clone().ok_or(Error::DescriptorMissing)?; let now = now(); + let name = Some(wallet_name(&descriptor)); - let mut account = Account::new(descriptor); - - let mut proprietary = serde_json::Map::new(); - proprietary.insert(LIANA_VERSION_KEY.to_string(), liana_version().into()); - - let settings = match &ctx.remote_backend { - // This append while user is importing a wallet already created on Liana-Connect. - RemoteBackend::WithWallet(backend) => extract_remote_gui_settings(&ctx, backend).await, - // Other cases are about wallet creation, the ctx contains all the keys aliases and - // descriptor registration hmacs. - _ => extract_local_gui_settings(&ctx), - }; - - let config = - extract_daemon_config(&ctx, &settings).map_err(|e| Error::Daemon(e.to_string()))?; - if let Ok(config) = serde_json::to_value(config) { - proprietary.insert(CONFIG_KEY.to_string(), config); - } - - let name = { - let name = settings.name.clone(); - if let Ok(settings) = serde_json::to_value(settings) { - proprietary.insert(SETTINGS_KEY.to_string(), settings); - } - Some(name) - }; + let mut account = Account::new(descriptor.to_string()); + account.name = name.clone(); + account.timestamp = Some(now); + account + .proprietary + .insert(LIANA_VERSION_KEY.to_string(), liana_version().into()); ctx.keys.iter().for_each(|(k, s)| { account.keys.insert(*k, s.to_backup()); }); - account.proprietary = proprietary; - account.name = name.clone(); - if timestamp { - account.timestamp = Some(now); - } - Ok(Backup { name, accounts: vec![account], diff --git a/liana-gui/src/delete.rs b/liana-gui/src/delete.rs new file mode 100644 index 00000000..dd3c27b1 --- /dev/null +++ b/liana-gui/src/delete.rs @@ -0,0 +1,82 @@ +use std::collections::HashSet; + +use crate::{ + app::settings::{self, SettingsError, WalletId}, + dir::NetworkDirectory, + services::connect::client::cache::{self, ConnectCacheError}, +}; + +pub enum DeleteError { + Io(std::io::Error), + Settings(SettingsError), + Connect(ConnectCacheError), +} + +impl std::fmt::Display for DeleteError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "{}", e), + Self::Settings(e) => write!(f, "{}", e), + Self::Connect(e) => write!(f, "{}", e), + } + } +} + +impl From for DeleteError { + fn from(value: std::io::Error) -> Self { + DeleteError::Io(value) + } +} + +fn ignore_not_found(result: std::io::Result) -> std::io::Result> { + match result { + Ok(value) => Ok(Some(value)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } +} + +pub async fn delete_wallet( + network_dir: &NetworkDirectory, + wallet_id: &WalletId, +) -> Result<(), DeleteError> { + let lianad_directory = network_dir.lianad_data_directory(wallet_id); + if !wallet_id.is_legacy() { + ignore_not_found(tokio::fs::remove_dir_all(lianad_directory.path()).await)?; + } else { + // if this is a legacy wallet, then it is the only wallet in the network directory. + ignore_not_found(tokio::fs::remove_file(lianad_directory.sqlite_db_file_path()).await)?; + ignore_not_found( + tokio::fs::remove_dir_all(lianad_directory.lianad_watchonly_wallet_path()).await, + )?; + ignore_not_found( + tokio::fs::remove_file(lianad_directory.path().join("daemon.toml")).await, + )?; + } + + let mut remaining_accounts = HashSet::::new(); + settings::update_settings_file(network_dir, |mut settings| { + settings + .wallets + .retain(|settings| settings.wallet_id() != *wallet_id); + remaining_accounts = settings + .wallets + .iter() + .filter_map(|settings| { + settings + .remote_backend_auth + .as_ref() + .map(|auth| auth.email.clone()) + }) + .collect(); + settings + }) + .await + .map_err(DeleteError::Settings)?; + + cache::filter_connect_cache(network_dir, &remaining_accounts) + .await + .map_err(DeleteError::Connect)?; + + Ok(()) +} diff --git a/liana-gui/src/dir.rs b/liana-gui/src/dir.rs index 9ced7df7..e8eb8616 100644 --- a/liana-gui/src/dir.rs +++ b/liana-gui/src/dir.rs @@ -1,4 +1,4 @@ -use crate::app::settings::WalletSettings; +use crate::app::settings::WalletId; use liana::miniscript::bitcoin::Network; use lianad::datadir::DataDirectory; use std::path::{Path, PathBuf}; @@ -86,11 +86,11 @@ impl NetworkDirectory { pub fn path(&self) -> &Path { self.0.as_path() } - pub fn lianad_data_directory(&self, settings: &WalletSettings) -> DataDirectory { + pub fn lianad_data_directory(&self, wallet_id: &WalletId) -> DataDirectory { let mut path = self.0.clone(); - if let Some(t) = settings.pinned_at { + if !wallet_id.is_legacy() { path.push("data"); - path.push(format!("{}-{}", settings.descriptor_checksum, t)) + path.push(wallet_id.to_string()); } DataDirectory::new(path) } diff --git a/liana-gui/src/installer/message.rs b/liana-gui/src/installer/message.rs index 804d8616..2eac96c6 100644 --- a/liana-gui/src/installer/message.rs +++ b/liana-gui/src/installer/message.rs @@ -43,7 +43,7 @@ pub enum Message { Reload, Select(usize), UseHotSigner, - Installed(Result), + Installed(settings::WalletId, Result), CreateTaprootDescriptor(bool), SelectDescriptorTemplate(context::DescriptorTemplate), SelectBackend(SelectBackend), diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index b67d6cca..e1776897 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -14,6 +14,7 @@ use liana_ui::{ }; use lianad::config::Config; use std::ops::Deref; +use tokio::runtime::Handle; use tracing::{error, info, warn}; use std::io::Write; @@ -23,11 +24,12 @@ use std::sync::{Arc, Mutex}; use crate::{ app::{ config as gui_config, - settings::{update_settings_file, AuthConfig, SettingsError, WalletSettings}, + settings::{update_settings_file, AuthConfig, SettingsError, WalletId, WalletSettings}, wallet::wallet_name, }, backup, daemon::DaemonError, + delete, dir::LianaDirectory, hw::{HardwareWalletConfig, HardwareWallets}, services::{ @@ -252,50 +254,65 @@ impl Installer { .get_mut(self.current) .expect("There is always a step") .update(&mut self.hws, message); + let wallet_id = WalletId::generate( + self.context + .descriptor + .as_ref() + .expect("Must be a descriptor at this point"), + ); + let context = self.context.clone(); + let signer = self.signer.clone(); match &self.context.remote_backend { RemoteBackend::WithoutWallet(backend) => Task::perform( - create_remote_wallet( - self.context.clone(), - self.signer.clone(), - backend.clone(), + with_wallet_id( + wallet_id.clone(), + create_remote_wallet(context, wallet_id, signer, backend.clone()), ), - Message::Installed, + |(id, res)| Message::Installed(id, res), ), RemoteBackend::WithWallet(backend) => Task::perform( - import_remote_wallet(self.context.clone(), backend.clone()), - Message::Installed, + with_wallet_id( + wallet_id.clone(), + import_remote_wallet(context, wallet_id, backend.clone()), + ), + |(id, res)| Message::Installed(id, res), ), RemoteBackend::None => Task::perform( - install_local_wallet(self.context.clone(), self.signer.clone()), - Message::Installed, + with_wallet_id( + wallet_id.clone(), + install_local_wallet(context, wallet_id, signer), + ), + |(id, res)| Message::Installed(id, res), ), RemoteBackend::Undefined => unreachable!("Must be defined at this point"), } } - Message::Installed(Err(e)) => { + Message::Installed(wallet_id, Err(e)) => { let network_directory = self .context .liana_directory .network_directory(self.context.bitcoin_config.network); // In case of failure during install, block the thread to // deleted the data_dir/network directory in order to start clean again. - warn!("Installation failed. Cleaning up the leftover data directory."); - if let Err(e) = std::fs::remove_dir_all(network_directory.path()) { + warn!("Installation failed. Cleaning up the network directory."); + if let Err(e) = Handle::current() + .block_on(delete::delete_wallet(&network_directory, &wallet_id)) + { error!( - "Failed to completely delete the data directory (path: '{}'): {}", + "Failed to completely clean the network directory (path: '{}'): {}", network_directory.path().to_string_lossy(), e ); } else { warn!( - "Successfully deleted data directory at '{}'.", + "Successfully cleaned network directory at '{}'.", network_directory.path().to_string_lossy() ); }; self.steps .get_mut(self.current) .expect("There is always a step") - .update(&mut self.hws, Message::Installed(Err(e))) + .update(&mut self.hws, Message::Installed(wallet_id, Err(e))) } Message::WalletFromBackup((ks, backup)) => { self.context.keys = ks; @@ -359,8 +376,16 @@ pub fn daemon_check(cfg: lianad::config::Config) -> Result<(), Error> { } } +async fn with_wallet_id(wallet_id: WalletId, res: F) -> (WalletId, Result) +where + F: std::future::Future>, +{ + (wallet_id, res.await) +} + pub async fn install_local_wallet( ctx: Context, + wallet_id: WalletId, signer: Arc>, ) -> Result { let network_datadir = ctx @@ -370,7 +395,31 @@ pub async fn install_local_wallet( .init() .map_err(|e| Error::Unexpected(format!("Failed to create datadir path: {}", e)))?; - let wallet_settings = extract_local_gui_settings(&ctx); + let descriptor = ctx + .descriptor + .as_ref() + .expect("Context must have a descriptor at this point"); + + let hardware_wallets = ctx + .hws + .iter() + .filter_map(|(kind, fingerprint, token)| { + token + .as_ref() + .map(|token| HardwareWalletConfig::new(kind, *fingerprint, token)) + }) + .collect(); + + let wallet_settings = WalletSettings { + name: wallet_name(descriptor), + pinned_at: wallet_id.timestamp, + descriptor_checksum: wallet_id.descriptor_checksum, + keys: ctx.keys.values().cloned().collect(), + hardware_wallets, + remote_backend_auth: None, + start_internal_bitcoind: Some(ctx.internal_bitcoind.is_some()), + }; + let cfg: lianad::config::Config = extract_daemon_config(&ctx, &wallet_settings)?; daemon_check(cfg.clone())?; @@ -384,7 +433,7 @@ pub async fn install_local_wallet( // create lianad configuration file create_and_write_file( &network_datadir - .lianad_data_directory(&wallet_settings) + .lianad_data_directory(&wallet_settings.wallet_id()) .path() .join("daemon.toml"), daemon_config.to_string().as_bytes(), @@ -447,6 +496,7 @@ pub async fn install_local_wallet( pub async fn create_remote_wallet( ctx: Context, + wallet_id: WalletId, signer: Arc>, remote_backend: BackendClient, ) -> Result { @@ -545,7 +595,20 @@ pub async fn create_remote_wallet( let remote_backend = remote_backend.connect_wallet(wallet).0; // create liana GUI settings file - let wallet_settings = extract_remote_gui_settings(&ctx, &remote_backend).await; + // if the wallet is using the remote backend, then the hardware wallet settings and + // keys will be store on the remote backend side and not in the settings file. + let wallet_settings = WalletSettings { + name: wallet_name(descriptor), + descriptor_checksum: wallet_id.descriptor_checksum, + pinned_at: wallet_id.timestamp, + keys: Vec::new(), + hardware_wallets: Vec::new(), + remote_backend_auth: Some(AuthConfig::new( + remote_backend.user_email().to_string(), + remote_backend.wallet_id(), + )), + start_internal_bitcoind: None, + }; update_settings_file(&network_datadir, |mut settings| { settings.wallets.push(wallet_settings.clone()); settings @@ -576,6 +639,7 @@ pub async fn create_remote_wallet( pub async fn import_remote_wallet( ctx: Context, + wallet_id: WalletId, backend: BackendWalletClient, ) -> Result { tracing::info!("Importing wallet from remote backend"); @@ -594,7 +658,24 @@ pub async fn import_remote_wallet( .map_err(|e| Error::Unexpected(format!("Failed to create datadir path: {}", e)))?; // create liana GUI settings file - let wallet_settings = extract_remote_gui_settings(&ctx, &backend).await; + // if the wallet is using the remote backend, then the hardware wallet settings and + // keys will be store on the remote backend side and not in the settings file. + let wallet_settings = WalletSettings { + name: wallet_name( + ctx.descriptor + .as_ref() + .expect("Context must have a descriptor at this point"), + ), + descriptor_checksum: wallet_id.descriptor_checksum, + pinned_at: wallet_id.timestamp, + keys: Vec::new(), + hardware_wallets: Vec::new(), + remote_backend_auth: Some(AuthConfig::new( + backend.user_email().to_string(), + backend.wallet_id(), + )), + start_internal_bitcoind: None, + }; update_settings_file(&network_datadir, |mut settings| { settings.wallets.push(wallet_settings.clone()); settings @@ -646,76 +727,11 @@ pub fn create_and_write_file(path: &Path, data: &[u8]) -> Result<(), Error> { Ok(()) } -// if the wallet is using the remote backend, then the hardware wallet settings and -// keys will be store on the remote backend side and not in the settings file. -pub async fn extract_remote_gui_settings( - ctx: &Context, - backend: &BackendWalletClient, -) -> WalletSettings { - let descriptor = ctx - .descriptor - .as_ref() - .expect("Context must have a descriptor at this point"); - - let descriptor_checksum = descriptor - .to_string() - .split_once('#') - .map(|(_, checksum)| checksum) - .expect("LianaDescriptor.to_string() always include the checksum") - .to_string(); - - WalletSettings { - name: wallet_name(descriptor), - descriptor_checksum, - pinned_at: Some(chrono::Utc::now().timestamp()), - keys: Vec::new(), - hardware_wallets: Vec::new(), - remote_backend_auth: Some(AuthConfig::new( - backend.user_email().to_string(), - backend.wallet_id(), - )), - start_internal_bitcoind: None, - } -} - -pub fn extract_local_gui_settings(ctx: &Context) -> WalletSettings { - let descriptor = ctx - .descriptor - .as_ref() - .expect("Context must have a descriptor at this point"); - - let descriptor_checksum = descriptor - .to_string() - .split_once('#') - .map(|(_, checksum)| checksum) - .expect("LianaDescriptor.to_string() always include the checksum") - .to_string(); - - let hardware_wallets = ctx - .hws - .iter() - .filter_map(|(kind, fingerprint, token)| { - token - .as_ref() - .map(|token| HardwareWalletConfig::new(kind, *fingerprint, token)) - }) - .collect(); - WalletSettings { - name: wallet_name(descriptor), - pinned_at: Some(chrono::Utc::now().timestamp()), - descriptor_checksum, - keys: ctx.keys.values().cloned().collect(), - hardware_wallets, - remote_backend_auth: None, - start_internal_bitcoind: Some(ctx.internal_bitcoind.is_some()), - } -} - pub fn extract_daemon_config(ctx: &Context, settings: &WalletSettings) -> Result { let data_directory = ctx .liana_directory .network_directory(ctx.bitcoin_config.network) - .lianad_data_directory(settings); + .lianad_data_directory(&settings.wallet_id()); data_directory .init() .map_err(|e| Error::CannotCreateDatadir(e.to_string()))?; diff --git a/liana-gui/src/installer/step/descriptor/mod.rs b/liana-gui/src/installer/step/descriptor/mod.rs index 7933fb77..a5ba04b1 100644 --- a/liana-gui/src/installer/step/descriptor/mod.rs +++ b/liana-gui/src/installer/step/descriptor/mod.rs @@ -379,7 +379,7 @@ impl Step for BackupDescriptor { let ctx = ctx.clone(); return Task::perform( async move { - let backup = Backup::from_installer(ctx, true).await?; + let backup = Backup::from_installer_descriptor_step(ctx).await?; serde_json::to_string_pretty(&backup).map_err(|_| backup::Error::Json) }, Message::ExportWallet, diff --git a/liana-gui/src/installer/step/mod.rs b/liana-gui/src/installer/step/mod.rs index 616869c3..fbca9973 100644 --- a/liana-gui/src/installer/step/mod.rs +++ b/liana-gui/src/installer/step/mod.rs @@ -151,7 +151,7 @@ impl Step for Final { }, ); } - Message::Installed(res) => match res { + Message::Installed(_, res) => match res { Err(e) => { self.generating = false; self.wallet_settings = None; diff --git a/liana-gui/src/launcher.rs b/liana-gui/src/launcher.rs index 105de7b6..1c1ae55e 100644 --- a/liana-gui/src/launcher.rs +++ b/liana-gui/src/launcher.rs @@ -11,9 +11,11 @@ use liana_ui::{ widget::*, }; use lianad::config::ConfigError; +use tokio::runtime::Handle; use crate::{ app::{self, settings::WalletSettings}, + delete::{delete_wallet, DeleteError}, dir::{LianaDirectory, NetworkDirectory}, installer::UserFlow, }; @@ -94,19 +96,21 @@ impl Launcher { Message::Install(d, n, UserFlow::ShareXpubs) }) } - Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::ShowModal)) => { - let wallet_datadir = self.datadir_path.network_directory(self.network); - let config_path = wallet_datadir.path().join(app::config::DEFAULT_FILE_NAME); - let internal_bitcoind = if let Ok(cfg) = app::Config::from_file(&config_path) { - Some(cfg.start_internal_bitcoind) - } else { - None - }; - self.delete_wallet_modal = Some(DeleteWalletModal::new( - self.network, - wallet_datadir, - internal_bitcoind, - )); + Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::ShowModal(i))) => { + if let State::Wallets { wallets, .. } = &self.state { + let wallet_datadir = self.datadir_path.network_directory(self.network); + let config_path = wallet_datadir.path().join(app::config::DEFAULT_FILE_NAME); + let internal_bitcoind = if let Ok(cfg) = app::Config::from_file(&config_path) { + Some(cfg.start_internal_bitcoind) + } else { + None + }; + self.delete_wallet_modal = Some(DeleteWalletModal::new( + wallet_datadir, + wallets[i].clone(), + internal_bitcoind, + )); + } Task::none() } Message::View(ViewMessage::SelectNetwork(network)) => { @@ -116,7 +120,8 @@ impl Launcher { } Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::Deleted)) => { self.state = State::NoWallet; - Task::none() + let network_dir = self.datadir_path.network_directory(self.network); + Task::perform(check_network_datadir(network_dir), Message::Checked) } Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::CloseModal)) => { @@ -365,7 +370,7 @@ fn wallets_list_item( Button::new(icon::trash_icon()) .style(theme::button::secondary) .padding(10) - .on_press(ViewMessage::DeleteWallet(DeleteWalletMessage::ShowModal)), + .on_press(ViewMessage::DeleteWallet(DeleteWalletMessage::ShowModal(i))), ), ) .into() @@ -395,16 +400,16 @@ pub enum ViewMessage { #[derive(Debug, Clone)] pub enum DeleteWalletMessage { - ShowModal, + ShowModal(usize), CloseModal, Confirm, Deleted, } struct DeleteWalletModal { - network: Network, - wallet_datadir: NetworkDirectory, - warning: Option, + network_directory: NetworkDirectory, + wallet_settings: WalletSettings, + warning: Option, deleted: bool, // `None` means we were not able to determine whether wallet uses internal bitcoind. internal_bitcoind: Option, @@ -412,13 +417,13 @@ struct DeleteWalletModal { impl DeleteWalletModal { fn new( - network: Network, - wallet_datadir: NetworkDirectory, + network_directory: NetworkDirectory, + wallet_settings: WalletSettings, internal_bitcoind: Option, ) -> Self { Self { - network, - wallet_datadir, + wallet_settings, + network_directory, warning: None, deleted: false, internal_bitcoind, @@ -428,7 +433,10 @@ impl DeleteWalletModal { fn update(&mut self, message: Message) -> Task { if let Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::Confirm)) = message { self.warning = None; - if let Err(e) = std::fs::remove_dir_all(self.wallet_datadir.path()) { + if let Err(e) = Handle::current().block_on(delete_wallet( + &self.network_directory, + &self.wallet_settings.wallet_id(), + )) { self.warning = Some(e); } else { self.deleted = true; @@ -439,6 +447,7 @@ impl DeleteWalletModal { } Task::none() } + fn view(&self) -> Element { let mut confirm_button = button::secondary(None, "Delete wallet") .width(Length::Fixed(200.0)) @@ -449,8 +458,8 @@ impl DeleteWalletModal { } // Use separate `Row`s for help text in order to have better spacing. let help_text_1 = format!( - "Are you sure you want to delete the configuration and all associated data for the network {}?", - &self.network + "Are you sure you want to delete the configuration and all associated data for the wallet Liana-{}?", + &self.wallet_settings.descriptor_checksum, ); let help_text_2 = match self.internal_bitcoind { Some(true) => Some("(The Liana-managed Bitcoin node for this network will not be affected by this action.)"), @@ -464,9 +473,12 @@ impl DeleteWalletModal { Column::new() .spacing(10) .push(Container::new( - h4_bold(format!("Delete configuration for {}", &self.network)) - .style(theme::text::destructive) - .width(Length::Fill), + h4_bold(format!( + "Delete configuration for Liana-{}", + &self.wallet_settings.descriptor_checksum + )) + .style(theme::text::destructive) + .width(Length::Fill), )) .push(Row::new().push(text(help_text_1))) .push_maybe( diff --git a/liana-gui/src/lib.rs b/liana-gui/src/lib.rs index 0b81af2b..9cc6959a 100644 --- a/liana-gui/src/lib.rs +++ b/liana-gui/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod backup; pub mod daemon; +pub mod delete; pub mod dir; pub mod download; pub mod export; diff --git a/liana-gui/src/loader.rs b/liana-gui/src/loader.rs index 604d2b2a..079a0de2 100644 --- a/liana-gui/src/loader.rs +++ b/liana-gui/src/loader.rs @@ -123,7 +123,7 @@ impl Loader { ) -> (Self, Task) { let socket_path = datadir_path .network_directory(network) - .lianad_data_directory(&wallet_settings) + .lianad_data_directory(&wallet_settings.wallet_id()) .lianad_rpc_socket_path(); ( Loader { @@ -559,7 +559,7 @@ pub async fn start_bitcoind_and_daemon( ) -> StartedResult { let mut config_path = liana_datadir_path .network_directory(network) - .lianad_data_directory(&settings) + .lianad_data_directory(&settings.wallet_id()) .path() .to_path_buf(); config_path.push("daemon.toml"); diff --git a/liana-gui/src/services/connect/client/cache.rs b/liana-gui/src/services/connect/client/cache.rs index 1a5885a7..610ac985 100644 --- a/liana-gui/src/services/connect/client/cache.rs +++ b/liana-gui/src/services/connect/client/cache.rs @@ -1,6 +1,7 @@ use crate::dir::NetworkDirectory; use async_fd_lock::LockWrite; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::io::SeekFrom; use tokio::fs::OpenOptions; use tokio::io::AsyncSeekExt; @@ -138,6 +139,68 @@ pub async fn update_connect_cache( Ok(tokens) } +pub async fn filter_connect_cache( + network_dir: &NetworkDirectory, + emails: &HashSet, +) -> Result<(), ConnectCacheError> { + let mut path = network_dir.path().to_path_buf(); + path.push(CONNECT_CACHE_FILENAME); + + let file_exists = tokio::fs::try_exists(&path).await.unwrap_or(false); + + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .await + .map_err(|e| ConnectCacheError::ReadingFile(format!("Opening file: {}", e)))? + .lock_write() + .await + .map_err(|e| ConnectCacheError::ReadingFile(format!("Locking file: {:?}", e)))?; + + let mut cache = if file_exists { + let mut file_content = Vec::new(); + file.read_to_end(&mut file_content) + .await + .map_err(|e| ConnectCacheError::ReadingFile(format!("Reading file content: {}", e)))?; + + match serde_json::from_slice::(&file_content) { + Ok(cache) => cache, + Err(e) => { + tracing::warn!("Something wrong with Liana-Connect cache file: {:?}", e); + tracing::warn!("Liana-Connect cache file is reset"); + ConnectCache::default() + } + } + } else { + ConnectCache::default() + }; + + cache.accounts.retain(|a| emails.contains(&a.email)); + + let content = serde_json::to_vec_pretty(&cache).map_err(|e| { + ConnectCacheError::WritingFile(format!("Failed to serialize settings: {}", e)) + })?; + + file.seek(SeekFrom::Start(0)).await.map_err(|e| { + ConnectCacheError::WritingFile(format!("Failed to seek to start of file: {}", e)) + })?; + + file.write_all(&content).await.map_err(|e| { + tracing::warn!("failed to write to file: {:?}", e); + ConnectCacheError::WritingFile(e.to_string()) + })?; + + file.inner_mut() + .set_len(content.len() as u64) + .await + .map_err(|e| ConnectCacheError::WritingFile(format!("Failed to truncate file: {}", e)))?; + + Ok(()) +} + #[derive(Debug, Clone)] pub enum ConnectCacheError { NotFound, From b6f45a42ee96399f4de70e5889ca32a3679466ae Mon Sep 17 00:00:00 2001 From: edouardparis Date: Fri, 16 May 2025 14:44:00 +0200 Subject: [PATCH 6/8] Delete settings file if no wallet And make launcher open default network directory as the first directory with a settings file. Fallback to the Create wallet view when no settings file exist anymore or if the settings file has no wallet. --- liana-gui/src/app/settings.rs | 15 ++++++++++++--- liana-gui/src/launcher.rs | 33 +++++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/liana-gui/src/app/settings.rs b/liana-gui/src/app/settings.rs index 518c5c29..cca444fb 100644 --- a/liana-gui/src/app/settings.rs +++ b/liana-gui/src/app/settings.rs @@ -20,7 +20,7 @@ use crate::{ services::{self, connect::client::backend}, }; -pub const DEFAULT_FILE_NAME: &str = "settings.json"; +pub const SETTINGS_FILE_NAME: &str = "settings.json"; #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Settings { @@ -30,7 +30,7 @@ pub struct Settings { impl Settings { pub fn from_file(network_dir: &NetworkDirectory) -> Result { let mut path = network_dir.path().to_path_buf(); - path.push(DEFAULT_FILE_NAME); + path.push(SETTINGS_FILE_NAME); std::fs::read(path) .map_err(|e| match e.kind() { @@ -52,7 +52,7 @@ pub async fn update_settings_file( where F: FnOnce(Settings) -> Settings, { - let path = network_dir.path().join(DEFAULT_FILE_NAME); + let path = network_dir.path().join(SETTINGS_FILE_NAME); let file_exists = tokio::fs::try_exists(&path).await.unwrap_or(false); let mut file = OpenOptions::new() @@ -81,6 +81,13 @@ where let settings = updater(settings); + if settings.wallets.is_empty() { + tokio::fs::remove_file(&path) + .await + .map_err(|e| SettingsError::ReadingFile(e.to_string()))?; + return Ok(()); + } + let content = serde_json::to_vec_pretty(&settings) .map_err(|e| SettingsError::WritingFile(format!("Failed to serialize settings: {}", e)))?; @@ -351,6 +358,7 @@ impl KeySetting { pub enum SettingsError { NotFound, ReadingFile(String), + DeletingFile(String), WritingFile(String), Unexpected(String), } @@ -359,6 +367,7 @@ impl std::fmt::Display for SettingsError { match self { Self::NotFound => write!(f, "Settings file not found"), Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e), + Self::DeletingFile(e) => write!(f, "Error while deleting file: {}", e), Self::WritingFile(e) => write!(f, "Error while writing file: {}", e), Self::Unexpected(e) => write!(f, "Unexpected error: {}", e), } diff --git a/liana-gui/src/launcher.rs b/liana-gui/src/launcher.rs index 1c1ae55e..295beec8 100644 --- a/liana-gui/src/launcher.rs +++ b/liana-gui/src/launcher.rs @@ -14,7 +14,10 @@ use lianad::config::ConfigError; use tokio::runtime::Handle; use crate::{ - app::{self, settings::WalletSettings}, + app::{ + self, + settings::{self, WalletSettings}, + }, delete::{delete_wallet, DeleteError}, dir::{LianaDirectory, NetworkDirectory}, installer::UserFlow, @@ -50,7 +53,13 @@ impl Launcher { let network = network.unwrap_or( NETWORKS .iter() - .find(|net| datadir_path.path().join(net.to_string()).exists()) + .find(|net| { + datadir_path + .path() + .join(net.to_string()) + .join(settings::SETTINGS_FILE_NAME) + .exists() + }) .cloned() .unwrap_or(Network::Bitcoin), ); @@ -560,10 +569,18 @@ async fn check_network_datadir(path: NetworkDirectory) -> Result })?; } - app::settings::Settings::from_file(&path) - .map(|s| State::Wallets { - wallets: s.wallets, - add_wallet: false, - }) - .map_err(|e| e.to_string()) + match settings::Settings::from_file(&path) { + Ok(s) => { + if s.wallets.is_empty() { + Ok(State::NoWallet) + } else { + Ok(State::Wallets { + wallets: s.wallets, + add_wallet: false, + }) + } + } + Err(settings::SettingsError::NotFound) => Ok(State::NoWallet), + Err(e) => Err(e.to_string()), + } } From 5baf2812040d5d7b0758e8f5058dc953a8adcb1b Mon Sep 17 00:00:00 2001 From: edouardparis Date: Fri, 16 May 2025 16:40:08 +0200 Subject: [PATCH 7/8] Remove wallet hot signer mnemonics on wallet delete. Mnemonics are now stored with the following format: mnemonic-fingerprint-checksum-timstamp.txt checksum and timestamp are the new way to identify wallets. When deleting wallet user expects any mnemonic related to the wallet to be removed. For legacy wallets, any mnemonic-fingerprint.txt file will be deleted. --- liana-gui/src/delete.rs | 8 ++ liana-gui/src/installer/mod.rs | 47 +++++++-- liana-gui/src/signer.rs | 52 +++++++++- liana/src/signer.rs | 174 +++++++++++++++++++++++++++++++-- 4 files changed, 265 insertions(+), 16 deletions(-) diff --git a/liana-gui/src/delete.rs b/liana-gui/src/delete.rs index dd3c27b1..dcd6b285 100644 --- a/liana-gui/src/delete.rs +++ b/liana-gui/src/delete.rs @@ -4,6 +4,7 @@ use crate::{ app::settings::{self, SettingsError, WalletId}, dir::NetworkDirectory, services::connect::client::cache::{self, ConnectCacheError}, + signer, }; pub enum DeleteError { @@ -78,5 +79,12 @@ pub async fn delete_wallet( .await .map_err(DeleteError::Connect)?; + signer::delete_wallet_mnemonics( + network_dir, + &wallet_id.descriptor_checksum, + wallet_id.timestamp, + ) + .map_err(DeleteError::Io)?; + Ok(()) } diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index e1776897..bbc200cb 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -413,7 +413,7 @@ pub async fn install_local_wallet( let wallet_settings = WalletSettings { name: wallet_name(descriptor), pinned_at: wallet_id.timestamp, - descriptor_checksum: wallet_id.descriptor_checksum, + descriptor_checksum: wallet_id.descriptor_checksum.clone(), keys: ctx.keys.values().cloned().collect(), hardware_wallets, remote_backend_auth: None, @@ -449,7 +449,14 @@ pub async fn install_local_wallet( signer .lock() .unwrap() - .store(&ctx.liana_directory, cfg.bitcoin_config.network) + .store( + &ctx.liana_directory, + cfg.bitcoin_config.network, + &wallet_id.descriptor_checksum, + wallet_id + .timestamp + .expect("Every new wallet have now a timestamp"), + ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; info!("Hot signer mnemonic stored"); @@ -457,7 +464,14 @@ pub async fn install_local_wallet( if let Some(signer) = &ctx.recovered_signer { signer - .store(&ctx.liana_directory, cfg.bitcoin_config.network) + .store( + &ctx.liana_directory, + cfg.bitcoin_config.network, + &wallet_id.descriptor_checksum, + wallet_id + .timestamp + .expect("Every new wallet have now a timestamp"), + ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; info!("Recovered signer mnemonic stored"); @@ -517,7 +531,14 @@ pub async fn create_remote_wallet( signer .lock() .unwrap() - .store(&ctx.liana_directory, ctx.network) + .store( + &ctx.liana_directory, + ctx.network, + &wallet_id.descriptor_checksum, + wallet_id + .timestamp + .expect("Every new wallet have now a timestamp"), + ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; info!("Hot signer mnemonic stored"); @@ -525,7 +546,14 @@ pub async fn create_remote_wallet( if let Some(signer) = &ctx.recovered_signer { signer - .store(&ctx.liana_directory, ctx.network) + .store( + &ctx.liana_directory, + ctx.network, + &wallet_id.descriptor_checksum, + wallet_id + .timestamp + .expect("Every new wallet have now a timestamp"), + ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; info!("Recovered signer mnemonic stored"); @@ -646,7 +674,14 @@ pub async fn import_remote_wallet( if let Some(signer) = &ctx.recovered_signer { signer - .store(&ctx.liana_directory, ctx.network) + .store( + &ctx.liana_directory, + ctx.network, + &wallet_id.descriptor_checksum, + wallet_id + .timestamp + .expect("Every new wallet have now a timestamp"), + ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; info!("Recovered signer mnemonic stored"); diff --git a/liana-gui/src/signer.rs b/liana-gui/src/signer.rs index be237ead..16842367 100644 --- a/liana-gui/src/signer.rs +++ b/liana-gui/src/signer.rs @@ -1,4 +1,5 @@ pub use liana::signer::SignerError; +use std::str::FromStr; use liana::{ miniscript::bitcoin::{ @@ -6,10 +7,10 @@ use liana::{ psbt::Psbt, secp256k1, Network, }, - signer::HotSigner, + signer::{self, HotSigner}, }; -use crate::dir::LianaDirectory; +use crate::dir::{LianaDirectory, NetworkDirectory}; pub struct Signer { curve: secp256k1::Secp256k1, @@ -62,7 +63,52 @@ impl Signer { &self, datadir_root: &LianaDirectory, network: Network, + checksum: &str, + timestamp: i64, ) -> Result<(), SignerError> { - self.key.store(datadir_root.path(), network, &self.curve) + self.key.store( + datadir_root.path(), + network, + &self.curve, + Some((checksum.to_string(), timestamp)), + ) } } + +pub fn delete_wallet_mnemonics( + network_directory: &NetworkDirectory, + descriptor_checksum: &str, + pinned_at: Option, +) -> Result<(), std::io::Error> { + let folder = network_directory + .path() + .join(signer::MNEMONICS_FOLDER_NAME) + .to_path_buf(); + if folder.exists() { + for entry in std::fs::read_dir(&folder)? { + let path = entry?.path(); + if let Some(filename) = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|s| signer::MnemonicFileName::from_str(s).ok()) + { + match (pinned_at, filename.descriptor_info) { + // legacy wallet, we delete any mnemonic-{}.txt + (None, None) => { + std::fs::remove_file(&path)?; + } + // we delete any mnemonic-fg-sum-tim.txt that matches the descriptor_checksum + // and timestamp + (Some(t), Some(info)) => { + if info.0 == descriptor_checksum && t == info.1 { + std::fs::remove_file(&path)?; + } + } + _ => { // The file is not related to the wallet} + } + } + } + } + } + Ok(()) +} diff --git a/liana/src/signer.rs b/liana/src/signer.rs index bbf1b2ce..2d88b967 100644 --- a/liana/src/signer.rs +++ b/liana/src/signer.rs @@ -15,7 +15,7 @@ use std::{ use miniscript::bitcoin::{ self, - bip32::{self, Error as Bip32Error}, + bip32::{self, Error as Bip32Error, Fingerprint}, ecdsa, hashes::Hash, key::TapTweak, @@ -186,22 +186,26 @@ impl HotSigner { /// Store the mnemonic in a file within the given "data directory". /// The file is stored within a "mnemonics" folder, with the filename set to the fingerprint of /// the master xpub corresponding to this mnemonic. + /// returns the filename pub fn store( &self, datadir_root: &path::Path, network: bitcoin::Network, secp: &secp256k1::Secp256k1, + descriptor_info: Option<(String, i64)>, ) -> Result<(), SignerError> { - let mut mnemonics_folder = Self::mnemonics_folder(datadir_root, network); + let mnemonics_folder = Self::mnemonics_folder(datadir_root, network); if !mnemonics_folder.exists() { create_dir(&mnemonics_folder).map_err(SignerError::MnemonicStorage)?; } // This will fail if a file with this fingerprint exists already. - mnemonics_folder.push(format!("mnemonic-{:x}.txt", self.fingerprint(secp))); - let mnemonic_path = mnemonics_folder; - let mut mnemonic_file = - create_file(&mnemonic_path).map_err(SignerError::MnemonicStorage)?; + let filename = MnemonicFileName { + fingerprint: self.fingerprint(secp), + descriptor_info, + }; + let mut mnemonic_file = create_file(&mnemonics_folder.join(filename.to_string())) + .map_err(SignerError::MnemonicStorage)?; mnemonic_file .write_all(self.mnemonic_str().as_bytes()) .map_err(SignerError::MnemonicStorage)?; @@ -404,6 +408,95 @@ impl HotSigner { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MnemonicFileName { + pub fingerprint: Fingerprint, + pub descriptor_info: Option<(String, i64)>, // (descriptor_checksum, timestamp) +} + +impl fmt::Display for MnemonicFileName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.descriptor_info { + Some((checksum, timestamp)) => { + write!( + f, + "mnemonic-{}-{}-{}.txt", + self.fingerprint, checksum, timestamp + ) + } + None => { + write!(f, "mnemonic-{}.txt", self.fingerprint) + } + } + } +} + +#[derive(Debug)] +pub enum MnemonicFileNameError { + InvalidFormat, + InvalidFingerprint, + InvalidTimestamp, +} + +impl fmt::Display for MnemonicFileNameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MnemonicFileNameError::InvalidFormat => write!(f, "Invalid mnemonic file name format"), + MnemonicFileNameError::InvalidFingerprint => write!(f, "Invalid fingerprint format"), + MnemonicFileNameError::InvalidTimestamp => write!(f, "Invalid timestamp format"), + } + } +} + +impl std::error::Error for MnemonicFileNameError {} + +// Implementation of FromStr for MnemonicFileName +impl FromStr for MnemonicFileName { + type Err = MnemonicFileNameError; + + fn from_str(s: &str) -> Result { + // Check if the string starts with "mnemonic-" and ends with ".txt" + if !s.starts_with("mnemonic-") || !s.ends_with(".txt") { + return Err(MnemonicFileNameError::InvalidFormat); + } + + let content = s + .strip_prefix("mnemonic-") + .expect("Already checked") + .strip_suffix(".txt") + .expect("Already checked"); + + let parts: Vec<&str> = content.split('-').collect(); + match parts.len() { + 1 => { + // Only fingerprint + let fingerprint = Fingerprint::from_str(parts[0]) + .map_err(|_| MnemonicFileNameError::InvalidFingerprint)?; + + Ok(MnemonicFileName { + fingerprint, + descriptor_info: None, + }) + } + 3 => { + // Fingerprint + checksum + timestamp + let fingerprint = Fingerprint::from_str(parts[0]) + .map_err(|_| MnemonicFileNameError::InvalidFingerprint)?; + + let timestamp = parts[2] + .parse::() + .map_err(|_| MnemonicFileNameError::InvalidTimestamp)?; + + Ok(MnemonicFileName { + fingerprint, + descriptor_info: Some((parts[1].to_string(), timestamp)), + }) + } + _ => Err(MnemonicFileNameError::InvalidFormat), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -471,7 +564,7 @@ mod tests { let words_set: HashSet<_> = (0..10) .map(|_| { let signer = HotSigner::generate(network).unwrap(); - signer.store(&tmp_dir, network, &secp).unwrap(); + signer.store(&tmp_dir, network, &secp, None).unwrap(); signer.words() }) .collect(); @@ -1087,4 +1180,71 @@ mod tests { ); } } + + #[test] + fn test_mnemonic_filename() { + // Test to_string with descriptor info + let fingerprint = Fingerprint::from_str("abcd1234").unwrap(); + let filename_with_info = MnemonicFileName { + fingerprint, + descriptor_info: Some(("def456".to_string(), 1620000000)), + }; + + assert_eq!( + filename_with_info.to_string(), + "mnemonic-abcd1234-def456-1620000000.txt" + ); + + // Test to_string without descriptor info + let filename_without_info = MnemonicFileName { + fingerprint, + descriptor_info: None, + }; + + assert_eq!(filename_without_info.to_string(), "mnemonic-abcd1234.txt"); + + // Test from_str with descriptor info + let input_with_info = "mnemonic-abcd1234-def456-1620000000.txt"; + let parsed_with_info = MnemonicFileName::from_str(input_with_info).unwrap(); + + assert_eq!(parsed_with_info.fingerprint, fingerprint); + assert_eq!( + parsed_with_info.descriptor_info, + Some(("def456".to_string(), 1620000000)) + ); + + // Test from_str without descriptor info + let input_without_info = "mnemonic-abcd1234.txt"; + let parsed_without_info = MnemonicFileName::from_str(input_without_info).unwrap(); + + assert_eq!(parsed_without_info.fingerprint, fingerprint); + assert_eq!(parsed_without_info.descriptor_info, None); + + // Test roundtrip with descriptor info + let roundtrip_with_info = + MnemonicFileName::from_str(&filename_with_info.to_string()).unwrap(); + assert_eq!(filename_with_info, roundtrip_with_info); + + // Test roundtrip without descriptor info + let roundtrip_without_info = + MnemonicFileName::from_str(&filename_without_info.to_string()).unwrap(); + assert_eq!(filename_without_info, roundtrip_without_info); + + // Test error cases + + // Missing prefix + assert!(MnemonicFileName::from_str("abcd1234.txt").is_err()); + + // Missing suffix + assert!(MnemonicFileName::from_str("mnemonic-abcd1234").is_err()); + + // Wrong number of parts + assert!(MnemonicFileName::from_str("mnemonic-abcd1234-def456.txt").is_err()); + + // Invalid fingerprint (assuming Fingerprint::from_str fails for "invalid") + assert!(MnemonicFileName::from_str("mnemonic-invalid-def456-1620000000.txt").is_err()); + + // Invalid timestamp + assert!(MnemonicFileName::from_str("mnemonic-abcd1234-def456-notanumber.txt").is_err()); + } } From 79fcbc41e27fd6ea1f0d4870832600476392f5e7 Mon Sep 17 00:00:00 2001 From: edouardparis Date: Fri, 16 May 2025 17:13:42 +0200 Subject: [PATCH 8/8] fix: remove unused method with_pin_date --- liana-gui/src/app/wallet.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/liana-gui/src/app/wallet.rs b/liana-gui/src/app/wallet.rs index fad98487..03f0c054 100644 --- a/liana-gui/src/app/wallet.rs +++ b/liana-gui/src/app/wallet.rs @@ -66,11 +66,6 @@ impl Wallet { self } - pub fn with_pin_date(mut self, pinned_at: Option) -> Self { - self.pinned_at = pinned_at; - self - } - // To match with WalletSettings.wallet_id pub fn id(&self) -> WalletId { WalletId::new(self.descriptor_checksum.clone(), self.pinned_at)