Skip to content

Commit af0c16a

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 fdaa759 commit af0c16a

File tree

9 files changed

+413
-15
lines changed

9 files changed

+413
-15
lines changed

bindings/ldk_node.udl

Lines changed: 8 additions & 1 deletion
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
SendingParameters? sending_parameters;
16+
boolean auto_rebroadcast_unconfirmed_tx;
1617
};
1718

1819
dictionary AnchorChannelsConfig {
@@ -231,6 +232,8 @@ interface OnchainPayment {
231232
Txid send_to_address([ByRef]Address address, u64 amount_sats, FeeRate? fee_rate);
232233
[Throws=NodeError]
233234
Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate);
235+
[Throws=NodeError]
236+
void rebroadcast_transaction(PaymentId payment_id);
234237
};
235238

236239
interface FeeRate {
@@ -311,6 +314,7 @@ enum NodeError {
311314
"InsufficientFunds",
312315
"LiquiditySourceUnavailable",
313316
"LiquidityFeeTooHigh",
317+
"InvalidTransaction",
314318
};
315319

316320
dictionary NodeStatus {
@@ -409,7 +413,7 @@ interface ClosureReason {
409413

410414
[Enum]
411415
interface PaymentKind {
412-
Onchain(Txid txid, ConfirmationStatus status);
416+
Onchain(Txid txid, ConfirmationStatus status, Transaction? raw_tx, u64? last_broadcast_time, u32? broadcast_attempts);
413417
Bolt11(PaymentHash hash, PaymentPreimage? preimage, PaymentSecret? secret);
414418
Bolt11Jit(PaymentHash hash, PaymentPreimage? preimage, PaymentSecret? secret, u64? counterparty_skimmed_fee_msat, LSPFeeLimits lsp_fee_limits);
415419
Bolt12Offer(PaymentHash? hash, PaymentPreimage? preimage, PaymentSecret? secret, OfferId offer_id, UntrustedString? payer_note, u64? quantity);
@@ -865,3 +869,6 @@ typedef string OrderId;
865869

866870
[Custom]
867871
typedef string DateTime;
872+
873+
[Custom]
874+
typedef string Transaction;

src/config.rs

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

3336
/// The default log level.
3437
pub const DEFAULT_LOG_LEVEL: LogLevel = LogLevel::Debug;
@@ -94,6 +97,9 @@ pub(crate) const RGS_SYNC_TIMEOUT_SECS: u64 = 5;
9497
/// The length in bytes of our wallets' keys seed.
9598
pub const WALLET_KEYS_SEED_LEN: usize = 64;
9699

100+
// The time in-between unconfirmed transaction broadcasts.
101+
pub(crate) const UNCONFIRMED_TX_BROADCAST_INTERVAL: Duration = Duration::from_secs(300);
102+
97103
#[derive(Debug, Clone)]
98104
/// Represents the configuration of an [`Node`] instance.
99105
///
@@ -115,6 +121,7 @@ pub const WALLET_KEYS_SEED_LEN: usize = 64;
115121
/// | `log_level` | Debug |
116122
/// | `anchor_channels_config` | Some(..) |
117123
/// | `sending_parameters` | None |
124+
/// | `auto_rebroadcast_unconfirmed_tx` | true |
118125
///
119126
/// See [`AnchorChannelsConfig`] and [`SendingParameters`] for more information regarding their
120127
/// respective default values.
@@ -179,6 +186,16 @@ pub struct Config {
179186
/// **Note:** If unset, default parameters will be used, and you will be able to override the
180187
/// parameters on a per-payment basis in the corresponding method calls.
181188
pub sending_parameters: Option<SendingParameters>,
189+
/// This will determine whether to automatically rebroadcast unconfirmed transactions
190+
/// (e.g., channel funding or sweep transactions).
191+
///
192+
/// If enabled, the node will periodically attempt to rebroadcast any unconfirmed transactions to
193+
/// increase propagation and confirmation likelihood. This is helpful in cases where transactions
194+
/// were dropped by the mempool or not widely propagated.
195+
///
196+
/// Defaults to `true`. Disabling this may be desired for privacy-sensitive use cases or low-bandwidth
197+
/// environments, but may result in slower or failed confirmations if transactions are not re-announced.
198+
pub auto_rebroadcast_unconfirmed_tx: bool,
182199
}
183200

184201
impl Default for Config {
@@ -193,6 +210,7 @@ impl Default for Config {
193210
anchor_channels_config: Some(AnchorChannelsConfig::default()),
194211
sending_parameters: None,
195212
node_alias: None,
213+
auto_rebroadcast_unconfirmed_tx: true,
196214
}
197215
}
198216
}
@@ -534,6 +552,49 @@ impl From<MaxDustHTLCExposure> for LdkMaxDustHTLCExposure {
534552
}
535553
}
536554

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

src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ pub enum Error {
120120
LiquiditySourceUnavailable,
121121
/// The given operation failed due to the LSP's required opening fee being too high.
122122
LiquidityFeeTooHigh,
123+
/// The given transaction is invalid.
124+
InvalidTransaction,
123125
}
124126

125127
impl fmt::Display for Error {
@@ -193,6 +195,7 @@ impl fmt::Display for Error {
193195
Self::LiquidityFeeTooHigh => {
194196
write!(f, "The given operation failed due to the LSP's required opening fee being too high.")
195197
},
198+
Self::InvalidTransaction => write!(f, "The given transaction is invalid."),
196199
}
197200
}
198201
}

src/ffi/types.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ pub use lightning_invoice::{Description, SignedRawBolt11Invoice};
3636
pub use lightning_liquidity::lsps1::msgs::ChannelInfo as ChannelOrderInfo;
3737
pub use lightning_liquidity::lsps1::msgs::{OrderId, OrderParameters, PaymentState};
3838

39-
pub use bitcoin::{Address, BlockHash, FeeRate, Network, OutPoint, Txid};
39+
pub use bitcoin::{Address, BlockHash, FeeRate, Network, OutPoint, Transaction, Txid};
4040

4141
pub use bip39::Mnemonic;
4242

@@ -1117,6 +1117,21 @@ impl UniffiCustomTypeConverter for DateTime {
11171117
}
11181118
}
11191119

