Merge #1366: Add templates to installer descriptor editor

bd03cc9cfffb7c5c45dd0dd60d54db807d4e02a2 Add inheritance and custom templates (edouardparis)
5889e60dc2a3b8894195caca5c9e00f954197523 Add descriptor template description step (edouardparis)
fab3303147ad1933f9b434502b8cc5465b451dc5 installer step: ChooseDescriptorTemplate (edouardparis)
d0ec811bef457bfcb266e6d39c7aaec07e34c8e0 installer: refac setup as a list of paths (edouardparis)
5fabd987e8ade7fae38f47d34b986c3ea13718c8 move installer view module in a directory (edouardparis)
0b1932c9fac6afc63021f57ae47ee3b42125efae keep key hot signer origin (edouardparis)
e6f85227ca9020b8390ee5a3e9e07438714bb4d5 installer descriptor editor: add key module (edouardparis)
d73894dfa0143f8bdb7fcb884b9c8c6d2154382b installer: module editor (edouardparis)

Pull request description:

  WIP for #1147

ACKs for top commit:
  edouardparis:
    Self-ACK bd03cc9cfffb7c5c45dd0dd60d54db807d4e02a2

Tree-SHA512: ddf80c250cb2d4d146167c84dfbf870f3e97589bc8a634e1b276b81a68a77721d15224a71bca1271dcccc299f9f33c893bdafc6affe40d7aaba980e902f0ded7
This commit is contained in:
edouardparis 2024-10-29 15:11:47 +01:00
commit 931b625e31
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
21 changed files with 2950 additions and 2415 deletions

View File

