Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions src/chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,20 +505,21 @@ impl ChainSource {
}
Some(next_package) = receiver.recv() => {
// Classify funding broadcasts into payment records before sending. If
// classification fails we skip the broadcast, since broadcasting a tx we
// failed to record would leave it on-chain without a payment.
let package = match self.tx_broadcaster.classify_package(next_package).await {
Ok(package) => package,
Err(e) => {
log_error!(
tx_bcast_logger,
"Skipping broadcast: failed to persist payment records: {:?}",
e,
);
continue;
},
};
let package = package.into_sorted_transactions();
// classification fails we delay the broadcast and retry, since broadcasting
// a tx we failed to record would leave it on-chain without a payment —
// while dropping the package would not keep an interactively funded tx
// off-chain (the counterparty broadcasts it regardless), only leave it
// confirming without a recorded candidate.
if let Err(e) = self.tx_broadcaster.classify_package(&next_package).await {
log_error!(
tx_bcast_logger,
"Delaying broadcast: failed to persist payment records, will retry: {:?}",
e,
);
self.tx_broadcaster.requeue_failed_classify(next_package);
continue;
}
let package = next_package.into_sorted_transactions();
match &self.kind {
ChainSourceKind::Esplora(esplora_chain_source) => {
esplora_chain_source.process_transaction_broadcast(package).await
Expand Down
33 changes: 26 additions & 7 deletions src/tx_broadcaster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use std::ops::Deref;
use std::sync::{Mutex as StdMutex, Weak};
use std::time::Duration;

use bitcoin::Transaction;
use lightning::chain::chaininterface::{
Expand All @@ -20,6 +21,11 @@ use crate::Error;

const BCAST_PACKAGE_QUEUE_SIZE: usize = 256;

/// How long to wait before re-classifying a package whose classification failed. Long enough to
/// give a struggling store room to recover, short against the ~minutes until the transaction
/// could confirm.
const FAILED_CLASSIFY_RETRY_DELAY: Duration = Duration::from_secs(2);

/// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions`
/// call, along with each transaction's type. Queued until the background task classifies and
/// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated
Expand Down Expand Up @@ -133,12 +139,11 @@ where
self.queue_receiver.lock().await
}

/// Classifies a queued package into payment records and returns the package ready for the
/// chain client. Returns `Err` if any classification fails; callers must not broadcast the
/// package in that case, since a crash would leave the transaction on-chain without a record.
pub(crate) async fn classify_package(
&self, package: BroadcastPackage,
) -> Result<BroadcastPackage, Error> {
/// Classifies a queued package into payment records. Returns `Err` if any classification
/// fails; callers must not broadcast the package in that case, since a crash would leave the
/// transaction on-chain without a record — but must requeue it via
/// [`Self::requeue_failed_classify`] rather than drop it.
pub(crate) async fn classify_package(&self, package: &BroadcastPackage) -> Result<(), Error> {
let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade);
if let Some(wallet) = wallet_opt {
for (tx, tx_type) in package.transactions() {
Expand All @@ -147,7 +152,21 @@ where
}
}
}
Ok(package)
Ok(())
}

/// Re-sends a package whose classification failed back into the queue after a delay, so a
/// transient persistence failure delays the broadcast instead of dropping the package.
/// Dropping an interactive-funding package would not even keep its transaction off-chain —
/// the counterparty broadcasts it regardless — it would only leave the transaction
/// confirming without a recorded candidate. If the queue has closed by the time the delay
/// elapses, the node is shutting down and the package is dropped with it.
pub(crate) fn requeue_failed_classify(&self, package: BroadcastPackage) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex:

  • [P1] Delayed requeue leaves the duplicate-record race open. /home/tnull/worktrees/ldk-node/pr-1057-review-20260819/src/tx_broadcaster.rs:164 removes the failed package and waits two seconds before requeueing it. If persistence recovers and wallet sync observes an interactive-RBF candidate
    during that interval, sync creates a generic record keyed by the active txid. Classification later creates the funding record keyed by the first candidate, while direct lookup continues to prefer the generic record. The funding record can therefore remain pending—the outcome this commit
    intends to prevent. The test only exercises a single Funding transaction whose payment ID equals its txid, without concurrent wallet sync.

let sender = self.queue_sender.clone();
tokio::spawn(async move {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only detached tokio::spawn left in non-test production code outside postgres_store. It's also what reorders the queue — the requeued package lands behind anything queued after it.
Holding the failed package in the loop and adding a sleep branch to the existing select! avoids both, and needs no runtime handle.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted above, this likely should be spawn_cancellable_background_task. Though given the codex comment above, not even sure if doing it in the background is the right approach?

tokio::time::sleep(FAILED_CLASSIFY_RETRY_DELAY).await;
let _ = sender.send(package).await;
});
}

pub(crate) fn broadcast_unclassified_transaction(&self, tx: Transaction) {
Expand Down
Loading
Loading