Merge #391: Gui add multipath descriptor

ce23bcf498d2284ed389e7b229e63aeaa0c96e91 gui: add path selection to recovery panel (edouard)
a42eb6d36a3501766dfdc981ea2346aa0c8394e8 gui: change wording coins view (edouard)
13248dbd026ef85bee147adbb0c6213c6fd017ec installer: add multipath support (edouard)
35dbb47bc1212b07859438fe989d4598e7eeb3e9 gui: update liana with multipath support (edouard)

Pull request description:

ACKs for top commit:
  edouardparis:
    Self-ACK ce23bcf498d2284ed389e7b229e63aeaa0c96e91

Tree-SHA512: 9a126262d403826c818aedeba919b9e222df82de204cf9ddbc3c928b6e4756308ea3c8b67cd25e1f86e6c283eb2edd54b48779199903660229e97bf40ded8af2
This commit is contained in:
edouard 2023-04-04 16:07:26 +02:00
commit cbc03202ca
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
27 changed files with 1745 additions and 1044 deletions

1092
gui/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -71,14 +71,13 @@ impl App {
menu::Menu::Home => Home::new(self.wallet.clone(), &self.cache.coins).into(),
menu::Menu::Coins => CoinsPanel::new(
&self.cache.coins,
self.wallet.main_descriptor.timelock_value(),
self.wallet.main_descriptor.first_timelock_value(),
)
.into(),
menu::Menu::Recovery => RecoveryPanel::new(
self.wallet.clone(),
&self.cache.coins,
self.wallet.main_descriptor.timelock_value(),
self.cache.blockheight as u32,
self.cache.blockheight,
)
.into(),
menu::Menu::Receive => ReceivePanel::default().into(),

View File

@ -15,11 +15,11 @@ pub struct CoinsPanel {
selected: Vec<usize>,
warning: Option<Error>,
/// timelock value to pass for the heir to consume a coin.
timelock: u32,
timelock: u16,
}
impl CoinsPanel {
pub fn new(coins: &[Coin], timelock: u32) -> Self {
pub fn new(coins: &[Coin], timelock: u16) -> Self {
let mut panel = Self {
coins: Vec::new(),
selected: Vec::new(),

View File

@ -124,12 +124,12 @@ impl State for Home {
for coin in coins {
if coin.spend_info.is_none() && coin.block_height.is_some() {
self.balance += coin.amount;
let timelock = self.wallet.main_descriptor.timelock_value();
let timelock = self.wallet.main_descriptor.first_timelock_value();
let seq = remaining_sequence(&coin, cache.blockheight as u32, timelock);
if seq == 0 {
recovery_alert.0 += coin.amount;
recovery_alert.1 += 1;
} else if seq < timelock * 10 / 100 {
} else if seq < timelock as u32 * 10 / 100 {
recovery_warning.0 += coin.amount;
recovery_warning.1 += 1;
}

View File

@ -3,6 +3,7 @@ use std::sync::Arc;
use iced::Command;
use liana::miniscript::bitcoin::util::bip32::{DerivationPath, Fingerprint};
use liana_ui::{component::form, widget::Element};
use crate::{
@ -26,41 +27,24 @@ use liana::miniscript::bitcoin::{Address, Amount};
pub struct RecoveryPanel {
wallet: Arc<Wallet>,
locked_coins: (usize, Amount),
recoverable_coins: (usize, Amount),
recovery_paths: Vec<RecoveryPath>,
selected_path: Option<usize>,
warning: Option<Error>,
feerate: form::Value<String>,
recipient: form::Value<String>,
generated: Option<detail::SpendTxState>,
/// timelock value to pass for the heir to consume a coin.
timelock: u32,
}
impl RecoveryPanel {
pub fn new(wallet: Arc<Wallet>, coins: &[Coin], timelock: u32, blockheight: u32) -> Self {
let mut locked_coins = (0, Amount::from_sat(0));
let mut recoverable_coins = (0, Amount::from_sat(0));
for coin in coins {
if coin.spend_info.is_none() {
// recoverable coins are coins that can be recoverable next block.
if remaining_sequence(coin, blockheight, timelock) > 1 {
locked_coins.0 += 1;
locked_coins.1 += coin.amount;
} else {
recoverable_coins.0 += 1;
recoverable_coins.1 += coin.amount;
}
}
}
pub fn new(wallet: Arc<Wallet>, coins: &[Coin], blockheight: i32) -> Self {
Self {
recovery_paths: recovery_paths(&wallet, coins, blockheight),
wallet,
locked_coins,
recoverable_coins,
selected_path: None,
warning: None,
feerate: form::Value::default(),
recipient: form::Value::default(),
generated: None,
timelock,
}
}
}
@ -74,8 +58,26 @@ impl State for RecoveryPanel {
false,
self.warning.as_ref(),
view::recovery::recovery(
&self.locked_coins,
&self.recoverable_coins,
self.recovery_paths
.iter()
.enumerate()
.filter_map(|(i, path)| {
if path.number_of_coins > 0 {
Some(view::recovery::recovery_path_view(
i,
path.threshold,
&path.origins,
path.total_amount,
path.number_of_coins,
&self.wallet.keys_aliases,
self.selected_path == Some(i),
))
} else {
None
}
})
.collect(),
self.selected_path,
&self.feerate,
&self.recipient,
),
@ -95,22 +97,7 @@ impl State for RecoveryPanel {
Err(e) => self.warning = Some(e),
Ok(coins) => {
self.warning = None;
self.locked_coins = (0, Amount::from_sat(0));
self.recoverable_coins = (0, Amount::from_sat(0));
for coin in coins {
if coin.spend_info.is_none() {
// recoverable coins are coins that can be recoverable next block.
if remaining_sequence(&coin, cache.blockheight as u32, self.timelock)
> 1
{
self.locked_coins.0 += 1;
self.locked_coins.1 += coin.amount;
} else {
self.recoverable_coins.0 += 1;
self.recoverable_coins.1 += coin.amount;
}
}
}
self.recovery_paths = recovery_paths(&self.wallet, &coins, cache.blockheight);
}
},
Message::Recovery(res) => match res {
@ -134,6 +121,13 @@ impl State for RecoveryPanel {
self.recipient.valid = false;
}
}
view::Message::CreateSpend(view::CreateSpendMessage::SelectPath(index)) => {
if Some(index) == self.selected_path {
self.selected_path = None;
} else {
self.selected_path = Some(index);
}
}
view::Message::CreateSpend(view::CreateSpendMessage::FeerateEdited(feerate)) => {
self.feerate.value = feerate;
self.feerate.valid =
@ -144,9 +138,13 @@ impl State for RecoveryPanel {
let feerate_vb = self.feerate.value.parse::<u64>().expect("Checked before");
self.warning = None;
let desc = self.wallet.main_descriptor.clone();
let sequence = self
.recovery_paths
.get(self.selected_path.expect("A path must be selected"))
.map(|p| p.sequence);
return Command::perform(
async move {
let psbt = daemon.create_recovery(address, feerate_vb)?;
let psbt = daemon.create_recovery(address, feerate_vb, sequence)?;
let coins = daemon.list_coins().map(|res| res.coins)?;
let coins = coins
.iter()
@ -198,3 +196,43 @@ impl From<RecoveryPanel> for Box<dyn State> {
Box::new(s)
}
}
pub struct RecoveryPath {
threshold: usize,
sequence: u16,
origins: Vec<(Fingerprint, DerivationPath)>,
total_amount: Amount,
number_of_coins: usize,
}
fn recovery_paths(wallet: &Wallet, coins: &[Coin], blockheight: i32) -> Vec<RecoveryPath> {
wallet
.main_descriptor
.policy()
.recovery_paths()
.iter()
.map(|(&sequence, path)| {
let (number_of_coins, total_amount) = coins
.iter()
.filter(|coin| {
coin.spend_info.is_none()
&& remaining_sequence(coin, blockheight as u32, sequence) <= 1
})
.fold(
(0, Amount::from_sat(0)),
|(number_of_coins, total_amount), coin| {
(number_of_coins + 1, total_amount + coin.amount)
},
);
let (threshold, origins) = path.thresh_origins();
RecoveryPath {
total_amount,
number_of_coins,
sequence,
threshold,
origins: origins.into_iter().collect(),
}
})
.collect()
}

View File

@ -302,13 +302,13 @@ impl Setting for RescanSetting {
}
}
view::SettingsEditMessage::Confirm => {
let date_time = NaiveDate::from_ymd(
let date_time = NaiveDate::from_ymd_opt(
i32::from_str(&self.year.value).unwrap_or(1),
u32::from_str(&self.month.value).unwrap_or(1),
u32::from_str(&self.day.value).unwrap_or(1),
)
.and_hms(0, 0, 0);
let t = date_time.timestamp() as u32;
.unwrap();
let t = date_time.and_hms_opt(0, 0, 0).unwrap().timestamp() as u32;
self.processing = true;
info!("Asking deamon to rescan with timestamp: {}", t);
return Command::perform(

View File

@ -138,7 +138,7 @@ pub struct CreateSpendPanel {
impl CreateSpendPanel {
pub fn new(wallet: Arc<Wallet>, coins: &[Coin], blockheight: u32) -> Self {
let descriptor = wallet.main_descriptor.clone();
let timelock = descriptor.timelock_value();
let timelock = descriptor.first_timelock_value();
Self {
draft: step::TransactionDraft::default(),
current: 0,

View File

@ -224,7 +224,7 @@ impl Recipient {
pub struct ChooseCoins {
descriptor: LianaDescriptor,
timelock: u32,
timelock: u16,
coins: Vec<(Coin, bool)>,
recipients: Vec<(Address, Amount)>,
@ -238,7 +238,7 @@ impl ChooseCoins {
pub fn new(
descriptor: LianaDescriptor,
coins: Vec<Coin>,
timelock: u32,
timelock: u16,
blockheight: u32,
) -> Self {
let mut coins: Vec<(Coin, bool)> = coins

View File

@ -19,7 +19,7 @@ use crate::{
pub fn coins_view<'a>(
cache: &Cache,
coins: &'a [Coin],
timelock: u32,
timelock: u16,
selected: &[usize],
) -> Element<'a, Message> {
Column::new()
@ -55,7 +55,7 @@ pub fn coins_view<'a>(
#[allow(clippy::collapsible_else_if)]
fn coin_list_view(
coin: &Coin,
timelock: u32,
timelock: u16,
blockheight: u32,
index: usize,
collapsed: bool,
@ -84,7 +84,7 @@ fn coin_list_view(
)
.align_items(Alignment::Center),
))
} else if seq < timelock * 10 / 100 {
} else if seq < timelock as u32 * 10 / 100 {
Some(Container::new(
Row::new()
.spacing(5)
@ -138,16 +138,16 @@ fn coin_list_view(
.spacing(5)
.push_maybe(if coin.spend_info.is_none() {
if let Some(b) = coin.block_height {
if blockheight > b as u32 + timelock {
if blockheight > b as u32 + timelock as u32 {
Some(Container::new(
text("The recovery path is available")
text("One of the recovery path is available")
.bold()
.small()
.style(color::legacy::ALERT),
))
} else {
Some(Container::new(
text(format!("The recovery path will be available in {} blocks", b as u32 + timelock - blockheight))
text(format!("One of the recovery path will be available in {} blocks", b as u32 + timelock as u32 - blockheight))
.bold()
.small(),
))

View File

@ -127,8 +127,11 @@ fn event_list_view<'a>(i: usize, event: &HistoryTransaction) -> Element<'a, Mess
})
.push(if let Some(t) = event.time {
Container::new(
text(format!("{}", NaiveDateTime::from_timestamp(t as i64, 0)))
.small(),
text(format!(
"{}",
NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(),
))
.small(),
)
} else {
badge::unconfirmed()
@ -186,7 +189,7 @@ pub fn event_view<'a>(cache: &Cache, event: &'a HistoryTransaction) -> Element<'
.push(card::simple(
Column::new()
.push_maybe(event.time.map(|t| {
let date = NaiveDateTime::from_timestamp(t as i64, 0);
let date = NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap();
Row::new()
.width(Length::Fill)
.push(Container::new(text("Date:").bold()).width(Length::Fill))

View File

@ -24,6 +24,7 @@ pub enum CreateSpendMessage {
SelectCoin(usize),
RecipientEdited(usize, &'static str, String),
FeerateEdited(String),
SelectPath(usize),
Generate,
}

View File

@ -1,20 +1,30 @@
use iced::{widget::Space, Alignment, Length};
use std::collections::HashMap;
use liana::miniscript::bitcoin::Amount;
use iced::{
widget::{tooltip, Space},
Alignment, Length,
};
use liana::miniscript::bitcoin::{
util::bip32::{DerivationPath, Fingerprint},
Amount,
};
use liana_ui::{
component::{button, form, text::*},
icon,
util::Collection,
icon, theme,
widget::*,
};
use crate::app::view::message::{CreateSpendMessage, Message};
use crate::app::view::{
message::{CreateSpendMessage, Message},
util::amount,
};
#[allow(clippy::too_many_arguments)]
pub fn recovery<'a>(
locked_coins: &(usize, Amount),
recoverable_coins: &(usize, Amount),
recovery_paths: Vec<Element<'a, Message>>,
selected_path: Option<usize>,
feerate: &form::Value<String>,
address: &'a form::Value<String>,
) -> Element<'a, Message> {
@ -30,23 +40,17 @@ pub fn recovery<'a>(
.spacing(1),
)
.push(
Container::new(Row::new().push(text(format!(
"{} ({} coins) will be spendable through the recovery path in the next block",
recoverable_coins.1, recoverable_coins.0
))))
.center_x(),
)
.push_maybe(if *locked_coins != (0, Amount::from_sat(0)) {
Some(
Container::new(Row::new().push(text(format!(
"{} ({} coins) are not yet spendable through the recovery path",
locked_coins.1, locked_coins.0
))))
.center_x(),
Container::new(
Column::new()
.spacing(10)
.push(text(format!(
"{} recovery paths are available or will be available next block, select one:",
recovery_paths.len()
)))
.push(Column::with_children(recovery_paths).spacing(10)),
)
} else {
None
})
.padding(20),
)
.push(Space::with_height(Length::Units(20)))
.push(
Column::new()
@ -80,7 +84,7 @@ pub fn recovery<'a>(
&& !feerate.value.is_empty()
&& address.valid
&& !address.value.is_empty()
&& recoverable_coins.0 != 0
&& selected_path.is_some()
{
button::primary(None, "Next")
.on_press(Message::Next)
@ -96,3 +100,81 @@ pub fn recovery<'a>(
.spacing(20)
.into()
}
pub fn recovery_path_view<'a>(
index: usize,
threshold: usize,
origins: &'a [(Fingerprint, DerivationPath)],
total_amount: Amount,
number_of_coins: usize,
key_aliases: &'a HashMap<Fingerprint, String>,
selected: bool,
) -> Element<'a, Message> {
Container::new(
Button::new(
Row::new()
.push(if selected {
icon::square_check_icon()
} else {
icon::square_icon()
})
.push(
Column::new()
.push(
Row::new()
.align_items(Alignment::Center)
.spacing(10)
.push(
text(format!(
"{} signature{} from",
threshold,
if threshold > 1 { "s" } else { "" }
))
.bold(),
)
.push(origins.iter().fold(
Row::new().align_items(Alignment::Center).spacing(5),
|row, (fg, _)| {
row.push(if let Some(alias) = key_aliases.get(fg) {
Container::new(
tooltip::Tooltip::new(
Container::new(text(alias)).padding(3).style(
theme::Container::Pill(theme::Pill::Simple),
),
fg.to_string(),
tooltip::Position::Bottom,
)
.style(theme::Container::Card(theme::Card::Simple)),
)
} else {
Container::new(text(fg.to_string()))
.padding(3)
.style(theme::Container::Pill(theme::Pill::Simple))
})
},
)),
)
.push(
Row::new()
.spacing(5)
.push(text("can recover"))
.push(text(format!(
"{} coin{} totalling",
number_of_coins,
if number_of_coins > 0 { "s" } else { "" }
)))
.push(amount(&total_amount)),
),
)
.align_items(Alignment::Center)
.spacing(20),
)
.padding(10)
.width(Length::Fill)
.on_press(Message::CreateSpend(CreateSpendMessage::SelectPath(index)))
.style(theme::Button::TransparentBorder),
)
.style(theme::Container::Card(theme::Card::Simple))
.width(Length::Fill)
.into()
}

View File

@ -203,7 +203,7 @@ fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> {
.push(
Row::new()
.push(badge::Badge::new(icon::send_icon()).style(theme::Badge::Standard))
.push(if tx.sigs.recovery_path().is_some() {
.push(if !tx.sigs.recovery_paths().is_empty() {
text("Recovery").bold()
} else {
text("Spend").bold()
@ -363,8 +363,8 @@ pub fn signatures<'a>(
Column::new()
.padding(15)
.spacing(10)
.push(text(if tx.sigs.recovery_path().is_some() {
"2 spending paths available. Finalizing this transaction requires either:"
.push(text(if !tx.sigs.recovery_paths().is_empty() {
"Multiple spending paths available. Finalizing this transaction requires either:"
} else {
"1 spending path available. Finalizing this transaction requires:"
}))
@ -373,9 +373,9 @@ pub fn signatures<'a>(
tx.sigs.primary_path(),
keys_aliases,
))
.push_maybe(tx.sigs.recovery_path().as_ref().map(|path| {
let (_, keys) = desc_info.recovery_path();
path_view(keys, path, keys_aliases)
.push(tx.sigs.recovery_paths().iter().fold(Column::new().spacing(10), |col, (seq, path)| {
let keys = &desc_info.recovery_paths()[seq];
col.push(path_view(keys, path, keys_aliases))
})),
),
)

View File

@ -110,30 +110,12 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> {
.push(
Row::new()
.push(badge::spend())
.push(if let Some(sigs) = tx.sigs.recovery_path() {
Row::new()
.spacing(10)
.align_items(Alignment::Center)
.push(
Row::new()
.spacing(5)
.align_items(Alignment::Center)
.push(text(format!(
"{}/{}",
if sigs.sigs_count <= sigs.threshold {
sigs.sigs_count
} else {
sigs.threshold
},
sigs.threshold
)))
.push(icon::key_icon()),
)
.push(
Container::new(text(" Recovery ").small())
.padding(3)
.style(theme::Container::Pill(theme::Pill::Simple)),
)
.push(if !tx.sigs.recovery_paths().is_empty() {
Row::new().push(
Container::new(text(" Recovery ").small())
.padding(3)
.style(theme::Container::Pill(theme::Pill::Simple)),
)
} else {
let sigs = tx.sigs.primary_path();
Row::new()

View File

@ -120,7 +120,7 @@ pub fn recipient_view<'a>(
pub fn choose_coins_view<'a>(
cache: &Cache,
timelock: u32,
timelock: u16,
coins: &[(Coin, bool)],
amount_left: Option<&Amount>,
feerate: &form::Value<String>,
@ -192,7 +192,7 @@ pub fn choose_coins_view<'a>(
fn coin_list_view<'a>(
i: usize,
coin: &Coin,
timelock: u32,
timelock: u16,
blockheight: u32,
selected: bool,
) -> Element<'a, Message> {
@ -223,7 +223,7 @@ fn coin_list_view<'a>(
)
.align_items(Alignment::Center),
))
} else if seq < timelock * 10 / 100 {
} else if seq < timelock as u32 * 10 / 100 {
Some(Container::new(
Row::new()
.spacing(5)

View File

@ -55,8 +55,10 @@ impl Wallet {
for (fingerprint, _) in info.primary_path().thresh_origins().1.iter() {
descriptor_keys.insert(*fingerprint);
}
for (fingerprint, _) in info.recovery_path().1.thresh_origins().1.iter() {
descriptor_keys.insert(*fingerprint);
for path in info.recovery_paths().values() {
for (fingerprint, _) in path.thresh_origins().1.iter() {
descriptor_keys.insert(*fingerprint);
}
}
descriptor_keys
}

View File

@ -134,10 +134,15 @@ impl<C: Client + Debug> Daemon for Lianad<C> {
self.call("listtransactions", Some(vec![txids]))
}
fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result<Psbt, DaemonError> {
fn create_recovery(
&self,
address: Address,
feerate_vb: u64,
sequence: Option<u16>,
) -> Result<Psbt, DaemonError> {
let res: CreateSpendResult = self.call(
"createrecovery",
Some(vec![json!(address), json!(feerate_vb)]),
Some(vec![json!(address), json!(feerate_vb), json!(sequence)]),
)?;
Ok(res.psbt)
}

View File

@ -194,14 +194,19 @@ impl Daemon for EmbeddedDaemon {
.map_err(|e| DaemonError::Unexpected(e.to_string()))
}
fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result<Psbt, DaemonError> {
fn create_recovery(
&self,
address: Address,
feerate_vb: u64,
sequence: Option<u16>,
) -> Result<Psbt, DaemonError> {
self.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.read()
.unwrap()
.control
.create_recovery(address, feerate_vb)
.create_recovery(address, feerate_vb, sequence)
.map_err(|e| DaemonError::Unexpected(e.to_string()))
.map(|res| res.psbt)
}

View File

@ -68,7 +68,12 @@ pub trait Daemon: Debug {
_end: u32,
_limit: u64,
) -> Result<model::ListTransactionsResult, DaemonError>;
fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result<Psbt, DaemonError>;
fn create_recovery(
&self,
address: Address,
feerate_vb: u64,
sequence: Option<u16>,
) -> Result<Psbt, DaemonError>;
fn list_txs(&self, txid: &[Txid]) -> Result<model::ListTransactionsResult, DaemonError>;
fn list_spend_transactions(&self) -> Result<Vec<model::SpendTx>, DaemonError> {

View File

@ -9,15 +9,15 @@ pub use liana::{
pub type Coin = ListCoinsEntry;
pub fn remaining_sequence(coin: &Coin, blockheight: u32, timelock: u32) -> u32 {
pub fn remaining_sequence(coin: &Coin, blockheight: u32, timelock: u16) -> u32 {
if let Some(coin_blockheight) = coin.block_height {
if blockheight > coin_blockheight as u32 + timelock {
if blockheight > coin_blockheight as u32 + timelock as u32 {
0
} else {
coin_blockheight as u32 + timelock - blockheight
coin_blockheight as u32 + timelock as u32 - blockheight
}
} else {
timelock
timelock as u32
}
}
@ -89,12 +89,10 @@ impl SpendTx {
if path.sigs_count >= path.threshold {
return Some(path);
}
if let Some(path) = self.sigs.recovery_path() {
if path.sigs_count >= path.threshold {
return Some(path);
}
}
None
self.sigs
.recovery_paths()
.values()
.find(|&path| path.sigs_count >= path.threshold)
}
}

View File

@ -43,16 +43,21 @@ pub enum DefineBitcoind {
#[derive(Debug, Clone)]
pub enum DefineDescriptor {
ImportDescriptor(String),
/// AddKey(is_recovery)
AddKey(bool),
Key(bool, usize, DefineKey),
HWXpubImported(Result<DescriptorPublicKey, Error>),
XPubEdited(String),
EditName,
NameEdited(String),
SequenceEdited(String),
ThresholdEdited(bool, usize),
ConfirmXpub,
PrimaryPath(DefinePath),
RecoveryPath(usize, DefinePath),
AddRecoveryPath,
KeyModal(ImportKeyModal),
SequenceModal(SequenceModal),
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
pub enum DefinePath {
AddKey,
Key(usize, DefineKey),
ThresholdEdited(usize),
SequenceEdited(u16),
EditSequence,
}
#[derive(Debug, Clone)]
@ -62,3 +67,18 @@ pub enum DefineKey {
Clipboard(String),
Edited(String, DescriptorPublicKey),
}
#[derive(Debug, Clone)]
pub enum ImportKeyModal {
HWXpubImported(Result<DescriptorPublicKey, Error>),
XPubEdited(String),
EditName,
NameEdited(String),
ConfirmXpub,
}
#[derive(Debug, Clone)]
pub enum SequenceModal {
SequenceEdited(String),
ConfirmSequence,
}

View File

@ -1,9 +1,9 @@
pub const BACKUP_DESCRIPTOR_MESSAGE: &str = "The descriptor is necessary to recover your funds. The backup of your key (via mnemonics, sometimes called 'seed words') is not enough. Please make sure you have backed up both your private key and your descriptor.";
pub const BACKUP_DESCRIPTOR_HELP: &str = "In Bitcoin, the coins are locked using a Script (related to the 'address'). In order to recover your funds you need both to know the Scripts you have participated in (your 'addresses'), and be able to sign a transaction that spends from those. For the ability to sign you backup your private key, this is your mnemonics ('seed words'). For finding the coins that belongs to you you backup a template of your Script ( / 'addresses'), this is your descriptor. Note however the descriptor needs not be as securely stored as the private key. A thief that steals your descriptor but not your private key will not be able to steal your funds.";
pub const DEFINE_DESCRIPTOR_PRIMATRY_PATH_TOOLTIP: &str =
"This is the keys that can spend received coins immediately,\n with no time restriction.";
pub const DEFINE_DESCRIPTOR_SEQUENCE_TOOLTIP: &str =
"Number of blocks after a coin is received \nfor which the recovery path is not available";
pub const DEFINE_DESCRIPTOR_PRIMARY_PATH_TOOLTIP: &str =
"Set key(s) that can be used to spend coins immediately, with no time restriction.";
pub const DEFINE_DESCRIPTOR_RECOVERY_PATH_TOOLTIP: &str =
"Set key(s) that can be used to spend coins after a defined period of time.\n Different sets of keys can be set to become available at different times.";
pub const DEFINE_DESCRIPTOR_FINGERPRINT_TOOLTIP: &str =
"The alias is applied on all the keys derived from the same seed";
pub const REGISTER_DESCRIPTOR_HELP: &str = "To be used with the wallet, a device needs the descriptor. If the descriptor contains one or more keys imported from an external signing device, the descriptor must be registered on it. Registration on a device is not a substitute for backing up the descriptor.";

View File

@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
@ -35,7 +35,7 @@ use crate::{
signer::Signer,
};
pub trait DescriptorKeyModal {
pub trait DescriptorEditModal {
fn processing(&self) -> bool {
false
}
@ -45,16 +45,53 @@ pub trait DescriptorKeyModal {
fn view(&self) -> Element<Message>;
}
pub struct RecoveryPath {
keys: Vec<DescriptorKey>,
threshold: usize,
sequence: u16,
}
impl RecoveryPath {
pub fn new() -> Self {
Self {
keys: vec![DescriptorKey::default()],
threshold: 1,
sequence: u16::MAX,
}
}
fn valid(&self) -> bool {
!self.keys.is_empty() && !self.keys.iter().any(|k| k.key.is_none())
}
fn check_network(&mut self, network: Network) {
for key in self.keys.iter_mut() {
key.check_network(network);
}
}
fn view(&self) -> Element<message::DefinePath> {
view::recovery_path_view(
self.sequence,
self.threshold,
self.keys
.iter()
.enumerate()
.map(|(i, key)| key.view().map(move |msg| message::DefinePath::Key(i, msg)))
.collect(),
)
}
}
pub struct DefineDescriptor {
network: Network,
network_valid: bool,
data_dir: Option<PathBuf>,
spending_keys: Vec<DescriptorKey>,
spending_threshold: usize,
recovery_keys: Vec<DescriptorKey>,
recovery_threshold: usize,
sequence: form::Value<String>,
modal: Option<Box<dyn DescriptorKeyModal>>,
recovery_paths: Vec<RecoveryPath>,
modal: Option<Box<dyn DescriptorEditModal>>,
signer: Arc<Signer>,
error: Option<String>,
@ -68,9 +105,7 @@ impl DefineDescriptor {
network_valid: true,
spending_keys: vec![DescriptorKey::default()],
spending_threshold: 1,
recovery_keys: vec![DescriptorKey::default()],
recovery_threshold: 1,
sequence: form::Value::default(),
recovery_paths: vec![RecoveryPath::new()],
modal: None,
signer: Arc::new(Signer::generate(Network::Bitcoin).unwrap()),
error: None,
@ -79,10 +114,8 @@ impl DefineDescriptor {
fn valid(&self) -> bool {
!self.spending_keys.is_empty()
&& !self.recovery_keys.is_empty()
&& !self.sequence.value.is_empty()
&& !self.spending_keys.iter().any(|k| k.key.is_none())
&& !self.spending_keys.iter().any(|k| k.key.is_none())
&& !self.recovery_paths.iter().any(|path| !path.valid())
}
fn set_network(&mut self, network: Network) {
@ -97,8 +130,8 @@ impl DefineDescriptor {
for key in self.spending_keys.iter_mut() {
key.check_network(self.network);
}
for key in self.recovery_keys.iter_mut() {
key.check_network(self.network);
for path in self.recovery_paths.iter_mut() {
path.check_network(self.network);
}
}
@ -126,19 +159,21 @@ impl DefineDescriptor {
}
}
}
for recovery_key in &self.recovery_keys {
if let Some(key) = &recovery_key.key {
if let Some(fg) = all_names.get(&recovery_key.name) {
if fg != &key.master_fingerprint() {
duplicate_names.insert(recovery_key.name.clone());
for path in &self.recovery_paths {
for recovery_key in &path.keys {
if let Some(key) = &recovery_key.key {
if let Some(fg) = all_names.get(&recovery_key.name) {
if fg != &key.master_fingerprint() {
duplicate_names.insert(recovery_key.name.clone());
}
} else {
all_names.insert(recovery_key.name.clone(), key.master_fingerprint());
}
if all_keys.contains(key) {
duplicate_keys.insert(key.clone());
} else {
all_keys.insert(key.clone());
}
} else {
all_names.insert(recovery_key.name.clone(), key.master_fingerprint());
}
if all_keys.contains(key) {
duplicate_keys.insert(key.clone());
} else {
all_keys.insert(key.clone());
}
}
}
@ -148,9 +183,12 @@ impl DefineDescriptor {
spending_key.duplicate_key = duplicate_keys.contains(key);
}
}
for recovery_key in self.recovery_keys.iter_mut() {
if let Some(key) = &recovery_key.key {
recovery_key.duplicate_key = duplicate_keys.contains(key);
for path in &mut self.recovery_paths {
for recovery_key in path.keys.iter_mut() {
if let Some(key) = &recovery_key.key {
recovery_key.duplicate_key = duplicate_keys.contains(key);
}
}
}
}
@ -161,9 +199,11 @@ impl DefineDescriptor {
spending_key.name = name.clone();
}
}
for recovery_key in &mut self.recovery_keys {
if recovery_key.key.as_ref().map(|k| k.master_fingerprint()) == Some(fingerprint) {
recovery_key.name = name.clone();
for path in &mut self.recovery_paths {
for recovery_key in &mut path.keys {
if recovery_key.key.as_ref().map(|k| k.master_fingerprint()) == Some(fingerprint) {
recovery_key.name = name.clone();
}
}
}
}
@ -199,7 +239,10 @@ impl DefineDescriptor {
}
};
update_mapping(&self.spending_keys, &mut mapping);
update_mapping(&self.recovery_keys, &mut mapping);
for path in &self.recovery_paths {
update_mapping(&path.keys, &mut mapping);
}
mapping
}
@ -210,9 +253,11 @@ impl DefineDescriptor {
map.insert(key.master_fingerprint(), spending_key.name.clone());
}
}
for recovery_key in &self.recovery_keys {
if let Some(key) = recovery_key.key.as_ref() {
map.insert(key.master_fingerprint(), recovery_key.name.clone());
for path in &self.recovery_paths {
for recovery_key in &path.keys {
if let Some(key) = recovery_key.key.as_ref() {
map.insert(key.master_fingerprint(), recovery_key.name.clone());
}
}
}
map
@ -229,108 +274,146 @@ impl Step for DefineDescriptor {
self.modal = None;
}
Message::Network(network) => self.set_network(network),
Message::DefineDescriptor(msg) => {
match msg {
message::DefineDescriptor::ThresholdEdited(is_recovery, value) => {
if is_recovery {
self.recovery_threshold = value;
} else {
self.spending_threshold = value;
}
}
message::DefineDescriptor::SequenceEdited(seq) => {
self.sequence.valid = true;
if seq.is_empty() || seq.parse::<u16>().is_ok() {
self.sequence.value = seq;
}
}
message::DefineDescriptor::AddKey(is_recovery) => {
if is_recovery {
self.recovery_keys.push(DescriptorKey::default());
self.recovery_threshold += 1;
} else {
self.spending_keys.push(DescriptorKey::default());
self.spending_threshold += 1;
}
}
message::DefineDescriptor::Key(is_recovery, i, msg) => match msg {
message::DefineKey::Clipboard(key) => {
return Command::perform(async move { key }, Message::Clibpboard);
}
message::DefineKey::Edited(name, imported_key) => {
self.edit_alias_for_key_with_same_fingerprint(
name.clone(),
imported_key.master_fingerprint(),
);
if is_recovery {
if let Some(recovery_key) = self.recovery_keys.get_mut(i) {
recovery_key.name = name;
recovery_key.key = Some(imported_key);
recovery_key.check_network(self.network);
}
} else if let Some(spending_key) = self.spending_keys.get_mut(i) {
spending_key.name = name;
spending_key.key = Some(imported_key);
spending_key.check_network(self.network);
}
self.modal = None;
self.check_for_duplicate();
}
message::DefineKey::Edit => {
if is_recovery {
if let Some(recovery_key) = self.recovery_keys.get(i) {
let modal = EditXpubModal::new(
recovery_key.name.clone(),
recovery_key.key.as_ref(),
i,
is_recovery,
self.network,
self.fingerprint_account_index_mappping(),
self.keys_aliases(),
self.signer.clone(),
);
let cmd = modal.load();
self.modal = Some(Box::new(modal));
return cmd;
}
} else if let Some(spending_key) = self.spending_keys.get(i) {
let modal = EditXpubModal::new(
spending_key.name.clone(),
spending_key.key.as_ref(),
i,
is_recovery,
self.network,
self.fingerprint_account_index_mappping(),
self.keys_aliases(),
self.signer.clone(),
);
let cmd = modal.load();
self.modal = Some(Box::new(modal));
return cmd;
}
}
message::DefineKey::Delete => {
if is_recovery {
self.recovery_keys.remove(i);
if self.recovery_threshold > self.recovery_keys.len() {
self.recovery_threshold -= 1;
}
} else {
self.spending_keys.remove(i);
if self.spending_threshold > self.spending_keys.len() {
self.spending_threshold -= 1;
}
}
self.check_for_duplicate();
}
},
_ => {
if let Some(modal) = &mut self.modal {
return modal.update(Message::DefineDescriptor(msg));
}
}
};
Message::DefineDescriptor(message::DefineDescriptor::AddRecoveryPath) => {
self.recovery_paths.push(RecoveryPath::new());
}
Message::DefineDescriptor(message::DefineDescriptor::PrimaryPath(msg)) => match msg {
message::DefinePath::ThresholdEdited(value) => {
self.spending_threshold = value;
}
message::DefinePath::AddKey => {
self.spending_keys.push(DescriptorKey::default());
self.spending_threshold += 1;
}
message::DefinePath::Key(i, msg) => match msg {
message::DefineKey::Clipboard(key) => {
return Command::perform(async move { key }, Message::Clibpboard);
}
message::DefineKey::Edited(name, imported_key) => {
self.edit_alias_for_key_with_same_fingerprint(
name.clone(),
imported_key.master_fingerprint(),
);
if let Some(spending_key) = self.spending_keys.get_mut(i) {
spending_key.name = name;
spending_key.key = Some(imported_key);
spending_key.check_network(self.network);
}
self.modal = None;
self.check_for_duplicate();
}
message::DefineKey::Edit => {
if let Some(spending_key) = self.spending_keys.get(i) {
let modal = EditXpubModal::new(
spending_key.name.clone(),
spending_key.key.as_ref(),
None,
i,
self.network,
self.fingerprint_account_index_mappping(),
self.keys_aliases(),
self.signer.clone(),
);
let cmd = modal.load();
self.modal = Some(Box::new(modal));
return cmd;
}
}
message::DefineKey::Delete => {
self.spending_keys.remove(i);
if self.spending_threshold > self.spending_keys.len() {
self.spending_threshold -= 1;
}
self.check_for_duplicate();
}
},
_ => {}
},
Message::DefineDescriptor(message::DefineDescriptor::RecoveryPath(i, msg)) => match msg
{
message::DefinePath::ThresholdEdited(value) => {
if let Some(path) = self.recovery_paths.get_mut(i) {
path.threshold = value;
}
}
message::DefinePath::SequenceEdited(seq) => {
self.modal = None;
if let Some(path) = self.recovery_paths.get_mut(i) {
path.sequence = seq;
}
}
message::DefinePath::EditSequence => {
if let Some(path) = self.recovery_paths.get(i) {
self.modal = Some(Box::new(EditSequenceModal::new(i, path.sequence)));
}
}
message::DefinePath::AddKey => {
if let Some(path) = self.recovery_paths.get_mut(i) {
path.keys.push(DescriptorKey::default());
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(name, imported_key) => {
self.edit_alias_for_key_with_same_fingerprint(
name.clone(),
imported_key.master_fingerprint(),
);
if let Some(key) = self
.recovery_paths
.get_mut(i)
.and_then(|path| path.keys.get_mut(j))
{
key.name = name;
key.key = Some(imported_key);
key.check_network(self.network);
}
self.modal = None;
self.check_for_duplicate();
}
message::DefineKey::Edit => {
if let Some(key) =
self.recovery_paths.get(i).and_then(|path| path.keys.get(j))
{
let modal = EditXpubModal::new(
key.name.clone(),
key.key.as_ref(),
Some(i),
j,
self.network,
self.fingerprint_account_index_mappping(),
self.keys_aliases(),
self.signer.clone(),
);
let cmd = modal.load();
self.modal = Some(Box::new(modal));
return cmd;
}
}
message::DefineKey::Delete => {
if let Some(path) = self.recovery_paths.get_mut(i) {
path.keys.remove(j);
if path.threshold > path.keys.len() {
path.threshold -= 1;
}
}
if self
.recovery_paths
.get(i)
.map(|path| path.keys.is_empty())
.unwrap_or(false)
{
self.recovery_paths.remove(i);
}
self.check_for_duplicate();
}
},
},
_ => {
if let Some(modal) = &mut self.modal {
return modal.update(message);
@ -375,40 +458,45 @@ impl Step for DefineDescriptor {
}
}
let mut recovery_keys: Vec<DescriptorPublicKey> = Vec::new();
for recovery_key in self.recovery_keys.iter().clone() {
if let Some(DescriptorPublicKey::XPub(xpub)) = recovery_key.key.as_ref() {
if let Some((master_fingerprint, _)) = xpub.origin {
ctx.keys.push(KeySetting {
master_fingerprint,
name: recovery_key.name.clone(),
});
if master_fingerprint == self.signer.fingerprint() {
signer_is_used = true;
let mut recovery_paths = BTreeMap::new();
for path in self.recovery_paths.iter_mut() {
let mut recovery_keys: Vec<DescriptorPublicKey> = Vec::new();
for recovery_key in path.keys.iter().clone() {
if let Some(DescriptorPublicKey::XPub(xpub)) = recovery_key.key.as_ref() {
if let Some((master_fingerprint, _)) = xpub.origin {
ctx.keys.push(KeySetting {
master_fingerprint,
name: recovery_key.name.clone(),
});
if master_fingerprint == self.signer.fingerprint() {
signer_is_used = true;
}
}
let xpub = DescriptorMultiXKey {
origin: xpub.origin.clone(),
xkey: xpub.xkey,
derivation_paths: DerivPaths::new(vec![
DerivationPath::from_str("m/0").unwrap(),
DerivationPath::from_str("m/1").unwrap(),
])
.unwrap(),
wildcard: Wildcard::Unhardened,
};
recovery_keys.push(DescriptorPublicKey::MultiXPub(xpub));
}
let xpub = DescriptorMultiXKey {
origin: xpub.origin.clone(),
xkey: xpub.xkey,
derivation_paths: DerivPaths::new(vec![
DerivationPath::from_str("m/0").unwrap(),
DerivationPath::from_str("m/1").unwrap(),
])
.unwrap(),
wildcard: Wildcard::Unhardened,
};
recovery_keys.push(DescriptorPublicKey::MultiXPub(xpub));
}
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);
}
let sequence = self.sequence.value.parse::<u16>();
self.sequence.valid = sequence.is_ok();
if !self.network_valid
|| !self.sequence.valid
|| recovery_keys.is_empty()
|| spending_keys.is_empty()
{
if !self.network_valid || spending_keys.is_empty() {
return false;
}
@ -418,13 +506,7 @@ impl Step for DefineDescriptor {
PathInfo::Multi(self.spending_threshold, spending_keys)
};
let recovery_keys = if recovery_keys.len() == 1 {
PathInfo::Single(recovery_keys[0].clone())
} else {
PathInfo::Multi(self.recovery_threshold, recovery_keys)
};
let policy = match LianaPolicy::new(spending_keys, recovery_keys, sequence.unwrap()) {
let policy = match LianaPolicy::new(spending_keys, recovery_paths) {
Ok(policy) => policy,
Err(e) => {
self.error = Some(e.to_string());
@ -449,22 +531,22 @@ impl Step for DefineDescriptor {
.enumerate()
.map(|(i, key)| {
key.view().map(move |msg| {
Message::DefineDescriptor(message::DefineDescriptor::Key(false, i, msg))
Message::DefineDescriptor(message::DefineDescriptor::PrimaryPath(
message::DefinePath::Key(i, msg),
))
})
})
.collect(),
self.recovery_keys
self.spending_threshold,
self.recovery_paths
.iter()
.enumerate()
.map(|(i, key)| {
key.view().map(move |msg| {
Message::DefineDescriptor(message::DefineDescriptor::Key(true, i, msg))
.map(|(i, path)| {
path.view().map(move |msg| {
Message::DefineDescriptor(message::DefineDescriptor::RecoveryPath(i, msg))
})
})
.collect(),
&self.sequence,
self.spending_threshold,
self.recovery_threshold,
self.valid(),
self.error.as_ref(),
);
@ -554,8 +636,69 @@ impl From<DefineDescriptor> for Box<dyn Step> {
}
}
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, message: Message) -> Command<Message> {
if let Message::DefineDescriptor(message::DefineDescriptor::SequenceModal(msg)) = message {
match msg {
message::SequenceModal::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::SequenceModal::ConfirmSequence => {
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::RecoveryPath(
path_index,
message::DefinePath::SequenceEdited(sequence),
)
},
)
.map(Message::DefineDescriptor);
}
}
}
}
}
Command::none()
}
fn view(&self) -> Element<Message> {
view::edit_sequence_modal(&self.sequence)
}
}
pub struct EditXpubModal {
is_recovery: bool,
/// None if path is primary path
path_index: Option<usize>,
key_index: usize,
network: Network,
error: Option<Error>,
@ -580,8 +723,8 @@ impl EditXpubModal {
fn new(
name: String,
key: Option<&DescriptorPublicKey>,
path_index: Option<usize>,
key_index: usize,
is_recovery: bool,
network: Network,
account_indexes: HashMap<Fingerprint, ChildNumber>,
keys_aliases: HashMap<Fingerprint, String>,
@ -598,7 +741,7 @@ impl EditXpubModal {
},
keys_aliases,
account_indexes,
is_recovery,
path_index,
key_index,
chosen_hw: None,
processing: false,
@ -618,7 +761,7 @@ impl EditXpubModal {
}
}
impl DescriptorKeyModal for EditXpubModal {
impl DescriptorEditModal for EditXpubModal {
fn processing(&self) -> bool {
self.processing
}
@ -647,8 +790,8 @@ impl DescriptorKeyModal for EditXpubModal {
generate_derivation_path(self.network, account_index),
),
|res| {
Message::DefineDescriptor(message::DefineDescriptor::HWXpubImported(
res,
Message::DefineDescriptor(message::DefineDescriptor::KeyModal(
message::ImportKeyModal::HWXpubImported(res),
))
},
);
@ -693,71 +836,89 @@ impl DescriptorKeyModal for EditXpubModal {
self.signer.get_extended_pubkey(&derivation_path)
);
}
Message::DefineDescriptor(message::DefineDescriptor::HWXpubImported(res)) => {
self.processing = false;
match res {
Ok(key) => {
if let Some(alias) = self.keys_aliases.get(&key.master_fingerprint()) {
self.form_name.valid = true;
self.form_name.value = alias.clone();
self.edit_name = false;
} else {
self.edit_name = true;
Message::DefineDescriptor(message::DefineDescriptor::KeyModal(msg)) => match msg {
message::ImportKeyModal::HWXpubImported(res) => {
self.processing = false;
match res {
Ok(key) => {
if let Some(alias) = self.keys_aliases.get(&key.master_fingerprint()) {
self.form_name.valid = true;
self.form_name.value = alias.clone();
self.edit_name = false;
} else {
self.edit_name = true;
}
self.chosen_signer = false;
self.form_xpub.valid = true;
self.form_xpub.value = key.to_string();
}
Err(e) => {
self.chosen_hw = None;
self.error = Some(e);
}
self.chosen_signer = false;
self.form_xpub.valid = true;
self.form_xpub.value = key.to_string();
}
Err(e) => {
self.chosen_hw = None;
self.error = Some(e);
}
}
}
Message::DefineDescriptor(message::DefineDescriptor::EditName) => {
self.edit_name = true;
}
Message::DefineDescriptor(message::DefineDescriptor::NameEdited(name)) => {
self.form_name.valid = true;
self.form_name.value = name;
}
Message::DefineDescriptor(message::DefineDescriptor::XPubEdited(s)) => {
if let Ok(DescriptorPublicKey::XPub(key)) = DescriptorPublicKey::from_str(&s) {
if let Some((fingerprint, _)) = key.origin {
self.form_xpub.valid = true;
if let Some(alias) = self.keys_aliases.get(&fingerprint) {
self.form_name.valid = true;
self.form_name.value = alias.clone();
self.edit_name = false;
message::ImportKeyModal::EditName => {
self.edit_name = true;
}
message::ImportKeyModal::NameEdited(name) => {
self.form_name.valid = true;
self.form_name.value = name;
}
message::ImportKeyModal::XPubEdited(s) => {
if let Ok(DescriptorPublicKey::XPub(key)) = DescriptorPublicKey::from_str(&s) {
if let Some((fingerprint, _)) = key.origin {
self.form_xpub.valid = true;
if let Some(alias) = self.keys_aliases.get(&fingerprint) {
self.form_name.valid = true;
self.form_name.value = alias.clone();
self.edit_name = false;
} else {
self.edit_name = true;
}
} else {
self.edit_name = true;
self.form_xpub.valid = false;
}
} else {
self.form_xpub.valid = false;
}
} else {
self.form_xpub.valid = false;
self.form_xpub.value = s;
}
self.form_xpub.value = s;
}
Message::DefineDescriptor(message::DefineDescriptor::ConfirmXpub) => {
if let Ok(key) = DescriptorPublicKey::from_str(&self.form_xpub.value) {
let key_index = self.key_index;
let is_recovery = self.is_recovery;
let name = self.form_name.value.clone();
return Command::perform(
async move { (is_recovery, key_index, key) },
|(is_recovery, key_index, key)| {
message::DefineDescriptor::Key(
is_recovery,
key_index,
message::DefineKey::Edited(name, key),
message::ImportKeyModal::ConfirmXpub => {
if let Ok(key) = DescriptorPublicKey::from_str(&self.form_xpub.value) {
let key_index = self.key_index;
let name = self.form_name.value.clone();
if let Some(path_index) = self.path_index {
return Command::perform(
async move { (path_index, key_index, key) },
|(path_index, key_index, key)| {
message::DefineDescriptor::RecoveryPath(
path_index,
message::DefinePath::Key(
key_index,
message::DefineKey::Edited(name, key),
),
)
},
)
},
)
.map(Message::DefineDescriptor);
.map(Message::DefineDescriptor);
} else {
return Command::perform(
async move { (key_index, key) },
|(key_index, key)| {
message::DefineDescriptor::PrimaryPath(
message::DefinePath::Key(
key_index,
message::DefineKey::Edited(name, key),
),
)
},
)
.map(Message::DefineDescriptor);
}
}
}
}
},
_ => {}
};
Command::none()
@ -1318,22 +1479,25 @@ mod tests {
// Edit primary key
sandbox
.update(Message::DefineDescriptor(message::DefineDescriptor::Key(
false,
0,
message::DefineKey::Edit,
)))
.update(Message::DefineDescriptor(
message::DefineDescriptor::PrimaryPath(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::NameEdited("hot signer key".to_string()),
message::DefineDescriptor::KeyModal(message::ImportKeyModal::NameEdited(
"hot signer key".to_string(),
)),
))
.await;
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::ConfirmXpub,
message::DefineDescriptor::KeyModal(message::ImportKeyModal::ConfirmXpub),
))
.await;
sandbox.check(|step| assert!(step.modal.is_none()));
@ -1341,30 +1505,38 @@ mod tests {
// Edit sequence
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::SequenceEdited("1000".to_string()),
message::DefineDescriptor::RecoveryPath(
0,
message::DefinePath::SequenceEdited(1000),
),
))
.await;
// Edit recovery key
sandbox
.update(Message::DefineDescriptor(message::DefineDescriptor::Key(
true,
0,
message::DefineKey::Edit,
)))
.update(Message::DefineDescriptor(
message::DefineDescriptor::RecoveryPath(
0,
message::DefinePath::Key(0, message::DefineKey::Edit),
),
))
.await;
sandbox.check(|step| assert!(step.modal.is_some()));
sandbox.update(Message::DefineDescriptor(
message::DefineDescriptor::XPubEdited("[f5acc2fd/48'/1'/0'/2']tpubDFAqEGNyad35aBCKUAXbQGDjdVhNueno5ZZVEn3sQbW5ci457gLR7HyTmHBg93oourBssgUxuWz1jX5uhc1qaqFo9VsybY1J5FuedLfm4dK".to_string()),
message::DefineDescriptor::KeyModal(
message::ImportKeyModal::XPubEdited("[f5acc2fd/48'/1'/0'/2']tpubDFAqEGNyad35aBCKUAXbQGDjdVhNueno5ZZVEn3sQbW5ci457gLR7HyTmHBg93oourBssgUxuWz1jX5uhc1qaqFo9VsybY1J5FuedLfm4dK".to_string()),
)
)).await;
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::NameEdited("external recovery key".to_string()),
message::DefineDescriptor::KeyModal(message::ImportKeyModal::NameEdited(
"External recovery key".to_string(),
)),
))
.await;
sandbox
.update(Message::DefineDescriptor(
message::DefineDescriptor::ConfirmXpub,
message::DefineDescriptor::KeyModal(message::ImportKeyModal::ConfirmXpub),
))
.await;
sandbox.check(|step| {

View File

@ -136,8 +136,10 @@ impl Step for RecoverMnemonic {
for (fingerprint, _) in info.primary_path().thresh_origins().1.iter() {
descriptor_keys.insert(*fingerprint);
}
for (fingerprint, _) in info.recovery_path().1.thresh_origins().1.iter() {
descriptor_keys.insert(*fingerprint);
for (_, path) in info.recovery_paths().iter() {
for (fingerprint, _) in path.thresh_origins().1.iter() {
descriptor_keys.insert(*fingerprint);
}
}
if !descriptor_keys.contains(&fingerprint) {
self.error =

View File

@ -1,9 +1,9 @@
use iced::widget::{
checkbox, container, pick_list, scrollable, scrollable::Properties, Space, TextInput,
checkbox, container, pick_list, scrollable, scrollable::Properties, slider, Space, TextInput,
};
use iced::{alignment, Alignment, Length};
use std::collections::HashSet;
use std::{collections::HashSet, str::FromStr};
use liana::miniscript::bitcoin;
use liana_ui::{
@ -142,10 +142,8 @@ pub fn define_descriptor<'a>(
network: bitcoin::Network,
network_valid: bool,
spending_keys: Vec<Element<'a, Message>>,
recovery_keys: Vec<Element<'a, Message>>,
sequence: &form::Value<String>,
spending_threshold: usize,
recovery_threshold: usize,
recovery_paths: Vec<Element<'a, Message>>,
valid: bool,
error: Option<&String>,
) -> Element<'a, Message> {
@ -178,151 +176,58 @@ pub fn define_descriptor<'a>(
.push(
Row::new()
.spacing(10)
.push(Space::with_width(Length::Units(40)))
.push(Space::with_width(Length::Units(5)))
.push(text("Primary path:").bold())
.push(tooltip(prompt::DEFINE_DESCRIPTOR_PRIMATRY_PATH_TOOLTIP)),
.push(tooltip(prompt::DEFINE_DESCRIPTOR_PRIMARY_PATH_TOOLTIP)),
)
.push(separation().width(Length::Fill))
.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::ThresholdEdited(false, 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::Units(200))
.height(Length::Units(200))
.align_y(alignment::Vertical::Center)
.align_x(alignment::Horizontal::Center),
)
.width(Length::Units(200))
.height(Length::Units(200))
.style(theme::Button::TransparentBorder)
.on_press(
Message::DefineDescriptor(
message::DefineDescriptor::AddKey(false),
),
),
)
.padding(5),
)
.horizontal_scroll(Properties::new().width(3).scroller_width(3)),
),
)
.width(Length::Fill)
.align_x(alignment::Horizontal::Center),
)
.spacing(10);
let col_recovery_keys = Column::new()
.push(
.push(Container::new(
Row::new()
.push(Space::with_width(Length::Units(50)))
.push(text("Recovery path:").bold()),
)
.push(separation().width(Length::Fill))
.push(
Container::new(
Row::new()
.align_items(Alignment::Center)
.push_maybe(if recovery_keys.len() > 1 {
Some(threshsold_input::threshsold_input(
recovery_threshold,
recovery_keys.len(),
|value| {
Message::DefineDescriptor(
message::DefineDescriptor::ThresholdEdited(true, value),
.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::Units(150))
.height(Length::Units(150))
.align_y(alignment::Vertical::Center)
.align_x(alignment::Horizontal::Center),
)
},
))
} 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::Units(200))
.height(Length::Units(200))
.align_y(alignment::Vertical::Center)
.align_x(alignment::Horizontal::Center),
)
.width(Length::Units(200))
.height(Length::Units(200))
.style(theme::Button::TransparentBorder)
.on_press(
Message::DefineDescriptor(
message::DefineDescriptor::AddKey(true),
.width(Length::Units(150))
.height(Length::Units(150))
.style(theme::Button::TransparentBorder)
.on_press(
Message::DefineDescriptor(
message::DefineDescriptor::PrimaryPath(
message::DefinePath::AddKey,
),
),
)
.padding(5),
)
.horizontal_scroll(Properties::new().width(3).scroller_width(3)),
),
)
.width(Length::Fill)
.align_x(alignment::Horizontal::Center),
)
),
)
.padding(5),
)
.horizontal_scroll(Properties::new().width(3).scroller_width(3)),
),
))
.spacing(10);
let col_sequence = Container::new(
Row::new()
.spacing(50)
.align_items(Alignment::Center)
.push(Container::new(icon::arrow_down().size(50)).align_x(alignment::Horizontal::Right))
.push(
Column::new()
.push(
Row::new()
.spacing(10)
.push(text("Blocks before recovery:").bold())
.push(tooltip(prompt::DEFINE_DESCRIPTOR_SEQUENCE_TOOLTIP)),
)
.push(
Container::new(
form::Form::new("Number of blocks", sequence, |msg| {
Message::DefineDescriptor(
message::DefineDescriptor::SequenceEdited(msg),
)
})
.warning("Please enter correct block number")
.size(20)
.padding(10),
)
.width(Length::Units(150)),
)
.spacing(10),
)
.padding(20),
)
.width(Length::Fill)
.align_x(alignment::Horizontal::Center);
layout(
progress,
Column::new()
@ -330,19 +235,38 @@ pub fn define_descriptor<'a>(
.push(text("Create the wallet").bold().size(50))
.push(
Column::new()
.width(Length::Fill)
.align_items(Alignment::Center)
.push(row_network)
.push(col_spending_keys)
.push(col_sequence)
.push(col_recovery_keys)
.push(
Column::new()
.spacing(25)
.push(col_spending_keys)
.push(
Row::new()
.spacing(10)
.push(Space::with_width(Length::Units(5)))
.push(text("Recovery paths:").bold())
.push(tooltip(prompt::DEFINE_DESCRIPTOR_RECOVERY_PATH_TOOLTIP)),
)
.push(Column::with_children(recovery_paths).spacing(10)),
)
.spacing(25),
)
.push(if !valid {
button::primary(None, "Next").width(Length::Units(200))
} else {
button::primary(None, "Next")
.width(Length::Units(200))
.on_press(Message::Next)
})
.push(
Row::new()
.spacing(10)
.push(button::border(None, "Add a recovery path").on_press(
Message::DefineDescriptor(message::DefineDescriptor::AddRecoveryPath),
))
.push(if !valid {
button::primary(None, "Next").width(Length::Units(200))
} else {
button::primary(None, "Next")
.width(Length::Units(200))
.on_press(Message::Next)
}),
)
.push_maybe(error.map(|e| card::error("Failed to create descriptor", e.to_string())))
.push(Space::with_height(Length::Units(20)))
.width(Length::Fill)
@ -352,6 +276,54 @@ pub fn define_descriptor<'a>(
)
}
pub fn recovery_path_view(
sequence: u16,
recovery_threshold: usize,
recovery_keys: Vec<Element<message::DefinePath>>,
) -> Element<message::DefinePath> {
Container::new(
Column::new().push(defined_sequence(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::Units(150))
.height(Length::Units(150))
.align_y(alignment::Vertical::Center)
.align_x(alignment::Horizontal::Center),
)
.width(Length::Units(150))
.height(Length::Units(150))
.style(theme::Button::TransparentBorder)
.on_press(message::DefinePath::AddKey),
)
.padding(5),
)
.horizontal_scroll(Properties::new().width(3).scroller_width(3)),
),
),
)
.padding(5)
.style(theme::Container::Card(theme::Card::Border))
.into()
}
pub fn import_descriptor<'a>(
progress: (usize, usize),
change_network: bool,
@ -1058,6 +1030,51 @@ pub fn install<'a>(
)
}
pub fn defined_sequence<'a>(sequence: u16) -> Element<'a, message::DefinePath> {
let (n_years, n_months, n_days, n_hours, n_minutes) = duration_from_sequence(sequence);
Container::new(
Row::new()
.width(Length::Fill)
.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::border(Some(icon::pencil_icon()), "Edit")
.on_press(message::DefinePath::EditSequence),
)
.spacing(15),
)
.padding(5)
.into()
}
pub fn undefined_descriptor_key<'a>() -> Element<'a, message::DefineKey> {
card::simple(
Column::new()
@ -1079,18 +1096,10 @@ pub fn undefined_descriptor_key<'a>() -> Element<'a, message::DefineKey> {
.spacing(15)
.align_items(Alignment::Center)
.push(
scrollable(
icon::key_icon()
.style(color::DARK_GREY)
.size(50)
.width(Length::Units(50)),
)
.horizontal_scroll(Properties::new().width(2).scroller_width(2)),
)
.push(
icon::circle_check_icon()
.style(color::legacy::FOREGROUND)
.size(50),
icon::key_icon()
.style(color::DARK_GREY)
.size(30)
.width(Length::Units(50)),
),
)
.height(Length::Fill)
@ -1102,8 +1111,8 @@ pub fn undefined_descriptor_key<'a>() -> Element<'a, message::DefineKey> {
.push(Space::with_height(Length::Units(5))),
)
.padding(5)
.height(Length::Units(200))
.width(Length::Units(200))
.height(Length::Units(150))
.width(Length::Units(150))
.into()
}
@ -1133,7 +1142,7 @@ pub fn defined_descriptor_key(
.push(
Container::new(
Column::new()
.spacing(15)
.spacing(5)
.align_items(Alignment::Center)
.push(
scrollable(text(name).bold()).horizontal_scroll(
@ -1143,7 +1152,7 @@ pub fn defined_descriptor_key(
.push(
icon::circle_check_icon()
.style(color::legacy::SUCCESS)
.size(40)
.size(20)
.width(Length::Units(50)),
),
)
@ -1161,8 +1170,8 @@ pub fn defined_descriptor_key(
.push(
card::invalid(col)
.padding(5)
.height(Length::Units(200))
.width(Length::Units(200)),
.height(Length::Units(150))
.width(Length::Units(150)),
)
.push(
text("Key is for a different network")
@ -1176,8 +1185,8 @@ pub fn defined_descriptor_key(
.push(
card::invalid(col)
.padding(5)
.height(Length::Units(200))
.width(Length::Units(200)),
.height(Length::Units(150))
.width(Length::Units(150)),
)
.push(text("Duplicate key").small().style(color::legacy::ALERT))
.into()
@ -1187,16 +1196,16 @@ pub fn defined_descriptor_key(
.push(
card::invalid(col)
.padding(5)
.height(Length::Units(200))
.width(Length::Units(200)),
.height(Length::Units(150))
.width(Length::Units(150)),
)
.push(text("Duplicate name").small().style(color::legacy::ALERT))
.into()
} else {
card::simple(col)
.padding(5)
.height(Length::Units(200))
.width(Length::Units(200))
.height(Length::Units(150))
.width(Length::Units(150))
.into()
}
}
@ -1286,7 +1295,7 @@ pub fn edit_key_modal<'a>(
.push(
form::Form::new("Extended public key", form_xpub, |msg| {
Message::DefineDescriptor(
message::DefineDescriptor::XPubEdited(msg),
message::DefineDescriptor::KeyModal(message::ImportKeyModal::XPubEdited(msg)),
)
})
.warning(if network == bitcoin::Network::Bitcoin {
@ -1320,7 +1329,9 @@ pub fn edit_key_modal<'a>(
.push(text(&form_name.value)),
)
.push(button::border(Some(icon::pencil_icon()), "Edit").on_press(
Message::DefineDescriptor(message::DefineDescriptor::EditName),
Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(message::ImportKeyModal::EditName),
)
)),
)
} else if !form_xpub.value.is_empty() && form_xpub.valid {
@ -1335,8 +1346,8 @@ pub fn edit_key_modal<'a>(
.push(
form::Form::new("Alias", form_name, |msg| {
Message::DefineDescriptor(
message::DefineDescriptor::NameEdited(msg),
)
message::DefineDescriptor::KeyModal(message::ImportKeyModal::NameEdited(msg)),
)
})
.warning("Please enter correct alias")
.size(20)
@ -1350,9 +1361,11 @@ pub fn edit_key_modal<'a>(
if form_xpub.valid && !form_xpub.value.is_empty() && !form_name.value.is_empty()
{
button::primary(None, "Apply")
.on_press(Message::DefineDescriptor(
message::DefineDescriptor::ConfirmXpub,
))
.on_press(
Message::DefineDescriptor(
message::DefineDescriptor::KeyModal(message::ImportKeyModal::ConfirmXpub),
)
)
.width(Length::Units(200))
} else {
button::primary(None, "Apply").width(Length::Units(100))
@ -1364,6 +1377,92 @@ pub fn edit_key_modal<'a>(
.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("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::Units(200)),
)
.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()),
))
}))
.width(Length::Units(500)),
);
}
}
card::simple(col.push(if sequence.valid {
button::primary(None, "Apply")
.on_press(Message::DefineDescriptor(
message::DefineDescriptor::SequenceModal(message::SequenceModal::ConfirmSequence),
))
.width(Length::Units(200))
} else {
button::primary(None, "Apply").width(Length::Units(200))
}))
.width(Length::Units(800))
.into()
}
fn hw_list_view(
i: usize,
hw: &HardwareWallet,
@ -1691,16 +1790,14 @@ mod threshsold_input {
};
Column::new()
.height(Length::Units(200))
.width(Length::Units(100))
.push(button(icon::up_icon().size(40), Event::IncrementPressed))
.width(Length::Units(150))
.push(button(icon::up_icon().size(30), Event::IncrementPressed))
.push(text("Threshold:").small().bold())
.push(
Container::new(text(format!("{}/{}", self.value, self.max)).size(50))
.height(Length::Fill)
Container::new(text(format!("{}/{}", self.value, self.max)).size(30))
.align_y(alignment::Vertical::Center),
)
.push(button(icon::down_icon().size(40), Event::DecrementPressed))
.push(button(icon::down_icon().size(30), Event::DecrementPressed))
.align_items(Alignment::Center)
.into()
}

View File

@ -18,6 +18,14 @@ pub fn arrow_down() -> Text<'static> {
icon('\u{F128}')
}
pub fn arrow_right() -> Text<'static> {
icon('\u{F138}')
}
pub fn arrow_return_right() -> Text<'static> {
icon('\u{F132}')
}
pub fn chevron_right() -> Text<'static> {
icon('\u{F285}')
}

View File

@ -1,7 +1,8 @@
use iced::{
application,
widget::{
button, checkbox, container, pick_list, progress_bar, radio, scrollable, text, text_input,
button, checkbox, container, pick_list, progress_bar, radio, scrollable, slider, text,
text_input,
},
};
@ -210,6 +211,7 @@ impl From<Pill> for Container {
pub enum Card {
#[default]
Simple,
Border,
Invalid,
Warning,
Error,
@ -223,6 +225,13 @@ impl Card {
background: color::GREY.into(),
..container::Appearance::default()
},
Card::Border => container::Appearance {
background: iced::Color::TRANSPARENT.into(),
border_radius: 10.0,
border_color: color::GREY,
border_width: 1.0,
..container::Appearance::default()
},
Card::Invalid => container::Appearance {
background: color::GREY.into(),
text_color: color::BLACK.into(),
@ -248,6 +257,13 @@ impl Card {
background: color::LIGHT_BLACK.into(),
..container::Appearance::default()
},
Card::Border => container::Appearance {
background: iced::Color::TRANSPARENT.into(),
border_radius: 10.0,
border_color: color::LIGHT_GREY,
border_width: 1.0,
..container::Appearance::default()
},
Card::Invalid => container::Appearance {
background: color::LIGHT_BLACK.into(),
text_color: color::BLACK.into(),
@ -276,6 +292,13 @@ impl Card {
border_width: 1.0,
..container::Appearance::default()
},
Card::Border => container::Appearance {
background: iced::Color::TRANSPARENT.into(),
border_radius: 10.0,
border_color: color::legacy::BORDER_GREY,
border_width: 1.0,
..container::Appearance::default()
},
Card::Invalid => container::Appearance {
background: color::legacy::FOREGROUND.into(),
text_color: iced::Color::BLACK.into(),
@ -791,3 +814,58 @@ impl progress_bar::StyleSheet for Theme {
}
}
}
#[derive(Debug, Copy, Clone, Default)]
pub enum Slider {
#[default]
Simple,
}
impl slider::StyleSheet for Theme {
type Style = Slider;
fn active(&self, _style: &Self::Style) -> slider::Appearance {
let handle = slider::Handle {
shape: slider::HandleShape::Rectangle {
width: 8,
border_radius: 4.0,
},
color: color::legacy::FOREGROUND,
border_color: color::GREEN,
border_width: 1.0,
};
slider::Appearance {
rail_colors: (color::GREEN, iced::Color::TRANSPARENT),
handle,
}
}
fn hovered(&self, _style: &Self::Style) -> slider::Appearance {
let handle = slider::Handle {
shape: slider::HandleShape::Rectangle {
width: 8,
border_radius: 4.0,
},
color: color::GREEN,
border_color: color::GREEN,
border_width: 1.0,
};
slider::Appearance {
rail_colors: (color::GREEN, iced::Color::TRANSPARENT),
handle,
}
}
fn dragging(&self, _style: &Self::Style) -> slider::Appearance {
let handle = slider::Handle {
shape: slider::HandleShape::Rectangle {
width: 8,
border_radius: 4.0,
},
color: color::GREEN,
border_color: color::GREEN,
border_width: 1.0,
};
slider::Appearance {
rail_colors: (color::GREEN, iced::Color::TRANSPARENT),
handle,
}
}
}