@ -45,10 +45,18 @@ impl RemoteBackend {
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum DescriptorTemplate {
#[default]
SimpleInheritance,
Custom,
}
#[derive(Clone)]
pub struct Context {
pub bitcoin_config: BitcoinConfig,
pub bitcoin_backend: Option<BitcoinBackend>,
pub descriptor_template: DescriptorTemplate,
pub descriptor: Option<LianaDescriptor>,
pub keys: Vec<KeySetting>,
pub hws: Vec<(DeviceKind, bitcoin::bip32::Fingerprint, Option<[u8; 32]>)>,
@ -71,6 +79,7 @@ impl Context {
remote_backend: RemoteBackend,
) -> Self {
Self {
descriptor_template: DescriptorTemplate::default(),
bitcoin_config: BitcoinConfig {
network,
poll_interval_secs: Duration::from_secs(30),

View File

@ -8,13 +8,13 @@ use super::{context, Error};
use crate::{
download::Progress,
hw::HardwareWalletMessage,
installer::step::descriptor::editor::key::Key,
lianalite::client::{auth::AuthClient, backend::api},
node::{
bitcoind::{Bitcoind, ConfigField, RpcAuthType},
electrum, NodeType,
},
};
use async_hwi::{DeviceKind, Version};
#[derive(Debug, Clone)]
pub enum Message {
@ -32,6 +32,7 @@ pub enum Message {
UseHotSigner,
Installed(Result<PathBuf, Error>),
CreateTaprootDescriptor(bool),
SelectDescriptorTemplate(context::DescriptorTemplate),
SelectBackend(SelectBackend),
ImportRemoteWallet(ImportRemoteWallet),
SelectBitcoindType(SelectBitcoindTypeMsg),
@ -107,14 +108,15 @@ pub enum InternalBitcoindMsg {
Start,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
pub enum DefineDescriptor {
ChangeTemplate(context::DescriptorTemplate),
ImportDescriptor(String),
PrimaryPath(DefinePath),
RecoveryPath(usize, DefinePath),
Path(usize, DefinePath),
AddRecoveryPath,
KeyModal(ImportKeyModal),
SequenceModal(SequenceModal),
ThresholdSequenceModal(ThresholdSequenceModal),
}
#[allow(clippy::large_enum_variant)]
@ -125,6 +127,7 @@ pub enum DefinePath {
ThresholdEdited(usize),
SequenceEdited(u16),
EditSequence,
EditThreshold,
}
#[allow(clippy::large_enum_variant)]
@ -133,26 +136,22 @@ pub enum DefineKey {
Delete,
Edit,
Clipboard(String),
Edited(
String,
DescriptorPublicKey,
Option<DeviceKind>,
Option<Version>,
),
Edited(Key),
}
#[derive(Debug, Clone)]
pub enum ImportKeyModal {
HWXpubImported(Result<DescriptorPublicKey, Error>),
FetchedKey(Result<Key, Error>),
XPubEdited(String),
EditName,
NameEdited(String),
ManuallyImportXpub,
ConfirmXpub,
SelectKey(usize),
}
#[derive(Debug, Clone)]
pub enum SequenceModal {
pub enum ThresholdSequenceModal {
ThresholdEdited(usize),
SequenceEdited(String),
ConfirmSequence,
Confirm,
}

View File

@ -39,9 +39,10 @@ use crate::{
pub use message::Message;
use step::{
BackupDescriptor, BackupMnemonic, ChooseBackend, DefineDescriptor, DefineNode, Final,
ImportDescriptor, ImportRemoteWallet, InternalBitcoindStep, RecoverMnemonic,
RegisterDescriptor, RemoteBackendLogin, SelectBitcoindTypeStep, ShareXpubs, Step,
BackupDescriptor, BackupMnemonic, ChooseBackend, ChooseDescriptorTemplate, DefineDescriptor,
DefineNode, DescriptorTemplateDescription, Final, ImportDescriptor, ImportRemoteWallet,
InternalBitcoindStep, RecoverMnemonic, RegisterDescriptor, RemoteBackendLogin,
SelectBitcoindTypeStep, ShareXpubs, Step,
};
#[derive(Debug, Clone)]
@ -119,6 +120,8 @@ impl Installer {
hws: HardwareWallets::new(destination_path.clone(), network),
steps: match user_flow {
UserFlow::CreateWallet => vec![
ChooseDescriptorTemplate::default().into(),
DescriptorTemplateDescription::default().into(),
DefineDescriptor::new(network, signer.clone()).into(),
BackupMnemonic::new(signer.clone()).into(),
BackupDescriptor::default().into(),

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,443 @@
use std::collections::HashSet;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use iced::{Command, Subscription};
use liana::miniscript::bitcoin::bip32::Xpub;
use liana::miniscript::{
bitcoin::{
bip32::{DerivationPath, Fingerprint},
Network,
},
descriptor::{DerivPaths, DescriptorMultiXKey, DescriptorPublicKey, DescriptorXKey, Wildcard},
};
use liana_ui::{component::form, widget::Element};
use async_hwi::{DeviceKind, Version};
use crate::{
hw::{is_compatible_with_tapminiscript, HardwareWallet, HardwareWallets},
installer::{
message::{self, Message},
view, Error,
},
signer::Signer,
};
pub fn new_multixkey_from_xpub(
xpub: DescriptorXKey<Xpub>,
derivation_index: usize,
) -> DescriptorMultiXKey<Xpub> {
DescriptorMultiXKey {
origin: xpub.origin,
xkey: xpub.xkey,
derivation_paths: DerivPaths::new(vec![
DerivationPath::from_str(&format!("m/{}", 2 * derivation_index)).unwrap(),
DerivationPath::from_str(&format!("m/{}", 2 * derivation_index + 1)).unwrap(),
])
.unwrap(),
wildcard: Wildcard::Unhardened,
}
}
#[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) => {
if network == Network::Bitcoin {
key.xkey.network == Network::Bitcoin
} else {
key.xkey.network == Network::Testnet
}
}
DescriptorPublicKey::MultiXPub(key) => {
if network == Network::Bitcoin {
key.xkey.network == Network::Bitcoin
} else {
key.xkey.network == Network::Testnet
}
}
_ => true,
}
}
pub struct EditXpubModal {
device_must_support_tapminiscript: bool,
path_index: usize,
key_index: usize,
network: Network,
error: Option<Error>,
processing: bool,
form_name: form::Value<String>,
form_xpub: form::Value<String>,
manually_imported_xpub: bool,
other_path_keys: HashSet<Fingerprint>,
duplicate_master_fg: bool,
keys: Vec<Key>,
hot_signer: Arc<Mutex<Signer>>,
hot_signer_fingerprint: Fingerprint,
chosen_signer: Option<Key>,
}
impl EditXpubModal {
#[allow(clippy::too_many_arguments)]
pub fn new(
device_must_support_tapminiscript: bool,
other_path_keys: HashSet<Fingerprint>,
key: Option<Key>,
path_index: usize,
key_index: usize,
network: Network,
hot_signer: Arc<Mutex<Signer>>,
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,
other_path_keys,
form_name: form::Value {
valid: true,
value: key.as_ref().map(|k| k.name.clone()).unwrap_or_default(),
},
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()
},
},
manually_imported_xpub,
keys,
path_index,
key_index,
processing: false,
error: None,
network,
chosen_signer: key,
hot_signer_fingerprint,
hot_signer,
duplicate_master_fg: false,
}
}
pub fn load(&self) -> Command<Message> {
Command::none()
}
}
impl super::DescriptorEditModal for EditXpubModal {
fn processing(&self) -> bool {
self.processing
}
fn update(&mut self, hws: &mut HardwareWallets, message: Message) -> Command<Message> {
// Reset these fields.
// the fonction will setup them again if something is wrong
self.duplicate_master_fg = false;
self.error = None;
match message {
Message::Select(i) => {
if let Some(HardwareWallet::Supported {
device,
fingerprint,
kind,
version,
..
}) = hws.list.get(i)
{
self.processing = true;
self.manually_imported_xpub = false;
let device_version = version.clone();
let fingerprint = *fingerprint;
let device_kind = *kind;
let network = self.network;
return Command::perform(
get_extended_pubkey(device.clone(), fingerprint, self.network),
move |res| {
Message::DefineDescriptor(message::DefineDescriptor::KeyModal(
message::ImportKeyModal::FetchedKey(match res {
Err(e) => Err(e),
Ok(key) => {
if check_key_network(&key, network) {
Ok(Key {
is_hot_signer: false,
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(
"Fetched key does not have the correct network"
.to_string(),
))
}
}
}),
))
},
);
}
}
Message::Reload => {
return self.load();
}
Message::UseHotSigner => {
self.manually_imported_xpub = false;
let fingerprint = self.hot_signer.lock().unwrap().fingerprint();
let derivation_path = default_derivation_path(self.network);
let key_str = format!(
"[{}{}]{}",
fingerprint,
derivation_path.to_string().trim_start_matches('m'),
self.hot_signer
.lock()
.unwrap()
.get_extended_pubkey(&derivation_path)
);
self.chosen_signer = Some(Key {
is_hot_signer: true,
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
.iter()
.find_map(|k| {
if k.fingerprint == fingerprint {
Some(k.name.clone())
} else {
None
}
})
.unwrap_or_default();
self.form_name.valid = true;
}
Message::DefineDescriptor(message::DefineDescriptor::KeyModal(msg)) => match msg {
message::ImportKeyModal::FetchedKey(res) => {
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);
}
Err(e) => {
self.chosen_signer = None;
self.error = Some(e);
}
}
}
message::ImportKeyModal::ManuallyImportXpub => {
self.chosen_signer = None;
self.manually_imported_xpub = true;
self.form_xpub = 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)
&& name == k.name
});
self.form_name.value = name;
}
message::ImportKeyModal::XPubEdited(s) => {
if let Ok(DescriptorPublicKey::XPub(key)) = DescriptorPublicKey::from_str(&s) {
self.chosen_signer = None;
if !key.derivation_path.is_master() {
self.form_xpub.valid = false;
} else if let Some((fingerprint, _)) = key.origin {
self.form_xpub.valid = if self.network == Network::Bitcoin {
key.xkey.network == Network::Bitcoin
} else {
key.xkey.network == Network::Testnet
};
if self.form_xpub.valid {
self.chosen_signer = Some(Key {
is_hot_signer: false,
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;
}
} else {
self.form_xpub.valid = false;
}
} else {
self.form_xpub.valid = false;
}
self.form_xpub.value = s;
}
message::ImportKeyModal::ConfirmXpub => {
if let Some(mut key) = self.chosen_signer.clone() {
let key_index = self.key_index;
key.name.clone_from(&self.form_name.value);
if self.other_path_keys.contains(&key.fingerprint) {
self.duplicate_master_fg = true;
} else {
let path_index = self.path_index;
return Command::perform(
async move { (path_index, key_index, key) },
move |(path_index, key_index, key)| {
message::DefineDescriptor::Path(
path_index,
message::DefinePath::Key(
key_index,
message::DefineKey::Edited(key),
),
)
},
)
.map(Message::DefineDescriptor);
}
}
}
message::ImportKeyModal::SelectKey(i) => {
if let Some(key) = self.keys.get(i) {
self.chosen_signer = Some(key.clone());
self.form_name.value.clone_from(&key.name);
self.form_name.valid = true;
}
}
},
_ => {}
};
Command::none()
}
fn subscription(&self, hws: &HardwareWallets) -> Subscription<Message> {
hws.refresh().map(Message::HardwareWallets)
}
fn view<'a>(&'a self, hws: &'a HardwareWallets) -> Element<'a, Message> {
let chosen_signer = self.chosen_signer.as_ref().map(|s| s.fingerprint);
view::editor::edit_key_modal(
if self.path_index > 0 {
"Set your key"
} else {
"Set your primary key"
},
self.network,
hws.list
.iter()
.enumerate()
.filter_map(|(i, hw)| {
if self
.keys
.iter()
.any(|k| Some(k.fingerprint) == hw.fingerprint())
{
None
} else {
Some(view::hw_list_view(
i,
hw,
hw.fingerprint() == chosen_signer,
self.processing,
hw.fingerprint() == chosen_signer,
self.device_must_support_tapminiscript,
))
}
})
.collect(),
self.keys
.iter()
.enumerate()
.filter_map(|(i, key)| {
if key.fingerprint == self.hot_signer_fingerprint {
None
} else {
Some(view::key_list_view(
i,
&key.name,
&key.fingerprint,
key.device_kind.as_ref(),
key.device_version.as_ref(),
Some(key.fingerprint) == chosen_signer,
self.device_must_support_tapminiscript,
))
}
})
.collect(),
self.error.as_ref(),
self.chosen_signer.as_ref().map(|s| s.fingerprint),
&self.hot_signer_fingerprint,
self.keys.iter().find_map(|k| {
if k.fingerprint == self.hot_signer_fingerprint {
Some(&k.name)
} else {
None
}
}),
&self.form_name,
&self.form_xpub,
self.manually_imported_xpub,
self.duplicate_master_fg,
)
}
}
pub fn default_derivation_path(network: Network) -> DerivationPath {
DerivationPath::from_str({
if network == Network::Bitcoin {
"m/48'/0'/0'/2'"
} else {
"m/48'/1'/0'/2'"
}
})
.unwrap()
}
/// LIANA_STANDARD_PATH: m/48'/0'/0'/2';
/// LIANA_TESTNET_STANDARD_PATH: m/48'/1'/0'/2';
pub async fn get_extended_pubkey(
hw: std::sync::Arc<dyn async_hwi::HWI + Send + Sync>,
fingerprint: Fingerprint,
network: Network,
) -> Result<DescriptorPublicKey, Error> {
let derivation_path = default_derivation_path(network);
let xkey = hw
.get_extended_pubkey(&derivation_path)
.await
.map_err(Error::from)?;
Ok(DescriptorPublicKey::XPub(DescriptorXKey {
origin: Some((fingerprint, derivation_path)),
derivation_path: DerivationPath::master(),
wildcard: Wildcard::None,
xkey,
}))
}

View File

@ -0,0 +1,808 @@
pub mod key;
pub mod template;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::iter::FromIterator;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use iced::{Command, Subscription};
use liana::{
descriptors::{LianaDescriptor, LianaPolicy, PathInfo},
miniscript::{
bitcoin::{bip32::Fingerprint, Network},
descriptor::DescriptorPublicKey,
},
};
use liana_ui::{
component::{form, modal::Modal},
widget::Element,
};
use crate::installer::context::DescriptorTemplate;
use crate::{
app::settings::KeySetting,
hw::HardwareWallets,
installer::{
message::{self, Message},
step::{Context, Step},
view,
},
signer::Signer,
};
use key::{new_multixkey_from_xpub, EditXpubModal, Key};
pub trait DescriptorEditModal {
fn processing(&self) -> bool {
false
}
fn update(&mut self, _hws: &mut HardwareWallets, _message: Message) -> Command<Message> {
Command::none()
}
fn view<'a>(&'a self, _hws: &'a HardwareWallets) -> Element<'a, Message>;
fn subscription(&self, _hws: &HardwareWallets) -> Subscription<Message> {
Subscription::none()
}
}
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,
}
}
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,
modal: Option<Box<dyn DescriptorEditModal>>,
signer: Arc<Mutex<Signer>>,
signer_fingerprint: Fingerprint,
keys: HashMap<Fingerprint, Key>,
paths: Vec<Path>,
descriptor_template: DescriptorTemplate,
error: Option<String>,
}
impl DefineDescriptor {
pub fn new(network: Network, signer: Arc<Mutex<Signer>>) -> Self {
let signer_fingerprint = signer.lock().unwrap().fingerprint();
Self {
network,
use_taproot: false,
modal: None,
signer_fingerprint,
signer,
error: None,
keys: HashMap::new(),
descriptor_template: DescriptorTemplate::default(),
paths: Vec::new(),
}
}
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) {
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);
} else {
all_sequence.insert(path.sequence);
}
}
for path in &mut self.paths {
path.duplicate_sequence = duplicate_sequences.contains(&path.sequence);
}
}
fn valid(&self) -> bool {
!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
}
}))
}) && self.paths.len() >= 2
}
fn check_setup(&mut self) {
self.check_for_duplicate();
}
fn load_template(&mut self, template: DescriptorTemplate) {
if self.descriptor_template != template || self.paths.is_empty() {
match template {
DescriptorTemplate::SimpleInheritance => {
self.paths = vec![Path::new_primary_path(), Path::new_recovery_path()];
}
DescriptorTemplate::Custom => {
self.paths = vec![Path::new_primary_path(), Path::new_recovery_path()];
}
}
}
self.descriptor_template = template;
}
}
impl Step for DefineDescriptor {
fn load_context(&mut self, ctx: &Context) {
self.load_template(ctx.descriptor_template)
}
// form value is set as valid each time it is edited.
// Verification of the values is happening when the user click on Next button.
fn update(&mut self, hws: &mut HardwareWallets, message: Message) -> Command<Message> {
self.error = None;
match message {
Message::Close => {
self.modal = None;
}
Message::CreateTaprootDescriptor(use_taproot) => {
self.use_taproot = use_taproot;
self.check_setup();
}
Message::DefineDescriptor(message::DefineDescriptor::ChangeTemplate(template)) => {
self.descriptor_template = template;
}
Message::DefineDescriptor(message::DefineDescriptor::AddRecoveryPath) => {
self.paths.push(Path::new_recovery_path());
}
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 Command::perform(async move { key }, Message::Clibpboard);
}
message::DefineKey::Edited(key) => {
hws.set_alias(key.fingerprint, key.name.clone());
self.paths[i].keys[j] = Some(key.fingerprint);
self.keys.insert(key.fingerprint, key);
self.modal = None;
self.check_setup();
}
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(),
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)
{
self.paths.remove(i);
}
self.check_setup();
}
},
},
_ => {
if let Some(modal) = &mut self.modal {
return modal.update(hws, message);
}
}
};
Command::none()
}
fn subscription(&self, hws: &HardwareWallets) -> Subscription<Message> {
if let Some(modal) = &self.modal {
modal.subscription(hws)
} else {
Subscription::none()
}
}
fn apply(&mut self, ctx: &mut Context) -> bool {
if self.paths.len() < 2 {
return false;
}
ctx.bitcoin_config.network = self.network;
ctx.keys = Vec::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 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 {
master_fingerprint,
name: key.name.clone(),
});
if key.device_kind.is_some() {
hw_is_used = true;
}
}
let derivation_index = key_derivation_index.get(&fingerprint).unwrap_or(&0);
spending_keys.push(DescriptorPublicKey::MultiXPub(new_multixkey_from_xpub(
xpub.clone(),
*derivation_index,
)));
key_derivation_index.insert(fingerprint, derivation_index + 1);
}
}
let mut recovery_paths = BTreeMap::new();
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 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 {
master_fingerprint,
name: key.name.clone(),
});
if key.device_kind.is_some() {
hw_is_used = true;
}
}
let derivation_index = key_derivation_index.get(&fingerprint).unwrap_or(&0);
recovery_keys.push(DescriptorPublicKey::MultiXPub(new_multixkey_from_xpub(
xpub.clone(),
*derivation_index,
)));
key_derivation_index.insert(fingerprint, derivation_index + 1);
}
}
let recovery_keys = if recovery_keys.len() == 1 {
PathInfo::Single(recovery_keys[0].clone())
} else {
PathInfo::Multi(path.threshold, recovery_keys)
};
recovery_paths.insert(path.sequence, recovery_keys);
}
if spending_keys.is_empty() {
return false;
}
let spending_keys = if spending_keys.len() == 1 {
PathInfo::Single(spending_keys[0].clone())
} else {
PathInfo::Multi(self.paths[0].threshold, spending_keys)
};
let policy = match if self.use_taproot {
LianaPolicy::new(spending_keys, recovery_paths)
} else {
LianaPolicy::new_legacy(spending_keys, recovery_paths)
} {
Ok(policy) => policy,
Err(e) => {
self.error = Some(e.to_string());
return false;
}
};
ctx.descriptor = Some(LianaDescriptor::new(policy));
ctx.hw_is_used = hw_is_used;
true
}
fn view<'a>(
&'a self,
hws: &'a HardwareWallets,
progress: (usize, usize),
_email: Option<&'a str>,
) -> Element<'a, Message> {
let content = match self.descriptor_template {
DescriptorTemplate::SimpleInheritance => {
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.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,
},
&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.valid(),
),
};
if let Some(modal) = &self.modal {
Modal::new(content, modal.view(hws))
.on_blur(if modal.processing() {
None
} else {
Some(Message::Close)
})
.into()
} else {
content
}
}
}
impl From<DefineDescriptor> for Box<dyn Step> {
fn from(s: DefineDescriptor) -> Box<dyn Step> {
Box::new(s)
}
}
pub struct EditSequenceModal {
path_index: usize,
sequence: form::Value<String>,
}
impl EditSequenceModal {
pub fn new(path_index: usize, sequence: u16) -> Self {
Self {
path_index,
sequence: form::Value {
value: sequence.to_string(),
valid: true,
},
}
}
}
impl DescriptorEditModal for EditSequenceModal {
fn processing(&self) -> bool {
false
}
fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command<Message> {
if let Message::DefineDescriptor(message::DefineDescriptor::ThresholdSequenceModal(msg)) =
message
{
match msg {
message::ThresholdSequenceModal::SequenceEdited(seq) => {
if let Ok(s) = u16::from_str(&seq) {
self.sequence.valid = s != 0
} else {
self.sequence.valid = false;
}
self.sequence.value = seq;
}
message::ThresholdSequenceModal::Confirm => {
if self.sequence.valid {
if let Ok(sequence) = u16::from_str(&self.sequence.value) {
let path_index = self.path_index;
return Command::perform(
async move { (path_index, sequence) },
|(path_index, sequence)| {
message::DefineDescriptor::Path(
path_index,
message::DefinePath::SequenceEdited(sequence),
)
},
)
.map(Message::DefineDescriptor);
}
}
}
_ => {}
}
}
Command::none()
}
fn view(&self, _hws: &HardwareWallets) -> Element<Message> {
view::editor::edit_sequence_modal(&self.sequence)
}
}
pub struct EditThresholdModal {
threshold: (usize, usize),
path_index: usize,
}
impl EditThresholdModal {
pub fn new(path_index: usize, threshold: (usize, usize)) -> Self {
Self {
threshold,
path_index,
}
}
}
impl DescriptorEditModal for EditThresholdModal {
fn processing(&self) -> bool {
false
}
fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command<Message> {
if let Message::DefineDescriptor(message::DefineDescriptor::ThresholdSequenceModal(msg)) =
message
{
match msg {
message::ThresholdSequenceModal::ThresholdEdited(threshold) => {
if threshold <= self.threshold.1 {
self.threshold.0 = threshold;
}
}
message::ThresholdSequenceModal::Confirm => {
let path_index = self.path_index;
let threshold = self.threshold.0;
return Command::perform(
async move { (path_index, threshold) },
|(path_index, threshold)| {
message::DefineDescriptor::Path(
path_index,
message::DefinePath::ThresholdEdited(threshold),
)
},
)
.map(Message::DefineDescriptor);
}
_ => {}
}
}
Command::none()
}
fn view(&self, _hws: &HardwareWallets) -> Element<Message> {
view::editor::edit_threshold_modal(self.threshold)
}
}
#[cfg(test)]
mod tests {
use super::*;
use iced_runtime::command::Action;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
pub struct Sandbox<S: Step> {
step: Arc<Mutex<S>>,
}
impl<S: Step + 'static> Sandbox<S> {
pub fn new(step: S) -> Self {
Self {
step: Arc::new(Mutex::new(step)),
}
}
pub fn check<F: FnOnce(&mut S)>(&self, check: F) {
let mut step = self.step.lock().unwrap();
check(&mut step)
}
pub async fn update(&self, message: Message) {
let mut hws = HardwareWallets::new(PathBuf::from_str("/").unwrap(), Network::Bitcoin);
let cmd = self.step.lock().unwrap().update(&mut hws, message);
for action in cmd.actions() {
if let Action::Future(f) = action {
let msg = f.await;
let _cmd = self.step.lock().unwrap().update(&mut hws, msg);
}
}
}
pub async fn load(&self, ctx: &Context) {
self.step.lock().unwrap().load_context(ctx);
}
}
#[tokio::test]
async fn test_define_descriptor_use_hotkey() {
let mut ctx = Context::new(
Network::Signet,
PathBuf::from_str("/").unwrap(),
crate::installer::context::RemoteBackend::None,
);
let sandbox: Sandbox<DefineDescriptor> = Sandbox::new(DefineDescriptor::new(
Network::Signet,
Arc::new(Mutex::new(Signer::generate(Network::Bitcoin).unwrap())),
));
sandbox.load(&ctx).await;
// Edit primary key
sandbox
.update(Message::DefineDescriptor(message::DefineDescriptor::Path(
0,
message::DefinePath::Key(0, message::DefineKey::Edit),
)))
.await;
sandbox.check(|step| assert!(step.modal.is_some()));
sandbox.update(Message::UseHotSigner).await;
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(message::ImportKeyModal::NameEdited(
"hot signer key".to_string(),
)),
))
.await;
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(message::ImportKeyModal::ConfirmXpub),
))
.await;
sandbox.check(|step| assert!(step.modal.is_none()));
// Edit sequence
sandbox
.update(Message::DefineDescriptor(message::DefineDescriptor::Path(
1,
message::DefinePath::SequenceEdited(1000),
)))
.await;
// Edit recovery key
sandbox
.update(Message::DefineDescriptor(message::DefineDescriptor::Path(
1,
message::DefinePath::Key(0, message::DefineKey::Edit),
)))
.await;
sandbox.check(|step| assert!(step.modal.is_some()));
sandbox.update(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(
message::ImportKeyModal::XPubEdited("[f5acc2fd/48'/1'/0'/2']tpubDFAqEGNyad35aBCKUAXbQGDjdVhNueno5ZZVEn3sQbW5ci457gLR7HyTmHBg93oourBssgUxuWz1jX5uhc1qaqFo9VsybY1J5FuedLfm4dK".to_string()),
)
)).await;
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(message::ImportKeyModal::NameEdited(
"External recovery key".to_string(),
)),
))
.await;
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(message::ImportKeyModal::ConfirmXpub),
))
.await;
sandbox.check(|step| {
assert!(step.modal.is_none());
assert!((step).apply(&mut ctx));
assert!(ctx
.descriptor
.as_ref()
.unwrap()
.to_string()
.contains(&step.signer.lock().unwrap().fingerprint().to_string()));
});
}
#[tokio::test]
async fn test_define_descriptor_stores_if_hw_is_used() {
let mut ctx = Context::new(
Network::Testnet,
PathBuf::from_str("/").unwrap(),
crate::installer::context::RemoteBackend::None,
);
let sandbox: Sandbox<DefineDescriptor> = Sandbox::new(DefineDescriptor::new(
Network::Testnet,
Arc::new(Mutex::new(Signer::generate(Network::Testnet).unwrap())),
));
sandbox.load(&ctx).await;
let key = DescriptorPublicKey::from_str("[4df3f0e3/84'/0'/0']tpubDDRs9DnRUiJc4hq92PSJKhfzQBgHJUrDo7T2i48smsDfLsQcm3Vh7JhuGqJv8zozVkNFin8YPgpmn2NWNmpRaE3GW2pSxbmAzYf2juy7LeW").unwrap();
let specter_key = message::DefinePath::Key(
0,
message::DefineKey::Edited(Key {
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,
}),
);
// Use Specter device for primary key
sandbox
.update(Message::DefineDescriptor(message::DefineDescriptor::Path(
0,
specter_key.clone(),
)))
.await;
// Edit recovery key
sandbox
.update(Message::DefineDescriptor(message::DefineDescriptor::Path(
1,
message::DefinePath::Key(0, message::DefineKey::Edit),
)))
.await;
sandbox.check(|step| assert!(step.modal.is_some()));
sandbox.update(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(
message::ImportKeyModal::XPubEdited("[f5acc2fd/48'/1'/0'/2']tpubDFAqEGNyad35aBCKUAXbQGDjdVhNueno5ZZVEn3sQbW5ci457gLR7HyTmHBg93oourBssgUxuWz1jX5uhc1qaqFo9VsybY1J5FuedLfm4dK".to_string()),
)
)).await;
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(message::ImportKeyModal::NameEdited(
"External recovery key".to_string(),
)),
))
.await;
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(message::ImportKeyModal::ConfirmXpub),
))
.await;
sandbox.check(|step| {
assert!(step.modal.is_none());
assert!((step).apply(&mut ctx));
assert!(ctx.hw_is_used);
});
// Now edit primary key to use hot signer instead of Specter device
sandbox
.update(Message::DefineDescriptor(message::DefineDescriptor::Path(
0,
message::DefinePath::Key(0, message::DefineKey::Edit),
)))
.await;
sandbox.check(|step| assert!(step.modal.is_some()));
sandbox.update(Message::UseHotSigner).await;
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(message::ImportKeyModal::NameEdited(
"hot signer key".to_string(),
)),
))
.await;
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(message::ImportKeyModal::ConfirmXpub),
))
.await;
sandbox.check(|step| {
assert!(step.modal.is_none());
assert!((step).apply(&mut ctx));
assert!(!ctx.hw_is_used);
});
// Now edit the recovery key to use Specter device
sandbox
.update(Message::DefineDescriptor(message::DefineDescriptor::Path(
1,
specter_key.clone(),
)))
.await;
sandbox.check(|step| {
assert!((step).apply(&mut ctx));
assert!(ctx.hw_is_used);
});
}
}

