Skip to content

Commit aa1558a

Browse files
committed
Implement RBF fee bumping for unconfirmed transactions
Add `Replace-by-Fee` functionality to allow users to increase fees on pending outbound transactions, improving confirmation likelihood during network congestion. - Uses BDK's `build_fee_bump` for transaction replacement - Validates transaction eligibility: must be outbound and unconfirmed - Implements fee rate estimation with safety limits - Maintains payment history consistency across wallet updates - Includes integration tests for various RBF scenarios
1 parent 9b09bb1 commit aa1558a

5 files changed

Lines changed: 282 additions & 8 deletions

File tree

src/payment/onchain.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,18 @@ impl OnchainPayment {
133133
pub fn rebroadcast_transaction(&self, payment_id: PaymentId) -> Result<(), Error> {
134134
self.wallet.rebroadcast_transaction(payment_id)
135135
}
136+
137+
/// Attempt to bump the fee of an unconfirmed transaction using Replace-by-Fee (RBF).
138+
///
139+
/// This creates a new transaction that replaces the original one, increasing the fee by the
140+
/// specified increment to improve its chances of confirmation. The original transaction must
141+
/// be signaling RBF replaceability for this to succeed.
142+
///
143+
/// The new transaction will have the same outputs as the original but with a
144+
/// higher fee, resulting in faster confirmation potential.
145+
///
146+
/// Returns the Txid of the new replacement transaction if successful.
147+
pub fn bump_fee_rbf(&self, payment_id: PaymentId) -> Result<Txid, Error> {
148+
self.wallet.bump_fee_rbf(payment_id)
149+
}
136150
}

src/payment/pending_payment_store.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ impl StorableObject for PendingPaymentDetails {
8686
}
8787

8888
if let Some(new_conflicting_txids) = &update.conflicting_txids {
89-
update_if_necessary!(self.conflicting_txids, new_conflicting_txids.clone());
89+
// Don't overwrite existing conflicts with an empty list
90+
if !new_conflicting_txids.is_empty() {
91+
update_if_necessary!(self.conflicting_txids, new_conflicting_txids.clone());
92+
}
9093
}
9194

9295
if let Some(new_raw_tx) = &update.raw_tx {

src/payment/store.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,15 @@ impl StorableObject for PaymentDetails {
291291
}
292292
}
293293

294+
if let Some(tx_id) = update.txid {
295+
match self.kind {
296+
PaymentKind::Onchain { ref mut txid, .. } => {
297+
update_if_necessary!(*txid, tx_id);
298+
},
299+
_ => {},
300+
}
301+
}
302+
294303
if updated {
295304
self.latest_update_timestamp = SystemTime::now()
296305
.duration_since(UNIX_EPOCH)
@@ -540,6 +549,7 @@ pub(crate) struct PaymentDetailsUpdate {
540549
pub direction: Option<PaymentDirection>,
541550
pub status: Option<PaymentStatus>,
542551
pub confirmation_status: Option<ConfirmationStatus>,
552+
pub txid: Option<Txid>,
543553
}
544554

545555
impl PaymentDetailsUpdate {
@@ -555,6 +565,7 @@ impl PaymentDetailsUpdate {
555565
direction: None,
556566
status: None,
557567
confirmation_status: None,
568+
txid: None,
558569
}
559570
}
560571
}
@@ -570,9 +581,9 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
570581
_ => (None, None, None),
571582
};
572583

573-
let confirmation_status = match value.kind {
574-
PaymentKind::Onchain { status, .. } => Some(status),
575-
_ => None,
584+
let (confirmation_status, txid) = match &value.kind {
585+
PaymentKind::Onchain { status, txid, .. } => (Some(*status), Some(*txid)),
586+
_ => (None, None),
576587
};
577588

578589
let counterparty_skimmed_fee_msat = match value.kind {
@@ -593,6 +604,7 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
593604
direction: Some(value.direction),
594605
status: Some(value.status),
595606
confirmation_status,
607+
txid,
596608
}
597609
}
598610
}

src/wallet/mod.rs

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use std::sync::{Arc, Mutex};
1212

