Skip to content

Commit b0b40b1

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 - Maintains payment history consistency across wallet updates - Includes integration tests for various RBF scenarios
1 parent 38f9a87 commit b0b40b1

File tree

6 files changed

+363
-23
lines changed

6 files changed

+363
-23
lines changed

bindings/ldk_node.udl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,8 @@ interface OnchainPayment {
277277
Txid send_to_address([ByRef]Address address, u64 amount_sats, FeeRate? fee_rate);
278278
[Throws=NodeError]
279279
Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate);
280+
[Throws=NodeError]
281+
Txid bump_fee_rbf(PaymentId payment_id, FeeRate? fee_rate);
280282
};
281283

282284
interface FeeRate {

src/payment/onchain.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use std::sync::{Arc, RwLock};
1111

1212
use bitcoin::{Address, Txid};
13+
use lightning::ln::channelmanager::PaymentId;
1314

1415
use crate::config::Config;
1516
use crate::error::Error;
@@ -120,4 +121,21 @@ impl OnchainPayment {
120121
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
121122
self.wallet.send_to_address(address, send_amount, fee_rate_opt)
122123
}
124+
125+
/// Attempt to bump the fee of an unconfirmed transaction using Replace-by-Fee (RBF).
126+
///
127+
/// This creates a new transaction that replaces the original one, increasing the fee by the
128+
/// specified increment to improve its chances of confirmation. The original transaction must
129+
/// be signaling RBF replaceability for this to succeed.
130+
///
131+
/// The new transaction will have the same outputs as the original but with a
132+
/// higher fee, resulting in faster confirmation potential.
133+
///
134+
/// Returns the Txid of the new replacement transaction if successful.
135+
pub fn bump_fee_rbf(
136+
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
137+
) -> Result<Txid, Error> {
138+
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
139+
self.wallet.bump_fee_rbf(payment_id, fee_rate_opt)
140+
}
123141
}

src/payment/pending_payment_store.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,11 @@ impl StorableObjectUpdate<PendingPaymentDetails> for PendingPaymentDetailsUpdate
8484

8585
impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate {
8686
fn from(value: &PendingPaymentDetails) -> Self {
87-
Self {
88-
id: value.id(),
89-
payment_update: Some(value.details.to_update()),
90-
conflicting_txids: Some(value.conflicting_txids.clone()),
91-
}
87+
let conflicting_txids = if value.conflicting_txids.is_empty() {
88+
None
89+
} else {
90+
Some(value.conflicting_txids.clone())
91+
};
92+
Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids }
9293
}
9394
}

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: 207 additions & 14 deletions
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;
@@ -30,7 +31,9 @@ use bitcoin::{
3031
WitnessProgram, WitnessVersion,
3132
};
3233

33-
use lightning::chain::chaininterface::BroadcasterInterface;
34+
use lightning::chain::chaininterface::{
35+
BroadcasterInterface, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT,
36+
};
3437
use lightning::chain::channelmonitor::ANTI_REORG_DELAY;
3538
use lightning::chain::{BestBlock, Listen};
3639
use lightning::events::bump_transaction::{Input, Utxo, WalletSource};
@@ -259,16 +262,23 @@ impl Wallet {
259262
confirmation_status,
260263
);
261264

262-
let pending_payment =
263-
self.create_pending_payment_from_tx(payment.clone(), Vec::new());
265+
self.payment_store.insert_or_update(payment.clone())?;
264266

