|
5 | 5 | // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in |
6 | 6 | // accordance with one or both of these licenses. |
7 | 7 |
|
| 8 | +use std::collections::VecDeque; |
8 | 9 | use std::ops::Deref; |
9 | 10 | use std::sync::{Mutex as StdMutex, Weak}; |
10 | 11 |
|
11 | | -use bitcoin::Transaction; |
| 12 | +use bitcoin::{Transaction, Txid}; |
12 | 13 | use lightning::chain::chaininterface::{ |
13 | 14 | BroadcasterInterface, TransactionType as LdkTransactionType, |
14 | 15 | }; |
15 | 16 | use tokio::sync::{mpsc, Mutex, MutexGuard}; |
| 17 | +use tokio::time::Instant; |
16 | 18 |
|
17 | 19 | use crate::logger::{log_error, LdkLogger}; |
18 | 20 | use crate::types::Wallet; |
19 | 21 | use crate::Error; |
20 | 22 |
|
21 | 23 | const BCAST_PACKAGE_QUEUE_SIZE: usize = 256; |
22 | 24 |
|
| 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. |
| 30 | +const MAX_QUEUED_RETRIES: usize = BCAST_PACKAGE_QUEUE_SIZE; |
| 31 | + |
23 | 32 | /// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` |
24 | 33 | /// call, along with each transaction's type. Queued until the background task classifies and |
25 | 34 | /// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated |
@@ -47,6 +56,100 @@ impl BroadcastPackage { |
47 | 56 | let txs = self.0.into_iter().map(|(tx, _)| tx).collect(); |
48 | 57 | SortedTransactions::sort_parents_child_package_topologically(txs) |
49 | 58 | } |
| 59 | + |
| 60 | + /// The packaged transactions' txids in sorted order, identifying the package's effect on |
| 61 | + /// chain: two packages with the same txids broadcast the same transactions. |
| 62 | + pub(crate) fn sorted_txids(&self) -> Vec<Txid> { |
| 63 | + let mut txids: Vec<Txid> = self.0.iter().map(|(tx, _)| tx.compute_txid()).collect(); |
| 64 | + txids.sort_unstable(); |
| 65 | + txids |
| 66 | + } |
| 67 | + |
| 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 | + ) |
| 79 | + }) |
| 80 | + } |
| 81 | +} |
| 82 | + |
| 83 | +/// What [`RetryQueue::schedule`] did with a package, so the caller can log the cases in which |
| 84 | +/// the package won't be retried as-is. |
| 85 | +pub(crate) enum ScheduleOutcome { |
| 86 | + /// 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 |
| 88 | + /// with LDK's next periodic rebroadcast. |
| 89 | + Scheduled { dropped: Option<BroadcastPackage> }, |
| 90 | + /// A package broadcasting the same transactions already waits, and its retry covers this |
| 91 | + /// one: the incoming package is dropped and returned. |
| 92 | + 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. |
| 95 | + Refused(BroadcastPackage), |
| 96 | +} |
| 97 | + |
| 98 | +/// Packages whose classification failed, each waiting out a retry delay before its next attempt. |
| 99 | +/// Deduplicated and bounded: LDK re-broadcasts pending claims every 30 seconds (and sweeps once |
| 100 | +/// per block) until they confirm, so while the store is unavailable, copies would otherwise |
| 101 | +/// accumulate without bound and replay as a burst on recovery. An identical copy is never queued |
| 102 | +/// twice — the waiting entry and its deadline stand; fee-bumped rebroadcast variants carry new |
| 103 | +/// txids, so the bound — not the dedup — is what limits their accumulation. |
| 104 | +pub(crate) struct RetryQueue(VecDeque<(Instant, Vec<Txid>, BroadcastPackage)>); |
| 105 | + |
| 106 | +impl RetryQueue { |
| 107 | + pub(crate) fn new() -> Self { |
| 108 | + Self(VecDeque::new()) |
| 109 | + } |
| 110 | + |
| 111 | + /// The deadline of the next retry, if a package is waiting. Packages are scheduled with a fixed |
| 112 | + /// delay, so the front entry is always the next to retry. |
| 113 | + pub(crate) fn next_retry_at(&self) -> Option<Instant> { |
| 114 | + self.0.front().map(|(deadline, _, _)| *deadline) |
| 115 | + } |
| 116 | + |
| 117 | + /// Removes and returns the package scheduled to retry first. |
| 118 | + pub(crate) fn pop_next(&mut self) -> Option<BroadcastPackage> { |
| 119 | + self.0.pop_front().map(|(_, _, package)| package) |
| 120 | + } |
| 121 | + |
| 122 | + /// 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`]. |
| 125 | + pub(crate) fn schedule( |
| 126 | + &mut self, package: BroadcastPackage, retry_at: Instant, |
| 127 | + ) -> ScheduleOutcome { |
| 128 | + let txids = package.sorted_txids(); |
| 129 | + if self.0.iter().any(|(_, waiting, _)| *waiting == txids) { |
| 130 | + // Same transactions, same classification outcome: keep the waiting entry and its |
| 131 | + // earlier deadline. The one same-txid package with a *different* type is LDK's |
| 132 | + // re-typed generic-funding rebroadcast of a promoted 0conf splice, which always |
| 133 | + // arrives after the interactive-funding original (the zero-conf rebroadcast canary |
| 134 | + // tests assert that ordering), so the entry kept is the richer of the two — and its |
| 135 | + // classification declines the downgrade anyway. |
| 136 | + return ScheduleOutcome::AlreadyQueued(package); |
| 137 | + } |
| 138 | + |
| 139 | + 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 |
| 142 | + // periodically, while the incoming package may carry a fresher fee-bumped variant. |
| 143 | + // 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()) { |
| 146 | + Some(oldest) => dropped = self.0.remove(oldest).map(|(_, _, package)| package), |
| 147 | + None => return ScheduleOutcome::Refused(package), |
| 148 | + } |
| 149 | + } |
| 150 | + self.0.push_back((retry_at, txids, package)); |
| 151 | + ScheduleOutcome::Scheduled { dropped } |
| 152 | + } |
50 | 153 | } |
51 | 154 |
|
52 | 155 | pub(crate) struct SortedTransactions(Vec<Transaction>); |
@@ -171,7 +274,10 @@ mod tests { |
171 | 274 | use bitcoin::hashes::Hash; |
172 | 275 | use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; |
173 | 276 |
|
174 | | - use super::SortedTransactions; |
| 277 | + use super::{ |
| 278 | + BroadcastPackage, LdkTransactionType, RetryQueue, ScheduleOutcome, SortedTransactions, |
| 279 | + MAX_QUEUED_RETRIES, |
| 280 | + }; |
175 | 281 |
|
176 | 282 | fn txin(txid: Txid, vout: u32) -> TxIn { |
177 | 283 | TxIn { |
@@ -312,4 +418,142 @@ mod tests { |
312 | 418 | fn topological_sort_accepts_empty_vec() { |
313 | 419 | SortedTransactions::sort_parents_child_package_topologically(Vec::new()); |
314 | 420 | } |
| 421 | + |
| 422 | + fn funding_package(tx: &Transaction) -> BroadcastPackage { |
| 423 | + BroadcastPackage::new(&[(tx, LdkTransactionType::Funding { channels: vec![] })]) |
| 424 | + } |
| 425 | + |
| 426 | + fn deadline(secs: u64) -> tokio::time::Instant { |
| 427 | + tokio::time::Instant::now() + std::time::Duration::from_secs(secs) |
| 428 | + } |
| 429 | + |
| 430 | + /// A re-broadcast of the same transactions is not queued again: the waiting entry keeps its |
| 431 | + /// earlier deadline and its package — the first arrival carries the richer classification |
| 432 | + /// when LDK later re-types a rebroadcast. |
| 433 | + #[tokio::test] |
| 434 | + async fn retry_queue_queues_identical_transactions_once() { |
| 435 | + let tx = parent_tx(1); |
| 436 | + let mut retries = RetryQueue::new(); |
| 437 | + |
| 438 | + let first_deadline = deadline(2); |
| 439 | + assert!(matches!( |
| 440 | + retries.schedule(funding_package(&tx), first_deadline), |
| 441 | + ScheduleOutcome::Scheduled { dropped: None } |
| 442 | + )); |
| 443 | + assert!(matches!( |
| 444 | + retries.schedule(BroadcastPackage::unclassified(tx.clone()), deadline(4)), |
| 445 | + ScheduleOutcome::AlreadyQueued(_) |
| 446 | + )); |
| 447 | + |
| 448 | + assert_eq!(retries.next_retry_at(), Some(first_deadline)); |
| 449 | + let kept = retries.pop_next().expect("the first package is kept"); |
| 450 | + assert!( |
| 451 | + matches!(kept.transactions()[0].1, Some(LdkTransactionType::Funding { .. })), |
| 452 | + "the first-scheduled package must be kept" |
| 453 | + ); |
| 454 | + assert!(retries.pop_next().is_none()); |
| 455 | + } |
| 456 | + |
| 457 | + #[tokio::test] |
| 458 | + async fn retry_queue_retries_in_schedule_order() { |
| 459 | + let (tx_a, tx_b) = (parent_tx(1), parent_tx(2)); |
| 460 | + let mut retries = RetryQueue::new(); |
| 461 | + |
| 462 | + assert!(matches!( |
| 463 | + retries.schedule(BroadcastPackage::unclassified(tx_a.clone()), deadline(2)), |
| 464 | + ScheduleOutcome::Scheduled { dropped: None } |
| 465 | + )); |
| 466 | + assert!(matches!( |
| 467 | + retries.schedule(BroadcastPackage::unclassified(tx_b.clone()), deadline(2)), |
| 468 | + ScheduleOutcome::Scheduled { dropped: None } |
| 469 | + )); |
| 470 | + |
| 471 | + let popped = retries.pop_next().expect("first package"); |
| 472 | + assert_eq!(popped.sorted_txids(), vec![tx_a.compute_txid()]); |
| 473 | + let popped = retries.pop_next().expect("second package"); |
| 474 | + assert_eq!(popped.sorted_txids(), vec![tx_b.compute_txid()]); |
| 475 | + } |
| 476 | + |
| 477 | + /// 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 |
| 479 | + /// package. |
| 480 | + #[tokio::test] |
| 481 | + async fn retry_queue_drops_the_oldest_non_funding_package_at_the_bound() { |
| 482 | + fn numbered_tx(n: u32) -> Transaction { |
| 483 | + Transaction { |
| 484 | + version: bitcoin::transaction::Version::TWO, |
| 485 | + lock_time: bitcoin::absolute::LockTime::ZERO, |
| 486 | + input: vec![txin(Txid::from_byte_array([7u8; 32]), n)], |
| 487 | + output: vec![txout(1_000)], |
| 488 | + } |
| 489 | + } |
| 490 | + |
| 491 | + let mut retries = RetryQueue::new(); |
| 492 | + let funding_tx = numbered_tx(0); |
| 493 | + assert!(matches!( |
| 494 | + retries.schedule(funding_package(&funding_tx), deadline(2)), |
| 495 | + ScheduleOutcome::Scheduled { dropped: None } |
| 496 | + )); |
| 497 | + let oldest_claim = numbered_tx(1); |
| 498 | + for n in 1..(MAX_QUEUED_RETRIES as u32) { |
| 499 | + assert!(matches!( |
| 500 | + retries.schedule(BroadcastPackage::unclassified(numbered_tx(n)), deadline(2)), |
| 501 | + ScheduleOutcome::Scheduled { dropped: None } |
| 502 | + )); |
| 503 | + } |
| 504 | + |
| 505 | + // At the bound, an incoming non-funding package drops the oldest waiting one — not the |
| 506 | + // older funding package. |
| 507 | + let new_claim = numbered_tx(MAX_QUEUED_RETRIES as u32); |
| 508 | + match retries.schedule(BroadcastPackage::unclassified(new_claim.clone()), deadline(2)) { |
| 509 | + ScheduleOutcome::Scheduled { dropped: Some(dropped) } => { |
| 510 | + assert_eq!(dropped.sorted_txids(), vec![oldest_claim.compute_txid()]); |
| 511 | + }, |
| 512 | + _ => panic!("the incoming claim must be scheduled by dropping the oldest one"), |
| 513 | + } |
| 514 | + |
| 515 | + // An incoming funding package is never dropped for the bound. |
| 516 | + let new_funding_tx = numbered_tx(MAX_QUEUED_RETRIES as u32 + 1); |
| 517 | + assert!(matches!( |
| 518 | + retries.schedule(funding_package(&new_funding_tx), deadline(2)), |
| 519 | + ScheduleOutcome::Scheduled { dropped: None } |
| 520 | + )); |
| 521 | + |
| 522 | + let mut remaining = Vec::new(); |
| 523 | + while let Some(package) = retries.pop_next() { |
| 524 | + remaining.extend(package.sorted_txids()); |
| 525 | + } |
| 526 | + assert!(remaining.contains(&funding_tx.compute_txid()), "funding is never dropped"); |
| 527 | + assert!(remaining.contains(&new_claim.compute_txid())); |
| 528 | + assert!(!remaining.contains(&oldest_claim.compute_txid())); |
| 529 | + } |
| 530 | + |
| 531 | + /// When only funding packages wait at the bound, an incoming non-funding package is refused: |
| 532 | + /// LDK re-broadcasts claims and sweeps periodically, while a dropped funding package would |
| 533 | + /// leave its transaction confirming without a recorded candidate. |
| 534 | + #[tokio::test] |
| 535 | + async fn retry_queue_refuses_a_non_funding_package_over_waiting_funding_packages() { |
| 536 | + fn numbered_tx(n: u32) -> Transaction { |
| 537 | + Transaction { |
| 538 | + version: bitcoin::transaction::Version::TWO, |
| 539 | + lock_time: bitcoin::absolute::LockTime::ZERO, |
| 540 | + input: vec![txin(Txid::from_byte_array([8u8; 32]), n)], |
| 541 | + output: vec![txout(1_000)], |
| 542 | + } |
| 543 | + } |
| 544 | + |
| 545 | + let mut retries = RetryQueue::new(); |
| 546 | + for n in 0..(MAX_QUEUED_RETRIES as u32) { |
| 547 | + assert!(matches!( |
| 548 | + retries.schedule(funding_package(&numbered_tx(n)), deadline(2)), |
| 549 | + ScheduleOutcome::Scheduled { dropped: None } |
| 550 | + )); |
| 551 | + } |
| 552 | + |
| 553 | + let claim = numbered_tx(MAX_QUEUED_RETRIES as u32); |
| 554 | + assert!(matches!( |
| 555 | + retries.schedule(BroadcastPackage::unclassified(claim), deadline(2)), |
| 556 | + ScheduleOutcome::Refused(_) |
| 557 | + )); |
| 558 | + } |
315 | 559 | } |
0 commit comments