diff --git a/gui/Cargo.lock b/gui/Cargo.lock index b2e91f49d..b150df96d 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -254,8 +254,9 @@ dependencies = [ [[package]] name = "bdk_coin_select" -version = "0.1.0" -source = "git+https://github.com/evanlinjin/bdk?branch=new_bdk_coin_select#2a06d73ac7a5dca933b19b51078f5279691364ed" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0320167c3655e83f0415d52f39618902e449186ffc7dfb090f922f79675c316" [[package]] name = "bech32" @@ -2430,7 +2431,7 @@ dependencies = [ [[package]] name = "liana" version = "2.0.0" -source = "git+https://github.com/wizardsardine/liana?branch=master#514535d8d6fec705c7271241f68276c42b918150" +source = "git+https://github.com/wizardsardine/liana?branch=master#6151c57af492dacc8502b0ea1ec1cd04580e08dc" dependencies = [ "backtrace", "bdk_coin_select", diff --git a/gui/src/app/menu.rs b/gui/src/app/menu.rs index 8239f675b..07d40f248 100644 --- a/gui/src/app/menu.rs +++ b/gui/src/app/menu.rs @@ -1,4 +1,4 @@ -use liana::miniscript::bitcoin::OutPoint; +use liana::miniscript::bitcoin::{OutPoint, Txid}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Menu { Home, @@ -10,4 +10,5 @@ pub enum Menu { CreateSpendTx, Recovery, RefreshCoins(Vec), + PsbtPreSelected(Txid), } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 605220308..d217bc642 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -95,6 +95,22 @@ impl App { } menu::Menu::Transactions => TransactionsPanel::new().into(), menu::Menu::PSBTs => PsbtsPanel::new(self.wallet.clone(), &self.cache.spend_txs).into(), + menu::Menu::PsbtPreSelected(txid) => { + // Get preselected spend from DB in case it's not yet in the cache. + // We only need this single spend as we will go straight to its view and not show the PSBTs list. + // In case of any error loading the spend or if it doesn't exist, fall back to using the cache + // and load PSBTs list in usual way. + match self + .daemon + .list_spend_transactions(Some(&[*txid])) + .map(|txs| txs.first().cloned()) + { + Ok(Some(spend_tx)) => { + PsbtsPanel::new_preselected(self.wallet.clone(), spend_tx).into() + } + _ => PsbtsPanel::new(self.wallet.clone(), &self.cache.spend_txs).into(), + } + } menu::Menu::CreateSpendTx => CreateSpendPanel::new( self.wallet.clone(), &self.cache.coins, diff --git a/gui/src/app/state/psbts.rs b/gui/src/app/state/psbts.rs index 3023ba34b..04458271e 100644 --- a/gui/src/app/state/psbts.rs +++ b/gui/src/app/state/psbts.rs @@ -32,6 +32,18 @@ impl PsbtsPanel { import_tx: None, } } + + pub fn new_preselected(wallet: Arc, spend_tx: SpendTx) -> Self { + let psbt_state = psbt::PsbtState::new(wallet.clone(), spend_tx.clone(), true); + + Self { + wallet, + spend_txs: vec![spend_tx], + warning: None, + selected_tx: Some(psbt_state), + import_tx: None, + } + } } impl State for PsbtsPanel { @@ -120,7 +132,7 @@ impl State for PsbtsPanel { fn load(&self, daemon: Arc) -> Command { let daemon = daemon.clone(); Command::perform( - async move { daemon.list_spend_transactions().map_err(|e| e.into()) }, + async move { daemon.list_spend_transactions(None).map_err(|e| e.into()) }, Message::SpendTxs, ) } diff --git a/gui/src/app/state/transactions.rs b/gui/src/app/state/transactions.rs index 4bcb37b34..030ed4592 100644 --- a/gui/src/app/state/transactions.rs +++ b/gui/src/app/state/transactions.rs @@ -5,7 +5,11 @@ use std::{ }; use iced::Command; -use liana_ui::widget::*; +use liana::miniscript::bitcoin::Txid; +use liana_ui::{ + component::{form, modal::Modal}, + widget::*, +}; use crate::app::{ cache::Cache, @@ -27,6 +31,7 @@ pub struct TransactionsPanel { labels_edited: LabelsEdited, selected_tx: Option, warning: Option, + create_rbf_modal: Option, } impl TransactionsPanel { @@ -37,6 +42,7 @@ impl TransactionsPanel { pending_txs: Vec::new(), labels_edited: LabelsEdited::default(), warning: None, + create_rbf_modal: None, } } } @@ -49,12 +55,21 @@ impl State for TransactionsPanel { } else { &self.txs[i - self.pending_txs.len()] }; - view::transactions::tx_view( + let content = view::transactions::tx_view( cache, tx, self.labels_edited.cache(), self.warning.as_ref(), - ) + ); + if let Some(modal) = &self.create_rbf_modal { + Modal::new(content, modal.view()) + .on_blur(Some(view::Message::CreateRbf( + view::CreateRbfMessage::Cancel, + ))) + .into() + } else { + content + } } else { view::transactions::transactions_view( cache, @@ -100,6 +115,24 @@ impl State for TransactionsPanel { Message::View(view::Message::Select(i)) => { self.selected_tx = Some(i); } + Message::View(view::Message::CreateRbf(view::CreateRbfMessage::Cancel)) => { + self.create_rbf_modal = None; + } + Message::View(view::Message::CreateRbf(view::CreateRbfMessage::New(is_cancel))) => { + if let Some(idx) = self.selected_tx { + if let Some(tx) = self.pending_txs.get(idx) { + if let Some(fee_amount) = tx.fee_amount { + let prev_feerate_vb = fee_amount + .to_sat() + .checked_div(tx.tx.vsize().try_into().unwrap()) + .unwrap(); + let modal = + CreateRbfModal::new(tx.tx.txid(), is_cancel, prev_feerate_vb); + self.create_rbf_modal = Some(modal); + } + } + } + } Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { match self.labels_edited.update( daemon, @@ -154,7 +187,11 @@ impl State for TransactionsPanel { ); } } - _ => {} + _ => { + if let Some(modal) = &mut self.create_rbf_modal { + return modal.update(daemon, _cache, message); + } + } }; Command::none() } @@ -200,3 +237,93 @@ impl From for Box { Box::new(s) } } + +pub struct CreateRbfModal { + /// Transaction to replace. + txid: Txid, + /// Whether to cancel or bump fee. + is_cancel: bool, + /// Min feerate required for RBF. + min_feerate_vb: u64, + /// Feerate form value. + feerate_val: form::Value, + /// Parsed feerate. + feerate_vb: Option, + warning: Option, + /// Replacement transaction ID. + replacement_txid: Option, +} + +impl CreateRbfModal { + fn new(txid: Txid, is_cancel: bool, prev_feerate_vb: u64) -> Self { + let min_feerate_vb = prev_feerate_vb.checked_add(1).unwrap(); + Self { + txid, + is_cancel, + min_feerate_vb, + feerate_val: form::Value { + valid: true, + value: min_feerate_vb.to_string(), + }, + // For cancel, we let `rbfpsbt` set the feerate. + feerate_vb: if is_cancel { + None + } else { + Some(min_feerate_vb) + }, + warning: None, + replacement_txid: None, + } + } + + fn update( + &mut self, + daemon: Arc, + _cache: &Cache, + message: Message, + ) -> Command { + match message { + Message::View(view::Message::CreateRbf(view::CreateRbfMessage::FeerateEdited(s))) => { + self.warning = None; + if let Ok(value) = s.parse::() { + self.feerate_val.value = s; + self.feerate_val.valid = value >= self.min_feerate_vb; + if self.feerate_val.valid { + self.feerate_vb = Some(value); + } + } else { + self.feerate_val.valid = false; + } + if !self.feerate_val.valid { + self.feerate_vb = None; + } + } + Message::View(view::Message::CreateRbf(view::CreateRbfMessage::Confirm)) => { + self.warning = None; + + let psbt = match daemon.rbf_psbt(&self.txid, self.is_cancel, self.feerate_vb) { + Ok(res) => res.psbt, + Err(e) => { + self.warning = Some(e.into()); + return Command::none(); + } + }; + if let Err(e) = daemon.update_spend_tx(&psbt) { + self.warning = Some(e.into()); + return Command::none(); + } + self.replacement_txid = Some(psbt.unsigned_tx.txid()); + } + _ => {} + } + Command::none() + } + fn view(&self) -> Element { + view::transactions::create_rbf_modal( + self.is_cancel, + &self.feerate_val, + self.replacement_txid, + self.warning.as_ref(), + ) + } +} diff --git a/gui/src/app/view/message.rs b/gui/src/app/view/message.rs index d8d3033db..919441d26 100644 --- a/gui/src/app/view/message.rs +++ b/gui/src/app/view/message.rs @@ -17,6 +17,7 @@ pub enum Message { Next, Previous, SelectHardwareWallet(usize), + CreateRbf(CreateRbfMessage), } #[derive(Debug, Clone)] @@ -77,3 +78,11 @@ pub enum SettingsEditMessage { Cancel, Confirm, } + +#[derive(Debug, Clone)] +pub enum CreateRbfMessage { + New(bool), + FeerateEdited(String), + Cancel, + Confirm, +} diff --git a/gui/src/app/view/transactions.rs b/gui/src/app/view/transactions.rs index eb702c020..19d29cfe4 100644 --- a/gui/src/app/view/transactions.rs +++ b/gui/src/app/view/transactions.rs @@ -1,11 +1,11 @@ use chrono::NaiveDateTime; use std::collections::HashMap; -use iced::{alignment, Alignment, Length}; +use iced::{alignment, widget::tooltip, Alignment, Length}; use liana_ui::{ color, - component::{amount::*, badge, card, form, text::*}, + component::{amount::*, badge, button, card, form, text::*}, icon, theme, util::Collection, widget::*, @@ -16,9 +16,9 @@ use crate::{ cache::Cache, error::Error, menu::Menu, - view::{dashboard, label, message::Message}, + view::{dashboard, label, message::CreateRbfMessage, message::Message, warning::warn}, }, - daemon::model::HistoryTransaction, + daemon::model::{HistoryTransaction, Txid}, }; pub const HISTORY_EVENT_PAGE_SIZE: u64 = 20; @@ -157,6 +157,70 @@ fn tx_list_view(i: usize, tx: &HistoryTransaction) -> Element<'_, Message> { .into() } +pub fn create_rbf_modal<'a>( + is_cancel: bool, + feerate: &form::Value, + replacement_txid: Option, + warning: Option<&'a Error>, +) -> Element<'a, Message> { + let mut confirm_button = button::primary(None, "Confirm").width(Length::Fixed(200.0)); + if feerate.valid || is_cancel { + confirm_button = + confirm_button.on_press(Message::CreateRbf(super::CreateRbfMessage::Confirm)); + } + let help_text = if is_cancel { + "Replace the transaction with one paying a higher feerate that sends the coins back to us. There is no guarantee the original transaction won't get mined first. New inputs may be used for the replacement transaction." + } else { + "Replace the transaction with one paying a higher feerate to incentivize faster confirmation. New inputs may be used for the replacement transaction." + }; + card::simple( + Column::new() + .spacing(10) + .push(Container::new(h4_bold("Transaction replacement")).width(Length::Fill)) + .push(Row::new().push(text(help_text))) + .push_maybe(if !is_cancel { + Some( + Row::new() + .push(Container::new(p1_bold("Feerate")).padding(10)) + .spacing(10) + .push( + form::Form::new_trimmed("", feerate, move |msg| { + Message::CreateRbf(CreateRbfMessage::FeerateEdited(msg)) + }) + .warning("Invalid feerate") + .size(20) + .padding(10), + ) + .width(Length::Fill), + ) + } else { + None + }) + .push(warn(warning)) + .push(Row::new().push(if replacement_txid.is_none() { + Row::new().push(confirm_button) + } else { + Row::new() + .spacing(10) + .align_items(Alignment::Center) + .push(icon::circle_check_icon().style(color::GREEN)) + .push( + text("Replacement PSBT created successfully and ready to be signed") + .style(color::GREEN), + ) + })) + .push_maybe(replacement_txid.map(|id| { + Row::new().push( + button::primary(None, "Go to replacement") + .width(Length::Fixed(200.0)) + .on_press(Message::Menu(Menu::PsbtPreSelected(id))), + ) + })), + ) + .width(Length::Fixed(600.0)) + .into() +} + pub fn tx_view<'a>( cache: &'a Cache, tx: &'a HistoryTransaction, @@ -221,6 +285,30 @@ pub fn tx_view<'a>( })), ), ) + // If unconfirmed, give option to use RBF. + // Check fee amount is some as otherwise we may be missing coins for this transaction. + .push_maybe(if tx.time.is_none() && tx.fee_amount.is_some() { + Some( + Row::new() + .push( + button::primary(None, "Bump fee") + .width(Length::Fixed(200.0)) + .on_press(Message::CreateRbf(super::CreateRbfMessage::New(false))), + ) + .push( + tooltip::Tooltip::new( + button::primary(None, "Cancel transaction") + .width(Length::Fixed(200.0)) + .on_press(Message::CreateRbf(super::CreateRbfMessage::New(true))), + "Best effort attempt at double spending an unconfirmed outgoing transaction", + tooltip::Position::Top, + ) + ) + .spacing(10), + ) + } else { + None + }) .push(card::simple( Column::new() .push_maybe(tx.time.map(|t| { diff --git a/gui/src/daemon/client/mod.rs b/gui/src/daemon/client/mod.rs index 32268a7e5..6d33695c7 100644 --- a/gui/src/daemon/client/mod.rs +++ b/gui/src/daemon/client/mod.rs @@ -96,6 +96,22 @@ impl Daemon for Lianad { ) } + fn rbf_psbt( + &self, + txid: &Txid, + is_cancel: bool, + feerate_vb: Option, + ) -> Result { + self.call( + "rbfpsbt", + Some(vec![ + json!(txid.to_string()), + json!(is_cancel.to_string()), + json!(feerate_vb), + ]), + ) + } + fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError> { let spend_tx = base64::encode(psbt.serialize()); let _res: serde_json::value::Value = self.call("updatespend", Some(vec![spend_tx]))?; diff --git a/gui/src/daemon/embedded.rs b/gui/src/daemon/embedded.rs index 57367adc1..a07d0bbe4 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -89,13 +89,24 @@ impl Daemon for EmbeddedDaemon { feerate_vb: u64, ) -> Result { self.control()? - .create_spend(destinations, coins_outpoints, feerate_vb) + .create_spend(destinations, coins_outpoints, feerate_vb, None) .map_err(|e| match e { CommandError::CoinSelectionError(_) => DaemonError::CoinSelectionError, e => DaemonError::Unexpected(e.to_string()), }) } + fn rbf_psbt( + &self, + txid: &Txid, + is_cancel: bool, + feerate_vb: Option, + ) -> Result { + self.control()? + .rbf_psbt(txid, is_cancel, feerate_vb) + .map_err(|e| DaemonError::Unexpected(e.to_string())) + } + fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError> { self.control()? .update_spend(psbt.clone()) diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index 9191b9a93..384526064 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -63,6 +63,12 @@ pub trait Daemon: Debug { destinations: &HashMap, u64>, feerate_vb: u64, ) -> Result; + fn rbf_psbt( + &self, + txid: &Txid, + is_cancel: bool, + feerate_vb: Option, + ) -> Result; fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError>; fn delete_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError>; fn broadcast_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError>; @@ -87,11 +93,21 @@ pub trait Daemon: Debug { fn update_labels(&self, labels: &HashMap>) -> Result<(), DaemonError>; - fn list_spend_transactions(&self) -> Result, DaemonError> { + // List spend transactions, optionally filtered to the specified `txids`. + // Set `txids` to `None` for no filter (passing an empty slice returns no transactions). + fn list_spend_transactions( + &self, + txids: Option<&[Txid]>, + ) -> Result, DaemonError> { let info = self.get_info()?; let coins = self.list_coins()?.coins; let mut spend_txs = Vec::new(); for tx in self.list_spend_txs()?.spend_txs { + if let Some(txids) = txids { + if !txids.contains(&tx.psbt.unsigned_tx.txid()) { + continue; + } + } let coins = coins .iter() .filter(|coin| { diff --git a/gui/src/loader.rs b/gui/src/loader.rs index 6898be6ce..99441b9a6 100644 --- a/gui/src/loader.rs +++ b/gui/src/loader.rs @@ -370,7 +370,7 @@ pub async fn load_application( Wallet::new(info.descriptors.main).load_settings(&gui_config, &datadir_path, network)?; let coins = daemon.list_coins().map(|res| res.coins)?; - let spend_txs = daemon.list_spend_transactions()?; + let spend_txs = daemon.list_spend_transactions(None)?; let cache = Cache { datadir_path,