From 2cc8d5cc0381da5d71776ee7a04d463de00111b4 Mon Sep 17 00:00:00 2001 From: amackillop Date: Thu, 25 Jun 2026 12:39:09 -0700 Subject: [PATCH] Add channel close and force-close by channel id close_channel and force_close_channel resolve the target by user_channel_id, scanning a counterparty's channels and acting on the first match. That breaks down here because every LSPS4-opened channel is created with user_channel_id = 0, so a counterparty holding more than one of them has several channels sharing the same id. The find then picks an arbitrary one and we close the wrong channel. Add close_channel_by_id and force_close_channel_by_id, which target the unique ChannelId directly. The existing user_channel_id methods stay as they were and now resolve user_channel_id to a channel_id before delegating to the shared by-id path, so their behavior is unchanged and the close and peer-cleanup logic lives in one place. This is what lets an operator close a specific channel, in particular a phantom 0conf channel that shares user_channel_id = 0 with a healthy channel to the same merchant. This can be dropped once LSPS4 channels are opened with a random user_channel_id and the existing channels stuck on user_channel_id = 0 have been closed. At that point user_channel_id is unique again and the original close_channel path is no longer ambiguous. --- src/lib.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4c39170999..809775d617 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -142,6 +142,7 @@ use lightning::ln::channel_state::{ChannelDetails as LdkChannelDetails, ChannelS use lightning::ln::channelmanager::PaymentId; use lightning::ln::funding::SpliceContribution; use lightning::ln::msgs::SocketAddress; +use lightning::ln::types::ChannelId; use lightning::routing::gossip::NodeAlias; use lightning::util::persist::KVStoreSync; use lightning_background_processor::process_events_async; @@ -1557,9 +1558,51 @@ impl Node { self.close_channel_internal(user_channel_id, counterparty_node_id, true, reason) } + /// Close a previously opened channel, identified by its [`ChannelId`]. + /// + /// Unlike [`Node::close_channel`], which resolves the channel by its + /// [`UserChannelId`], this targets the channel directly by its unique + /// [`ChannelId`]. Prefer this whenever a counterparty may hold multiple + /// channels that share a `user_channel_id` (e.g. LSP-opened channels all + /// assigned `user_channel_id = 0`), where resolving by `user_channel_id` + /// would close an arbitrary one of them. + pub fn close_channel_by_id( + &self, channel_id: &ChannelId, counterparty_node_id: PublicKey, + ) -> Result<(), Error> { + self.close_channel_by_id_internal(channel_id, counterparty_node_id, false, None) + } + + /// Force-close a previously opened channel, identified by its [`ChannelId`]. + /// + /// Behaves like [`Node::force_close_channel`] but targets the channel + /// directly by its unique [`ChannelId`] rather than resolving it by + /// [`UserChannelId`]. See [`Node::close_channel_by_id`] for why this matters. + pub fn force_close_channel_by_id( + &self, channel_id: &ChannelId, counterparty_node_id: PublicKey, reason: Option, + ) -> Result<(), Error> { + self.close_channel_by_id_internal(channel_id, counterparty_node_id, true, reason) + } + fn close_channel_internal( &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, force: bool, force_close_reason: Option, + ) -> Result<(), Error> { + let open_channels = + self.channel_manager.list_channels_with_counterparty(&counterparty_node_id); + match open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { + Some(channel) => self.close_channel_by_id_internal( + &channel.channel_id, + counterparty_node_id, + force, + force_close_reason, + ), + None => Ok(()), + } + } + + fn close_channel_by_id_internal( + &self, channel_id: &ChannelId, counterparty_node_id: PublicKey, force: bool, + force_close_reason: Option, ) -> Result<(), Error> { debug_assert!( force_close_reason.is_none() || force, @@ -1567,13 +1610,11 @@ impl Node { ); let open_channels: Vec = self.channel_manager.list_channels_with_counterparty(&counterparty_node_id); - if let Some(channel_details) = - open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) - { + if open_channels.iter().any(|c| &c.channel_id == channel_id) { if force { self.channel_manager .force_close_broadcasting_latest_txn( - &channel_details.channel_id, + channel_id, &counterparty_node_id, force_close_reason.unwrap_or_default(), ) @@ -1582,12 +1623,12 @@ impl Node { Error::ChannelClosingFailed })?; } else { - self.channel_manager - .close_channel(&channel_details.channel_id, &counterparty_node_id) - .map_err(|e| { + self.channel_manager.close_channel(channel_id, &counterparty_node_id).map_err( + |e| { log_error!(self.logger, "Failed to close channel: {:?}", e); Error::ChannelClosingFailed - })?; + }, + )?; } // Check if this was the last open channel, if so, forget the peer.