View File

@ -0,0 +1,81 @@
use iced::Command;
use liana_ui::widget::Element;
use crate::{
hw::HardwareWallets,
installer::{
context::DescriptorTemplate,
message::Message,
step::{Context, Step},
view,
},
};
#[derive(Default)]
pub struct ChooseDescriptorTemplate {
template: DescriptorTemplate,
}
impl From<ChooseDescriptorTemplate> for Box<dyn Step> {
fn from(s: ChooseDescriptorTemplate) -> Box<dyn Step> {
Box::new(s)
}
}
impl Step for ChooseDescriptorTemplate {
fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command<Message> {
if let Message::SelectDescriptorTemplate(template) = message {
self.template = template;
Command::perform(async move {}, |_| Message::Next)
} else {
Command::none()
}
}
fn apply(&mut self, ctx: &mut Context) -> bool {
ctx.descriptor_template = self.template;
true
}
fn view<'a>(
&'a self,
_hws: &'a HardwareWallets,
progress: (usize, usize),
_email: Option<&'a str>,
) -> Element<Message> {
view::editor::template::choose_descriptor_template(progress)
}
}
#[derive(Default)]
pub struct DescriptorTemplateDescription {
template: DescriptorTemplate,
}
impl From<DescriptorTemplateDescription> for Box<dyn Step> {
fn from(s: DescriptorTemplateDescription) -> Box<dyn Step> {
Box::new(s)
}
}
impl Step for DescriptorTemplateDescription {
fn load_context(&mut self, ctx: &Context) {
self.template = ctx.descriptor_template;
}
fn view<'a>(
&'a self,
_hws: &'a HardwareWallets,
progress: (usize, usize),
_email: Option<&'a str>,
) -> Element<Message> {
match self.template {
DescriptorTemplate::SimpleInheritance => {
view::editor::template::inheritance::inheritance_template_description(progress)
}
DescriptorTemplate::Custom { .. } => {
view::editor::template::custom::custom_template_description(progress)
}
}
}
}

View File

