Skip to content

Commit 238ccc8

Browse files
committed
f - Never drop a queued cooperative close for the retry bound
1 parent 6cf748a commit 238ccc8

1 file changed

Lines changed: 158 additions & 30 deletions

File tree

src/tx_broadcaster.rs

Lines changed: 158 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ use crate::Error;
2222

2323
const BCAST_PACKAGE_QUEUE_SIZE: usize = 256;
2424

25-
/// The most non-funding packages [`RetryQueue`] holds. Claims and sweeps re-enter the
26-
/// broadcast queue on LDK's periodic rebroadcast timers, so one dropped here resurfaces on its
27-
/// own once the store recovers. Funding packages don't count against the bound: nothing
28-
/// re-broadcasts them for us, and they are finite — one per negotiated candidate, since a copy
29-
/// of a waiting package is never queued twice.
25+
/// The most droppable packages [`RetryQueue`] holds. Claims and sweeps re-enter the broadcast
26+
/// queue on LDK's periodic rebroadcast timers, so one dropped here resurfaces on its own once
27+
/// the store recovers. Packages nothing re-broadcasts — fundings and cooperative closes —
28+
/// don't count against the bound: they are finite — one per negotiated funding candidate and
29+
/// one per closing channel, since a copy of a waiting package is never queued twice.
3030
const MAX_QUEUED_RETRIES: usize = BCAST_PACKAGE_QUEUE_SIZE;
3131

3232
/// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions`
@@ -65,17 +65,30 @@ impl BroadcastPackage {
6565
txids
6666
}
6767

