From f439deade4cc9d91090b9b0f7a98573b2305af19 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Thu, 30 Apr 2026 21:17:43 +0100 Subject: [PATCH 1/4] event: remove peer from store on counterparty-initiated force-close --- src/event.rs | 38 +++++++++- tests/integration_tests_rust.rs | 121 ++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/event.rs b/src/event.rs index 65fe683ec..d2aa83a40 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1585,11 +1585,47 @@ where } => { log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason); + // If the counterparty initiated closure of their last remaining channel + // with us, remove them from the peer store so we stop trying to reconnect. + // + // If we initiated the closure, keep them in the peer store so the + // background reconnection task fires and we can complete the + // channel_reestablish recovery flow. This matters especially for LND + // peers, which need us to reconnect to recover from force-closures. + // + // We exclude `channel_id` from the remaining-channel check because LDK + // fires ChannelClosed before removing the channel from its internal list, + // so list_channels_with_counterparty still includes the closing channel. + if let Some(counterparty_node_id) = counterparty_node_id { + let all_channels = + self.channel_manager.list_channels_with_counterparty(&counterparty_node_id); + + let has_other_channels = + all_channels.iter().any(|c| c.channel_id != channel_id); + + let counterparty_initiated = matches!( + reason, + ClosureReason::CounterpartyForceClosed { .. } + | ClosureReason::CounterpartyInitiatedCooperativeClosure + ); + + if !has_other_channels && counterparty_initiated { + if let Err(e) = self.peer_store.remove_peer(&counterparty_node_id) { + log_error!( + self.logger, + "Failed to remove peer {} from peer store: {}", + counterparty_node_id, + e + ); + } + } + } + let event = Event::ChannelClosed { channel_id, user_channel_id: UserChannelId(user_channel_id), counterparty_node_id, - reason: Some(reason), + reason: Some(reason.clone()), }; match self.event_queue.add_event(event).await { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index d2c057a16..ad42803df 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2957,3 +2957,124 @@ async fn splice_in_with_all_balance() { node_a.stop().unwrap(); node_b.stop().unwrap(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_peer_removed_on_counterparty_force_close() { + // When the counterparty force-closes the last channel between us, we + // should remove them from the peer store — no need to keep reconnecting. + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b], + Amount::from_sat(100_000), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // Open a channel from A to B and wait for it to be ready. + open_channel(&node_a, &node_b, 50_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + expect_event!(node_a, ChannelReady); + expect_event!(node_b, ChannelReady); + + // Confirm B is in A's peer store. + let node_b_id = node_b.node_id(); + let node_a_id = node_a.node_id(); + assert!( + node_a.list_peers().iter().any(|p| p.node_id == node_b_id), + "node_b should be in node_a peer store after channel open" + ); + + // B force-closes (counterparty-initiated from A's perspective). + let channels_b = node_b.list_channels(); + assert_eq!(channels_b.len(), 1); + node_b.force_close_channel(&channels_b[0].user_channel_id, node_a_id, None).unwrap(); + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_event!(node_a, ChannelClosed); + expect_event!(node_b, ChannelClosed); + + // checks if peer is persisted in the store + assert!( + !node_a.list_peers().iter().any(|p| p.node_id == node_b_id && p.is_persisted), + "node_b should not be persisted in node_a peer store after counterparty force-close" + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_peer_retained_on_local_force_close() { + // When WE force-close, keep the peer in the store so we can reconnect + // and complete channel_reestablish recovery. This matters especially + // for LND counterparties that may not handle force-closure error + // messages correctly. + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b], + Amount::from_sat(100_000), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // Open a channel from A to B and wait for it to be ready. + open_channel(&node_a, &node_b, 50_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + expect_event!(node_a, ChannelReady); + expect_event!(node_b, ChannelReady); + + // Confirm B is in A's peer store. + let node_b_id = node_b.node_id(); + assert!( + node_a.list_peers().iter().any(|p| p.node_id == node_b_id), + "node_b should be in node_a peer store after channel open" + ); + + // A force-closes (locally-initiated from A's perspective). + let channels_a = node_a.list_channels(); + assert_eq!(channels_a.len(), 1); + node_a.force_close_channel(&channels_a[0].user_channel_id, node_b_id, None).unwrap(); + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_event!(node_a, ChannelClosed); + expect_event!(node_b, ChannelClosed); + + // A should STILL have B in peer store — kept for channel_reestablish. + assert!( + !node_a.list_peers().iter().any(|p| p.node_id == node_b_id && p.is_persisted), + "node_b should not be persisted in node_a peer store after locally-initiated force-close" + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} From bb7d1706deb3a701873f29a3e2483e2c8d3295a2 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Fri, 1 May 2026 00:47:01 +0100 Subject: [PATCH 2/4] updated test name --- tests/integration_tests_rust.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index ad42803df..0e7180754 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3019,7 +3019,7 @@ async fn test_peer_removed_on_counterparty_force_close() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_peer_retained_on_local_force_close() { +async fn test_peer_not_persisted_on_local_force_close() { // When WE force-close, keep the peer in the store so we can reconnect // and complete channel_reestablish recovery. This matters especially // for LND counterparties that may not handle force-closure error From cad9b7365d66ceca657e555cfe99dd4ef5288d6b Mon Sep 17 00:00:00 2001 From: jolah1 Date: Fri, 1 May 2026 01:08:11 +0100 Subject: [PATCH 3/4] fix peer connection after force_to_close --- src/lib.rs | 10 ++++++++-- tests/integration_tests_rust.rs | 6 +++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b95e84470..d4cc7f8bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1842,9 +1842,15 @@ impl Node { } // Check if this was the last open channel, if so, forget the peer. - if open_channels.len() == 1 { + //For force-closes we keep the peer + // in the store so the background reconnection task can fire and + // complete the channel_reestablish recovery flow. This is especially + // important for LND peers + // The peer will be removed by the ChannelClosed event handler once + // the counterparty reconnects and confirms closure. + if open_channels.len() == 1 && !force { self.peer_store.remove_peer(&counterparty_node_id)?; - } + } } Ok(()) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 0e7180754..c4076db49 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3019,7 +3019,7 @@ async fn test_peer_removed_on_counterparty_force_close() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_peer_not_persisted_on_local_force_close() { +async fn test_peer_retained_on_local_force_close() { // When WE force-close, keep the peer in the store so we can reconnect // and complete channel_reestablish recovery. This matters especially // for LND counterparties that may not handle force-closure error @@ -3071,8 +3071,8 @@ async fn test_peer_not_persisted_on_local_force_close() { // A should STILL have B in peer store — kept for channel_reestablish. assert!( - !node_a.list_peers().iter().any(|p| p.node_id == node_b_id && p.is_persisted), - "node_b should not be persisted in node_a peer store after locally-initiated force-close" + node_a.list_peers().iter().any(|p| p.node_id == node_b_id && p.is_persisted), + "node_b should remain persisted in node_a peer store after locally-initiated force-close" ); node_a.stop().unwrap(); From c1bd96c2609d01797700f466ead252c5b2ffb577 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Fri, 1 May 2026 01:58:44 +0100 Subject: [PATCH 4/4] fmt: run cargo fmt --- src/lib.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d4cc7f8bd..5f1f6c2c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1843,14 +1843,14 @@ impl Node { // Check if this was the last open channel, if so, forget the peer. //For force-closes we keep the peer - // in the store so the background reconnection task can fire and - // complete the channel_reestablish recovery flow. This is especially - // important for LND peers + // in the store so the background reconnection task can fire and + // complete the channel_reestablish recovery flow. This is especially + // important for LND peers // The peer will be removed by the ChannelClosed event handler once - // the counterparty reconnects and confirms closure. + // the counterparty reconnects and confirms closure. if open_channels.len() == 1 && !force { self.peer_store.remove_peer(&counterparty_node_id)?; - } + } } Ok(())