From 47dbaec38467c39a5043b60bf03b32832e0d7ae7 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 17 Jul 2026 17:22:44 +0000 Subject: [PATCH 1/8] Increase the size of the broadcast package queue In 202474948014c5d9b43ba17b31263704717a7c54, we started taking one slot in the package queue for each transaction broadcasted by the wallet upon `WalletEvent::ChainTipChanged`, so we increase the number of slots available in the queue. --- src/tx_broadcaster.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 491a9cbde..782112dad 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -18,7 +18,7 @@ use crate::logger::{log_error, LdkLogger}; use crate::types::Wallet; use crate::Error; -const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; +const BCAST_PACKAGE_QUEUE_SIZE: usize = 256; /// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` /// call, along with each transaction's type. Queued until the background task classifies and From 91b8b7a7fcd4c794a8112456a5d3cb309287831e Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 17 Jul 2026 17:22:56 +0000 Subject: [PATCH 2/8] f: Draw the rebroadcast tx batch at random Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f8d9d521e..f569d2985 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -63,6 +63,7 @@ use crate::payment::{ }; use crate::runtime::Runtime; use crate::types::{Broadcaster, PaymentStore, PendingPaymentStore}; +use crate::util::random_range; use crate::{ChainSource, Error}; pub(crate) enum OnchainSendAmount { @@ -326,7 +327,7 @@ impl Wallet { } if !unconfirmed_outbound_txids.is_empty() { - let txs_to_broadcast: Vec = { + let mut txs_to_broadcast: Vec = { let locked_wallet = self.inner.lock().expect("lock"); unconfirmed_outbound_txids .iter() @@ -337,6 +338,10 @@ impl Wallet { }) .collect() }; + for i in (1..txs_to_broadcast.len()).rev() { + let j = random_range(0, i as u64) as usize; + txs_to_broadcast.swap(i, j); + } if !txs_to_broadcast.is_empty() { let tx_count = txs_to_broadcast.len(); From 727bc620b37ee926ea5e68308daf20a3209cc2a6 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 17 Jul 2026 17:22:58 +0000 Subject: [PATCH 3/8] Stop the chain source upon startup checks failure This is particularly relevant for the electrum chain source; if we fail to fetch feerates, or zero fee commitments validation fails, and we do not stop the electrum chain source before returning an error, then the user will hit a debug assertion on the next restart. --- src/lib.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cb570243c..dfddb714e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -335,14 +335,18 @@ impl Node { // // TODO: drop 0FC chain source validation when support is ubiquitous let chain_source = Arc::clone(&self.chain_source); - self.runtime.block_on(async move { + let startup_chain_check_res = self.runtime.block_on(async move { tokio::try_join!( chain_source.update_fee_rate_estimates(), chain_source.validate_zero_fee_commitments_support_if_required( zero_fee_commitments_support_required ) ) - })?; + }); + if let Err(e) = startup_chain_check_res { + self.chain_source.stop(); + return Err(e); + } // Spawn background task continuously syncing onchain, lightning, and fee rate cache. let stop_sync_receiver = self.stop_sender.subscribe(); From fe9131271805fe6816c6007dda8ddea9f0cfd836 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 17 Jul 2026 17:23:00 +0000 Subject: [PATCH 4/8] f: Stop chain source after *all* startup failures Co-Authored-By: HAL 9000 --- src/lib.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index dfddb714e..3cfc78223 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -291,6 +291,16 @@ impl Node { return Err(Error::AlreadyRunning); } + match self.start_inner(&mut is_running_lock) { + Ok(()) => Ok(()), + Err(e) => { + self.chain_source.stop(); + Err(e) + }, + } + } + + fn start_inner(&self, is_running_lock: &mut bool) -> Result<(), Error> { log_info!( self.logger, "Starting up LDK Node with node ID {} on network: {}", @@ -335,18 +345,14 @@ impl Node { // // TODO: drop 0FC chain source validation when support is ubiquitous let chain_source = Arc::clone(&self.chain_source); - let startup_chain_check_res = self.runtime.block_on(async move { + self.runtime.block_on(async move { tokio::try_join!( chain_source.update_fee_rate_estimates(), chain_source.validate_zero_fee_commitments_support_if_required( zero_fee_commitments_support_required ) ) - }); - if let Err(e) = startup_chain_check_res { - self.chain_source.stop(); - return Err(e); - } + })?; // Spawn background task continuously syncing onchain, lightning, and fee rate cache. let stop_sync_receiver = self.stop_sender.subscribe(); From 1aa6618af82434d9cb428dfd296124c3280058f2 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 17 Jul 2026 17:23:03 +0000 Subject: [PATCH 5/8] Validate submitpackage behavior for v29+ Require Electrum and Esplora zero-fee commitments validation to observe the Bitcoin Core v29+ failure shape for the dummy TRUC package, instead of accepting any structured submitpackage response. Set the locktime field of the transaction to zero so that the test works at any chain-height, which is particularly helpful when starting ldk-node against test networks. Map HTTP 400 errors returned to `ChainSourceNotSupported` as this error code is returned by blockstream-electrs and mempool-electrs when running against Bitcoin Core v26. We previously would map this error to a general `ConnectionFailed` error, which is not correct for Bitcoin Core v26. Co-Authored-By: HAL 9000 --- src/chain/electrum.rs | 42 +++++++++++++++++++++++++++++++----------- src/chain/esplora.rs | 43 ++++++++++++++++++++++++++++++------------- src/chain/mod.rs | 35 ++++++++++++++++++++++++++--------- 3 files changed, 87 insertions(+), 33 deletions(-) diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 59fa23a6c..0d897272a 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -19,7 +19,8 @@ use bdk_wallet::{KeychainKind as BdkKeyChainKind, Update as BdkUpdate}; use bitcoin::transaction::Version; use bitcoin::{FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; use electrum_client::{ - Batch, Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder, ElectrumApi, + Batch, BroadcastPackageRes, Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder, + ElectrumApi, }; use lightning::chain::{Confirm, Filter, WatchedOutput}; use lightning::util::ser::Writeable; @@ -321,11 +322,6 @@ impl ElectrumChainSource { return Err(Error::ConnectionFailed); }; - // TODO: Use `protocol_version` API once shipped in - // https://github.com/bitcoindevkit/rust-electrum-client/pull/213. - // - // This could still accept an Electrum server running against Bitcoin Core v26 - // through v28, which does not relay ephemeral dust. let spawn_fut = electrum_client.runtime.spawn_blocking({ let electrum_client = Arc::clone(&electrum_client.electrum_client); move || electrum_client.transaction_broadcast_package(&super::dummy_package()) @@ -336,11 +332,19 @@ impl ElectrumChainSource { ); match timeout_fut.await { - Ok(Ok(Ok(_))) => Ok(()), - Ok(Ok(Err( - e @ (electrum_client::Error::Protocol(_) - | electrum_client::Error::AllAttemptsErrored(_)), - ))) => { + Ok(Ok(Ok(result))) => { + if dummy_submit_package_result_matches_v29_or_later(&result) { + Ok(()) + } else { + log_error!( + self.logger, + "Electrum server does not support submitpackage: {:?}", + result + ); + Err(Error::ChainSourceNotSupported) + } + }, + Ok(Ok(Err(e))) if electrum_submitpackage_error_implies_unsupported(&e) => { log_error!(self.logger, "Electrum server does not support submitpackage: {:?}", e); Err(Error::ChainSourceNotSupported) }, @@ -377,6 +381,22 @@ impl ElectrumChainSource { } } +fn electrum_submitpackage_error_implies_unsupported(e: &electrum_client::Error) -> bool { + matches!(e, electrum_client::Error::Protocol(_) | electrum_client::Error::AllAttemptsErrored(_)) +} + +fn dummy_submit_package_result_matches_v29_or_later(result: &BroadcastPackageRes) -> bool { + if result.success { + return false; + } + + super::dummy_package_txids().iter().all(|expected_txid| { + result.errors.iter().any(|error| { + &error.txid == expected_txid && error.error == super::DUMMY_PACKAGE_EXPECTED_ERROR + }) + }) +} + impl Filter for ElectrumChainSource { fn register_tx(&self, txid: &Txid, script_pubkey: &Script) { self.electrum_runtime_status.write().expect("lock").register_tx(txid, script_pubkey) diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 21205bd25..465b7d124 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -13,7 +13,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use bdk_esplora::EsploraAsyncExt; use bitcoin::transaction::Version; use bitcoin::{FeeRate, Network, Script, Txid}; -use esplora_client::AsyncClient as EsploraAsyncClient; +use esplora_client::{AsyncClient as EsploraAsyncClient, SubmitPackageResult}; use lightning::chain::{Confirm, Filter, WatchedOutput}; use lightning::util::ser::Writeable; use lightning_transaction_sync::EsploraSyncClient; @@ -86,16 +86,13 @@ impl EsploraChainSource { } pub(super) async fn validate_zero_fee_commitments_support(&self) -> Result<(), Error> { - // This could still accept an Esplora server running against Bitcoin Core v26 - // through v28, which does not relay ephemeral dust. - self.esplora_client.submit_package(&super::dummy_package(), None, None).await.map_err( - |e| { - if let esplora_client::Error::HttpResponse { status: 404, message } = e { - log_error!( - self.logger, - "Esplora server does not support submitpackage: {}", - message - ); + let result = self + .esplora_client + .submit_package(&super::dummy_package(), None, None) + .await + .map_err(|e| { + if esplora_submitpackage_error_implies_unsupported(&e) { + log_error!(self.logger, "Esplora server does not support submitpackage: {}", e); Error::ChainSourceNotSupported } else { log_error!( @@ -105,8 +102,11 @@ impl EsploraChainSource { ); Error::ConnectionFailed } - }, - )?; + })?; + if !dummy_submit_package_result_matches_v29_or_later(&result) { + log_error!(self.logger, "Esplora server does not support submitpackage: {:?}", result); + return Err(Error::ChainSourceNotSupported); + } Ok(()) } @@ -521,6 +521,23 @@ impl EsploraChainSource { } } +fn esplora_submitpackage_error_implies_unsupported(e: &esplora_client::Error) -> bool { + matches!(e, esplora_client::Error::HttpResponse { status: 400 | 404, .. }) +} + +fn dummy_submit_package_result_matches_v29_or_later(result: &SubmitPackageResult) -> bool { + if result.package_msg != "transaction failed" { + return false; + } + + super::dummy_package_txids().iter().all(|expected_txid| { + result.tx_results.values().any(|tx_result| { + &tx_result.txid == expected_txid + && tx_result.error.as_deref() == Some(super::DUMMY_PACKAGE_EXPECTED_ERROR) + }) + }) +} + impl Filter for EsploraChainSource { fn register_tx(&self, txid: &Txid, script_pubkey: &Script) { self.tx_sync.register_tx(txid, script_pubkey); diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 0f96c409f..9b2f97544 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -31,19 +31,20 @@ use crate::{Error, PersistedNodeMetrics}; /// We use this parent-child TRUC package to make sure the configured chain source supports /// broadcasting packages via the `submitpackage` Bitcoin Core RPC. -const PARENT_TXID: &str = "9a015f93fac6cb203c2b994e18b85176eb0354a22a468255516f3c6002d3f696"; +const DUMMY_PACKAGE_EXPECTED_ERROR: &str = "bad-txns-inputs-missingorspent"; +const PARENT_TXID: &str = "11105ba7c94f2fdc1b870a9fbdb136ca006bcf598c372548a1a3051001f3238f"; const PARENT_HEX: &str = - "0300000000010160d0cdb72f2ddf719f40ca32f44614c67577fc75996140544003915683c34a310000000000fd\ + "0300000000010160d0cdb72f2ddf719f40ca32f44614c67577fc75996140544003915683c34a310000000000ff\ ffffff0201000000000000000451024e73876100000000000022512042731375894dad3b25092cd0f713dc5bee4\ a71e30a95e1db3d880906d7eba1fa01409327942924218e4eb1635a7cce6706fcb37b8bbb61a2f0b86357356681\ - 4e09419a3501e02252043bb237d479304632282fe9159db9e9a6ae6ec5bedea9f0f115a97b0e00"; -const CHILD_TXID: &str = "d011b3ff78cdfb8b93822639ea87771847936b04bb83afc8763a7c02a386ae26"; + 4e09419a3501e02252043bb237d479304632282fe9159db9e9a6ae6ec5bedea9f0f11500000000"; +const CHILD_TXID: &str = "6a051464dbf0534a060a61355a6a971559de004795328f8dfd19f3197f9bb4b0"; const CHILD_HEX: &str = - "0300000000010296f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0000000000ff\ - ffffff96f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0100000000fdffffff015\ + "030000000001028f23f3011005a3a14825378c59cf6b00ca36b1bd9f0a871bdc2f4fc9a75b10110000000000ff\ + ffffff8f23f3011005a3a14825378c59cf6b00ca36b1bd9f0a871bdc2f4fc9a75b10110100000000ffffffff015\ 660000000000000225120ac18cd599a1be003595854e2eeec18dbe1c92d04b0ba05812d04445e3fcf16bc000140\ 1462a35808d77a164f0a23a84c4721d1545befd09ad19945bb8aa0ea5576953a9699038725f944b1bc429942ef4\ - 7e6504a554babf022cb15db53be2d8c1dbfe5a97b0e00"; + 7e6504a554babf022cb15db53be2d8c1dbfe500000000"; fn dummy_package() -> [bitcoin::Transaction; 2] { use bitcoin::consensus::Decodable; @@ -55,11 +56,27 @@ fn dummy_package() -> [bitcoin::Transaction; 2] { Transaction::consensus_decode(&mut &parent_tx_bytes[..]).expect("read from a constant"); let child = Transaction::consensus_decode(&mut &child_tx_bytes[..]).expect("read from a constant"); - assert_eq!(parent.compute_txid().to_string(), PARENT_TXID); - assert_eq!(child.compute_txid().to_string(), CHILD_TXID); + let [parent_txid, child_txid] = dummy_package_txids(); + assert_eq!(parent.compute_txid(), parent_txid); + assert_eq!(child.compute_txid(), child_txid); + assert_eq!(parent.lock_time, bitcoin::absolute::LockTime::ZERO); + assert_eq!(child.lock_time, bitcoin::absolute::LockTime::ZERO); + assert!(parent + .input + .iter() + .chain(child.input.iter()) + .all(|input| input.sequence == bitcoin::Sequence::MAX)); + assert!(child.input.iter().all(|input| input.previous_output.txid == parent.compute_txid())); [parent, child] } +fn dummy_package_txids() -> [Txid; 2] { + [ + PARENT_TXID.parse().expect("read from a constant"), + CHILD_TXID.parse().expect("read from a constant"), + ] +} + pub(crate) enum WalletSyncStatus { Completed, InProgress { subscribers: tokio::sync::broadcast::Sender> }, From a5628e3f668e2bc7063a65d5f86b5a20aabc4f39 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 17 Jul 2026 17:23:05 +0000 Subject: [PATCH 6/8] Expose `ChannelTypeFeatures` in `ChannelDetails` Add a UniFFI wrapper so bindings can inspect channel type flags. Update anchor accounting tests to use channel type features instead of inferring zero-fee commitments from the commitment feerate. Co-Authored-By: HAL 9000 --- CHANGELOG.md | 2 + src/ffi/types.rs | 81 ++++++++++++++++++++++++++++++++- src/types.rs | 10 ++++ tests/common/mod.rs | 13 ++++-- tests/integration_tests_rust.rs | 4 +- 5 files changed, 103 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b231e8d1c..07078cfa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ - `EsploraSyncConfig` and `ElectrumSyncConfig` now support `force_wallet_full_scan`. When set, the on-chain wallet keeps using BDK `full_scan` instead of incremental sync until a full scan succeeds, allowing restored wallets to rediscover funds sent to previously-unknown addresses. +- The `ChannelDetails` returned by `Node::list_channels` now exposes the negotiated + `ChannelTypeFeatures`. - `Config::anchor_channels_config` is no longer optional, hence anchor channels can no longer be disabled. We still negotiate legacy channels if the peer does not support anchor channels. diff --git a/src/ffi/types.rs b/src/ffi/types.rs index c6b48dc96..31c1b3607 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -44,7 +44,10 @@ pub use lightning_liquidity::lsps0::ser::LSPSDateTime; pub use lightning_liquidity::lsps1::msgs::{ LSPS1ChannelInfo, LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentState, }; -use lightning_types::features::{InitFeatures as LdkInitFeatures, NodeFeatures as LdkNodeFeatures}; +use lightning_types::features::{ + ChannelTypeFeatures as LdkChannelTypeFeatures, InitFeatures as LdkInitFeatures, + NodeFeatures as LdkNodeFeatures, +}; pub use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; pub use lightning_types::string::UntrustedString; use vss_client::headers::{ @@ -1817,6 +1820,82 @@ impl From for NodeFeatures { } } +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Object)] +#[uniffi::export(Debug, Eq)] +pub struct ChannelTypeFeatures { + pub(crate) inner: LdkChannelTypeFeatures, +} + +#[uniffi::export] +impl ChannelTypeFeatures { + /// Constructs channel type features from big-endian BOLT 9 encoded bytes. + #[uniffi::constructor] + pub fn from_bytes(bytes: &[u8]) -> Self { + Self { inner: LdkChannelTypeFeatures::from_be_bytes(bytes.to_vec()) } + } + + /// Returns the BOLT 9 big-endian encoded representation of these features. + pub fn to_bytes(&self) -> Vec { + self.inner.encode() + } + + /// Whether this channel type advertises support for `option_static_remotekey`. + pub fn supports_static_remote_key(&self) -> bool { + self.inner.supports_static_remote_key() + } + + /// Whether this channel type requires `option_static_remotekey`. + pub fn requires_static_remote_key(&self) -> bool { + self.inner.requires_static_remote_key() + } + + /// Whether this channel type advertises support for `option_anchors_zero_fee_htlc_tx`. + pub fn supports_anchors_zero_fee_htlc_tx(&self) -> bool { + self.inner.supports_anchors_zero_fee_htlc_tx() + } + + /// Whether this channel type requires `option_anchors_zero_fee_htlc_tx`. + pub fn requires_anchors_zero_fee_htlc_tx(&self) -> bool { + self.inner.requires_anchors_zero_fee_htlc_tx() + } + + /// Whether this channel type advertises support for `option_anchors_nonzero_fee_htlc_tx`. + pub fn supports_anchors_nonzero_fee_htlc_tx(&self) -> bool { + self.inner.supports_anchors_nonzero_fee_htlc_tx() + } + + /// Whether this channel type requires `option_anchors_nonzero_fee_htlc_tx`. + pub fn requires_anchors_nonzero_fee_htlc_tx(&self) -> bool { + self.inner.requires_anchors_nonzero_fee_htlc_tx() + } + + /// Whether this channel type advertises support for `option_taproot`. + pub fn supports_taproot(&self) -> bool { + self.inner.supports_taproot() + } + + /// Whether this channel type requires `option_taproot`. + pub fn requires_taproot(&self) -> bool { + self.inner.requires_taproot() + } + + /// Whether this channel type advertises support for `option_zero_fee_commitments`. + pub fn supports_anchor_zero_fee_commitments(&self) -> bool { + self.inner.supports_anchor_zero_fee_commitments() + } + + /// Whether this channel type requires `option_zero_fee_commitments`. + pub fn requires_anchor_zero_fee_commitments(&self) -> bool { + self.inner.requires_anchor_zero_fee_commitments() + } +} + +impl From for ChannelTypeFeatures { + fn from(features: LdkChannelTypeFeatures) -> Self { + Self { inner: features } + } +} + #[derive(Debug, Clone, PartialEq, Eq, uniffi::Object)] #[uniffi::export(Debug, Eq)] pub struct InitFeatures { diff --git a/src/types.rs b/src/types.rs index 5552877ef..22429d980 100644 --- a/src/types.rs +++ b/src/types.rs @@ -39,6 +39,8 @@ use lightning::util::sweep::OutputSweeper; use lightning_block_sync::gossip::GossipVerifier; use lightning_liquidity::utils::time::DefaultTimeProvider; use lightning_net_tokio::SocketDescriptor; +#[cfg(not(feature = "uniffi"))] +use lightning_types::features::ChannelTypeFeatures; use crate::chain::bitcoind::UtxoSourceClient; use crate::chain::ChainSource; @@ -51,6 +53,8 @@ use crate::message_handler::NodeCustomMessageHandler; use crate::payment::{PaymentDetails, PendingPaymentDetails}; use crate::runtime::RuntimeSpawner; +#[cfg(feature = "uniffi")] +type ChannelTypeFeatures = Arc; #[cfg(not(feature = "uniffi"))] type InitFeatures = lightning::types::features::InitFeatures; #[cfg(feature = "uniffi")] @@ -642,6 +646,11 @@ pub struct ChannelDetails { /// /// See [`ReserveType`] for details on how reserves differ between anchor and legacy channels. pub reserve_type: Option, + /// The negotiated channel type features. + /// + /// Will be `None` until channel negotiation has completed and the channel type has been + /// determined. + pub channel_type: Option, } impl ChannelDetails { @@ -706,6 +715,7 @@ impl ChannelDetails { .expect("value is set for objects serialized with LDK v0.0.109+"), channel_shutdown_state: value.channel_shutdown_state, reserve_type, + channel_type: value.channel_type.map(maybe_wrap), } } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 50e2b993c..04be19629 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1578,12 +1578,15 @@ pub(crate) async fn do_channel_full_cycle( ); if disable_node_b_reserve { - let node_a_outbound_capacity_msat = node_a.list_channels()[0].outbound_capacity_msat; - let node_a_reserve_msat = - node_a.list_channels()[0].unspendable_punishment_reserve.unwrap() * 1000; - let zero_fee_commitments = node_a.list_channels()[0].feerate_sat_per_1000_weight == 0; + let node_a_channel = node_a.list_channels().into_iter().next().unwrap(); + let node_a_outbound_capacity_msat = node_a_channel.outbound_capacity_msat; + let node_a_reserve_msat = node_a_channel.unspendable_punishment_reserve.unwrap() * 1000; + let zero_fee_commitments = node_a_channel + .channel_type + .as_ref() + .map_or(false, |c| c.requires_anchor_zero_fee_commitments()); let node_a_anchors_msat = if zero_fee_commitments { 0 } else { 2 * 330 * 1000 }; - let funding_amount_msat = node_a.list_channels()[0].channel_value_sats * 1000; + let funding_amount_msat = node_a_channel.channel_value_sats * 1000; // Node B does not have any reserve, so we only subtract a few items on node A's // side to arrive at node B's capacity let node_b_capacity_msat = funding_amount_msat diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index ba15f6fa3..fe8c79f75 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1686,7 +1686,9 @@ async fn splice_channel() { let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); let opening_transaction_fee_sat = 156; - let zero_fee_commitments = node_a.list_channels()[0].feerate_sat_per_1000_weight == 0; + let channel = node_a.list_channels().into_iter().next().unwrap(); + let zero_fee_commitments = + channel.channel_type.as_ref().map_or(false, |c| c.requires_anchor_zero_fee_commitments()); let closing_transaction_fee_sat = if zero_fee_commitments { 0 } else { 614 }; let anchor_output_sat = if zero_fee_commitments { 0 } else { 330 }; From fbb18cfdaf86375a1033d88f98b3ee30001c2753 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 17 Jul 2026 17:23:08 +0000 Subject: [PATCH 7/8] Add Eclair interop test for zero-fee-commitments Co-Authored-By: HAL 9000 --- .github/workflows/0fc-integration.yml | 51 ++++++++++++++++++++++++++ tests/common/scenarios/mod.rs | 22 +++++++++-- tests/docker/docker-compose-eclair.yml | 1 + 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/.github/workflows/0fc-integration.yml b/.github/workflows/0fc-integration.yml index 02a25eef5..47e7b4eb8 100644 --- a/.github/workflows/0fc-integration.yml +++ b/.github/workflows/0fc-integration.yml @@ -47,3 +47,54 @@ jobs: - name: Test with 0FC enabled run: | RUSTFLAGS="--cfg no_download --cfg cycle_tests --cfg tokio_unstable --cfg zero_fee_commitment_tests" cargo test -- --test-threads=1 + eclair-interop-test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Start bitcoind and electrs + run: docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml up -d bitcoin electrs + + - name: Wait for bitcoind to be healthy + run: | + for i in $(seq 1 30); do + if docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass getblockchaininfo > /dev/null 2>&1; then + echo "bitcoind is ready" + exit 0 + fi + echo "Waiting for bitcoind... ($i/30)" + sleep 2 + done + echo "ERROR: bitcoind not ready" + exit 1 + + - name: Create wallets on bitcoind + run: | + docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet eclair + docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass -rpcwallet=eclair getnewaddress + docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet ldk_node_test + + - name: Start Eclair with 0FC enabled + env: + ECLAIR_EXTRA_JAVA_OPTS: "-Declair.features.zero_fee_commitments=optional" + run: docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml up -d eclair + + - name: Wait for Eclair to be ready + run: | + for i in $(seq 1 60); do + if curl -sf -u :eclairpassword -X POST http://127.0.0.1:8080/getinfo > /dev/null 2>&1; then + echo "Eclair is ready" + exit 0 + fi + echo "Waiting for Eclair... ($i/60)" + sleep 5 + done + echo "Eclair failed to start" + docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml logs eclair + exit 1 + + - name: Run Eclair 0FC integration tests + run: | + RUSTFLAGS="--cfg eclair_test --cfg zero_fee_commitment_tests" cargo test --test integration_tests_eclair -- --show-output --test-threads=1 diff --git a/tests/common/scenarios/mod.rs b/tests/common/scenarios/mod.rs index ffbfc2b00..23b25e859 100644 --- a/tests/common/scenarios/mod.rs +++ b/tests/common/scenarios/mod.rs @@ -20,6 +20,8 @@ use std::time::Duration; use bitcoin::Amount; use electrsd::corepc_node::Client as BitcoindClient; use electrsd::electrum_client::ElectrumApi; +#[cfg(zero_fee_commitment_tests)] +use ldk_node::ReserveType; use ldk_node::{Event, Node}; use super::external_node::ExternalNode; @@ -87,15 +89,15 @@ pub(crate) async fn wait_for_htlcs_settled( panic!("HTLCs did not settle on {} channel {} within 15s", peer.name(), ext_channel_id); } -/// Build a fresh LDK node configured for interop tests. Uses electrum at the +/// Build a fresh LDK node configured for interop tests. Uses esplora 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(); let mut builder = ldk_node::Builder::from_config(config.node_config); - let mut sync_config = ldk_node::config::ElectrumSyncConfig::default(); + let mut sync_config = ldk_node::config::EsploraSyncConfig::default(); sync_config.timeouts_config.onchain_wallet_sync_timeout_secs = 180; sync_config.timeouts_config.lightning_wallet_sync_timeout_secs = 120; - builder.set_chain_source_electrum("tcp://127.0.0.1:50001".to_string(), Some(sync_config)); + builder.set_chain_source_esplora("http://127.0.0.1:3002".to_string(), Some(sync_config)); let node = builder.build(config.node_entropy).unwrap(); node.start().unwrap(); node @@ -164,6 +166,20 @@ pub(crate) async fn basic_channel_cycle_scenario( ) .await; + #[cfg(zero_fee_commitment_tests)] + { + let ext_node_id = peer.get_node_id().await.unwrap(); + let channel = node + .list_channels() + .into_iter() + .find(|channel| channel.user_channel_id == user_ch) + .expect("opened channel should be listed"); + assert_eq!(channel.counterparty.node_id, ext_node_id); + assert!(channel.counterparty.features.supports_anchor_zero_fee_commitments()); + assert_eq!(channel.feerate_sat_per_1000_weight, 0); + assert_eq!(channel.reserve_type, Some(ReserveType::Adaptive)); + } + payment::send_bolt11_to_peer(node, peer, 10_000_000, "basic-send").await; payment::receive_bolt11_payment(node, peer, 10_000_000).await; diff --git a/tests/docker/docker-compose-eclair.yml b/tests/docker/docker-compose-eclair.yml index 56a5629f1..a33c4309c 100644 --- a/tests/docker/docker-compose-eclair.yml +++ b/tests/docker/docker-compose-eclair.yml @@ -77,4 +77,5 @@ services: -Declair.bitcoind.zmqtx=tcp://127.0.0.1:28333 -Declair.features.keysend=optional -Declair.on-chain-fees.confirmation-priority.funding=slow + ${ECLAIR_EXTRA_JAVA_OPTS:-} -Declair.printToConsole From bda78ccdabd3693761d40c283d6d3da5c0a5c645 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 17 Jul 2026 17:23:11 +0000 Subject: [PATCH 8/8] f: move 0FC eclair interop test to eclair workflow --- .github/workflows/0fc-integration.yml | 51 ------------------------ .github/workflows/eclair-integration.yml | 16 +++++++- tests/common/scenarios/channel.rs | 17 ++++++++ tests/common/scenarios/mod.rs | 16 -------- tests/docker/docker-compose-eclair.yml | 2 +- 5 files changed, 33 insertions(+), 69 deletions(-) diff --git a/.github/workflows/0fc-integration.yml b/.github/workflows/0fc-integration.yml index 47e7b4eb8..02a25eef5 100644 --- a/.github/workflows/0fc-integration.yml +++ b/.github/workflows/0fc-integration.yml @@ -47,54 +47,3 @@ jobs: - name: Test with 0FC enabled run: | RUSTFLAGS="--cfg no_download --cfg cycle_tests --cfg tokio_unstable --cfg zero_fee_commitment_tests" cargo test -- --test-threads=1 - eclair-interop-test: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Start bitcoind and electrs - run: docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml up -d bitcoin electrs - - - name: Wait for bitcoind to be healthy - run: | - for i in $(seq 1 30); do - if docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass getblockchaininfo > /dev/null 2>&1; then - echo "bitcoind is ready" - exit 0 - fi - echo "Waiting for bitcoind... ($i/30)" - sleep 2 - done - echo "ERROR: bitcoind not ready" - exit 1 - - - name: Create wallets on bitcoind - run: | - docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet eclair - docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass -rpcwallet=eclair getnewaddress - docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet ldk_node_test - - - name: Start Eclair with 0FC enabled - env: - ECLAIR_EXTRA_JAVA_OPTS: "-Declair.features.zero_fee_commitments=optional" - run: docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml up -d eclair - - - name: Wait for Eclair to be ready - run: | - for i in $(seq 1 60); do - if curl -sf -u :eclairpassword -X POST http://127.0.0.1:8080/getinfo > /dev/null 2>&1; then - echo "Eclair is ready" - exit 0 - fi - echo "Waiting for Eclair... ($i/60)" - sleep 5 - done - echo "Eclair failed to start" - docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml logs eclair - exit 1 - - - name: Run Eclair 0FC integration tests - run: | - RUSTFLAGS="--cfg eclair_test --cfg zero_fee_commitment_tests" cargo test --test integration_tests_eclair -- --show-output --test-threads=1 diff --git a/.github/workflows/eclair-integration.yml b/.github/workflows/eclair-integration.yml index daa4572cc..f00d8943a 100644 --- a/.github/workflows/eclair-integration.yml +++ b/.github/workflows/eclair-integration.yml @@ -8,8 +8,19 @@ concurrency: jobs: check-eclair: + name: check-eclair (${{ matrix.name }}) timeout-minutes: 60 runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: standard + eclair_extra_java_opts: "" + rustflags: "--cfg eclair_test" + - name: zero-fee-commitments + eclair_extra_java_opts: "-Declair.features.zero_fee_commitments=optional" + rustflags: "--cfg eclair_test --cfg zero_fee_commitment_tests" steps: - name: Checkout repository uses: actions/checkout@v4 @@ -37,6 +48,8 @@ jobs: docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet ldk_node_test - name: Start Eclair + env: + ECLAIR_EXTRA_JAVA_OPTS: ${{ matrix.eclair_extra_java_opts }} run: docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml up -d eclair - name: Wait for Eclair to be ready @@ -54,4 +67,5 @@ jobs: exit 1 - name: Run Eclair integration tests - run: RUSTFLAGS="--cfg eclair_test" cargo test --test integration_tests_eclair -- --show-output --test-threads=1 + run: | + RUSTFLAGS="${{ matrix.rustflags }}" cargo test --test integration_tests_eclair -- --show-output --test-threads=1 diff --git a/tests/common/scenarios/channel.rs b/tests/common/scenarios/channel.rs index 74e04127c..038a016ad 100644 --- a/tests/common/scenarios/channel.rs +++ b/tests/common/scenarios/channel.rs @@ -9,6 +9,8 @@ use std::time::Duration; use electrsd::corepc_node::Client as BitcoindClient; use electrsd::electrum_client::ElectrumApi; +#[cfg(all(eclair_test, zero_fee_commitment_tests))] +use ldk_node::ReserveType; use ldk_node::{Event, Node}; use super::super::external_node::ExternalNode; @@ -41,6 +43,21 @@ pub(crate) async fn open_channel_to_external( .map(|ch| ch.channel_id.clone()) .unwrap_or_else(|| panic!("Could not find channel on external node {}", peer.name())); + #[cfg(all(eclair_test, zero_fee_commitment_tests))] + { + let channel = node + .list_channels() + .into_iter() + .find(|channel| channel.user_channel_id == user_channel_id) + .expect("opened channel should be listed"); + let channel_type = channel.channel_type.as_ref().expect("channel type should be set"); + assert_eq!(channel.counterparty.node_id, ext_node_id); + assert!(channel.counterparty.features.supports_anchor_zero_fee_commitments()); + assert!(channel_type.requires_anchor_zero_fee_commitments()); + assert_eq!(channel.feerate_sat_per_1000_weight, 0); + assert_eq!(channel.reserve_type, Some(ReserveType::Adaptive)); + } + (user_channel_id, ext_channel_id) } diff --git a/tests/common/scenarios/mod.rs b/tests/common/scenarios/mod.rs index 23b25e859..5b73b6511 100644 --- a/tests/common/scenarios/mod.rs +++ b/tests/common/scenarios/mod.rs @@ -20,8 +20,6 @@ use std::time::Duration; use bitcoin::Amount; use electrsd::corepc_node::Client as BitcoindClient; use electrsd::electrum_client::ElectrumApi; -#[cfg(zero_fee_commitment_tests)] -use ldk_node::ReserveType; use ldk_node::{Event, Node}; use super::external_node::ExternalNode; @@ -166,20 +164,6 @@ pub(crate) async fn basic_channel_cycle_scenario( ) .await; - #[cfg(zero_fee_commitment_tests)] - { - let ext_node_id = peer.get_node_id().await.unwrap(); - let channel = node - .list_channels() - .into_iter() - .find(|channel| channel.user_channel_id == user_ch) - .expect("opened channel should be listed"); - assert_eq!(channel.counterparty.node_id, ext_node_id); - assert!(channel.counterparty.features.supports_anchor_zero_fee_commitments()); - assert_eq!(channel.feerate_sat_per_1000_weight, 0); - assert_eq!(channel.reserve_type, Some(ReserveType::Adaptive)); - } - payment::send_bolt11_to_peer(node, peer, 10_000_000, "basic-send").await; payment::receive_bolt11_payment(node, peer, 10_000_000).await; diff --git a/tests/docker/docker-compose-eclair.yml b/tests/docker/docker-compose-eclair.yml index a33c4309c..e305b0b42 100644 --- a/tests/docker/docker-compose-eclair.yml +++ b/tests/docker/docker-compose-eclair.yml @@ -5,7 +5,7 @@ services: # causing Eclair to fall behind the chain tip. Host networking avoids # this by keeping all inter-process communication on localhost. bitcoin: - image: blockstream/bitcoind:30.2 + image: blockstream/bitcoind:31.0 platform: linux/amd64 network_mode: host command: