From 2f9568c3a8c125f73386bd18e7ce7b3a018ddfc2 Mon Sep 17 00:00:00 2001 From: jp1ac4 <121959000+jp1ac4@users.noreply.github.com> Date: Tue, 28 Nov 2023 19:59:11 +0000 Subject: [PATCH] gui: enable use of RBF on pending transactions --- gui/src/app/menu.rs | 3 +- gui/src/app/mod.rs | 4 + gui/src/app/state/psbts.rs | 31 ++++++- gui/src/app/state/transactions.rs | 134 +++++++++++++++++++++++++++++- gui/src/app/view/message.rs | 9 ++ gui/src/app/view/transactions.rs | 94 ++++++++++++++++++++- gui/src/daemon/client/mod.rs | 16 ++++ gui/src/daemon/embedded.rs | 11 +++ gui/src/daemon/mod.rs | 6 ++ 9 files changed, 298 insertions(+), 10 deletions(-) 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..46159fb09 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -95,6 +95,10 @@ 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) => { + PsbtsPanel::new_preselected_txid(self.wallet.clone(), &self.cache.spend_txs, txid) + .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..78e286193 100644 --- a/gui/src/app/state/psbts.rs +++ b/gui/src/app/state/psbts.rs @@ -11,7 +11,10 @@ use liana_ui::{ use super::{psbt, State}; use crate::{ app::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet}, - daemon::{model::SpendTx, Daemon}, + daemon::{ + model::{SpendTx, Txid}, + Daemon, + }, }; pub struct PsbtsPanel { @@ -20,6 +23,7 @@ pub struct PsbtsPanel { spend_txs: Vec, warning: Option, import_tx: Option, + preselected_txid: Option, } impl PsbtsPanel { @@ -30,6 +34,18 @@ impl PsbtsPanel { warning: None, selected_tx: None, import_tx: None, + preselected_txid: None, + } + } + + pub fn new_preselected_txid(wallet: Arc, spend_txs: &[SpendTx], txid: &Txid) -> Self { + Self { + wallet, + spend_txs: spend_txs.to_vec(), + warning: None, + selected_tx: None, + import_tx: None, + preselected_txid: Some(*txid), } } } @@ -71,6 +87,19 @@ impl State for PsbtsPanel { Ok(txs) => { self.warning = None; self.spend_txs = txs; + if let Some(txid) = self.preselected_txid { + if let Some(spend_tx) = self + .spend_txs + .iter() + .find(|spend_tx| spend_tx.psbt.unsigned_tx.txid() == txid) + { + let psbt_state = + psbt::PsbtState::new(self.wallet.clone(), spend_tx.clone(), true); + let cmd = psbt_state.load(daemon); + self.selected_tx = Some(psbt_state); + return cmd; + } + } } }, Message::View(view::Message::ImportSpend(view::ImportSpendMessage::Import)) => { diff --git a/gui/src/app/state/transactions.rs b/gui/src/app/state/transactions.rs index 4bcb37b34..4854abf73 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,23 @@ 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) { + let prev_feerate_vb = tx + .fee_amount + .expect("fee_amount must be set for pending transaction") + .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 +186,11 @@ impl State for TransactionsPanel { ); } } - _ => {} + _ => { + if let Some(modal) = &mut self.create_rbf_modal { + return modal.update(daemon, _cache, message); + } + } }; Command::none() } @@ -200,3 +236,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..7728a09c4 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,28 @@ pub fn tx_view<'a>( })), ), ) + .push_maybe(if tx.time.is_none() { + 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 02b5ccf81..a07d0bbe4 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -96,6 +96,17 @@ impl Daemon for EmbeddedDaemon { }) } + 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..c77244d87 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>;