Prune closed LSPS2 terminal channel state
What changed, and why it matters
This commit adds cleanup logic for a Lightning Service Provider (LSP) feature called LSPS2 Just-In-Time (JIT) channels. When a JIT channel closes, the service now deletes leftover internal tracking entries instead of keeping them forever in persistent storage. The change is framed as a state-management hygiene fix rather than an active vulnerability patch. It does not appear to fix a remotely triggerable exploit, but stale state could theoretically lead to confusion, incorrect reuse of old channel identifiers, or unbounded growth of stored data over time.
Treat as a routine maintenance/hygiene improvement. Integrators using LSPS2 should ensure their event loop forwards `Event::ChannelClosed` to the new handler so stale state is actually pruned. No urgent security deployment is indicated by the available evidence, but keeping persistent state bounded is good operational practice.
Security signals we found
State pruning for closed JIT channels to prevent indefinite retention of stale mappings
New public API surface (`channel_closed`) requiring correct event forwarding by callers
Persistence of updated peer state after pruning, with error mapping to `APIMisuseError`
Unit test covers idempotent removal and non-removal of unrelated channels
No explicit security bug, CVE, or exploit described in commit or supplied references
Evidence from the diff
The patch introduces remove_terminal_channel_state in lightning-liquidity/src/lsps2/service.rs, plus public channel_closed methods on LSPS2ServiceHandler and its sync wrapper. These methods are intended to be invoked when the host application processes an Event::ChannelClosed. They remove the intercept SCID mapping, outbound JIT channel state, and user-channel-id mapping for closed channels, then persist the updated peer state. lightning-liquidity/src/manager.rs documentation is updated to list Event::ChannelClosed as an event that should be forwarded. A unit test verifies that only the closed channel’s state is pruned while other channels remain intact. The commit message describes the change as preventing persisted service state from retaining stale entries indefinitely.
Changed components
lightning-liquidity/src/lsps2/service.rslightning-liquidity/src/manager.rsLSPS2ServiceHandlerLiquidityManagerOutboundJITChannel / PeerStateInspect captured patch +146 / −0
diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs
index 5f318fc..4675479 100644
--- a/lightning-liquidity/src/lsps2/service.rs
+++ b/lightning-liquidity/src/lsps2/service.rs
@@ -644,6 +644,26 @@ impl PeerState {
});
}
+ fn remove_terminal_channel_state(&mut self, channel_id: ChannelId) -> Option<u64> {
+ let intercept_scid = self.intercept_scid_by_channel_id.get(&channel_id).copied()?;
+ let should_remove = self
+ .outbound_channels_by_intercept_scid
+ .get(&intercept_scid)
+ .and_then(|entry| entry.get_channel_id())
+ .is_some_and(|existing_channel_id| existing_channel_id == channel_id);
+
+ if !should_remove {
+ return None;
+ }
+
+ self.outbound_channels_by_intercept_scid.remove(&intercept_scid);
+ self.intercept_scid_by_channel_id.remove(&channel_id);
+ self.intercept_scid_by_user_channel_id.retain(|_, iscid| *iscid != intercept_scid);
+ self.needs_persist = true;
+
+ Some(intercept_scid)
+ }
+
fn pending_requests_and_channels(&self) -> usize {
let pending_requests = self.pending_requests.len();
let pending_outbound_channels = self
@@ -1252,6 +1272,45 @@ where
Ok(())
}
+ /// Forward [`Event::ChannelClosed`] event parameter into this function.
+ ///
+ /// Will prune terminal JIT channel state once the corresponding channel has closed.
+ ///
+ /// [`Event::ChannelClosed`]: lightning::events::Event::ChannelClosed
+ pub async fn channel_closed(&self, channel_id: ChannelId) -> Result<(), APIError> {
+ let counterparty_node_id =
+ self.peer_by_channel_id.read().unwrap().get(&channel_id).copied();
+ let Some(counterparty_node_id) = counterparty_node_id else {
+ return Ok(());
+ };
+
+ let removed_intercept_scid = {
+ let outer_state_lock = self.per_peer_state.read().unwrap();
+ match outer_state_lock.get(&counterparty_node_id) {
+ Some(inner_state_lock) => {
+ let mut peer_state = inner_state_lock.lock().unwrap();
+ peer_state.remove_terminal_channel_state(channel_id)
+ },
+ None => None,
+ }
+ };
+
+ if let Some(intercept_scid) = removed_intercept_scid {
+ self.peer_by_intercept_scid.write().unwrap().remove(&intercept_scid);
+ self.peer_by_channel_id.write().unwrap().remove(&channel_id);
+ self.persist_peer_state(counterparty_node_id).await.map_err(|e| {
+ APIError::APIMisuseError {
+ err: format!(
+ "Failed to persist peer state after channel {} closed: {}",
+ channel_id, e
+ ),
+ }
+ })?;
+ }
+
+ Ok(())
+ }
+
/// Abandons a pending JIT‐open flow for `user_channel_id`, removing all local state.
///
/// This removes the intercept SCID, any outbound channel state, and associated
@@ -2270,6 +2329,25 @@ where
}
}
+ /// Forward [`Event::ChannelClosed`] event parameter into this function.
+ ///
+ /// Wraps [`LSPS2ServiceHandler::channel_closed`].
+ ///
+ /// [`Event::ChannelClosed`]: lightning::events::Event::ChannelClosed
+ pub fn channel_closed(&self, channel_id: ChannelId) -> Result<(), APIError> {
+ let mut fut = pin!(self.inner.channel_closed(channel_id));
+
+ let mut waker = dummy_waker();
+ let mut ctx = task::Context::from_waker(&mut waker);
+ match fut.as_mut().poll(&mut ctx) {
+ task::Poll::Ready(result) => result,
+ task::Poll::Pending => {
+ // In a sync context, we can't wait for the future to complete.
+ unreachable!("Should not be pending in a sync context");
+ },
+ }
+ }
+
/// Wraps [`LSPS2ServiceHandler::channel_needs_manual_broadcast`].
pub fn channel_needs_manual_broadcast(
&self, user_channel_id: u128, counterparty_node_id: &PublicKey,
@@ -2764,6 +2842,72 @@ mod tests {
}
}
+ #[test]
+ fn removes_terminal_state_for_closed_channel() {
+ 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 stale_intercept_scid = 42;
+ let stale_user_channel_id = 43;
+ let stale_channel_id = ChannelId([44; 32]);
+ let live_intercept_scid = 45;
+ let live_user_channel_id = 46;
+ let live_channel_id = ChannelId([47; 32]);
+
+ let mut stale_jit_channel =
+ OutboundJITChannel::new(None, opening_fee_params.clone(), stale_user_channel_id, false);
+ stale_jit_channel.state =
+ OutboundJITChannelState::PaymentForwarded { channel_id: stale_channel_id };
+ let mut live_jit_channel =
+ OutboundJITChannel::new(None, opening_fee_params, live_user_channel_id, false);
+ live_jit_channel.state =
+ OutboundJITChannelState::PaymentForwarded { channel_id: live_channel_id };
+
+ let mut peer_state = PeerState::new();
+ peer_state.insert_outbound_channel(stale_intercept_scid, stale_jit_channel);
+ peer_state.insert_outbound_channel(live_intercept_scid, live_jit_channel);
+ peer_state
+ .intercept_scid_by_user_channel_id
+ .insert(stale_user_channel_id, stale_intercept_scid);
+ peer_state
+ .intercept_scid_by_user_channel_id
+ .insert(live_user_channel_id, live_intercept_scid);
+ peer_state.intercept_scid_by_channel_id.insert(stale_channel_id, stale_intercept_scid);
+ peer_state.intercept_scid_by_channel_id.insert(live_channel_id, live_intercept_scid);
+ peer_state.needs_persist = false;
+
+ assert_eq!(
+ peer_state.remove_terminal_channel_state(stale_channel_id),
+ Some(stale_intercept_scid)
+ );
+ assert!(!peer_state
+ .outbound_channels_by_intercept_scid
+ .contains_key(&stale_intercept_scid));
+ assert!(peer_state.outbound_channels_by_intercept_scid.contains_key(&live_intercept_scid));
+ assert!(!peer_state.intercept_scid_by_user_channel_id.contains_key(&stale_user_channel_id));
+ assert_eq!(
+ peer_state.intercept_scid_by_user_channel_id.get(&live_user_channel_id),
+ Some(&live_intercept_scid)
+ );
+ assert!(!peer_state.intercept_scid_by_channel_id.contains_key(&stale_channel_id));
+ assert_eq!(
+ peer_state.intercept_scid_by_channel_id.get(&live_channel_id),
+ Some(&live_intercept_scid)
+ );
+ assert!(peer_state.needs_persist);
+
+ peer_state.needs_persist = false;
+ assert_eq!(peer_state.remove_terminal_channel_state(stale_channel_id), None);
+ assert!(!peer_state.needs_persist);
+ }
+
#[test]
fn broadcast_not_allowed_after_non_paying_fee_payment_claimed() {
let min_fee_msat: u64 = 12345;
diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs
index f1b098d..9accd9e 100644
--- a/lightning-liquidity/src/manager.rs
+++ b/lightning-liquidity/src/manager.rs
@@ -256,6 +256,7 @@ where
/// - [`Event::ChannelReady`] to [`LSPS2ServiceHandler::channel_ready`]
/// - [`Event::HTLCHandlingFailed`] to [`LSPS2ServiceHandler::htlc_handling_failed`]
/// - [`Event::PaymentForwarded`] to [`LSPS2ServiceHandler::payment_forwarded`]
+/// - [`Event::ChannelClosed`] to [`LSPS2ServiceHandler::channel_closed`]
///
/// [`PeerManager`]: lightning::ln::peer_handler::PeerManager
/// [`MessageHandler`]: lightning::ln::peer_handler::MessageHandler
@@ -263,6 +264,7 @@ where
/// [`Event::ChannelReady`]: lightning::events::Event::ChannelReady
/// [`Event::HTLCHandlingFailed`]: lightning::events::Event::HTLCHandlingFailed
/// [`Event::PaymentForwarded`]: lightning::events::Event::PaymentForwarded
+/// [`Event::ChannelClosed`]: lightning::events::Event::ChannelClosed
pub struct LiquidityManager<
ES: EntropySource + Clone,
NS: NodeSigner + Clone,
Why this scored 24/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.