Merge #852: gui: enable use of RBF on unconfirmed transactions

71fd9c4900bb1bf6f263867eb85b6e81da6c4b1e gui: enable use of RBF on pending transactions (jp1ac4)
ce50dd8c41522a2f45c5bc79505762857c8f8bcd gui: optionally filter spend transactions by txids (jp1ac4)
4846a0b05a9ea32d1fd3bb7852b3b74cee99daeb gui: update liana dependency (jp1ac4)

Pull request description:

  This is to resolve #43.

  When viewing an unconfirmed transaction, a user can now either bump its fee or cancel it. This will generate a new PSBT that the user can jump to in order to sign and broadcast it.

  I haven't added any comparison between the previous and replacement inputs as suggested in https://github.com/wizardsardine/liana/issues/43#issuecomment-1825623520. I think that would be a bigger change and might be better as a follow-up.

  I decided not to add "Unconfirmed" on the transaction screen as suggested in https://github.com/wizardsardine/liana/issues/43#issuecomment-1831763013 as I thought it might be better as a separate PR.

  I haven't yet added the RBF buttons to the home screen, but that could also be done as a follow-up :)

  In a separate commit, I pass `None` to `create_spend` following #821.

ACKs for top commit:
  darosior:
    tested-but-not-review ACK 71fd9c4900bb1bf6f263867eb85b6e81da6c4b1e
  edouardparis:
    ACK 71fd9c4900bb1bf6f263867eb85b6e81da6c4b1e

Tree-SHA512: c3ddbb85ad008e9e450b79ba77816ad9065f1eec675913f20463c4271ff017d5cb9ff0a0fca9ed919c97b3f6bb2b806344dc4ff062f5393ad5ac85c8c039ab83
This commit is contained in:
Antoine Poinsot 2023-12-08 11:41:39 +01:00
commit 321104531b
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
11 changed files with 313 additions and 16 deletions

7
gui/Cargo.lock generated
View File

@ -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",

View File

@ -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<OutPoint>),
PsbtPreSelected(Txid),
}

View File

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

View File

@ -32,6 +32,18 @@ impl PsbtsPanel {
import_tx: None,
}
}
pub fn new_preselected(wallet: Arc<Wallet>, 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<dyn Daemon + Sync + Send>) -> Command<Message> {
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,
)
}

View File

@ -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<usize>,
warning: Option<Error>,
create_rbf_modal: Option<CreateRbfModal>,
}
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<TransactionsPanel> for Box<dyn State> {
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<String>,
/// Parsed feerate.
feerate_vb: Option<u64>,
warning: Option<Error>,
/// Replacement transaction ID.
replacement_txid: Option<Txid>,
}
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<dyn Daemon + Sync + Send>,
_cache: &Cache,
message: Message,
) -> Command<Message> {
match message {
Message::View(view::Message::CreateRbf(view::CreateRbfMessage::FeerateEdited(s))) => {
self.warning = None;
if let Ok(value) = s.parse::<u64>() {
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::Message> {
view::transactions::create_rbf_modal(
self.is_cancel,
&self.feerate_val,
self.replacement_txid,
self.warning.as_ref(),
)
}
}

View File

@ -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,
}

View File

@ -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<String>,
replacement_txid: Option<Txid>,
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| {

View File

@ -96,6 +96,22 @@ impl<C: Client + Debug> Daemon for Lianad<C> {
)
}
fn rbf_psbt(
&self,
txid: &Txid,
is_cancel: bool,
feerate_vb: Option<u64>,
) -> Result<CreateSpendResult, DaemonError> {
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]))?;

View File

@ -89,13 +89,24 @@ impl Daemon for EmbeddedDaemon {
feerate_vb: u64,
) -> Result<CreateSpendResult, DaemonError> {
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<u64>,
) -> Result<CreateSpendResult, DaemonError> {
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())

View File

@ -63,6 +63,12 @@ pub trait Daemon: Debug {
destinations: &HashMap<Address<address::NetworkUnchecked>, u64>,
feerate_vb: u64,
) -> Result<model::CreateSpendResult, DaemonError>;
fn rbf_psbt(
&self,
txid: &Txid,
is_cancel: bool,
feerate_vb: Option<u64>,
) -> Result<model::CreateSpendResult, DaemonError>;
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<LabelItem, Option<String>>)
-> Result<(), DaemonError>;
fn list_spend_transactions(&self) -> Result<Vec<model::SpendTx>, 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<Vec<model::SpendTx>, 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| {

View File

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