Skip to content

Commit 6e6e2aa

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 af0c16a commit 6e6e2aa

File tree

4 files changed

+302
-1
lines changed

4 files changed

+302
-1
lines changed

bindings/ldk_node.udl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ interface OnchainPayment {
234234
Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate);
235235
[Throws=NodeError]
236236
void rebroadcast_transaction(PaymentId payment_id);
237+
[Throws=NodeError]
238+
Txid bump_fee_rbf(PaymentId payment_id);
237239
};
238240

239241
interface FeeRate {

src/payment/onchain.rs

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

src/wallet/mod.rs

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// http://opensource.org/licenses/MIT>, at your option. You may not use this file except in
66
// accordance with one or both of these licenses.
77

8+
use bdk_wallet::error::{BuildFeeBumpError, CreateTxError};
89
use persist::KVStoreWalletPersister;
910

1011
use crate::config::{Config, RebroadcastPolicy};
@@ -789,6 +790,167 @@ where
789790
log_info!(self.logger, "No details found for payment {} in store", payment_id);
790791
return Err(Error::InvalidPaymentId);
791792
}
793+
794+
pub(crate) fn bump_fee_rbf(&self, payment_id: PaymentId) -> Result<Txid, Error> {
795+
let old_payment =
796+
self.payment_store.get(&payment_id).ok_or(Error::InvalidPaymentId)?.clone();
797+
798+
let mut locked_wallet = self.inner.lock().unwrap();
799+
800+
let txid = Txid::from_slice(&payment_id.0).expect("32 bytes");
801+
802+
let wallet_tx = locked_wallet.get_tx(txid).ok_or(Error::InvalidPaymentId)?;
803+
let (sent, received) = locked_wallet.sent_and_received(&wallet_tx.tx_node.tx);
804+
805+
if sent <= received {
806+
log_error!(
807+
self.logger,
808+
"Transaction {} is not an outbound payment (sent: {}, received: {})",
809+
txid,
810+
sent,
811+
received
812+
);
813+
return Err(Error::InvalidPaymentId);
814+
}
815+
816+
if old_payment.direction != PaymentDirection::Outbound {
817+
log_error!(self.logger, "Transaction {} is not an outbound payment", txid);
818+
return Err(Error::InvalidPaymentId);
819+
}
820+
821+
if let PaymentKind::Onchain { status, .. } = &old_payment.kind {
822+
match status {
823+
ConfirmationStatus::Confirmed { .. } => {
824+
log_error!(
825+
self.logger,
826+
"Transaction {} is already confirmed and cannot be fee bumped",
827+
txid
828+
);
829+
return Err(Error::InvalidPaymentId);
830+
},
831+
ConfirmationStatus::Unconfirmed => {},
832+
}
833+
}
834+
835+
let confirmation_target = ConfirmationTarget::OnchainPayment;
836+
let estimated_fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target);
837+
838+
log_info!(self.logger, "Bumping fee to {}", estimated_fee_rate);
839+
840+
let mut psbt = {
841+
let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| {
842+
log_error!(self.logger, "BDK fee bump failed for {}: {:?}", txid, e);
843+
match e {
844+
BuildFeeBumpError::TransactionNotFound(_) => Error::InvalidPaymentId,
845+
BuildFeeBumpError::TransactionConfirmed(_) => Error::InvalidPaymentId,
846+
BuildFeeBumpError::IrreplaceableTransaction(_) => Error::InvalidPaymentId,
847+
BuildFeeBumpError::FeeRateUnavailable => Error::InvalidPaymentId,
848+
_ => Error::InvalidFeeRate,
849+
}
850+
})?;
851+
852+
builder.fee_rate(estimated_fee_rate);
853+
854+
match builder.finish() {
855+
Ok(psbt) => Ok(psbt),
856+
Err(CreateTxError::FeeRateTooLow { required }) => {
857+
log_info!(self.logger, "BDK requires higher fee rate: {}", required);
858+
859+
// Safety check
860+
const MAX_REASONABLE_FEE_RATE_SAT_VB: u64 = 1000;
861+
if required.to_sat_per_vb_ceil() > MAX_REASONABLE_FEE_RATE_SAT_VB {
862+
log_error!(
863+
self.logger,
864+
"BDK requires unreasonably high fee rate: {} sat/vB",
865+
required.to_sat_per_vb_ceil()
866+
);
867+
return Err(Error::InvalidFeeRate);
868+
}
869+
870+
let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| {
871+
log_error!(self.logger, "BDK fee bump retry failed for {}: {:?}", txid, e);
872+
Error::InvalidFeeRate
873+
})?;
874+
875+
builder.fee_rate(required);
876+
builder.finish().map_err(|e| {
877+
log_error!(
878+
self.logger,
879+
"Failed to finish PSBT with required fee rate: {:?}",
880+
e
881+
);
882+
Error::InvalidFeeRate
883+
})
884+
},
885+
Err(e) => {
886+
log_error!(self.logger, "Failed to create fee bump PSBT: {:?}", e);
887+
Err(Error::InvalidFeeRate)
888+
},
889+
}?
890+
};
891+
892+
match locked_wallet.sign(&mut psbt, SignOptions::default()) {
893+
Ok(finalized) => {
894+
if !finalized {
895+
return Err(Error::OnchainTxCreationFailed);
896+
}
897+
},
898+
Err(err) => {
899+
log_error!(self.logger, "Failed to create transaction: {}", err);
900+
return Err(err.into());
901+
},
902+
}
903+
904+
let mut locked_persister = self.persister.lock().unwrap();
905+
locked_wallet.persist(&mut locked_persister).map_err(|e| {
906+
log_error!(self.logger, "Failed to persist wallet: {}", e);
907+
Error::PersistenceFailed
908+
})?;
909+
910+
let fee_bumped_tx = psbt.extract_tx().map_err(|e| {
911+
log_error!(self.logger, "Failed to extract transaction: {}", e);
912+
e
913+
})?;
914+
915+
let new_txid = fee_bumped_tx.compute_txid();
916+
917+
self.broadcaster.broadcast_transactions(&[&fee_bumped_tx]);
918+
919+
let new_fee = locked_wallet.calculate_fee(&fee_bumped_tx).unwrap_or(Amount::ZERO);
920+
let new_fee_sats = new_fee.to_sat();
921+
922+
let payment_details = PaymentDetails {
923+
id: PaymentId(new_txid.to_byte_array()),
924+
kind: PaymentKind::Onchain {
925+
txid: new_txid,
926+
status: ConfirmationStatus::Unconfirmed,
927+
raw_tx: Some(fee_bumped_tx),
928+
last_broadcast_time: Some(
929+
SystemTime::now()
930+
.duration_since(UNIX_EPOCH)
931+
.unwrap_or(Duration::from_secs(0))
932+
.as_secs(),
933+
),
934+
broadcast_attempts: Some(1),
935+
},
936+
amount_msat: old_payment.amount_msat,
937+
fee_paid_msat: Some(new_fee_sats * 1000),
938+
direction: old_payment.direction,
939+
status: PaymentStatus::Pending,
940+
latest_update_timestamp: SystemTime::now()
941+
.duration_since(UNIX_EPOCH)
942+
.unwrap_or(Duration::from_secs(0))
943+
.as_secs(),
944+
};
945+
946+
self.payment_store.remove(&payment_id)?;
947+
948+
self.payment_store.insert_or_update(payment_details)?;
949+
950+
log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid);
951+
952+
Ok(new_txid)
953+
}
792954
}
793955

794956
impl<B: Deref, E: Deref, L: Deref> Listen for Wallet<B, E, L>

tests/integration_tests_rust.rs

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ use lightning::util::persist::KVStore;
3333
use lightning_invoice::{Bolt11InvoiceDescription, Description};
3434
use lightning_types::payment::{PaymentHash, PaymentPreimage};
3535

36-
use bitcoin::address::NetworkUnchecked;
3736
use bitcoin::hashes::sha256::Hash as Sha256Hash;
3837
use bitcoin::hashes::Hash;
38+
use bitcoin::{address::NetworkUnchecked, Txid};
3939
use bitcoin::{Address, Amount, ScriptBuf};
4040
use log::LevelFilter;
4141

@@ -671,6 +671,129 @@ fn onchain_wallet_recovery() {
671671
);
672672
}
673673

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

0 commit comments

Comments
 (0)