Skip to content

Commit b5b191b

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 8a9caaf commit b5b191b

File tree

6 files changed

+351
-23
lines changed

6 files changed

+351
-23
lines changed

bindings/ldk_node.udl

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

281283
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: 195 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};
@@ -238,16 +241,23 @@ impl Wallet {
238241
confirmation_status,
239242
);
240243

241-
let pending_payment =
242-
self.create_pending_payment_from_tx(payment.clone(), Vec::new());
244+
self.payment_store.insert_or_update(payment.clone())?;
243245

244-
self.payment_store.insert_or_update(payment)?;
245-
self.pending_payment_store.insert_or_update(pending_payment)?;
246+
if payment_status == PaymentStatus::Pending {
247+
let pending_payment =
248+
self.create_pending_payment_from_tx(payment, Vec::new());
249+
250+
self.pending_payment_store.insert_or_update(pending_payment)?;
251+
}
246252
},
247253
WalletEvent::ChainTipChanged { new_tip, .. } => {
248-
// Get all on-chain payments that are Pending
249254
let pending_payments: Vec<PendingPaymentDetails> =
250255
self.pending_payment_store.list_filter(|p| {
256+
debug_assert!(
257+
p.details.status == PaymentStatus::Pending,
258+
"Non-pending payment {:?} found in pending store",
259+
p.details.id,
260+
);
251261
p.details.status == PaymentStatus::Pending
252262
&& matches!(p.details.kind, PaymentKind::Onchain { .. })
253263
});
@@ -265,6 +275,11 @@ impl Wallet {
265275
payment.details.status = PaymentStatus::Succeeded;
266276
self.payment_store.insert_or_update(payment.details)?;
267277
self.pending_payment_store.remove(&payment_id)?;
278+
debug_assert!(
279+
!self.pending_payment_store.contains_key(&payment_id),
280+
"Payment {:?} still in pending store after removal",
281+
payment_id,
282+
);
268283
}
269284
},
270285
PaymentKind::Onchain {
@@ -286,7 +301,16 @@ impl Wallet {
286301
.collect();
287302

288303
if !txs_to_broadcast.is_empty() {
289-
let tx_refs: Vec<&Transaction> = txs_to_broadcast.iter().collect();
304+
let tx_refs: Vec<(
305+
&Transaction,
306+
lightning::chain::chaininterface::TransactionType,
307+
)> =
308+
txs_to_broadcast
309+
.iter()
310+
.map(|tx| {
311+
(tx, lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] })
312+
})
313+
.collect();
290314
self.broadcaster.broadcast_transactions(&tx_refs);
291315
log_info!(
292316
self.logger,
@@ -314,7 +338,7 @@ impl Wallet {
314338
self.payment_store.insert_or_update(payment)?;
315339
self.pending_payment_store.insert_or_update(pending_payment)?;
316340
},
317-
WalletEvent::TxReplaced { txid, conflicts, tx, .. } => {
341+
WalletEvent::TxReplaced { txid, conflicts, .. } => {
318342
let payment_id = self
319343
.find_payment_by_txid(txid)
320344
.unwrap_or_else(|| PaymentId(txid.to_byte_array()));
@@ -323,14 +347,14 @@ impl Wallet {
323347
let conflict_txids: Vec<Txid> =
324348
conflicts.iter().map(|(_, conflict_txid)| *conflict_txid).collect();
325349

326-
let payment = self.create_payment_from_tx(
327-
locked_wallet,
328-
txid,
350+
// We fetch payment details here since the replacement has updated the stored state
351+
debug_assert!(
352+
self.payment_store.get(&payment_id).is_some(),
353+
"Payment {:?} expected in store during WalletEvent::TxReplaced but not found",
329354
payment_id,
330-
&tx,
331-
PaymentStatus::Pending,
332-
ConfirmationStatus::Unconfirmed,
333355
);
356+
let payment =
357+
self.payment_store.get(&payment_id).ok_or(Error::InvalidPaymentId)?;
334358
let pending_payment_details = self
335359
.create_pending_payment_from_tx(payment.clone(), conflict_txids.clone());
336360

@@ -1005,6 +1029,163 @@ impl Wallet {
10051029

10061030
None
10071031
}
1032+
1033+
#[allow(deprecated)]
1034+
pub(crate) fn bump_fee_rbf(
1035+
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>,
1036+
) -> Result<Txid, Error> {
1037+
let payment = self.payment_store.get(&payment_id).ok_or(Error::InvalidPaymentId)?;
1038+
1039+
let mut locked_wallet = self.inner.lock().unwrap();
1040+
1041+
if let PaymentKind::Onchain { status, .. } = &payment.kind {
1042+
match status {
1043+
ConfirmationStatus::Confirmed { .. } => {
1044+
log_error!(
1045+
self.logger,
1046+
"Transaction {} is already confirmed and cannot be fee bumped",
1047+
payment_id
1048+
);
1049+
return Err(Error::InvalidPaymentId);
1050+
},
1051+
ConfirmationStatus::Unconfirmed => {},
1052+
}
1053+
}
1054+
1055+
if payment.direction != PaymentDirection::Outbound {
1056+
log_error!(self.logger, "Transaction {} is not an outbound payment", payment_id);
1057+
return Err(Error::InvalidPaymentId);
1058+
}
1059+
1060+
let txid = match &payment.kind {
1061+
PaymentKind::Onchain { txid, .. } => *txid,
1062+
_ => return Err(Error::InvalidPaymentId),
1063+
};
1064+
1065+
debug_assert!(
1066+
locked_wallet.tx_details(txid).is_some(),
1067+
"Transaction {} expected in wallet but not found",
1068+
txid,
1069+
);
1070+
let old_tx =
1071+
locked_wallet.tx_details(txid).ok_or(Error::InvalidPaymentId)?.tx.deref().clone();
1072+
1073+
let old_fee_rate = locked_wallet.calculate_fee_rate(&old_tx).map_err(|e| {
1074+
log_error!(self.logger, "Failed to calculate fee rate of transaction {}: {}", txid, e);
1075+
Error::InvalidPaymentId
1076+
})?;
1077+
let old_fee_rate_sat_per_kwu = old_fee_rate.to_sat_per_kwu();
1078+
1079+
// BIP 125 requires the replacement to pay a higher fee rate than the original.
1080+
// The minimum increase is the incremental relay fee.
1081+
let min_required_fee_rate_sat_per_kwu =
1082+
old_fee_rate_sat_per_kwu + INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT as u64;
1083+
1084+
let confirmation_target = ConfirmationTarget::OnchainPayment;
1085+
let estimated_fee_rate =
1086+
fee_rate.unwrap_or_else(|| self.fee_estimator.estimate_fee_rate(confirmation_target));
1087+
1088+
// Use the higher of minimum RBF requirement or current network estimate
1089+
let final_fee_rate_sat_per_kwu =
1090+
min_required_fee_rate_sat_per_kwu.max(estimated_fee_rate.to_sat_per_kwu());
1091+
let final_fee_rate = FeeRate::from_sat_per_kwu(final_fee_rate_sat_per_kwu);
1092+
1093+
let mut psbt = {
1094+
let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| {
1095+
log_error!(self.logger, "BDK fee bump failed for {}: {:?}", txid, e);
1096+
match e {
1097+
BuildFeeBumpError::TransactionNotFound(_) => Error::InvalidPaymentId,
1098+
BuildFeeBumpError::TransactionConfirmed(_) => {
1099+
log_error!(self.logger, "Payment {} is already confirmed", payment_id);
1100+
Error::InvalidPaymentId
1101+
},
1102+
BuildFeeBumpError::IrreplaceableTransaction(_) => {
1103+
Error::OnchainTxCreationFailed
1104+
},
1105+
BuildFeeBumpError::FeeRateUnavailable => Error::FeerateEstimationUpdateFailed,
1106+
BuildFeeBumpError::UnknownUtxo(_) => Error::OnchainTxCreationFailed,
1107+
BuildFeeBumpError::InvalidOutputIndex(_) => Error::OnchainTxCreationFailed,
1108+
}
1109+
})?;
1110+
1111+
builder.fee_rate(final_fee_rate);
1112+
1113+
match builder.finish() {
1114+
Ok(psbt) => Ok(psbt),
1115+
Err(CreateTxError::FeeRateTooLow { required: required_fee_rate }) => {
1116+
log_info!(self.logger, "BDK requires higher fee rate: {}", required_fee_rate);
1117+
1118+
let mut builder = locked_wallet.build_fee_bump(txid).map_err(|e| {
1119+
log_error!(self.logger, "BDK fee bump retry failed for {}: {:?}", txid, e);
1120+
Error::InvalidFeeRate
1121+
})?;
1122+
1123+
builder.fee_rate(required_fee_rate);
1124+
builder.finish().map_err(|e| {
1125+
log_error!(
1126+
self.logger,
1127+
"Failed to finish PSBT with required fee rate: {:?}",
1128+
e
1129+
);
1130+
Error::InvalidFeeRate
1131+
})
1132+
},
1133+
Err(e) => {
1134+
log_error!(self.logger, "Failed to create fee bump PSBT: {:?}", e);
1135+
Err(Error::InvalidFeeRate)
1136+
},
1137+
}?
1138+
};
1139+
1140+
match locked_wallet.sign(&mut psbt, SignOptions::default()) {
1141+
Ok(finalized) => {
1142+
if !finalized {
1143+
return Err(Error::OnchainTxCreationFailed);
1144+
}
1145+
},
1146+
Err(err) => {
1147+
log_error!(self.logger, "Failed to create transaction: {}", err);
1148+
return Err(err.into());
1149+
},
1150+
}
1151+
1152+
let mut locked_persister = self.persister.lock().unwrap();
1153+
locked_wallet.persist(&mut locked_persister).map_err(|e| {
1154+
log_error!(self.logger, "Failed to persist wallet: {}", e);
1155+
Error::PersistenceFailed
1156+
})?;
1157+
1158+
let fee_bumped_tx = psbt.extract_tx().map_err(|e| {
1159+
log_error!(self.logger, "Failed to extract transaction: {}", e);
1160+
e
1161+
})?;
1162+
1163+
let new_txid = fee_bumped_tx.compute_txid();
1164+
1165+
self.broadcaster.broadcast_transactions(&[(
1166+
&fee_bumped_tx,
1167+
lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] },
1168+
)]);
1169+
1170+
let new_payment = self.create_payment_from_tx(
1171+
&locked_wallet,
1172+
new_txid,
1173+
payment.id,
1174+
&fee_bumped_tx,
1175+
PaymentStatus::Pending,
1176+
ConfirmationStatus::Unconfirmed,
1177+
);
1178+
1179+
let pending_payment_store =
1180+
self.create_pending_payment_from_tx(new_payment.clone(), Vec::new());
1181+
1182+
self.pending_payment_store.insert_or_update(pending_payment_store)?;
1183+
self.payment_store.insert_or_update(new_payment)?;
1184+
1185+
log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid);
1186+
1187+
Ok(new_txid)
1188+
}
10081189
}
10091190

10101191
impl Listen for Wallet {

0 commit comments

Comments
 (0)