Skip to content

Commit 39922cc

Browse files
committed
Enhance onchain transaction management
- Implement background job for rebroadcasting unconfirmed transactions with max attempt limit - Add RBF (Replace-by-Fee) functionality allowing users to bump fees on outbound unconfirmed transactions - Remove stale pending payments marked as PaymentStatus::Pending to prevent infinite pending states - Detect and handle double spends by removing replaced unconfirmed transactions from store
1 parent 86cc3d2 commit 39922cc

File tree

8 files changed

+907
-39
lines changed

8 files changed

+907
-39
lines changed

bindings/ldk_node.udl

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,20 @@ dictionary Config {
1313
u64 probing_liquidity_limit_multiplier;
1414
AnchorChannelsConfig? anchor_channels_config;
1515
SendingParameters? sending_parameters;
16+
RebroadcastPolicy rebroadcast_policy;
1617
};
1718

1819
dictionary AnchorChannelsConfig {
1920
sequence<PublicKey> trusted_peers_no_reserve;
2021
u64 per_channel_reserve_sats;
2122
};
2223

24+
dictionary RebroadcastPolicy {
25+
u64 min_rebroadcast_interval;
26+
u32 max_broadcast_attempts;
27+
f32 backoff_factor;
28+
};
29+
2330
dictionary BackgroundSyncConfig {
2431
u64 onchain_wallet_sync_interval_secs;
2532
u64 lightning_wallet_sync_interval_secs;
@@ -227,6 +234,10 @@ interface OnchainPayment {
227234
Txid send_to_address([ByRef]Address address, u64 amount_sats, FeeRate? fee_rate);
228235
[Throws=NodeError]
229236
Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate);
237+
[Throws=NodeError]
238+
void rebroadcast_transaction(Txid txid);
239+
[Throws=NodeError]
240+
Txid bump_fee_rbf(Txid txid, u64 fee_bump_increment);
230241
};
231242

