Skip to content

Commit 7d26667

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 ba16c92 commit 7d26667

9 files changed

Lines changed: 269 additions & 9 deletions

File tree

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
RouteParametersConfig? route_parameters;
16+
boolean auto_rebroadcast_unconfirmed_tx;
1617
};
1718

1819
dictionary AnchorChannelsConfig {
@@ -266,6 +267,8 @@ 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);
269272
};
270273

271274
interface FeeRate {
@@ -351,6 +354,7 @@ enum NodeError {
351354
"InvalidBlindedPaths",
352355
"AsyncPaymentServicesDisabled",
353356
"HrnParsingFailed",
357+
"InvalidTransaction",
354358
};
355359

356360
dictionary NodeStatus {
@@ -450,7 +454,7 @@ interface ClosureReason {
450454

451455
[Enum]
452456
interface PaymentKind {
453-
Onchain(Txid txid, ConfirmationStatus status);
457+
Onchain(Txid txid, ConfirmationStatus status, Transaction? raw_tx, u64? last_broadcast_time, u32? broadcast_attempts);
454458
Bolt11(PaymentHash hash, PaymentPreimage? preimage, PaymentSecret? secret);
455459
Bolt11Jit(PaymentHash hash, PaymentPreimage? preimage, PaymentSecret? secret, u64? counterparty_skimmed_fee_msat, LSPFeeLimits lsp_fee_limits);
456460
Bolt12Offer(PaymentHash? hash, PaymentPreimage? preimage, PaymentSecret? secret, OfferId offer_id, UntrustedString? payer_note, u64? quantity);
@@ -916,3 +920,6 @@ typedef string LSPSDateTime;
916920

917921
[Custom]
918922
typedef string ScriptBuf;
923+
924+
[Custom]
925+
typedef string Transaction;

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/ffi/types.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,6 +1248,21 @@ impl UniffiCustomTypeConverter for LSPSDateTime {
12481248
}
12491249
}
12501250

1251+
impl UniffiCustomTypeConverter for Transaction {
1252+
type Builtin = String;
1253+
fn into_custom(val: Self::Builtin) -> uniffi::Result<Self> {
1254+
if let Some(bytes) = hex_utils::to_vec(&val) {
1255+
if let Ok(tx) = bitcoin::consensus::deserialize::<Transaction>(&bytes) {
1256+
return Ok(tx);
1257+
}
1258+
}
1259+
Err(Error::InvalidTransaction.into())
1260+
}
1261+
fn from_custom(obj: Self) -> Self::Builtin {
1262+
hex_utils::to_string(&bitcoin::consensus::serialize(&obj))
1263+
}
1264+
}
1265+
12511266
#[cfg(test)]
12521267
mod tests {
12531268
use std::num::NonZeroU64;

src/lib.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ pub use builder::NodeBuilder as Builder;
124124
use chain::ChainSource;
125125
use config::{
126126
default_user_config, may_announce_channel, AsyncPaymentsRole, ChannelConfig, Config,
127-
NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL, RGS_SYNC_INTERVAL,
127+
NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL, RGS_SYNC_INTERVAL, UNCONFIRMED_TX_BROADCAST_INTERVAL,
128128
};
129129
use connection::ConnectionManager;
130130
pub use error::Error as NodeError;
@@ -459,6 +459,33 @@ impl Node {
459459
}
460460
});
461461

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

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ use lightning::{
1919
use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret};
2020
use lightning_types::string::UntrustedString;
2121

22+
use bitcoin::{BlockHash, Transaction, Txid};
23+
24+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
25+
2226
use crate::data_store::{StorableObject, StorableObjectId, StorableObjectUpdate};
2327
use crate::hex_utils;
2428

@@ -291,6 +295,33 @@ impl StorableObject for PaymentDetails {
291295
}
292296
}
293297

298+
if let Some(tx) = &update.raw_tx {
299+
match self.kind {
300+
PaymentKind::Onchain { ref mut raw_tx, .. } => {
301+
update_if_necessary!(*raw_tx, tx.clone());
302+
},
303+
_ => {},
304+
}
305+
}
306+
307+
if let Some(attempts) = update.broadcast_attempts {
308+
match self.kind {
309+
PaymentKind::Onchain { ref mut broadcast_attempts, .. } => {
310+
update_if_necessary!(*broadcast_attempts, attempts);
311+
},
312+
_ => {},
313+
}
314+
}
315+
316+
if let Some(broadcast_time) = update.last_broadcast_time {
317+
match self.kind {
318+
PaymentKind::Onchain { ref mut last_broadcast_time, .. } => {
319+
update_if_necessary!(*last_broadcast_time, broadcast_time);
320+
},
321+
_ => {},
322+
}
323+
}
324+
294325
if updated {
295326
self.latest_update_timestamp = SystemTime::now()
296327
.duration_since(UNIX_EPOCH)
@@ -351,6 +382,12 @@ pub enum PaymentKind {
351382
txid: Txid,
352383
/// The confirmation status of this payment.
353384
status: ConfirmationStatus,
385+
/// The raw transaction for rebroadcasting
386+
raw_tx: Option<Transaction>,
387+
/// Last broadcast attempt timestamp (UNIX seconds)
388+
last_broadcast_time: Option<u64>,
389+
/// Number of broadcast attempts
390+
broadcast_attempts: Option<u32>,
354391
},
355392
/// A [BOLT 11] payment.
356393
///
@@ -448,7 +485,10 @@ pub enum PaymentKind {
448485
impl_writeable_tlv_based_enum!(PaymentKind,
449486
(0, Onchain) => {
450487
(0, txid, required),
488+
(1, raw_tx, option),
451489
(2, status, required),
490+
(3, last_broadcast_time, option),
491+
(5, broadcast_attempts, option),
452492
},
453493
(2, Bolt11) => {
454494
(0, hash, required),
@@ -540,6 +580,9 @@ pub(crate) struct PaymentDetailsUpdate {
540580
pub direction: Option<PaymentDirection>,
541581
pub status: Option<PaymentStatus>,
542582
pub confirmation_status: Option<ConfirmationStatus>,
583+
pub raw_tx: Option<Option<Transaction>>,
584+
pub last_broadcast_time: Option<Option<u64>>,
585+
pub broadcast_attempts: Option<Option<u32>>,
543586
}
544587

545588
impl PaymentDetailsUpdate {
@@ -555,6 +598,9 @@ impl PaymentDetailsUpdate {
555598
direction: None,
556599
status: None,
557600
confirmation_status: None,
601+
raw_tx: None,
602+
last_broadcast_time: None,
603+
broadcast_attempts: None,
558604
}
559605
}
560606
}
@@ -570,10 +616,17 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
570616
_ => (None, None, None),
571617
};
572618

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

578631
let counterparty_skimmed_fee_msat = match value.kind {
579632
PaymentKind::Bolt11Jit { counterparty_skimmed_fee_msat, .. } => {
@@ -593,6 +646,9 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
593646
direction: Some(value.direction),
594647
status: Some(value.status),
595648
confirmation_status,
649+
raw_tx: Some(raw_tx),
650+
last_broadcast_time: Some(last_broadcast_time),
651+
broadcast_attempts: Some(broadcast_attempts),
596652
}
597653
}
598654
}

0 commit comments

Comments
 (0)