Skip to content

Commit 49ebe63

Browse files
committed
Implement background job for transaction rebroadcasting
Introduces a `RebroadcastPolicy` to manage the automatic rebroadcasting of unconfirmed transactions with exponential backoff. This prevents excessive network spam while systematically retrying stuck transactions. The feature is enabled by default but can be disabled via the builder: `builder.set_auto_rebroadcast_unconfirmed(false)`. Configuration options: - min_rebroadcast_interval: Base delay between attempts (seconds) - max_broadcast_attempts: Total attempts before abandonment - backoff_factor: Multiplier for exponential delay increase Sensible defaults are provided (300s, 24 attempts, 1.5x backoff).
1 parent 066c0e1 commit 49ebe63

8 files changed

Lines changed: 308 additions & 20 deletions

File tree

bindings/ldk_node.udl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dictionary Config {
1313
u64 probing_liquidity_limit_multiplier;
1414
AnchorChannelsConfig? anchor_channels_config;
1515
RouteParametersConfig? route_parameters;
16+
boolean auto_rebroadcast_unconfirmed_tx;
1617
};
1718

1819
dictionary AnchorChannelsConfig {
@@ -266,6 +267,10 @@ interface OnchainPayment {
266267
Txid send_to_address([ByRef]Address address, u64 amount_sats, FeeRate? fee_rate);
267268
[Throws=NodeError]
268269
Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate);
270+
[Throws=NodeError]
271+
void rebroadcast_transaction(PaymentId payment_id);
272+
[Throws=NodeError]
273+
Txid bump_fee_rbf(PaymentId payment_id);
269274
};
270275

271276
interface FeeRate {
@@ -351,6 +356,7 @@ enum NodeError {
351356
"InvalidBlindedPaths",
352357
"AsyncPaymentServicesDisabled",
353358
"HrnParsingFailed",
359+
"InvalidTransaction",
354360
};
355361

356362
dictionary NodeStatus {

src/config.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ const DEFAULT_LDK_WALLET_SYNC_INTERVAL_SECS: u64 = 30;
2828
const DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS: u64 = 60 * 10;
2929
const DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER: u64 = 3;
3030
const DEFAULT_ANCHOR_PER_CHANNEL_RESERVE_SATS: u64 = 25_000;
31+
const DEFAULT_MIN_REBROADCAST_INTERVAL_SECS: u64 = 300;
32+
const DEFAULT_MAX_BROADCAST_ATTEMPTS: u32 = 24;
33+
const DEFAULT_BACKOFF_FACTOR: f32 = 1.5;
3134

3235
/// The default log level.
3336
pub const DEFAULT_LOG_LEVEL: LogLevel = LogLevel::Debug;
@@ -107,6 +110,9 @@ pub(crate) const EXTERNAL_PATHFINDING_SCORES_SYNC_TIMEOUT_SECS: u64 = 5;
107110
// The timeout after which we abort a parsing/looking up an HRN resolution.
108111
pub(crate) const HRN_RESOLUTION_TIMEOUT_SECS: u64 = 5;
109112

113+
// The time in-between unconfirmed transaction broadcasts.
114+
pub(crate) const UNCONFIRMED_TX_BROADCAST_INTERVAL: Duration = Duration::from_secs(300);
115+
110116
#[derive(Debug, Clone)]
111117
/// Represents the configuration of an [`Node`] instance.
112118
///
@@ -128,6 +134,7 @@ pub(crate) const HRN_RESOLUTION_TIMEOUT_SECS: u64 = 5;
128134
/// | `log_level` | Debug |
129135
/// | `anchor_channels_config` | Some(..) |
130136
/// | `route_parameters` | None |
137+
/// | `auto_rebroadcast_unconfirmed_tx` | true |
131138
///
132139
/// See [`AnchorChannelsConfig`] and [`RouteParametersConfig`] for more information regarding their
133140
/// respective default values.
@@ -192,6 +199,16 @@ pub struct Config {
192199
/// **Note:** If unset, default parameters will be used, and you will be able to override the
193200
/// parameters on a per-payment basis in the corresponding method calls.
194201
pub route_parameters: Option<RouteParametersConfig>,
202+
/// This will determine whether to automatically rebroadcast unconfirmed transactions
203+
/// (e.g., channel funding or sweep transactions).
204+
///
205+
/// If enabled, the node will periodically attempt to rebroadcast any unconfirmed transactions to
206+
/// increase propagation and confirmation likelihood. This is helpful in cases where transactions
207+
/// were dropped by the mempool or not widely propagated.
208+
///
209+
/// Defaults to `true`. Disabling this may be desired for privacy-sensitive use cases or low-bandwidth
210+
/// environments, but may result in slower or failed confirmations if transactions are not re-announced.
211+
pub auto_rebroadcast_unconfirmed_tx: bool,
195212
}
196213

197214
impl Default for Config {
@@ -206,6 +223,7 @@ impl Default for Config {
206223
anchor_channels_config: Some(AnchorChannelsConfig::default()),
207224
route_parameters: None,
208225
node_alias: None,
226+
auto_rebroadcast_unconfirmed_tx: true,
209227
}
210228
}
211229
}
@@ -561,6 +579,49 @@ pub enum AsyncPaymentsRole {
561579
Server,
562580
}
563581

582+
/// Policy for controlling transaction rebroadcasting behavior.
583+
///
584+
/// Determines the strategy for resending unconfirmed transactions to the network
585+
/// to ensure they remain in mempools and eventually get confirmed.
586+
#[derive(Clone, Debug)]
587+
pub struct RebroadcastPolicy {
588+
/// Minimum time between rebroadcast attempts in seconds.
589+
///
590+
/// This prevents excessive network traffic by ensuring a minimum delay
591+
/// between consecutive rebroadcast attempts.
592+
///
593+
/// **Recommended values**: 60-600 seconds (1-10 minutes)
594+
pub min_rebroadcast_interval_secs: u64,
595+
/// Maximum number of broadcast attempts before giving up.
596+
///
597+
/// After reaching this limit, the transaction will no longer be rebroadcast
598+
/// automatically. Manual intervention may be required.
599+
///
600+
/// **Recommended values**: 12-48 attempts
601+
pub max_broadcast_attempts: u32,
602+
/// Exponential backoff factor for increasing intervals between attempts.
603+
///
604+
/// Each subsequent rebroadcast wait time is multiplied by this factor,
605+
/// creating an exponential backoff pattern.
606+
///
607+
/// - `1.0`: No backoff (constant interval)
608+
/// - `1.5`: 50% increase each attempt
609+
/// - `2.0`: 100% increase (doubling) each attempt
610+
///
611+
/// **Recommended values**: 1.2-2.0
612+
pub backoff_factor: f32,
613+
}
614+
615+
impl Default for RebroadcastPolicy {
616+
fn default() -> Self {
617+
Self {
618+
min_rebroadcast_interval_secs: DEFAULT_MIN_REBROADCAST_INTERVAL_SECS,
619+
max_broadcast_attempts: DEFAULT_MAX_BROADCAST_ATTEMPTS,
620+
backoff_factor: DEFAULT_BACKOFF_FACTOR,
621+
}
622+
}
623+
}
624+
564625
#[cfg(test)]
565626
mod tests {
566627
use std::str::FromStr;

src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ pub enum Error {
131131
AsyncPaymentServicesDisabled,
132132
/// Parsing a Human-Readable Name has failed.
133133
HrnParsingFailed,
134+
/// The given transaction is invalid.
135+
InvalidTransaction,
134136
}
135137

136138
impl fmt::Display for Error {
@@ -213,6 +215,7 @@ impl fmt::Display for Error {
213215
Self::HrnParsingFailed => {
214216
write!(f, "Failed to parse a human-readable name.")
215217
},
218+
Self::InvalidTransaction => write!(f, "The given transaction is invalid."),
216219
}
217220
}
218221
}

src/lib.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ use chain::ChainSource;
125125
use config::{
126126
default_user_config, may_announce_channel, AsyncPaymentsRole, ChannelConfig, Config,
127127
NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL, RGS_SYNC_INTERVAL,
128+
UNCONFIRMED_TX_BROADCAST_INTERVAL,
128129
};
129130
use connection::ConnectionManager;
130131
pub use error::Error as NodeError;
@@ -459,6 +460,33 @@ impl Node {
459460
}
460461
});
461462

