From 71541c69bae7f8f33659337ad66e7ca161cbcef4 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Wed, 16 Apr 2025 11:03:10 +0100 Subject: [PATCH 1/7] feat: support outpoints recovery parameter This changes the GUI daemon trait's create_recovery method to support passing an outpoints parameter. For now, however, this method is called with an empty outpoints slice, meaning that all recoverable coins will be used. --- liana-gui/src/app/state/recovery.rs | 2 +- liana-gui/src/daemon/client/mod.rs | 3 ++- liana-gui/src/daemon/embedded.rs | 3 ++- liana-gui/src/daemon/mod.rs | 1 + liana-gui/src/lianalite/client/backend/api.rs | 5 ++++- liana-gui/src/lianalite/client/backend/mod.rs | 2 ++ 6 files changed, 12 insertions(+), 4 deletions(-) diff --git a/liana-gui/src/app/state/recovery.rs b/liana-gui/src/app/state/recovery.rs index 3f50dedc..daa63b43 100644 --- a/liana-gui/src/app/state/recovery.rs +++ b/liana-gui/src/app/state/recovery.rs @@ -156,7 +156,7 @@ impl State for RecoveryPanel { return Task::perform( async move { let psbt = daemon - .create_recovery(address, feerate_vb, sequence) + .create_recovery(address, &[], feerate_vb, sequence) .await?; let outpoints: Vec<_> = psbt .unsigned_tx diff --git a/liana-gui/src/daemon/client/mod.rs b/liana-gui/src/daemon/client/mod.rs index d08abc5d..266dbb07 100644 --- a/liana-gui/src/daemon/client/mod.rs +++ b/liana-gui/src/daemon/client/mod.rs @@ -182,12 +182,13 @@ impl Daemon for Lianad { async fn create_recovery( &self, address: Address, + coins_outpoints: &[OutPoint], feerate_vb: u64, sequence: Option, ) -> Result { - // The `outpoints` parameter is omitted, which means all recoverable coins will be used. let mut params = serde_json::Map::new(); params.insert("address".to_string(), json!(address)); + params.insert("outpoints".to_string(), json!(coins_outpoints)); params.insert("feerate".to_string(), json!(feerate_vb)); if let Some(sequence) = sequence { params.insert("timelock".to_string(), json!(sequence)); diff --git a/liana-gui/src/daemon/embedded.rs b/liana-gui/src/daemon/embedded.rs index 113c22a7..e87bbb24 100644 --- a/liana-gui/src/daemon/embedded.rs +++ b/liana-gui/src/daemon/embedded.rs @@ -212,12 +212,13 @@ impl Daemon for EmbeddedDaemon { async fn create_recovery( &self, address: Address, + coins_outpoints: &[OutPoint], feerate_vb: u64, sequence: Option, ) -> Result { self.command(|daemon| { daemon - .create_recovery(address, &[], feerate_vb, sequence) + .create_recovery(address, coins_outpoints, feerate_vb, sequence) .map(|res| res.psbt) .map_err(|e| DaemonError::Unexpected(e.to_string())) }) diff --git a/liana-gui/src/daemon/mod.rs b/liana-gui/src/daemon/mod.rs index 63a88232..5b961c3b 100644 --- a/liana-gui/src/daemon/mod.rs +++ b/liana-gui/src/daemon/mod.rs @@ -138,6 +138,7 @@ pub trait Daemon: Debug { async fn create_recovery( &self, address: Address, + coins_outpoints: &[OutPoint], feerate_vb: u64, sequence: Option, ) -> Result; diff --git a/liana-gui/src/lianalite/client/backend/api.rs b/liana-gui/src/lianalite/client/backend/api.rs index 3f4c5d06..f795bd0e 100644 --- a/liana-gui/src/lianalite/client/backend/api.rs +++ b/liana-gui/src/lianalite/client/backend/api.rs @@ -418,9 +418,12 @@ pub mod payload { } #[derive(Serialize)] - pub struct GenerateRecoveryPsbt { + pub struct GenerateRecoveryPsbt<'a> { /// The address to sweep funds to. pub address: bitcoin::Address, + /// The outpoints of coins to use as transaction inputs. If empty, all + /// coins that are recoverable on the chosen recovery path will be used. + pub inputs: &'a [bitcoin::OutPoint], // The feerate to use for this transaction. pub feerate: u64, /// Timelock of the recovery path to use. diff --git a/liana-gui/src/lianalite/client/backend/mod.rs b/liana-gui/src/lianalite/client/backend/mod.rs index 7b1ffaf2..db0cf3d9 100644 --- a/liana-gui/src/lianalite/client/backend/mod.rs +++ b/liana-gui/src/lianalite/client/backend/mod.rs @@ -912,6 +912,7 @@ impl Daemon for BackendWalletClient { async fn create_recovery( &self, address: Address, + coins_outpoints: &[OutPoint], feerate_vb: u64, sequence: Option, ) -> Result { @@ -931,6 +932,7 @@ impl Daemon for BackendWalletClient { timelock: sequence .ok_or(DaemonError::Unexpected("Missing sequence".to_string()))?, address, + inputs: coins_outpoints, }) .send() .await?; From 934d0c76f486016bdd2a7098d14694b9ba470442 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Tue, 15 Apr 2025 12:03:31 +0100 Subject: [PATCH 2/7] fix: grey out next button in case of error It should not be possible to click the Next button if there's any error. This could arise for a self-send if a very high feerate is chosen and there are insufficient funds. In this case, the Next button will now be greyed out until form values are edited and the error has disappeared. --- liana-gui/src/app/view/spend/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/liana-gui/src/app/view/spend/mod.rs b/liana-gui/src/app/view/spend/mod.rs index e8b9973a..dce4339f 100644 --- a/liana-gui/src/app/view/spend/mod.rs +++ b/liana-gui/src/app/view/spend/mod.rs @@ -300,6 +300,7 @@ pub fn create_spend_tx<'a>( .push( if is_valid && !duplicate + && error.is_none() && (is_self_send || Some(&Amount::from_sat(0)) == amount_left) { button::secondary(None, "Next") From 0514f743daae738c05bb124ce55f0ae01193df01 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Tue, 15 Apr 2025 15:08:33 +0100 Subject: [PATCH 3/7] refactor: add function to filter coins for selection --- liana-gui/src/app/state/spend/step.rs | 46 +++++++++++++++------------ 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/liana-gui/src/app/state/spend/step.rs b/liana-gui/src/app/state/spend/step.rs index 3294d120..01279efa 100644 --- a/liana-gui/src/app/state/spend/step.rs +++ b/liana-gui/src/app/state/spend/step.rs @@ -68,6 +68,29 @@ pub trait Step { } } +/// Filter the coins that should be available for selection. +/// +/// `selected` will be `None` if the coins are being filtered for the first time. +/// In that case, no coins will be selected. +fn filter_coins(coins: &[Coin], selected: Option>) -> Vec<(Coin, bool)> { + coins + .iter() + .filter_map(|c| { + if c.spend_info.is_none() && !c.is_immature { + Some(( + c.clone(), + selected + .as_ref() + .map(|sel| sel.contains(&c.outpoint)) + .unwrap_or(false), + )) + } else { + None + } + }) + .collect() +} + pub struct DefineSpend { recipients: Vec, /// If set, this is the index of a recipient that should @@ -100,16 +123,7 @@ impl DefineSpend { coins: &[Coin], timelock: u16, ) -> Self { - let coins: Vec<(Coin, bool)> = coins - .iter() - .filter_map(|c| { - if c.spend_info.is_none() && !c.is_immature { - Some((c.clone(), false)) - } else { - None - } - }) - .collect(); + let coins = filter_coins(coins, None); Self { network, @@ -563,17 +577,7 @@ impl Step for DefineSpend { None } })); - self.coins = coins - .into_iter() - .filter_map(|coin| { - if coin.spend_info.is_none() && !coin.is_immature { - let selected = selected.contains(&coin.outpoint); - Some((coin, selected)) - } else { - None - } - }) - .collect(); + self.coins = filter_coins(&coins, Some(selected)); self.sort_coins(cache.blockheight as u32); // In case some selected coins are not spendable anymore and // new coins make more sense to be selected. A redraft is triggered From 83c90b085a8fc3c6f738678a01b63f2811a33600 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Wed, 16 Apr 2025 09:57:46 +0100 Subject: [PATCH 4/7] refactor: get first timelock from descriptor --- liana-gui/src/app/state/spend/mod.rs | 6 ++---- liana-gui/src/app/state/spend/step.rs | 21 ++++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/liana-gui/src/app/state/spend/mod.rs b/liana-gui/src/app/state/spend/mod.rs index 2b493277..7c693703 100644 --- a/liana-gui/src/app/state/spend/mod.rs +++ b/liana-gui/src/app/state/spend/mod.rs @@ -27,13 +27,12 @@ pub struct CreateSpendPanel { impl CreateSpendPanel { pub fn new(wallet: Arc, coins: &[Coin], blockheight: u32, network: Network) -> Self { let descriptor = wallet.main_descriptor.clone(); - let timelock = descriptor.first_timelock_value(); Self { draft: step::TransactionDraft::new(network), current: 0, steps: vec![ Box::new( - step::DefineSpend::new(network, descriptor, coins, timelock) + step::DefineSpend::new(network, descriptor, coins) .with_coins_sorted(blockheight), ), Box::new(step::SaveSpend::new(wallet)), @@ -49,13 +48,12 @@ impl CreateSpendPanel { network: Network, ) -> Self { let descriptor = wallet.main_descriptor.clone(); - let timelock = descriptor.first_timelock_value(); Self { draft: step::TransactionDraft::new(network), current: 0, steps: vec![ Box::new( - step::DefineSpend::new(network, descriptor, coins, timelock) + step::DefineSpend::new(network, descriptor, coins) .with_preselected_coins(preselected_coins) .with_coins_sorted(blockheight) .self_send(), diff --git a/liana-gui/src/app/state/spend/step.rs b/liana-gui/src/app/state/spend/step.rs index 01279efa..0147d92f 100644 --- a/liana-gui/src/app/state/spend/step.rs +++ b/liana-gui/src/app/state/spend/step.rs @@ -105,7 +105,6 @@ pub struct DefineSpend { network: Network, descriptor: LianaDescriptor, curve: secp256k1::Secp256k1, - timelock: u16, coins: Vec<(Coin, bool)>, coins_labels: HashMap, batch_label: form::Value, @@ -117,19 +116,13 @@ pub struct DefineSpend { } impl DefineSpend { - pub fn new( - network: Network, - descriptor: LianaDescriptor, - coins: &[Coin], - timelock: u16, - ) -> Self { + pub fn new(network: Network, descriptor: LianaDescriptor, coins: &[Coin]) -> Self { let coins = filter_coins(coins, None); Self { network, descriptor, curve: secp256k1::Secp256k1::verification_only(), - timelock, generated: None, coins, coins_labels: HashMap::new(), @@ -159,7 +152,7 @@ impl DefineSpend { } fn sort_coins(&mut self, blockheight: u32) { - let timelock = self.timelock; + let timelock = self.timelock(); self.coins.sort_by(|(a, a_selected), (b, b_selected)| { if *a_selected && !b_selected || !a_selected && *b_selected { b_selected.cmp(a_selected) @@ -180,6 +173,13 @@ impl DefineSpend { self } + /// This is used for calculating a coin's remaining sequence. + /// + /// It returns the descriptor's first timelock. + pub fn timelock(&self) -> u16 { + self.descriptor.first_timelock_value() + } + // If `is_redraft`, the validation of recipients will take into account // whether any should receive the max amount. Otherwise, all recipients // will be fully validated. @@ -433,7 +433,6 @@ impl Step for DefineSpend { .map(|(c, _)| c.clone()) .collect::>() .as_slice(), - self.timelock, ); return Task::none(); } @@ -647,7 +646,7 @@ impl Step for DefineSpend { .collect(), self.is_valid, self.is_duplicate, - self.timelock, + self.timelock(), &self.coins, &self.coins_labels, &self.batch_label, From 0d9cf43bf9c623221be6ed9882ec7df6e710566d Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Tue, 22 Apr 2025 11:30:45 +0100 Subject: [PATCH 5/7] fix: avoid overflow when checking for previous step --- liana-gui/src/app/state/spend/mod.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/liana-gui/src/app/state/spend/mod.rs b/liana-gui/src/app/state/spend/mod.rs index 7c693703..83cf3d52 100644 --- a/liana-gui/src/app/state/spend/mod.rs +++ b/liana-gui/src/app/state/spend/mod.rs @@ -102,10 +102,11 @@ impl State for CreateSpendPanel { } } - if matches!(message, Message::View(view::Message::Previous)) - && self.steps.get(self.current - 1).is_some() - { - self.current -= 1; + if matches!(message, Message::View(view::Message::Previous)) { + let previous = self.current.saturating_sub(1); + if self.steps.get(previous).is_some() { + self.current = previous; + } } if let Some(step) = self.steps.get_mut(self.current) { From c2f964d5b365501ec5a608a8f8333ffeb1edc2b7 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Tue, 22 Apr 2025 14:53:26 +0100 Subject: [PATCH 6/7] fix: load tip height directly rather than from cache This ensures the coins are sorted according to the latest tip height in case the cache has not been recently refreshed. --- liana-gui/src/app/message.rs | 2 ++ liana-gui/src/app/state/spend/mod.rs | 20 ++++++++++++++------ liana-gui/src/app/state/spend/step.rs | 8 ++++---- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/liana-gui/src/app/message.rs b/liana-gui/src/app/message.rs index e7afe426..6abcd912 100644 --- a/liana-gui/src/app/message.rs +++ b/liana-gui/src/app/message.rs @@ -27,6 +27,8 @@ pub enum Message { Info(Result), ReceiveAddress(Result<(Address, ChildNumber), Error>), Coins(Result, Error>), + /// When we want both coins and tip height together. + CoinsTipHeight(Result, Error>, Result), Labels(Result, Error>), SpendTxs(Result, Error>), Psbt(Result<(Psbt, Vec), Error>), diff --git a/liana-gui/src/app/state/spend/mod.rs b/liana-gui/src/app/state/spend/mod.rs index 83cf3d52..24d4f341 100644 --- a/liana-gui/src/app/state/spend/mod.rs +++ b/liana-gui/src/app/state/spend/mod.rs @@ -126,13 +126,21 @@ impl State for CreateSpendPanel { Task::batch(vec![ Task::perform( async move { - daemon1 - .list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[]) - .await - .map(|res| res.coins) - .map_err(|e| e.into()) + ( + daemon1 + .clone() + .list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[]) + .await + .map(|res| res.coins) + .map_err(|e| e.into()), + daemon1 + .get_info() + .await + .map(|res| res.block_height) + .map_err(|e| e.into()), + ) }, - Message::Coins, + |(res_coins, res_tip)| Message::CoinsTipHeight(res_coins, res_tip), ), Task::perform( async move { diff --git a/liana-gui/src/app/state/spend/step.rs b/liana-gui/src/app/state/spend/step.rs index 0147d92f..24a761f4 100644 --- a/liana-gui/src/app/state/spend/step.rs +++ b/liana-gui/src/app/state/spend/step.rs @@ -566,8 +566,8 @@ impl Step for DefineSpend { } Err(e) => self.warning = Some(e), }, - Message::Coins(res) => match res { - Ok(coins) => { + Message::CoinsTipHeight(res_coins, res_tip) => match (res_coins, res_tip) { + (Ok(coins), Ok(tip)) => { let selected: HashSet = HashSet::from_iter(self.coins.iter().filter_map(|(c, selected)| { if *selected { @@ -577,14 +577,14 @@ impl Step for DefineSpend { } })); self.coins = filter_coins(&coins, Some(selected)); - self.sort_coins(cache.blockheight as u32); + self.sort_coins(tip as u32); // In case some selected coins are not spendable anymore and // new coins make more sense to be selected. A redraft is triggered // if all forms are valid (checked in the redraft method) self.redraft(daemon); self.check_valid(); } - Err(e) => self.warning = Some(e), + (Err(e), _) | (Ok(_), Err(e)) => self.warning = Some(e), }, _ => {} }; From c7bbbd854ccfb8f2dccb6e7f63b6f4c982cd76c1 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Fri, 11 Apr 2025 07:59:13 +0100 Subject: [PATCH 7/7] feat: enable coin control for recovery spends The regular spend panel has been updated to allow for recovery spends. A recovery spend will use the same panel type as a regular spend, with an extra step added for selecting the recovery path. --- liana-gui/src/app/mod.rs | 23 +- liana-gui/src/app/state/mod.rs | 2 - liana-gui/src/app/state/recovery.rs | 268 -------------- liana-gui/src/app/state/spend/mod.rs | 93 ++++- liana-gui/src/app/state/spend/step.rs | 479 ++++++++++++++++++++++---- liana-gui/src/app/view/recovery.rs | 51 +-- liana-gui/src/app/view/spend/mod.rs | 78 +++-- 7 files changed, 567 insertions(+), 427 deletions(-) delete mode 100644 liana-gui/src/app/state/recovery.rs diff --git a/liana-gui/src/app/mod.rs b/liana-gui/src/app/mod.rs index cce65196..b5c11987 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -30,8 +30,7 @@ pub use config::Config; pub use message::Message; use state::{ - CoinsPanel, CreateSpendPanel, Home, PsbtsPanel, ReceivePanel, RecoveryPanel, State, - TransactionsPanel, + CoinsPanel, CreateSpendPanel, Home, PsbtsPanel, ReceivePanel, State, TransactionsPanel, }; use wallet::{sync_status, SyncStatus}; @@ -49,7 +48,7 @@ struct Panels { coins: CoinsPanel, transactions: TransactionsPanel, psbts: PsbtsPanel, - recovery: RecoveryPanel, + recovery: CreateSpendPanel, receive: ReceivePanel, create_spend: CreateSpendPanel, settings: SettingsState, @@ -90,7 +89,7 @@ impl Panels { coins: CoinsPanel::new(&cache.coins, wallet.main_descriptor.first_timelock_value()), transactions: TransactionsPanel::new(wallet.clone()), psbts: PsbtsPanel::new(wallet.clone()), - recovery: RecoveryPanel::new(wallet.clone(), &cache.coins, cache.blockheight), + recovery: new_recovery_panel(wallet.clone(), cache), receive: ReceivePanel::new(data_dir.clone(), wallet.clone()), create_spend: CreateSpendPanel::new( wallet.clone(), @@ -237,7 +236,7 @@ impl App { } menu::Menu::CreateSpendTx => { // redo the process of spending only if user want to start a new one. - if !self.panels.create_spend.is_first_step() { + if !self.panels.create_spend.keep_state() { self.panels.create_spend = CreateSpendPanel::new( self.wallet.clone(), &self.cache.coins, @@ -246,6 +245,11 @@ impl App { ); } } + menu::Menu::Recovery => { + if !self.panels.recovery.keep_state() { + self.panels.recovery = new_recovery_panel(self.wallet.clone(), &self.cache); + } + } _ => {} }; @@ -417,3 +421,12 @@ impl App { } } } + +fn new_recovery_panel(wallet: Arc, cache: &Cache) -> CreateSpendPanel { + CreateSpendPanel::new_recovery( + wallet, + &cache.coins, + cache.blockheight as u32, + cache.network, + ) +} diff --git a/liana-gui/src/app/state/mod.rs b/liana-gui/src/app/state/mod.rs index 37950c78..79cfbcbf 100644 --- a/liana-gui/src/app/state/mod.rs +++ b/liana-gui/src/app/state/mod.rs @@ -4,7 +4,6 @@ mod label; mod psbt; mod psbts; mod receive; -mod recovery; mod settings; mod spend; mod transactions; @@ -38,7 +37,6 @@ pub use coins::CoinsPanel; use label::LabelsEdited; pub use psbts::PsbtsPanel; pub use receive::ReceivePanel; -pub use recovery::RecoveryPanel; pub use settings::SettingsState; pub use spend::CreateSpendPanel; pub use transactions::TransactionsPanel; diff --git a/liana-gui/src/app/state/recovery.rs b/liana-gui/src/app/state/recovery.rs deleted file mode 100644 index daa63b43..00000000 --- a/liana-gui/src/app/state/recovery.rs +++ /dev/null @@ -1,268 +0,0 @@ -use std::collections::HashSet; -use std::str::FromStr; -use std::sync::Arc; - -use iced::Task; - -use liana::miniscript::bitcoin::{ - bip32::{DerivationPath, Fingerprint}, - secp256k1, -}; -use liana_ui::{component::form, widget::Element}; -use lianad::commands::CoinStatus; - -use crate::{ - app::{ - cache::Cache, - error::Error, - menu::Menu, - message::Message, - state::psbt, - state::{redirect, State}, - view, - wallet::Wallet, - }, - daemon::{ - model::{remaining_sequence, Coin, SpendTx}, - Daemon, - }, -}; - -use liana::miniscript::bitcoin::{Address, Amount}; - -pub struct RecoveryPanel { - wallet: Arc, - recovery_paths: Vec, - selected_path: Option, - warning: Option, - feerate: form::Value, - recipient: form::Value, - generated: Option, -} - -impl RecoveryPanel { - pub fn new(wallet: Arc, coins: &[Coin], blockheight: i32) -> Self { - Self { - recovery_paths: recovery_paths(&wallet, coins, blockheight), - wallet, - selected_path: None, - warning: None, - feerate: form::Value::default(), - recipient: form::Value::default(), - generated: None, - } - } -} - -impl State for RecoveryPanel { - fn subscription(&self) -> iced::Subscription { - if let Some(psbt) = &self.generated { - psbt.subscription() - } else { - iced::Subscription::none() - } - } - - fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { - if let Some(generated) = &self.generated { - generated.view(cache) - } else { - view::recovery::recovery( - cache, - 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, - self.warning.as_ref(), - ) - } - } - - fn update( - &mut self, - daemon: Arc, - cache: &Cache, - message: Message, - ) -> Task { - match message { - Message::Coins(res) => match res { - Err(e) => self.warning = Some(e), - Ok(coins) => { - self.warning = None; - self.recovery_paths = recovery_paths(&self.wallet, &coins, cache.blockheight); - } - }, - Message::Recovery(res) => match res { - Ok(tx) => { - self.generated = Some(psbt::PsbtState::new(self.wallet.clone(), tx, false)) - } - Err(e) => self.warning = Some(e), - }, - Message::View(msg) => match msg { - view::Message::Close => return redirect(Menu::Settings), - view::Message::Previous => self.generated = None, - view::Message::CreateSpend(view::CreateSpendMessage::RecipientEdited( - _, - "address", - address, - )) => { - self.recipient.value = address; - if let Ok(address) = Address::from_str(&self.recipient.value) { - self.recipient.valid = address.is_valid_for_network(cache.network); - } else { - 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 = - self.feerate.value.parse::().is_ok() && self.feerate.value != "0"; - } - view::Message::Next => { - let address = Address::from_str(&self.recipient.value).expect("Checked before"); - 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); - let network = cache.network; - return Task::perform( - async move { - let psbt = daemon - .create_recovery(address, &[], feerate_vb, sequence) - .await?; - let outpoints: Vec<_> = psbt - .unsigned_tx - .input - .iter() - .map(|txin| txin.previous_output) - .collect(); - let coins = daemon - .list_coins(&[], &outpoints) - .await - .map(|res| res.coins)?; - Ok(SpendTx::new( - None, - psbt, - coins, - &desc, - &secp256k1::Secp256k1::verification_only(), - network, - )) - }, - Message::Recovery, - ); - } - _ => { - if let Some(generated) = &mut self.generated { - return generated.update(daemon, cache, Message::View(msg)); - } - } - }, - _ => { - if let Some(generated) = &mut self.generated { - return generated.update(daemon, cache, message); - } - } - }; - Task::none() - } - - fn reload( - &mut self, - daemon: Arc, - wallet: Arc, - ) -> Task { - let daemon = daemon.clone(); - self.wallet = wallet; - self.selected_path = None; - self.warning = None; - self.feerate = form::Value::default(); - self.recipient = form::Value::default(); - self.generated = None; - Task::perform( - async move { - daemon - .list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[]) - .await - .map(|res| res.coins) - .map_err(|e| e.into()) - }, - Message::Coins, - ) - } -} - -impl From for Box { - fn from(s: RecoveryPanel) -> Box { - Box::new(s) - } -} - -pub struct RecoveryPath { - threshold: usize, - sequence: u16, - origins: Vec<(Fingerprint, HashSet)>, - 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.block_height.is_some() // only confirmed coins are included in a recovery transaction - && 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/liana-gui/src/app/state/spend/mod.rs b/liana-gui/src/app/state/spend/mod.rs index 24d4f341..9f79ac66 100644 --- a/liana-gui/src/app/state/spend/mod.rs +++ b/liana-gui/src/app/state/spend/mod.rs @@ -1,6 +1,7 @@ mod step; use std::collections::HashSet; +use std::convert::TryInto; use std::sync::Arc; use iced::Task; @@ -22,21 +23,66 @@ pub struct CreateSpendPanel { draft: step::TransactionDraft, current: usize, steps: Vec>, + /// All coins that may be required by any of the steps in the panel. + /// Additional filtering should be performed by individual steps. + coins: Vec, + tip_height: i32, } impl CreateSpendPanel { + /// Create a new instance to be used for a primary path spend. pub fn new(wallet: Arc, coins: &[Coin], blockheight: u32, network: Network) -> Self { let descriptor = wallet.main_descriptor.clone(); Self { - draft: step::TransactionDraft::new(network), + draft: step::TransactionDraft::new(network, None), current: 0, steps: vec![ Box::new( - step::DefineSpend::new(network, descriptor, coins) + step::DefineSpend::new(network, descriptor, coins, blockheight, None, true) .with_coins_sorted(blockheight), ), Box::new(step::SaveSpend::new(wallet)), ], + coins: coins.to_vec(), + tip_height: blockheight.try_into().expect("i32 by consensus"), + } + } + + /// Create a new instance to be used for a recovery spend. + /// + /// By default, the wallet's first timelock value is used for `DefineSpend`. + pub fn new_recovery( + wallet: Arc, + coins: &[Coin], + blockheight: u32, + network: Network, + ) -> Self { + let descriptor = wallet.main_descriptor.clone(); + let timelock = descriptor.first_timelock_value(); + Self { + draft: step::TransactionDraft::new(network, Some(timelock)), + current: 0, + steps: vec![ + Box::new(step::SelectRecoveryPath::new( + wallet.clone(), + coins, + blockheight.try_into().expect("i32 by consensus"), + )), + Box::new( + step::DefineSpend::new( + network, + descriptor, + coins, + blockheight, + Some(timelock), // the recovery timelock must always be set to a value + false, + ) + .with_coins_sorted(blockheight), + ), + Box::new(step::SaveSpend::new(wallet)), + ], + coins: coins.to_vec(), + tip_height: blockheight.try_into().expect("i32 by consensus"), } } @@ -49,22 +95,30 @@ impl CreateSpendPanel { ) -> Self { let descriptor = wallet.main_descriptor.clone(); Self { - draft: step::TransactionDraft::new(network), + draft: step::TransactionDraft::new(network, None), current: 0, steps: vec![ Box::new( - step::DefineSpend::new(network, descriptor, coins) + step::DefineSpend::new(network, descriptor, coins, blockheight, None, true) .with_preselected_coins(preselected_coins) .with_coins_sorted(blockheight) .self_send(), ), Box::new(step::SaveSpend::new(wallet)), ], + coins: coins.to_vec(), + tip_height: blockheight.try_into().expect("i32 by consensus"), } } - pub fn is_first_step(&self) -> bool { - self.current == 0 + pub fn keep_state(&self) -> bool { + if self.draft.is_recovery() { + // For recovery spend, retain the state if user is on the first two steps + // (choosing recovery path and defining spend) + self.current < 2 + } else { + self.current == 0 + } } } @@ -98,17 +152,29 @@ impl State for CreateSpendPanel { if let Some(step) = self.steps.get_mut(self.current + 1) { self.current += 1; - step.load(&self.draft); + step.load(&self.coins, self.tip_height, &self.draft); } } if matches!(message, Message::View(view::Message::Previous)) { let previous = self.current.saturating_sub(1); - if self.steps.get(previous).is_some() { + if let Some(step) = self.steps.get_mut(previous) { self.current = previous; + // For recovery spends, ensure all steps use the latest coins and tip height. + // TODO: consider doing this for all spend kinds, not just recovery. + if self.draft.is_recovery() { + step.load(&self.coins, self.tip_height, &self.draft); + } } } + if let Message::CoinsTipHeight(Ok(coins), Ok(tip)) = &message { + // Save the coins and tip for use in the `load()` method. + self.coins = coins.clone(); + self.tip_height = *tip; + // We still send this message to the current step below to update the values directly. + } + if let Some(step) = self.steps.get_mut(self.current) { return step.update(daemon, cache, message); } @@ -123,13 +189,20 @@ impl State for CreateSpendPanel { ) -> Task { let daemon1 = daemon.clone(); let daemon2 = daemon.clone(); + let coin_statuses_1 = if self.draft.is_recovery() { + // only confirmed coins can be included in a recovery spend. + vec![CoinStatus::Confirmed] + } else { + vec![CoinStatus::Unconfirmed, CoinStatus::Confirmed] + }; + let coin_statuses_2 = coin_statuses_1.clone(); Task::batch(vec![ Task::perform( async move { ( daemon1 .clone() - .list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[]) + .list_coins(&coin_statuses_1, &[]) .await .map(|res| res.coins) .map_err(|e| e.into()), @@ -145,7 +218,7 @@ impl State for CreateSpendPanel { Task::perform( async move { let coins = daemon - .list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[]) + .list_coins(&coin_statuses_2, &[]) .await .map(|res| res.coins) .map_err(Error::from)?; diff --git a/liana-gui/src/app/state/spend/step.rs b/liana-gui/src/app/state/spend/step.rs index 24a761f4..8cf90925 100644 --- a/liana-gui/src/app/state/spend/step.rs +++ b/liana-gui/src/app/state/spend/step.rs @@ -1,6 +1,7 @@ use std::{ cmp::Ordering, collections::{HashMap, HashSet}, + convert::TryInto, iter::FromIterator, str::FromStr, sync::Arc, @@ -10,7 +11,10 @@ use iced::{Subscription, Task}; use liana::{ descriptors::LianaDescriptor, miniscript::bitcoin::{ - address, psbt::Psbt, secp256k1, Address, Amount, Denomination, Network, OutPoint, + address, + bip32::{DerivationPath, Fingerprint}, + psbt::Psbt, + secp256k1, Address, Amount, Denomination, Network, OutPoint, }, spend::{SpendCreationError, MAX_FEERATE}, }; @@ -37,10 +41,16 @@ pub struct TransactionDraft { generated: Option<(Psbt, Vec)>, batch_label: Option, labels: HashMap, + /// The timelock of the recovery path to use for spending. + /// + /// If the primary path will be used, this value will remain as `None`. + /// Otherwise, its value should always be set to a recovery path, + /// which may change from one to another. + recovery_timelock: Option, } impl TransactionDraft { - pub fn new(network: Network) -> Self { + pub fn new(network: Network, recovery_timelock: Option) -> Self { Self { network, inputs: Vec::new(), @@ -48,8 +58,13 @@ impl TransactionDraft { generated: None, batch_label: None, labels: HashMap::new(), + recovery_timelock, } } + + pub fn is_recovery(&self) -> bool { + self.recovery_timelock.is_some() + } } pub trait Step { @@ -62,7 +77,7 @@ pub trait Step { ) -> Task; fn apply(&self, _draft: &mut TransactionDraft) {} fn interrupt(&mut self) {} - fn load(&mut self, _draft: &TransactionDraft) {} + fn load(&mut self, _coins: &[Coin], _tip_height: i32, _draft: &TransactionDraft) {} fn subscription(&self) -> Subscription { Subscription::none() } @@ -71,19 +86,41 @@ pub trait Step { /// Filter the coins that should be available for selection. /// /// `selected` will be `None` if the coins are being filtered for the first time. -/// In that case, no coins will be selected. -fn filter_coins(coins: &[Coin], selected: Option>) -> Vec<(Coin, bool)> { +/// In that case, all suitable coins will be selected for a recovery spend, +/// while for a primary path spend, no coins will be selected. +fn filter_coins( + coins: &[Coin], + recovery_timelock: Option, + tip_height: i32, + selected: Option>, +) -> Vec<(Coin, bool)> { coins .iter() .filter_map(|c| { if c.spend_info.is_none() && !c.is_immature { - Some(( - c.clone(), - selected - .as_ref() - .map(|sel| sel.contains(&c.outpoint)) - .unwrap_or(false), - )) + if let Some(recovery_timelock) = recovery_timelock { + c.block_height + .filter(|bh| { + tip_height + 1 >= bh + >::into(recovery_timelock) + }) + .map(|_| { + ( + c.clone(), + selected + .as_ref() + .map(|sel| sel.contains(&c.outpoint)) + .unwrap_or(true), + ) + }) + } else { + Some(( + c.clone(), + selected + .as_ref() + .map(|sel| sel.contains(&c.outpoint)) + .unwrap_or(false), + )) + } } else { None } @@ -97,7 +134,7 @@ pub struct DefineSpend { /// receive the max amount. send_max_to_recipient: Option, /// Will be `true` if coins for spend were manually selected by user. - /// Otherwise, will be `false` (including for self-send). + /// Otherwise, will be `false` (including for self-send & recovery). is_user_coin_selection: bool, is_valid: bool, is_duplicate: bool, @@ -105,6 +142,13 @@ pub struct DefineSpend { network: Network, descriptor: LianaDescriptor, curve: secp256k1::Secp256k1, + /// Leave as `None` for a primary path spend. Otherwise, this is the timelock + /// corresponding to the recovery path to use for the spend. + /// + /// For a recovery path spend, this value can change from one timelock to another, but + /// it must never be set to `None`. + recovery_timelock: Option, + tip_height: u32, coins: Vec<(Coin, bool)>, coins_labels: HashMap, batch_label: form::Value, @@ -113,29 +157,48 @@ pub struct DefineSpend { fee_amount: Option, generated: Option<(Psbt, Vec)>, warning: Option, + /// Whether this is the first step of the spend creation. + /// Required in order to know whether the user can navigate to a previous step. + is_first_step: bool, } impl DefineSpend { - pub fn new(network: Network, descriptor: LianaDescriptor, coins: &[Coin]) -> Self { - let coins = filter_coins(coins, None); + pub fn new( + network: Network, + descriptor: LianaDescriptor, + coins: &[Coin], + tip_height: u32, + recovery_timelock: Option, + is_first_step: bool, + ) -> Self { + let coins = filter_coins( + coins, + recovery_timelock, + tip_height.try_into().expect("i32 by consensus"), + None, + ); Self { network, descriptor, curve: secp256k1::Secp256k1::verification_only(), + recovery_timelock, + tip_height, generated: None, coins, coins_labels: HashMap::new(), batch_label: form::Value::default(), - recipients: vec![Recipient::default()], - send_max_to_recipient: None, - is_user_coin_selection: false, // Start with auto-selection until user edits selection. + recipients: vec![Recipient::new(recovery_timelock.is_some())], + // For recovery, send max to the (single) recipient. + send_max_to_recipient: recovery_timelock.map(|_| 0), + is_user_coin_selection: false, is_valid: false, is_duplicate: false, feerate: form::Value::default(), fee_amount: None, amount_left_to_select: None, warning: None, + is_first_step, } } @@ -175,9 +238,11 @@ impl DefineSpend { /// This is used for calculating a coin's remaining sequence. /// - /// It returns the descriptor's first timelock. + /// Use the first timelock if this is a primary path spend and otherwise the same + /// timelock as used for the recovery. pub fn timelock(&self) -> u16 { - self.descriptor.first_timelock_value() + self.recovery_timelock + .unwrap_or_else(|| self.descriptor.first_timelock_value()) } // If `is_redraft`, the validation of recipients will take into account @@ -235,6 +300,9 @@ impl DefineSpend { return; } let is_self_transfer = self.recipients.is_empty(); + // Define the destinations for a primary path spend from all non-max recipients. + // TODO: Set this variable only in the non-recovery case. For now, we use it later below + // for setting `amount_left_to_select`, which is only required in the non-recovery case. let destinations: HashMap, u64> = self .recipients .iter() @@ -266,7 +334,14 @@ impl DefineSpend { } else { None }; - let outpoints = if self.is_user_coin_selection || is_self_transfer { + let outpoints = if self.is_user_coin_selection + || is_self_transfer + || self.recovery_timelock.is_some() + { + // If user has edited selection, or otherwise for self-transfers and recovery spends, we pass the outpoints list. + // (We could also in principle pass an empty outpoints list for a recovery spend if all should + // be selected, i.e. not user edited, but this way we don't need to worry about checking selected + // coins match those used in the recovery). let outpoints: Vec<_> = self .coins .iter() @@ -291,6 +366,7 @@ impl DefineSpend { // Simply set the amount left to select as the total destination value. Note this // doesn't take account of the fee, but passing an empty list to `create_spend_tx` // would use auto-selection and so we settle for this approximation. + // Note that for a recovery, the amount left to select is ignored by the view. self.amount_left_to_select = Some(Amount::from_sat(destinations.values().sum())); self.fee_amount = None; return; @@ -307,10 +383,10 @@ impl DefineSpend { }; // If sending the max to a recipient, use that recipient's address as the - // change address. - // Otherwise, use a fixed change address from the user's own wallet so that - // we don't increment the change index. - let change_address = if let Some((_, recipient)) = &recipient_with_max { + // change/recovery address. + // Otherwise, for a primary path spend, use a fixed change address from the user's + // own wallet so that we don't increment the change index. + let max_address = if let Some((_, recipient)) = &recipient_with_max { Address::from_str(&recipient.address.value) .expect("Checked before") .as_unchecked() @@ -325,20 +401,34 @@ impl DefineSpend { }; let feerate_vb = self.feerate.value.parse::().expect("Checked before"); + let recovery_timelock = self.recovery_timelock; match tokio::runtime::Handle::current().block_on(async { - daemon - .create_spend_tx( - &outpoints, - &destinations, - feerate_vb, - Some(change_address.clone()), - ) - .await + // If recovery timelock is set, create a recovery transaction. Otherwise, a regular spend. + if let Some(reco_tl) = recovery_timelock { + daemon + .create_recovery(max_address.clone(), &outpoints, feerate_vb, Some(reco_tl)) + .await + // Map the PSBT to `CreateSpendResult` result. We only need the PSBT below. + .map(|psbt| CreateSpendResult::Success { + psbt, + warnings: vec![], + }) + } else { + daemon + .create_spend_tx( + &outpoints, + &destinations, + feerate_vb, + Some(max_address.clone()), + ) + .await + } }) { Ok(CreateSpendResult::Success { psbt, .. }) => { self.warning = None; self.fee_amount = Some(psbt.fee().expect("Valid fees")); - if !self.is_user_coin_selection { + // Update selected coins for auto-selection (non-recovery case). + if !self.is_user_coin_selection && self.recovery_timelock.is_none() { let selected_coins: Vec = psbt .unsigned_tx .input @@ -360,8 +450,7 @@ impl DefineSpend { .output .iter() .find(|o| { - o.script_pubkey - == change_address.clone().assume_checked().script_pubkey() + o.script_pubkey == max_address.clone().assume_checked().script_pubkey() }) .map(|change_output| change_output.value.to_btc()) .unwrap_or(0.0) @@ -375,7 +464,9 @@ impl DefineSpend { Ok(CreateSpendResult::InsufficientFunds { missing }) => { self.fee_amount = None; self.amount_left_to_select = Some(Amount::from_sat(missing)); - if !self.is_user_coin_selection { + // To be sure, we exclude recovery transactions here, although they + // can't currently reach this part of code. + if !self.is_user_coin_selection && self.recovery_timelock.is_none() { // The missing amount is based on all candidates for coin selection // being used, which are all owned coins. for (coin, selected) in &mut self.coins { @@ -411,6 +502,46 @@ impl DefineSpend { } impl Step for DefineSpend { + fn load(&mut self, coins: &[Coin], tip_height: i32, draft: &TransactionDraft) { + self.tip_height = tip_height as u32; + match (self.recovery_timelock, draft.recovery_timelock) { + (Some(old_tl), Some(new_tl)) => { + if old_tl != new_tl { + // If the timelock has changed, reinitialise this step. + let new = Self::new( + self.network, + self.descriptor.clone(), + coins, + tip_height as u32, + Some(new_tl), + self.is_first_step, + ); + *self = new; + return; + } else { + // If the timelock has not changed, we keep the existing coins selection if it has already been edited + // by the user or otherwise if the form values are valid. The form values being valid means a redraft has + // been performed and so we keep the currently selected coins in order that the recipient amount displayed + // matches the selection. + let selected = (self.is_user_coin_selection + || self.form_values_are_valid(true)) + .then_some( + self.coins + .iter() + .filter_map(|(coin, sel)| sel.then_some(coin.outpoint)) + .collect(), + ); + self.coins = filter_coins(coins, self.recovery_timelock, tip_height, selected); + } + } + _ => { + // We don't handle the case of a recovery timelock being added or removed: + // A spend is either primary or recovery and cannot change from one to the other. + } + } + self.check_valid(); + } + fn update( &mut self, daemon: Arc, @@ -433,6 +564,9 @@ impl Step for DefineSpend { .map(|(c, _)| c.clone()) .collect::>() .as_slice(), + self.tip_height, + self.recovery_timelock, + self.is_first_step, ); return Task::none(); } @@ -495,35 +629,61 @@ impl Step for DefineSpend { .collect(); let mut outputs: HashMap, u64> = HashMap::new(); - for recipient in &self.recipients { - outputs.insert( - Address::from_str(&recipient.address.value) - .expect("Checked before"), - recipient.amount().expect("Checked before"), - ); - } let feerate_vb = self.feerate.value.parse::().unwrap_or(0); self.warning = None; - return Task::perform( - async move { - daemon - .create_spend_tx(&inputs, &outputs, feerate_vb, None) - .await - .map_err(|e| e.into()) - .and_then(|res| match res { - CreateSpendResult::Success { psbt, warnings } => { - Ok((psbt, warnings)) - } - CreateSpendResult::InsufficientFunds { missing } => { - Err(SpendCreationError::CoinSelection( - liana::spend::InsufficientFunds { missing }, - ) - .into()) - } - }) - }, - Message::Psbt, - ); + if let Some(reco_tl) = self.recovery_timelock { + let recovery_address = Address::from_str( + &self + .recipients + .first() + .expect("recovery spend has a recipient") + .address + .value, + ) + .expect("Checked before"); + return Task::perform( + async move { + daemon + .create_recovery( + recovery_address, + &inputs, + feerate_vb, + Some(reco_tl), + ) + .await + .map_err(|e| e.into()) + .map(|psbt| (psbt, vec![])) + }, + Message::Psbt, + ); + } else { + for recipient in &self.recipients { + let address = Address::from_str(&recipient.address.value) + .expect("Checked before"); + outputs + .insert(address, recipient.amount().expect("Checked before")); + } + return Task::perform( + async move { + daemon + .create_spend_tx(&inputs, &outputs, feerate_vb, None) + .await + .map_err(|e| e.into()) + .and_then(|res| match res { + CreateSpendResult::Success { psbt, warnings } => { + Ok((psbt, warnings)) + } + CreateSpendResult::InsufficientFunds { missing } => { + Err(SpendCreationError::CoinSelection( + liana::spend::InsufficientFunds { missing }, + ) + .into()) + } + }) + }, + Message::Psbt, + ); + } } view::CreateSpendMessage::SelectCoin(i) => { if let Some(coin) = self.coins.get_mut(i) { @@ -568,15 +728,21 @@ impl Step for DefineSpend { }, Message::CoinsTipHeight(res_coins, res_tip) => match (res_coins, res_tip) { (Ok(coins), Ok(tip)) => { - let selected: HashSet = - HashSet::from_iter(self.coins.iter().filter_map(|(c, selected)| { - if *selected { - Some(c.outpoint) - } else { - None - } - })); - self.coins = filter_coins(&coins, Some(selected)); + self.tip_height = tip as u32; + // If it's a recovery spend and user has never edited the selection, then we won't pass + // any selection and will let `filter_coins` select all by default. + let selected = (self.recovery_timelock.is_none() + || self.is_user_coin_selection) + .then_some(HashSet::from_iter(self.coins.iter().filter_map( + |(c, selected)| { + if *selected { + Some(c.outpoint) + } else { + None + } + }, + ))); + self.coins = filter_coins(&coins, self.recovery_timelock, tip, selected); self.sort_coins(tip as u32); // In case some selected coins are not spendable anymore and // new coins make more sense to be selected. A redraft is triggered @@ -647,6 +813,7 @@ impl Step for DefineSpend { self.is_valid, self.is_duplicate, self.timelock(), + self.recovery_timelock, &self.coins, &self.coins_labels, &self.batch_label, @@ -654,6 +821,7 @@ impl Step for DefineSpend { &self.feerate, self.fee_amount.as_ref(), self.warning.as_ref(), + self.is_first_step, ) } } @@ -663,9 +831,17 @@ struct Recipient { label: form::Value, address: form::Value, amount: form::Value, + is_recovery: bool, } impl Recipient { + fn new(is_recovery: bool) -> Self { + Self { + is_recovery, + ..Default::default() + } + } + fn amount(&self) -> Result { if self.amount.value.is_empty() { return Err(Error::Unexpected("Amount should be non-zero".to_string())); @@ -738,7 +914,14 @@ impl Recipient { } fn view(&self, i: usize, is_max_selected: bool) -> Element { - view::spend::recipient_view(i, &self.address, &self.amount, &self.label, is_max_selected) + view::spend::recipient_view( + i, + &self.address, + &self.amount, + &self.label, + is_max_selected, + self.is_recovery, + ) } } @@ -759,7 +942,7 @@ impl SaveSpend { } impl Step for SaveSpend { - fn load(&mut self, draft: &TransactionDraft) { + fn load(&mut self, _coins: &[Coin], _tip_height: i32, draft: &TransactionDraft) { let (psbt, warnings) = draft.generated.clone().unwrap(); let mut tx = SpendTx::new( None, @@ -839,3 +1022,149 @@ impl Step for SaveSpend { } } } + +pub struct SelectRecoveryPath { + wallet: Arc, + recovery_paths: Vec, + selected_path: Option, + warning: Option, +} + +impl SelectRecoveryPath { + pub fn new(wallet: Arc, coins: &[Coin], tip_height: i32) -> Self { + Self { + recovery_paths: recovery_paths(&wallet, coins, tip_height), + wallet, + selected_path: None, + warning: None, + } + } + + pub fn load_from_coins_and_tip_height(&mut self, coins: &[Coin], tip_height: i32) { + self.warning = None; + // Update the available recovery paths, maintaining any selected path. + let selected_seq = self.selected_path.and_then(|selected| { + self.recovery_paths + .get(selected) + .map(|reco_path| reco_path.sequence) + }); + self.recovery_paths = recovery_paths(&self.wallet, coins, tip_height); + self.selected_path = selected_seq.and_then(|seq| { + self.recovery_paths + .iter() + .enumerate() + .find_map(|(i, path)| (path.sequence == seq).then_some(i)) + }); + } +} + +impl Step for SelectRecoveryPath { + fn load(&mut self, coins: &[Coin], tip_height: i32, _draft: &TransactionDraft) { + self.load_from_coins_and_tip_height(coins, tip_height); + } + + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + view::recovery::recovery( + cache, + 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.warning.as_ref(), + ) + } + + fn update( + &mut self, + _daemon: Arc, + _cache: &Cache, + message: Message, + ) -> Task { + match message { + Message::CoinsTipHeight(res_coins, res_tip) => match (res_coins, res_tip) { + (Ok(coins), Ok(tip)) => { + self.load_from_coins_and_tip_height(&coins, tip); + } + (Err(e), _) | (Ok(_), Err(e)) => { + self.warning = Some(e); + } + }, + Message::View(view::Message::CreateSpend(view::CreateSpendMessage::SelectPath( + index, + ))) => { + if Some(index) == self.selected_path { + self.selected_path = None; + } else { + self.selected_path = Some(index); + } + } + _ => {} + }; + Task::none() + } + + fn apply(&self, draft: &mut TransactionDraft) { + if let Some(selected_path) = self.selected_path { + if let Some(path) = self.recovery_paths.get(selected_path) { + draft.recovery_timelock = Some(path.sequence); + } + } + } +} + +pub struct RecoveryPath { + threshold: usize, + sequence: u16, + origins: Vec<(Fingerprint, HashSet)>, + total_amount: Amount, + number_of_coins: usize, +} + +fn recovery_paths(wallet: &Wallet, coins: &[Coin], tip_height: i32) -> Vec { + wallet + .main_descriptor + .policy() + .recovery_paths() + .iter() + .map(|(&sequence, path)| { + let (number_of_coins, total_amount) = coins + .iter() + .filter(|coin| { + coin.block_height.is_some() // only confirmed coins are included in a recovery transaction + && coin.spend_info.is_none() + && remaining_sequence(coin, tip_height 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/liana-gui/src/app/view/recovery.rs b/liana-gui/src/app/view/recovery.rs index bebc045c..a3faa87f 100644 --- a/liana-gui/src/app/view/recovery.rs +++ b/liana-gui/src/app/view/recovery.rs @@ -11,7 +11,7 @@ use liana::miniscript::bitcoin::{ }; use liana_ui::{ - component::{amount::*, button, form, text::*}, + component::{amount::*, button, text::*}, theme, widget::*, }; @@ -31,8 +31,6 @@ pub fn recovery<'a>( cache: &'a Cache, recovery_paths: Vec>, selected_path: Option, - feerate: &form::Value, - address: &'a form::Value, warning: Option<&Error>, ) -> Element<'a, Message> { let no_recovery_paths = recovery_paths.is_empty(); @@ -43,38 +41,6 @@ pub fn recovery<'a>( Column::new() .push(Container::new(h3("Recovery")).width(Length::Fill)) .push(Space::with_height(Length::Fixed(20.0))) - .push( - Row::new() - .spacing(20) - .align_y(Alignment::Center) - .push(text("Destination").bold()) - .push( - Container::new( - form::Form::new_trimmed("Address", address, move |msg| { - Message::CreateSpend(CreateSpendMessage::RecipientEdited( - 0, "address", msg, - )) - }) - .warning("Invalid Bitcoin address") - .size(P1_SIZE) - .padding(10), - ) - .max_width(500) - .width(Length::Fill), - ) - .push(text("Feerate").bold()) - .push( - Container::new( - form::Form::new_trimmed("42 (sats/vbyte)", feerate, move |msg| { - Message::CreateSpend(CreateSpendMessage::FeerateEdited(msg)) - }) - .warning("Invalid feerate") - .size(P1_SIZE) - .padding(10), - ) - .width(Length::Fixed(200.0)), - ), - ) .push(if no_recovery_paths { Container::new(text("No recovery path is currently available")) } else { @@ -97,18 +63,9 @@ pub fn recovery<'a>( Row::new() .push(Space::with_width(Length::Fill)) .push( - if feerate.valid - && !feerate.value.is_empty() - && address.valid - && !address.value.is_empty() - && selected_path.is_some() - { - button::secondary(None, "Next") - .on_press(Message::Next) - .width(Length::Fixed(200.0)) - } else { - button::secondary(None, "Next").width(Length::Fixed(200.0)) - }, + button::secondary(None, "Next") + .on_press_maybe(selected_path.map(|_| Message::Next)) + .width(Length::Fixed(200.0)), ) .spacing(20) .align_y(Alignment::Center), diff --git a/liana-gui/src/app/view/spend/mod.rs b/liana-gui/src/app/view/spend/mod.rs index dce4339f..ff649141 100644 --- a/liana-gui/src/app/view/spend/mod.rs +++ b/liana-gui/src/app/view/spend/mod.rs @@ -39,13 +39,26 @@ pub fn spend_view<'a>( network: Network, warning: Option<&Error>, ) -> Element<'a, Message> { + let is_recovery = tx + .psbt + .unsigned_tx + .input + .iter() + .any(|txin| txin.sequence.is_relative_lock_time()); dashboard( - &Menu::CreateSpendTx, + if is_recovery { + &Menu::Recovery + } else { + &Menu::CreateSpendTx + }, cache, warning, Column::new() .spacing(20) - .push(Container::new(h3("Send")).width(Length::Fill)) + .push( + Container::new(h3(if is_recovery { "Recovery" } else { "Send" })) + .width(Length::Fill), + ) .push(psbt::spend_header(tx, labels_editing)) .push_maybe(if spend_warnings.is_empty() || saved { None @@ -114,6 +127,7 @@ pub fn create_spend_tx<'a>( is_valid: bool, duplicate: bool, timelock: u16, + recovery_timelock: Option, coins: &[(Coin, bool)], coins_labels: &'a HashMap, batch_label: &form::Value, @@ -121,14 +135,21 @@ pub fn create_spend_tx<'a>( feerate: &form::Value, fee_amount: Option<&Amount>, error: Option<&Error>, + is_first_step: bool, ) -> Element<'a, Message> { let is_self_send = recipients.is_empty(); dashboard( - &Menu::CreateSpendTx, + if recovery_timelock.is_some() { + &Menu::Recovery + } else { + &Menu::CreateSpendTx + }, cache, error, Column::new() - .push(h3(if is_self_send { + .push(h3(if recovery_timelock.is_some() { + "Recovery" + } else if is_self_send { "Self-transfer" } else { "Send" @@ -162,7 +183,8 @@ pub fn create_spend_tx<'a>( None }) .push(Space::with_width(Length::Fill)) - .push_maybe(if is_self_send { + .push_maybe(if is_self_send || recovery_timelock.is_some() { + // Recipients cannot be added for self-send (zero recipients) and recovery (exactly one recipient). None } else { Some( @@ -208,7 +230,7 @@ pub fn create_spend_tx<'a>( Row::new() .align_y(Alignment::Center) .push(p1_bold("Coins selection").width(Length::Fill)) - .push(if is_self_send { + .push(if is_self_send || recovery_timelock.is_some() { Row::new() .spacing(5) .push(amount_with_size( @@ -291,6 +313,13 @@ pub fn create_spend_tx<'a>( Row::new() .spacing(20) .align_y(Alignment::Center) + .push_maybe( + (!is_first_step).then_some( + button::secondary(None, "< Previous") + .width(Length::Fixed(150.0)) + .on_press(Message::Previous), + ), + ) .push(Space::with_width(Length::Fill)) .push( button::secondary(None, "Clear") @@ -301,7 +330,9 @@ pub fn create_spend_tx<'a>( if is_valid && !duplicate && error.is_none() - && (is_self_send || Some(&Amount::from_sat(0)) == amount_left) + && (is_self_send + || recovery_timelock.is_some() + || Some(&Amount::from_sat(0)) == amount_left) { button::secondary(None, "Next") .on_press(Message::CreateSpend(CreateSpendMessage::Generate)) @@ -322,16 +353,20 @@ pub fn recipient_view<'a>( amount: &'a form::Value, label: &'a form::Value, is_max_selected: bool, + is_recovery: bool, ) -> Element<'a, CreateSpendMessage> { Container::new( Column::new() .spacing(10) - .push( - Row::new().push(Space::with_width(Length::Fill)).push( - Button::new(icon::cross_icon()) - .style(theme::button::transparent) - .on_press(CreateSpendMessage::DeleteRecipient(index)) - .width(Length::Shrink), + .push_maybe( + // Recipient for recovery cannot be deleted. + (!is_recovery).then_some( + Row::new().push(Space::with_width(Length::Fill)).push( + Button::new(icon::cross_icon()) + .style(theme::button::transparent) + .on_press(CreateSpendMessage::DeleteRecipient(index)) + .width(Length::Shrink), + ), ), ) .push( @@ -409,13 +444,16 @@ pub fn recipient_view<'a>( } else { None }) - .push(tooltip::Tooltip::new( - checkbox("MAX", is_max_selected) - .on_toggle(move |_| CreateSpendMessage::SendMaxToRecipient(index)), - // Add spaces at end so that text is padded at screen edge. - "Total amount remaining after paying fee and any other recipients ", - tooltip::Position::Bottom, - )) + .push_maybe( + // The MAX option cannot be edited for recovery recipients. + (!is_recovery).then_some(tooltip::Tooltip::new( + checkbox("MAX", is_max_selected) + .on_toggle(move |_| CreateSpendMessage::SendMaxToRecipient(index)), + // Add spaces at end so that text is padded at screen edge. + "Total amount remaining after paying fee and any other recipients ", + tooltip::Position::Bottom, + )), + ) .width(Length::Fill), ), )