Treat replayed LSPS2 HTLCs idempotently
What changed, and why it matters
This change fixes a crash and duplicate-queue bug in the Lightning Dev Kit's LSPS2 payment queue. After a restart, previously seen intercepted HTLCs can be replayed; the old code would panic (in debug builds) or add duplicates, while the new code returns the existing payment status instead. It is a reliability/idempotency fix rather than a direct theft-of-funds vulnerability, but a panic on restart could disrupt a liquidity service.
Review whether production builds could still be affected by any non-debug panic paths for intercept_id collisions, and confirm that downstream callers handle the returned status tuple consistently. Consider backporting to releases that include LSPS2 support.
Security signals we found
debug_assert! panic on replayed intercepted HTLCs removed
duplicate queued payment entries prevented
state consistency across restart improved
idempotency added to hot path HTLC handler
Evidence from the diff
In lightning-liquidity/src/lsps2/payment_queue.rs, add_htlc now checks whether any existing queued payment already contains an HTLC with the same intercept_id. If so, it returns the cached (total_expected_outbound_amount_msat, htlc_count) via a new payment_status helper and does not push a duplicate. Previously the code only matched on payment_hash and used debug_assert! to claim the intercept_id must be new, which would panic on replayed events after restart. The patch also removes the now-redundant duplicate-detection assertion and adds a regression test for replay idempotency.
Changed components
lightning-liquidity/src/lsps2/payment_queue.rsPaymentQueue::add_htlcLSPS2 intercepted HTLC replay handlingInspect captured patch +25 / −8
diff --git a/lightning-liquidity/src/lsps2/payment_queue.rs b/lightning-liquidity/src/lsps2/payment_queue.rs
index 421e42d..600f588 100644
--- a/lightning-liquidity/src/lsps2/payment_queue.rs
+++ b/lightning-liquidity/src/lsps2/payment_queue.rs
@@ -26,21 +26,29 @@ impl PaymentQueue {
PaymentQueue { payments: Vec::new() }
}
+ fn payment_status(entry: &PaymentQueueEntry) -> (u64, usize) {
+ let total_expected_outbound_amount_msat =
+ entry.htlcs.iter().map(|htlc| htlc.expected_outbound_amount_msat).sum();
+ (total_expected_outbound_amount_msat, entry.htlcs.len())
+ }
+
pub(crate) fn add_htlc(&mut self, new_htlc: InterceptedHTLC) -> (u64, usize) {
+ if let Some(entry) = self
+ .payments
+ .iter()
+ .find(|entry| entry.htlcs.iter().any(|htlc| htlc.intercept_id == new_htlc.intercept_id))
+ {
+ debug_assert_eq!(entry.payment_hash, new_htlc.payment_hash);
+ return Self::payment_status(entry);
+ }
+
let payment =
self.payments.iter_mut().find(|entry| entry.payment_hash == new_htlc.payment_hash);
if let Some(entry) = payment {
// HTLCs within a payment should have the same payment hash.
debug_assert!(entry.htlcs.iter().all(|htlc| htlc.payment_hash == entry.payment_hash));
- // The given HTLC should not already be present.
- debug_assert!(entry
- .htlcs
- .iter()
- .all(|htlc| htlc.intercept_id != new_htlc.intercept_id));
entry.htlcs.push(new_htlc);
- let total_expected_outbound_amount_msat =
- entry.htlcs.iter().map(|htlc| htlc.expected_outbound_amount_msat).sum();
- (total_expected_outbound_amount_msat, entry.htlcs.len())
+ Self::payment_status(entry)
} else {
let expected_outbound_amount_msat = new_htlc.expected_outbound_amount_msat;
let entry =
@@ -127,6 +135,15 @@ mod tests {
(500_000_000, 2),
);
+ assert_eq!(
+ payment_queue.add_htlc(InterceptedHTLC {
+ intercept_id: InterceptId([2; 32]),
+ expected_outbound_amount_msat: 300_000_000,
+ payment_hash: PaymentHash([100; 32]),
+ }),
+ (500_000_000, 2),
+ );
+
let expected_entry = PaymentQueueEntry {
payment_hash: PaymentHash([100; 32]),
htlcs: vec![
Why this scored 44/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.