@@ -22,11 +22,11 @@ use crate::Error;
2222
2323const 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.
3030const 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.
8598pub ( 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