1313
use bdk_chain::spk_client::{FullScanRequest, SyncRequest};
1414
use bdk_wallet::descriptor::ExtendedDescriptor;
15+
use bdk_wallet::error::{BuildFeeBumpError, CreateTxError};
1516
use bdk_wallet::event::WalletEvent;
1617
#[allow(deprecated)]
1718
use bdk_wallet::SignOptions;
@@ -311,9 +312,11 @@ impl Wallet {
311312
let conflict_txids: Vec<Txid> =
312313
conflicts.iter().map(|(_, conflict_txid)| *conflict_txid).collect();
313314

315+
// Use the last transaction id in the conflicts as the new txid
316+
let new_txid = conflicts.last().map(|(_, new_tx)| *new_tx).unwrap_or(txid);
314317
let payment = self.create_payment_from_tx(
315318
locked_wallet,
316-
txid,
319+
new_txid,
317320
payment_id,
318321
&tx,
319322
PaymentStatus::Pending,
@@ -1116,6 +1119,145 @@ impl Wallet {
11161119
log_info!(self.logger, "No details found for payment {} in store", payment_id);
11171120
return Err(Error::InvalidPaymentId);
11181121
}
1122+
1123+
#[allow(deprecated)]
1124+
pub(crate) fn bump_fee_rbf(&self, payment_id: PaymentId) -> Result<Txid, Error> {
1125+
let payment = self.payment_store.get(&payment_id).ok_or(Error::InvalidPaymentId)?;
1126+
1127+
let mut locked_wallet = self.inner.lock().unwrap();
1128+
1129+
if payment.direction != PaymentDirection::Outbound {
1130+
log_error!(self.logger, "Transaction {} is not an outbound payment", payment_id);
1131+
return Err(Error::InvalidPaymentId);
1132+
}
1133+
1134+
if let PaymentKind::Onchain { status, .. } = &payment.kind {
1135+
match status {
1136+
ConfirmationStatus::Confirmed { .. } => {
1137+
log_error!(
1138+
self.logger,
1139+
"Transaction {} is already confirmed and cannot be fee bumped",
1140+
payment_id
1141+
);
1142+
return Err(Error::InvalidPaymentId);
1143+
},
1144+
ConfirmationStatus::Unconfirmed => {},
1145+
}
1146+
}
1147+
1148+
let txid = match &payment.kind {
1149+
PaymentKind::Onchain { txid, .. } => *txid,
1150+
_ => return Err(Error::InvalidPaymentId),
1151+
};
1152+
1153+
let confirmation_target = ConfirmationTarget::OnchainPayment;
1154+
let estimated_fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target);
1155+
1156+
log_info!(self.logger, "Bumping fee to {}", estimated_fee_rate);
1157+
1158+
let mut psbt = {
1159+
let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| {
1160+
log_error!(self.logger, "BDK fee bump failed for {}: {:?}", txid, e);
1161+
match e {
1162+
BuildFeeBumpError::TransactionNotFound(_) => Error::InvalidPaymentId,
1163+
BuildFeeBumpError::TransactionConfirmed(_) => Error::InvalidPaymentId,
1164+
BuildFeeBumpError::IrreplaceableTransaction(_) => Error::InvalidPaymentId,
1165+
BuildFeeBumpError::FeeRateUnavailable => Error::InvalidPaymentId,
1166+
_ => Error::InvalidFeeRate,
1167+
}
1168+
})?;
1169+
1170+
builder.fee_rate(estimated_fee_rate);
1171+
1172+
match builder.finish() {
1173+
Ok(psbt) => Ok(psbt),
1174+
Err(CreateTxError::FeeRateTooLow { required }) => {
1175+
log_info!(self.logger, "BDK requires higher fee rate: {}", required);
1176+
1177+
// Safety check
1178+
const MAX_REASONABLE_FEE_RATE_SAT_VB: u64 = 1000;
1179+
if required.to_sat_per_vb_ceil() > MAX_REASONABLE_FEE_RATE_SAT_VB {
1180+
log_error!(
1181+
self.logger,
1182+
"BDK requires unreasonably high fee rate: {} sat/vB",
1183+
required.to_sat_per_vb_ceil()
1184+
);
1185+
return Err(Error::InvalidFeeRate);
1186+
}
1187+
1188+
let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| {
1189+
log_error!(self.logger, "BDK fee bump retry failed for {}: {:?}", txid, e);
1190+
Error::InvalidFeeRate
1191+
})?;
1192+
1193+
builder.fee_rate(required);
1194+
builder.finish().map_err(|e| {
1195+
log_error!(
1196+
self.logger,
1197+
"Failed to finish PSBT with required fee rate: {:?}",
1198+
e
1199+
);
1200+
Error::InvalidFeeRate
1201+
})
1202+
},
1203+
Err(e) => {
1204+
log_error!(self.logger, "Failed to create fee bump PSBT: {:?}", e);
1205+
Err(Error::InvalidFeeRate)
1206+
},
1207+
}?
1208+
};
1209+
1210+
match locked_wallet.sign(&mut psbt, SignOptions::default()) {
1211+
Ok(finalized) => {
1212+
if !finalized {
1213+
return Err(Error::OnchainTxCreationFailed);
1214+
}
1215+
},
1216+
Err(err) => {
1217+
log_error!(self.logger, "Failed to create transaction: {}", err);
1218+
return Err(err.into());
1219+
},
1220+
}
1221+
1222+
let mut locked_persister = self.persister.lock().unwrap();
1223+
locked_wallet.persist(&mut locked_persister).map_err(|e| {
1224+
log_error!(self.logger, "Failed to persist wallet: {}", e);
1225+
Error::PersistenceFailed
1226+
})?;
1227+
1228+
let fee_bumped_tx = psbt.extract_tx().map_err(|e| {
1229+
log_error!(self.logger, "Failed to extract transaction: {}", e);
1230+
e
1231+
})?;
1232+
1233+
let new_txid = fee_bumped_tx.compute_txid();
1234+
1235+
self.broadcaster.broadcast_transactions(&[&fee_bumped_tx]);
1236+
1237+
let new_payment = self.create_payment_from_tx(
1238+
&locked_wallet,
1239+
new_txid,
1240+
payment.id,
1241+
&fee_bumped_tx,
1242+
PaymentStatus::Pending,
1243+
ConfirmationStatus::Unconfirmed,
1244+
);
1245+
1246+
let pending_payment_store = self.create_pending_payment_from_tx(
1247+
new_payment.clone(),
1248+
Vec::new(),
1249+
Some(&fee_bumped_tx),
1250+
Some(0),
1251+
Some(0),
1252+
);
1253+
1254+
self.pending_payment_store.insert_or_update(pending_payment_store)?;
1255+
self.payment_store.insert_or_update(new_payment)?;
1256+
1257+
log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid);
1258+
1259+
Ok(new_txid)
1260+
}
11191261
}
11201262