463+
// Regularly rebroadcast unconfirmed transactions.
464+
let rebroadcast_wallet = Arc::clone(&self.wallet);
465+
let rebroadcast_logger = Arc::clone(&self.logger);
466+
let mut stop_rebroadcast = self.stop_sender.subscribe();
467+
if self.config.auto_rebroadcast_unconfirmed_tx {
468+
self.runtime.spawn_cancellable_background_task(async move {
469+
let mut interval = tokio::time::interval(UNCONFIRMED_TX_BROADCAST_INTERVAL);
470+
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
471+
loop {
472+
tokio::select! {
473+
_ = stop_rebroadcast.changed() => {
474+
log_debug!(
475+
rebroadcast_logger,
476+
"Stopping rebroadcasting unconfirmed transactions."
477+
);
478+
return;
479+
}
480+
_ = interval.tick() => {
481+
if let Err(e) = rebroadcast_wallet.rebroadcast_unconfirmed_transactions() {
482+
log_error!(rebroadcast_logger, "Background rebroadcast failed: {}", e);
483+
}
484+
}
485+
}
486+
}
487+
});
488+
}
489+
462490
// Regularly broadcast node announcements.
463491
let bcast_cm = Arc::clone(&self.channel_manager);
464492
let bcast_pm = Arc::clone(&self.peer_manager);

src/payment/onchain.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ use crate::logger::{log_info, LdkLogger, Logger};
1717
use crate::types::{ChannelManager, Wallet};
1818
use crate::wallet::OnchainSendAmount;
1919

