Fail held HTLCs on LSPS2 abandon
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit's LSPS2 (Just-In-Time channel) service. Previously, when a service provider abandoned a pending JIT channel open, any intercepted HTLCs (pending payment holds) waiting for that channel were left stuck in limbo instead of being properly failed backwards. The patch now drains the queued HTLCs and fails them before removing the channel state, preventing funds from being locked up and ensuring the payment sender receives a failure notification.
Review and merge the patch, then ensure any deployments running LSPS2 JIT channels update to include this fix to avoid stuck HTLCs on abandoned channel opens.
Security signals we found
HTLCs held in payment_queue could remain pending indefinitely after channel_open_abandoned, potentially locking sender funds
Missing cleanup of intercepted HTLC state on JIT channel abandonment
Regression test confirms fail_intercepted_htlc is invoked and HTLC is released
Evidence from the diff
In lightning-liquidity/src/lsps2/service.rs, channel_open_abandoned now retrieves the JIT channel mutably, extracts any queued intercepted HTLCs from OutboundJITChannelState::PendingInitialPayment or PendingChannelOpen, and calls fail_intercepted_htlc for each before removing the intercept SCID and channel mappings. A regression test channel_open_abandoned_releases_intercepted_htlcs verifies that after abandonment, the intercepted HTLC is no longer pending and produces the expected HTLCHandlingFailed event.
Changed components
lightning-liquidity/src/lsps2/service.rslightning-liquidity/tests/lsps2_integration_tests.rsLSPS2 JIT channel service handlerInspect captured patch +139 / −16
diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs
index b4ae2db..5987756 100644
--- a/lightning-liquidity/src/lsps2/service.rs
+++ b/lightning-liquidity/src/lsps2/service.rs
@@ -1316,6 +1316,8 @@ where
/// This removes the intercept SCID, any outbound channel state, and associated
/// channel‐ID mappings for the specified `user_channel_id`, but only while no payment
/// has been forwarded yet and no channel has been opened on-chain.
+ /// Any held HTLCs for the pending flow are failed backwards before the local state
+ /// is removed.
///
/// Returns an error if:
/// - there is no channel matching `user_channel_id`, or
@@ -1351,25 +1353,27 @@ where
let jit_channel = peer_state
.outbound_channels_by_intercept_scid
- .get(&intercept_scid)
+ .get_mut(&intercept_scid)
.ok_or_else(|| APIError::APIMisuseError {
- err: format!(
- "Failed to map intercept_scid {} for user_channel_id {} to a channel.",
- intercept_scid, user_channel_id,
- ),
- })?;
+ err: format!(
+ "Failed to map intercept_scid {} for user_channel_id {} to a channel.",
+ intercept_scid, user_channel_id,
+ ),
+ })?;
- let is_pending = matches!(
- jit_channel.state,
- OutboundJITChannelState::PendingInitialPayment { .. }
- | OutboundJITChannelState::PendingChannelOpen { .. }
- );
+ let intercepted_htlcs = match &mut jit_channel.state {
+ OutboundJITChannelState::PendingInitialPayment { payment_queue }
+ | OutboundJITChannelState::PendingChannelOpen { payment_queue, .. } => payment_queue.clear(),
+ _ => {
+ return Err(APIError::APIMisuseError {
+ err: "Cannot abandon channel open after channel creation or payment forwarding"
+ .to_string(),
+ });
+ },
+ };
- if !is_pending {
- return Err(APIError::APIMisuseError {
- err: "Cannot abandon channel open after channel creation or payment forwarding"
- .to_string(),
- });
+ for htlc in intercepted_htlcs {
+ let _ = self.channel_manager.get_cm().fail_intercepted_htlc(htlc.intercept_id);
}
peer_state.intercept_scid_by_user_channel_id.remove(&user_channel_id);
diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs
index 6ebf176..241fabe 100644
--- a/lightning-liquidity/tests/lsps2_integration_tests.rs
+++ b/lightning-liquidity/tests/lsps2_integration_tests.rs
@@ -682,6 +682,125 @@ fn channel_open_abandoned() {
assert!(result.is_err());
}
+#[test]
+fn channel_open_abandoned_releases_intercepted_htlcs() {
+ let chanmon_cfgs = create_chanmon_cfgs(3);
+ let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
+ let mut service_node_config = test_default_channel_config();
+ service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8;
+
+ let mut client_node_config = test_default_channel_config();
+ client_node_config.channel_config.accept_underpaying_htlcs = true;
+
+ let node_chanmgrs = create_node_chanmgrs(
+ 3,
+ &node_cfgs,
+ &[Some(service_node_config), Some(client_node_config), None],
+ );
+ let nodes = create_network(3, &node_cfgs, &node_chanmgrs);
+ let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes);
+ let LSPSNodesWithPayer { ref service_node, ref client_node, ref payer_node } = lsps_nodes;
+
+ let payer_node_id = payer_node.node.get_our_node_id();
+ let service_node_id = service_node.inner.node.get_our_node_id();
+ let client_node_id = client_node.inner.node.get_our_node_id();
+
+ let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap();
+ create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000);
+
+ let intercept_scid = service_node.node.get_intercept_scid();
+ let user_channel_id = 42u128;
+ let cltv_expiry_delta: u32 = 144;
+ let payment_size_msat = Some(1_000_000);
+ let fee_base_msat: u64 = 1_000;
+
+ execute_lsps2_dance(
+ &lsps_nodes,
+ intercept_scid,
+ user_channel_id,
+ cltv_expiry_delta,
+ promise_secret,
+ payment_size_msat,
+ fee_base_msat,
+ );
+
+ let invoice = create_jit_invoice(
+ &client_node,
+ service_node_id,
+ intercept_scid,
+ cltv_expiry_delta,
+ payment_size_msat,
+ "channel-open-abandoned-cleanup",
+ 3600,
+ )
+ .unwrap();
+
+ payer_node
+ .node
+ .pay_for_bolt11_invoice(
+ &invoice,
+ PaymentId(invoice.payment_hash().0),
+ None,
+ OptionalBolt11PaymentParams::default(),
+ )
+ .unwrap();
+
+ check_added_monitors(&payer_node, 1);
+ let events = payer_node.node.get_and_clear_pending_msg_events();
+ let ev = SendEvent::from_event(events[0].clone());
+ service_node.inner.node.handle_update_add_htlc(payer_node_id, &ev.msgs[0]);
+ do_commitment_signed_dance(&service_node.inner, &payer_node, &ev.commitment_msg, false, true);
+ service_node.inner.node.process_pending_htlc_forwards();
+
+ let events = service_node.inner.node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 1);
+ let intercept_id = match &events[0] {
+ Event::HTLCIntercepted {
+ intercept_id,
+ requested_next_hop_scid,
+ payment_hash,
+ expected_outbound_amount_msat,
+ ..
+ } => {
+ assert_eq!(*requested_next_hop_scid, intercept_scid);
+ service_handler
+ .htlc_intercepted(
+ *requested_next_hop_scid,
+ *intercept_id,
+ *expected_outbound_amount_msat,
+ *payment_hash,
+ )
+ .unwrap();
+ *intercept_id
+ },
+ other => panic!("Expected HTLCIntercepted, got {:?}", other),
+ };
+
+ match service_node.liquidity_manager.next_event().unwrap() {
+ LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::OpenChannel { .. }) => {},
+ other => panic!("Unexpected event: {:?}", other),
+ };
+
+ service_handler.channel_open_abandoned(&client_node_id, user_channel_id).unwrap();
+
+ let res = service_node.inner.node.fail_intercepted_htlc(intercept_id);
+ assert!(
+ res.is_err(),
+ "channel_open_abandoned must release the intercepted HTLC via fail_intercepted_htlc, but the entry is still pending: {:?}",
+ res,
+ );
+
+ let events = service_node.inner.node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 1);
+ match &events[0] {
+ Event::HTLCHandlingFailed {
+ failure_type: HTLCHandlingFailureType::InvalidForward { requested_forward_scid },
+ ..
+ } => assert_eq!(*requested_forward_scid, intercept_scid),
+ other => panic!("Expected HTLCHandlingFailed, got {:?}", other),
+ }
+}
+
#[test]
fn channel_open_abandoned_nonexistent_channel() {
let chanmon_cfgs = create_chanmon_cfgs(2);
Why this scored 60/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.