@ -0,0 +1,336 @@
pub mod editor;
use std::{
collections::{HashMap, HashSet},
str::FromStr,
};
use iced::{Command, Subscription};
use liana::{
descriptors::LianaDescriptor,
miniscript::bitcoin::{bip32::Fingerprint, Network},
};
use liana_ui::{component::form, widget::Element};
use async_hwi::DeviceKind;
use crate::{
app::wallet::wallet_name,
hw::{HardwareWallet, HardwareWallets},
installer::{
message::{self, Message},
step::{Context, Step},
view, Error,
},
};
pub struct ImportDescriptor {
network: Network,
imported_descriptor: form::Value<String>,
wrong_network: bool,
error: Option<String>,
}
impl ImportDescriptor {
pub fn new(network: Network) -> Self {
Self {
network,
imported_descriptor: form::Value::default(),
wrong_network: false,
error: None,
}
}
fn check_descriptor(&mut self, network: Network) -> Option<LianaDescriptor> {
if !self.imported_descriptor.value.is_empty() {
if let Ok(desc) = LianaDescriptor::from_str(&self.imported_descriptor.value) {
if network == Network::Bitcoin {
self.imported_descriptor.valid = desc.all_xpubs_net_is(network);
} else {
self.imported_descriptor.valid = desc.all_xpubs_net_is(Network::Testnet);
}
if self.imported_descriptor.valid {
self.wrong_network = false;
Some(desc)
} else {
self.wrong_network = true;
None
}
} else {
self.imported_descriptor.valid = false;
self.wrong_network = false;
None
}
} else {
self.wrong_network = false;
self.imported_descriptor.valid = true;
None
}
}
}
impl Step for ImportDescriptor {
// ImportRemoteWallet is used instead
fn skip(&self, ctx: &Context) -> bool {
ctx.remote_backend.is_some()
}
// form value is set as valid each time it is edited.
// Verification of the values is happening when the user click on Next button.
fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command<Message> {
if let Message::DefineDescriptor(message::DefineDescriptor::ImportDescriptor(desc)) =
message
{
self.imported_descriptor.value = desc;
self.check_descriptor(self.network);
}
Command::none()
}
fn apply(&mut self, ctx: &mut Context) -> bool {
ctx.bitcoin_config.network = self.network;
// Set to true in order to force the registration process to be shown to user.
ctx.hw_is_used = true;
// descriptor forms for import or creation cannot be both empty or filled.
if let Some(desc) = self.check_descriptor(self.network) {
ctx.descriptor = Some(desc);
true
} else {
false
}
}
fn view<'a>(
&'a self,
_hws: &'a HardwareWallets,
progress: (usize, usize),
email: Option<&'a str>,
) -> Element<Message> {
view::import_descriptor(
progress,
email,
&self.imported_descriptor,
self.wrong_network,
self.error.as_ref(),
)
}
}
impl From<ImportDescriptor> for Box<dyn Step> {
fn from(s: ImportDescriptor) -> Box<dyn Step> {
Box::new(s)
}
}
pub struct RegisterDescriptor {
descriptor: Option<LianaDescriptor>,
processing: bool,
chosen_hw: Option<usize>,
hmacs: Vec<(Fingerprint, DeviceKind, Option<[u8; 32]>)>,
registered: HashSet<Fingerprint>,
error: Option<Error>,
done: bool,
/// Whether this step is part of the descriptor creation process. This is used to detect when
/// it's instead shown as part of the descriptor *import* process, where we can't detect
/// whether a signing device is used, to explicit this step is not required if the user isn't
/// using a signing device.
created_desc: bool,
}
impl RegisterDescriptor {
fn new(created_desc: bool) -> Self {
Self {
created_desc,
descriptor: Default::default(),
processing: Default::default(),
chosen_hw: Default::default(),
hmacs: Default::default(),
registered: Default::default(),
error: Default::default(),
done: Default::default(),
}
}
pub fn new_create_wallet() -> Self {
Self::new(true)
}
pub fn new_import_wallet() -> Self {
Self::new(false)
}
}
impl Step for RegisterDescriptor {
fn load_context(&mut self, ctx: &Context) {
// we reset device registered set if the descriptor have changed.
if self.descriptor != ctx.descriptor {
self.registered = Default::default();
self.done = false;
}
self.descriptor.clone_from(&ctx.descriptor);
let mut map = HashMap::new();
for key in ctx.keys.iter().filter(|k| !k.name.is_empty()) {
map.insert(key.master_fingerprint, key.name.clone());
}
}
fn update(&mut self, hws: &mut HardwareWallets, message: Message) -> Command<Message> {
match message {
Message::Select(i) => {
if let Some(HardwareWallet::Supported {
device,
fingerprint,
..
}) = hws.list.get(i)
{
if !self.registered.contains(fingerprint) {
let descriptor = self.descriptor.as_ref().unwrap();
let name = wallet_name(descriptor);
self.chosen_hw = Some(i);
self.processing = true;
self.error = None;
return Command::perform(
register_wallet(
device.clone(),
*fingerprint,
name,
descriptor.to_string(),
),
Message::WalletRegistered,
);
}
}
}
Message::WalletRegistered(res) => {
self.processing = false;
self.chosen_hw = None;
match res {
Ok((fingerprint, hmac)) => {
if let Some(hw_h) = hws
.list
.iter()
.find(|hw_h| hw_h.fingerprint() == Some(fingerprint))
{
self.registered.insert(fingerprint);
self.hmacs.push((fingerprint, *hw_h.kind(), hmac));
}
}
Err(e) => {
if !matches!(e, Error::HardwareWallet(async_hwi::Error::UserRefused)) {
self.error = Some(e)
}
}
}
}
Message::Reload => {
return self.load();
}
Message::UserActionDone(done) => {
self.done = done;
}
_ => {}
};
Command::none()
}
fn skip(&self, ctx: &Context) -> bool {
!ctx.hw_is_used
}
fn apply(&mut self, ctx: &mut Context) -> bool {
for (fingerprint, kind, token) in &self.hmacs {
ctx.hws.push((*kind, *fingerprint, *token));
}
true
}
fn subscription(&self, hws: &HardwareWallets) -> Subscription<Message> {
hws.refresh().map(Message::HardwareWallets)
}
fn load(&self) -> Command<Message> {
Command::none()
}
fn view<'a>(
&'a self,
hws: &'a HardwareWallets,
progress: (usize, usize),
email: Option<&'a str>,
) -> Element<'a, Message> {
let desc = self.descriptor.as_ref().unwrap();
view::register_descriptor(
progress,
email,
desc.to_string(),
&hws.list,
&self.registered,
self.error.as_ref(),
self.processing,
self.chosen_hw,
self.done,
self.created_desc,
)
}
}
async fn register_wallet(
hw: std::sync::Arc<dyn async_hwi::HWI + Send + Sync>,
fingerprint: Fingerprint,
name: String,
descriptor: String,
) -> Result<(Fingerprint, Option<[u8; 32]>), Error> {
let hmac = hw
.register_wallet(&name, &descriptor)
.await
.map_err(Error::from)?;
Ok((fingerprint, hmac))
}
impl From<RegisterDescriptor> for Box<dyn Step> {
fn from(s: RegisterDescriptor) -> Box<dyn Step> {
Box::new(s)
}
}
#[derive(Default)]
pub struct BackupDescriptor {
done: bool,
descriptor: Option<LianaDescriptor>,
key_aliases: HashMap<Fingerprint, String>,
}
impl Step for BackupDescriptor {
fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command<Message> {
if let Message::UserActionDone(done) = message {
self.done = done;
}
Command::none()
}
fn load_context(&mut self, ctx: &Context) {
if self.descriptor != ctx.descriptor {
self.descriptor.clone_from(&ctx.descriptor);
self.done = false;
}
self.key_aliases = ctx
.keys
.iter()
.cloned()
.map(|k| (k.master_fingerprint, k.name))
.collect()
}
fn view<'a>(
&'a self,
_hws: &'a HardwareWallets,
progress: (usize, usize),
email: Option<&'a str>,
) -> Element<Message> {
view::backup_descriptor(
progress,
email,
self.descriptor.as_ref().expect("Must be a descriptor"),
&self.key_aliases,
self.done,
)
}
}
impl From<BackupDescriptor> for Box<dyn Step> {
fn from(s: BackupDescriptor) -> Box<dyn Step> {
Box::new(s)
}
}

View File

@ -1,5 +1,6 @@
pub mod descriptor;
mod backend;
mod descriptor;
mod mnemonic;
mod node;
mod share_xpubs;
@ -9,7 +10,11 @@ pub use node::{
DefineNode,
};
pub use descriptor::{BackupDescriptor, DefineDescriptor, ImportDescriptor, RegisterDescriptor};
pub use descriptor::{
editor::template::{ChooseDescriptorTemplate, DescriptorTemplateDescription},
editor::DefineDescriptor,
BackupDescriptor, ImportDescriptor, RegisterDescriptor,
};
pub use backend::{ChooseBackend, ImportRemoteWallet, RemoteBackendLogin};
pub use mnemonic::{BackupMnemonic, RecoverMnemonic};

View File