232243
interface FeeRate {
@@ -404,7 +415,7 @@ interface ClosureReason {
404415

405416
[Enum]
406417
interface PaymentKind {
407-
Onchain(Txid txid, ConfirmationStatus status);
418+
Onchain(Txid txid, ConfirmationStatus status, sequence<u8>? raw_tx, u64? last_broadcast_time, u32 broadcast_attempts);
408419
Bolt11(PaymentHash hash, PaymentPreimage? preimage, PaymentSecret? secret);
409420
Bolt11Jit(PaymentHash hash, PaymentPreimage? preimage, PaymentSecret? secret, u64? counterparty_skimmed_fee_msat, LSPFeeLimits lsp_fee_limits);
410421
Bolt12Offer(PaymentHash? hash, PaymentPreimage? preimage, PaymentSecret? secret, OfferId offer_id, UntrustedString? payer_note, u64? quantity);

src/config.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ pub(crate) const RGS_SYNC_TIMEOUT_SECS: u64 = 5;
9494
/// The length in bytes of our wallets' keys seed.
9595
pub const WALLET_KEYS_SEED_LEN: usize = 64;
9696

97+
// The time in-between unconfirmed transaction broadcasts.
98+
pub(crate) const UNCONFIRMED_TX_BROADCAST_INTERVAL: Duration = Duration::from_secs(300);
99+
97100
#[derive(Debug, Clone)]
98101
/// Represents the configuration of an [`Node`] instance.
99102
///
@@ -179,6 +182,16 @@ pub struct Config {
179182
/// **Note:** If unset, default parameters will be used, and you will be able to override the
180183
/// parameters on a per-payment basis in the corresponding method calls.
181184
pub sending_parameters: Option<SendingParameters>,
185+
/// Policy for controlling transaction rebroadcasting behavior.
186+
///
187+
/// This policy determines how and when unconfirmed transactions are rebroadcast to the network
188+
/// to improve confirmation reliability.
189+
///
190+
/// ### Default Behavior
191+
/// - Minimum interval: 300 seconds (5 minutes)
192+
/// - Maximum attempts: 24
193+
/// - Exponential backoff factor: 1.5 (50% increase each attempt)
194+
pub rebroadcast_policy: RebroadcastPolicy,
182195
}
183196

184197
impl Default for Config {
@@ -193,6 +206,7 @@ impl Default for Config {
193206
anchor_channels_config: Some(AnchorChannelsConfig::default()),
194207
sending_parameters: None,
195208
node_alias: None,
209+
rebroadcast_policy: RebroadcastPolicy::default(),
196210
}
197211
}
198212
}
@@ -534,6 +548,45 @@ impl From<MaxDustHTLCExposure> for LdkMaxDustHTLCExposure {
534548
}
535549
}
536550

551+
/// Policy for controlling transaction rebroadcasting behavior.
552+
///
553+
/// Determines the strategy for resending unconfirmed transactions to the network
554+
/// to ensure they remain in mempools and eventually get confirmed.
555+
#[derive(Clone, Debug)]
556+
pub struct RebroadcastPolicy {
557+
/// Minimum time between rebroadcast attempts in seconds.
558+
///
559+
/// This prevents excessive network traffic by ensuring a minimum delay
560+
/// between consecutive rebroadcast attempts.
561+
///
562+
/// **Recommended values**: 60-600 seconds (1-10 minutes)
563+
pub min_rebroadcast_interval: u64,
564+
/// Maximum number of broadcast attempts before giving up.
565+
///
566+
/// After reaching this limit, the transaction will no longer be rebroadcast
567+
/// automatically. Manual intervention may be required.
568+
///
569+
/// **Recommended values**: 12-48 attempts
570+
pub max_broadcast_attempts: u32,
571+
/// Exponential backoff factor for increasing intervals between attempts.
572+
///
573+
/// Each subsequent rebroadcast wait time is multiplied by this factor,
574+
/// creating an exponential backoff pattern.
575+
///
576+
/// - `1.0`: No backoff (constant interval)
577+
/// - `1.5`: 50% increase each attempt
578+
/// - `2.0`: 100% increase (doubling) each attempt
579+
///
580+
/// **Recommended values**: 1.2-2.0
581+
pub backoff_factor: f32,
582+
}
583+
584+
impl Default for RebroadcastPolicy {
585+
fn default() -> Self {
586+
Self { min_rebroadcast_interval: 300, max_broadcast_attempts: 24, backoff_factor: 1.5 }
587+
}
588+
}
589+
537590
#[cfg(test)]
538591
mod tests {
539592
use std::str::FromStr;

src/ffi/types.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
pub use crate::config::{
1414
default_config, AnchorChannelsConfig, BackgroundSyncConfig, ElectrumSyncConfig,
15-
EsploraSyncConfig, MaxDustHTLCExposure,
15+
EsploraSyncConfig, MaxDustHTLCExposure, RebroadcastPolicy,
1616
};
1717
pub use crate::graph::{ChannelInfo, ChannelUpdateInfo, NodeAnnouncementInfo, NodeInfo};
1818
pub use crate::liquidity::{LSPS1OrderStatus, LSPS2ServiceConfig, OnchainPaymentInfo, PaymentInfo};

src/lib.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ use config::{
129129
default_user_config, may_announce_channel, ChannelConfig, Config,
130130
BACKGROUND_TASK_SHUTDOWN_TIMEOUT_SECS, LDK_EVENT_HANDLER_SHUTDOWN_TIMEOUT_SECS,
131131
NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL, RGS_SYNC_INTERVAL,
132+
UNCONFIRMED_TX_BROADCAST_INTERVAL,
132133
};
133134
use connection::ConnectionManager;
134135
use event::{EventHandler, EventQueue};
@@ -428,6 +429,34 @@ impl Node {
428429
}
429430
}, runtime_handle);
430431

432+
// Regularly rebroadcast unconfirmed transactions.
433+
let rebroadcast_wallet = Arc::clone(&self.wallet);
434+
let rebroadcast_logger = Arc::clone(&self.logger);
435+
let mut stop_rebroadcast = self.stop_sender.subscribe();
436+
cancellable_background_tasks.spawn_on(
437+
async move {
438+
let mut interval = tokio::time::interval(UNCONFIRMED_TX_BROADCAST_INTERVAL);
439+
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
440+
loop {
441+
tokio::select! {
442+
_ = stop_rebroadcast.changed() => {
443+
log_debug!(
444+
rebroadcast_logger,
445+
"Stopping rebroadcasting unconfirmed transactions."
446+
);
447+
return;
448+
}
449+
_ = interval.tick() => {
450+
if let Err(e) = rebroadcast_wallet.rebroadcast_unconfirmed_transactions() {
451+
log_error!(rebroadcast_logger, "Background rebroadcast failed: {}", e);
452+
}
453+
}
454+
}
455+
}
456+
},
457+
runtime_handle,
458+
);
459+
431460
// Regularly broadcast node announcements.
432461
let bcast_cm = Arc::clone(&self.channel_manager);
433462
let bcast_pm = Arc::clone(&self.peer_manager);