20+
use bitcoin::{Address, Txid};
21+
use lightning::ln::channelmanager::PaymentId;
22+
23+
use std::sync::{Arc, RwLock};
24+
2025
#[cfg(not(feature = "uniffi"))]
2126
type FeeRate = bitcoin::FeeRate;
2227
#[cfg(feature = "uniffi")]
@@ -120,4 +125,15 @@ impl OnchainPayment {
120125
let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate);
121126
self.wallet.send_to_address(address, send_amount, fee_rate_opt)
122127
}
128+
129+
/// Manually trigger a rebroadcast of a specific transaction according to the default policy.
130+
///
131+
/// This is useful if you suspect a transaction may not have propagated properly through the
132+
/// network and you want to attempt to rebroadcast it immediately rather than waiting for the
133+
/// automatic background job to handle it.
134+
///
135+
/// updating the attempt count and last broadcast time for the transaction in the payment store.
136+
pub fn rebroadcast_transaction(&self, payment_id: PaymentId) -> Result<(), Error> {
137+
self.wallet.rebroadcast_transaction(payment_id)
138+
}
123139
}

src/payment/pending_payment_store.rs

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +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 bitcoin::Txid;
8+
use bitcoin::{Transaction, Txid};
99
use lightning::{impl_writeable_tlv_based, ln::channelmanager::PaymentId};
1010

1111
use crate::{
@@ -20,11 +20,20 @@ pub struct PendingPaymentDetails {
2020
pub details: PaymentDetails,
2121
/// Transaction IDs that have replaced or conflict with this payment.
2222
pub conflicting_txids: Vec<Txid>,
23+
/// The raw transaction for rebroadcasting
24+
pub raw_tx: Option<Transaction>,
25+
/// Last broadcast attempt timestamp (UNIX seconds)
26+
pub last_broadcast_time: Option<u64>,
27+
/// Number of broadcast attempts
28+
pub broadcast_attempts: Option<u32>,
2329
}
2430

2531
impl PendingPaymentDetails {
26-
pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec<Txid>) -> Self {
27-
Self { details, conflicting_txids }
32+
pub(crate) fn new(
33+
details: PaymentDetails, conflicting_txids: Vec<Txid>, raw_tx: Option<Transaction>,
34+
last_broadcast_time: Option<u64>, broadcast_attempts: Option<u32>,
35+
) -> Self {
36+
Self { details, conflicting_txids, raw_tx, last_broadcast_time, broadcast_attempts }
2837
}
2938

3039
/// Convert to finalized payment for the main payment store
@@ -36,13 +45,19 @@ impl PendingPaymentDetails {
3645
impl_writeable_tlv_based!(PendingPaymentDetails, {
3746
(0, details, required),
3847
(2, conflicting_txids, optional_vec),
48+
(3, raw_tx, option),
49+
(5, last_broadcast_time, option),
50+
(7, broadcast_attempts, option),
3951
});
4052

4153
#[derive(Clone, Debug, PartialEq, Eq)]
4254
pub(crate) struct PendingPaymentDetailsUpdate {
4355
pub id: PaymentId,
4456
pub payment_update: Option<PaymentDetailsUpdate>,
4557
pub conflicting_txids: Option<Vec<Txid>>,
58+
pub raw_tx: Option<Option<Transaction>>,
59+
pub last_broadcast_time: Option<Option<u64>>,
60+
pub broadcast_attempts: Option<Option<u32>>,
4661
}
4762

4863
impl StorableObject for PendingPaymentDetails {
@@ -56,16 +71,34 @@ impl StorableObject for PendingPaymentDetails {
5671
fn update(&mut self, update: &Self::Update) -> bool {
5772
let mut updated = false;
5873

74+
macro_rules! update_if_necessary {
75+
($val:expr, $update:expr) => {
76+
if $val != $update {
77+
$val = $update;
78+
updated = true;
79+
}
80+
};
81+
}
82+
5983
// Update the underlying payment details if present
6084
if let Some(payment_update) = &update.payment_update {
6185
updated |= self.details.update(payment_update);
6286
}
6387

6488
if let Some(new_conflicting_txids) = &update.conflicting_txids {
65-
if &self.conflicting_txids != new_conflicting_txids {
66-
self.conflicting_txids = new_conflicting_txids.clone();
67-
updated = true;
68-
}
89+
update_if_necessary!(self.conflicting_txids, new_conflicting_txids.clone());
90+
}
91+
92+
if let Some(new_raw_tx) = &update.raw_tx {
93+
update_if_necessary!(self.raw_tx, new_raw_tx.clone());
94+
}
95+
96+
if let Some(new_last_broadcast_time) = update.last_broadcast_time {
97+
update_if_necessary!(self.last_broadcast_time, new_last_broadcast_time);
98+
}
99+
100+
if let Some(new_broadcast_attempts) = update.broadcast_attempts {
101+
update_if_necessary!(self.broadcast_attempts, new_broadcast_attempts);
69102
}
70103

71104
updated
@@ -88,6 +121,9 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate {
88121
id: value.id(),
89122
payment_update: Some(value.details.to_update()),
90123
conflicting_txids: Some(value.conflicting_txids.clone()),
124+
raw_tx: Some(value.raw_tx.clone()),
125+
last_broadcast_time: Some(value.last_broadcast_time),
126+
broadcast_attempts: Some(value.broadcast_attempts),
91127
}
92128
}
93129
}

0 commit comments

Comments
 (0)