@ -13,7 +13,7 @@ use crate::{
installer::{
message::Message,
step::{
descriptor::{default_derivation_path, get_extended_pubkey},
descriptor::editor::key::{default_derivation_path, get_extended_pubkey},
Context, Step,
},
view, Error,

View File

@ -0,0 +1,586 @@
pub mod template;
use iced::widget::{container, pick_list, slider, Button, Space};
use iced::{Alignment, Length};
use liana::miniscript::bitcoin::Network;
use liana_ui::component::text::{self, h3, p1_bold, p2_regular, H3_SIZE};
use liana_ui::image;
use std::str::FromStr;
use liana::miniscript::bitcoin::{self, bip32::Fingerprint};
use liana_ui::{
color,
component::{
button, card, form, hw, separation,
text::{p1_regular, text, Text},
tooltip,
},
icon, theme,
widget::*,
};
use crate::installer::{
message::{self, Message},
prompt,
view::defined_sequence,
Error,
};
use super::defined_threshold;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DescriptorKind {
P2WSH,
Taproot,
}
const DESCRIPTOR_KINDS: [DescriptorKind; 2] = [DescriptorKind::P2WSH, DescriptorKind::Taproot];
impl std::fmt::Display for DescriptorKind {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::P2WSH => write!(f, "P2WSH"),
Self::Taproot => write!(f, "Taproot"),
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn define_descriptor_advanced_settings<'a>(use_taproot: bool) -> Element<'a, Message> {
let col_wallet = Column::new()
.spacing(10)
.push(text("Descriptor type").bold())
.push(container(
pick_list(
&DESCRIPTOR_KINDS[..],
Some(if use_taproot {
DescriptorKind::Taproot
} else {
DescriptorKind::P2WSH
}),
|kind| Message::CreateTaprootDescriptor(kind == DescriptorKind::Taproot),
)
.style(theme::PickList::Secondary)
.padding(10),
));
container(
Column::new()
.spacing(20)
.push(Space::with_height(0))
.push(separation().width(500))
.push(Row::new().push(col_wallet))
.push_maybe(if use_taproot {
Some(
p1_regular("Taproot is only supported by Liana version 5.0 and above")
.style(color::GREY_2),
)
} else {
None
}),
)
.into()
}
pub fn path(
color: iced::Color,
title: Option<String>,
sequence: u16,
duplicate_sequence: bool,
threshold: usize,
keys: Vec<Element<message::DefinePath>>,
fixed: bool,
) -> Element<message::DefinePath> {
let keys_len = keys.len();
Container::new(
Column::new()
.spacing(10)
.push_maybe(title.map(p1_bold))
.push(defined_sequence(sequence, duplicate_sequence))
.push(
Column::new()
.spacing(5)
.align_items(Alignment::Center)
.push(Column::with_children(keys).spacing(5)),
)
.push_maybe(if fixed {
if keys_len == 1 {
None
} else {
Some(Row::new().push(defined_threshold(color, fixed, (threshold, keys_len))))
}
} else {
Some(
Row::new()
.spacing(10)
.push(defined_threshold(color, fixed, (threshold, keys_len)))
.push(
button::secondary(Some(icon::plus_icon()), "Add key")
.on_press(message::DefinePath::AddKey),
),
)
}),
)
.padding(10)
.style(theme::Container::Card(theme::Card::Border))
.into()
}
pub fn defined_key<'a>(
alias: &'a str,
color: iced::Color,
title: &'static str,
warning: Option<&'static str>,
fixed: bool,
) -> Element<'a, message::DefineKey> {
card::simple(
Row::new()
.spacing(10)
.width(Length::Fill)
.align_items(Alignment::Center)
.push(icon::round_key_icon().size(H3_SIZE).style(color))
.push(
Column::new()
.width(Length::Fill)
.spacing(5)
.push(
Row::new()
.spacing(10)
.push(p1_regular(title).style(color::GREY_2))
.push(p1_bold(alias)),
)
.push_maybe(warning.map(|w| p2_regular(w).style(color::RED))),
)
.push_maybe(if warning.is_none() {
Some(icon::check_icon().style(color::GREEN))
} else {
None
})
.push(
button::secondary(Some(icon::pencil_icon()), "Edit")
.on_press(message::DefineKey::Edit),
)
.push_maybe(if fixed {
None
} else {
Some(
Button::new(icon::trash_icon())
.style(theme::Button::Secondary)
.padding(5)
.on_press(message::DefineKey::Delete),
)
}),
)
.into()
}
pub fn undefined_key<'a>(
color: iced::Color,
title: &'static str,
active: bool,
fixed: bool,
) -> Element<'a, message::DefineKey> {
card::simple(
Row::new()
.spacing(10)
.width(Length::Fill)
.align_items(Alignment::Center)
.push(icon::round_key_icon().size(H3_SIZE).style(color))
.push(
Column::new()
.width(Length::Fill)
.spacing(5)
.push(p1_bold(title)),
)
.push_maybe(if active {
Some(
button::primary(Some(icon::pencil_icon()), "Set")
.on_press(message::DefineKey::Edit),
)
} else {
None
})
.push_maybe(if fixed {
None
} else {
Some(
Button::new(icon::trash_icon())
.style(theme::Button::Secondary)
.padding(5)
.on_press(message::DefineKey::Delete),
)
}),
)
.into()
}
#[allow(clippy::too_many_arguments)]
pub fn edit_key_modal<'a>(
title: &'a str,
network: bitcoin::Network,
hws: Vec<Element<'a, Message>>,
keys: Vec<Element<'a, Message>>,
error: Option<&Error>,
chosen_signer: Option<Fingerprint>,
hot_signer_fingerprint: &Fingerprint,
signer_alias: Option<&'a String>,
form_name: &'a form::Value<String>,
form_xpub: &form::Value<String>,
manually_imported_xpub: bool,
duplicate_master_fg: bool,
) -> Element<'a, Message> {
Column::new()
.padding(25)
.push_maybe(error.map(|e| card::error("Failed to import xpub", e.to_string())))
.push(card::modal(
Column::new()
.spacing(25)
.push(Row::new()
.push(Space::with_width(Length::Fill))
.push(button::transparent(Some(icon::cross_icon()), "").on_press(Message::Close)))
.push(
Column::new()
.push(h3(title))
.push(p1_regular("Select the signing device for 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 {
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::Border),
)
.push(if manually_imported_xpub {
card::simple(Column::new()
.spacing(10)
.push(
Row::new()
.align_items(Alignment::Center)
.push(p1_regular("Enter an extended public key:").width(Length::Fill))
.push(image::success_mark_icon().width(Length::Fixed(50.0)))
)
.push(
Row::new()
.push(
form::Form::new_trimmed(
&example_xpub(network),
form_xpub, |msg| {
Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(
message::ImportKeyModal::XPubEdited(msg),),)
})
.warning(if network == bitcoin::Network::Bitcoin {
"Please enter correct xpub with origin and without appended derivation path"
} else {
"Please enter correct tpub with origin and without appended derivation path"
})
.size(text::P1_SIZE)
.padding(10),
)
.spacing(10)
))
} else {
Container::new(
Button::new(
Row::new()
.align_items(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),
)
}
)
.width(Length::Fill),
)
.push_maybe(
if chosen_signer.is_some() {
Some(card::simple(Column::new()
.spacing(10)
.push(
Row::new()
.spacing(5)
.push(text("Key name:").bold())
.push(tooltip(prompt::DEFINE_DESCRIPTOR_FINGERPRINT_TOOLTIP)),
)
.push(p1_regular("Give this key a friendly name. It helps you identify it later").style(color::GREY_2))
.push(
form::Form::new("Name", form_name, |msg| {
Message::DefineDescriptor(message::DefineDescriptor::KeyModal(
message::ImportKeyModal::NameEdited(msg),
))
})
.warning("Two different keys cannot have the same name")
.padding(10)
.size(text::P1_SIZE)
)))
} else {
None
}
)
.push_maybe(
if duplicate_master_fg {
Some(text("A single signing device may not be used more than once per path. (It can still be used in other paths.)").style(color::RED))
} else {
None
}
)
.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})
.width(Length::Fixed(200.0))
)
.align_items(Alignment::Center),
))
.width(Length::Fixed(800.0))
.into()
}
fn example_xpub(network: Network) -> String {
format!("[aabbccdd/42'/0']{}pub6DAkq8LWw91WGgUGnkR5Sbzjev5JCsXaTVZQ9MwsPV4BkNFKygtJ8GHodfDVx1udR723nT7JASqGPpKvz7zQ25pUTW6zVEBdiWoaC4aUqik",
if network == bitcoin::Network::Bitcoin { "x" } else { "t" }
)
}
/// returns y,m,d,h,m
pub fn duration_from_sequence(sequence: u16) -> (u32, u32, u32, u32, u32) {
let mut n_minutes = sequence as u32 * 10;
let n_years = n_minutes / 525960;
n_minutes -= n_years * 525960;
let n_months = n_minutes / 43830;
n_minutes -= n_months * 43830;
let n_days = n_minutes / 1440;
n_minutes -= n_days * 1440;
let n_hours = n_minutes / 60;
n_minutes -= n_hours * 60;
(n_years, n_months, n_days, n_hours, n_minutes)
}
pub fn edit_sequence_modal<'a>(sequence: &form::Value<String>) -> Element<'a, Message> {
let mut col = Column::new()
.width(Length::Fill)
.spacing(20)
.align_items(Alignment::Center)
.push(text("Keys can move the funds after inactivity of:"))
.push(
Row::new()
.push(
Container::new(
form::Form::new_trimmed("ex: 1000", sequence, |v| {
Message::DefineDescriptor(
message::DefineDescriptor::ThresholdSequenceModal(
message::ThresholdSequenceModal::SequenceEdited(v),
),
)
})
.warning("Sequence must be superior to 0 and inferior to 65535"),
)
.width(Length::Fixed(200.0)),
)
.spacing(10)
.push(text("blocks").bold()),
);
if sequence.valid {
if let Ok(sequence) = u16::from_str(&sequence.value) {
let (n_years, n_months, n_days, n_hours, n_minutes) = duration_from_sequence(sequence);
col = col
.push(
[
(n_years, "year"),
(n_months, "month"),
(n_days, "day"),
(n_hours, "hour"),
(n_minutes, "minute"),
]
.iter()
.fold(Row::new().spacing(5), |row, (n, unit)| {
row.push_maybe(if *n > 0 {
Some(
text(format!("{} {}{}", n, unit, if *n > 1 { "s" } else { "" }))
.bold(),
)
} else {
None
})
}),
)
.push(
Container::new(
slider(1..=u16::MAX, sequence, |v| {
Message::DefineDescriptor(
message::DefineDescriptor::ThresholdSequenceModal(
message::ThresholdSequenceModal::SequenceEdited(v.to_string()),
),
)
})
.step(144_u16), // 144 blocks per day
)
.width(Length::Fixed(500.0)),
);
}
}
card::modal(col.push(if sequence.valid {
button::primary(None, "Apply")
.on_press(Message::DefineDescriptor(
message::DefineDescriptor::ThresholdSequenceModal(
message::ThresholdSequenceModal::Confirm,
),
))
.width(Length::Fixed(200.0))
} else {
button::primary(None, "Apply").width(Length::Fixed(200.0))
}))
.width(Length::Fixed(800.0))
.into()
}
pub fn edit_threshold_modal<'a>(threshold: (usize, usize)) -> Element<'a, Message> {
card::modal(
Column::new()
.width(Length::Fill)
.spacing(20)
.align_items(Alignment::Center)
.push(threshsold_input::threshsold_input(
threshold.0,
threshold.1,
|v| {
Message::DefineDescriptor(message::DefineDescriptor::ThresholdSequenceModal(
message::ThresholdSequenceModal::ThresholdEdited(v),
))
},
))
.push(
button::primary(None, "Apply")
.on_press(Message::DefineDescriptor(
message::DefineDescriptor::ThresholdSequenceModal(
message::ThresholdSequenceModal::Confirm,
),
))
.width(Length::Fixed(200.0)),
),
)
.width(Length::Fixed(800.0))
.into()
}
mod threshsold_input {
use iced::alignment::{self, Alignment};
use iced::widget::{component, Component};
use iced::Length;
use liana_ui::{component::text::*, icon, theme, widget::*};
pub struct ThresholdInput<Message> {
value: usize,
max: usize,
on_change: Box<dyn Fn(usize) -> Message>,
}
pub fn threshsold_input<Message>(
value: usize,
max: usize,
on_change: impl Fn(usize) -> Message + 'static,
) -> ThresholdInput<Message> {
ThresholdInput::new(value, max, on_change)
}
#[derive(Debug, Clone)]
pub enum Event {
IncrementPressed,
DecrementPressed,
}
impl<Message> ThresholdInput<Message> {
pub fn new(
value: usize,
max: usize,
on_change: impl Fn(usize) -> Message + 'static,
) -> Self {
Self {
value,
max,
on_change: Box::new(on_change),
}
}
}
impl<Message> Component<Message, theme::Theme> for ThresholdInput<Message> {
type State = ();
type Event = Event;
fn update(&mut self, _state: &mut Self::State, event: Event) -> Option<Message> {
match event {
Event::IncrementPressed => {
if self.value < self.max {
Some((self.on_change)(self.value.saturating_add(1)))
} else {
None
}
}
Event::DecrementPressed => {
if self.value > 1 {
Some((self.on_change)(self.value.saturating_sub(1)))
} else {
None
}
}
}
}
fn view(&self, _state: &Self::State) -> Element<Self::Event> {
let button = |label, on_press| {
Button::new(label)
.style(theme::Button::Transparent)
.width(Length::Fixed(50.0))
.on_press(on_press)
};
Column::new()
.width(Length::Fixed(150.0))
.push(button(icon::up_icon().size(30), Event::IncrementPressed))
.push(text("Threshold:").small().bold())
.push(
Container::new(text(format!("{}/{}", self.value, self.max)).size(30))
.align_y(alignment::Vertical::Center),
)
.push(button(icon::down_icon().size(30), Event::DecrementPressed))
.align_items(Alignment::Center)
.into()
}
}
impl<'a, Message> From<ThresholdInput<Message>> for Element<'a, Message>
where
Message: 'a,
{
fn from(numeric_input: ThresholdInput<Message>) -> Self {
component(numeric_input)
}
}
}

View File

