From c844affdd7a6392b64f253c486c09058bef02d13 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Jul 2026 13:33:57 +0200 Subject: [PATCH 1/2] Add simple-close interop coverage Expose an opt-in for v2 cooperative closes and exercise both legacy and simple-close negotiations against implementations that support the protocol. Co-Authored-By: HAL 9000 --- Cargo.toml | 26 +++++++++++++------------- src/config.rs | 25 ++++++++++++++++++++++--- tests/common/mod.rs | 25 +++++++++++++++++-------- tests/common/scenarios/channel.rs | 24 +++++++++++++++++++++++- tests/common/scenarios/mod.rs | 8 +++++--- tests/docker/docker-compose-lnd.yml | 1 + tests/integration_tests_cln.rs | 10 +++++----- tests/integration_tests_eclair.rs | 19 ++++++++++++++----- tests/integration_tests_lnd.rs | 19 ++++++++++++++----- 9 files changed, 114 insertions(+), 43 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7fc945833e..40827fc362 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -185,16 +185,16 @@ harness = false #vss-client-ng = { path = "../vss-client" } #vss-client-ng = { git = "https://github.com/lightningdevkit/vss-client", branch = "main" } # -#[patch."https://github.com/lightningdevkit/rust-lightning"] -#lightning = { path = "../rust-lightning/lightning" } -#lightning-types = { path = "../rust-lightning/lightning-types" } -#lightning-invoice = { path = "../rust-lightning/lightning-invoice" } -#lightning-net-tokio = { path = "../rust-lightning/lightning-net-tokio" } -#lightning-persister = { path = "../rust-lightning/lightning-persister" } -#lightning-background-processor = { path = "../rust-lightning/lightning-background-processor" } -#lightning-rapid-gossip-sync = { path = "../rust-lightning/lightning-rapid-gossip-sync" } -#lightning-block-sync = { path = "../rust-lightning/lightning-block-sync" } -#lightning-transaction-sync = { path = "../rust-lightning/lightning-transaction-sync" } -#lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } -#lightning-macros = { path = "../rust-lightning/lightning-macros" } -#lightning-dns-resolver = { path = "../rust-lightning/lightning-dns-resolver" } +[patch."https://github.com/lightningdevkit/rust-lightning"] +lightning = { path = "../rust-lightning/lightning" } +lightning-types = { path = "../rust-lightning/lightning-types" } +lightning-invoice = { path = "../rust-lightning/lightning-invoice" } +lightning-net-tokio = { path = "../rust-lightning/lightning-net-tokio" } +lightning-persister = { path = "../rust-lightning/lightning-persister" } +lightning-background-processor = { path = "../rust-lightning/lightning-background-processor" } +lightning-rapid-gossip-sync = { path = "../rust-lightning/lightning-rapid-gossip-sync" } +lightning-block-sync = { path = "../rust-lightning/lightning-block-sync" } +lightning-transaction-sync = { path = "../rust-lightning/lightning-transaction-sync" } +lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } +lightning-macros = { path = "../rust-lightning/lightning-macros" } +lightning-dns-resolver = { path = "../rust-lightning/lightning-dns-resolver" } diff --git a/src/config.rs b/src/config.rs index 958eb14fcd..8292eb0010 100644 --- a/src/config.rs +++ b/src/config.rs @@ -143,6 +143,7 @@ pub(crate) const LNURL_AUTH_TIMEOUT_SECS: u64 = 15; /// | `announcement_addresses` | None | /// | `node_alias` | None | /// | `trusted_peers_0conf` | [] | +/// | `enable_v2_channel_close` | false | /// | `probing_liquidity_limit_multiplier` | 3 | /// | `anchor_channels_config` | AnchorChannelsConfig::default() | /// | `route_parameters` | None | @@ -182,6 +183,10 @@ pub struct Config { /// funding transaction ends up never being confirmed on-chain. Zero-confirmation channels /// should therefore only be accepted from trusted peers. pub trusted_peers_0conf: Vec, + /// Whether to enable the `option_simple_close` protocol for cooperative channel closures. + /// + /// The protocol will only be used when supported by the channel counterparty. + pub enable_v2_channel_close: bool, /// The liquidity factor by which we filter the outgoing channels used for sending probes. /// /// Channels with available liquidity less than the required amount times this value won't be @@ -222,6 +227,7 @@ impl Default for Config { listening_addresses: None, announcement_addresses: None, trusted_peers_0conf: Vec::new(), + enable_v2_channel_close: false, probing_liquidity_limit_multiplier: DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER, anchor_channels_config: AnchorChannelsConfig::default(), tor_config: None, @@ -428,6 +434,7 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { user_config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = config.anchor_channels_config.enable_zero_fee_commitments; user_config.reject_inbound_splices = false; + user_config.enable_v2_channel_close = config.enable_v2_channel_close; if may_announce_channel(config).is_err() { user_config.accept_forwards_to_priv_channels = false; @@ -776,9 +783,9 @@ mod tests { use std::str::FromStr; use super::{ - clamp_full_scan_stop_gap, may_announce_channel, AnnounceError, Config, ElectrumSyncConfig, - EsploraSyncConfig, NodeAlias, SocketAddress, DEFAULT_FULL_SCAN_STOP_GAP, - MAX_FULL_SCAN_STOP_GAP, MIN_FULL_SCAN_STOP_GAP, + clamp_full_scan_stop_gap, default_user_config, may_announce_channel, AnnounceError, Config, + ElectrumSyncConfig, EsploraSyncConfig, NodeAlias, SocketAddress, + DEFAULT_FULL_SCAN_STOP_GAP, MAX_FULL_SCAN_STOP_GAP, MIN_FULL_SCAN_STOP_GAP, }; #[test] @@ -844,4 +851,16 @@ mod tests { assert_eq!(clamp_full_scan_stop_gap(0), MIN_FULL_SCAN_STOP_GAP); assert_eq!(clamp_full_scan_stop_gap(MAX_FULL_SCAN_STOP_GAP + 1), MAX_FULL_SCAN_STOP_GAP); } + + #[test] + fn v2_channel_close_config() { + let default_config = Config::default(); + assert!(!default_user_config(&default_config).enable_v2_channel_close); + + let enabled_config = Config { enable_v2_channel_close: true, ..Config::default() }; + assert!( + default_user_config(&enabled_config).enable_v2_channel_close, + "v2 channel close opt-in must be forwarded to LDK" + ); + } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 50e2b993c8..6fc0c5343b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -799,21 +799,30 @@ pub(crate) async fn wait_for_tx(electrs: &E, txid: Txid) { .await; } -pub(crate) async fn wait_for_outpoint_spend(electrs: &E, outpoint: OutPoint) { +pub(crate) async fn wait_for_outpoint_spend( + electrs: &E, outpoint: OutPoint, +) -> Transaction { let tx = electrs.transaction_get(&outpoint.txid).unwrap(); let txout_script = tx.output.get(outpoint.vout as usize).unwrap().clone().script_pubkey; - // Script history already contains the funding transaction itself, so wait until the exact - // funding outpoint leaves the unspent set instead of treating any history as a spend. + // Script history already contains the funding transaction itself and may contain unrelated + // transactions for reused scripts, so only return a transaction spending the exact outpoint. exponential_backoff_poll(|| { electrs.ping().unwrap(); - let is_spent = !electrs.script_list_unspent(&txout_script).unwrap().iter().any(|output| { - output.tx_hash == outpoint.txid && output.tx_pos == outpoint.vout as usize - }); - is_spent.then_some(()) + electrs.script_get_history(&txout_script).unwrap().into_iter().find_map(|entry| { + if entry.tx_hash == outpoint.txid { + return None; + } + let transaction = electrs.transaction_get(&entry.tx_hash).ok()?; + transaction + .input + .iter() + .any(|input| input.previous_output == outpoint) + .then_some(transaction) + }) }) - .await; + .await } /// Polls the channel from `source_node` to `counterparty_node` until it reports `is_usable` diff --git a/tests/common/scenarios/channel.rs b/tests/common/scenarios/channel.rs index 74e04127c6..0f3f102e8e 100644 --- a/tests/common/scenarios/channel.rs +++ b/tests/common/scenarios/channel.rs @@ -7,12 +7,13 @@ use std::time::Duration; +use bitcoin::Sequence; use electrsd::corepc_node::Client as BitcoindClient; use electrsd::electrum_client::ElectrumApi; use ldk_node::{Event, Node}; use super::super::external_node::ExternalNode; -use super::super::generate_blocks_and_wait; +use super::super::{generate_blocks_and_wait, wait_for_outpoint_spend}; use super::Side; /// Open a channel from LDK to peer; returns (user_channel_id, external_channel_id). @@ -50,6 +51,12 @@ pub(crate) async fn cooperative_close( user_channel_id: &ldk_node::UserChannelId, ext_channel_id: &str, initiator: Side, ) { tokio::time::sleep(Duration::from_secs(2)).await; + let funding_txo = node + .list_channels() + .into_iter() + .find(|channel| channel.user_channel_id == *user_channel_id) + .and_then(|channel| channel.funding_txo) + .expect("channel funding outpoint must be available before cooperative close"); match initiator { Side::Ldk => { let ext_node_id = peer.get_node_id().await.unwrap(); @@ -59,6 +66,21 @@ pub(crate) async fn cooperative_close( peer.close_channel(ext_channel_id).await.unwrap(); }, } + let closing_tx = wait_for_outpoint_spend(electrs, funding_txo).await; + let funding_input = closing_tx + .input + .iter() + .find(|input| input.previous_output == funding_txo) + .expect("closing transaction must spend the channel funding outpoint"); + let expected_sequence = if node.config().enable_v2_channel_close { + Sequence::ENABLE_RBF_NO_LOCKTIME + } else { + Sequence::MAX + }; + assert_eq!( + funding_input.sequence, expected_sequence, + "cooperative close used an unexpected transaction format" + ); generate_blocks_and_wait(bitcoind, electrs, 1).await; super::sync_wallets_with_retry(node).await; expect_event!(node, ChannelClosed); diff --git a/tests/common/scenarios/mod.rs b/tests/common/scenarios/mod.rs index ffbfc2b007..6d7db69eb1 100644 --- a/tests/common/scenarios/mod.rs +++ b/tests/common/scenarios/mod.rs @@ -89,8 +89,9 @@ pub(crate) async fn wait_for_htlcs_settled( /// Build a fresh LDK node configured for interop tests. Uses electrum at the /// docker-compose default port and bumps sync timeouts for combo stress. -pub(crate) fn setup_ldk_node() -> Node { - let config = crate::common::random_config(); +pub(crate) fn setup_ldk_node(enable_v2_channel_close: bool) -> Node { + let mut config = crate::common::random_config(); + config.node_config.enable_v2_channel_close = enable_v2_channel_close; let mut builder = ldk_node::Builder::from_config(config.node_config); let mut sync_config = ldk_node::config::ElectrumSyncConfig::default(); sync_config.timeouts_config.onchain_wallet_sync_timeout_secs = 180; @@ -138,13 +139,14 @@ pub(crate) async fn setup_interop_test( /// per-impl `setup_clients` future and a scenario fn. pub(crate) async fn run_interop_scenario( setup_fut: impl Future, scenario: F, + enable_v2_channel_close: bool, ) where N: ExternalNode, E: ElectrumApi, F: AsyncFnOnce(&Node, &N, &BitcoindClient, &E), { let (bitcoind, electrs, ext) = setup_fut.await; - let node = setup_ldk_node(); + let node = setup_ldk_node(enable_v2_channel_close); setup_interop_test(&node, &ext, &bitcoind, &electrs).await; scenario(&node, &ext, &bitcoind, &electrs).await; node.stop().unwrap(); diff --git a/tests/docker/docker-compose-lnd.yml b/tests/docker/docker-compose-lnd.yml index ad6a4e2192..94bddad162 100644 --- a/tests/docker/docker-compose-lnd.yml +++ b/tests/docker/docker-compose-lnd.yml @@ -77,6 +77,7 @@ services: - "--bitcoind.zmqpubrawblock=tcp://bitcoin:28332" - "--bitcoind.zmqpubrawtx=tcp://bitcoin:28333" - "--accept-keysend" + - "--protocol.rbf-coop-close" - "--rpclisten=0.0.0.0:8081" - "--tlsextradomain=lnd" - "--tlsextraip=0.0.0.0" diff --git a/tests/integration_tests_cln.rs b/tests/integration_tests_cln.rs index 484c52b109..b55d363307 100644 --- a/tests/integration_tests_cln.rs +++ b/tests/integration_tests_cln.rs @@ -31,26 +31,26 @@ async fn setup_clients() -> (BitcoindClient, ElectrumClient, TestClnNode) { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_basic_channel_cycle() { - run_interop_scenario(setup_clients(), basic_channel_cycle_scenario).await; + run_interop_scenario(setup_clients(), basic_channel_cycle_scenario, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[ignore = "CLN <=v25.12.x keysend final_cltv=22 < LDK min 42; fixed in master (ElementsProject/lightning#9034), awaiting v26.04 Docker image"] async fn test_keysend() { - run_interop_scenario(setup_clients(), keysend_scenario).await; + run_interop_scenario(setup_clients(), keysend_scenario, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_force_close_after_payment() { - run_interop_scenario(setup_clients(), force_close_after_payment_scenario).await; + run_interop_scenario(setup_clients(), force_close_after_payment_scenario, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_disconnect_during_payment() { - run_interop_scenario(setup_clients(), disconnect_during_payment_scenario).await; + run_interop_scenario(setup_clients(), disconnect_during_payment_scenario, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_splice_in() { - run_interop_scenario(setup_clients(), splice_in_scenario).await; + run_interop_scenario(setup_clients(), splice_in_scenario, false).await; } diff --git a/tests/integration_tests_eclair.rs b/tests/integration_tests_eclair.rs index cec957a91d..6734d0f84d 100644 --- a/tests/integration_tests_eclair.rs +++ b/tests/integration_tests_eclair.rs @@ -47,28 +47,37 @@ async fn setup_clients() -> (BitcoindClient, ElectrumClient, TestEclairNode) { (bitcoind, electrs, eclair) } +async fn do_test_basic_channel_cycle(v2_closing: bool) { + run_interop_scenario(setup_clients(), basic_channel_cycle_scenario, v2_closing).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_basic_channel_cycle() { - run_interop_scenario(setup_clients(), basic_channel_cycle_scenario).await; + do_test_basic_channel_cycle(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_basic_channel_cycle_with_simple_close() { + do_test_basic_channel_cycle(true).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_keysend() { - run_interop_scenario(setup_clients(), keysend_scenario).await; + run_interop_scenario(setup_clients(), keysend_scenario, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_force_close_after_payment() { - run_interop_scenario(setup_clients(), force_close_after_payment_scenario).await; + run_interop_scenario(setup_clients(), force_close_after_payment_scenario, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_disconnect_during_payment() { - run_interop_scenario(setup_clients(), disconnect_during_payment_scenario).await; + run_interop_scenario(setup_clients(), disconnect_during_payment_scenario, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[ignore = "Eclair advertises splicing via custom bit 154 instead of BOLT bit 62/63; disjoint from LDK until Eclair migrates"] async fn test_splice_in() { - run_interop_scenario(setup_clients(), splice_in_scenario).await; + run_interop_scenario(setup_clients(), splice_in_scenario, false).await; } diff --git a/tests/integration_tests_lnd.rs b/tests/integration_tests_lnd.rs index ab82678afe..565ed8bbf9 100755 --- a/tests/integration_tests_lnd.rs +++ b/tests/integration_tests_lnd.rs @@ -29,28 +29,37 @@ async fn setup_clients() -> (BitcoindClient, ElectrumClient, TestLndNode) { (bitcoind, electrs, lnd) } +async fn do_test_basic_channel_cycle(v2_closing: bool) { + run_interop_scenario(setup_clients(), basic_channel_cycle_scenario, v2_closing).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_basic_channel_cycle() { - run_interop_scenario(setup_clients(), basic_channel_cycle_scenario).await; + do_test_basic_channel_cycle(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_basic_channel_cycle_with_simple_close() { + do_test_basic_channel_cycle(true).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_keysend() { - run_interop_scenario(setup_clients(), keysend_scenario).await; + run_interop_scenario(setup_clients(), keysend_scenario, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_force_close_after_payment() { - run_interop_scenario(setup_clients(), force_close_after_payment_scenario).await; + run_interop_scenario(setup_clients(), force_close_after_payment_scenario, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_disconnect_during_payment() { - run_interop_scenario(setup_clients(), disconnect_during_payment_scenario).await; + run_interop_scenario(setup_clients(), disconnect_during_payment_scenario, false).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[ignore = "LND does not implement BOLT splicing"] async fn test_splice_in() { - run_interop_scenario(setup_clients(), splice_in_scenario).await; + run_interop_scenario(setup_clients(), splice_in_scenario, false).await; } From c0aa6975bcc9bcf58908a464e29224b86af0141e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Jul 2026 13:42:46 +0200 Subject: [PATCH 2/2] f Use remote simple-close revision Make the interop branch reproducible without the sibling rust-lightning worktree by fetching the pushed simple-close revision from forigin. Co-Authored-By: HAL 9000 --- Cargo.toml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 40827fc362..2a62bd4a1d 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -186,15 +186,15 @@ harness = false #vss-client-ng = { git = "https://github.com/lightningdevkit/vss-client", branch = "main" } # [patch."https://github.com/lightningdevkit/rust-lightning"] -lightning = { path = "../rust-lightning/lightning" } -lightning-types = { path = "../rust-lightning/lightning-types" } -lightning-invoice = { path = "../rust-lightning/lightning-invoice" } -lightning-net-tokio = { path = "../rust-lightning/lightning-net-tokio" } -lightning-persister = { path = "../rust-lightning/lightning-persister" } -lightning-background-processor = { path = "../rust-lightning/lightning-background-processor" } -lightning-rapid-gossip-sync = { path = "../rust-lightning/lightning-rapid-gossip-sync" } -lightning-block-sync = { path = "../rust-lightning/lightning-block-sync" } -lightning-transaction-sync = { path = "../rust-lightning/lightning-transaction-sync" } -lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } -lightning-macros = { path = "../rust-lightning/lightning-macros" } -lightning-dns-resolver = { path = "../rust-lightning/lightning-dns-resolver" } +lightning = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" } +lightning-types = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" } +lightning-invoice = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" } +lightning-net-tokio = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" } +lightning-persister = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" } +lightning-background-processor = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" } +lightning-rapid-gossip-sync = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" } +lightning-block-sync = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" } +lightning-transaction-sync = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" } +lightning-liquidity = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" } +lightning-macros = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" } +lightning-dns-resolver = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "54f4ae38f09bf936376796685bdcdd30abc112e2" }