diff --git a/liana-gui/src/app/settings.rs b/liana-gui/src/app/settings.rs index eca160d8..cca444fb 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; @@ -19,13 +20,31 @@ 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 { pub wallets: Vec, } +impl Settings { + pub fn from_file(network_dir: &NetworkDirectory) -> Result { + let mut path = network_dir.path().to_path_buf(); + path.push(SETTINGS_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, @@ -33,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() @@ -62,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)))?; @@ -107,6 +133,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 +156,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 +197,52 @@ impl WalletSettings { .collect(); } } + + 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 { + write!(f, "{}", self.descriptor_checksum) + } + } } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] @@ -298,6 +358,7 @@ impl KeySetting { pub enum SettingsError { NotFound, ReadingFile(String), + DeletingFile(String), WritingFile(String), Unexpected(String), } @@ -306,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/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..03f0c054 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::{WalletId, 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,16 @@ impl Wallet { self } + // To match with WalletSettings.wallet_id + 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 { + self.pinned_at = pinned_at; + self + } + pub fn with_key_aliases(mut self, aliases: HashMap) -> Self { self.keys_aliases = aliases; self @@ -92,26 +113,16 @@ 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_pinned_at(wallet_settings.pinned_at) .with_hardware_wallets(wallet_settings.hardware_wallets)) - } else { - Ok(self) } } @@ -172,6 +183,7 @@ impl Wallet { #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub enum WalletError { + WrongWalletLoaded, Settings(settings::SettingsError), HotSigner(String), } @@ -179,6 +191,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..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,62 +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 config = extract_daemon_config(&ctx).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 { - 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); - } - Some(name) - } else { - None - }; + 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], @@ -199,10 +157,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/delete.rs b/liana-gui/src/delete.rs new file mode 100644 index 00000000..dcd6b285 --- /dev/null +++ b/liana-gui/src/delete.rs @@ -0,0 +1,90 @@ +use std::collections::HashSet; + +use crate::{ + app::settings::{self, SettingsError, WalletId}, + dir::NetworkDirectory, + services::connect::client::cache::{self, ConnectCacheError}, + signer, +}; + +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)?; + + signer::delete_wallet_mnemonics( + network_dir, + &wallet_id.descriptor_checksum, + wallet_id.timestamp, + ) + .map_err(DeleteError::Io)?; + + Ok(()) +} diff --git a/liana-gui/src/dir.rs b/liana-gui/src/dir.rs index 5b498704..e8eb8616 100644 --- a/liana-gui/src/dir.rs +++ b/liana-gui/src/dir.rs @@ -1,4 +1,6 @@ +use crate::app::settings::WalletId; 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, wallet_id: &WalletId) -> DataDirectory { + let mut path = self.0.clone(); + if !wallet_id.is_legacy() { + path.push("data"); + path.push(wallet_id.to_string()); + } + DataDirectory::new(path) + } } #[derive(Clone, Debug)] diff --git a/liana-gui/src/installer/message.rs b/liana-gui/src/installer/message.rs index c1ee32ca..2eac96c6 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(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 692bc0e0..b7fe6d40 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -14,21 +14,23 @@ use liana_ui::{ }; use lianad::config::{BitcoinBackend, BitcoindConfig, BitcoindRpcAuth, Config}; use std::ops::Deref; +use tokio::runtime::Handle; use tracing::{error, info, warn}; use std::io::Write; -use std::path::PathBuf; +use std::path::Path; 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, WalletId, WalletSettings}, wallet::wallet_name, }, backup, daemon::DaemonError, - dir::{LianaDirectory, NetworkDirectory}, + delete, + dir::LianaDirectory, hw::{HardwareWalletConfig, HardwareWallets}, services::{ self, @@ -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,10 +376,18 @@ 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 { +) -> Result { let network_datadir = ctx .liana_directory .network_directory(ctx.bitcoin_config.network); @@ -370,7 +395,32 @@ 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 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.clone(), + 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())?; @@ -381,9 +431,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.wallet_id()) + .path() + .join("daemon.toml"), daemon_config.to_string().as_bytes(), )?; @@ -397,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"); @@ -405,46 +464,56 @@ 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"); } // 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 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(), - )?; + 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, + wallet_id: WalletId, signer: Arc>, remote_backend: BackendClient, -) -> Result { +) -> Result { let network_datadir = ctx.liana_directory.network_directory(ctx.network); network_datadir .init() @@ -462,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"); @@ -470,26 +546,33 @@ 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"); } // 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 @@ -540,14 +623,26 @@ 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(), - )?; + // 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 + }) + .await + .map_err(|e| Error::Unexpected(e.to_string()))?; info!("Settings file created"); @@ -567,18 +662,26 @@ 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, + wallet_id: WalletId, backend: BackendWalletClient, -) -> Result { +) -> Result { tracing::info!("Importing wallet from remote backend"); 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"); @@ -590,31 +693,47 @@ 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(), - )?; + // 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 + }) + .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( - &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,91 +751,27 @@ pub async fn import_remote_wallet( info!("Liana-Connect cache updated"); }; - Ok(gui_config_path) + 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 -// 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 { - 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(); - - Settings { - wallets: vec![WalletSettings { - name: wallet_name(descriptor), - descriptor_checksum, - 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 { - 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(); - Settings { - wallets: vec![WalletSettings { - name: wallet_name(descriptor), - 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) -> 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.wallet_id()); + 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/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 98fc4568..fbca9973 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 { + 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/launcher.rs b/liana-gui/src/launcher.rs index 0fa95e16..295beec8 100644 --- a/liana-gui/src/launcher.rs +++ b/liana-gui/src/launcher.rs @@ -11,9 +11,14 @@ use liana_ui::{ widget::*, }; use lianad::config::ConfigError; +use tokio::runtime::Handle; use crate::{ - app, + app::{ + self, + settings::{self, WalletSettings}, + }, + delete::{delete_wallet, DeleteError}, dir::{LianaDirectory, NetworkDirectory}, installer::UserFlow, }; @@ -28,10 +33,9 @@ const NETWORKS: [Network; 4] = [ #[derive(Debug, Clone)] pub enum State { Unchecked, - Wallet { - name: Option, - email: Option, - checksum: Option, + Wallets { + wallets: Vec, + add_wallet: bool, }, NoWallet, } @@ -49,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), ); @@ -95,19 +105,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)) => { @@ -117,8 +129,10 @@ 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)) => { self.delete_wallet_modal = None; Task::none() @@ -133,20 +147,32 @@ 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::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 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() } @@ -170,6 +196,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), @@ -191,7 +232,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,122 +240,39 @@ 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, - ), - ) - })), + 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", ) - .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) + .on_press(ViewMessage::AddWalletToList(true)) .padding(10) - .on_press(ViewMessage::DeleteWallet( - DeleteWalletMessage::ShowModal, - )), - ), - ), - 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), - ) - .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), + .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), ) @@ -338,38 +296,129 @@ 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, + 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::Fixed(500.0)), + ) + .style(theme::card::simple), + ) + .push( + Button::new(icon::trash_icon()) + .style(theme::button::secondary) + .padding(10) + .on_press(ViewMessage::DeleteWallet(DeleteWalletMessage::ShowModal(i))), + ), + ) + .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)] pub enum ViewMessage { ImportWallet, CreateWallet, + AddWalletToList(bool), ShareXpubs, SelectNetwork(Network), StartInstall(Network), Check, - Run, + Run(usize), DeleteWallet(DeleteWalletMessage), } #[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, @@ -377,13 +426,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, @@ -393,7 +442,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; @@ -404,6 +456,7 @@ impl DeleteWalletModal { } Task::none() } + fn view(&self) -> Element { let mut confirm_button = button::secondary(None, "Delete wallet") .width(Length::Fixed(200.0)) @@ -414,8 +467,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.)"), @@ -429,9 +482,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( @@ -513,16 +569,18 @@ 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), - }); + 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()), } - Ok(State::Wallet { - name: None, - checksum: None, - email: None, - }) } 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 c34c5dbe..079a0de2 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,9 +119,12 @@ 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); + let socket_path = datadir_path + .network_directory(network) + .lianad_data_directory(&wallet_settings.wallet_id()) + .lianad_rpc_socket_path(); ( Loader { network, @@ -131,21 +134,17 @@ impl Loader { daemon_started: false, internal_bitcoind, waiting_daemon_bitcoind: false, - wallet_setting, + wallet_settings, backup, }, - Task::perform(connect(path), Message::Loaded), + Task::perform(connect(socket_path), Message::Loaded), ) } 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 +161,7 @@ impl Loader { if info.block_height > 0 { return Task::perform( load_application( + self.wallet_settings.clone(), daemon, info, self.datadir_path.clone(), @@ -207,6 +207,7 @@ impl Loader { self.datadir_path.clone(), self.start_bitcoind(), self.network, + self.wallet_settings.clone(), ), Message::Started, ); @@ -256,6 +257,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 +311,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 +408,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 +425,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)?; @@ -553,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.wallet_id()) .path() .to_path_buf(); config_path.push("daemon.toml"); @@ -631,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 -} diff --git a/liana-gui/src/main.rs b/liana-gui/src/main.rs index 42dc9cc8..fd942403 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))) } @@ -279,20 +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); - 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()) - { + 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(), @@ -310,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))) 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, 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()); + } }