@ -0,0 +1,206 @@
use iced::{alignment, widget::Space, Alignment, Length};
use liana_ui::{
color,
component::{
button, collapse,
text::{h3, p1_regular, text, Text},
},
icon, image, theme,
widget::*,
};
use crate::installer::{
message::{self, Message},
prompt,
step::descriptor::editor::key::Key,
view::{
editor::{define_descriptor_advanced_settings, defined_key, path, undefined_key},
layout,
},
};
pub fn custom_template_description(progress: (usize, usize)) -> Element<'static, Message> {
layout(
progress,
None,
"Introduction",
Column::new()
.align_items(Alignment::Start)
.push(h3("Custom wallet"))
.max_width(800.0)
.push(Container::new(
p1_regular("Through this setup you can choose how many keys you want to use. For security reasons, we suggest you use Hardware Wallets to store them.")
.style(color::GREY_2)
.horizontal_alignment(alignment::Horizontal::Left)
).align_x(alignment::Horizontal::Left).width(Length::Fill))
.push(Container::new(
p1_regular("For this Custom wallet you will need to define your Primary and Recovery Sets of Keys.")
.style(color::GREY_2)
.horizontal_alignment(alignment::Horizontal::Left)
).align_x(alignment::Horizontal::Left).width(Length::Fill))
.push(image::custom_template_description().width(Length::Fill))
.push(Container::new(
p1_regular("The Primary set of Keys will always be able to spend. Your Recovery set(s) of Keys will activate only after a defined time of wallet inactivity, allowing for secure recovery and advanced spending policies. You can define more than one set of Recovery Keys activating at different times.")
.style(color::GREY_2)
.horizontal_alignment(alignment::Horizontal::Left)
).align_x(alignment::Horizontal::Left).width(Length::Fill))
.push(Row::new().push(Space::with_width(Length::Fill)).push(button::primary(None, "Select").width(Length::Fixed(200.0)).on_press(Message::Next)))
.spacing(20),
true,
Some(Message::Previous),
)
}
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>>,
valid: bool,
) -> Element<'a, Message> {
layout(
progress,
None,
"Set keys",
Column::new()
.align_items(Alignment::Start)
.max_width(1000.0)
.push(collapse::Collapse::new(
|| {
Button::new(
Row::new()
.align_items(Alignment::Center)
.spacing(10)
.push(text("Advanced settings").small().bold())
.push(icon::collapse_icon()),
)
.style(theme::Button::Transparent)
},
|| {
Button::new(
Row::new()
.align_items(Alignment::Center)
.spacing(10)
.push(text("Advanced settings").small().bold())
.push(icon::collapsed_icon()),
)
.style(theme::Button::Transparent)
},
move || define_descriptor_advanced_settings(use_taproot),
))
.push(p1_regular(prompt::DEFINE_DESCRIPTOR_PRIMARY_PATH_TOOLTIP).style(color::GREY_2))
.push(
path(
color::GREEN,
Some("Primary spending option:".to_string()),
primary_path.sequence,
primary_path.duplicate_sequence,
primary_path.threshold,
primary_path
.keys
.iter()
.enumerate()
.map(|(i, primary_key)| {
if let Some(key) = primary_key {
defined_key(
&key.name,
color::GREEN,
"Primary key",
if use_taproot && !key.is_compatible_taproot {
Some("Key is not compatible with taproot")
} else {
None
},
i == 0,
)
} else {
undefined_key(
color::GREEN,
"Primary key",
!primary_path.keys[0..i].iter().any(|k| k.is_none()),
i == 0,
)
}
.map(move |msg| message::DefinePath::Key(i, msg))
})
.collect(),
false,
)
.map(|msg| Message::DefineDescriptor(message::DefineDescriptor::Path(0, msg))),
)
.push(p1_regular(prompt::DEFINE_DESCRIPTOR_RECOVERY_PATH_TOOLTIP).style(color::GREY_2))
.push(recovery_paths.into_iter().enumerate().fold(
Column::new().spacing(20),
|col, (i, p)| {
col.push(
path(
color::ORANGE,
Some(format!("Recovery option #{}:", i + 1)),
p.sequence,
p.duplicate_sequence,
p.threshold,
p.keys
.iter()
.enumerate()
.map(|(j, recovery_key)| {
if let Some(key) = recovery_key {
defined_key(
&key.name,
color::ORANGE,
"Recovery key",
if use_taproot && !key.is_compatible_taproot {
Some("Key is not compatible with Taproot")
} else {
None
},
false,
)
} else {
undefined_key(
color::ORANGE,
"Recovery key",
!p.keys[0..j].iter().any(|k| k.is_none()),
false,
)
}
.map(move |msg| message::DefinePath::Key(j, msg))
})
.collect(),
false,
)
.map(move |msg| {
Message::DefineDescriptor(message::DefineDescriptor::Path(i + 1, msg))
}),
)
},
))
.push(
Row::new()
.push(
button::secondary(Some(icon::plus_icon()), "Add recovery option")
.width(Length::Fixed(200.0))
.on_press(Message::DefineDescriptor(
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(Space::with_height(100.0))
.spacing(20),
true,
Some(Message::Previous),
)
}

View File

@ -0,0 +1,164 @@
use iced::{alignment, widget::Space, Alignment, Length};
use liana_ui::{
color,
component::{
button, collapse,
text::{h3, p1_regular, text, Text},
},
icon, image, theme,
widget::*,
};
use crate::installer::{
context,
message::{self, Message},
step::descriptor::editor::key::Key,
view::{
editor::{define_descriptor_advanced_settings, defined_key, path, undefined_key},
layout,
},
};
pub fn inheritance_template_description(progress: (usize, usize)) -> Element<'static, Message> {
layout(
progress,
None,
"Introduction",
Column::new()
.align_items(Alignment::Start)
.push(h3("Inheritance wallet"))
.max_width(800.0)
.push(Container::new(
p1_regular("For this Inheritance wallet you will need 2 Keys: Your Primary Key and an Inheritance Key to be given to a chosen heir. For security reasons, we suggest you use 2 Hardware Wallets to store them.")
.style(color::GREY_2)
.horizontal_alignment(alignment::Horizontal::Left)
).align_x(alignment::Horizontal::Left).width(Length::Fill))
.push(image::inheritance_template_description().width(Length::Fill))
.push(Container::new(
p1_regular("Your relatives Inheritance Key will become active only if you dont move the coins in your wallet for the defined period of time, enabling him/her to recover your funds while not being able to access them before that.")
.style(color::GREY_2)
.horizontal_alignment(alignment::Horizontal::Left)
).align_x(alignment::Horizontal::Left).width(Length::Fill))
.push(Row::new().push(Space::with_width(Length::Fill)).push(button::primary(None, "Select").width(Length::Fixed(200.0)).on_press(Message::Next)))
.spacing(20),
true,
Some(Message::Previous),
)
}
pub fn inheritance_template<'a>(
progress: (usize, usize),
use_taproot: bool,
primary_key: Option<&'a Key>,
recovery_key: Option<&'a Key>,
sequence: u16,
valid: bool,
) -> Element<'a, Message> {
layout(
progress,
None,
"Set keys",
Column::new()
.align_items(Alignment::Start)
.max_width(1000.0)
.push(collapse::Collapse::new(
|| {
Button::new(
Row::new()
.align_items(Alignment::Center)
.spacing(10)
.push(text("Advanced settings").small().bold())
.push(icon::collapse_icon()),
)
.style(theme::Button::Transparent)
},
|| {
Button::new(
Row::new()
.align_items(Alignment::Center)
.spacing(10)
.push(text("Advanced settings").small().bold())
.push(icon::collapsed_icon()),
)
.style(theme::Button::Transparent)
},
move || define_descriptor_advanced_settings(use_taproot),
))
.push(
path(
color::GREEN,
None,
0,
false,
1,
vec![if let Some(key) = primary_key {
defined_key(
&key.name,
color::GREEN,
"Primary key",
if use_taproot && !key.is_compatible_taproot {
Some("Key is not compatible with Taproot")
} else {
None
},
true,
)
} else {
undefined_key(color::GREEN, "Primary key", true, true)
}
.map(|msg| message::DefinePath::Key(0, msg))],
true,
)
.map(|msg| Message::DefineDescriptor(message::DefineDescriptor::Path(0, msg))),
)
.push(
path(
color::WHITE,
None,
sequence,
false,
1,
vec![if let Some(key) = recovery_key {
defined_key(
&key.name,
color::WHITE,
"Inheritance key",
if use_taproot && !key.is_compatible_taproot {
Some("Key is not compatible with taproot")
} else {
None
},
true,
)
} else {
undefined_key(color::WHITE, "Inheritance key", primary_key.is_some(), true)
}
.map(|msg| message::DefinePath::Key(0, msg))],
true,
)
.map(|msg| Message::DefineDescriptor(message::DefineDescriptor::Path(1, msg))),
)
.push(
Row::new()
.push(
button::secondary(None, "Customize")
.width(Length::Fixed(200.0))
.on_press(Message::DefineDescriptor(
message::DefineDescriptor::ChangeTemplate(
context::DescriptorTemplate::Custom,
),
)),
)
.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 }),
),
)
.spacing(20),
true,
Some(Message::Previous),
)
}

View File

@ -0,0 +1,73 @@
pub mod custom;
pub mod inheritance;
use iced::{alignment, Alignment, Length};
use liana_ui::{
color,
component::{
button, card,
text::{h3, p1_regular, p2_regular},
},
widget::*,
};
use crate::installer::context;
use crate::installer::{message::Message, view::layout};
pub fn choose_descriptor_template(progress: (usize, usize)) -> Element<'static, Message> {
layout(
progress,
None,
"Choose wallet type",
Column::new()
.max_width(800.0)
.align_items(Alignment::Start)
.push(Container::new(
p1_regular("What do you want your wallet for? This depends on the amount of funds you have, the more funds, the higher the security should be. Not sure about the wallet type? We can help you.")
.style(color::GREY_2)
.horizontal_alignment(alignment::Horizontal::Left)
).align_x(alignment::Horizontal::Left).width(Length::Fill))
.push(
card::simple(
Row::new()
.align_items(Alignment::Center)
.push(
Column::new()
.align_items(Alignment::Start)
.push(h3("Simple inheritance"))
.push(p2_regular("Two keys required, one for yourself to spend and another for your heir.").style(color::GREY_2))
.width(Length::Fill)
)
.push(button::secondary(None, "Select").on_press(
Message::SelectDescriptorTemplate(
context::DescriptorTemplate::SimpleInheritance,
),
)),
)
.width(Length::Fill),
)
.push(
card::simple(
Row::new()
.align_items(Alignment::Center)
.push(
Column::new()
.align_items(Alignment::Start)
.push(h3("Custom (choose your own)"))
.push(p2_regular("Create a custom setup that fits all your needs").style(color::GREY_2))
.width(Length::Fill)
)
.push(button::secondary(None, "Select").on_press(
Message::SelectDescriptorTemplate(
context::DescriptorTemplate::Custom ,
),
)),
)
.width(Length::Fill),
)
.spacing(20),
true,
Some(Message::Previous),
)
}

View File

