diff --git a/liana-gui/src/app/settings.rs b/liana-gui/src/app/settings.rs index b4376154..27471b5b 100644 --- a/liana-gui/src/app/settings.rs +++ b/liana-gui/src/app/settings.rs @@ -6,9 +6,15 @@ use std::io::Write; use std::path::PathBuf; use liana::miniscript::bitcoin::{bip32::Fingerprint, Network}; +use liana_ui::component::form; use serde::{Deserialize, Serialize}; -use crate::{hw::HardwareWalletConfig, lianalite::client::backend, services}; +use crate::{ + backup::{Key, KeyRole, KeyType}, + hw::HardwareWalletConfig, + lianalite::client::backend, + services, +}; pub const DEFAULT_FILE_NAME: &str = "settings.json"; @@ -151,6 +157,67 @@ pub struct KeySetting { pub provider_key: Option, } +impl KeySetting { + pub fn to_backup(&self) -> Key { + if let Some(provider_key) = &self.provider_key { + if let Ok(metadata) = serde_json::to_value(provider_key) { + return Key { + key: self.master_fingerprint, + alias: Some(self.name.clone()), + role: None, + key_type: Some(KeyType::ThirdParty), + metadata, + }; + } + } + Key { + key: self.master_fingerprint, + alias: Some(self.name.clone()), + role: None, + key_type: None, + metadata: serde_json::Value::Null, + } + } + + pub fn from_backup( + name: String, + fg: Fingerprint, + _role: Option, + key_type: Option, + metadata: serde_json::Value, + ) -> Option { + if let Some(KeyType::ThirdParty) = key_type { + let provider_key = serde_json::from_value(metadata).ok(); + Some(Self { + name, + master_fingerprint: fg, + provider_key, + }) + } else { + Some(Self { + name, + master_fingerprint: fg, + provider_key: None, + }) + } + } + + pub fn to_form(&self) -> form::Value { + form::Value { + value: self.name.clone(), + valid: true, + } + } + + pub fn name(&self) -> String { + self.name.clone() + } + + pub fn has_name(&self) -> bool { + !self.name.is_empty() + } +} + #[derive(PartialEq, Eq, Debug, Clone)] pub enum SettingsError { NotFound, diff --git a/liana-gui/src/app/wallet.rs b/liana-gui/src/app/wallet.rs index 9313e271..52a095f1 100644 --- a/liana-gui/src/app/wallet.rs +++ b/liana-gui/src/app/wallet.rs @@ -179,6 +179,28 @@ impl Wallet { Ok(self) } } + + pub fn keys(&self) -> HashMap { + let mut map = HashMap::new(); + self.keys_aliases.iter().for_each(|(fg, alias)| { + map.insert( + *fg, + settings::KeySetting { + name: alias.clone(), + master_fingerprint: *fg, + provider_key: None, + }, + ); + }); + + self.provider_keys.iter().for_each(|(fg, key)| { + if let Some(entry) = map.get_mut(fg) { + entry.provider_key = Some(key.clone()) + } + }); + + map + } } #[allow(clippy::large_enum_variant)] diff --git a/liana-gui/src/backup.rs b/liana-gui/src/backup.rs new file mode 100644 index 00000000..d8577dac --- /dev/null +++ b/liana-gui/src/backup.rs @@ -0,0 +1,393 @@ +use chrono::{Duration, Utc}; +use liana::miniscript::{ + self, + bitcoin::{bip32::Fingerprint, Network, Txid}, +}; +use lianad::bip329; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{ + collections::{BTreeMap, HashMap}, + fmt::{Debug, Display}, + path::PathBuf, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use crate::{ + app::{settings::Settings, wallet::Wallet, Config}, + daemon::{model::HistoryTransaction, Daemon, DaemonBackend, DaemonError}, + installer::{ + extract_daemon_config, extract_local_gui_settings, extract_remote_gui_settings, Context, + RemoteBackend, + }, + lianalite::client::backend::api::DEFAULT_LIMIT, +}; + +const CONFIG_KEY: &str = "config"; +const SETTINGS_KEY: &str = "settings"; + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("cannot fail") + .as_secs() +} + +#[derive(Serialize, Deserialize)] +pub struct Backup { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub accounts: Vec, + pub network: Network, + pub date: u64, + /// App proprietary metadata (settings, configuration, etc..) + #[serde(skip_serializing_if = "serde_json::Map::is_empty")] + pub proprietary: serde_json::Map, +} + +pub enum Error { + DescriptorMissing, + NotSingleWallet, + Json, + SettingsFromFile, + Daemon(DaemonError), + TxTimeMissing, +} + +impl From for Error { + fn from(value: DaemonError) -> Self { + Error::Daemon(value) + } +} + +impl Display for Backup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = serde_json::to_string(self).map_err(|_| std::fmt::Error)?; + write!(f, "{str}") + } +} + +impl Debug for Backup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = serde_json::to_string_pretty(self).map_err(|_| std::fmt::Error)?; + write!(f, "{str}") + } +} + +impl Backup { + /// Create a Backup from the Installer context + /// + /// # 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(); + + let now = now(); + + let mut account = Account::new(descriptor); + + let mut proprietary = serde_json::Map::new(); + + let config = extract_daemon_config(&ctx); + 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 + }; + + 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], + network: ctx.network, + proprietary: serde_json::Map::new(), + date: now, + }) + } + + /// Create a Backup from the Liana App context + pub async fn from_app( + datadir: PathBuf, + network: Network, + config: Config, + wallet: Arc, + daemon: Arc, + ) -> Result { + let mut proprietary = serde_json::Map::new(); + let name = wallet.name.clone(); + let descriptor = wallet.main_descriptor.to_string(); + let keys = wallet.keys(); + + let settings = + Settings::from_file(datadir, network).map_err(|_| Error::SettingsFromFile)?; + if settings.wallets.len() == 1 { + if let Ok(settings) = serde_json::to_value(settings.wallets[0].clone()) { + proprietary.insert(SETTINGS_KEY.to_string(), settings); + } + } + + if let Ok(config) = serde_json::to_value(config) { + proprietary.insert(CONFIG_KEY.to_string(), config); + } + + let mut account = Account::new(descriptor); + account.proprietary = proprietary; + account.name = Some(name.clone()); + let info = daemon.get_info().await?; + account.timestamp = Some(info.timestamp as u64); + account.change_index = Some(info.change_index); + account.receive_index = Some(info.receive_index); + for (fg, setting) in keys { + account.keys.insert(fg, setting.to_backup()); + } + + const MAX_LABEL_BIP329: u32 = 100; + + let labels = { + let mut buff = Vec::new(); + let mut start = 0; + loop { + let mut fetched = daemon.get_labels_bip329(start, 100).await?.into_vec(); + + if fetched.len() < MAX_LABEL_BIP329 as usize { + buff.append(&mut fetched); + break; + } else { + buff.append(&mut fetched); + start += MAX_LABEL_BIP329; + } + } + bip329::Labels::new(buff) + }; + + account.labels = Some(labels); + account.transactions = get_transactions(&daemon) + .await? + .into_iter() + .map(|tx| miniscript::bitcoin::consensus::encode::serialize_hex(&tx.tx)) + .collect(); + account.psbts = daemon + .list_spend_transactions(None) + .await? + .into_iter() + .map(|tx| tx.psbt.serialize_hex()) + .collect(); + + Ok(Backup { + name: Some(name), + accounts: vec![account], + network, + proprietary: serde_json::Map::new(), + date: now(), + }) + } + + fn account(&self) -> Result<&Account, Error> { + if self.accounts.len() != 1 { + Err(Error::NotSingleWallet) + } else { + Ok(self.accounts.first().expect("single account")) + } + } + + pub fn config(&self) -> Result, Error> { + let account = self.account()?; + if let Some(config) = account.proprietary.get(CONFIG_KEY) { + let config: Config = serde_json::from_value(config.clone()).map_err(|_| Error::Json)?; + Ok(Some(config)) + } else { + Ok(None) + } + } + + pub fn settings(&self) -> Result, Error> { + let account = self.account()?; + if let Some(settings) = account.proprietary.get(SETTINGS_KEY) { + let settings: Settings = + serde_json::from_value(settings.clone()).map_err(|_| Error::Json)?; + Ok(Some(settings)) + } else { + Ok(None) + } + } +} + +async fn get_transactions( + daemon: &Arc, +) -> Result, Error> { + let max = match daemon.backend() { + DaemonBackend::RemoteBackend => DEFAULT_LIMIT as u64, + _ => u32::MAX as u64, + }; + + // look 2 hour forward + // https://github.com/bitcoin/bitcoin/blob/62bd61de110b057cbfd6e31e4d0b727d93119c72/src/chain.h#L29 + let mut end = ((Utc::now() + Duration::hours(2)).timestamp()) as u32; + + // store txs in a map to avoid duplicates + let mut map = HashMap::::new(); + let mut limit = max; + + loop { + let history_txs = daemon.list_history_txs(0, end, limit).await?; + // all txs have been fetched + if history_txs.is_empty() { + return Ok(Vec::new()); + } + + if history_txs.len() == limit as usize { + let first = if let Some(t) = history_txs.first().expect("checked").time { + t + } else { + return Err(Error::TxTimeMissing); + }; + + let last = if let Some(t) = history_txs.last().expect("checked").time { + t + } else { + return Err(Error::TxTimeMissing); + }; + + // limit too low, all tx are in the same timestamp + // we must increase limit and retry + if first == last { + limit += DEFAULT_LIMIT as u64; + continue; + } else { + // add txs to map + for tx in history_txs { + let txid = tx.txid; + map.insert(txid, tx); + } + limit = max; + end = first.min(last); + continue; + } + } else + /* history_txs.len() < limit */ + { + // add txs to map + for tx in history_txs { + let txid = tx.txid; + map.insert(txid, tx); + } + break; + } + } + let vec: Vec<_> = map.into_values().collect(); + Ok(vec) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Account { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub descriptor: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub receive_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub change_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub keys: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub transactions: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub psbts: Vec, + #[serde(skip_serializing_if = "serde_json::Map::is_empty")] + pub proprietary: serde_json::Map, +} + +impl Account { + pub fn new(descriptor: String) -> Self { + Self { + name: None, + descriptor, + receive_index: None, + change_index: None, + timestamp: None, + keys: BTreeMap::new(), + labels: None, + transactions: Vec::new(), + psbts: Vec::new(), + proprietary: serde_json::Map::new(), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Key { + pub key: Fingerprint, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_type: Option, + #[serde(default, skip_serializing_if = "Value::is_null")] + pub metadata: Value, +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum KeyRole { + /// Key to be used in normal spending condition + Main, + /// Key that will be used for recover in case loss of main key(s) + Recovery, + /// Key that wil inherit coins if main user disapear + Inheritance, + /// Key that will cosign a spend in order to enforce some policy + Cosigning, +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum KeyType { + /// Main user + Internal, + /// Heirs or friends + External, + /// Service the user pay for + ThirdParty, +} diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index 9c1fa3c5..4c946087 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -5,6 +5,7 @@ mod prompt; mod step; mod view; +pub use context::{Context, RemoteBackend}; use iced::{clipboard, Subscription, Task}; use liana::miniscript::bitcoin::{self, Network}; use liana_ui::{ @@ -14,8 +15,6 @@ use liana_ui::{ use lianad::config::Config; use tracing::{error, info, warn}; -use context::{Context, RemoteBackend}; - use std::io::Write; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -429,7 +428,7 @@ 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).await; + let settings: gui_settings::Settings = extract_local_gui_settings(&ctx); create_and_write_file( network_datadir_path, gui_settings::DEFAULT_FILE_NAME, @@ -669,7 +668,7 @@ pub async fn extract_remote_gui_settings(ctx: &Context, backend: &BackendWalletC } } -pub async fn extract_local_gui_settings(ctx: &Context) -> Settings { +pub fn extract_local_gui_settings(ctx: &Context) -> Settings { let descriptor = ctx .descriptor .as_ref() diff --git a/liana-gui/src/lib.rs b/liana-gui/src/lib.rs index f0160fa1..411db157 100644 --- a/liana-gui/src/lib.rs +++ b/liana-gui/src/lib.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod backup; pub mod daemon; pub mod datadir; pub mod download; diff --git a/liana-gui/src/node/bitcoind.rs b/liana-gui/src/node/bitcoind.rs index 955c5898..59831507 100644 --- a/liana-gui/src/node/bitcoind.rs +++ b/liana-gui/src/node/bitcoind.rs @@ -6,6 +6,7 @@ use liana::{ }; use liana_ui::component::form; use lianad::config::BitcoindConfig; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fmt; use std::path::{Path, PathBuf}; @@ -154,7 +155,7 @@ impl std::fmt::Display for RpcAuthParseError { } /// Represents RPC auth credentials as stored in bitcoin.conf. -#[derive(PartialEq, Eq, Debug, Clone)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct RpcAuth { pub user: String, salt: String, @@ -209,7 +210,7 @@ impl std::str::FromStr for RpcAuth { } /// Represents section for a single network in `bitcoin.conf` file. -#[derive(PartialEq, Eq, Debug, Clone)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct InternalBitcoindNetworkConfig { pub rpc_port: u16, pub p2p_port: u16,