1120+
impl UniffiCustomTypeConverter for Transaction {
1121+
type Builtin = String;
1122+
fn into_custom(val: Self::Builtin) -> uniffi::Result<Self> {
1123+
if let Some(bytes) = hex_utils::to_vec(&val) {
1124+
if let Ok(tx) = bitcoin::consensus::deserialize::<Transaction>(&bytes) {
1125+
return Ok(tx);
1126+
}
1127+
}
1128+
Err(Error::InvalidTransaction.into())
1129+
}
1130+
fn from_custom(obj: Self) -> Self::Builtin {
1131+
hex_utils::to_string(&bitcoin::consensus::serialize(&obj))
1132+
}
1133+
}
1134+
11201135
#[cfg(test)]
11211136
mod tests {
11221137
use std::{

src/lib.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ pub use builder::NodeBuilder as Builder;
129129
use chain::ChainSource;
130130
use config::{
131131
default_user_config, may_announce_channel, ChannelConfig, Config, NODE_ANN_BCAST_INTERVAL,
132-
PEER_RECONNECTION_INTERVAL, RGS_SYNC_INTERVAL,
132+
PEER_RECONNECTION_INTERVAL, RGS_SYNC_INTERVAL, UNCONFIRMED_TX_BROADCAST_INTERVAL,
133133
};
134134
use connection::ConnectionManager;
135135
use event::{EventHandler, EventQueue};
@@ -402,6 +402,33 @@ impl Node {
402402
}
403403
});
404404

405+
// Regularly rebroadcast unconfirmed transactions.
406+
let rebroadcast_wallet = Arc::clone(&self.wallet);
407+
let rebroadcast_logger = Arc::clone(&self.logger);
408+
let mut stop_rebroadcast = self.stop_sender.subscribe();
409+
if self.config.auto_rebroadcast_unconfirmed_tx {
410+
self.runtime.spawn_cancellable_background_task(async move {
411+
let mut interval = tokio::time::interval(UNCONFIRMED_TX_BROADCAST_INTERVAL);
412+
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
413+
loop {
414+
tokio::select! {
415+
_ = stop_rebroadcast.changed() => {
416+
log_debug!(
417+
rebroadcast_logger,
418+
"Stopping rebroadcasting unconfirmed transactions."
419+
);
420+
return;
421+
}
422+
_ = interval.tick() => {
423+
if let Err(e) = rebroadcast_wallet.rebroadcast_unconfirmed_transactions() {
424+
log_error!(rebroadcast_logger, "Background rebroadcast failed: {}", e);
425+
}
426+
}
427+
}
428+
}
429+
});
430+
}
431+
405432
// Regularly broadcast node announcements.
406433
let bcast_cm = Arc::clone(&self.channel_manager);
407434
let bcast_pm = Arc::clone(&self.peer_manager);

src/payment/onchain.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::types::{ChannelManager, Wallet};
1414
use crate::wallet::OnchainSendAmount;
1515

1616
use bitcoin::{Address, Txid};
17+
use lightning::ln::channelmanager::PaymentId;
1718

1819
use std::sync::{Arc, RwLock};
1920

@@ -120,4 +121,15 @@ 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+
/// Manually trigger a rebroadcast of a specific transaction according to the default policy.
126+
///
127+
/// This is useful if you suspect a transaction may not have propagated properly through the
128+
/// network and you want to attempt to rebroadcast it immediately rather than waiting for the
129+
/// automatic background job to handle it.
130+
///
131+
/// updating the attempt count and last broadcast time for the transaction in the payment store.
132+
pub fn rebroadcast_transaction(&self, payment_id: PaymentId) -> Result<(), Error> {
133+
self.wallet.rebroadcast_transaction(payment_id)
134+
}
123135
}