src/payment/onchain.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,31 @@ impl OnchainPayment {
122122
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
123123
self.wallet.send_to_address(address, send_amount, fee_rate_opt)
124124
}
125+
126+
/// Manually trigger a rebroadcast of a specific transaction according to the default policy.
127+
///
128+
/// This is useful if you suspect a transaction may not have propagated properly through the
129+
/// network and you want to attempt to rebroadcast it immediately rather than waiting for the
130+
/// automatic background job to handle it.
131+
///
132+
/// updating the attempt count and last broadcast time for the transaction in the payment store.
133+
pub fn rebroadcast_transaction(&self, txid: Txid) -> Result<(), Error> {
134+
self.wallet.rebroadcast_transaction(txid)?;
135+
Ok(())
136+
}
137+
138+
/// Attempt to bump the fee of an unconfirmed transaction using Replace-by-Fee (RBF).
139+
///
140+
/// This creates a new transaction that replaces the original one, increasing the fee by the
141+
/// specified increment to improve its chances of confirmation. The original transaction must
142+
/// be signaling RBF replaceability for this to succeed.
143+
///
144+
/// `fee_bump_increment` specifies the additional fee amount in satoshis to add to the
145+
/// transaction. The new transaction will have the same outputs as the original but with a
146+
/// higher fee, resulting in faster confirmation potential.
147+
///
148+
/// Returns the Txid of the new replacement transaction if successful.
149+
pub fn bump_fee_rbf(&self, txid: Txid, fee_bump_increment: u64) -> Result<Txid, Error> {
150+
self.wallet.bump_fee_rbf(txid, fee_bump_increment)
151+
}
125152
}

src/payment/store.rs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,24 @@ impl StorableObject for PaymentDetails {
293293
}
294294
}
295295

296+
if let Some(attempts) = update.broadcast_attempts {
297+
match self.kind {
298+
PaymentKind::Onchain { ref mut broadcast_attempts, .. } => {
299+
update_if_necessary!(*broadcast_attempts, attempts);
300+
},
301+
_ => {},
302+
}
303+
}
304+
305+
if let Some(broadcast_time) = update.last_broadcast_time {
306+
match self.kind {
307+
PaymentKind::Onchain { ref mut last_broadcast_time, .. } => {
308+
update_if_necessary!(*last_broadcast_time, broadcast_time);
309+
},
310+
_ => {},
311+
}
312+
}
313+
296314
if updated {
297315
self.latest_update_timestamp = SystemTime::now()
298316
.duration_since(UNIX_EPOCH)
@@ -353,6 +371,12 @@ pub enum PaymentKind {
353371
txid: Txid,
354372
/// The confirmation status of this payment.
355373
status: ConfirmationStatus,
374+
/// The raw transaction for rebroadcasting
375+
raw_tx: Option<Vec<u8>>,
376+
/// Last broadcast attempt timestamp (UNIX seconds)
377+
last_broadcast_time: Option<u64>,
378+
/// Number of broadcast attempts
379+
broadcast_attempts: u32,
356380
},
357381
/// A [BOLT 11] payment.
358382
///
@@ -451,6 +475,9 @@ impl_writeable_tlv_based_enum!(PaymentKind,
451475
(0, Onchain) => {
452476
(0, txid, required),
453477
(2, status, required),
478+
(4, raw_tx, option),
479+
(10, last_broadcast_time, option),
480+
(12, broadcast_attempts, required),
454481
},
455482
(2, Bolt11) => {
456483
(0, hash, required),
@@ -542,6 +569,8 @@ pub(crate) struct PaymentDetailsUpdate {
542569
pub direction: Option<PaymentDirection>,
543570
pub status: Option<PaymentStatus>,
544571
pub confirmation_status: Option<ConfirmationStatus>,
572+
pub last_broadcast_time: Option<Option<u64>>,
573+
pub broadcast_attempts: Option<u32>,
545574
}
546575

547576
impl PaymentDetailsUpdate {
@@ -557,6 +586,8 @@ impl PaymentDetailsUpdate {
557586
direction: None,
558587
status: None,
559588
confirmation_status: None,
589+
last_broadcast_time: None,
590+
broadcast_attempts: None,
560591
}
561592
}
562593
}
@@ -572,9 +603,11 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
572603
_ => (None, None, None),
573604
};
574605

575-
let confirmation_status = match value.kind {
576-
PaymentKind::Onchain { status, .. } => Some(status),
577-
_ => None,
606+
let (confirmation_status, last_broadcast_time, broadcast_attempts) = match value.kind {
607+
PaymentKind::Onchain { status, last_broadcast_time, broadcast_attempts, .. } => {
608+
(Some(status), last_broadcast_time, broadcast_attempts)
609+
},
610+
_ => (None, None, 0),
578611
};
579612

580613
let counterparty_skimmed_fee_msat = match value.kind {
@@ -595,6 +628,8 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
595628
direction: Some(value.direction),
596629
status: Some(value.status),
597630
confirmation_status,
631+
last_broadcast_time: Some(last_broadcast_time),
632+
broadcast_attempts: Some(broadcast_attempts),
598633
}
599634
}
600635
}

0 commit comments

Comments
 (0)