265-
self.payment_store.insert_or_update(payment)?;
266-
self.pending_payment_store.insert_or_update(pending_payment)?;
267+
if payment_status == PaymentStatus::Pending {
268+
let pending_payment =
269+
self.create_pending_payment_from_tx(payment, Vec::new());
270+
271+
self.pending_payment_store.insert_or_update(pending_payment)?;
272+
}
267273
},
268274
WalletEvent::ChainTipChanged { new_tip, .. } => {
269-
// Get all on-chain payments that are Pending
270275
let pending_payments: Vec<PendingPaymentDetails> =
271276
self.pending_payment_store.list_filter(|p| {
277+
debug_assert!(
278+
p.details.status == PaymentStatus::Pending,
279+
"Non-pending payment {:?} found in pending store",
280+
p.details.id,
281+
);
272282
p.details.status == PaymentStatus::Pending
273283
&& matches!(p.details.kind, PaymentKind::Onchain { .. })
274284
});
@@ -286,6 +296,11 @@ impl Wallet {
286296
payment.details.status = PaymentStatus::Succeeded;
287297
self.payment_store.insert_or_update(payment.details)?;
288298
self.pending_payment_store.remove(&payment_id)?;
299+
debug_assert!(
300+
!self.pending_payment_store.contains_key(&payment_id),
301+
"Payment {:?} still in pending store after removal",
302+
payment_id,
303+
);
289304
}
290305
},
291306
PaymentKind::Onchain {
@@ -307,7 +322,16 @@ impl Wallet {
307322
.collect();
308323

309324
if !txs_to_broadcast.is_empty() {
310-
let tx_refs: Vec<&Transaction> = txs_to_broadcast.iter().collect();
325+
let tx_refs: Vec<(
326+
&Transaction,
327+
lightning::chain::chaininterface::TransactionType,
328+
)> =
329+
txs_to_broadcast
330+
.iter()
331+
.map(|tx| {
332+
(tx, lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] })
333+
})
334+
.collect();
311335
self.broadcaster.broadcast_transactions(&tx_refs);
312336
log_info!(
313337
self.logger,
@@ -335,7 +359,7 @@ impl Wallet {
335359
self.payment_store.insert_or_update(payment)?;
336360
self.pending_payment_store.insert_or_update(pending_payment)?;
337361
},
338-
WalletEvent::TxReplaced { txid, conflicts, tx, .. } => {
362+
WalletEvent::TxReplaced { txid, conflicts, .. } => {
339363
let payment_id = self
340364
.find_payment_by_txid(txid)
341365
.unwrap_or_else(|| PaymentId(txid.to_byte_array()));
@@ -344,14 +368,14 @@ impl Wallet {
344368
let conflict_txids: Vec<Txid> =
345369
conflicts.iter().map(|(_, conflict_txid)| *conflict_txid).collect();
346370

347-
let payment = self.create_payment_from_tx(
348-
locked_wallet,
349-
txid,
371+
// We fetch payment details here since the replacement has updated the stored state
372+
debug_assert!(
373+
self.payment_store.get(&payment_id).is_some(),
374+
"Payment {:?} expected in store during WalletEvent::TxReplaced but not found",
350375
payment_id,
351-
&tx,
352-
PaymentStatus::Pending,
353-
ConfirmationStatus::Unconfirmed,
354376
);
377+
let payment =
378+
self.payment_store.get(&payment_id).ok_or(Error::InvalidPaymentId)?;
355379
let pending_payment_details = self
356380
.create_pending_payment_from_tx(payment.clone(), conflict_txids.clone());
357381

@@ -1026,6 +1050,175 @@ impl Wallet {
10261050

10271051
None
10281052
}
1053+
1054+
#[allow(deprecated)]
1055+
pub(crate) fn bump_fee_rbf(
1056+
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
1057+
) -> Result<Txid, Error> {
1058+
let payment = self.payment_store.get(&payment_id).ok_or(Error::InvalidPaymentId)?;
1059+
1060+
if let PaymentKind::Onchain { status, .. } = &payment.kind {
1061+
match status {
1062+
ConfirmationStatus::Confirmed { .. } => {
1063+
log_error!(
1064+
self.logger,
1065+
"Transaction {} is already confirmed and cannot be fee bumped",
1066+
payment_id
1067+
);
1068+
return Err(Error::InvalidPaymentId);
1069+
},
1070+
ConfirmationStatus::Unconfirmed => {},
1071+
}
1072+
}
1073+
1074+
if payment.direction != PaymentDirection::Outbound {
1075+
log_error!(self.logger, "Transaction {} is not an outbound payment", payment_id);
1076+
return Err(Error::InvalidPaymentId);
1077+
}
1078+
1079+
let txid = match &payment.kind {
1080+
PaymentKind::Onchain { txid, .. } => *txid,
1081+
_ => return Err(Error::InvalidPaymentId),
1082+
};
1083+
1084+
let mut locked_wallet = self.inner.lock().unwrap();
1085+
1086+
debug_assert!(
1087+
locked_wallet.tx_details(txid).is_some(),
1088+
"Transaction {} expected in wallet but not found",
1089+
txid,
1090+
);
1091+
let old_tx =
1092+
locked_wallet.tx_details(txid).ok_or(Error::InvalidPaymentId)?.tx.deref().clone();
1093+
1094+
let old_fee_rate = locked_wallet.calculate_fee_rate(&old_tx).map_err(|e| {
1095+
log_error!(self.logger, "Failed to calculate fee rate of transaction {}: {}", txid, e);
1096+
Error::InvalidPaymentId
1097+
})?;
1098+
let old_fee_rate_sat_per_kwu = old_fee_rate.to_sat_per_kwu();
1099+
1100+
// BIP 125 requires the replacement to pay a higher fee rate than the original.
1101+
// The minimum increase is the incremental relay fee.
1102+
let min_required_fee_rate_sat_per_kwu =
1103+
old_fee_rate_sat_per_kwu + INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT as u64;
1104+
1105+
let confirmation_target = ConfirmationTarget::OnchainPayment;
1106+
let estimated_fee_rate =
1107+
fee_rate.unwrap_or_else(|| self.fee_estimator.estimate_fee_rate(confirmation_target));
1108+
1109+
// Use the higher of minimum RBF requirement or current network estimate
1110+
let final_fee_rate_sat_per_kwu =
1111+
min_required_fee_rate_sat_per_kwu.max(estimated_fee_rate.to_sat_per_kwu());
1112+
let final_fee_rate = FeeRate::from_sat_per_kwu(final_fee_rate_sat_per_kwu);
1113+
1114+
let mut psbt = {
1115+
let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| {
1116+
log_error!(self.logger, "BDK fee bump failed for {}: {:?}", txid, e);
1117+
match e {
1118+
BuildFeeBumpError::TransactionNotFound(_) => Error::InvalidPaymentId,
1119+
BuildFeeBumpError::TransactionConfirmed(_) => {
1120+
log_error!(self.logger, "Payment {} is already confirmed", payment_id);
1121+
Error::InvalidPaymentId
1122+
},
1123+
BuildFeeBumpError::IrreplaceableTransaction(_) => {
1124+
Error::OnchainTxCreationFailed
1125+
},
1126+
BuildFeeBumpError::FeeRateUnavailable => Error::FeerateEstimationUpdateFailed,
1127+
BuildFeeBumpError::UnknownUtxo(_) => Error::OnchainTxCreationFailed,
1128+
BuildFeeBumpError::InvalidOutputIndex(_) => Error::OnchainTxCreationFailed,
1129+
}
1130+
})?;
1131+
1132+
builder.fee_rate(final_fee_rate);
1133+
1134+
match builder.finish() {
1135+
Ok(psbt) => Ok(psbt),
1136+
Err(CreateTxError::FeeRateTooLow { required: required_fee_rate }) => {
1137+
log_info!(self.logger, "BDK requires higher fee rate: {}", required_fee_rate);
1138+
1139+
// BDK may require a higher fee rate than our estimate due to
1140+
// differences in UTXO selection or transaction weight calculations.
1141+
// We cap the retry at 1.5x our target fee rate as a safety bound
1142+
// to avoid overpaying.
1143+
let max_allowed_fee_rate = FeeRate::from_sat_per_kwu(
1144+
final_fee_rate_sat_per_kwu.saturating_mul(3).saturating_div(2),
1145+
);
1146+
if required_fee_rate > max_allowed_fee_rate {
1147+
log_error!( self.logger, "BDK required fee rate {} exceeds sanity cap {} (1.5x our estimate) for tx {}", required_fee_rate, max_allowed_fee_rate, txid );
1148+
return Err(Error::InvalidFeeRate);
1149+
}
1150+
1151+
let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| {
1152+
log_error!(self.logger, "BDK fee bump retry failed for {}: {:?}", txid, e);
1153+
Error::InvalidFeeRate
1154+
})?;
1155+
1156+
builder.fee_rate(required_fee_rate);
1157+
builder.finish().map_err(|e| {
1158+
log_error!(
1159+
self.logger,
1160+
"Failed to finish PSBT with required fee rate: {:?}",
1161+
e
1162+
);
1163+
Error::InvalidFeeRate
1164+
})
1165+
},
1166+
Err(e) => {
1167+
log_error!(self.logger, "Failed to create fee bump PSBT: {:?}", e);
1168+
Err(Error::InvalidFeeRate)
1169+
},
1170+
}?
1171+
};
1172+
1173+
match locked_wallet.sign(&mut psbt, SignOptions::default()) {
1174+
Ok(finalized) => {
1175+
if !finalized {
1176+
return Err(Error::OnchainTxCreationFailed);
1177+
}
1178+
},
1179+
Err(err) => {
1180+
log_error!(self.logger, "Failed to create transaction: {}", err);
1181+
return Err(err.into());
1182+
},
1183+
}
1184+
1185+
let mut locked_persister = self.persister.lock().unwrap();
1186+
locked_wallet.persist(&mut locked_persister).map_err(|e| {
1187+
log_error!(self.logger, "Failed to persist wallet: {}", e);
1188+
Error::PersistenceFailed
1189+
})?;
1190+
1191+
let fee_bumped_tx = psbt.extract_tx().map_err(|e| {
1192+
log_error!(self.logger, "Failed to extract transaction: {}", e);
1193+
e
1194+
})?;
1195+
1196+
let new_txid = fee_bumped_tx.compute_txid();
1197+
1198+
self.broadcaster.broadcast_transactions(&[(
1199+
&fee_bumped_tx,
1200+
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
1201+
)]);
1202+
1203+
let new_payment = self.create_payment_from_tx(
1204+
&locked_wallet,
1205+
new_txid,
1206+
payment.id,
1207+
&fee_bumped_tx,
1208+
PaymentStatus::Pending,
1209+
ConfirmationStatus::Unconfirmed,
1210+
);
1211+
1212+
let pending_payment_store =
1213+
self.create_pending_payment_from_tx(new_payment.clone(), Vec::new());
1214+
1215+
self.pending_payment_store.insert_or_update(pending_payment_store)?;
1216+
self.payment_store.insert_or_update(new_payment)?;
1217+
1218+
log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid);
1219+
1220+
Ok(new_txid)
1221+
}
10291222
}
10301223

10311224
impl Listen for Wallet {

0 commit comments

Comments
 (0)