@ -1,8 +1,7 @@
pub mod editor;
use async_hwi::utils::extract_keys_and_template;
use iced::widget::{
checkbox, container, pick_list, radio, scrollable, scrollable::Properties, slider, Button,
Space, TextInput,
};
use iced::widget::{checkbox, radio, scrollable, scrollable::Properties, Button, Space, TextInput};
use iced::{
alignment,
widget::{progress_bar, tooltip as iced_tooltip},
@ -25,9 +24,8 @@ use liana_ui::{
component::{
button, card, collapse, form, hw, separation,
text::{h2, h3, h4_bold, h5_regular, p1_regular, text, Text},
tooltip,
},
icon, image, theme,
icon, theme,
widget::*,
};
@ -37,6 +35,7 @@ use crate::{
message::{self, DefineBitcoind, DefineNode, Message},
prompt,
step::{DownloadState, InstallState},
view::editor::duration_from_sequence,
Error,
},
node::{
@ -45,252 +44,6 @@ use crate::{
},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DescriptorKind {
P2WSH,
Taproot,
}
const DESCRIPTOR_KINDS: [DescriptorKind; 2] = [DescriptorKind::P2WSH, DescriptorKind::Taproot];
impl std::fmt::Display for DescriptorKind {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::P2WSH => write!(f, "P2WSH"),
Self::Taproot => write!(f, "Taproot"),
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn define_descriptor_advanced_settings<'a>(use_taproot: bool) -> Element<'a, Message> {
let col_wallet = Column::new()
.spacing(10)
.push(text("Descriptor type").bold())
.push(container(
pick_list(
&DESCRIPTOR_KINDS[..],
Some(if use_taproot {
DescriptorKind::Taproot
} else {
DescriptorKind::P2WSH
}),
|kind| Message::CreateTaprootDescriptor(kind == DescriptorKind::Taproot),
)
.style(theme::PickList::Secondary)
.padding(10),
));
container(
Column::new()
.spacing(20)
.push(Space::with_height(0))
.push(separation().width(500))
.push(Row::new().push(col_wallet))
.push_maybe(if use_taproot {
Some(
p1_regular("Taproot is only supported by Liana version 5.0 and above")
.style(color::GREY_2),
)
} else {
None
}),
)
.into()
}
#[allow(clippy::too_many_arguments)]
pub fn define_descriptor<'a>(
progress: (usize, usize),
email: Option<&'a str>,
use_taproot: bool,
spending_keys: Vec<Element<'a, Message>>,
spending_threshold: usize,
recovery_paths: Vec<Element<'a, Message>>,
valid: bool,
error: Option<&String>,
) -> Element<'a, Message> {
let col_spending_keys = Column::new()
.push(
Row::new()
.spacing(10)
.push(text("Primary path:").bold())
.push(tooltip(prompt::DEFINE_DESCRIPTOR_PRIMARY_PATH_TOOLTIP)),
)
.push(Container::new(
Row::new()
.align_items(Alignment::Center)
.push_maybe(if spending_keys.len() > 1 {
Some(threshsold_input::threshsold_input(
spending_threshold,
spending_keys.len(),
|value| {
Message::DefineDescriptor(message::DefineDescriptor::PrimaryPath(
message::DefinePath::ThresholdEdited(value),
))
},
))
} else {
None
})
.push(
scrollable(
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(Row::with_children(spending_keys).spacing(5))
.push(
Button::new(
Container::new(icon::plus_icon().size(50))
.width(Length::Fixed(150.0))
.height(Length::Fixed(150.0))
.align_y(alignment::Vertical::Center)
.align_x(alignment::Horizontal::Center),
)
.width(Length::Fixed(150.0))
.height(Length::Fixed(150.0))
.style(theme::Button::TransparentBorder)
.on_press(
Message::DefineDescriptor(
message::DefineDescriptor::PrimaryPath(
message::DefinePath::AddKey,
),
),
),
)
.padding(5),
)
.direction(scrollable::Direction::Horizontal(
Properties::new().width(3).scroller_width(3),
)),
),
))
.spacing(10);
layout(
progress,
email,
"Create the wallet",
Column::new()
.push(collapse::Collapse::new(
|| {
Button::new(
Row::new()
.align_items(Alignment::Center)
.spacing(10)
.push(text("Advanced settings").small().bold())
.push(icon::collapse_icon()),
)
.style(theme::Button::Transparent)
},
|| {
Button::new(
Row::new()
.align_items(Alignment::Center)
.spacing(10)
.push(text("Advanced settings").small().bold())
.push(icon::collapsed_icon()),
)
.style(theme::Button::Transparent)
},
move || define_descriptor_advanced_settings(use_taproot),
))
.push(
Column::new()
.width(Length::Fill)
.push(
Column::new()
.spacing(25)
.push(col_spending_keys)
.push(
Row::new()
.spacing(10)
.push(text("Recovery paths:").bold())
.push(tooltip(prompt::DEFINE_DESCRIPTOR_RECOVERY_PATH_TOOLTIP)),
)
.push(Column::with_children(recovery_paths).spacing(10)),
)
.spacing(25),
)
.push(
Row::new()
.spacing(10)
.push(
button::secondary(Some(icon::plus_icon()), "Add a recovery path")
.on_press(Message::DefineDescriptor(
message::DefineDescriptor::AddRecoveryPath,
))
.width(Length::Fixed(200.0)),
)
.push(if !valid {
button::secondary(None, "Next").width(Length::Fixed(200.0))
} else {
button::secondary(None, "Next")
.width(Length::Fixed(200.0))
.on_press(Message::Next)
}),
)
.push_maybe(error.map(|e| card::error("Failed to create descriptor", e.to_string())))
.push(Space::with_height(Length::Fixed(20.0)))
.spacing(50),
false,
Some(Message::Previous),
)
}
pub fn recovery_path_view(
sequence: u16,
duplicate_sequence: bool,
recovery_threshold: usize,
recovery_keys: Vec<Element<message::DefinePath>>,
) -> Element<message::DefinePath> {
Container::new(
Column::new()
.push(defined_sequence(sequence, duplicate_sequence))
.push(
Row::new()
.align_items(Alignment::Center)
.push_maybe(if recovery_keys.len() > 1 {
Some(threshsold_input::threshsold_input(
recovery_threshold,
recovery_keys.len(),
message::DefinePath::ThresholdEdited,
))
} else {
None
})
.push(
scrollable(
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(Row::with_children(recovery_keys).spacing(5))
.push(
Button::new(
Container::new(icon::plus_icon().size(50))
.width(Length::Fixed(150.0))
.height(Length::Fixed(150.0))
.align_y(alignment::Vertical::Center)
.align_x(alignment::Horizontal::Center),
)
.width(Length::Fixed(150.0))
.height(Length::Fixed(150.0))
.style(theme::Button::TransparentBorder)
.on_press(message::DefinePath::AddKey),
)
.padding(5),
)
.direction(scrollable::Direction::Horizontal(
Properties::new().width(3).scroller_width(3),
)),
),
),
)
.padding(5)
.style(theme::Container::Card(theme::Card::Border))
.into()
}
pub fn import_wallet_or_descriptor<'a>(
progress: (usize, usize),
email: Option<&'a str>,
@ -1515,10 +1268,6 @@ pub fn start_internal_bitcoind<'a>(
install_state: Option<&InstallState>,
) -> Element<'a, Message> {
let version = crate::node::bitcoind::VERSION;
let mut next_button = button::secondary(None, "Next").width(Length::Fixed(200.0));
if let Some(Ok(_)) = started {
next_button = next_button.on_press(Message::Next);
};
layout(
progress,
None,
@ -1611,11 +1360,7 @@ pub fn start_internal_bitcoind<'a>(
}
})
.spacing(50)
.push(
Row::new()
.spacing(10)
.push(Row::new().spacing(10).push(next_button)),
)
.push(Row::new())
.push_maybe(error.map(|e| card::invalid(text(e)))),
true,
Some(message::Message::InternalBitcoind(
@ -1662,6 +1407,57 @@ pub fn install<'a>(
)
}
pub fn defined_threshold<'a>(
color: iced::Color,
fixed: bool,
threshold: (usize, usize),
) -> Element<'a, message::DefinePath> {
if !fixed && threshold.1 > 1 {
Button::new(
Row::new()
.spacing(10)
.push((0..threshold.1).fold(Row::new(), |row, i| {
if i < threshold.0 {
row.push(icon::round_key_icon().style(color))
} else {
row.push(icon::round_key_icon())
}
}))
.push(text(format!(
"{} out of {} key{}",
threshold.0,
threshold.1,
if threshold.0 > 1 { "s" } else { "" },
)))
.push(icon::pencil_icon()),
)
.padding(10)
.on_press(message::DefinePath::EditThreshold)
.style(theme::Button::Secondary)
.into()
} else {
card::simple(
Row::new()
.spacing(10)
.push((0..threshold.1).fold(Row::new(), |row, i| {
if i < threshold.0 {
row.push(icon::round_key_icon().style(color))
} else {
row.push(icon::round_key_icon())
}
}))
.push(text(format!(
"{} out of {} key{}",
threshold.0,
threshold.1,
if threshold.0 > 1 { "s" } else { "" },
))),
)
.padding(10)
.into()
}
}
pub fn defined_sequence<'a>(
sequence: u16,
duplicate_sequence: bool,
@ -1670,417 +1466,71 @@ pub fn defined_sequence<'a>(
Container::new(
Column::new()
.spacing(5)
.push(if sequence != 0 {
Row::new().align_items(Alignment::Center).push(
Container::new(
Row::new()
.align_items(Alignment::Center)
.spacing(5)
.push(
text::p1_regular("Available after inactivity of ~")
.style(color::GREY_2),
)
.push(
Button::new(
Row::new()
.padding(5)
.spacing(5)
.align_items(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),
),
)
.width(Length::Fill)
.padding(5)
.align_y(alignment::Vertical::Center),
)
} else {
Row::new()
.push(p1_regular("Able to move the funds at any time.").style(color::GREY_2))
.padding(5)
})
.push_maybe(if duplicate_sequence {
Some(
text("No two recovery paths may become available at the very same date.")
text("No two recovery options may become available at the very same date.")
.small()
.style(color::RED),
)
} else {
None
})
.push(
Row::new()
.align_items(Alignment::Center)
.push(
Container::new(
Column::new()
.spacing(5)
.push(text(format!("Available after {} blocks", sequence)).bold())
.push(
[
(n_years, "y"),
(n_months, "m"),
(n_days, "d"),
(n_hours, "h"),
(n_minutes, "mn"),
]
.iter()
.fold(
Row::new().spacing(5),
|row, (n, unit)| {
row.push_maybe(if *n > 0 {
Some(text(format!("{}{}", n, unit,)))
} else {
None
})
},
),
),
)
.padding(5)
.align_y(alignment::Vertical::Center),
)
.push(
button::secondary(Some(icon::pencil_icon()), "Edit")
.on_press(message::DefinePath::EditSequence),
)
.spacing(15),
),
.spacing(15),
)
.padding(5)
.into()
}
pub fn undefined_descriptor_key<'a>() -> Element<'a, message::DefineKey> {
card::simple(
Column::new()
.width(Length::Fill)
.align_items(Alignment::Center)
.push(
Row::new()
.align_items(Alignment::Center)
.push(Space::with_width(Length::Fill))
.push(
Button::new(icon::cross_icon())
.style(theme::Button::Transparent)
.on_press(message::DefineKey::Delete),
),
)
.push(
Container::new(
Column::new()
.spacing(15)
.align_items(Alignment::Center)
.push(image::key_mark_icon().width(Length::Fixed(30.0))),
)
.height(Length::Fill)
.align_y(alignment::Vertical::Center),
)
.push(
button::secondary(Some(icon::pencil_icon()), "Set")
.on_press(message::DefineKey::Edit),
)
.push(Space::with_height(Length::Fixed(5.0))),
)
.padding(5)
.height(Length::Fixed(150.0))
.width(Length::Fixed(150.0))
.into()
}
pub fn defined_descriptor_key<'a>(
name: String,
duplicate_name: bool,
incompatible_with_tapminiscript: bool,
) -> Element<'a, message::DefineKey> {
let col = Column::new()
.width(Length::Fill)
.align_items(Alignment::Center)
.push(
Row::new()
.align_items(Alignment::Center)
.push(Space::with_width(Length::Fill))
.push(
Button::new(icon::cross_icon())
.style(theme::Button::Transparent)
.on_press(message::DefineKey::Delete),
),
)
.push(
Container::new(
Column::new()
.spacing(10)
.align_items(Alignment::Center)
.push(
scrollable(
Column::new()
.push(text(name).bold())
.push(Space::with_height(Length::Fixed(5.0))),
)
.direction(scrollable::Direction::Horizontal(
Properties::new().width(5).scroller_width(5),
)),
)
.push(image::success_mark_icon().width(Length::Fixed(50.0)))
.push(Space::with_width(Length::Fixed(1.0))),
)
.height(Length::Fill)
.align_y(alignment::Vertical::Center),
)
.push(
button::secondary(Some(icon::pencil_icon()), "Edit").on_press(message::DefineKey::Edit),
)
.push(Space::with_height(Length::Fixed(5.0)));
if duplicate_name {
Column::new()
.align_items(Alignment::Center)
.push(
card::invalid(col)
.padding(5)
.height(Length::Fixed(150.0))
.width(Length::Fixed(150.0)),
)
.push(text("Duplicate name").small().style(color::RED))
.into()
} else if incompatible_with_tapminiscript {
Column::new()
.align_items(Alignment::Center)
.push(
card::invalid(col)
.padding(5)
.height(Length::Fixed(150.0))
.width(Length::Fixed(150.0)),
)
.push(
text("Taproot is not supported\nby this key device")
.small()
.style(color::RED),
)
.into()
} else {
card::simple(col)
.padding(5)
.height(Length::Fixed(150.0))
.width(Length::Fixed(150.0))
.into()
}
}
#[allow(clippy::too_many_arguments)]
pub fn edit_key_modal<'a>(
network: bitcoin::Network,
hws: Vec<Element<'a, Message>>,
keys: Vec<Element<'a, Message>>,
error: Option<&Error>,
chosen_signer: Option<Fingerprint>,
hot_signer_fingerprint: &Fingerprint,
signer_alias: Option<&'a String>,
form_xpub: &form::Value<String>,
form_name: &'a form::Value<String>,
edit_name: bool,
duplicate_master_fg: bool,
) -> Element<'a, Message> {
Column::new()
.push_maybe(error.map(|e| card::error("Failed to import xpub", e.to_string())))
.push(card::simple(
Column::new()
.spacing(25)
.push(
Column::new()
.push(
Container::new(text("Select a signing device:").bold())
.width(Length::Fill),
)
.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 {
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::Border),
)
.width(Length::Fill),
)
.push(
Column::new()
.spacing(5)
.push(text("Or enter an extended public key:").bold())
.push(
Row::new()
.push(
form::Form::new_trimmed(
&format!(
"[aabbccdd/42'/0']{}pub6DAkq8LWw91WGgUGnkR5Sbzjev5JCsXaTVZQ9MwsPV4BkNFKygtJ8GHodfDVx1udR723nT7JASqGPpKvz7zQ25pUTW6zVEBdiWoaC4aUqik",
if network == bitcoin::Network::Bitcoin {
"x"
} else {
"t"
}
),
form_xpub, |msg| {
Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(
message::ImportKeyModal::XPubEdited(msg),),)
})
.warning(if network == bitcoin::Network::Bitcoin {
"Please enter correct xpub with origin and without appended derivation path"
} else {
"Please enter correct tpub with origin and without appended derivation path"
})
.size(text::P1_SIZE)
.padding(10),
)
.spacing(10)
),
)
.push(
if !edit_name && !form_xpub.value.is_empty() && form_xpub.valid {
Column::new().push(
Row::new()
.push(
Column::new()
.spacing(5)
.width(Length::Fill)
.push(
Row::new()
.spacing(5)
.push(text("Fingerprint alias:").bold())
.push(tooltip(
prompt::DEFINE_DESCRIPTOR_FINGERPRINT_TOOLTIP,
)),
)
.push(text(&form_name.value)),
)
.push(
button::secondary(Some(icon::pencil_icon()), "Edit").on_press(
Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(
message::ImportKeyModal::EditName,
),
),
),
),
)
} else if !form_xpub.value.is_empty() && form_xpub.valid {
Column::new()
.spacing(5)
.push(
Row::new()
.spacing(5)
.push(text("Fingerprint alias:").bold())
.push(tooltip(prompt::DEFINE_DESCRIPTOR_FINGERPRINT_TOOLTIP)),
)
.push(
form::Form::new("Alias", form_name, |msg| {
Message::DefineDescriptor(message::DefineDescriptor::KeyModal(
message::ImportKeyModal::NameEdited(msg),
))
})
.warning("Please enter correct alias")
.size(text::P1_SIZE)
.padding(10),
)
} else {
Column::new()
},
)
.push_maybe(
if duplicate_master_fg {
Some(text("A single signing device may not be used more than once per path. (It can still be used in other paths.)").style(color::RED))
} else {
None
}
)
.push(
if form_xpub.valid && !form_xpub.value.is_empty() && !form_name.value.is_empty() && !duplicate_master_fg
{
button::secondary(None, "Apply")
.on_press(Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(
message::ImportKeyModal::ConfirmXpub,
),
))
.width(Length::Fixed(200.0))
} else {
button::secondary(None, "Apply").width(Length::Fixed(100.0))
},
)
.align_items(Alignment::Center),
))
.width(Length::Fixed(600.0))
.into()
}
/// returns y,m,d,h,m
fn duration_from_sequence(sequence: u16) -> (u32, u32, u32, u32, u32) {
let mut n_minutes = sequence as u32 * 10;
let n_years = n_minutes / 525960;
n_minutes -= n_years * 525960;
let n_months = n_minutes / 43830;
n_minutes -= n_months * 43830;
let n_days = n_minutes / 1440;
n_minutes -= n_days * 1440;
let n_hours = n_minutes / 60;
n_minutes -= n_hours * 60;
(n_years, n_months, n_days, n_hours, n_minutes)
}
pub fn edit_sequence_modal<'a>(sequence: &form::Value<String>) -> Element<'a, Message> {
let mut col = Column::new()
.width(Length::Fill)
.spacing(20)
.align_items(Alignment::Center)
.push(text("Activate recovery path after:"))
.push(
Row::new()
.push(
Container::new(
form::Form::new_trimmed("ex: 1000", sequence, |v| {
Message::DefineDescriptor(message::DefineDescriptor::SequenceModal(
message::SequenceModal::SequenceEdited(v),
))
})
.warning("Sequence must be superior to 0 and inferior to 65535"),
)
.width(Length::Fixed(200.0)),
)
.spacing(10)
.push(text("blocks").bold()),
);
if sequence.valid {
if let Ok(sequence) = u16::from_str(&sequence.value) {
let (n_years, n_months, n_days, n_hours, n_minutes) = duration_from_sequence(sequence);
col = col
.push(
[
(n_years, "year"),
(n_months, "month"),
(n_days, "day"),
(n_hours, "hour"),
(n_minutes, "minute"),
]
.iter()
.fold(Row::new().spacing(5), |row, (n, unit)| {
row.push_maybe(if *n > 0 {
Some(
text(format!("{} {}{}", n, unit, if *n > 1 { "s" } else { "" }))
.bold(),
)
} else {
None
})
}),
)
.push(
Container::new(
slider(1..=u16::MAX, sequence, |v| {
Message::DefineDescriptor(message::DefineDescriptor::SequenceModal(
message::SequenceModal::SequenceEdited(v.to_string()),
))
})
.step(144_u16), // 144 blocks per day
)
.width(Length::Fixed(500.0)),
);
}
}
card::simple(col.push(if sequence.valid {
button::secondary(None, "Apply")
.on_press(Message::DefineDescriptor(
message::DefineDescriptor::SequenceModal(message::SequenceModal::ConfirmSequence),
))
.width(Length::Fixed(200.0))
} else {
button::secondary(None, "Apply").width(Length::Fixed(200.0))
}))
.width(Length::Fixed(800.0))
.into()
}
pub fn hw_list_view(
i: usize,
hw: &HardwareWallet,
@ -2589,98 +2039,3 @@ fn layout<'a>(
.style(theme::Container::Background)
.into()
}
mod threshsold_input {
use iced::alignment::{self, Alignment};
use iced::widget::{component, Component};
use iced::Length;
use liana_ui::{component::text::*, icon, theme, widget::*};
pub struct ThresholdInput<Message> {
value: usize,
max: usize,
on_change: Box<dyn Fn(usize) -> Message>,
}
pub fn threshsold_input<Message>(
value: usize,
max: usize,
on_change: impl Fn(usize) -> Message + 'static,
) -> ThresholdInput<Message> {
ThresholdInput::new(value, max, on_change)
}
#[derive(Debug, Clone)]
pub enum Event {
IncrementPressed,
DecrementPressed,
}
impl<Message> ThresholdInput<Message> {
pub fn new(
value: usize,
max: usize,
on_change: impl Fn(usize) -> Message + 'static,
) -> Self {
Self {
value,
max,
on_change: Box::new(on_change),
}
}
}
impl<Message> Component<Message, theme::Theme> for ThresholdInput<Message> {
type State = ();
type Event = Event;
fn update(&mut self, _state: &mut Self::State, event: Event) -> Option<Message> {
match event {
Event::IncrementPressed => {
if self.value < self.max {
Some((self.on_change)(self.value.saturating_add(1)))
} else {
None
}
}
Event::DecrementPressed => {
if self.value > 1 {
Some((self.on_change)(self.value.saturating_sub(1)))
} else {
None
}
}
}
}
fn view(&self, _state: &Self::State) -> Element<Self::Event> {
let button = |label, on_press| {
Button::new(label)
.style(theme::Button::Transparent)
.width(Length::Fixed(50.0))
.on_press(on_press)
};
Column::new()
.width(Length::Fixed(150.0))
.push(button(icon::up_icon().size(30), Event::IncrementPressed))
.push(text("Threshold:").small().bold())
.push(
Container::new(text(format!("{}/{}", self.value, self.max)).size(30))
.align_y(alignment::Vertical::Center),
)
.push(button(icon::down_icon().size(30), Event::DecrementPressed))
.align_items(Alignment::Center)
.into()
}
}
impl<'a, Message> From<ThresholdInput<Message>> for Element<'a, Message>
where
Message: 'a,
{
fn from(numeric_input: ThresholdInput<Message>) -> Self {
component(numeric_input)
}
}
}

