From ce23bcf498d2284ed389e7b229e63aeaa0c96e91 Mon Sep 17 00:00:00 2001 From: edouard Date: Fri, 31 Mar 2023 15:01:25 +0200 Subject: [PATCH] gui: add path selection to recovery panel --- gui/src/app/mod.rs | 3 +- gui/src/app/state/recovery.rs | 120 ++++++++++++++++++++----------- gui/src/app/view/message.rs | 1 + gui/src/app/view/recovery.rs | 130 +++++++++++++++++++++++++++------- gui/src/daemon/client/mod.rs | 9 ++- gui/src/daemon/embedded.rs | 9 ++- gui/src/daemon/mod.rs | 7 +- 7 files changed, 207 insertions(+), 72 deletions(-) diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index c02a4c55..4e0be44f 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -77,8 +77,7 @@ impl App { menu::Menu::Recovery => RecoveryPanel::new( self.wallet.clone(), &self.cache.coins, - self.wallet.main_descriptor.first_timelock_value(), - self.cache.blockheight as u32, + self.cache.blockheight, ) .into(), menu::Menu::Receive => ReceivePanel::default().into(), diff --git a/gui/src/app/state/recovery.rs b/gui/src/app/state/recovery.rs index f2be2064..d22f4456 100644 --- a/gui/src/app/state/recovery.rs +++ b/gui/src/app/state/recovery.rs @@ -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, - locked_coins: (usize, Amount), - recoverable_coins: (usize, Amount), + recovery_paths: Vec, + selected_path: Option, warning: Option, feerate: form::Value, recipient: form::Value, generated: Option, - /// timelock value to pass for the heir to consume a coin. - timelock: u16, } impl RecoveryPanel { - pub fn new(wallet: Arc, coins: &[Coin], timelock: u16, 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, 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::().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 for Box { 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 { + 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() +} diff --git a/gui/src/app/view/message.rs b/gui/src/app/view/message.rs index e636d723..0c589f58 100644 --- a/gui/src/app/view/message.rs +++ b/gui/src/app/view/message.rs @@ -24,6 +24,7 @@ pub enum CreateSpendMessage { SelectCoin(usize), RecipientEdited(usize, &'static str, String), FeerateEdited(String), + SelectPath(usize), Generate, } diff --git a/gui/src/app/view/recovery.rs b/gui/src/app/view/recovery.rs index b03e40e5..3129a70b 100644 --- a/gui/src/app/view/recovery.rs +++ b/gui/src/app/view/recovery.rs @@ -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>, + selected_path: Option, feerate: &form::Value, address: &'a form::Value, ) -> 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, + 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() +} diff --git a/gui/src/daemon/client/mod.rs b/gui/src/daemon/client/mod.rs index 4f27777c..24342417 100644 --- a/gui/src/daemon/client/mod.rs +++ b/gui/src/daemon/client/mod.rs @@ -134,10 +134,15 @@ impl Daemon for Lianad { self.call("listtransactions", Some(vec![txids])) } - fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result { + fn create_recovery( + &self, + address: Address, + feerate_vb: u64, + sequence: Option, + ) -> Result { 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) } diff --git a/gui/src/daemon/embedded.rs b/gui/src/daemon/embedded.rs index 973c71f7..c01f4ac0 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -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 { + fn create_recovery( + &self, + address: Address, + feerate_vb: u64, + sequence: Option, + ) -> Result { self.handle .as_ref() .ok_or(DaemonError::NoAnswer)? .read() .unwrap() .control - .create_recovery(address, feerate_vb, None) + .create_recovery(address, feerate_vb, sequence) .map_err(|e| DaemonError::Unexpected(e.to_string())) .map(|res| res.psbt) } diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index d81716eb..aff5c012 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -68,7 +68,12 @@ pub trait Daemon: Debug { _end: u32, _limit: u64, ) -> Result; - fn create_recovery(&self, address: Address, feerate_vb: u64) -> Result; + fn create_recovery( + &self, + address: Address, + feerate_vb: u64, + sequence: Option, + ) -> Result; fn list_txs(&self, txid: &[Txid]) -> Result; fn list_spend_transactions(&self) -> Result, DaemonError> {