Add LSPS2 replay regression coverage
What changed, and why it matters
This commit adds a new regression test for the LSPS2 (Lightning Service Provider Specification 2) feature in rust-lightning. It checks that if a node crashes and restarts, replaying the same intercepted payment event does not cause the service to accidentally queue or process the payment twice. The test ensures that after restoring saved peer state, replaying an already-seen HTLC returns no action, and the payment is still forwarded correctly once the channel is ready. There is no code fix here—only a new test to catch a past or potential duplicate-queueing bug.
Treat as a defensive regression test rather than an active vulnerability. If the project has not already shipped a production fix for the duplicate-queueing race, review OutboundJITChannel::htlc_intercepted and PeerState deserialization to ensure replayed HTLCs are correctly deduplicated before any channel-open or forward action is queued. Run the new test in CI and consider adding similar idempotency tests for inbound JIT channels and other persisted interception paths.
Security signals we found
Regression test for replay idempotency after state persistence
Race condition between persistence and replayed intercepted HTLC events
Potential duplicate queueing/processing of intercepted HTLCs on restart
No production code change—only test coverage added
Evidence from the diff
The diff adds a unit test, replayed_intercepted_htlc_after_persist_is_idempotent, in lightning-liquidity/src/lsps2/service.rs. The test constructs an OutboundJITChannel, intercepts an HTLC, persists PeerState via encode()/read() using lightning serialization traits, then replays the same InterceptedHTLC against the deserialized channel. It asserts that htlc_intercepted() returns None on replay (idempotency) and that channel_ready() still produces a ForwardPaymentAction containing the original HTLC. The commit message describes this as covering a race between persistence and replayed intercepted HTLC events after restart, so duplicate queueing is caught.
Changed components
lightning-liquidity/src/lsps2/service.rsLSPS2 service state persistence and HTLC replay handlingOutboundJITChannel HTLC interception logicInspect captured patch +48 / −0
diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs
index 4675479..4f338a5 100644
--- a/lightning-liquidity/src/lsps2/service.rs
+++ b/lightning-liquidity/src/lsps2/service.rs
@@ -2439,6 +2439,8 @@ mod tests {
use bitcoin::{absolute::LockTime, transaction::Version};
use core::str::FromStr;
+ use lightning::io::Cursor;
+ use lightning::util::ser::{Readable, Writeable};
const MAX_VALUE_MSAT: u64 = 21_000_000_0000_0000_000;
@@ -2842,6 +2844,52 @@ mod tests {
}
}
+ #[test]
+ fn replayed_intercepted_htlc_after_persist_is_idempotent() {
+ let payment_size_msat = Some(500_000_000);
+ let opening_fee_params = LSPS2OpeningFeeParams {
+ min_fee_msat: 10_000_000,
+ proportional: 10_000,
+ valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(),
+ min_lifetime: 4032,
+ max_client_to_self_delay: 2016,
+ min_payment_size_msat: 10_000_000,
+ max_payment_size_msat: 1_000_000_000,
+ promise: "ignore".to_string(),
+ };
+ let intercept_scid = 42;
+ let user_channel_id = 43;
+ let htlc = InterceptedHTLC {
+ intercept_id: InterceptId([1; 32]),
+ expected_outbound_amount_msat: 500_000_000,
+ payment_hash: PaymentHash([2; 32]),
+ };
+
+ let mut jit_channel =
+ OutboundJITChannel::new(payment_size_msat, opening_fee_params, user_channel_id, false);
+ assert!(matches!(
+ jit_channel.htlc_intercepted(htlc).unwrap(),
+ Some(HTLCInterceptedAction::OpenChannel(_))
+ ));
+
+ let mut peer_state = PeerState::new();
+ peer_state.intercept_scid_by_user_channel_id.insert(user_channel_id, intercept_scid);
+ peer_state.insert_outbound_channel(intercept_scid, jit_channel);
+
+ let encoded_peer_state = peer_state.encode();
+ let mut decoded_peer_state = PeerState::read(&mut Cursor::new(encoded_peer_state)).unwrap();
+ let decoded_jit_channel = decoded_peer_state
+ .outbound_channels_by_intercept_scid
+ .get_mut(&intercept_scid)
+ .unwrap();
+
+ assert!(decoded_jit_channel.htlc_intercepted(htlc).unwrap().is_none());
+
+ let ForwardPaymentAction(_, fee_payment) =
+ decoded_jit_channel.channel_ready(ChannelId([3; 32])).unwrap();
+ assert_eq!(fee_payment.htlcs, vec![htlc]);
+ }
+
#[test]
fn removes_terminal_state_for_closed_channel() {
let opening_fee_params = LSPS2OpeningFeeParams {
Why this scored 29/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.