gui: enable use of RBF on pending transactions
This commit is contained in:
parent
ce50dd8c41
commit
71fd9c4900
@ -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),
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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| {
|
||||
|
||||
@ -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]))?;
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user