11211263
impl Listen for Wallet {

tests/integration_tests_rust.rs

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use std::sync::Arc;
1414
use bitcoin::address::NetworkUnchecked;
1515
use bitcoin::hashes::sha256::Hash as Sha256Hash;
1616
use bitcoin::hashes::Hash;
17-
use bitcoin::{Address, Amount, ScriptBuf};
17+
use bitcoin::{Address, Amount, ScriptBuf, Txid};
1818
use common::logging::{init_log_logger, validate_log_entry, MultiNodeLogger, TestLogWriter};
1919
use common::{
2020
bump_fee_and_broadcast, distribute_funds_unconfirmed, do_channel_full_cycle,
@@ -685,8 +685,111 @@ async fn onchain_wallet_recovery() {
685685
}
686686

687687
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
688-
async fn test_rbf_via_mempool() {
689-
run_rbf_test(false).await;
688+
async fn onchain_fee_bump_rbf() {
689+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
690+
let chain_source = TestChainSource::Esplora(&electrsd);
691+
let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false);
692+
693+
// Fund both nodes
694+
let addr_a = node_a.onchain_payment().new_address().unwrap();
695+
let addr_b = node_b.onchain_payment().new_address().unwrap();
696+
697+
let premine_amount_sat = 500_000;
698+
premine_and_distribute_funds(
699+
&bitcoind.client,
700+
&electrsd.client,
701+
vec![addr_a.clone(), addr_b.clone()],
702+
Amount::from_sat(premine_amount_sat),
703+
)
704+
.await;
705+
706+
node_a.sync_wallets().unwrap();
707+
node_b.sync_wallets().unwrap();
708+
709+
// Send a transaction from node_b to node_a that we'll later bump
710+
let amount_to_send_sats = 100_000;
711+
let txid =
712+
node_b.onchain_payment().send_to_address(&addr_a, amount_to_send_sats, None).unwrap();
713+
wait_for_tx(&electrsd.client, txid).await;
714+
node_a.sync_wallets().unwrap();
715+
node_b.sync_wallets().unwrap();
716+
717+
let payment_id = PaymentId(txid.to_byte_array());
718+
let original_payment = node_b.payment(&payment_id).unwrap();
719+
let original_fee = original_payment.fee_paid_msat.unwrap();
720+
721+
// Non-existent payment id
722+
let fake_txid =
723+
Txid::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap();
724+
let invalid_payment_id = PaymentId(fake_txid.to_byte_array());
725+
assert_eq!(
726+
Err(NodeError::InvalidPaymentId),
727+
node_b.onchain_payment().bump_fee_rbf(invalid_payment_id)
728+
);
729+
730+
// Bump an inbound payment
731+
assert_eq!(Err(NodeError::InvalidPaymentId), node_a.onchain_payment().bump_fee_rbf(payment_id));
732+
733+
// Successful fee bump
734+
let new_txid = node_b.onchain_payment().bump_fee_rbf(payment_id).unwrap();
735+
wait_for_tx(&electrsd.client, new_txid).await;
736+
737+
// Sleep to allow for transaction propagation
738+
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
739+
740+
node_a.sync_wallets().unwrap();
741+
node_b.sync_wallets().unwrap();
742+
743+
// Verify fee increased
744+
let new_payment = node_b.payment(&payment_id).unwrap();
745+
assert!(
746+
new_payment.fee_paid_msat > Some(original_fee),
747+
"Fee should increase after RBF bump. Original: {}, New: {}",
748+
original_fee,
749+
new_payment.fee_paid_msat.unwrap()
750+
);
751+
752+
// Multiple consecutive bumps
753+
let second_bump_txid = node_b.onchain_payment().bump_fee_rbf(payment_id).unwrap();
754+
wait_for_tx(&electrsd.client, second_bump_txid).await;
755+
756+
// Sleep to allow for transaction propagation
757+
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
758+
759+
node_a.sync_wallets().unwrap();
760+
node_b.sync_wallets().unwrap();
761+
762+
// Verify second bump payment exists
763+
let second_payment = node_b.payment(&payment_id).unwrap();
764+
assert!(
765+
second_payment.fee_paid_msat > new_payment.fee_paid_msat,
766+
"Second bump should have higher fee than first bump"
767+
);
768+
769+
// Confirm the transaction and try to bump again (should fail)
770+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
771+
node_a.sync_wallets().unwrap();
772+
node_b.sync_wallets().unwrap();
773+
774+
assert_eq!(Err(NodeError::InvalidPaymentId), node_b.onchain_payment().bump_fee_rbf(payment_id));
775+
776+
// Verify final payment is confirmed
777+
let final_payment = node_b.payment(&payment_id).unwrap();
778+
assert_eq!(final_payment.status, PaymentStatus::Succeeded);
779+
match final_payment.kind {
780+
PaymentKind::Onchain { status, .. } => {
781+
assert!(matches!(status, ConfirmationStatus::Confirmed { .. }));
782+
},
783+
_ => panic!("Unexpected payment kind"),
784+
}
785+
786+
// Verify node A received the funds correctly
787+
let node_a_received_payment = node_a.list_payments_with_filter(
788+
|p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == second_bump_txid),
789+
);
790+
assert_eq!(node_a_received_payment.len(), 1);
791+
assert_eq!(node_a_received_payment[0].amount_msat, Some(amount_to_send_sats * 1000));
792+
assert_eq!(node_a_received_payment[0].status, PaymentStatus::Succeeded);
690793
}
691794

692795
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]

0 commit comments

Comments
 (0)