68-
/// Whether the package contains a funding transaction (a channel open or splice), whose
69-
/// classification writes the payment record tracking the funding.
70-
fn contains_funding(&self) -> bool {
71-
self.0.iter().any(|(_, tx_type)| {
72-
matches!(
73-
tx_type,
74-
Some(
75-
LdkTransactionType::Funding { .. }
76-
| LdkTransactionType::InteractiveFunding { .. }
77-
)
78-
)
68+
/// Whether the package may be dropped to keep [`RetryQueue`] within its bound: every
69+
/// transaction in it is re-broadcast by its originator, so a dropped package resurfaces on
70+
/// its own. LDK re-hands claims, anchor bumps, and force-close commitments to the
71+
/// broadcaster periodically, and the sweeper regenerates sweeps once per block. Nothing
72+
/// re-broadcasts a funding transaction (a channel open or splice, whose classification
73+
/// writes the payment record tracking the funding) or a cooperative close (whose channel is
74+
/// gone from the `ChannelManager` by broadcast time), so a package containing either is
75+
/// never dropped.
76+
fn is_droppable(&self) -> bool {
77+
self.0.iter().all(|(_, tx_type)| match tx_type {
78+
Some(
79+
LdkTransactionType::Funding { .. }
80+
| LdkTransactionType::InteractiveFunding { .. }
81+
| LdkTransactionType::CooperativeClose { .. },
82+
) => false,
83+
Some(
84+
LdkTransactionType::UnilateralClose { .. }
85+
| LdkTransactionType::AnchorBump { .. }
86+
| LdkTransactionType::Claim { .. }
87+
| LdkTransactionType::Sweep { .. },
88+
) => true,
89+
// Wallet-originated: re-submitted on chain tip changes. Never queued anyway, since
90+
// classification of an untyped package is a no-op that can't fail.
91+
None => true,
7992
})
8093
}
8194
}
@@ -84,14 +97,14 @@ impl BroadcastPackage {
8497
/// the package won't be retried as-is.
8598
pub(crate) enum ScheduleOutcome {
8699
/// The package waits for its retry deadline. When the bound was reached, the oldest waiting
87-
/// non-funding package was dropped to make room and is returned — its transactions resurface
100+
/// droppable package was dropped to make room and is returned — its transactions resurface
88101
/// with LDK's next periodic rebroadcast.
89102
Scheduled { dropped: Option<BroadcastPackage> },
90103
/// A package broadcasting the same transactions already waits, and its retry covers this
91104
/// one: the incoming package is dropped and returned.
92105
AlreadyQueued(BroadcastPackage),
93-
/// The bound was reached and every waiting package is a funding package, which must not be
94-
/// dropped: the incoming package is refused and returned.
106+
/// The bound was reached and every waiting package is one that must not be dropped (a
107+
/// funding or a cooperative close): the incoming package is refused and returned.
95108
Refused(BroadcastPackage),
96109
}
97110

@@ -120,8 +133,8 @@ impl RetryQueue {
120133
}
121134

122135
/// Schedules a package to retry at `retry_at`, unless a package with the same transactions already
123-
/// waits or accepting it would exceed [`MAX_QUEUED_RETRIES`] with no non-funding package to
124-
/// drop for it; see [`ScheduleOutcome`].
136+
/// waits or accepting it would exceed [`MAX_QUEUED_RETRIES`] with no droppable package to
137+
/// make room with; see [`ScheduleOutcome`].
125138
pub(crate) fn schedule(
126139
&mut self, package: BroadcastPackage, retry_at: Instant,
127140
) -> ScheduleOutcome {
@@ -137,12 +150,14 @@ impl RetryQueue {
137150
}
138151

139152
let mut dropped = None;
140-
if !package.contains_funding() && self.0.len() >= MAX_QUEUED_RETRIES {
141-
// Drop the oldest non-funding package: LDK re-broadcasts its transactions
153+
if package.is_droppable() && self.0.len() >= MAX_QUEUED_RETRIES {
154+
// Drop the oldest droppable package: its transactions are re-broadcast
142155
// periodically, while the incoming package may carry a fresher fee-bumped variant.
143156
// A funding package is never dropped — nothing would re-broadcast it, and losing it
144-
// leaves its transaction confirming without a recorded candidate.
145-
match self.0.iter().position(|(_, _, waiting)| !waiting.contains_funding()) {
157+
// leaves its transaction confirming without a recorded candidate. Neither is a
158+
// cooperative close, whose queued package may hold the only copy of the signed
159+
// closing transaction.
160+
match self.0.iter().position(|(_, _, waiting)| waiting.is_droppable()) {
146161
Some(oldest) => dropped = self.0.remove(oldest).map(|(_, _, package)| package),
147162
None => return ScheduleOutcome::Refused(package),
148163
}
@@ -423,6 +438,34 @@ mod tests {
423438
BroadcastPackage::new(&[(tx, LdkTransactionType::Funding { channels: vec![] })])
424439
}
425440

441+
fn test_counterparty_node_id() -> bitcoin::secp256k1::PublicKey {
442+
use std::str::FromStr;
443+
bitcoin::secp256k1::PublicKey::from_str(
444+
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
445+
)
446+
.unwrap()
447+
}
448+
449+
fn coop_close_package(tx: &Transaction) -> BroadcastPackage {
450+
BroadcastPackage::new(&[(
451+
tx,
452+
LdkTransactionType::CooperativeClose {
453+
counterparty_node_id: test_counterparty_node_id(),
454+
channel_id: lightning::ln::types::ChannelId([13u8; 32]),
455+
},
456+
)])
457+
}
458+
459+
fn claim_package(tx: &Transaction) -> BroadcastPackage {
460+
BroadcastPackage::new(&[(
461+
tx,
462+
LdkTransactionType::Claim {
463+
counterparty_node_id: test_counterparty_node_id(),
464+
channel_id: lightning::ln::types::ChannelId([13u8; 32]),
465+
},
466+
)])
467+
}
468+
426469
fn deadline(secs: u64) -> tokio::time::Instant {
427470
tokio::time::Instant::now() + std::time::Duration::from_secs(secs)
428471
}
@@ -475,10 +518,10 @@ mod tests {
475518
}
476519

477520
/// Distinct transactions (e.g. fee-bumped claim variants during a store outage) are held to
478-
/// the bound: the oldest non-funding package is dropped for an incoming one, never a funding
521+
/// the bound: the oldest droppable package is dropped for an incoming one, never a funding
479522
/// package.
480523
#[tokio::test]
481-
async fn retry_queue_drops_the_oldest_non_funding_package_at_the_bound() {
524+
async fn retry_queue_drops_the_oldest_droppable_package_at_the_bound() {
482525
fn numbered_tx(n: u32) -> Transaction {
483526
Transaction {
484527
version: bitcoin::transaction::Version::TWO,
@@ -502,7 +545,7 @@ mod tests {
502545
));
503546
}
504547

505-
// At the bound, an incoming non-funding package drops the oldest waiting one — not the
548+
// At the bound, an incoming droppable package drops the oldest waiting one — not the
506549
// older funding package.
507550
let new_claim = numbered_tx(MAX_QUEUED_RETRIES as u32);
508551
match retries.schedule(BroadcastPackage::unclassified(new_claim.clone()), deadline(2)) {
@@ -528,11 +571,11 @@ mod tests {
528571
assert!(!remaining.contains(&oldest_claim.compute_txid()));
529572
}
530573

531-
/// When only funding packages wait at the bound, an incoming non-funding package is refused:
574+
/// When only funding packages wait at the bound, an incoming droppable package is refused:
532575
/// LDK re-broadcasts claims and sweeps periodically, while a dropped funding package would
533576
/// leave its transaction confirming without a recorded candidate.
534577
#[tokio::test]
535-
async fn retry_queue_refuses_a_non_funding_package_over_waiting_funding_packages() {
578+
async fn retry_queue_refuses_a_droppable_package_over_waiting_funding_packages() {
536579
fn numbered_tx(n: u32) -> Transaction {
537580
Transaction {
538581
version: bitcoin::transaction::Version::TWO,
@@ -556,4 +599,89 @@ mod tests {
556599
ScheduleOutcome::Refused(_)
557600
));
558601
}
602+
603+
/// A cooperative close is never dropped at the bound: nothing re-broadcasts it, and the
604+
/// queued package may hold the only copy of the signed closing transaction.
605+
#[tokio::test]
606+
async fn retry_queue_never_drops_a_cooperative_close_at_the_bound() {
607+
fn numbered_tx(n: u32) -> Transaction {
608+
Transaction {
609+
version: bitcoin::transaction::Version::TWO,
610+
lock_time: bitcoin::absolute::LockTime::ZERO,
611+
input: vec![txin(Txid::from_byte_array([9u8; 32]), n)],
612+
output: vec![txout(1_000)],
613+
}
614+
}
615+
616+
let mut retries = RetryQueue::new();
617+
let coop_close_tx = numbered_tx(0);
618+
assert!(matches!(
619+
retries.schedule(coop_close_package(&coop_close_tx), deadline(2)),
620+
ScheduleOutcome::Scheduled { dropped: None }
621+
));
622+
let oldest_claim = numbered_tx(1);
623+
for n in 1..(MAX_QUEUED_RETRIES as u32) {
624+
assert!(matches!(
625+
retries.schedule(claim_package(&numbered_tx(n)), deadline(2)),
626+
ScheduleOutcome::Scheduled { dropped: None }
627+
));
628+
}
629+
630+
// At the bound, an incoming claim drops the oldest waiting claim — not the older
631+
// cooperative close.
632+
let new_claim = numbered_tx(MAX_QUEUED_RETRIES as u32);
633+
match retries.schedule(claim_package(&new_claim), deadline(2)) {
634+
ScheduleOutcome::Scheduled { dropped: Some(dropped) } => {
635+
assert_eq!(dropped.sorted_txids(), vec![oldest_claim.compute_txid()]);
636+
},
637+
_ => panic!("the incoming claim must be scheduled by dropping the oldest one"),
638+
}
639+
640+
// An incoming cooperative close is never dropped for the bound either.
641+
let new_coop_close_tx = numbered_tx(MAX_QUEUED_RETRIES as u32 + 1);
642+
assert!(matches!(
643+
retries.schedule(coop_close_package(&new_coop_close_tx), deadline(2)),
644+
ScheduleOutcome::Scheduled { dropped: None }
645+
));
646+
647+
let mut remaining = Vec::new();
648+
while let Some(package) = retries.pop_next() {
649+
remaining.extend(package.sorted_txids());
650+
}
651+
assert!(
652+
remaining.contains(&coop_close_tx.compute_txid()),
653+
"a cooperative close is never dropped"
654+
);
655+
assert!(remaining.contains(&new_coop_close_tx.compute_txid()));
656+
assert!(!remaining.contains(&oldest_claim.compute_txid()));
657+
}
658+
659+
/// When only cooperative closes wait at the bound, an incoming claim is refused: LDK
660+
/// re-broadcasts the claim periodically, while a dropped close would lose the only copy of
661+
/// its signed closing transaction.
662+
#[tokio::test]
663+
async fn retry_queue_refuses_a_claim_over_waiting_cooperative_closes() {
664+
fn numbered_tx(n: u32) -> Transaction {
665+
Transaction {
666+
version: bitcoin::transaction::Version::TWO,
667+
lock_time: bitcoin::absolute::LockTime::ZERO,
668+
input: vec![txin(Txid::from_byte_array([10u8; 32]), n)],
669+
output: vec![txout(1_000)],
670+
}
671+
}
672+
673+
let mut retries = RetryQueue::new();
674+
for n in 0..(MAX_QUEUED_RETRIES as u32) {
675+
assert!(matches!(
676+
retries.schedule(coop_close_package(&numbered_tx(n)), deadline(2)),
677+
ScheduleOutcome::Scheduled { dropped: None }
678+
));
679+
}
680+
681+
let claim = numbered_tx(MAX_QUEUED_RETRIES as u32);
682+
assert!(matches!(
683+
retries.schedule(claim_package(&claim), deadline(2)),
684+
ScheduleOutcome::Refused(_)
685+
));
686+
}
559687
}

0 commit comments

Comments
 (0)