Merge #1583: Fetch and redeem provider keys using tokens

52e552313f1e7cadad08bf7402660efee0f34441 Fetch and redeem provider keys using tokens (Michael Mallan)

Pull request description:

  This PR implements #1578 and #1579.

  Please note the following:
  - I've left the cosigner option enabled for initial testing as part of this PR, but will disable it afterwards (#1584 ).
  - Using cosigner keys is currently possible in all templates, including the Simple Inheritance template, but given that there is only one key per path in this template, it would not be possible to continue with a cosigner key selected. I could disable the option to select cosigner keys in this template in a follow-up PR, but in any case there will be a warning so the user will be aware of the problem.
  - Safety net paths can only be added in the Custom template for now.
  - ~~This cannot yet be tested with Liana Connect.~~ Can be tested on Signet with Liana Connect.
  - ~~The URL for fetching & redeeming keys is still localhost and I will update it once the service is ready.~~
  - ~~The GUI layout needs to be refined as part of this PR.~~ No further GUI layout changes planned.

  Please also note that I've unhelpfully squashed everything into a single commit. I hope to be able to split these out into smaller commits, but wanted to check the overall approach first and there may also be some conflicts with other PRs.

ACKs for top commit:
  edouardparis:
    ACK 52e552313f1e7cadad08bf7402660efee0f34441

Tree-SHA512: 7a6ff05c04e2c29d775175576fae725a8988696e0eca600b98b012265e561fad6acb1c90a3c3925127cd021b3758358db84f0687d8f1b7153e45fd7f72326600
This commit is contained in:
edouardparis 2025-03-13 18:09:48 +01:00
commit 96570cd4cc
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
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)
}