Release LSPS2 intercepted HTLCs on open failure
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit's LSPS2 (JIT channel) service. When a just-in-time (JIT) channel opening failed, intercepted HTLCs (pending payments held for that channel) were not being released back through the intercept API. They could sit until their expiry timeout, tying up funds and potentially causing payment failures or liquidity problems. The fix releases those held payments immediately when the channel open fails, and also handles the case where an intercept was already released by resetting the LSPS2 state anyway.
Review and merge the patch; ensure downstream users running LSPS2 service code update to a release containing this commit. Monitor for any related issues with HTLCs stuck on failed JIT opens in prior versions.
Security signals we found
Denial-of-service / liquidity lock-up: intercepted HTLCs held until expiry on JIT open failure
Incorrect HTLC release API used: fail_htlc_backwards_with_reason vs fail_intercepted_htlc
State cleanup bug in LSPS2 service on channel open failure
New regression test added to enforce correct cleanup behavior
Evidence from the diff
In lightning-liquidity/src/lsps2/service.rs, the channel_open_failed path previously called fail_htlc_backwards_with_reason with FailureCode::TemporaryNodeFailure for each queued intercepted HTLC. That method does not release the intercept-scid-based hold; the HTLC remains pending in the channel manager. The patch changes this to call fail_intercepted_htlc(htlc.intercept_id), which properly releases the intercepted HTLC through the intercept API. It also removes the FailureCode import and adds a comment explaining that if the intercept is already gone, the state reset should still proceed. A new integration test channel_open_failed_releases_intercepted_htlcs verifies that after channel_open_failed, fail_intercepted_htlc returns an error because the entry is no longer pending, and that an HTLCHandlingFailed event with InvalidForward for the intercept SCID is emitted.
Changed components
lightning-liquidity/src/lsps2/service.rslightning-liquidity/tests/lsps2_integration_tests.rsLSPS2 JIT channel service logicHTLC interception/release flowInspect captured patch +123 / −6
diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs
index 5f318fc..b52d12e 100644
--- a/lightning-liquidity/src/lsps2/service.rs
+++ b/lightning-liquidity/src/lsps2/service.rs
@@ -42,7 +42,7 @@ use crate::utils::async_poll::dummy_waker;
use lightning::chain::chaininterface::{BroadcasterInterface, TransactionType};
use lightning::events::HTLCHandlingFailureType;
-use lightning::ln::channelmanager::{AChannelManager, FailureCode, InterceptId};
+use lightning::ln::channelmanager::{AChannelManager, InterceptId};
use lightning::ln::msgs::{ErrorAction, LightningError};
use lightning::ln::types::ChannelId;
use lightning::util::errors::APIError;
@@ -1375,10 +1375,8 @@ where
{
let intercepted_htlcs = payment_queue.clear();
for htlc in intercepted_htlcs {
- self.channel_manager.get_cm().fail_htlc_backwards_with_reason(
- &htlc.payment_hash,
- FailureCode::TemporaryNodeFailure,
- );
+ // A missing intercept has already been released; still reset this LSPS2 state.
+ let _ = self.channel_manager.get_cm().fail_intercepted_htlc(htlc.intercept_id);
}
jit_channel.state = OutboundJITChannelState::PendingInitialPayment {
diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs
index d361215..6ebf176 100644
--- a/lightning-liquidity/tests/lsps2_integration_tests.rs
+++ b/lightning-liquidity/tests/lsps2_integration_tests.rs
@@ -7,7 +7,7 @@ use common::{
get_lsps_message, LSPSNodes, LSPSNodesWithPayer, LiquidityNode,
};
-use lightning::events::{ClosureReason, Event};
+use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType};
use lightning::get_event_msg;
use lightning::ln::channelmanager::{
OptionalBolt11PaymentParams, PaymentId, TrustedChannelFeatures,
@@ -453,6 +453,125 @@ fn channel_open_failed() {
};
}
+#[test]
+fn channel_open_failed_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-failed-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_failed(&client_node_id, user_channel_id).unwrap();
+
+ let res = service_node.inner.node.fail_intercepted_htlc(intercept_id);
+ assert!(
+ res.is_err(),
+ "channel_open_failed 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_failed_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.