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), ), )