Fetch and redeem provider keys using tokens

This commit is contained in:
Michael Mallan 2025-02-12 15:31:34 +00:00
parent 759411cfce
commit 52e552313f
No known key found for this signature in database
GPG Key ID: 5177CDCEDB0EABEB
25 changed files with 1468 additions and 467 deletions

View File

@ -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<Fingerprint, ProviderKey> {
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<backend::api::Provider> for Provider {
fn from(provider: backend::api::Provider) -> Self {
Self {
uuid: provider.uuid,
name: provider.name,
}
}
}
impl From<services::api::Provider> 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<backend::api::ProviderKey> 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<ProviderKey>,
}
#[derive(PartialEq, Eq, Debug, Clone)]

View File

@ -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();
}

View File

@ -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<String>)],
provider_keys: &'a HashMap<Fingerprint, ProviderKey>,
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<String>)],
) -> Element<'_, Message> {
keys_aliases: &'a [(Fingerprint, form::Value<String>)],
provider_keys: &'a HashMap<Fingerprint, ProviderKey>,
) -> 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()

View File

@ -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<Fingerprint, settings::KeySetting>`.
pub keys_aliases: HashMap<Fingerprint, String>,
pub provider_keys: HashMap<Fingerprint, settings::ProviderKey>,
pub hardware_wallets: Vec<HardwareWalletConfig>,
pub signer: Option<Arc<Signer>>,
}
@ -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<Fingerprint, settings::ProviderKey>,
) -> Self {
self.provider_keys = provider_keys;
self
}
pub fn with_hardware_wallets(mut self, hardware_wallets: Vec<HardwareWalletConfig>) -> 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(),

View File

@ -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<BitcoinBackend>,
pub descriptor_template: DescriptorTemplate,
pub descriptor: Option<LianaDescriptor>,
pub keys: Vec<KeySetting>,
pub keys: HashMap<bitcoin::bip32::Fingerprint, KeySetting>,
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,

View File

@ -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<Version>),
/// 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<ProviderKey> {
if let KeySource::Token(_, provider_key) = self {
Some(provider_key.clone())
} else {
None
}
}
pub fn provider_key_kind(&self) -> Option<KeyKind> {
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<Option<Key>>,
pub threshold: usize,
pub sequence: PathSequence,
pub warning: Option<PathWarning>,
}
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."
}
}
}
}

View File

@ -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<Bitcoind>),
Exit(PathBuf, Option<Bitcoind>, /* 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),
}

View File

@ -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<DaemonError>),
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),

View File

@ -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<DeviceKind>,
pub is_hot_signer: bool,
pub device_version: Option<Version>,
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<String>,
form_xpub: form::Value<String>,
manually_imported_xpub: bool,
// TODO: Define new `form::Value` type with `Option<String>` instead of `bool` so that we can
// store `form_token_warning` directly in `form_token`.
form_token: form::Value<String>,
form_token_warning: Option<String>,
other_path_keys: HashSet<Fingerprint>,
duplicate_master_fg: bool,
path_kind: PathKind,
keys: Vec<Key>,
hot_signer: Arc<Mutex<Signer>>,
hot_signer_fingerprint: Fingerprint,
chosen_signer: Option<Key>,
chosen_key_source_kind: Option<KeySourceKind>,
}
impl EditXpubModal {
#[allow(clippy::too_many_arguments)]
pub fn new(
device_must_support_tapminiscript: bool,
path_kind: PathKind,
other_path_keys: HashSet<Fingerprint>,
key: Option<Key>,
keys_coordinate: Vec<(usize, usize)>,
@ -104,13 +100,9 @@ impl EditXpubModal {
hot_signer_fingerprint: Fingerprint,
keys: Vec<Key>,
) -> 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,
)
}

View File

@ -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<Option<Fingerprint>>,
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<Option<&'a Key>> {
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::<Fingerprint>::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<DescriptorPublicKey> = Vec::new();
let mut key_derivation_index = HashMap::<Fingerprint, usize>::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<DescriptorPublicKey> = 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<S: Step> {
step: Arc<Mutex<S>>,
}
@ -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

View File

@ -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<RegisterDescriptor> for Box<dyn Step> {
pub struct BackupDescriptor {
done: bool,
descriptor: Option<LianaDescriptor>,
key_aliases: HashMap<Fingerprint, String>,
keys: HashMap<Fingerprint, KeySetting>,
}
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,
)
}

View File

@ -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<Bitcoind>,
warning: Option<String>,
config_path: Option<PathBuf>,
key_redemptions: HashMap<ProviderKey, Option<Result<(), services::Error>>>,
}
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<Message> {
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<Message> {
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;

View File

@ -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<String>,
sequence: u16,
duplicate_sequence: bool,
sequence: PathSequence,
warning: Option<PathWarning>,
threshold: usize,
keys: Vec<Element<message::DefinePath>>,
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<String>,
form_token_warning: Option<&'a String>,
key_kind: services::api::KeyKind,
) -> Option<Element<'a, Message>> {
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<Element<'a, Message>>,
keys: Vec<Element<'a, Message>>,
provider_keys: Vec<Element<'a, Message>>,
error: Option<&Error>,
chosen_signer: Option<Fingerprint>,
chosen_key_source_kind: Option<&KeySourceKind>,
hot_signer_fingerprint: &Fingerprint,
signer_alias: Option<&'a String>,
form_name: &'a form::Value<String>,
form_xpub: &form::Value<String>,
manually_imported_xpub: bool,
form_token: &form::Value<String>,
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),

View File

@ -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<Option<&'a Key>>,
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<Item = Path<'a>>,
num_recovery_paths: usize,
primary_path: &'a Path,
recovery_paths: &mut dyn Iterator<Item = (usize, &'a Path)>,
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),

View File

@ -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

View File

@ -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<Option<&'a Key>>,
recovery_keys: Vec<Option<&'a Key>>,
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))
}

View File

@ -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<Fingerprint, String>,
keys: &'a HashMap<Fingerprint, settings::KeySetting>,
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<Fingerprint, String>,
keys: &HashMap<Fingerprint, settings::KeySetting>,
) -> 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<PathWarning>,
) -> 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::<Vec<String>>()
.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::<Vec<String>>()
.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<usize>, 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>,

View File

@ -120,10 +120,26 @@ pub struct ListWallets {
pub wallets: Vec<Wallet>,
}
#[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<LedgerHmac>,
pub fingerprint_aliases: Vec<FingerprintAlias>,
pub provider_keys: Vec<ProviderKey>,
}
#[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<ProviderKey>,
}
#[derive(Serialize)]

View File

@ -148,11 +148,16 @@ impl BackendClient {
&self,
name: &str,
descriptor: &LianaDescriptor,
provider_keys: &Vec<api::payload::ProviderKey>,
) -> Result<api::Wallet, DaemonError> {
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() {

View File

@ -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;

View File

@ -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"),

View File

@ -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<D>(deserializer: D) -> Result<Self, D::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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)
}
}

View File

@ -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<u16>, 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<reqwest::Error> for Error {
fn from(error: reqwest::Error) -> Self {
Self::Http(None, error.to_string())
}
}
async fn check_response_status(response: reqwest::Response) -> Result<reqwest::Response, Error> {
if !response.status().is_success() {
return Err(Error::Http(
Some(response.status().into()),
response.text().await?,
));
}
Ok(response)
}
fn request<U: reqwest::IntoUrl>(
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<U: IntoUrl>(&self, method: Method, url: U) -> RequestBuilder {
request(&self.0, method, url)
}
pub async fn get_key_by_token(&self, token: String) -> Result<api::Key, Error> {
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<api::Key, Error> {
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)
}
}

View File

@ -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);

View File

@ -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<Cow<'a, str>> + Display,
key_kind: impl Into<Cow<'a, str>> + Display,
token: impl Into<Cow<'a, str>> + 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<Cow<'a, str>> + Display,
key_kind: impl Into<Cow<'a, str>> + Display,
token: impl Into<Cow<'a, str>> + 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<Cow<'a, str>> + Display,
token: impl Into<Cow<'a, str>> + 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)
}