View File

@ -1,5 +1,11 @@
use crate::{color, component::text::text, icon, theme, widget::*};
pub fn modal<'a, T: 'a, C: Into<Element<'a, T>>>(content: C) -> Container<'a, T> {
Container::new(content)
.padding(15)
.style(theme::Container::Card(theme::Card::Modal))
}
pub fn simple<'a, T: 'a, C: Into<Element<'a, T>>>(content: C) -> Container<'a, T> {
Container::new(content)
.padding(15)

View File

@ -115,6 +115,14 @@ pub fn previous_icon() -> Text<'static> {
bootstrap_icon('\u{F284}')
}
pub fn check_icon() -> Text<'static> {
bootstrap_icon('\u{F633}')
}
pub fn round_key_icon() -> Text<'static> {
bootstrap_icon('\u{F44E}')
}
const ICONEX_ICONS: Font = Font::with_name("Untitled1");
fn iconex_icon(unicode: char) -> Text<'static> {

View File

@ -59,3 +59,19 @@ pub fn key_mark_icon() -> Svg {
let h = Handle::from_memory(KEY_MARK_ICON.to_vec());
Svg::new(h)
}
const INHERITANCE_TEMPLATE_DESC: &[u8] =
include_bytes!("../static/images/inheritance_template_description.svg");
pub fn inheritance_template_description() -> Svg {
let h = Handle::from_memory(INHERITANCE_TEMPLATE_DESC.to_vec());
Svg::new(h)
}
const CUSTOM_TEMPLATE_DESC: &[u8] =
include_bytes!("../static/images/custom_template_description.svg");
pub fn custom_template_description() -> Svg {
let h = Handle::from_memory(CUSTOM_TEMPLATE_DESC.to_vec());
Svg::new(h)
}

View File

@ -288,6 +288,7 @@ impl Notification {
pub enum Card {
#[default]
Simple,
Modal,
Border,
Invalid,
Warning,
@ -302,10 +303,14 @@ impl Card {
background: Some(color::GREY_2.into()),
..container::Appearance::default()
},
Card::Modal => container::Appearance {
background: Some(color::GREY_2.into()),
..container::Appearance::default()
},
Card::Border => container::Appearance {
background: Some(iced::Color::TRANSPARENT.into()),
border: iced::Border {
color: color::GREY_2,
color: color::GREY_7,
width: 1.0,
radius: 10.0.into(),
},
@ -347,10 +352,19 @@ impl Card {
},
..container::Appearance::default()
},
Card::Modal => container::Appearance {
background: Some(color::LIGHT_BLACK.into()),
border: iced::Border {
color: color::TRANSPARENT,
width: 0.0,
radius: 25.0.into(),
},
..container::Appearance::default()
},
Card::Border => container::Appearance {
background: Some(iced::Color::TRANSPARENT.into()),
border: iced::Border {
color: color::GREY_5,
color: color::GREY_7,
width: 1.0,
radius: 25.0.into(),
},
@ -790,8 +804,11 @@ impl button::StyleSheet for Theme {
}
}
fn disabled(&self, style: &Self::Style) -> button::Appearance {
let active = self.active(style);
let active = if let Button::Primary = style {
self.active(&Button::Secondary)
} else {
self.active(style)
};
button::Appearance {
shadow_offset: iced::Vector::default(),
background: Some(color::TRANSPARENT.into()),

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 125 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 124 KiB