gui: enable use of RBF on pending transactions

This commit is contained in:
jp1ac4 2023-11-28 19:59:11 +00:00
parent ce50dd8c41
commit 71fd9c4900
No known key found for this signature in database
GPG Key ID: A7ACD32423568D7B
9 changed files with 295 additions and 9 deletions

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 {

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

@ -96,6 +96,17 @@ impl Daemon for EmbeddedDaemon {
})
}
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>;