src/payment/store.rs

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use lightning::{
1717

1818
use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret};
1919

20-
use bitcoin::{BlockHash, Txid};
20+
use bitcoin::{BlockHash, Transaction, Txid};
2121

2222
use std::time::{Duration, SystemTime, UNIX_EPOCH};
2323

@@ -293,6 +293,33 @@ impl StorableObject for PaymentDetails {
293293
}
294294
}
295295

296+
if let Some(tx) = &update.raw_tx {
297+
match self.kind {
298+
PaymentKind::Onchain { ref mut raw_tx, .. } => {
299+
update_if_necessary!(*raw_tx, tx.clone());
300+
},
301+
_ => {},
302+
}
303+
}
304+
305+
if let Some(attempts) = update.broadcast_attempts {
306+
match self.kind {
307+
PaymentKind::Onchain { ref mut broadcast_attempts, .. } => {
308+
update_if_necessary!(*broadcast_attempts, attempts);
309+
},
310+
_ => {},
311+
}
312+
}
313+
314+
if let Some(broadcast_time) = update.last_broadcast_time {
315+
match self.kind {
316+
PaymentKind::Onchain { ref mut last_broadcast_time, .. } => {
317+
update_if_necessary!(*last_broadcast_time, broadcast_time);
318+
},
319+
_ => {},
320+
}
321+
}
322+
296323
if updated {
297324
self.latest_update_timestamp = SystemTime::now()
298325
.duration_since(UNIX_EPOCH)
@@ -353,6 +380,12 @@ pub enum PaymentKind {
353380
txid: Txid,
354381
/// The confirmation status of this payment.
355382
status: ConfirmationStatus,
383+
/// The raw transaction for rebroadcasting
384+
raw_tx: Option<Transaction>,
385+
/// Last broadcast attempt timestamp (UNIX seconds)
386+
last_broadcast_time: Option<u64>,
387+
/// Number of broadcast attempts
388+
broadcast_attempts: Option<u32>,
356389
},
357390
/// A [BOLT 11] payment.
358391
///
@@ -450,7 +483,10 @@ pub enum PaymentKind {
450483
impl_writeable_tlv_based_enum!(PaymentKind,
451484
(0, Onchain) => {
452485
(0, txid, required),
486+
(1, raw_tx, option),
453487
(2, status, required),
488+
(3, last_broadcast_time, option),
489+
(5, broadcast_attempts, option),
454490
},
455491
(2, Bolt11) => {
456492
(0, hash, required),
@@ -542,6 +578,9 @@ pub(crate) struct PaymentDetailsUpdate {
542578
pub direction: Option<PaymentDirection>,
543579
pub status: Option<PaymentStatus>,
544580
pub confirmation_status: Option<ConfirmationStatus>,
581+
pub raw_tx: Option<Option<Transaction>>,
582+
pub last_broadcast_time: Option<Option<u64>>,
583+
pub broadcast_attempts: Option<Option<u32>>,
545584
}
546585

547586
impl PaymentDetailsUpdate {
@@ -557,6 +596,9 @@ impl PaymentDetailsUpdate {
557596
direction: None,
558597
status: None,
559598
confirmation_status: None,
599+
raw_tx: None,
600+
last_broadcast_time: None,
601+
broadcast_attempts: None,
560602
}
561603
}
562604
}
@@ -572,10 +614,17 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
572614
_ => (None, None, None),
573615
};
574616

575-
let confirmation_status = match value.kind {
576-
PaymentKind::Onchain { status, .. } => Some(status),
577-
_ => None,
578-
};
617+
let (confirmation_status, raw_tx, last_broadcast_time, broadcast_attempts) =
618+
match &value.kind {
619+
PaymentKind::Onchain {
620+
status,
621+
raw_tx,
622+
last_broadcast_time,
623+
broadcast_attempts,
624+
..
625+
} => (Some(*status), raw_tx.clone(), *last_broadcast_time, *broadcast_attempts),
626+
_ => (None, None, None, None),
627+
};
579628

580629
let counterparty_skimmed_fee_msat = match value.kind {
581630
PaymentKind::Bolt11Jit { counterparty_skimmed_fee_msat, .. } => {
@@ -595,6 +644,9 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
595644
direction: Some(value.direction),
596645
status: Some(value.status),
597646
confirmation_status,
647+
raw_tx: Some(raw_tx),
648+
last_broadcast_time: Some(last_broadcast_time),
649+
broadcast_attempts: Some(broadcast_attempts),
598650
}
599651
}
600652
}

0 commit comments

Comments
 (0)