diff --git a/liana-gui/src/app/settings.rs b/liana-gui/src/app/settings.rs index 0427cab2..b4376154 100644 --- a/liana-gui/src/app/settings.rs +++ b/liana-gui/src/app/settings.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use liana::miniscript::bitcoin::{bip32::Fingerprint, Network}; use serde::{Deserialize, Serialize}; -use crate::hw::HardwareWalletConfig; +use crate::{hw::HardwareWalletConfig, lianalite::client::backend, services}; pub const DEFAULT_FILE_NAME: &str = "settings.json"; @@ -89,12 +89,66 @@ impl WalletSetting { } map } + + pub fn provider_keys(&self) -> HashMap { + let mut map = HashMap::new(); + for (fingerprint, provider_key) in self + .keys + .iter() + .filter_map(|k| k.provider_key.as_ref().map(|pk| (k.master_fingerprint, pk))) + { + map.insert(fingerprint, provider_key.clone()); + } + map + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub struct Provider { + pub uuid: String, + pub name: String, +} + +impl From for Provider { + fn from(provider: backend::api::Provider) -> Self { + Self { + uuid: provider.uuid, + name: provider.name, + } + } +} + +impl From for Provider { + fn from(provider: services::api::Provider) -> Self { + Self { + uuid: provider.uuid, + name: provider.name, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub struct ProviderKey { + pub uuid: String, + pub token: String, + pub provider: Provider, +} + +impl From for ProviderKey { + fn from(pk: backend::api::ProviderKey) -> Self { + Self { + uuid: pk.uuid.clone(), + token: pk.token.clone(), + provider: pk.provider.into(), + } + } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct KeySetting { pub name: String, pub master_fingerprint: Fingerprint, + pub provider_key: Option, } #[derive(PartialEq, Eq, Debug, Clone)] diff --git a/liana-gui/src/app/state/settings/wallet.rs b/liana-gui/src/app/state/settings/wallet.rs index a3564cef..f5281fae 100644 --- a/liana-gui/src/app/state/settings/wallet.rs +++ b/liana-gui/src/app/state/settings/wallet.rs @@ -82,6 +82,7 @@ impl State for WalletSettingsState { self.warning.as_ref(), &self.descriptor, &self.keys_aliases, + &self.wallet.provider_keys, self.processing, self.updated, ); @@ -389,6 +390,7 @@ async fn update_keys_aliases( .map(|(master_fingerprint, name)| settings::KeySetting { master_fingerprint: *master_fingerprint, name: name.clone(), + provider_key: wallet.provider_keys.get(master_fingerprint).cloned(), }) .collect(); } diff --git a/liana-gui/src/app/view/settings.rs b/liana-gui/src/app/view/settings.rs index e4129f1b..ec0d2627 100644 --- a/liana-gui/src/app/view/settings.rs +++ b/liana-gui/src/app/view/settings.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::str::FromStr; use iced::{ @@ -27,6 +27,7 @@ use crate::{ cache::Cache, error::Error, menu::Menu, + settings::ProviderKey, view::{hw, warning::warn}, }, hw::HardwareWallet, @@ -849,6 +850,7 @@ pub fn wallet_settings<'a>( warning: Option<&Error>, descriptor: &'a LianaDescriptor, keys_aliases: &'a [(Fingerprint, form::Value)], + provider_keys: &'a HashMap, processing: bool, updated: bool, ) -> Element<'a, Message> { @@ -943,16 +945,22 @@ pub fn wallet_settings<'a>( .push(header) .push(descr) .push( - card::simple(display_policy(descriptor.policy(), keys_aliases)).width(Length::Fill), + card::simple(display_policy( + descriptor.policy(), + keys_aliases, + provider_keys, + )) + .width(Length::Fill), ) .push(aliases), ) } -fn display_policy( +fn display_policy<'a>( policy: LianaPolicy, - keys_aliases: &[(Fingerprint, form::Value)], -) -> Element<'_, Message> { + keys_aliases: &'a [(Fingerprint, form::Value)], + provider_keys: &'a HashMap, +) -> Element<'a, Message> { let (primary_threshold, primary_keys) = policy.primary_path().thresh_origins(); let recovery_paths = policy.recovery_paths(); @@ -1068,7 +1076,18 @@ fn display_policy( )) .bold(), ) - .push(text(format!("(Recovery path #{})", i + 1))), + .push(text( + // If max timelock and all keys are from provider, then it's a safety net path. + if *sequence == u16::MAX + && recovery_keys + .iter() + .all(|fg| provider_keys.contains_key(fg)) + { + "(Safety Net path)".to_string() + } else { + format!("(Recovery path #{})", i + 1) + }, + )), ); } Column::new() diff --git a/liana-gui/src/app/wallet.rs b/liana-gui/src/app/wallet.rs index f9a04896..9313e271 100644 --- a/liana-gui/src/app/wallet.rs +++ b/liana-gui/src/app/wallet.rs @@ -31,7 +31,9 @@ pub fn wallet_name(main_descriptor: &LianaDescriptor) -> String { pub struct Wallet { pub name: String, pub main_descriptor: LianaDescriptor, + // TODO: We could replace these two fields with `keys: HashMap`. pub keys_aliases: HashMap, + pub provider_keys: HashMap, pub hardware_wallets: Vec, pub signer: Option>, } @@ -42,6 +44,7 @@ impl Wallet { name: wallet_name(&main_descriptor), main_descriptor, keys_aliases: HashMap::new(), + provider_keys: HashMap::new(), hardware_wallets: Vec::new(), signer: None, } @@ -57,6 +60,14 @@ impl Wallet { self } + pub fn with_provider_keys( + mut self, + provider_keys: HashMap, + ) -> Self { + self.provider_keys = provider_keys; + self + } + pub fn with_hardware_wallets(mut self, hardware_wallets: Vec) -> Self { self.hardware_wallets = hardware_wallets; self @@ -101,6 +112,7 @@ impl Wallet { self.with_name(wallet_setting.name.clone()) .with_hardware_wallets(wallet_setting.hardware_wallets.clone()) .with_key_aliases(wallet_setting.keys_aliases()) + .with_provider_keys(wallet_setting.provider_keys()) } else { self } @@ -117,6 +129,7 @@ impl Wallet { .map(|(master_fingerprint, name)| settings::KeySetting { name, master_fingerprint, + provider_key: self.provider_keys.get(&master_fingerprint).cloned(), }) .collect(), descriptor_checksum: self.descriptor_checksum(), diff --git a/liana-gui/src/installer/context.rs b/liana-gui/src/installer/context.rs index b33b8431..c1071ff1 100644 --- a/liana-gui/src/installer/context.rs +++ b/liana-gui/src/installer/context.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -56,7 +57,7 @@ pub struct Context { pub bitcoin_backend: Option, pub descriptor_template: DescriptorTemplate, pub descriptor: Option, - pub keys: Vec, + pub keys: HashMap, pub hws: Vec<(DeviceKind, bitcoin::bip32::Fingerprint, Option<[u8; 32]>)>, pub data_dir: PathBuf, pub network: bitcoin::Network, @@ -83,7 +84,7 @@ impl Context { poll_interval_secs: Duration::from_secs(30), }, hws: Vec::new(), - keys: Vec::new(), + keys: HashMap::new(), bitcoin_backend: None, descriptor: None, data_dir, diff --git a/liana-gui/src/installer/descriptor.rs b/liana-gui/src/installer/descriptor.rs new file mode 100644 index 00000000..917c3b68 --- /dev/null +++ b/liana-gui/src/installer/descriptor.rs @@ -0,0 +1,242 @@ +use async_hwi::{DeviceKind, Version}; +use liana::miniscript::{bitcoin::bip32::Fingerprint, descriptor::DescriptorPublicKey}; + +use crate::{ + app::settings::ProviderKey, hw::is_compatible_with_tapminiscript, services::api::KeyKind, +}; + +/// Whether to enable cosigner keys on all paths (excluding safety net paths). +const ENABLE_COSIGNER_KEYS: bool = true; // FIXME: Set to false after testing. + +/// The source of a descriptor public key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeySource { + /// A hardware signing device with the given kind and version. + Device(DeviceKind, Option), + /// A hot signer on the user's computer. + HotSigner, + /// A manually inserted xpub. + Manual, + /// A token for a key with the given kind. + Token(KeyKind, ProviderKey), +} + +impl KeySource { + pub fn device_kind(&self) -> Option<&DeviceKind> { + if let KeySource::Device(ref device_kind, _) = self { + Some(device_kind) + } else { + None + } + } + + pub fn device_version(&self) -> Option<&Version> { + if let KeySource::Device(_, ref version) = self { + version.as_ref() + } else { + None + } + } + + pub fn is_compatible_taproot(&self) -> bool { + if let KeySource::Device(ref device_kind, ref version) = self { + is_compatible_with_tapminiscript(device_kind, version.as_ref()) + } else { + true + } + } + + pub fn is_manual(&self) -> bool { + matches!(self, KeySource::Manual) + } + + pub fn is_token(&self) -> bool { + matches!(self, KeySource::Token(_, _)) + } + + pub fn kind(&self) -> KeySourceKind { + match self { + Self::Device(_, _) => KeySourceKind::Device, + Self::HotSigner => KeySourceKind::HotSigner, + Self::Manual => KeySourceKind::Manual, + Self::Token(kind, _) => KeySourceKind::Token(*kind), + } + } + + pub fn token(&self) -> Option<&String> { + if let KeySource::Token(_, ProviderKey { token, .. }) = self { + Some(token) + } else { + None + } + } + + pub fn provider_key(&self) -> Option { + if let KeySource::Token(_, provider_key) = self { + Some(provider_key.clone()) + } else { + None + } + } + + pub fn provider_key_kind(&self) -> Option { + if let KeySource::Token(key_kind, _) = self { + Some(*key_kind) + } else { + None + } + } +} + +/// The kind of `KeySource`. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)] +pub enum KeySourceKind { + /// A hardware signing device. + Device, + /// A hot signer. + HotSigner, + /// A manually inserted xpub. + Manual, + /// A token for a key with the given kind. + Token(KeyKind), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Key { + pub source: KeySource, + pub name: String, + pub fingerprint: Fingerprint, + pub key: DescriptorPublicKey, +} + +pub struct Path { + pub keys: Vec>, + pub threshold: usize, + pub sequence: PathSequence, + pub warning: Option, +} + +impl Path { + pub fn new(kind: PathKind) -> Self { + let sequence = match kind { + PathKind::Primary => PathSequence::Primary, + PathKind::Recovery => PathSequence::Recovery(52_596), // displays "1y" in GUI + PathKind::SafetyNet => PathSequence::SafetyNet, + }; + Self { + keys: vec![None], + threshold: 1, + sequence, + warning: None, + } + } + + pub fn new_primary_path() -> Self { + Self::new(PathKind::Primary) + } + + pub fn new_recovery_path() -> Self { + Self::new(PathKind::Recovery) + } + + pub fn new_safety_net_path() -> Self { + Self::new(PathKind::SafetyNet) + } + + pub fn with_n_keys(mut self, n: usize) -> Self { + self.keys = Vec::new(); + for _i in 0..n { + self.keys.push(None); + } + self + } + + pub fn with_threshold(mut self, t: usize) -> Self { + self.threshold = if t > self.keys.len() { + self.keys.len() + } else { + t + }; + self + } + + pub fn kind(&self) -> PathKind { + self.sequence.path_kind() + } + + pub fn valid(&self) -> bool { + !self.keys.is_empty() && !self.keys.iter().any(|k| k.is_none()) && self.warning.is_none() + } +} + +/// The kind of spending path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathKind { + Primary, + Recovery, + SafetyNet, +} + +impl PathKind { + /// Whether a key with the given `KeySourceKind` can be chosen for this `PathKind`. + pub fn can_choose_key_source_kind(&self, source_kind: &KeySourceKind) -> bool { + match (self, source_kind) { + // Safety net path only allows safety net keys. + (Self::SafetyNet, KeySourceKind::Token(KeyKind::SafetyNet)) => true, + (Self::SafetyNet, _) => false, + // Safety net keys cannot be used in any other path kind. + (_, KeySourceKind::Token(KeyKind::SafetyNet)) => false, + // Enable/disable cosigner keys. + (_, KeySourceKind::Token(KeyKind::Cosigner)) => ENABLE_COSIGNER_KEYS, + _ => true, + } + } +} + +/// The sequence of a spending path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathSequence { + Primary, + Recovery(u16), // this excludes zero, but we don't enforce it here. + SafetyNet, +} + +impl PathSequence { + pub fn as_u16(&self) -> u16 { + match self { + Self::Primary => 0, + Self::Recovery(s) => *s, + Self::SafetyNet => u16::MAX, + } + } + + pub fn path_kind(&self) -> PathKind { + match self { + Self::Primary => PathKind::Primary, + Self::Recovery(_) => PathKind::Recovery, + Self::SafetyNet => PathKind::SafetyNet, + } + } +} + +/// A path warning. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathWarning { + DuplicateSequence, + OnlyCosignerKeys, + KeySourceKindDisallowed, +} + +impl PathWarning { + pub fn message(&self) -> &'static str { + match self { + Self::DuplicateSequence => { + "No two recovery options may become available at the very same date." + } + Self::OnlyCosignerKeys => "A path cannot contain only cosigner keys.", + Self::KeySourceKindDisallowed => { + "Path contains a key that is disallowed for this kind of path." + } + } + } +} diff --git a/liana-gui/src/installer/message.rs b/liana-gui/src/installer/message.rs index 551f08e9..56828ee6 100644 --- a/liana-gui/src/installer/message.rs +++ b/liana-gui/src/installer/message.rs @@ -6,20 +6,22 @@ use std::path::PathBuf; use super::{context, Error}; use crate::{ + app::settings::ProviderKey, download::{DownloadError, Progress}, hw::HardwareWalletMessage, - installer::step::descriptor::editor::key::Key, + installer::descriptor::{Key, PathKind}, lianalite::client::{auth::AuthClient, backend::api}, node::{ bitcoind::{Bitcoind, ConfigField, RpcAuthType}, electrum, NodeType, }, + services, }; #[derive(Debug, Clone)] pub enum Message { UserActionDone(bool), - Exit(PathBuf, Option), + Exit(PathBuf, Option, /* remove log */ bool), Clibpboard(String), Next, Skip, @@ -44,6 +46,9 @@ pub enum Message { WalletRegistered(Result<(Fingerprint, Option<[u8; 32]>), Error>), MnemonicWord(usize, String), ImportMnemonic(bool), + RedeemNextKey, + KeyRedeemed(ProviderKey, Result<(), services::Error>), + AllKeysRedeemed, } #[derive(Debug, Clone)] @@ -114,9 +119,10 @@ pub enum DefineDescriptor { ChangeTemplate(context::DescriptorTemplate), ImportDescriptor(String), KeysEdited(Vec<(usize, usize)>, Key), - KeysEdit(Vec<(usize, usize)>), + KeysEdit(PathKind, Vec<(usize, usize)>), Path(usize, DefinePath), AddRecoveryPath, + AddSafetyNetPath, KeyModal(ImportKeyModal), ThresholdSequenceModal(ThresholdSequenceModal), } @@ -147,6 +153,9 @@ pub enum ImportKeyModal { NameEdited(String), ManuallyImportXpub, ConfirmXpub, + UseToken(services::api::KeyKind), + TokenEdited(String), + ConfirmToken, SelectKey(usize), } diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index 1e22f19d..9c1fa3c5 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -1,4 +1,5 @@ mod context; +mod descriptor; mod message; mod prompt; mod step; @@ -21,8 +22,8 @@ use std::sync::{Arc, Mutex}; use crate::{ app::{ - config as gui_config, settings as gui_settings, - settings::{AuthConfig, Settings, SettingsError, WalletSetting}, + config as gui_config, + settings::{self as gui_settings, AuthConfig, Settings, SettingsError, WalletSetting}, wallet::wallet_name, }, daemon::DaemonError, @@ -30,11 +31,16 @@ use crate::{ hw::{HardwareWalletConfig, HardwareWallets}, lianalite::client::{ auth::AuthError, - backend::{BackendClient, BackendWalletClient}, + backend::{ + api::payload::{Provider, ProviderKey}, + BackendClient, BackendWalletClient, + }, }, + services, signer::Signer, }; +pub use descriptor::{KeySource, KeySourceKind, PathKind, PathSequence}; pub use message::Message; use step::{ BackupDescriptor, BackupMnemonic, ChooseBackend, ChooseDescriptorTemplate, DefineDescriptor, @@ -498,8 +504,23 @@ pub async fn create_remote_wallet( info!("Gui configuration file created"); + let pks: Vec<_> = ctx + .keys + .values() + .filter_map(|key| { + key.provider_key.as_ref().map(|pk| ProviderKey { + fingerprint: key.master_fingerprint.to_string(), + uuid: pk.uuid.clone(), + token: pk.token.clone(), + provider: Provider { + uuid: pk.provider.uuid.clone(), + name: pk.provider.name.clone(), + }, + }) + }) + .collect(); let wallet = remote_backend - .create_wallet(&wallet_name(descriptor), descriptor) + .create_wallet(&wallet_name(descriptor), descriptor, &pks) .await .map_err(|e| Error::Unexpected(e.to_string()))?; @@ -515,7 +536,7 @@ pub async fn create_remote_wallet( let descriptor_str = descriptor.to_string(); let aliases = ctx .keys - .iter() + .values() .filter_map(|k| { if descriptor_str.contains(&k.master_fingerprint.to_string()) { Some((k.master_fingerprint, k.name.to_string())) @@ -674,7 +695,7 @@ pub async fn extract_local_gui_settings(ctx: &Context) -> Settings { wallets: vec![WalletSetting { name: wallet_name(descriptor), descriptor_checksum, - keys: ctx.keys.clone(), + keys: ctx.keys.values().cloned().collect(), hardware_wallets, remote_backend_auth: None, }], @@ -700,6 +721,7 @@ pub enum Error { // DaemonError does not implement Clone. // TODO: maybe Arc is overkill Backend(Arc), + Services(services::Error), Settings(SettingsError), Bitcoind(String), Electrum(String), @@ -752,6 +774,7 @@ impl std::fmt::Display for Error { match self { Self::Auth(e) => write!(f, "Authentication error: {}", e), Self::Backend(e) => write!(f, "Remote backend error: {}", e), + Self::Services(e) => write!(f, "Services error: {}", e), Self::Settings(e) => write!(f, "Settings file error: {}", e), Self::Bitcoind(e) => write!(f, "Failed to ping bitcoind: {}", e), Self::Electrum(e) => write!(f, "Failed to ping Electrum: {}", e), diff --git a/liana-gui/src/installer/step/descriptor/editor/key.rs b/liana-gui/src/installer/step/descriptor/editor/key.rs index 2ddd8751..e9a205be 100644 --- a/liana-gui/src/installer/step/descriptor/editor/key.rs +++ b/liana-gui/src/installer/step/descriptor/editor/key.rs @@ -14,14 +14,15 @@ use liana::miniscript::{ use liana_ui::{component::form, widget::Element}; -use async_hwi::{DeviceKind, Version}; - use crate::{ - hw::{is_compatible_with_tapminiscript, HardwareWallet, HardwareWallets}, + app::settings::ProviderKey, + hw::{HardwareWallet, HardwareWallets}, installer::{ + descriptor::{Key, KeySource, KeySourceKind, PathKind}, message::{self, Message}, view, Error, }, + services, signer::Signer, }; @@ -41,17 +42,6 @@ pub fn new_multixkey_from_xpub( } } -#[derive(Debug, Clone)] -pub struct Key { - pub device_kind: Option, - pub is_hot_signer: bool, - pub device_version: Option, - pub name: String, - pub fingerprint: Fingerprint, - pub key: DescriptorPublicKey, - pub is_compatible_taproot: bool, -} - pub fn check_key_network(key: &DescriptorPublicKey, network: Network) -> bool { match key { DescriptorPublicKey::XPub(key) => { @@ -81,21 +71,27 @@ pub struct EditXpubModal { form_name: form::Value, form_xpub: form::Value, - manually_imported_xpub: bool, + // TODO: Define new `form::Value` type with `Option` instead of `bool` so that we can + // store `form_token_warning` directly in `form_token`. + form_token: form::Value, + form_token_warning: Option, other_path_keys: HashSet, duplicate_master_fg: bool, + path_kind: PathKind, keys: Vec, hot_signer: Arc>, hot_signer_fingerprint: Fingerprint, chosen_signer: Option, + chosen_key_source_kind: Option, } impl EditXpubModal { #[allow(clippy::too_many_arguments)] pub fn new( device_must_support_tapminiscript: bool, + path_kind: PathKind, other_path_keys: HashSet, key: Option, keys_coordinate: Vec<(usize, usize)>, @@ -104,13 +100,9 @@ impl EditXpubModal { hot_signer_fingerprint: Fingerprint, keys: Vec, ) -> Self { - // The xpub is manually imported if the key is neither from a device or the hot signer. - let manually_imported_xpub = key - .as_ref() - .map(|k| !k.is_hot_signer && k.device_kind.is_none()) - .unwrap_or(false); Self { device_must_support_tapminiscript, + path_kind, other_path_keys, form_name: form::Value { valid: true, @@ -118,18 +110,26 @@ impl EditXpubModal { }, form_xpub: form::Value { valid: true, - value: if manually_imported_xpub { - key.as_ref().map(|k| k.key.to_string()).unwrap_or_default() - } else { - String::new() - }, + value: key + .as_ref() + .filter(|k| k.source.is_manual()) + .map(|k| k.key.to_string()) + .unwrap_or_default(), }, - manually_imported_xpub, + form_token: form::Value { + valid: true, + value: key + .as_ref() + .and_then(|k| k.source.token().cloned()) + .unwrap_or_default(), + }, + form_token_warning: None, keys, keys_coordinate, processing: false, error: None, network, + chosen_key_source_kind: key.as_ref().map(|k| k.source.kind()), chosen_signer: key, hot_signer_fingerprint, hot_signer, @@ -163,7 +163,7 @@ impl super::DescriptorEditModal for EditXpubModal { }) = hws.list.get(i) { self.processing = true; - self.manually_imported_xpub = false; + self.chosen_key_source_kind = Some(KeySourceKind::Device); let device_version = version.clone(); let fingerprint = *fingerprint; let device_kind = *kind; @@ -186,17 +186,13 @@ impl super::DescriptorEditModal for EditXpubModal { Ok(key) => { if check_key_network(&key, network) { Ok(Key { - is_hot_signer: false, + source: KeySource::Device( + device_kind, + device_version, + ), fingerprint, name: "".to_string(), key, - is_compatible_taproot: - is_compatible_with_tapminiscript( - &device_kind, - device_version.as_ref(), - ), - device_kind: Some(device_kind), - device_version, }) } else { Err(Error::Unexpected( @@ -215,7 +211,7 @@ impl super::DescriptorEditModal for EditXpubModal { return self.load(); } Message::UseHotSigner => { - self.manually_imported_xpub = false; + self.chosen_key_source_kind = Some(KeySourceKind::HotSigner); let fingerprint = self.hot_signer.lock().unwrap().fingerprint(); let derivation_path = default_derivation_path(self.network); let key_str = format!( @@ -228,13 +224,10 @@ impl super::DescriptorEditModal for EditXpubModal { .get_extended_pubkey(&derivation_path) ); self.chosen_signer = Some(Key { - is_hot_signer: true, + source: KeySource::HotSigner, fingerprint, name: "".to_string(), key: DescriptorPublicKey::from_str(&key_str).unwrap(), - is_compatible_taproot: true, - device_kind: None, - device_version: None, }); self.form_name.value = self .keys @@ -254,9 +247,42 @@ impl super::DescriptorEditModal for EditXpubModal { self.processing = false; match res { Ok(key) => { - self.form_name.valid = true; - self.form_name.value.clone_from(&key.name); - self.chosen_signer = Some(key); + // If it is a provider key that has just been fetched, do some additional sanity checks. + if let Some(key_kind) = key.source.provider_key_kind() { + // We don't need to check key's status as redeemed keys are not returned. + self.form_token_warning = if self.chosen_key_source_kind + != Some(KeySourceKind::Token(key_kind)) + { + Some("Wrong kind of token".to_string()) + } else if !check_key_network(&key.key, self.network) { + Some( + "Fetched key does not have the correct network".to_string(), + ) + } + // If two keys have the same fingerprint, they must both have the same provider key kind (which could be `None`). + // Note that this checks all keys regardless of whether they are currently being used in a path. + else if self.keys.iter().any(|existing| { + existing.fingerprint == key.fingerprint + && existing.source.provider_key_kind() + != key.source.provider_key_kind() + }) { + Some("Two keys with the same fingerprint must have the same provider key kind.".to_string()) + } else { + None + }; + self.form_token.valid = self.form_token_warning.is_none(); + } + // User can set name for key if it is not a provider key or is a valid provider key. + if key.source.provider_key().is_none() || self.form_token.valid { + self.form_name.valid = key.name.is_empty() + || !self.keys.iter().any(|k| { + k.fingerprint != key.fingerprint && k.name == key.name + }); + self.form_name.value.clone_from(&key.name); + self.chosen_signer = Some(key); + } else { + self.chosen_signer = None; + } } Err(e) => { self.chosen_signer = None; @@ -266,9 +292,14 @@ impl super::DescriptorEditModal for EditXpubModal { } message::ImportKeyModal::ManuallyImportXpub => { self.chosen_signer = None; - self.manually_imported_xpub = true; + self.chosen_key_source_kind = Some(KeySourceKind::Manual); self.form_xpub = form::Value::default(); } + message::ImportKeyModal::UseToken(kind) => { + self.chosen_signer = None; + self.chosen_key_source_kind = Some(KeySourceKind::Token(kind)); + self.form_token = form::Value::default(); + } message::ImportKeyModal::NameEdited(name) => { self.form_name.valid = !self.keys.iter().any(|k| { Some(&k.fingerprint) != self.chosen_signer.as_ref().map(|s| &s.fingerprint) @@ -276,6 +307,18 @@ impl super::DescriptorEditModal for EditXpubModal { }); self.form_name.value = name; } + message::ImportKeyModal::TokenEdited(s) => { + self.chosen_signer = None; + // We check if the token has already been fetched and saved regardless of its kind. + self.form_token_warning = + if self.keys.iter().any(|k| k.source.token() == Some(&s)) { + Some("Duplicate token".to_string()) + } else { + None + }; + self.form_token.valid = s.is_empty() || self.form_token_warning.is_none(); + self.form_token.value = s; + } message::ImportKeyModal::XPubEdited(s) => { if let Ok(DescriptorPublicKey::XPub(key)) = DescriptorPublicKey::from_str(&s) { self.chosen_signer = None; @@ -289,13 +332,10 @@ impl super::DescriptorEditModal for EditXpubModal { }; if self.form_xpub.valid { self.chosen_signer = Some(Key { - is_hot_signer: false, + source: KeySource::Manual, fingerprint, name: "".to_string(), key: DescriptorPublicKey::XPub(key), - is_compatible_taproot: true, - device_kind: None, - device_version: None, }); self.form_name.value = "".to_string(); self.form_name.valid = true; @@ -325,9 +365,42 @@ impl super::DescriptorEditModal for EditXpubModal { } } } + message::ImportKeyModal::ConfirmToken => { + // We have checked that the token has not already been fetched and saved. + let token = self.form_token.value.clone(); + let client = services::Client::new(); + return Task::perform( + async move { (token.clone(), client.get_key_by_token(token).await) }, + |(token, res)| { + Message::DefineDescriptor(message::DefineDescriptor::KeyModal( + message::ImportKeyModal::FetchedKey(match res { + Err(e) => Err(Error::Services(e)), + Ok(ref key) => Ok(Key { + source: KeySource::Token( + key.kind, + ProviderKey { + uuid: key.uuid.clone(), + token, + provider: key.provider.clone().into(), + }, + ), + fingerprint: key.xpub.master_fingerprint(), + name: format!( + "{} - {}", + key.provider.name.clone(), + key.kind + ), + key: key.xpub.clone(), + }), + }), + )) + }, + ); + } message::ImportKeyModal::SelectKey(i) => { if let Some(key) = self.keys.get(i) { self.chosen_signer = Some(key.clone()); + self.chosen_key_source_kind = Some(key.source.kind()); self.form_name.value.clone_from(&key.name); self.form_name.valid = true; } @@ -343,10 +416,26 @@ impl super::DescriptorEditModal for EditXpubModal { } fn view<'a>(&'a self, hws: &'a HardwareWallets) -> Element<'a, Message> { + // For provider keys, include the chosen signer in case this is a provider key + // and has not yet been saved, i.e. if it's not in `self.keys`. An unsaved provider + // key will be displayed in a similar way to saved ones. + let provider_keys: Vec<_> = self + .keys + .iter() + .enumerate() + .map(|(i, k)| (Some(i), k)) + .chain((self.chosen_signer).iter().filter_map(|cs| { + (!self.keys.iter().any(|k| k.fingerprint == cs.fingerprint)).then_some((None, cs)) + })) + .filter(|(_, k)| { + k.source.is_token() && self.path_kind.can_choose_key_source_kind(&k.source.kind()) + }) + .collect(); let chosen_signer = self.chosen_signer.as_ref().map(|s| s.fingerprint); view::editor::edit_key_modal( "Set your key", self.network, + self.path_kind, hws.list .iter() .enumerate() @@ -355,6 +444,9 @@ impl super::DescriptorEditModal for EditXpubModal { .keys .iter() .any(|k| Some(k.fingerprint) == hw.fingerprint()) + || !self + .path_kind + .can_choose_key_source_kind(&KeySourceKind::Device) { None } else { @@ -373,23 +465,36 @@ impl super::DescriptorEditModal for EditXpubModal { .iter() .enumerate() .filter_map(|(i, key)| { - if key.fingerprint == self.hot_signer_fingerprint { + // ignore hot signers and provider keys. + if key.fingerprint == self.hot_signer_fingerprint + || key.source.is_token() + || !self + .path_kind + .can_choose_key_source_kind(&key.source.kind()) + { None } else { Some(view::key_list_view( i, &key.name, &key.fingerprint, - key.device_kind.as_ref(), - key.device_version.as_ref(), + key.source.device_kind(), + key.source.device_version(), Some(key.fingerprint) == chosen_signer, self.device_must_support_tapminiscript, )) } }) .collect(), + provider_keys + .iter() + .map(|(i, pk)| { + view::provider_key_list_view(*i, pk, Some(pk.fingerprint) == chosen_signer) + }) + .collect(), self.error.as_ref(), self.chosen_signer.as_ref().map(|s| s.fingerprint), + self.chosen_key_source_kind.as_ref(), &self.hot_signer_fingerprint, self.keys.iter().find_map(|k| { if k.fingerprint == self.hot_signer_fingerprint { @@ -400,7 +505,8 @@ impl super::DescriptorEditModal for EditXpubModal { }), &self.form_name, &self.form_xpub, - self.manually_imported_xpub, + &self.form_token, + self.form_token_warning.as_ref(), self.duplicate_master_fg, ) } diff --git a/liana-gui/src/installer/step/descriptor/editor/mod.rs b/liana-gui/src/installer/step/descriptor/editor/mod.rs index c6157d9e..13c761fe 100644 --- a/liana-gui/src/installer/step/descriptor/editor/mod.rs +++ b/liana-gui/src/installer/step/descriptor/editor/mod.rs @@ -20,19 +20,21 @@ use liana_ui::{ widget::Element, }; -use crate::installer::context::DescriptorTemplate; use crate::{ app::settings::KeySetting, hw::HardwareWallets, installer::{ + context::DescriptorTemplate, + descriptor::{Key, Path, PathKind, PathSequence, PathWarning}, message::{self, Message}, step::{Context, Step}, view, }, + services::api::KeyKind, signer::Signer, }; -use key::{new_multixkey_from_xpub, EditXpubModal, Key}; +use key::{new_multixkey_from_xpub, EditXpubModal}; pub trait DescriptorEditModal { fn processing(&self) -> bool { @@ -47,55 +49,6 @@ pub trait DescriptorEditModal { } } -pub struct Path { - keys: Vec>, - threshold: usize, - // sequence is 0 if it is a primary path. - sequence: u16, - duplicate_sequence: bool, -} - -impl Path { - pub fn new_primary_path() -> Self { - Self { - keys: vec![None], - threshold: 1, - sequence: 0, - duplicate_sequence: false, - } - } - - pub fn new_recovery_path() -> Self { - Self { - keys: vec![None], - threshold: 1, - sequence: u16::MAX, - duplicate_sequence: false, - } - } - - pub fn with_n_keys(mut self, n: usize) -> Self { - self.keys = Vec::new(); - for _i in 0..n { - self.keys.push(None); - } - self - } - - pub fn with_threshold(mut self, t: usize) -> Self { - self.threshold = if t > self.keys.len() { - self.keys.len() - } else { - t - }; - self - } - - fn valid(&self) -> bool { - !self.keys.is_empty() && !self.keys.iter().any(|k| k.is_none()) && !self.duplicate_sequence - } -} - pub struct DefineDescriptor { network: Network, use_taproot: bool, @@ -128,32 +81,35 @@ impl DefineDescriptor { } } - fn path_keys<'a>(&'a self, p: &Path) -> Vec> { - p.keys - .iter() - .map(|f| { - if let Some(f) = f { - self.keys.get(f) - } else { - None - } - }) - .collect() - } - - fn check_for_duplicate(&mut self) { + fn check_for_warning(&mut self) { let mut all_sequence = HashSet::new(); let mut duplicate_sequences = HashSet::new(); for path in &mut self.paths { - if all_sequence.contains(&path.sequence) { - duplicate_sequences.insert(path.sequence); + if all_sequence.contains(&path.sequence.as_u16()) { + duplicate_sequences.insert(path.sequence.as_u16()); } else { - all_sequence.insert(path.sequence); + all_sequence.insert(path.sequence.as_u16()); } } - for path in &mut self.paths { - path.duplicate_sequence = duplicate_sequences.contains(&path.sequence); + if duplicate_sequences.contains(&path.sequence.as_u16()) { + path.warning = Some(PathWarning::DuplicateSequence); + } else if path.keys.iter().all(|key| { + // All keys must be Some for warning to apply. + key.as_ref() + .is_some_and(|k| k.source.provider_key_kind() == Some(KeyKind::Cosigner)) + }) { + path.warning = Some(PathWarning::OnlyCosignerKeys); + } else if path + .keys + .iter() + .flatten() // can ignore None + .any(|key| !path.kind().can_choose_key_source_kind(&key.source.kind())) + { + path.warning = Some(PathWarning::KeySourceKindDisallowed); + } else { + path.warning = None; + } } } @@ -161,18 +117,15 @@ impl DefineDescriptor { !self.paths.iter().any(|path| { !path.valid() || (self.use_taproot - && path.keys.iter().any(|k| { - if let Some(k) = k.and_then(|k| self.keys.get(&k)) { - !k.is_compatible_taproot - } else { - false - } - })) + && path + .keys + .iter() + .any(|k| !k.as_ref().is_some_and(|k| k.source.is_compatible_taproot()))) }) && self.paths.len() >= 2 } fn check_setup(&mut self) { - self.check_for_duplicate(); + self.check_for_warning(); } fn load_template(&mut self, template: DescriptorTemplate) { @@ -218,27 +171,33 @@ impl Step for DefineDescriptor { Message::DefineDescriptor(message::DefineDescriptor::AddRecoveryPath) => { self.paths.push(Path::new_recovery_path()); } + Message::DefineDescriptor(message::DefineDescriptor::AddSafetyNetPath) => { + if !self.paths.iter().any(|p| p.kind() == PathKind::SafetyNet) { + self.paths.push(Path::new_safety_net_path()); + } + } Message::DefineDescriptor(message::DefineDescriptor::KeysEdited(coordinate, key)) => { hws.set_alias(key.fingerprint, key.name.clone()); for (i, j) in coordinate { - self.paths[i].keys[j] = Some(key.fingerprint); + self.paths[i].keys[j] = Some(key.clone()); } self.keys.insert(key.fingerprint, key); self.modal = None; self.check_setup(); } - Message::DefineDescriptor(message::DefineDescriptor::KeysEdit(coordinate)) => { + Message::DefineDescriptor(message::DefineDescriptor::KeysEdit( + path_kind, + coordinate, + )) => { let use_taproot = self.use_taproot; let mut set = HashSet::::new(); let key = coordinate .first() - .and_then(|(i, j)| self.paths[*i].keys[*j]) - .and_then(|f| self.keys.get(&f)) - .cloned(); + .and_then(|(i, j)| self.paths[*i].keys[*j].clone()); for (i, j) in &coordinate { - set.extend(self.paths[*i].keys.iter().filter_map(|key| { - if key.is_some() && key != &self.paths[*i].keys[*j] { - *key + set.extend(self.paths[*i].keys.iter().flatten().filter_map(|key| { + if Some(key) != self.paths[*i].keys[*j].as_ref() { + Some(key.fingerprint) } else { None } @@ -246,6 +205,7 @@ impl Step for DefineDescriptor { } let modal = EditXpubModal::new( use_taproot, + path_kind, set, key, coordinate, @@ -258,89 +218,97 @@ impl Step for DefineDescriptor { self.modal = Some(Box::new(modal)); return cmd; } - Message::DefineDescriptor(message::DefineDescriptor::Path(i, msg)) => match msg { - message::DefinePath::SequenceEdited(seq) => { - self.modal = None; - if let Some(path) = self.paths.get_mut(i) { - path.sequence = seq; - } - self.check_for_duplicate(); - } - message::DefinePath::ThresholdEdited(t) => { - self.modal = None; - if let Some(path) = self.paths.get_mut(i) { - path.threshold = t; - } - } - message::DefinePath::EditSequence => { - if let Some(path) = self.paths.get(i) { - self.modal = Some(Box::new(EditSequenceModal::new(i, path.sequence))); - } - } - message::DefinePath::EditThreshold => { - if let Some(path) = self.paths.get(i) { - self.modal = Some(Box::new(EditThresholdModal::new( - i, - (path.threshold, path.keys.len()), - ))); - } - } - - message::DefinePath::AddKey => { - if let Some(path) = self.paths.get_mut(i) { - path.keys.push(None); - path.threshold += 1; - } - } - message::DefinePath::Key(j, msg) => match msg { - message::DefineKey::Clipboard(key) => { - return Task::perform(async move { key }, Message::Clibpboard); - } - - message::DefineKey::Edit => { - let use_taproot = self.use_taproot; - let path = &self.paths[i]; - let modal = EditXpubModal::new( - use_taproot, - HashSet::from_iter(path.keys.iter().filter_map(|key| { - if key.is_some() && key != &path.keys[j] { - *key - } else { - None - } - })), - path.keys[j].and_then(|f| self.keys.get(&f)).cloned(), - vec![(i, j)], - self.network, - self.signer.clone(), - self.signer_fingerprint, - self.keys.values().cloned().collect(), - ); - let cmd = modal.load(); - self.modal = Some(Box::new(modal)); - return cmd; - } - message::DefineKey::Delete => { - if let Some(path) = self.paths.get_mut(i) { - path.keys.remove(j); - if path.threshold > path.keys.len() { - path.threshold -= 1; - } - } - // Only delete recovery paths. - if i > 0 - && self - .paths - .get(i) - .map(|path| path.keys.is_empty()) - .unwrap_or(false) + Message::DefineDescriptor(message::DefineDescriptor::Path(i, msg)) => { + match msg { + message::DefinePath::SequenceEdited(seq) => { + self.modal = None; + if let Some(Path { + sequence: PathSequence::Recovery(s), + .. + }) = self.paths.get_mut(i) { - self.paths.remove(i); + *s = seq; } - self.check_setup(); + self.check_for_warning(); } - }, - }, + message::DefinePath::ThresholdEdited(t) => { + self.modal = None; + if let Some(path) = self.paths.get_mut(i) { + path.threshold = t; + } + } + message::DefinePath::EditSequence => { + if let Some(path) = self.paths.get(i) { + self.modal = Some(Box::new(EditSequenceModal::new(i, path.sequence))); + } + } + message::DefinePath::EditThreshold => { + if let Some(path) = self.paths.get(i) { + self.modal = Some(Box::new(EditThresholdModal::new( + i, + (path.threshold, path.keys.len()), + ))); + } + } + + message::DefinePath::AddKey => { + if let Some(path) = self.paths.get_mut(i) { + path.keys.push(None); + path.threshold += 1; + self.check_for_warning(); + } + } + message::DefinePath::Key(j, msg) => match msg { + message::DefineKey::Clipboard(key) => { + return Task::perform(async move { key }, Message::Clibpboard); + } + + message::DefineKey::Edit => { + let use_taproot = self.use_taproot; + let path = &self.paths[i]; + let modal = EditXpubModal::new( + use_taproot, + path.kind(), + HashSet::from_iter(path.keys.iter().flatten().filter_map(|key| { + if Some(key) != path.keys[j].as_ref() { + Some(key.fingerprint) + } else { + None + } + })), + path.keys[j].clone(), + vec![(i, j)], + self.network, + self.signer.clone(), + self.signer_fingerprint, + self.keys.values().cloned().collect(), + ); + let cmd = modal.load(); + self.modal = Some(Box::new(modal)); + return cmd; + } + message::DefineKey::Delete => { + if let Some(path) = self.paths.get_mut(i) { + path.keys.remove(j); + if path.threshold > path.keys.len() { + path.threshold -= 1; + } + } + // Only delete non-primary paths. + if i > 0 // we could alternatively check `path_kind != PathKind::Primary` + && self + .paths + .get(i) + .map(|path| path.keys.is_empty()) + .unwrap_or(false) + { + self.paths.remove(i); + } + self.check_setup(); + } + }, + } + } _ => { if let Some(modal) = &mut self.modal { return modal.update(hws, message); @@ -364,23 +332,30 @@ impl Step for DefineDescriptor { } ctx.bitcoin_config.network = self.network; - ctx.keys = Vec::new(); + ctx.keys = HashMap::new(); let mut hw_is_used = false; let mut spending_keys: Vec = Vec::new(); let mut key_derivation_index = HashMap::::new(); for spending_key in self.paths[0].keys.iter().clone() { - let fingerprint = spending_key.expect("Must be present at this step"); + let fingerprint = spending_key + .as_ref() + .expect("Must be present at this step") + .fingerprint; let key = self .keys .get(&fingerprint) .expect("Must be present at this step"); if let DescriptorPublicKey::XPub(xpub) = &key.key { if let Some((master_fingerprint, _)) = xpub.origin { - ctx.keys.push(KeySetting { + ctx.keys.insert( master_fingerprint, - name: key.name.clone(), - }); - if key.device_kind.is_some() { + KeySetting { + master_fingerprint, + name: key.name.clone(), + provider_key: key.source.provider_key(), + }, + ); + if key.source.device_kind().is_some() { hw_is_used = true; } } @@ -398,18 +373,25 @@ impl Step for DefineDescriptor { for path in &self.paths[1..] { let mut recovery_keys: Vec = Vec::new(); for recovery_key in path.keys.iter().clone() { - let fingerprint = recovery_key.expect("Must be present at this step"); + let fingerprint = recovery_key + .as_ref() + .expect("Must be present at this step") + .fingerprint; let key = self .keys .get(&fingerprint) .expect("Must be present at this step"); if let DescriptorPublicKey::XPub(xpub) = &key.key { if let Some((master_fingerprint, _)) = xpub.origin { - ctx.keys.push(KeySetting { + ctx.keys.insert( master_fingerprint, - name: key.name.clone(), - }); - if key.device_kind.is_some() { + KeySetting { + master_fingerprint, + name: key.name.clone(), + provider_key: key.source.provider_key(), + }, + ); + if key.source.device_kind().is_some() { hw_is_used = true; } } @@ -429,7 +411,7 @@ impl Step for DefineDescriptor { PathInfo::Multi(path.threshold, recovery_keys) }; - recovery_paths.insert(path.sequence, recovery_keys); + recovery_paths.insert(path.sequence.as_u16(), recovery_keys); } if spending_keys.is_empty() { @@ -470,13 +452,8 @@ impl Step for DefineDescriptor { view::editor::template::inheritance::inheritance_template( progress, self.use_taproot, - self.paths[0].keys[0] - .as_ref() - .and_then(|f| self.keys.get(f)), - self.paths[1].keys[0] - .as_ref() - .and_then(|f| self.keys.get(f)), - self.paths[1].sequence, + &self.paths[0], + &self.paths[1], self.valid(), ) } @@ -484,31 +461,24 @@ impl Step for DefineDescriptor { view::editor::template::multisig_security_wallet::multisig_security_template( progress, self.use_taproot, - self.path_keys(&self.paths[0]), - self.path_keys(&self.paths[1]), - self.paths[1].sequence, - self.paths[1].threshold, + &self.paths[0], + &self.paths[1], self.valid(), ) } DescriptorTemplate::Custom => view::editor::template::custom::custom_template( progress, self.use_taproot, - view::editor::template::custom::Path { - keys: self.path_keys(&self.paths[0]), - sequence: self.paths[0].sequence, - duplicate_sequence: self.paths[0].duplicate_sequence, - threshold: self.paths[0].threshold, - }, + &self.paths[0], &mut self.paths[1..] .iter() - .map(|p| view::editor::template::custom::Path { - sequence: p.sequence, - duplicate_sequence: p.duplicate_sequence, - threshold: p.threshold, - keys: self.path_keys(p), - }), - self.paths.len().saturating_sub(1), // subtract 1 for primary path + .enumerate() + .filter(|(_, p)| p.kind() == PathKind::Recovery), + self.paths[1..] + .iter() + .enumerate() + .find(|(_, p)| p.kind() == PathKind::SafetyNet), + self.paths[1..].len(), self.valid(), ), }; @@ -538,11 +508,11 @@ pub struct EditSequenceModal { } impl EditSequenceModal { - pub fn new(path_index: usize, sequence: u16) -> Self { + pub fn new(path_index: usize, path_sequence: PathSequence) -> Self { Self { path_index, sequence: form::Value { - value: sequence.to_string(), + value: path_sequence.as_u16().to_string(), valid: true, }, } @@ -657,6 +627,8 @@ mod tests { use std::path::PathBuf; use std::sync::{Arc, Mutex}; + use crate::installer::descriptor::KeySource; + pub struct Sandbox { step: Arc>, } @@ -788,10 +760,7 @@ mod tests { name: "My Specter key".to_string(), fingerprint: key.master_fingerprint(), key, - device_kind: Some(async_hwi::DeviceKind::Specter), - device_version: None, - is_compatible_taproot: false, - is_hot_signer: false, + source: KeySource::Device(async_hwi::DeviceKind::Specter, None), }; // Use Specter device for primary key diff --git a/liana-gui/src/installer/step/descriptor/mod.rs b/liana-gui/src/installer/step/descriptor/mod.rs index ce0ecb08..e98463b9 100644 --- a/liana-gui/src/installer/step/descriptor/mod.rs +++ b/liana-gui/src/installer/step/descriptor/mod.rs @@ -16,7 +16,7 @@ use liana_ui::{component::form, widget::Element}; use async_hwi::DeviceKind; use crate::{ - app::wallet::wallet_name, + app::{settings::KeySetting, wallet::wallet_name}, hw::{HardwareWallet, HardwareWallets}, installer::{ message::{self, Message}, @@ -169,7 +169,7 @@ impl Step for RegisterDescriptor { } self.descriptor.clone_from(&ctx.descriptor); let mut map = HashMap::new(); - for key in ctx.keys.iter().filter(|k| !k.name.is_empty()) { + for key in ctx.keys.values().filter(|k| !k.name.is_empty()) { map.insert(key.master_fingerprint, key.name.clone()); } } @@ -291,7 +291,7 @@ impl From for Box { pub struct BackupDescriptor { done: bool, descriptor: Option, - key_aliases: HashMap, + keys: HashMap, } impl Step for BackupDescriptor { @@ -306,12 +306,11 @@ impl Step for BackupDescriptor { self.descriptor.clone_from(&ctx.descriptor); self.done = false; } - self.key_aliases = ctx + self.keys = ctx .keys - .iter() - .cloned() - .map(|k| (k.master_fingerprint, k.name)) - .collect() + .values() + .map(|k| (k.master_fingerprint, k.clone())) + .collect(); } fn view<'a>( &'a self, @@ -323,7 +322,7 @@ impl Step for BackupDescriptor { progress, email, self.descriptor.as_ref().expect("Must be a descriptor"), - &self.key_aliases, + &self.keys, self.done, ) } diff --git a/liana-gui/src/installer/step/mod.rs b/liana-gui/src/installer/step/mod.rs index d799185b..a6b7b082 100644 --- a/liana-gui/src/installer/step/mod.rs +++ b/liana-gui/src/installer/step/mod.rs @@ -19,17 +19,20 @@ pub use descriptor::{ pub use backend::{ChooseBackend, ImportRemoteWallet, RemoteBackendLogin}; pub use mnemonic::{BackupMnemonic, RecoverMnemonic}; pub use share_xpubs::ShareXpubs; +use tracing::warn; -use std::path::PathBuf; +use std::{collections::HashMap, path::PathBuf}; use iced::{Subscription, Task}; use liana_ui::widget::*; use crate::{ + app::settings::ProviderKey, hw::HardwareWallets, installer::{context::Context, message::Message, view}, node::bitcoind::Bitcoind, + services, }; pub trait Step { @@ -65,6 +68,7 @@ pub struct Final { internal_bitcoind: Option, warning: Option, config_path: Option, + key_redemptions: HashMap>>, } impl Final { @@ -74,6 +78,7 @@ impl Final { generating: false, warning: None, config_path: None, + key_redemptions: HashMap::new(), } } } @@ -87,6 +92,11 @@ impl Default for Final { impl Step for Final { fn load_context(&mut self, ctx: &Context) { self.internal_bitcoind.clone_from(&ctx.internal_bitcoind); + self.key_redemptions = ctx + .keys + .values() + .filter_map(|ks| ks.provider_key.as_ref().map(|pk| (pk.clone(), None))) + .collect(); } fn load(&self) -> Task { if !self.generating && self.config_path.is_none() { @@ -97,24 +107,62 @@ impl Step for Final { } fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Task { match message { - Message::Installed(res) => { + Message::RedeemNextKey => { + if let Some((pk, _)) = self.key_redemptions.iter().find(|(_, v)| v.is_none()) { + let client = services::Client::new(); + let pk = pk.clone(); + return Task::perform( + async move { (pk.clone(), client.redeem_key(pk.uuid, pk.token).await) }, + |(pk, res)| Message::KeyRedeemed(pk, res.map(|_| ())), + ); + } + return Task::perform(async move {}, |_| Message::AllKeysRedeemed); + } + Message::KeyRedeemed(pk, res) => { + if let Some(v) = self.key_redemptions.get_mut(&pk) { + *v = Some(res); + } + return Task::perform(async move {}, |_| Message::RedeemNextKey); + } + Message::AllKeysRedeemed => { self.generating = false; - match res { - Err(e) => { - self.config_path = None; - self.warning = Some(e.to_string()); - } - Ok(path) => { - self.config_path = Some(path.clone()); - let internal_bitcoind = self.internal_bitcoind.clone(); - let path = path.clone(); - return Task::perform( - async { (path, internal_bitcoind) }, - |(path, internal_bitcoind)| Message::Exit(path, internal_bitcoind), - ); + // If any errors occurred redeeming tokens, add a warning to the log. + let mut has_error = false; + for (pk, res) in &self.key_redemptions { + if let Some(res) = res { + if let Err(e) = res { + warn!("Error redeeming key for token '{}': '{}'.", pk.token, e); + has_error = true; + } + } else { + // We expect to have all redemption results by now. + warn!("Missing redemption info for token '{}'.", pk.token); + has_error = true; } } + // 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"); + // 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) + }, + ); } + Message::Installed(res) => match res { + Err(e) => { + self.generating = false; + self.config_path = None; + self.warning = Some(e.to_string()); + } + Ok(path) => { + self.config_path = Some(path.clone()); + // Now redeem any provider keys. + return Task::perform(async move {}, |_| Message::RedeemNextKey); + } + }, Message::Install => { self.generating = true; self.config_path = None; diff --git a/liana-gui/src/installer/view/editor/mod.rs b/liana-gui/src/installer/view/editor/mod.rs index 4632734a..3464258d 100644 --- a/liana-gui/src/installer/view/editor/mod.rs +++ b/liana-gui/src/installer/view/editor/mod.rs @@ -24,8 +24,9 @@ use liana_ui::{ }; use crate::installer::{ + descriptor::{KeySourceKind, PathKind, PathSequence, PathWarning}, message::{self, Message}, - prompt, + prompt, services, view::defined_sequence, Error, }; @@ -89,8 +90,8 @@ pub fn define_descriptor_advanced_settings<'a>(use_taproot: bool) -> Element<'a, pub fn path( color: iced::Color, title: Option, - sequence: u16, - duplicate_sequence: bool, + sequence: PathSequence, + warning: Option, threshold: usize, keys: Vec>, fixed: bool, @@ -100,7 +101,7 @@ pub fn path( Column::new() .spacing(10) .push_maybe(title.map(|t| Row::new().push(Space::with_width(10)).push(p1_bold(t)))) - .push(defined_sequence(sequence, duplicate_sequence)) + .push(defined_sequence(sequence, warning)) .push( Column::new() .spacing(5) @@ -119,8 +120,15 @@ pub fn path( .spacing(10) .push(defined_threshold(color, fixed, (threshold, keys_len))) .push( - button::secondary(Some(icon::plus_icon()), "Add key") - .on_press(message::DefinePath::AddKey), + button::secondary( + Some(icon::plus_icon()), + if sequence.path_kind() == PathKind::SafetyNet { + "Add Safety Net key" + } else { + "Add key" + }, + ) + .on_press(message::DefinePath::AddKey), ), ) }), @@ -251,19 +259,97 @@ pub fn undefined_key<'a>( .into() } +fn maybe_key_from_token<'a>( + path_kind: PathKind, + chosen_key_source_kind: Option<&KeySourceKind>, + has_chosen_signer: bool, + form_token: &form::Value, + form_token_warning: Option<&'a String>, + key_kind: services::api::KeyKind, +) -> Option> { + if !path_kind.can_choose_key_source_kind(&KeySourceKind::Token(key_kind)) { + None + } else { + Some( + match (chosen_key_source_kind, has_chosen_signer) { + (Some(KeySourceKind::Token(key_kind)), false) => card::simple( + Column::new() + .spacing(10) + .push( + Row::new() + .align_y(Alignment::Center) + .push( + p1_regular(format!("Enter a {key_kind} token:")) + .width(Length::Fill), + ) + .push(image::success_mark_icon().width(Length::Fixed(50.0))), + ) + .push( + Row::new() + .push( + form::Form::new_trimmed("", form_token, |msg| { + Message::DefineDescriptor( + message::DefineDescriptor::KeyModal( + message::ImportKeyModal::TokenEdited(msg), + ), + ) + }) + .maybe_warning(form_token_warning.map(|w| w.as_str())) + .size(text::P1_SIZE) + .padding(10), + ) + .push(button::primary(None, "Confirm").on_press_maybe( + (!form_token.value.is_empty() && form_token.valid).then_some( + Message::DefineDescriptor( + message::DefineDescriptor::KeyModal( + message::ImportKeyModal::ConfirmToken, + ), + ), + ), + )) + .spacing(10), + ), + ), + _ => Container::new( + Button::new( + Row::new() + .align_y(Alignment::Center) + .spacing(10) + .push(icon::import_icon()) + .push(p1_regular(format!("Enter a {key_kind} token"))), + ) + .padding(20) + .width(Length::Fill) + .on_press(Message::DefineDescriptor( + message::DefineDescriptor::KeyModal(message::ImportKeyModal::UseToken( + key_kind, + )), + )) + .style(theme::button::secondary), + ), + } + .into(), + ) + } +} + #[allow(clippy::too_many_arguments)] pub fn edit_key_modal<'a>( title: &'a str, network: bitcoin::Network, + path_kind: PathKind, hws: Vec>, keys: Vec>, + provider_keys: Vec>, error: Option<&Error>, chosen_signer: Option, + chosen_key_source_kind: Option<&KeySourceKind>, hot_signer_fingerprint: &Fingerprint, signer_alias: Option<&'a String>, form_name: &'a form::Value, form_xpub: &form::Value, - manually_imported_xpub: bool, + form_token: &form::Value, + form_token_warning: Option<&'a String>, duplicate_master_fg: bool, ) -> Element<'a, Message> { let content = Column::new() @@ -280,26 +366,28 @@ pub fn edit_key_modal<'a>( ) .push( Column::new() - .push(p1_regular("Select the signing device for your key")) + .push(p1_regular("Select the source of your key")) .spacing(10) - .push( - Column::with_children(hws).spacing(10) - ) - .push( - Column::with_children(keys).spacing(10) - ) - .push( - Button::new(if Some(*hot_signer_fingerprint) == chosen_signer { + .push(Column::with_children(hws).spacing(10)) + .push(Column::with_children(keys).spacing(10)) + .push(Column::with_children(provider_keys).spacing(10)) + .push_maybe(if !path_kind.can_choose_key_source_kind(&KeySourceKind::HotSigner) { + None + } else { + Some(Button::new(if Some(*hot_signer_fingerprint) == chosen_signer { hw::selected_hot_signer(hot_signer_fingerprint, signer_alias) } else { hw::unselected_hot_signer(hot_signer_fingerprint, signer_alias) }) .width(Length::Fill) .on_press(Message::UseHotSigner) - .style(theme::button::secondary), + .style(theme::button::secondary)) + } ) - .push(if manually_imported_xpub { - card::simple(Column::new() + .push_maybe(if !path_kind.can_choose_key_source_kind(&KeySourceKind::Manual) { + None + } else if chosen_key_source_kind == Some(&KeySourceKind::Manual) && chosen_signer.is_none() { + Some(card::simple(Column::new() .spacing(10) .push( Row::new() @@ -326,25 +414,27 @@ pub fn edit_key_modal<'a>( .padding(10), ) .spacing(10) - )) - } else { - Container::new( - Button::new( - Row::new() - .align_y(Alignment::Center) - .spacing(10) - .push(icon::import_icon()) - .push(p1_regular("Enter an extended public key")) - ) - .padding(20) - .width(Length::Fill) - .on_press(Message::DefineDescriptor( - message::DefineDescriptor::KeyModal(message::ImportKeyModal::ManuallyImportXpub) - )) - .style(theme::button::secondary), - ) + ))) + } else { + Some(Container::new( + Button::new( + Row::new() + .align_y(Alignment::Center) + .spacing(10) + .push(icon::import_icon()) + .push(p1_regular("Enter an extended public key")) + ) + .padding(20) + .width(Length::Fill) + .on_press(Message::DefineDescriptor( + message::DefineDescriptor::KeyModal(message::ImportKeyModal::ManuallyImportXpub) + )) + .style(theme::button::secondary), + )) } - ) + ) + .push_maybe(maybe_key_from_token(path_kind, chosen_key_source_kind, chosen_signer.is_some(), form_token, form_token_warning, services::api::KeyKind::SafetyNet)) + .push_maybe(maybe_key_from_token(path_kind, chosen_key_source_kind, chosen_signer.is_some(), form_token, form_token_warning, services::api::KeyKind::Cosigner)) .width(Length::Fill), ) .push_maybe( @@ -382,14 +472,16 @@ pub fn edit_key_modal<'a>( .push( button::primary(None, "Apply") .on_press_maybe(if !duplicate_master_fg - && (!manually_imported_xpub || form_xpub.valid) - && !form_name.value.is_empty() && form_name.valid { - Some(Message::DefineDescriptor( - message::DefineDescriptor::KeyModal( - message::ImportKeyModal::ConfirmXpub, - ), - )) - } else {None}) + && !form_name.value.is_empty() && form_name.valid + && chosen_signer.is_some() { + Some(Message::DefineDescriptor( + message::DefineDescriptor::KeyModal( + message::ImportKeyModal::ConfirmXpub, + ) + )) + } else { + None + }) .width(Length::Fixed(200.0)) ) .align_x(Alignment::Center), diff --git a/liana-gui/src/installer/view/editor/template/custom.rs b/liana-gui/src/installer/view/editor/template/custom.rs index 35fe241b..75559d39 100644 --- a/liana-gui/src/installer/view/editor/template/custom.rs +++ b/liana-gui/src/installer/view/editor/template/custom.rs @@ -1,4 +1,8 @@ -use iced::{alignment, widget::Space, Alignment, Length}; +use iced::{ + alignment, + widget::{tooltip, Container, Space}, + Alignment, Length, +}; use liana_ui::{ color, @@ -11,14 +15,16 @@ use liana_ui::{ }; use crate::installer::{ + descriptor::Path, message::{self, Message}, - step::descriptor::editor::key::Key, view::{ editor::{define_descriptor_advanced_settings, defined_key, path, undefined_key}, layout, }, }; +const SAFETY_NET_DESCRIPTION: &str = "This adds a final recovery option containing keys from professional key agents.\n\nUse this option if you have been provided one or more Safety Net tokens."; + pub fn custom_template_description(progress: (usize, usize)) -> Element<'static, Message> { layout( progress, @@ -47,19 +53,13 @@ pub fn custom_template_description(progress: (usize, usize)) -> Element<'static, ) } -pub struct Path<'a> { - pub keys: Vec>, - pub sequence: u16, - pub duplicate_sequence: bool, - pub threshold: usize, -} - pub fn custom_template<'a>( progress: (usize, usize), use_taproot: bool, - primary_path: Path<'a>, - recovery_paths: &mut dyn Iterator>, - num_recovery_paths: usize, + primary_path: &'a Path, + recovery_paths: &mut dyn Iterator, + safety_net_path: Option<(usize, &'a Path)>, + num_non_primary_paths: usize, valid: bool, ) -> Element<'a, Message> { let prim_keys_fixed = primary_path.keys.len() < 2; // can only delete a primary key if there are 2 or more @@ -98,7 +98,7 @@ pub fn custom_template<'a>( color::GREEN, Some("Primary spending option:".to_string()), primary_path.sequence, - primary_path.duplicate_sequence, + primary_path.warning, primary_path.threshold, primary_path .keys @@ -110,7 +110,7 @@ pub fn custom_template<'a>( &key.name, color::GREEN, "Primary key", - if use_taproot && !key.is_compatible_taproot { + if use_taproot && !key.source.is_compatible_taproot() { Some("This device does not support Taproot") } else { None @@ -134,27 +134,28 @@ pub fn custom_template<'a>( ) .push(recovery_paths.into_iter().enumerate().fold( Column::new().spacing(20), - |col, (i, p)| { + |col, (i, (p_idx, p))| { col.push( path( color::ORANGE, Some(format!("Recovery option #{}:", i + 1)), p.sequence, - p.duplicate_sequence, + p.warning, p.threshold, p.keys .iter() .enumerate() .map(|(j, recovery_key)| { // We cannot delete a key if doing so would remove all recovery paths, - // i.e. if there is only 1 recovery path and it contains only 1 key. - let fixed = num_recovery_paths < 2 && p.keys.len() < 2; + // i.e. if there is only 1 recovery path and it contains only 1 key, + // and there is no safety net path. + let fixed = num_non_primary_paths < 2 && p.keys.len() < 2; if let Some(key) = recovery_key { defined_key( &key.name, color::ORANGE, "Recovery key", - if use_taproot && !key.is_compatible_taproot { + if use_taproot && !key.source.is_compatible_taproot() { Some("This device does not support Taproot") } else { None @@ -175,7 +176,10 @@ pub fn custom_template<'a>( false, ) .map(move |msg| { - Message::DefineDescriptor(message::DefineDescriptor::Path(i + 1, msg)) + Message::DefineDescriptor(message::DefineDescriptor::Path( + p_idx + 1, // add one to index to account for primary path. + msg, + )) }), ) }, @@ -189,12 +193,72 @@ pub fn custom_template<'a>( message::DefineDescriptor::AddRecoveryPath, )), ) - .push(Space::with_width(Length::Fill)) - .push( - button::primary(None, "Continue") - .width(Length::Fixed(200.0)) - .on_press_maybe(if valid { Some(Message::Next) } else { None }), - ), + .push_maybe( + safety_net_path.is_none().then_some(tooltip::Tooltip::new( + button::secondary(Some(icon::plus_icon()), "Add Safety Net") + .width(Length::Fixed(210.0)) + .on_press(Message::DefineDescriptor( + message::DefineDescriptor::AddSafetyNetPath, + )), + Container::new(text(SAFETY_NET_DESCRIPTION)) + .style(theme::card::simple) + .padding(10), + tooltip::Position::Bottom, + )), + ) + .spacing(10), + ) + .push_maybe(safety_net_path.map(|(sn_index, sn_path)| { + path( + color::WHITE, + Some("Safety Net:".to_string()), + sn_path.sequence, + sn_path.warning, + sn_path.threshold, + sn_path + .keys + .iter() + .enumerate() + .map(|(i, sn_key)| { + // Cannot delete safety net key if doing so would remove the safety net path + // and there are no other recovery paths. + let fixed = num_non_primary_paths < 2 && sn_path.keys.len() < 2; + if let Some(key) = sn_key { + defined_key( + &key.name, + color::WHITE, + "Safety Net key", + if use_taproot && !key.source.is_compatible_taproot() { + Some("This key source does not support Taproot") + } else { + None + }, + fixed, + ) + } else { + undefined_key( + color::WHITE, + "Safety Net key", + !sn_path.keys[0..i].iter().any(|k| k.is_none()), + fixed, + ) + } + .map(move |msg| message::DefinePath::Key(i, msg)) + }) + .collect(), + false, + ) + .map(move |msg| { + // Add 1 to index to account for primary path. + Message::DefineDescriptor(message::DefineDescriptor::Path(sn_index + 1, msg)) + }) + })) + .push( + Row::new().push(Space::with_width(Length::Fill)).push( + button::primary(None, "Continue") + .width(Length::Fixed(200.0)) + .on_press_maybe(if valid { Some(Message::Next) } else { None }), + ), ) .push(Space::with_height(100.0)) .spacing(20), diff --git a/liana-gui/src/installer/view/editor/template/inheritance.rs b/liana-gui/src/installer/view/editor/template/inheritance.rs index 6b218411..4d076bec 100644 --- a/liana-gui/src/installer/view/editor/template/inheritance.rs +++ b/liana-gui/src/installer/view/editor/template/inheritance.rs @@ -12,8 +12,8 @@ use liana_ui::{ use crate::installer::{ context, + descriptor::{Path, PathSequence}, message::{self, Message}, - step::descriptor::editor::key::Key, view::{ editor::{define_descriptor_advanced_settings, defined_key, path, undefined_key}, layout, @@ -67,11 +67,15 @@ After a period of inactivity (but not before that) your Inheritance Key will bec pub fn inheritance_template<'a>( progress: (usize, usize), use_taproot: bool, - primary_key: Option<&'a Key>, - recovery_key: Option<&'a Key>, - sequence: u16, + primary_path: &'a Path, + recovery_path: &'a Path, valid: bool, ) -> Element<'a, Message> { + let primary_key = if let Some(first) = primary_path.keys.first() { + first.as_ref() + } else { + None + }; layout( progress, None, @@ -106,15 +110,15 @@ pub fn inheritance_template<'a>( path( color::GREEN, None, - 0, - false, + PathSequence::Primary, + primary_path.warning, 1, vec![if let Some(key) = primary_key { defined_key( &key.name, color::GREEN, "Primary key", - if use_taproot && !key.is_compatible_taproot { + if use_taproot && !key.source.is_compatible_taproot() { Some("This device does not support Taproot") } else { None @@ -133,15 +137,15 @@ pub fn inheritance_template<'a>( path( color::WHITE, None, - sequence, - false, + recovery_path.sequence, + recovery_path.warning, 1, - vec![if let Some(key) = recovery_key { + vec![if let Some(Some(key)) = recovery_path.keys.first() { defined_key( &key.name, color::WHITE, "Inheritance key", - if use_taproot && !key.is_compatible_taproot { + if use_taproot && !key.source.is_compatible_taproot() { Some("This device does not support Taproot") } else { None diff --git a/liana-gui/src/installer/view/editor/template/multisig_security_wallet.rs b/liana-gui/src/installer/view/editor/template/multisig_security_wallet.rs index 199dd3b6..d8cf1c57 100644 --- a/liana-gui/src/installer/view/editor/template/multisig_security_wallet.rs +++ b/liana-gui/src/installer/view/editor/template/multisig_security_wallet.rs @@ -12,8 +12,8 @@ use liana_ui::{ use crate::installer::{ context, + descriptor::{Path, PathKind, PathSequence}, message::{self, Message}, - step::descriptor::editor::key::Key, view::{ editor::{ define_descriptor_advanced_settings, defined_key, path, undefined_key, @@ -77,10 +77,8 @@ pub fn multisig_security_template_description( pub fn multisig_security_template<'a>( progress: (usize, usize), use_taproot: bool, - primary_keys: Vec>, - recovery_keys: Vec>, - sequence: u16, - threshold: usize, + primary_path: &'a Path, + recovery_path: &'a Path, valid: bool, ) -> Element<'a, Message> { layout( @@ -117,10 +115,11 @@ pub fn multisig_security_template<'a>( path( color::GREEN, None, - 0, - false, - primary_keys.len(), - primary_keys + PathSequence::Primary, + primary_path.warning, + primary_path.keys.len(), + primary_path + .keys .iter() .enumerate() .map(|(i, primary_key)| { @@ -129,7 +128,7 @@ pub fn multisig_security_template<'a>( &key.name, color::GREEN, format!("Primary key #{}", i + 1), - if use_taproot && !key.is_compatible_taproot { + if use_taproot && !key.source.is_compatible_taproot() { Some("This device does not support Taproot") } else { None @@ -140,7 +139,7 @@ pub fn multisig_security_template<'a>( undefined_key( color::GREEN, format!("Primary key #{}", i + 1), - !primary_keys[0..i].iter().any(|k| k.is_none()), + !primary_path.keys[0..i].iter().any(|k| k.is_none()), true, ) } @@ -151,10 +150,10 @@ pub fn multisig_security_template<'a>( ) .map(move |msg| { if let message::DefinePath::Key(i, message::DefineKey::Edit) = msg { - Message::DefineDescriptor(message::DefineDescriptor::KeysEdit(vec![ - (0, i), - (1, i), - ])) + Message::DefineDescriptor(message::DefineDescriptor::KeysEdit( + PathKind::Primary, + vec![(0, i), (1, i)], + )) } else { Message::DefineDescriptor(message::DefineDescriptor::Path(0, msg)) } @@ -164,10 +163,11 @@ pub fn multisig_security_template<'a>( path( color::ORANGE, None, - sequence, - false, - threshold, - recovery_keys + recovery_path.sequence, + recovery_path.warning, + recovery_path.threshold, + recovery_path + .keys .iter() .enumerate() .map(|(j, recovery_key)| { @@ -177,7 +177,7 @@ pub fn multisig_security_template<'a>( &key.name, color::GREEN, format!("Primary key #{}", j + 1), - if use_taproot && !key.is_compatible_taproot { + if use_taproot && !key.source.is_compatible_taproot() { Some("This device does not support Taproot") } else { None @@ -188,7 +188,7 @@ pub fn multisig_security_template<'a>( &key.name, color::ORANGE, "Recovery key".to_string(), - if use_taproot && !key.is_compatible_taproot { + if use_taproot && !key.source.is_compatible_taproot() { Some("This device does not support Taproot") } else { None @@ -204,8 +204,8 @@ pub fn multisig_security_template<'a>( } else { "Recovery key".to_string() }, - !(primary_keys.iter().any(|k| k.is_none()) - || recovery_keys[0..j].iter().any(|k| k.is_none())), + !(primary_path.keys.iter().any(|k| k.is_none()) + || recovery_path.keys[0..j].iter().any(|k| k.is_none())), true, ) } @@ -216,12 +216,15 @@ pub fn multisig_security_template<'a>( ) .map(move |msg| { if let message::DefinePath::Key(i, message::DefineKey::Edit) = msg { - Message::DefineDescriptor(message::DefineDescriptor::KeysEdit(if i < 2 { - vec![(0, i), (1, i)] + let (path_kind, keys) = if i < 2 { + (PathKind::Primary, vec![(0, i), (1, i)]) } else { // recovery path is the path with three keys - vec![(1, i)] - })) + (PathKind::Recovery, vec![(1, i)]) + }; + Message::DefineDescriptor(message::DefineDescriptor::KeysEdit( + path_kind, keys, + )) } else { Message::DefineDescriptor(message::DefineDescriptor::Path(1, msg)) } diff --git a/liana-gui/src/installer/view/mod.rs b/liana-gui/src/installer/view/mod.rs index 6ffbd463..c85a542e 100644 --- a/liana-gui/src/installer/view/mod.rs +++ b/liana-gui/src/installer/view/mod.rs @@ -29,8 +29,10 @@ use liana_ui::{ }; use crate::{ + app::settings, hw::{is_compatible_with_tapminiscript, HardwareWallet, UnsupportedReason}, installer::{ + descriptor::{Key, PathSequence, PathWarning}, message::{self, DefineBitcoind, DefineNode, Message}, prompt, step::{DownloadState, InstallState}, @@ -686,7 +688,7 @@ pub fn backup_descriptor<'a>( progress: (usize, usize), email: Option<&'a str>, descriptor: &'a LianaDescriptor, - keys_aliases: &'a HashMap, + keys: &'a HashMap, done: bool, ) -> Element<'a, Message> { layout( @@ -749,7 +751,7 @@ pub fn backup_descriptor<'a>( .max_width(1500), ) .push( - card::simple(display_policy(descriptor.policy(), keys_aliases)) + card::simple(display_policy(descriptor.policy(), keys)) .width(Length::Fill) .max_width(1500), ) @@ -772,7 +774,7 @@ pub fn backup_descriptor<'a>( fn display_policy( policy: LianaPolicy, - keys_aliases: &HashMap, + keys: &HashMap, ) -> Element<'_, Message> { let (primary_threshold, primary_keys) = policy.primary_path().thresh_origins(); // The iteration over an HashMap keys can have a different order at each refresh @@ -800,10 +802,10 @@ fn display_policy( .iter() .enumerate() .fold(Row::new().spacing(5), |row, (i, k)| { - let content = if let Some(alias) = keys_aliases.get(k) { + let content = if let Some(key) = keys.get(k) { Container::new( iced_tooltip::Tooltip::new( - text(alias).bold(), + text(key.name.clone()).bold(), text(k.to_string()), iced_tooltip::Position::Bottom, ) @@ -847,10 +849,10 @@ fn display_policy( .push(recovery_keys.iter().enumerate().fold( Row::new().spacing(5), |row, (i, k)| { - let content = if let Some(alias) = keys_aliases.get(k) { + let content = if let Some(key) = keys.get(k) { Container::new( iced_tooltip::Tooltip::new( - text(alias).bold(), + text(key.name.clone()).bold(), text(k.to_string()), iced_tooltip::Position::Bottom, ) @@ -877,7 +879,18 @@ fn display_policy( )) .bold(), ) - .push(text(format!("(Recovery path #{})", i + 1))), + .push(text( + // If max timelock and all keys are from provider, then it's a safety net path. + if *sequence == u16::MAX + && recovery_keys + .iter() + .all(|fg| keys.get(fg).is_some_and(|k| k.provider_key.is_some())) + { + "(Safety Net path)".to_string() + } else { + format!("(Recovery path #{})", i + 1) + }, + )), ); } Column::new() @@ -1488,15 +1501,38 @@ pub fn defined_threshold<'a>( } pub fn defined_sequence<'a>( - sequence: u16, - duplicate_sequence: bool, + sequence: PathSequence, + warning: Option, ) -> Element<'a, message::DefinePath> { - let (n_years, n_months, n_days, n_hours, n_minutes) = duration_from_sequence(sequence); + let (n_years, n_months, n_days, n_hours, n_minutes) = duration_from_sequence(sequence.as_u16()); + let duration_row = Row::new() + .padding(5) + .spacing(5) + .align_y(Alignment::Center) + .push(text( + [ + (n_years, "y"), + (n_months, "m"), + (n_days, "d"), + (n_hours, "h"), + (n_minutes, "mn"), + ] + .iter() + .filter_map(|(n, unit)| { + if *n > 0 { + Some(format!("{}{}", n, unit)) + } else { + None + } + }) + .collect::>() + .join(" "), + )); Container::new( Column::new() .spacing(5) - .push(if sequence != 0 { - Row::new().align_y(Alignment::Center).push( + .push(match sequence { + PathSequence::Recovery(_) => Row::new().align_y(Alignment::Center).push( Container::new( Row::new() .align_y(Alignment::Center) @@ -1506,57 +1542,38 @@ pub fn defined_sequence<'a>( .style(theme::text::secondary), ) .push( - Button::new( - Row::new() - .padding(5) - .spacing(5) - .align_y(Alignment::Center) - .push(text( - [ - (n_years, "y"), - (n_months, "m"), - (n_days, "d"), - (n_hours, "h"), - (n_minutes, "mn"), - ] - .iter() - .filter_map(|(n, unit)| { - if *n > 0 { - Some(format!("{}{}", n, unit)) - } else { - None - } - }) - .collect::>() - .join(" "), - )) - .push(icon::pencil_icon()), - ) - .style(theme::button::secondary) - .on_press(message::DefinePath::EditSequence), + Button::new(duration_row.push(icon::pencil_icon())) + .style(theme::button::secondary) + .on_press(message::DefinePath::EditSequence), ), ) .width(Length::Fill) .padding(5) .align_y(alignment::Vertical::Center), - ) - } else { - Row::new() + ), + PathSequence::Primary => Row::new() .push( p1_regular("Able to move the funds at any time.") .style(theme::text::secondary), ) + .padding(5), + PathSequence::SafetyNet => Row::new().align_y(Alignment::Center).push( + Container::new( + Row::new() + .align_y(Alignment::Center) + .spacing(5) + .push( + text::p1_regular("Available after inactivity of ~") + .style(theme::text::secondary), + ) + .push(duration_row), + ) + .width(Length::Fill) .padding(5) + .align_y(alignment::Vertical::Center), + ), }) - .push_maybe(if duplicate_sequence { - Some( - text("No two recovery options may become available at the very same date.") - .small() - .style(theme::text::error), - ) - } else { - None - }) + .push_maybe(warning.map(|w| text(w.message()).small().style(theme::text::error))) .spacing(15), ) .padding(5) @@ -1685,6 +1702,33 @@ pub fn key_list_view<'a>( .into() } +pub fn provider_key_list_view(i: Option, key: &Key, chosen: bool) -> Element<'_, Message> { + // If `i.is_some()`, it means this key is in our list of (saved) keys and can be selected. + let key_kind = key + .source + .provider_key_kind() + .expect("has kind") + .to_string(); + let token = key.source.token().expect("has token"); + Button::new(if i.is_some() { + if chosen { + hw::selected_provider_key(key.fingerprint, key.name.clone(), key_kind, token) + } else { + hw::unselected_provider_key(key.fingerprint, key.name.clone(), key_kind, token) + } + } else { + hw::unsaved_provider_key(key.fingerprint, key_kind, token) + }) + .style(theme::button::secondary) + .width(Length::Fill) + .on_press_maybe(i.map(|i| { + Message::DefineDescriptor(message::DefineDescriptor::KeyModal( + message::ImportKeyModal::SelectKey(i), + )) + })) + .into() +} + pub fn backup_mnemonic<'a>( progress: (usize, usize), email: Option<&'a str>, diff --git a/liana-gui/src/lianalite/client/backend/api.rs b/liana-gui/src/lianalite/client/backend/api.rs index 571e1f2a..2c45c2ba 100644 --- a/liana-gui/src/lianalite/client/backend/api.rs +++ b/liana-gui/src/lianalite/client/backend/api.rs @@ -120,10 +120,26 @@ pub struct ListWallets { pub wallets: Vec, } +#[derive(Debug, Clone, Deserialize)] +pub struct Provider { + pub uuid: String, + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ProviderKey { + #[serde(deserialize_with = "deser_fromstr")] + pub fingerprint: bip32::Fingerprint, + pub uuid: String, + pub token: String, + pub provider: Provider, +} + #[derive(Debug, Clone, Deserialize)] pub struct WalletMetadata { pub ledger_hmacs: Vec, pub fingerprint_aliases: Vec, + pub provider_keys: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -335,11 +351,26 @@ pub mod payload { s.serialize_str(&field.to_string()) } + #[derive(Serialize)] + pub struct Provider { + pub uuid: String, + pub name: String, + } + + #[derive(Serialize)] + pub struct ProviderKey { + pub fingerprint: String, + pub uuid: String, + pub token: String, + pub provider: Provider, + } + #[derive(Serialize)] pub struct CreateWallet<'a> { pub name: &'a str, #[serde(serialize_with = "ser_to_string")] pub descriptor: &'a LianaDescriptor, + pub provider_keys: &'a Vec, } #[derive(Serialize)] diff --git a/liana-gui/src/lianalite/client/backend/mod.rs b/liana-gui/src/lianalite/client/backend/mod.rs index de40f317..7b8eca2a 100644 --- a/liana-gui/src/lianalite/client/backend/mod.rs +++ b/liana-gui/src/lianalite/client/backend/mod.rs @@ -148,11 +148,16 @@ impl BackendClient { &self, name: &str, descriptor: &LianaDescriptor, + provider_keys: &Vec, ) -> Result { let response = self .request(Method::POST, &format!("{}/v1/wallets", self.url)) .await - .json(&api::payload::CreateWallet { name, descriptor }) + .json(&api::payload::CreateWallet { + name, + descriptor, + provider_keys, + }) .send() .await?; if !response.status().is_success() { diff --git a/liana-gui/src/lib.rs b/liana-gui/src/lib.rs index 6f816464..f0160fa1 100644 --- a/liana-gui/src/lib.rs +++ b/liana-gui/src/lib.rs @@ -10,6 +10,7 @@ pub mod lianalite; pub mod loader; pub mod logger; pub mod node; +pub mod services; pub mod signer; pub mod utils; diff --git a/liana-gui/src/main.rs b/liana-gui/src/main.rs index d587b70f..72ef2814 100644 --- a/liana-gui/src/main.rs +++ b/liana-gui/src/main.rs @@ -297,7 +297,7 @@ 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) = *msg { + if let installer::Message::Exit(path, internal_bitcoind, remove_log) = *msg { let settings = app::settings::Settings::from_file(i.datadir.clone(), i.network) .expect("A settings file was created"); if settings @@ -326,7 +326,9 @@ impl GUI { self.log_level .unwrap_or_else(|| cfg.log_level().unwrap_or(LevelFilter::INFO)), ); - self.logger.remove_install_log_file(datadir_path.clone()); + if remove_log { + self.logger.remove_install_log_file(datadir_path.clone()); + } let (loader, command) = Loader::new( datadir_path, cfg, @@ -443,6 +445,12 @@ pub fn create_app_with_remote_backend( } }) .collect(); + let provider_keys: HashMap<_, _> = wallet + .metadata + .provider_keys + .into_iter() + .map(|pk| (pk.fingerprint, pk.into())) + .collect(); App::new( Cache { network, @@ -459,6 +467,7 @@ pub fn create_app_with_remote_backend( Wallet::new(wallet.descriptor) .with_name(wallet.name) .with_key_aliases(aliases) + .with_provider_keys(provider_keys) .with_hardware_wallets(hws) .load_hotsigners(&datadir, network) .expect("Datadir should be conform"), diff --git a/liana-gui/src/services/api.rs b/liana-gui/src/services/api.rs new file mode 100644 index 00000000..04e65c01 --- /dev/null +++ b/liana-gui/src/services/api.rs @@ -0,0 +1,81 @@ +use serde::{de, Deserialize}; + +use liana::miniscript::descriptor::DescriptorPublicKey; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)] +pub enum KeyKind { + SafetyNet, + Cosigner, +} + +impl std::fmt::Display for KeyKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + KeyKind::SafetyNet => write!(f, "Safety Net"), + KeyKind::Cosigner => write!(f, "Cosigner"), + } + } +} + +impl<'de> Deserialize<'de> for KeyKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "safetynet" => Ok(KeyKind::SafetyNet), + "cosigner" => Ok(KeyKind::Cosigner), + s => Err(de::Error::custom(format!( + "invalid value for KeyKind: '{}'", + s + ))), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyStatus { + NotFetched, + Fetched, + Redeemed, +} + +impl<'de> Deserialize<'de> for KeyStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "not-fetched" => Ok(KeyStatus::NotFetched), + "fetched" => Ok(KeyStatus::Fetched), + "redeemed" => Ok(KeyStatus::Redeemed), + s => Err(de::Error::custom(format!( + "invalid value for KeyStatus: '{}'", + s + ))), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Provider { + pub uuid: String, + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Key { + pub provider: Provider, + pub uuid: String, + pub kind: KeyKind, + pub status: KeyStatus, + pub xpub: DescriptorPublicKey, +} + +impl Key { + pub fn is_redeemed(&self) -> bool { + matches!(self.status, KeyStatus::Redeemed) + } +} diff --git a/liana-gui/src/services/mod.rs b/liana-gui/src/services/mod.rs new file mode 100644 index 00000000..340ff684 --- /dev/null +++ b/liana-gui/src/services/mod.rs @@ -0,0 +1,97 @@ +pub mod api; + +use reqwest::{self, IntoUrl, Method, RequestBuilder}; +use serde_json::json; + +const KEYS_API_URL: &str = "https://keys.wizardsardine.com"; + +#[derive(Debug, Clone)] +pub enum Error { + Http(Option, String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Http(kind, e) => write!(f, "Http error: [{:?}] {}", kind, e), + } + } +} + +impl From for Error { + fn from(error: reqwest::Error) -> Self { + Self::Http(None, error.to_string()) + } +} + +async fn check_response_status(response: reqwest::Response) -> Result { + if !response.status().is_success() { + return Err(Error::Http( + Some(response.status().into()), + response.text().await?, + )); + } + Ok(response) +} + +fn request( + http: &reqwest::Client, + method: reqwest::Method, + url: U, +) -> reqwest::RequestBuilder { + let req = http + .request(method, url) + .header("Content-Type", "application/json") + .header("API-Version", "0.1"); + tracing::debug!("Sending http request: {:?}", req); + req +} + +#[derive(Debug, Clone)] +pub struct Client(reqwest::Client); + +impl Default for Client { + fn default() -> Self { + Self::new() + } +} + +impl Client { + pub fn new() -> Self { + let http = reqwest::Client::new(); + Client(http) + } + + async fn request(&self, method: Method, url: U) -> RequestBuilder { + request(&self.0, method, url) + } + + pub async fn get_key_by_token(&self, token: String) -> Result { + let response = self + .request(Method::GET, &format!("{}/v1/keys", KEYS_API_URL)) + .await + .query(&[("token", token)]) + .send() + .await?; + let response = check_response_status(response).await?; + let key = response.json().await?; + Ok(key) + } + + pub async fn redeem_key(&self, uuid: String, token: String) -> Result { + let response = self + .request( + Method::POST, + &format!("{}/v1/keys/{}/redeem", KEYS_API_URL, uuid), + ) + .await + .json(&json!({ + "token": token, + })) + .send() + .await?; + let response = check_response_status(response).await?; + let key = response.json().await?; + Ok(key) + } +} diff --git a/liana-ui/src/component/form.rs b/liana-ui/src/component/form.rs index 5b618982..ad062161 100644 --- a/liana-ui/src/component/form.rs +++ b/liana-ui/src/component/form.rs @@ -93,6 +93,12 @@ where self } + /// Sets the [`Form`] with a warning message + pub fn maybe_warning(mut self, warning: Option<&'a str>) -> Self { + self.warning = warning; + self + } + /// Sets the padding of the [`Form`]. pub fn padding(mut self, units: u16) -> Self { self.input = self.input.padding(units); diff --git a/liana-ui/src/component/hw.rs b/liana-ui/src/component/hw.rs index 087c82b2..31f560be 100644 --- a/liana-ui/src/component/hw.rs +++ b/liana-ui/src/component/hw.rs @@ -496,3 +496,82 @@ pub fn hot_signer<'a, T: 'a, F: Display>( ) .padding(10) } + +pub fn selected_provider_key<'a, T: 'a, F: Display>( + fingerprint: F, + alias: impl Into> + Display, + key_kind: impl Into> + Display, + token: impl Into> + Display, +) -> Container<'a, T> { + container( + row(vec![ + column(vec![ + Row::new() + .spacing(5) + .push(text::p1_bold(alias)) + .push(text::p1_regular(format!("#{}", fingerprint))) + .into(), + Row::new() + .spacing(5) + .push(text::caption(format!("{key_kind} ({token})"))) + .into(), + ]) + .width(Length::Fill) + .into(), + image::success_mark_icon().width(Length::Fixed(50.0)).into(), + ]) + .align_y(Alignment::Center), + ) + .padding(10) +} + +pub fn unselected_provider_key<'a, T: 'a, F: Display>( + fingerprint: F, + alias: impl Into> + Display, + key_kind: impl Into> + Display, + token: impl Into> + Display, +) -> Container<'a, T> { + container( + row(vec![column(vec![ + Row::new() + .spacing(5) + .push(text::p1_bold(alias)) + .push(text::p1_regular(format!("#{}", fingerprint))) + .into(), + Row::new() + .spacing(5) + .push(text::caption(format!("{key_kind} ({token})"))) + .into(), + ]) + .width(Length::Fill) + .into()]) + .align_y(Alignment::Center), + ) + .padding(10) +} + +pub fn unsaved_provider_key<'a, T: 'a, F: Display>( + fingerprint: F, + key_kind: impl Into> + Display, + token: impl Into> + Display, +) -> Container<'a, T> { + container( + row(vec![ + column(vec![ + Row::new() + .spacing(5) + .push(text::p1_regular(format!("#{}", fingerprint))) + .into(), + Row::new() + .spacing(5) + .push(text::caption(format!("{key_kind} ({token})"))) + .into(), + ]) + .width(Length::Fill) + .into(), + image::success_mark_icon().width(Length::Fixed(50.0)).into(), // it must be selected if unsaved + ]) + .align_y(Alignment::Center), + ) + .padding(10) +}